diff --git a/.dockerignore b/.dockerignore index 9c6459c7..3cb82b19 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,18 @@ -/** -!cmd/ -!pkg/ -!scripts/ -!web/ -!go.mod -!go.sum -!LICENSE -!Makefile +.git/ +.github/ +.idea/ +.vscode/ +.gitignore + +.editorconfig +.env +docker-compose.yml +Dockerfile + +LICENSE +README.md +bin/ +docs/ +release/ + +assets/games/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 40fed385..f3ef9a0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ # ------------------------------------------------------------ -# Build and test workflow (Linux x64, macOS x64, Windows x64) +# Build workflow (Linux x64, macOS x64, Windows x64) # ------------------------------------------------------------ name: build @@ -16,73 +16,101 @@ on: jobs: build: + name: Build strategy: matrix: - os: [ ubuntu-latest, windows-latest ] + os: [ ubuntu-latest, macos-latest, windows-latest ] + step: [ build, check ] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v2 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v2 with: - go-version: 'stable' + go-version: ^1.20 - - name: Linux + - name: Get Linux dev libraries and tools if: matrix.os == 'ubuntu-latest' - env: - MESA_GL_VERSION_OVERRIDE: 3.3COMPAT run: | sudo apt-get -qq update - sudo apt-get -qq install -y \ - make pkg-config \ - libvpx-dev libx264-dev libopus-dev libyuv-dev libjpeg-turbo8-dev \ - libsdl2-dev libgl1 libglx-mesa0 libspeexdsp-dev - - make build - xvfb-run --auto-servernum make test verify-cores + sudo apt-get -qq install -y make pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libgl1-mesa-glx - - name: macOS - if: matrix.os == 'macos-12' + - name: Get MacOS dev libraries and tools + if: matrix.os == 'macos-latest' run: | - brew install libvpx x264 sdl2 speexdsp - make build test verify-cores + brew install pkg-config libvpx x264 opus sdl2 - - uses: msys2/setup-msys2@v2 + - name: Get Windows dev libraries and tools if: matrix.os == 'windows-latest' + uses: msys2/setup-msys2@v2 with: - msystem: ucrt64 + msystem: MINGW64 path-type: inherit release: false install: > - mingw-w64-ucrt-x86_64-gcc - mingw-w64-ucrt-x86_64-pkgconf - mingw-w64-ucrt-x86_64-dlfcn - mingw-w64-ucrt-x86_64-libvpx - mingw-w64-ucrt-x86_64-opus - mingw-w64-ucrt-x86_64-libx264 - mingw-w64-ucrt-x86_64-SDL2 - mingw-w64-ucrt-x86_64-libyuv - mingw-w64-ucrt-x86_64-libjpeg-turbo - mingw-w64-ucrt-x86_64-speexdsp + mingw-w64-x86_64-gcc + mingw-w64-x86_64-pkgconf + mingw-w64-x86_64-dlfcn + mingw-w64-x86_64-libvpx + mingw-w64-x86_64-opus + mingw-w64-x86_64-x264-git + mingw-w64-x86_64-SDL2 - - name: Windows - if: matrix.os == 'windows-latest' - env: - MESA_VERSION: '24.0.7' - MESA_GL_VERSION_OVERRIDE: 3.3COMPAT + - name: Get Windows OpenGL drivers + if: matrix.step == 'check' && matrix.os == 'windows-latest' shell: msys2 {0} run: | - set MSYSTEM=UCRT64 - - wget -q https://github.com/pal1000/mesa-dist-win/releases/download/$MESA_VERSION/mesa3d-$MESA_VERSION-release-msvc.7z - "/c/Program Files/7-Zip/7z.exe" x mesa3d-$MESA_VERSION-release-msvc.7z -omesa - echo -e " 1\r\n 9\r\n " >> commands + wget -q https://github.com/pal1000/mesa-dist-win/releases/download/20.2.1/mesa3d-20.2.1-release-mingw.7z + "/c/Program Files/7-Zip/7z.exe" x mesa3d-20.2.1-release-mingw.7z -omesa + echo -e " 2\r\n 8\r\n " >> commands ./mesa/systemwidedeploy.cmd < ./commands - - make build test verify-cores - - uses: actions/upload-artifact@v4 - if: always() + - name: Build Windows app + if: matrix.step == 'build' && matrix.os == 'windows-latest' + shell: msys2 {0} + run: | + make build + + - name: Build Linux app + if: matrix.step == 'build' && matrix.os == 'ubuntu-latest' + run: | + make build + + - name: Build macOS app + if: matrix.step == 'build' && matrix.os == 'macos-latest' + run: | + make build + + - name: Verify core rendering (windows-latest) + if: matrix.step == 'check' && matrix.os == 'windows-latest' && always() + shell: msys2 {0} + env: + MESA_GL_VERSION_OVERRIDE: 3.3COMPAT + run: | + GL_CTX=-autoGlContext make verify-cores + + - name: Verify core rendering (ubuntu-latest) + if: matrix.step == 'check' && matrix.os == 'ubuntu-latest' && always() + env: + MESA_GL_VERSION_OVERRIDE: 3.3COMPAT + run: | + GL_CTX=-autoGlContext xvfb-run --auto-servernum make verify-cores + + - name: Verify core rendering (macos-latest) + if: matrix.step == 'check' && matrix.os == 'macos-latest' && always() + run: | + make verify-cores + + - uses: actions/upload-artifact@v2 + if: matrix.step == 'check' && always() with: - name: emulator-test-frames-${{ matrix.os }} + name: emulator-test-frames path: _rendered/*.png + + build_docker: + name: Build (docker) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v2 + - run: docker build --build-arg VERSION=$(./scripts/version.sh) . diff --git a/.github/workflows/cd/cloudretro.io/config.yaml b/.github/workflows/cd/cloudretro.io/config.yaml deleted file mode 100644 index ac29d636..00000000 --- a/.github/workflows/cd/cloudretro.io/config.yaml +++ /dev/null @@ -1,40 +0,0 @@ -version: 4 - -coordinator: - debug: true - server: - address: - frameOptions: SAMEORIGIN - https: true - tls: - domain: cloudretro.io - analytics: - inject: true - gtag: UA-145078282-1 - -worker: - debug: true - network: - coordinatorAddress: cloudretro.io - publicAddress: cloudretro.io - secure: true - server: - https: true - tls: - address: :444 -# domain: cloudretro.io - -emulator: - libretro: - logLevel: 1 - cores: - list: - dos: - uniqueSaveDir: true - mame: - options: - "fbneo-diagnostic-input": "Hold Start" - nes: - scale: 2 - snes: - scale: 2 diff --git a/.github/workflows/cd/cloudretro.io/coordinator.env b/.github/workflows/cd/cloudretro.io/coordinator.env new file mode 100644 index 00000000..bfee32c2 --- /dev/null +++ b/.github/workflows/cd/cloudretro.io/coordinator.env @@ -0,0 +1,5 @@ +CLOUD_GAME_COORDINATOR_ANALYTICS_GTAG=UA-145078282-1 +CLOUD_GAME_COORDINATOR_ANALYTICS_INJECT=true +CLOUD_GAME_COORDINATOR_SERVER_ADDRESS= +CLOUD_GAME_COORDINATOR_SERVER_HTTPS=true +CLOUD_GAME_COORDINATOR_SERVER_TLS_DOMAIN=cloudretro.io diff --git a/.github/workflows/cd/cloudretro.io/script.env b/.github/workflows/cd/cloudretro.io/script.env index c806ac50..e38ca5c0 100644 --- a/.github/workflows/cd/cloudretro.io/script.env +++ b/.github/workflows/cd/cloudretro.io/script.env @@ -1,6 +1,6 @@ -COORDINATORS="138.68.48.200" -DOCKER_IMAGE_TAG=master -#DO_ADDRESS_LIST="cloud-gaming cloud-gaming-eu cloud-gaming-usw" -#SPLIT_HOSTS=1 +COORDINATORS="167.172.70.98 cloudretro.io" +DOCKER_IMAGE_TAG=dev +DO_ADDRESS_LIST="cloud-gaming cloud-gaming-eu cloud-gaming-usw" +SPLIT_HOSTS=1 USER=root -WORKERS=${WORKERS:-4} +WORKERS=${WORKERS:-5} diff --git a/.github/workflows/cd/cloudretro.io/worker.env b/.github/workflows/cd/cloudretro.io/worker.env new file mode 100644 index 00000000..99c1bf07 --- /dev/null +++ b/.github/workflows/cd/cloudretro.io/worker.env @@ -0,0 +1,9 @@ +CLOUD_GAME_STORAGE_KEY=https://objectstorage.eu-frankfurt-1.oraclecloud.com/p/VVlJPTNcP28wlnBXtA0ezjD5fTut2T01qz5TVwdNejQc6OR1DF5VYYjKTTP2HIlL/n/frmb1qagq0wf/b/cloud-retro-st-001/o/ +CLOUD_GAME_STORAGE_PROVIDER=oracle +CLOUD_GAME_WORKER_NETWORK_COORDINATORADDRESS=cloudretro.io +CLOUD_GAME_WORKER_NETWORK_PUBLICADDRESS=cloudretro.io +CLOUD_GAME_WORKER_NETWORK_SECURE=true +CLOUD_GAME_WORKER_SERVER_ADDRESS=:80 +CLOUD_GAME_WORKER_SERVER_HTTPS=true +CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:443 +CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io diff --git a/.github/workflows/cd/deploy-app.sh b/.github/workflows/cd/deploy-app.sh index cc7b3c85..c1e19e1c 100755 --- a/.github/workflows/cd/deploy-app.sh +++ b/.github/workflows/cd/deploy-app.sh @@ -54,10 +54,6 @@ IP_LIST=${IP_LIST:-} # a list of machines mark some addresses to deploy only a coordinator there COORDINATORS=${COORDINATORS:-} -if [ -z "$SPLIT_HOSTS" ]; then - IP_LIST+=$COORDINATORS -fi - # Digital Ocean operations #DO_TOKEN DO_ADDRESS_LIST=${DO_ADDRESS_LIST:-} @@ -68,7 +64,7 @@ REMOTE_WORK_DIR=${REMOTE_WORK_DIR:-"/cloud-game"} DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG:-latest} echo "Docker tag:$DOCKER_IMAGE_TAG" # the total number of worker replicas to deploy -WORKERS=${WORKERS:-4} +WORKERS=${WORKERS:-5} USER=${USER:-root} compose_src=$(cat $LOCAL_WORK_DIR/docker-compose.yml) @@ -128,7 +124,7 @@ echo "IPs:" $IP_LIST # Run command builder # -# By default it will run docker compose with both coordinator and worker apps. +# By default it will run docker-compose with both coordinator and worker apps. # With the SPLIT_HOSTS parameter specified, it will run either coordinator app # if the current server address is found in the IP_LIST variable, otherwise it # will run just the worker app. @@ -146,39 +142,28 @@ for ip in $IP_LIST; do fi # build run command - cmd="ZONE=\$zone docker compose up -d --remove-orphans" + cmd="ZONE=\$zone docker-compose up -d --remove-orphans --scale worker=\${workers:-$WORKERS}" if [ ! -z "$SPLIT_HOSTS" ]; then cmd+=" worker" deploy_coordinator=0 deploy_worker=1 - else - cmd+=" worker" fi # override run command if [ ! -z "$SPLIT_HOSTS" ]; then for addr in $COORDINATORS; do if [ "$ip" == $addr ]; then - cmd="docker compose up -d --remove-orphans coordinator" + cmd="docker-compose up -d --remove-orphans coordinator" deploy_coordinator=1 deploy_worker=0 break fi done - else - cmd+=" coordinator" fi # build Docker container env file run_env="" - custom_config="" if [[ ! -z "${ENV_DIR}" ]]; then - env_f=$ENV_DIR/config.yaml - if [[ -e "$env_f" ]]; then - echo "config.yaml found" - custom_config=$(cat $env_f) - fi - if [ $deploy_coordinator == 1 ]; then env_f=$ENV_DIR/coordinator.env if [[ -e "$env_f" ]]; then @@ -205,13 +190,13 @@ for ip in $IP_LIST; do run="#!/bin/bash"$'\n' run+=$(remote_run_commands "$ENV_DIR")$'\n' run+=$(remote_run_commands "$PROVIDER_DIR")$'\n' - run+="IMAGE_TAG=$DOCKER_IMAGE_TAG APP_DIR=$REMOTE_WORK_DIR WORKER_REPLICAS=$WORKERS $cmd" + run+="IMAGE_TAG=$DOCKER_IMAGE_TAG APP_DIR=$REMOTE_WORK_DIR $cmd" echo "" echo "run.sh:"$'\n'"$run" echo "" - # !to add docker compose install / warning + # !to add docker-compose install / warning # custom scripts remote_sudo_run_once $ip "$PROVIDER_DIR" "$ssh_i" @@ -220,13 +205,14 @@ for ip in $IP_LIST; do echo "Update the remote host" ssh -o ConnectTimeout=10 $USER@$ip ${ssh_i:-} "\ - docker compose version; \ + docker-compose -v; \ mkdir -p $REMOTE_WORK_DIR; \ cd $REMOTE_WORK_DIR; \ - mkdir -p $REMOTE_WORK_DIR/home; \ - echo \"$custom_config\" > $REMOTE_WORK_DIR/home/config.yaml; \ echo '$compose_src' > ./docker-compose.yml; \ - docker compose down; \ - IMAGE_TAG=$DOCKER_IMAGE_TAG docker compose pull; \ - docker compose up -d;" + echo '$run_env' > ./run.env; \ + docker image prune -f -a; \ + IMAGE_TAG=$DOCKER_IMAGE_TAG docker-compose pull; \ + echo '$run' > ./run.sh; \ + chmod +x ./run.sh; \ + ./run.sh" done diff --git a/.github/workflows/cd/docker-compose.yml b/.github/workflows/cd/docker-compose.yml index 02d94786..2a4e31b1 100644 --- a/.github/workflows/cd/docker-compose.yml +++ b/.github/workflows/cd/docker-compose.yml @@ -1,93 +1,36 @@ -x-params: &default-params - image: ghcr.io/giongto35/cloud-game/cloud-game:${IMAGE_TAG:-master} +version: "3.4" + +x-params: + &default-params + env_file: run.env + image: ghcr.io/giongto35/cloud-game/cloud-game:${IMAGE_TAG:-latest} network_mode: "host" privileged: true restart: always - security_opt: - - seccomp=unconfined logging: - driver: "journald" -x-worker: &worker - depends_on: - - coordinator - command: ./worker - volumes: - - ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache - - ${APP_DIR:-/cloud-game}/cores:/usr/local/share/cloud-game/assets/cores - - ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games - - ${APP_DIR:-/cloud-game}/libretro:/usr/local/share/cloud-game/libretro - - ${APP_DIR:-/cloud-game}/home:/root/.cr - - x11:/tmp/.X11-unix - healthcheck: - test: curl -f https://cloudretro.io/echo || exit 1 - interval: 1m - timeout: 10s - retries: 3 - start_period: 40s - start_interval: 5s + driver: "json-file" + options: + max-size: "64m" + max-file: "5" + compress: "true" services: coordinator: <<: *default-params - command: ./coordinator - environment: - - CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games + command: coordinator volumes: - ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache - ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games - - ${APP_DIR:-/cloud-game}/home:/root/.cr - worker01: - <<: [ *default-params, *worker ] + worker: + <<: *default-params environment: - - DISPLAY=:99 - - MESA_GL_VERSION_OVERRIDE=4.5 - - CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games - - CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores - - CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io - - CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:444 - healthcheck: - test: curl -f https://cloudretro.io:444/echo || exit 1 - worker02: - <<: [ *default-params, *worker ] - environment: - - CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:445 - - DISPLAY=:99 - - MESA_GL_VERSION_OVERRIDE=4.5 - - CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games - - CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores - - CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io - healthcheck: - test: curl -f https://cloudretro.io:445/echo || exit 1 - worker03: - <<: [ *default-params, *worker ] - environment: - - DISPLAY=:99 - - MESA_GL_VERSION_OVERRIDE=4.5 - - CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games - - CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores - - CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io - - CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:446 - healthcheck: - test: curl -f https://cloudretro.io:446/echo || exit 1 - worker04: - <<: [ *default-params, *worker ] - environment: - - DISPLAY=:99 - - MESA_GL_VERSION_OVERRIDE=4.5 - - CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games - - CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores - - CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io - - CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:447 - healthcheck: - test: curl -f https://cloudretro.io:447/echo || exit 1 - - xvfb: - image: kcollins/xvfb:latest + - MESA_GL_VERSION_OVERRIDE=3.3 + entrypoint: [ "/bin/sh", "-c", "xvfb-run -a $$@", "" ] + command: worker --zone=${ZONE:-} volumes: - - x11:/tmp/.X11-unix - command: [ ":99", "-screen", "0", "320x240x16" ] - -volumes: - x11: + - ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache + - ${APP_DIR:-/cloud-game}/cores:/usr/local/share/cloud-game/assets/cores + - ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games + - ${APP_DIR:-/cloud-game}/home:/root/.cr diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dd296ead..c58fc810 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,7 +16,7 @@ jobs: key: ${{ secrets.SSH_PRIVATE_KEY }} known_hosts: 'PLACEHOLDER' - - uses: actions/checkout@v3 + - uses: actions/checkout@v2 - name: Deploy to all servers env: diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml deleted file mode 100644 index 65bc5856..00000000 --- a/.github/workflows/docker_build.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: docker_build -on: - pull_request: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: DOCKER_BUILDKIT=1 docker build --build-arg VERSION=$(./scripts/version.sh) . diff --git a/.github/workflows/docker_publish.yml b/.github/workflows/docker_publish.yml deleted file mode 100644 index 7ba231eb..00000000 --- a/.github/workflows/docker_publish.yml +++ /dev/null @@ -1,48 +0,0 @@ -# ---------------------------------------------------------------------------------- -# Publish Docker image from the current master branch or v* into Github repository -# ---------------------------------------------------------------------------------- - -name: docker-publish - -on: - push: - branches: - - master - tags: - - 'v*' - -jobs: - docker-publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - run: echo "V=$(./scripts/version.sh)" >> $GITHUB_ENV - - name: Docker meta - id: meta - uses: docker/metadata-action@v4 - with: - images: ghcr.io/${{ github.repository }}/cloud-game - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - - uses: docker/setup-buildx-action@v2 - - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v4 - with: - build-args: VERSION=${{ env.V }} - context: . - push: true - provenance: false - sbom: false - tags: | - ${{ steps.meta.outputs.tags }} - labels: | - ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/docker_publish_stable.yml b/.github/workflows/docker_publish_stable.yml new file mode 100644 index 00000000..ea1b1c43 --- /dev/null +++ b/.github/workflows/docker_publish_stable.yml @@ -0,0 +1,31 @@ +# ------------------------------------------------------------------------ +# Publish Docker image from the stable snapshot into Github repository +# ------------------------------------------------------------------------ + +name: publish-stable + +on: + push: + tags: + - 'v*' + +jobs: + docker-publish-stable: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV + + - run: echo "V=$(./scripts/version.sh)" >> $GITHUB_ENV + + - uses: docker/build-push-action@v1 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + add_git_labels: true + tags: latest,${{ env.TAG }} + build_args: VERSION=${{ env.V }} + + registry: docker.pkg.github.com + repository: ${{ github.REPOSITORY }}/cloud-game diff --git a/.github/workflows/docker_publish_unstable.yml b/.github/workflows/docker_publish_unstable.yml new file mode 100644 index 00000000..c5bdba5d --- /dev/null +++ b/.github/workflows/docker_publish_unstable.yml @@ -0,0 +1,29 @@ +# ---------------------------------------------------------------------------- +# Publish Docker image from the current master branch into Github repository +# ---------------------------------------------------------------------------- + +name: publish-unstable + +on: + push: + branches: + - master + +jobs: + docker-publish-unstable: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - run: echo "V=$(./scripts/version.sh)" >> $GITHUB_ENV + + - uses: docker/build-push-action@v1 + with: + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + add_git_labels: true + tags: dev + build_args: VERSION=${{ env.V }} + + registry: docker.pkg.github.com + repository: ${{ github.REPOSITORY }}/cloud-game diff --git a/.github/workflows/release.yml_ b/.github/workflows/release.yml_ index 4f7d1d27..701e2618 100644 --- a/.github/workflows/release.yml_ +++ b/.github/workflows/release.yml_ @@ -37,9 +37,9 @@ jobs: env: release-dir: _release steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v2 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v2 with: go-version: ^1.20 diff --git a/DESIGNv2.md b/DESIGNv2.md deleted file mode 100644 index 838677f5..00000000 --- a/DESIGNv2.md +++ /dev/null @@ -1,79 +0,0 @@ -# Cloud Gaming Service Design Document - -Cloud Gaming Service contains multiple workers for gaming streams and a coordinator for distributing traffic and pairing -up connections. - -## Coordinator - -Coordinator is a web-frontend, load balancer and signalling server for WebRTC. - -``` - WORKERS - ┌──────────────────────────────────┐ - │ │ - │ REGION 1 REGION 2 REGION N │ - │ (US) (DE) (XX) │ - │ ┌──────┐ ┌──────┐ ┌──────┐ | - COORDINATOR │ │WORKER│ │WORKER│ │WORKER│ | -┌───────────┐ │ └──────┘ └──────┘ └──────┘ | -│ │ ───────────────────────HEALTH────────────────────► │ • • • | -│ HTTP/WS │ ◄─────────────────────REG/DEREG─────────────────── │ • • • | -│┌─────────┐│ │ • • • | -│| |│ USER │ ┌──────┐* ┌──────┐ ┌──────┐ | -│└─────────┘│ ┌──────┐ │ │WORKER│ │WORKER│ │WORKER│ | -│ │ ◄──(1)CONNECT───────── │ │ ────(3)SELECT────► │ └──────┘ └──────┘ └──────┘ | -│ │ ───(2)LIST WORKERS───► │ │ ◄───(4)STREAM───── │ │ -└───────────┘ └──────┘ │ * MULTIPLAYER │ - │ ┌──────┐────► ONE GAME │ - │ ┌───►│WORKER│◄──┐ │ - │ │ └──────┘ │ │ - │ │ ▲ ▲ │ │ - │ ┌┴─┐ │ │ ┌┴─┐ | - │ │U1│ ┌─┴┐ ┌┴─┐ │U4│ | - │ └──┘ │U2│ │U3│ └──┘ | - │ └──┘ └──┘ | - │ | - └──────────────────────────────────┘ -``` - -- (1) A user opens the main page of the app in the browser, i.e. connects to the coordinator. -- (2) The coordinator searches and serves a list of most suitable workers to the user. -- (3) The user proceeds with latency check of each worker from the list, then coordinator collects user-to-worker - latency data and picks the best candidate. -- (4) The coordinator sets up peer-to-peer connection between a worker and the user based on the WebRTC protocol and a - game hosted on the worker is streamed to the user. - -## Worker - -Worker is responsible for running and streaming games to users. - -``` - WORKER -┌─────────────────────────────────────────────────────────────────┐ -│ EMULATOR WEBRTC │ BROWSER -│ ┌─────────────────┐ ENCODER ┌────────┐ │ ┌──────────┐ -│ │ │ ┌─────────┐ | DMUX | | ───RTP──► | WEBRTC | -│ │ AUDIO SAMPLES │ ──PCM──► │ │ ──OPUS──► │ ┌──► │ │ ◄──SCTP── | | -│ │ VIDEO FRAMES │ ──RGB──► │ │ ──H264──► │ └──► | | └──────────┘ COORDINATOR -│ │ │ └─────────┘ │ │ │ • ┌─────────────┐ -│ │ │ | MUX | | ───TCP──────── • ───────► | WEBSOCKET | -│ │ │ │ ┌── │ │ • └─────────────┘ -| | | BINARY | ▼ | | BROWSER -│ │ INPUT STATE │ ◄───────────────────────────── │ • │ │ ┌──────────┐ -│ │ │ │ ▲ │ │ ───RTP──► | WEBRTC | -│ └─────────────────┘ HTTP/WS | └── | │ ◄──SCTP── │ │ -│ ┌─────────┐ └────────┘ │ └──────────┘ -| | | | -| └─────────┘ | -└─────────────────────────────────────────────────────────────────┘ -``` - -- After coordinator matches the most appropriate server (peer 1) to the user (peer 2), a WebRTC peer-to-peer handshake - will be conducted. The coordinator will help initiate the session between the two peers over a WebSocket connection. -- The worker either spawns new rooms running game emulators or connects users to existing rooms. -- Raw image and audio streams from the emulator are captured and encoded to a WebRTC-supported streaming format. Next, - these stream are piped out (dmux) to all users in the room. -- On the other hand, input from players is sent to the worker over WebRTC DataChannel. The game logic on the emulator - will be updated based on the input stream of all players, for that each stream is multiplexed (mux) into one. -- Game states (saves) are stored in cloud storage, so all distributed workers can keep game states in sync and players - can continue their games where they left off. diff --git a/Dockerfile b/Dockerfile index 1cb760b2..48615e41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,101 +1,61 @@ -ARG BUILD_PATH=/tmp/cloud-game -ARG VERSION=master +# The base cloud-game image +ARG BUILD_PATH=/go/src/github.com/giongto35/cloud-game -# base build stage -FROM ubuntu:plucky AS build0 -ARG GO=1.26rc1 -ARG GO_DIST=go${GO}.linux-amd64.tar.gz +# build image +FROM debian:bullseye-slim AS build +ARG BUILD_PATH +WORKDIR ${BUILD_PATH} -ADD https://go.dev/dl/$GO_DIST ./ -RUN tar -C /usr/local -xzf $GO_DIST && \ - rm $GO_DIST -ENV PATH="${PATH}:/usr/local/go/bin" - -RUN apt-get -q update && apt-get -q install --no-install-recommends -y \ +# system libs layer +RUN apt-get -qq update && apt-get -qq install --no-install-recommends -y \ + gcc \ ca-certificates \ - make \ - upx \ -&& rm -rf /var/lib/apt/lists/* - -# next conditional build stage -FROM build0 AS build_coordinator -ARG BUILD_PATH -ARG VERSION -ENV GIT_VERSION=${VERSION} - -WORKDIR ${BUILD_PATH} - -# by default we ignore all except some folders and files, see .dockerignore -COPY . ./ -RUN --mount=type=cache,target=/root/.cache/go-build make build.coordinator -RUN find ./bin/* | xargs upx --best --lzma - -WORKDIR /usr/local/share/cloud-game -RUN mv ${BUILD_PATH}/bin/* ./ && \ - mv ${BUILD_PATH}/web ./web && \ - mv ${BUILD_PATH}/LICENSE ./ -RUN ${BUILD_PATH}/scripts/version.sh ./web/index.html ${VERSION} && \ - ${BUILD_PATH}/scripts/mkdirs.sh - -# next worker build stage -FROM build0 AS build_worker -ARG BUILD_PATH -ARG VERSION -ENV GIT_VERSION=${VERSION} - -WORKDIR ${BUILD_PATH} - -# install deps -RUN apt-get -q update && apt-get -q install --no-install-recommends -y \ - build-essential \ libopus-dev \ libsdl2-dev \ libvpx-dev \ - libyuv-dev \ - libjpeg-turbo8-dev \ libx264-dev \ - libspeexdsp-dev \ + make \ pkg-config \ -&& rm -rf /var/lib/apt/lists/* + wget \ + && rm -rf /var/lib/apt/lists/* -# by default we ignore all except some folders and files, see .dockerignore -COPY . ./ -RUN --mount=type=cache,target=/root/.cache/go-build make GO_TAGS=static,st build.worker -RUN find ./bin/* | xargs upx --best --lzma +# go setup layer +ARG GO=go1.20.linux-amd64.tar.gz +RUN wget -q https://golang.org/dl/$GO \ + && rm -rf /usr/local/go \ + && tar -C /usr/local -xzf $GO \ + && rm $GO +ENV PATH="${PATH}:/usr/local/go/bin" -WORKDIR /usr/local/share/cloud-game -RUN mv ${BUILD_PATH}/bin/* ./ && \ - mv ${BUILD_PATH}/LICENSE ./ -RUN ${BUILD_PATH}/scripts/mkdirs.sh worker +# go deps layer +COPY go.mod go.sum ./ +RUN go mod download -FROM scratch AS coordinator - -COPY --from=build_coordinator /usr/local/share/cloud-game /cloud-game -# autocertbot (SSL) requires these on the first run -COPY --from=build_coordinator /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - -FROM ubuntu:plucky AS worker - -RUN apt-get -q update && apt-get -q install --no-install-recommends -y \ - curl \ - libx11-6 \ - libxext6 \ - && apt-get autoremove \ - && rm -rf /var/lib/apt/lists/* /var/log/* /usr/share/bug /usr/share/doc /usr/share/doc-base \ - /usr/share/X11/locale/* - -COPY --from=build_worker /usr/local/share/cloud-game /cloud-game -COPY --from=build_worker /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - -ADD https://github.com/sergystepanov/mesa-llvmpipe/releases/download/v1.0.0/libGL.so.1.5.0 \ - /usr/lib/x86_64-linux-gnu/ -RUN cd /usr/lib/x86_64-linux-gnu && \ - ln -s libGL.so.1.5.0 libGL.so.1 && \ - ln -s libGL.so.1 libGL.so - -FROM worker AS cloud-game +# app build layer +COPY pkg ./pkg +COPY cmd ./cmd +COPY Makefile . +COPY scripts/version.sh scripts/version.sh +ARG VERSION +RUN GIT_VERSION=${VERSION} make build +# base image +FROM debian:bullseye-slim +ARG BUILD_PATH WORKDIR /usr/local/share/cloud-game -COPY --from=coordinator /cloud-game ./ -COPY --from=worker /cloud-game ./ +COPY scripts/install.sh install.sh +RUN bash install.sh && \ + rm -rf /var/lib/apt/lists/* install.sh + +COPY --from=build ${BUILD_PATH}/bin/ ./ +RUN cp -s $(pwd)/* /usr/local/bin +COPY assets/cores ./assets/cores +COPY configs ./configs +COPY web ./web +ARG VERSION +COPY scripts/version.sh version.sh +RUN bash ./version.sh ./web/index.html ${VERSION} && \ + rm -rf version.sh + +EXPOSE 8000 9000 diff --git a/Makefile b/Makefile index 1fbe81de..af45bf55 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,8 @@ PROJECT = cloud-game REPO_ROOT = github.com/giongto35 ROOT = ${REPO_ROOT}/${PROJECT} -CGO_CFLAGS='-g -O3' +CGO_CFLAGS='-g -O3 -funroll-loops' CGO_LDFLAGS='-g -O3' -GO_TAGS= - -.PHONY: clean test fmt: @goimports -w cmd pkg tests @@ -20,39 +17,26 @@ clean: @rm -rf build @go clean ./cmd/* - -build.coordinator: +build: mkdir -p bin/ go build -ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" -o bin/ ./cmd/coordinator - -build.worker: - mkdir -p bin/ CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} \ - go build -pgo=auto -buildmode=exe $(if $(GO_TAGS),-tags $(GO_TAGS),) \ + go build -buildmode=exe -tags static \ -ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" $(EXT_WFLAGS) \ -o bin/ ./cmd/worker -build: build.coordinator build.worker - -test: - go test -v ./pkg/... - verify-cores: - go test -run TestAll ./pkg/worker/room -v -renderFrames $(GL_CTX) -outputPath "./_rendered" + go test -run TestAllEmulatorRooms ./pkg/worker -v -renderFrames $(GL_CTX) -outputPath "../../_rendered" dev.build: compile build dev.build-local: mkdir -p bin/ go build -o bin/ ./cmd/coordinator - CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} go build -pgo=auto -o bin/ ./cmd/worker + CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} go build -o bin/ ./cmd/worker dev.run: dev.build-local -ifeq ($(OS),Windows_NT) - ./bin/coordinator.exe & ./bin/worker.exe -else ./bin/coordinator & ./bin/worker -endif dev.run.debug: go build -race -o bin/ ./cmd/coordinator @@ -62,7 +46,7 @@ dev.run.debug: dev.run-docker: docker rm cloud-game-local -f || true - docker compose up --build + docker-compose up --build # RELEASE # Builds the app for new release. diff --git a/README.md b/README.md index 1054d2ab..58734c9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ # CloudRetro - [![Build](https://github.com/giongto35/cloud-game/workflows/build/badge.svg)](https://github.com/giongto35/cloud-game/actions?query=workflow:build) [![Latest release](https://img.shields.io/github/v/release/giongto35/cloud-game.svg)](https://github.com/giongto35/cloud-game/releases/latest) @@ -11,10 +10,10 @@ on generic solution for cloudgaming Discord: [Join Us](https://discord.gg/sXRQZa2zeP) -![screenshot](https://user-images.githubusercontent.com/846874/235532552-8c8253df-aa8d-48c9-a58e-3f54e284f86e.jpg) - -## Try it at **[cloudretro.io](https://cloudretro.io)** +## Announcement +**Due to the current economic recession, i'm unable to keep demo server. Google Stadia also shutdown the Cloud service because of high cost and low adoption. I still believe Cloud Gaming is a brilliant idea and it should keep getting more investment. I open source my works so that everyone can experience self-hosting cloud gaming service to hold this spirit. You can check the rest of idea in the wiki** +## Try the service at **[cloudretro.io](https://cloudretro.io)** Direct play an existing game: **[Pokemon Emerald](https://cloudretro.io/?id=1bd37d4b5dfda87c___Pokemon%20-%20Emerald%20Version%20(U))** ## Introduction @@ -56,24 +55,19 @@ a better sense of performance. * Install [Go](https://golang.org/doc/install) * Install [libvpx](https://www.webmproject.org/code/), [libx264](https://www.videolan.org/developers/x264.html) , [libopus](http://opus-codec.org/), [pkg-config](https://www.freedesktop.org/wiki/Software/pkg-config/) - , [sdl2](https://wiki.libsdl.org/Installation), [libyuv](https://chromium.googlesource.com/libyuv/libyuv/)+[libjpeg-turbo](https://github.com/libjpeg-turbo/libjpeg-turbo) + , [sdl2](https://wiki.libsdl.org/Installation) ``` # Ubuntu / Windows (WSL2) -apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libyuv-dev libjpeg-turbo8-dev libspeexdsp-dev +apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev # MacOS -brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo speexdsp +brew install pkg-config libvpx x264 opus sdl2 # Windows (MSYS2) -pacman -Sy --noconfirm --needed git make mingw-w64-ucrt-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,SDL2,libyuv,libjpeg-turbo,speexdsp} +pacman -Sy --noconfirm --needed git make mingw-w64-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,x264-git,SDL2} ``` -(You don't need to download libyuv on macOS) - -(If you need to use the app on an older version of Ubuntu that does not have libyuv (when it says: unable to locate package libyuv-dev), you can add a custom apt repository: -`add sudo add-apt-repository ppa:savoury1/graphics`) - Because the coordinator and workers need to run simultaneously. Workers connect to the coordinator. 1. Script @@ -92,16 +86,16 @@ __See the `docker-compose.yml` file for Xvfb example config.__ ## Run with Docker -Use makefile script: `make dev.run-docker` or Docker Compose directly: `docker compose up --build`. -It will spawn a docker environment and you can access the service on `localhost:8000`. +Use makefile script: `make dev.run-docker` or Docker Compose directly: `docker-compose up --build` +(`CLOUD_GAME_GAMES_PATH` is env variable for games on your host). It will spawn a docker environment and you can access +the service on `localhost:8000`. ## Configuration -The default configuration file is stored in the [`pkg/configs/config.yaml`](pkg/config/config.yaml) file. -This configuration file will be embedded into the applications and loaded automatically during startup. -In order to change the default parameters you can specify environment variables with the `CLOUD_GAME_` prefix, or place -a custom `config.yaml` file into one of these places: just near the application, `.cr` folder in user's home, or -specify own directory with `-w-conf` application param (`worker -w-conf /usr/conf`). +The configuration parameters are stored in the [`configs/config.yaml`](configs/config.yaml) file which is shared for all +application instances on the same host system. It is possible to specify individual configuration files for each +instance as well as override some parameters, for that purpose, please refer to the list of command-line options of the +apps. ## Deployment @@ -112,11 +106,15 @@ application [installed](https://docs.docker.com/compose/install/). ## Technical documents -- [Design document v2](DESIGNv2.md) - [webrtchacks Blog: Open Source Cloud Gaming with WebRTC](https://webrtchacks.com/open-source-cloud-gaming-with-webrtc/) - [Wiki (outdated)](https://github.com/giongto35/cloud-game/wiki) + - [Code Pointer Wiki](https://github.com/giongto35/cloud-game/wiki/Code-Deep-Dive) +| High level | Worker internal | +| :----------------------------------: | :-----------------------------------------: | +| ![screenshot](docs/img/overview.png) | ![screenshot](docs/img/worker-internal.png) | + ## FAQ - [FAQ](https://github.com/giongto35/cloud-game/wiki/FAQ) @@ -125,7 +123,7 @@ application [installed](https://docs.docker.com/compose/install/). By clicking these deep link, you can join the game directly and play it together with other people. -- [Play Pokemon Emerald](https://cloudretro.io/?id=652e45d78d2b91cd___Pokemon%20-%20Emerald%20Version%20(U)) +- [Play Pokemon Emerald](https://cloudretro.io/?id=652e45d78d2b91cd%7CPokemon%20-%20Emerald%20Version%20%28U%29) - [Fire Emblem](https://cloudretro.io/?id=314ea4d7f9c94d25___Fire%20Emblem%20%28U%29%20%5B%21%5D) - [Samurai Showdown 4](https://cloudretro.io/?id=733c73064c368832___samsho4) - [Metal Slug X](https://cloudretro.io/?id=2a9c4b3f1c872d28___mslugx) @@ -133,6 +131,11 @@ By clicking these deep link, you can join the game directly and play it together And you can host the new game by yourself by accessing [cloudretro.io](https://cloudretro.io) and click "share" button to generate a permanent link to your game. +

+
+Synchronize a game session on multiple devices +

+ ## Credits We are very much thankful to [everyone](https://github.com/giongto35/cloud-game/graphs/contributors) we've been lucky to @@ -158,6 +161,7 @@ Thanks: # Announcement + **[CloudMorph](https://github.com/giongto35/cloud-morph) is a sibling project that offers a more generic to run any offline games/application on browser in Cloud Gaming approach: [https://github.com/giongto35/cloud-morph](https://github.com/giongto35/cloud-morph))** diff --git a/assets/cores/mupen64plus_next_libretro.cfg b/assets/cores/mupen64plus_next_libretro.cfg new file mode 100644 index 00000000..6fa02e37 --- /dev/null +++ b/assets/cores/mupen64plus_next_libretro.cfg @@ -0,0 +1,53 @@ +mupen64plus-169screensize = 480x270 +mupen64plus-43screensize = 320x240 +mupen64plus-alt-map = False +mupen64plus-aspect = 4:3 +mupen64plus-astick-deadzone = 15 +mupen64plus-astick-sensitivity = 100 +mupen64plus-BackgroundMode = OnePiece +mupen64plus-BilinearMode = standard +mupen64plus-CorrectTexrectCoords = Off +mupen64plus-CountPerOp = 0 +mupen64plus-cpucore = dynamic_recompiler +mupen64plus-CropMode = Auto +mupen64plus-d-cbutton = C3 +mupen64plus-EnableCopyColorToRDRAM = Off +mupen64plus-EnableCopyDepthToRDRAM = Software +mupen64plus-EnableEnhancedHighResStorage = False +mupen64plus-EnableEnhancedTextureStorage = False +mupen64plus-EnableFBEmulation = True +mupen64plus-EnableFragmentDepthWrite = False +mupen64plus-EnableHWLighting = False +mupen64plus-EnableLegacyBlending = True +mupen64plus-EnableLODEmulation = True +mupen64plus-EnableNativeResTexrects = Disabled +mupen64plus-EnableOverscan = Enabled +mupen64plus-EnableShadersStorage = True +mupen64plus-EnableTextureCache = True +mupen64plus-ForceDisableExtraMem = False +mupen64plus-FrameDuping = False +mupen64plus-Framerate = Original +mupen64plus-FXAA = 0 +mupen64plus-l-cbutton = C2 +mupen64plus-MaxTxCacheSize = 8000 +mupen64plus-NoiseEmulation = True +mupen64plus-OverscanBottom = 0 +mupen64plus-OverscanLeft = 0 +mupen64plus-OverscanRight = 0 +mupen64plus-OverscanTop = 0 +mupen64plus-pak1 = memory +mupen64plus-pak2 = none +mupen64plus-pak3 = none +mupen64plus-pak4 = none +mupen64plus-r-cbutton = C1 +mupen64plus-rdp-plugin = gliden64 +mupen64plus-rsp-plugin = hle +mupen64plus-rspmode = HLE +mupen64plus-txCacheCompression = True +mupen64plus-txEnhancementMode = None +mupen64plus-txFilterIgnoreBG = True +mupen64plus-txFilterMode = None +mupen64plus-txHiresEnable = False +mupen64plus-txHiresFullAlphaChannel = False +mupen64plus-u-cbutton = C4 +mupen64plus-virefresh = Auto diff --git a/assets/cores/nestopia_libretro.cfg b/assets/cores/nestopia_libretro.cfg new file mode 100644 index 00000000..fd06bde7 --- /dev/null +++ b/assets/cores/nestopia_libretro.cfg @@ -0,0 +1 @@ +nestopia_audio_type=stereo diff --git a/assets/games/n64/Sample Demo by Florian (PD).z64 b/assets/games/Sample Demo by Florian (PD).z64 similarity index 100% rename from assets/games/n64/Sample Demo by Florian (PD).z64 rename to assets/games/Sample Demo by Florian (PD).z64 diff --git a/assets/games/Super Mario Bros.nes b/assets/games/Super Mario Bros.nes new file mode 100644 index 00000000..878ef21b Binary files /dev/null and b/assets/games/Super Mario Bros.nes differ diff --git a/assets/games/gba/Sushi The Cat.gba b/assets/games/Sushi The Cat.gba similarity index 100% rename from assets/games/gba/Sushi The Cat.gba rename to assets/games/Sushi The Cat.gba diff --git a/assets/games/gba/anguna.gba b/assets/games/anguna.gba similarity index 100% rename from assets/games/gba/anguna.gba rename to assets/games/anguna.gba diff --git a/assets/games/dos/rogue.conf b/assets/games/dos/rogue.conf deleted file mode 100644 index 015eb847..00000000 --- a/assets/games/dos/rogue.conf +++ /dev/null @@ -1,2 +0,0 @@ -[autoexec] -ROGUE.EXE \ No newline at end of file diff --git a/assets/games/dos/rogue.zip b/assets/games/dos/rogue.zip deleted file mode 100644 index 53129bd8..00000000 Binary files a/assets/games/dos/rogue.zip and /dev/null differ diff --git a/assets/games/nes/Alwa's Awakening (Demo).nes b/assets/games/nes/Alwa's Awakening (Demo).nes deleted file mode 100644 index 7c2f5155..00000000 Binary files a/assets/games/nes/Alwa's Awakening (Demo).nes and /dev/null differ diff --git a/cmd/coordinator/main.go b/cmd/coordinator/main.go index 95609982..294d6957 100644 --- a/cmd/coordinator/main.go +++ b/cmd/coordinator/main.go @@ -1,32 +1,33 @@ package main import ( - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/coordinator" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/os" + "math/rand" + "time" + + config "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/os" ) var Version = "?" func main() { - conf, paths := config.NewCoordinatorConfig() + rand.Seed(time.Now().UTC().UnixNano()) + conf := config.NewConfig() conf.ParseFlags() log := logger.NewConsole(conf.Coordinator.Debug, "c", false) + log.Info().Msgf("version %s", Version) - log.Info().Msgf("conf: v%v, loaded: %v", conf.Version, paths) + log.Info().Msgf("conf version: %v", conf.Version) if log.GetLevel() < logger.InfoLevel { - log.Debug().Msgf("conf: %+v", conf) - } - c, err := coordinator.New(conf, log) - if err != nil { - log.Error().Err(err).Msgf("init fail") - return + log.Debug().Msgf("config: %+v", conf) } + c := coordinator.New(conf, log) c.Start() <-os.ExpectTermination() if err := c.Stop(); err != nil { - log.Error().Err(err).Msg("shutdown fail") + log.Error().Err(err).Msg("service shutdown errors") } } diff --git a/cmd/worker/default.pgo b/cmd/worker/default.pgo deleted file mode 100644 index c659757c..00000000 Binary files a/cmd/worker/default.pgo and /dev/null differ diff --git a/cmd/worker/main.go b/cmd/worker/main.go index a20cd819..cf3e4322 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -1,40 +1,40 @@ package main import ( + "math/rand" "time" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/os" - "github.com/giongto35/cloud-game/v3/pkg/worker" - "github.com/giongto35/cloud-game/v3/pkg/worker/thread" + config "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/worker" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" ) var Version = "?" func run() { - conf, paths := config.NewWorkerConfig() + rand.Seed(time.Now().UTC().UnixNano()) + conf := config.NewConfig() conf.ParseFlags() log := logger.NewConsole(conf.Worker.Debug, "w", false) log.Info().Msgf("version %s", Version) - log.Info().Msgf("conf: v%v, loaded: %v", conf.Version, paths) + log.Info().Msgf("conf version: %v", conf.Version) if log.GetLevel() < logger.InfoLevel { - log.Debug().Msgf("conf: %+v", conf) + log.Debug().Msgf("config: %+v", conf) } done := os.ExpectTermination() - w, err := worker.New(conf, log) - if err != nil { - log.Error().Err(err).Msgf("init fail") - return - } - w.Start(done) + wrk := worker.New(conf, log, done) + wrk.Start() <-done - time.Sleep(100 * time.Millisecond) // hack - if err := w.Stop(); err != nil { - log.Error().Err(err).Msg("shutdown fail") + time.Sleep(100 * time.Millisecond) + if err := wrk.Stop(); err != nil { + log.Error().Err(err).Msg("service shutdown errors") } } -func main() { thread.Wrap(run) } +func main() { + thread.Wrap(run) +} diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 00000000..8ef789bf --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,306 @@ +# +# Application configuration file +# + +# for the compatibility purposes +version: 3 + +coordinator: + # debugging switch + # - shows debug logs + # - allows selecting worker instances + debug: false + # selects free workers: + # - any (default, any free) + # - ping (with the lowest ping) + selector: any + # games library + library: + # root folder for the library (where games are stored) + basePath: assets/games + # an explicit list of supported file extensions + # which overrides Libretro emulator ROMs configs + supported: + # a list of ignored words in the ROM filenames + ignored: + - neogeo + - pgm + # print some additional info + verbose: true + # enable library directory live reload + # (experimental) + watchMode: false + monitoring: + port: 6601 + # enable Go profiler HTTP server + profilingEnabled: false + metricEnabled: false + urlPrefix: /coordinator + # a custom Origins for incoming Websocket connections: + # "" -- checks same origin policy + # "*" -- allows all + # "your address" -- checks for that address + origin: + userWs: + workerWs: + # HTTP(S) server config + server: + address: :8000 + https: false + # Letsencrypt or self cert config + tls: + address: :443 + # allowed host name + domain: + # if both are set then will use certs + # and Letsencryt instead + httpsCert: + httpsKey: + analytics: + inject: false + gtag: + +worker: + # show more logs + debug: false + network: + # a coordinator address to connect to + coordinatorAddress: localhost:8000 + # where to connect + endpoint: /wso + # ping endpoint + pingEndpoint: /echo + # set public ping address (IP or hostname) + publicAddress: + # make coordinator connection secure (wss) + secure: false + # ISO Alpha-2 country code to group workers by zones + zone: + monitoring: + # monitoring server port + port: 6602 + profilingEnabled: false + # monitoring server URL prefix + metricEnabled: false + urlPrefix: /worker + server: + address: :9000 + https: false + tls: + address: :444 + # LetsEncrypt config + # allowed host name + domain: + # Own certs config + httpsCert: + httpsKey: + # optional server tag + tag: + +emulator: + # set output viewport scale factor + scale: 1 + + # set the total number of threads for the image processing + # (experimental) + threads: 4 + + aspectRatio: + # enable aspect ratio changing + # (experimental) + keep: false + # recalculate emulator game frame size to the given WxH + width: 320 + height: 240 + + # enable autosave for emulator states if set to a non-zero value of seconds + autosaveSec: 0 + + # save directory for emulator states + # special tag {user} will be replaced with current user's home dir + storage: "{user}/.cr/save" + + # path for storing emulator generated files + localPath: "./libretro" + + libretro: + # use zip compression for emulator save states + saveCompression: true + # Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX + logLevel: 1 + cores: + paths: + libs: assets/cores + configs: assets/cores + # Config params for Libretro cores repository, + # available types are: + # - buildbot (the default Libretro nightly repository) + # - github (GitHub raw repository with a similar structure to buildbot) + # - raw (just a link to a zip file extracted as is) + repo: + # enable auto-download for the list of cores (list->lib) + sync: true + # external cross-process mutex lock + extLock: "{user}/.cr/cloud-game.lock" + main: + type: buildbot + url: https://buildbot.libretro.com/nightly + # if repo has file compression + compression: zip + # a secondary repo to use i.e. for not found in the main cores + secondary: + type: github + url: https://github.com/sergystepanov/libretro-spiegel/blob/main + compression: zip + # Libretro core configuration + # + # The emulator selection will happen in this order: + # - based on the folder name in the folder param + # - based on the folder name (core name) in the list (i.e. nes, snes) + # - based on the rom names in the roms param + # + # Available config params: + # - altRepo (bool) prioritize secondary repo as the download source + # - lib (string) + # - config (string) + # - roms ([]string) + # - folder (string) + # By default emulator selection is based on the folder named as cores + # in the list (i.e. nes, snes) but if you specify folder param, + # then it will try to load the ROM file from that folder first. + # - width (int) -- broken + # - height (int) -- broken + # - ratio (float) + # - isGlAllowed (bool) + # - usesLibCo (bool) + # - hasMultitap (bool) + list: + gba: + lib: mgba_libretro + roms: [ "gba", "gbc" ] + pcsx: + lib: pcsx_rearmed_libretro + roms: [ "cue" ] + # example of folder override + folder: psx + # MAME core requires additional manual setup, please read: + # https://docs.libretro.com/library/fbneo/ + mame: + lib: fbneo_libretro + roms: [ "zip" ] + nes: + lib: nestopia_libretro + config: nestopia_libretro.cfg + roms: [ "nes" ] + snes: + lib: snes9x_libretro + roms: [ "smc", "sfc", "swc", "fig", "bs" ] + hasMultitap: true + n64: + lib: mupen64plus_next_libretro + altRepo: true + config: mupen64plus_next_libretro.cfg + roms: [ "n64", "v64", "z64" ] + isGlAllowed: true + usesLibCo: true + +encoder: + audio: + # audio frame duration needed for WebRTC (Opus) + # most of the emulators have ~1400 samples per a video frame, + # so we keep the frame buffer roughly half of that size or 2 RTC packets per frame + frame: 10 + video: + # h264, vpx (VP8) + codec: h264 + # concurrent execution units (0 - disabled) + concurrency: 0 + # see: https://trac.ffmpeg.org/wiki/Encode/H.264 + h264: + # Constant Rate Factor (CRF) 0-51 (default: 23) + crf: 23 + # ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo + preset: superfast + # baseline, main, high, high10, high422, high444 + profile: baseline + # film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency + tune: zerolatency + # 0-3 + logLevel: 0 + # see: https://www.webmproject.org/docs/encoder-parameters + vpx: + # target bitrate (KBit/s) + bitrate: 1200 + # force keyframe interval + keyframeInterval: 5 + +# game recording +# (experimental) +# recording allows export RAW a/v streams of games +# by default, it will export audio as WAV files, +# video as a list of PNG-encoded images, and +# one additional FFMPEG concat demux file +recording: + enabled: false + # image compression level: + # 0 - default compression + # -1 - no compression + # -2 - best speed + # -3 - best compression + compressLevel: 0 + # name contains the name of the recording dir (or zip) + # format: + # %date:go_time_format% -- refer: https://go.dev/src/time/format.go + # %user% -- user name who started the recording + # %game% -- game name (game ROM name) + # %rand:len% -- a random string of given length + # as example: 20210101101010_yeE_user1_badApple + name: "%date:20060102150405%_%rand:3%_%user%_%game%" + # zip and remove recording dir on completion + zip: true + # save directory + folder: ./recording + +storage: + # cloud storage provider: + # - empty (No op storage stub) + # - oracle [Oracle Object Storage](https://www.oracle.com/cloud/storage/object-storage.html) + provider: + # this value contains arbitrary key attribute: + # - oracle: pre-authenticated URL (see: https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/usingpreauthenticatedrequests.htm) + key: + +webrtc: + # turn off default Pion interceptors (see: https://github.com/pion/interceptor) + # (performance) + disableDefaultInterceptors: true + # indicates the role of the DTLS transport (see: https://github.com/pion/webrtc/blob/master/dtlsrole.go) + # (debug) + # - (default) + # - 1 (auto) + # - 2 (client) + # - 3 (server) + dtlsRole: + # a list of STUN/TURN servers to use + iceServers: + - urls: stun:stun.l.google.com:19302 + # configures whether the ice agent should be a lite agent (true/false) + # (performance) + # don't use iceServers when enabled + iceLite: false + # ICE configuration + # by default, ICE ports are random and unlimited + # alternatives: + # 1. instead of random unlimited port range for + # WebRTC connections, these params limit port range of ICE connections + icePorts: + min: + max: + # 2. select a single port to forward all ICE connections there + singlePort: + # override ICE candidate IP, see: https://github.com/pion/webrtc/issues/835, + # can be used for Docker bridged network internal IP override + iceIpMap: + # set additional log level for WebRTC separately + # -1 - trace, 6 - nothing, ..., debug - 0 + logLevel: 6 diff --git a/docker-compose.yml b/docker-compose.yml index dff2c59a..5fb3725c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +version: '3' services: cloud-game: @@ -6,28 +7,20 @@ services: container_name: cloud-game-local environment: - DISPLAY=:99 - - MESA_GL_VERSION_OVERRIDE=4.5 + - MESA_GL_VERSION_OVERRIDE=3.3 - CLOUD_GAME_WEBRTC_SINGLEPORT=8443 - # - CLOUD_GAME_WEBRTC_ICEIPMAP=127.0.0.1 - - CLOUD_GAME_COORDINATOR_DEBUG=true - - CLOUD_GAME_WORKER_DEBUG=true + - CLOUD_GAME_WEBRTC_ICEIPMAP=127.0.0.1 # - PION_LOG_TRACE=all ports: - - "8000:8000" - - "9000:9000" - - "8443:8443/udp" + - 8000:8000 + - 9000:9000 + - 8443:8443/udp command: > - bash -c "./coordinator & ./worker" + bash -c "Xvfb :99 & coordinator & worker" volumes: - - ./assets/cores:/usr/local/share/cloud-game/assets/cores - - ./assets/games:/usr/local/share/cloud-game/assets/games - - x11:/tmp/.X11-unix - - xvfb: - image: kcollins/xvfb:latest - volumes: - - x11:/tmp/.X11-unix - command: [ ":99", "-screen", "0", "320x240x16" ] + # keep cores persistent in the cloud-game_cores volume + - cores:/usr/local/share/cloud-game/assets/cores + - ${CLOUD_GAME_GAMES_PATH:-./assets/games}:/usr/local/share/cloud-game/assets/games volumes: - x11: + cores: diff --git a/docs/DESIGNv2.md b/docs/DESIGNv2.md new file mode 100644 index 00000000..f6ade821 --- /dev/null +++ b/docs/DESIGNv2.md @@ -0,0 +1,25 @@ +# Web-based Cloud Gaming Service Design Document + +Web-based Cloud Gaming Service contains multiple workers for gaming stream and a coordinator (Coordinator) for distributing traffic and pairing up connection. + +## Worker + +Worker is responsible for streaming game to frontend +![worker](img/worker-internal.png) + +- After Coordinator matches the most appropriate server to the user, webRTC peer-to-peer handshake will be conducted. The coordinator will exchange the signature (WebRTC Session Remote Description) between two peers over Web Socket connection. +- On worker, each user session will spawn a new room running a gaming emulator. Image stream and audio stream from emulator is captured and encoded to WebRTC streaming format. We applied Vp8 for Video compression and Opus for audio compression to ensure the smoothest experience. After finish encoded, these stream is then piped out to user and observers joining that room. +- On the other hand, input from users is sent to workers over WebRTC DataChannel. Game logic on the emulator will be updated based on the input stream. +- Game state is stored in cloud storage, so all workers can collaborate and keep the same understanding with each other. It allows user can continue from the saved state in the next time. + +## Coordinator + +Coordinator is loadbalancer and coordinator, which is in charge of picking the most suitable workers for a user. Every time a user connects to Coordinator, it will collect all the metric from all workers, i.e free CPU resources and latency from worker to user. Coordinator will decide the best candidate based on the metric and setup peer-to-peer connection between worker and user based on WebRTC protocol + +![Architecture](img/coordinator.png) + +1. A user connected to Coordinator . +2. Coordinator will find the most suitable worker to serve the user. +3. Coordinator collects all latencies from workers to users as well as CPU usage on each machine. +4. Coordinator setup peer-to-peer handshake between worker and user by exchanging Session Description Protocol. +5. A game is hosted on worker and streamed to the user. diff --git a/docs/img/coordinator.png b/docs/img/coordinator.png new file mode 100644 index 00000000..55eebeb4 Binary files /dev/null and b/docs/img/coordinator.png differ diff --git a/docs/img/multiplatform.png b/docs/img/multiplatform.png new file mode 100644 index 00000000..40fcf4f1 Binary files /dev/null and b/docs/img/multiplatform.png differ diff --git a/docs/img/overview.png b/docs/img/overview.png new file mode 100644 index 00000000..aa7ef1df Binary files /dev/null and b/docs/img/overview.png differ diff --git a/docs/img/worker-internal.png b/docs/img/worker-internal.png new file mode 100644 index 00000000..63c89d7c Binary files /dev/null and b/docs/img/worker-internal.png differ diff --git a/go.mod b/go.mod index 5cfb890b..4a4a87c3 100644 --- a/go.mod +++ b/go.mod @@ -1,62 +1,50 @@ -module github.com/giongto35/cloud-game/v3 +module github.com/giongto35/cloud-game/v2 -go 1.25 +go 1.18 require ( - github.com/VictoriaMetrics/metrics v1.40.2 + github.com/VictoriaMetrics/metrics v1.23.1 github.com/cavaliergopher/grab/v3 v3.0.1 - github.com/fsnotify/fsnotify v1.9.0 - github.com/goccy/go-json v0.10.5 - github.com/gofrs/flock v0.13.0 - github.com/gorilla/websocket v1.5.3 - github.com/knadh/koanf/maps v0.1.2 - github.com/knadh/koanf/v2 v2.3.0 - github.com/minio/minio-go/v7 v7.0.97 - github.com/pion/ice/v4 v4.1.0 - github.com/pion/interceptor v0.1.42 - github.com/pion/logging v0.2.4 - github.com/pion/webrtc/v4 v4.1.8 - github.com/rs/xid v1.6.0 - github.com/rs/zerolog v1.34.0 - github.com/veandco/go-sdl2 v0.4.40 - golang.org/x/crypto v0.46.0 - golang.org/x/image v0.34.0 - gopkg.in/yaml.v3 v3.0.1 + github.com/fsnotify/fsnotify v1.6.0 + github.com/goccy/go-json v0.10.0 + github.com/gofrs/flock v0.8.1 + github.com/gorilla/websocket v1.5.0 + github.com/kkyr/fig v0.3.1 + github.com/pion/interceptor v0.1.12 + github.com/pion/logging v0.2.2 + github.com/pion/webrtc/v3 v3.1.53 + github.com/rs/xid v1.4.0 + github.com/rs/zerolog v1.29.0 + github.com/veandco/go-sdl2 v0.4.31 + golang.org/x/crypto v0.5.0 + golang.org/x/image v0.3.0 ) require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-ini/ini v1.67.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/klauspost/crc32 v1.3.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/minio/crc64nvme v1.1.1 // indirect - github.com/minio/md5-simd v1.1.2 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/philhofer/fwd v1.2.0 // indirect - github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.9 // indirect - github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pion/datachannel v1.5.5 // indirect + github.com/pion/dtls/v2 v2.2.2 // indirect + github.com/pion/ice/v2 v2.2.16 // indirect + github.com/pion/mdns v0.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.16 // indirect - github.com/pion/rtp v1.8.27 // indirect - github.com/pion/sctp v1.8.41 // indirect - github.com/pion/sdp/v3 v3.0.17 // indirect - github.com/pion/srtp/v3 v3.0.9 // indirect - github.com/pion/stun/v3 v3.0.2 // indirect - github.com/pion/transport/v3 v3.1.1 // indirect - github.com/pion/turn/v4 v4.1.3 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect - github.com/tinylib/msgp v1.6.1 // indirect + github.com/pion/rtcp v1.2.10 // indirect + github.com/pion/rtp v1.7.13 // indirect + github.com/pion/sctp v1.8.6 // indirect + github.com/pion/sdp/v3 v3.0.6 // indirect + github.com/pion/srtp/v2 v2.0.11 // indirect + github.com/pion/stun v0.4.0 // indirect + github.com/pion/transport v0.14.1 // indirect + github.com/pion/transport/v2 v2.0.0 // indirect + github.com/pion/turn/v2 v2.0.9 // indirect + github.com/pion/udp v0.1.4 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect - github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/net v0.5.0 // indirect + golang.org/x/sys v0.4.0 // indirect + golang.org/x/text v0.6.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e104a47a..cbb7508e 100644 --- a/go.sum +++ b/go.sum @@ -1,133 +1,230 @@ -github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac= -github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA= +github.com/VictoriaMetrics/metrics v1.23.1 h1:/j8DzeJBxSpL2qSIdqnRFLvQQhbJyJbbEi22yMm7oL0= +github.com/VictoriaMetrics/metrics v1.23.1/go.mod h1:rAr/llLpEnAdTehiNlUxKgnjcOuROSzpw0GvjpEbvFc= github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4= github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= -github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= +github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= -github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= -github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= -github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= -github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= -github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= -github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/kkyr/fig v0.3.1 h1:GqsamO9dwY05t2xh6ubzjPPYw2It4hoWbKZEWmDxM0o= +github.com/kkyr/fig v0.3.1/go.mod h1:ItUILF8IIzgZOMhx5xpJ1W/bviQsWRKOwKXfE/tqUoA= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= -github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= -github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= -github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= -github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= -github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= -github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM= -github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= -github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU= -github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4= -github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ= -github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU= -github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= -github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= -github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8= +github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0= +github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY= +github.com/pion/dtls/v2 v2.2.2 h1:ProtrjiUsniOnqzxc2N1l8s31LjzEx6CnOR/VYI4mBM= +github.com/pion/dtls/v2 v2.2.2/go.mod h1:jabr7NM22jSGqytLKlPJ872ruQZRPb96+I6q+kPp6aQ= +github.com/pion/ice/v2 v2.2.16 h1:ht10A9FxLrFouaQQy9oSzZHaN+HJqN07jQ0SmzsBgpU= +github.com/pion/ice/v2 v2.2.16/go.mod h1:bygTkwN2e4U4v57VE77qS2wk5P8kc951WZZkf4LeA2E= +github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8= +github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8= +github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/mdns v0.0.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U= +github.com/pion/mdns v0.0.7/go.mod h1:4iP2UbeFhLI/vWju/bw6ZfwjJzk0z8DNValjGxR/dD8= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= -github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc= -github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/rtp v1.8.27 h1:kbWTdZr62RDlYjatVAW4qFwrAu9XcGnwMsofCfAHlOU= -github.com/pion/rtp v1.8.27/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs= -github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY= -github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= -github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= -github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= -github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= -github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY= -github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8= -github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU= -github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA= -github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= -github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= -github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA= -github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A= -github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk= -github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s= +github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo= +github.com/pion/rtcp v1.2.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc= +github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= +github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA= +github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= +github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0= +github.com/pion/sctp v1.8.6 h1:CUex11Vkt9YS++VhLf8b55O3VqKrWL6W3SDwX4jAqsI= +github.com/pion/sctp v1.8.6/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0= +github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw= +github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw= +github.com/pion/srtp/v2 v2.0.11 h1:6cEEgT1oCLWgE+BynbfaSMAxtsqU0M096x9dNH6olY0= +github.com/pion/srtp/v2 v2.0.11/go.mod h1:vzHprzbuVoYJ9NfaRMycnFrkHcLSaLVuBZDOtFQNZjY= +github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA= +github.com/pion/stun v0.4.0 h1:vgRrbBE2htWHy7l3Zsxckk7rkjnjOsSM7PHZnBwo8rk= +github.com/pion/stun v0.4.0/go.mod h1:QPsh1/SbXASntw3zkkrIk3ZJVKz4saBY2G7S10P3wCw= +github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q= +github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g= +github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg= +github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40= +github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= +github.com/pion/transport/v2 v2.0.0 h1:bsMYyqHCbkvHwj+eNCFBuxtlKndKfyGI2vaQmM3fIE4= +github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc= +github.com/pion/turn/v2 v2.0.9 h1:jcDPw0Vfd5I4iTc7s0Upfc2aMnyu2lgJ9vV0SUrNC1o= +github.com/pion/turn/v2 v2.0.9/go.mod h1:DQlwUwx7hL8Xya6TTAabbd9DdKXTNR96Xf5g5Qqso/M= +github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M= +github.com/pion/udp v0.1.2/go.mod h1:CuqU2J4MmF3sjqKfk1SaIhuNXdum5PJRqd2LHuLMQSk= +github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8= +github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us= +github.com/pion/webrtc/v3 v3.1.53 h1:vVyBPndwclEb2sClJHegQvlXWlA1YAbADyOhVQgphlM= +github.com/pion/webrtc/v3 v3.1.53/go.mod h1:CJ3+hHptn5qzgeeTRGN5zuAlVtTXwGJYH18Zznn+onw= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= +github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= -github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U= -github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= -github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= -github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= -golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +github.com/veandco/go-sdl2 v0.4.31 h1:hANDTXYfoRiFrDDD4OkkTBQHCMhXgQnXl1IXC/V9Jbc= +github.com/veandco/go-sdl2 v0.4.31/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20221010152910-d6f0a8c073c2/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg= +golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/api/api.go b/pkg/api/api.go index 6605a188..19d3bffd 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -1,71 +1,42 @@ -// Package api defines the general API for both coordinator and worker applications. -// -// Each API call (request and response) is a JSON-encoded "packet" of the following structure: -// -// id - (optional) a globally unique packet id; -// t - (required) one of the predefined unique packet types; -// p - (optional) packet payload with arbitrary data. -// -// The basic idea behind this API is that the packets differentiate by their predefined types -// with which it is possible to unwrap the payload into distinct request/response data structures. -// And the id field is used for tracking packets through a chain of different network points (apps, devices), -// for example, passing a packet from a browser forward to a worker and back through a coordinator. -// -// Example: -// -// {"t":4,"p":{"ice":[{"urls":"stun:stun.l.google.com:19302"}],"games":["Sushi The Cat"],"wid":"cfv68irdrc3ifu3jn6bg"}} package api import ( - "encoding/json" + "encoding/base64" "fmt" - "strings" + + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/goccy/go-json" ) type ( - Id interface { - String() string - } Stateful struct { - Id string `json:"id"` + Id network.Uid `json:"id"` } Room struct { - Rid string `json:"room_id"` + Rid string `json:"room_id"` // room id } StatefulRoom struct { - Id string `json:"id"` - Rid string `json:"room_id"` + Stateful + Room } PT uint8 ) -type In[I Id] struct { - Id I `json:"id,omitempty"` - T PT `json:"t"` - Payload json.RawMessage `json:"p,omitempty"` // should be json.RawMessage for 2-pass unmarshal +type ( + RoomInterface interface { + GetRoom() string + } +) + +func StateRoom(id network.Uid, rid string) StatefulRoom { + return StatefulRoom{Stateful: Stateful{id}, Room: Room{rid}} } - -func (i In[I]) GetId() I { return i.Id } -func (i In[I]) GetPayload() []byte { return i.Payload } -func (i In[I]) GetType() PT { return i.T } - -type Out struct { - Id string `json:"id,omitempty"` // string because omitempty won't work as intended with arrays - T uint8 `json:"t"` - Payload any `json:"p,omitempty"` -} - -func (o *Out) SetId(s string) { o.Id = s } -func (o *Out) SetType(u uint8) { o.T = u } -func (o *Out) SetPayload(a any) { o.Payload = a } -func (o *Out) SetGetId(s fmt.Stringer) { o.Id = s.String() } -func (o *Out) GetPayload() any { return o.Payload } +func (sr StatefulRoom) GetRoom() string { return sr.Rid } // Packet codes: // -// x, 1xx - user codes -// 15x - webrtc data exchange codes -// 2xx - worker codes +// x, 1xx - user codes +// 2xx - worker codes const ( CheckLatency PT = 3 InitSession PT = 4 @@ -74,21 +45,17 @@ const ( WebrtcAnswer PT = 102 WebrtcIce PT = 103 StartGame PT = 104 + ChangePlayer PT = 108 QuitGame PT = 105 SaveGame PT = 106 LoadGame PT = 107 - ChangePlayer PT = 108 + ToggleMultitap PT = 109 RecordGame PT = 110 GetWorkerList PT = 111 - ErrNoFreeSlots PT = 112 - ResetGame PT = 113 RegisterRoom PT = 201 CloseRoom PT = 202 IceCandidate = WebrtcIce TerminateSession PT = 204 - AppVideoChange PT = 150 - LibNewGameList PT = 205 - PrevSessions PT = 206 ) func (p PT) String() string { @@ -115,26 +82,18 @@ func (p PT) String() string { return "SaveGame" case LoadGame: return "LoadGame" + case ToggleMultitap: + return "ToggleMultitap" case RecordGame: return "RecordGame" case GetWorkerList: return "GetWorkerList" - case ErrNoFreeSlots: - return "NoFreeSlots" - case ResetGame: - return "ResetGame" case RegisterRoom: return "RegisterRoom" case CloseRoom: return "CloseRoom" case TerminateSession: return "TerminateSession" - case AppVideoChange: - return "AppVideoChange" - case LibNewGameList: - return "LibNewGameList" - case PrevSessions: - return "PrevSessions" default: return "Unknown" } @@ -151,27 +110,6 @@ var ( ErrMalformed = fmt.Errorf("malformed") ) -var ( - EmptyPacket = Out{Payload: ""} - ErrPacket = Out{Payload: "err"} - OkPacket = Out{Payload: "ok"} -) - -func Do[I Id, T any](in In[I], fn func(T)) error { - if dat := Unwrap[T](in.Payload); dat != nil { - fn(*dat) - return nil - } - return ErrMalformed -} - -func DoE[I Id, T any](in In[I], fn func(T) error) error { - if dat := Unwrap[T](in.Payload); dat != nil { - return fn(*dat) - } - return ErrMalformed -} - func Unwrap[T any](data []byte) *T { out := new(T) if err := json.Unmarshal(data, out); err != nil { @@ -187,16 +125,27 @@ func UnwrapChecked[T any](bytes []byte, err error) (*T, error) { return Unwrap[T](bytes), nil } -func Wrap(t any) ([]byte, error) { return json.Marshal(t) } - -const separator = "___" - -func ExplodeDeepLink(link string) (string, string) { - p := strings.SplitN(link, separator, 2) - - if len(p) == 1 { - return p[0], "" +// ToBase64Json encodes data to a URL-encoded Base64+JSON string. +func ToBase64Json(data any) (string, error) { + if data == nil { + return "", nil } - - return p[0], p[1] + b, err := json.Marshal(data) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// FromBase64Json decodes data from a URL-encoded Base64+JSON string. +func FromBase64Json(data string, obj any) error { + b, err := base64.URLEncoding.DecodeString(data) + if err != nil { + return err + } + err = json.Unmarshal(b, obj) + if err != nil { + return err + } + return nil } diff --git a/pkg/api/coordinator.go b/pkg/api/coordinator.go index 9cdf22b7..1d0c308b 100644 --- a/pkg/api/coordinator.go +++ b/pkg/api/coordinator.go @@ -1,42 +1,47 @@ package api +import "github.com/giongto35/cloud-game/v2/pkg/network" + type ( - CloseRoomRequest string - ConnectionRequest[T Id] struct { + CloseRoomRequest string + ConnectionRequest struct { Addr string `json:"addr,omitempty"` - Id T `json:"id,omitempty"` + Id string `json:"id,omitempty"` IsHTTPS bool `json:"is_https,omitempty"` PingURL string `json:"ping_url,omitempty"` Port string `json:"port,omitempty"` Tag string `json:"tag,omitempty"` Zone string `json:"zone,omitempty"` } + GetWorkerListRequest struct{} GetWorkerListResponse struct { Servers []Server `json:"servers"` } RegisterRoomRequest string ) +// Server contains a list of server groups. +// Server is a separate machine that may contain +// multiple sub-processes. +type Server struct { + Addr string `json:"addr,omitempty"` + Id network.Uid `json:"id,omitempty"` + IsBusy bool `json:"is_busy,omitempty"` + InGroup bool `json:"in_group,omitempty"` + PingURL string `json:"ping_url"` + Port string `json:"port,omitempty"` + Replicas uint32 `json:"replicas,omitempty"` + Tag string `json:"tag,omitempty"` + Zone string `json:"zone,omitempty"` +} + +type HasServerInfo interface { + GetServerList() []Server +} + const ( DataQueryParam = "data" RoomIdQueryParam = "room_id" ZoneQueryParam = "zone" WorkerIdParam = "wid" ) - -// Server contains a list of server groups. -// Server is a separate machine that may contain -// multiple sub-processes. -type Server struct { - Addr string `json:"addr,omitempty"` - Id Id `json:"id,omitempty"` - IsBusy bool `json:"is_busy,omitempty"` - InGroup bool `json:"in_group,omitempty"` - Machine string `json:"machine,omitempty"` - PingURL string `json:"ping_url"` - Port string `json:"port,omitempty"` - Replicas uint32 `json:"replicas,omitempty"` - Room string `json:"room,omitempty"` - Tag string `json:"tag,omitempty"` - Zone string `json:"zone,omitempty"` -} diff --git a/pkg/api/user.go b/pkg/api/user.go index 262375b7..998b3320 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -11,11 +11,6 @@ type ( RecordUser string `json:"record_user,omitempty"` PlayerIndex int `json:"player_index"` } - GameStartUserResponse struct { - RoomId string `json:"roomId"` - Av *AppVideoInfo `json:"av"` - KbMouse bool `json:"kb_mouse"` - } IceServer struct { Urls string `json:"urls,omitempty"` Username string `json:"username,omitempty"` @@ -23,14 +18,13 @@ type ( } InitSessionUserResponse struct { Ice []IceServer `json:"ice"` - Games []AppMeta `json:"games"` + Games []string `json:"games"` Wid string `json:"wid"` } - AppMeta struct { - Alias string `json:"alias,omitempty"` - Title string `json:"title"` - System string `json:"system"` - } WebrtcAnswerUserRequest string WebrtcUserIceCandidate string ) + +func InitSessionResult(ice []IceServer, games []string, wid string) (PT, InitSessionUserResponse) { + return InitSession, InitSessionUserResponse{Ice: ice, Games: games, Wid: wid} +} diff --git a/pkg/api/worker.go b/pkg/api/worker.go index c498009d..eb05f277 100644 --- a/pkg/api/worker.go +++ b/pkg/api/worker.go @@ -1,38 +1,41 @@ package api +import "github.com/giongto35/cloud-game/v2/pkg/network" + +type GameInfo struct { + Name string `json:"name"` + Base string `json:"base"` + Path string `json:"path"` + Type string `json:"type"` +} + type ( - ChangePlayerRequest struct { + ChangePlayerRequest = struct { StatefulRoom Index int `json:"index"` } ChangePlayerResponse int - GameQuitRequest StatefulRoom - LoadGameRequest StatefulRoom - LoadGameResponse string - ResetGameRequest StatefulRoom - ResetGameResponse string - SaveGameRequest StatefulRoom - SaveGameResponse string - StartGameRequest struct { + GameQuitRequest struct { + StatefulRoom + } + LoadGameRequest struct { + StatefulRoom + } + LoadGameResponse string + SaveGameRequest struct { + StatefulRoom + } + SaveGameResponse string + StartGameRequest struct { StatefulRoom Record bool RecordUser string - Game string `json:"game"` - PlayerIndex int `json:"player_index"` - } - GameInfo struct { - Alias string `json:"alias"` - Base string `json:"base"` - Name string `json:"name"` - Path string `json:"path"` - System string `json:"system"` - Type string `json:"type"` + Game GameInfo `json:"game"` + PlayerIndex int `json:"player_index"` } StartGameResponse struct { Room - AV *AppVideoInfo `json:"av"` - Record bool `json:"record"` - KbMouse bool `json:"kb_mouse"` + Record bool } RecordGameRequest struct { StatefulRoom @@ -40,31 +43,26 @@ type ( User string `json:"user"` } RecordGameResponse string - TerminateSessionRequest Stateful - WebrtcAnswerRequest struct { + TerminateSessionRequest struct { + Stateful + } + ToggleMultitapRequest struct { + StatefulRoom + } + WebrtcAnswerRequest struct { Stateful Sdp string `json:"sdp"` } WebrtcIceCandidateRequest struct { Stateful - Candidate string `json:"candidate"` // Base64-encoded ICE candidate + Candidate string `json:"candidate"` + } + WebrtcInitRequest struct { + Stateful } - WebrtcInitRequest Stateful WebrtcInitResponse string - - AppVideoInfo struct { - W int `json:"w"` - H int `json:"h"` - S int `json:"s"` - A float32 `json:"a"` - } - - LibGameListInfo struct { - T int - List []GameInfo - } - - PrevSessionInfo struct { - List []string - } ) + +func NewWebrtcIceCandidateRequest(id network.Uid, can string) (PT, any) { + return WebrtcIce, WebrtcIceCandidateRequest{Stateful: Stateful{id}, Candidate: can} +} diff --git a/pkg/com/com.go b/pkg/com/com.go index 8b475622..5899f2a5 100644 --- a/pkg/com/com.go +++ b/pkg/com/com.go @@ -1,121 +1,93 @@ package com -import "github.com/giongto35/cloud-game/v3/pkg/logger" +import ( + "encoding/json" -type stringer interface { - comparable - String() string -} + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" +) -type NetClient[K stringer] interface { - Disconnect() - Id() K -} - -type NetMap[K stringer, T NetClient[K]] struct{ Map[K, T] } - -func NewNetMap[K stringer, T NetClient[K]]() NetMap[K, T] { - return NetMap[K, T]{Map: Map[K, T]{m: make(map[K]T, 10)}} -} - -func (m *NetMap[K, T]) Add(client T) bool { return m.Put(client.Id(), client) } -func (m *NetMap[K, T]) Empty() bool { return m.Map.Len() == 0 } -func (m *NetMap[K, T]) Remove(client T) { m.Map.Remove(client.Id()) } -func (m *NetMap[K, T]) RemoveL(client T) int { return m.Map.RemoveL(client.Id()) } -func (m *NetMap[K, T]) Reset() { m.Map = Map[K, T]{m: make(map[K]T, 10)} } -func (m *NetMap[K, T]) RemoveDisconnect(client T) { client.Disconnect(); m.Remove(client) } -func (m *NetMap[K, T]) Find(id string) T { - v, _ := m.Map.FindBy(func(v T) bool { - return v.Id().String() == id - }) - return v -} - -type SocketClient[T ~uint8, P Packet[T], X any, P2 Packet2[X]] struct { - id Uid - rpc *RPC[T, P] - sock *Connection - log *logger.Logger // a special logger for showing x -> y directions -} - -func NewConnection[T ~uint8, P Packet[T], X any, P2 Packet2[X]](conn *Connection, id Uid, log *logger.Logger) *SocketClient[T, P, X, P2] { - if id.IsNil() { - id = NewUid() +type ( + In struct { + Id network.Uid `json:"id,omitempty"` + T api.PT `json:"t"` + Payload json.RawMessage `json:"p,omitempty"` } - dir := logger.MarkOut + Out struct { + Id network.Uid `json:"id,omitempty"` + T api.PT `json:"t"` + Payload any `json:"p,omitempty"` + } +) + +var ( + EmptyPacket = Out{Payload: ""} + ErrPacket = Out{Payload: "err"} + OkPacket = Out{Payload: "ok"} +) + +type ( + NetClient interface { + Close() + Id() network.Uid + } + RegionalClient interface { + In(region string) bool + } +) + +type SocketClient struct { + NetClient + + id network.Uid + wire *Client + Tag string + Log *logger.Logger +} + +func New(conn *Client, tag string, id network.Uid, log *logger.Logger) SocketClient { + l := log.Extend(log.With().Str("cid", id.Short())) + dir := "→" if conn.IsServer() { - dir = logger.MarkIn + dir = "←" } - dirClLog := log.Extend(log.With(). - Str("cid", id.Short()). - Str(logger.DirectionField, dir), - ) - dirClLog.Debug().Msg("Connect") - return &SocketClient[T, P, X, P2]{sock: conn, id: id, log: dirClLog} + l.Debug().Str("c", tag).Str("d", dir).Msg("Connect") + return SocketClient{id: id, wire: conn, Tag: tag, Log: l} } -func (c *SocketClient[T, P, _, _]) ProcessPackets(fn func(in P) error) chan struct{} { - c.rpc = NewRPC[T, P]() - c.rpc.Handler = func(p P) { - c.log.Debug().Str(logger.DirectionField, logger.MarkIn).Msgf("%v", p.GetType()) - if err := fn(p); err != nil { // 3rd handler - c.log.Error().Err(err).Send() +func (c *SocketClient) SetId(id network.Uid) { c.id = id } + +func (c *SocketClient) OnPacket(fn func(p In) error) { + logFn := func(p In) { + c.Log.Debug().Str("c", c.Tag).Str("d", "←").Msgf("%s", p.T) + if err := fn(p); err != nil { + c.Log.Error().Err(err).Send() } } - c.sock.conn.SetMessageHandler(c.handleMessage) // 1st handler - return c.sock.conn.Listen() -} - -func (c *SocketClient[T, P, X, P2]) SetErrorHandler(h func(error)) { c.sock.conn.SetErrorHandler(h) } - -func (c *SocketClient[T, P, X, P2]) SetMaxMessageSize(s int64) { c.sock.conn.SetMaxMessageSize(s) } - -func (c *SocketClient[_, _, _, _]) handleMessage(message []byte, err error) { - if err != nil { - c.log.Error().Err(err).Send() - return - } - if err = c.rpc.handleMessage(message); err != nil { // 2nd handler - c.log.Error().Err(err).Send() - return - } -} - -func (c *SocketClient[_, P, X, P2]) Route(in P, out P2) { - rq := P2(new(X)) - rq.SetId(in.GetId().String()) - rq.SetType(uint8(in.GetType())) - rq.SetPayload(out.GetPayload()) - if err := c.rpc.Send(c.sock.conn, rq); err != nil { - c.log.Error().Err(err).Msgf("message route fail") - } + c.wire.OnPacket(logFn) } // Send makes a blocking call. -func (c *SocketClient[T, P, X, P2]) Send(t T, data any) ([]byte, error) { - c.log.Debug().Str(logger.DirectionField, logger.MarkOut).Msgf("ᵇ%v", t) - rq := P2(new(X)) - rq.SetType(uint8(t)) - rq.SetPayload(data) - return c.rpc.Call(c.sock.conn, rq) +func (c *SocketClient) Send(t api.PT, data any) ([]byte, error) { + c.Log.Debug().Str("c", c.Tag).Str("d", "→").Msgf("ᵇ%s", t) + return c.wire.Call(t, data) } // Notify just sends a message and goes further. -func (c *SocketClient[T, P, X, P2]) Notify(t T, data any) { - c.log.Debug().Str(logger.DirectionField, logger.MarkOut).Msgf("%v", t) - rq := P2(new(X)) - rq.SetType(uint8(t)) - rq.SetPayload(data) - if err := c.rpc.Send(c.sock.conn, rq); err != nil { - c.log.Error().Err(err).Msgf("notify fail") - } +func (c *SocketClient) Notify(t api.PT, data any) { + c.Log.Debug().Str("c", c.Tag).Str("d", "→").Msgf("%s", t) + _ = c.wire.Send(t, data) } -func (c *SocketClient[_, _, _, _]) Disconnect() { - c.sock.conn.Close() - c.rpc.Cleanup() - c.log.Debug().Str(logger.DirectionField, logger.MarkCross).Msg("Close") +func (c *SocketClient) Close() { + c.wire.Close() + c.Log.Debug().Str("c", c.Tag).Str("d", "x").Msg("Close") } -func (c *SocketClient[_, _, _, _]) Id() Uid { return c.id } -func (c *SocketClient[_, _, _, _]) String() string { return c.Id().String() } +func (c *SocketClient) Id() network.Uid { return c.id } +func (c *SocketClient) Listen() { c.ProcessMessages(); <-c.Done() } +func (c *SocketClient) ProcessMessages() { c.wire.Listen() } +func (c *SocketClient) Route(in In, out Out) { _ = c.wire.Route(in, out) } +func (c *SocketClient) String() string { return c.Tag + ":" + string(c.Id()) } +func (c *SocketClient) Done() chan struct{} { return c.wire.Wait() } diff --git a/pkg/com/map.go b/pkg/com/map.go index ce2c5cd5..ae45a21c 100644 --- a/pkg/com/map.go +++ b/pkg/com/map.go @@ -1,127 +1,98 @@ package com import ( - "fmt" - "iter" + "errors" "sync" + + "github.com/giongto35/cloud-game/v2/pkg/network" ) -// Map defines a concurrent-safe map structure. -// Keep in mind that the underlying map structure will grow indefinitely. -type Map[K comparable, V any] struct { - m map[K]V - mu sync.RWMutex +// NetMap defines a thread-safe NetClient list. +type NetMap[T NetClient] struct { + m map[string]T + mu sync.Mutex } -func (m *Map[K, _]) Len() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.m) -} +// ErrNotFound is returned by NetMap when some value is not present. +var ErrNotFound = errors.New("not found") -func (m *Map[K, _]) Has(key K) bool { - m.mu.RLock() - _, ok := m.m[key] - m.mu.RUnlock() - return ok -} +func NewNetMap[T NetClient]() NetMap[T] { return NetMap[T]{m: make(map[string]T, 10)} } -// Get returns the value and exists flag (standard map comma-ok idiom). -func (m *Map[K, V]) Get(key K) (V, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - val, ok := m.m[key] - return val, ok -} +// Add adds a new NetClient value with its id value as the key. +func (m *NetMap[T]) Add(client T) { m.Put(string(client.Id()), client) } -func (m *Map[K, V]) Find(key K) V { - v, _ := m.Get(key) - return v -} - -func (m *Map[K, V]) String() string { - m.mu.RLock() - defer m.mu.RUnlock() - return fmt.Sprintf("%v", m.m) -} - -// FindBy searches for the first value satisfying the predicate. -// Note: This holds a Read Lock during iteration. -func (m *Map[K, V]) FindBy(predicate func(v V) bool) (V, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - for _, v := range m.m { - if predicate(v) { - return v, true - } - } - var zero V - return zero, false -} - -// Put sets the value and returns true if the key already existed. -func (m *Map[K, V]) Put(key K, v V) bool { +// Put adds a new NetClient value with a custom key value. +func (m *NetMap[T]) Put(key string, client T) { m.mu.Lock() - defer m.mu.Unlock() - - if m.m == nil { - m.m = make(map[K]V) - } - - _, exists := m.m[key] - m.m[key] = v - return exists + m.m[key] = client + m.mu.Unlock() } -func (m *Map[K, V]) Remove(key K) { +// Remove removes NetClient from the map if present. +func (m *NetMap[T]) Remove(client T) { m.RemoveByKey(string(client.Id())) } + +// RemoveByKey removes NetClient from the map by a specified key value. +func (m *NetMap[T]) RemoveByKey(key string) { m.mu.Lock() delete(m.m, key) m.mu.Unlock() } -// Pop returns the value and removes it from the map. -// Returns zero value if not found. -func (m *Map[K, V]) Pop(key K) V { +// RemoveAll removes all occurrences of specified NetClient. +func (m *NetMap[T]) RemoveAll(client T) { m.mu.Lock() defer m.mu.Unlock() - - val, ok := m.m[key] - if ok { - delete(m.m, key) - } - return val -} - -// RemoveL removes the key and returns the new length of the map. -func (m *Map[K, _]) RemoveL(key K) int { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.m, key) - return len(m.m) -} - -// Clear empties the map. -func (m *Map[K, V]) Clear() { - m.mu.Lock() - m.m = make(map[K]V) - m.mu.Unlock() -} - -// Values returns an iterator for values only. -// -// Usage: for k, v := range m.Values() { ... } -// -// Warning: This holds a Read Lock (RLock) during iteration. -// Do not call Put/Remove on this map inside the loop (Deadlock). -func (m *Map[K, V]) Values() iter.Seq[V] { - return func(yield func(V) bool) { - m.mu.RLock() - defer m.mu.RUnlock() - - for _, v := range m.m { - if !yield(v) { - return - } + for k, c := range m.m { + if c.Id() == client.Id() { + delete(m.m, k) } } } + +func (m *NetMap[T]) IsEmpty() bool { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.m) == 0 +} + +// List returns the current NetClient map. +func (m *NetMap[T]) List() map[string]T { return m.m } + +func (m *NetMap[T]) Has(id network.Uid) bool { + _, err := m.Find(string(id)) + return err == nil +} + +// Find searches the first NetClient by a specified key value. +func (m *NetMap[T]) Find(key string) (client T, err error) { + if key == "" { + return client, ErrNotFound + } + m.mu.Lock() + defer m.mu.Unlock() + if c, ok := m.m[key]; ok { + return c, nil + } + return client, ErrNotFound +} + +// FindBy searches the first NetClient with the provided predicate function. +func (m *NetMap[T]) FindBy(fn func(c T) bool) (client T, err error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, w := range m.m { + if fn(w) { + return w, nil + } + } + return client, ErrNotFound +} + +// ForEach processes every NetClient with the provided callback function. +func (m *NetMap[T]) ForEach(fn func(c T)) { + m.mu.Lock() + defer m.mu.Unlock() + for _, w := range m.m { + fn(w) + } +} diff --git a/pkg/com/map_test.go b/pkg/com/map_test.go index 15af76c4..4c1aea94 100644 --- a/pkg/com/map_test.go +++ b/pkg/com/map_test.go @@ -1,63 +1,32 @@ package com -import "testing" +import ( + "fmt" + "sync/atomic" + "testing" -func TestMap_Base(t *testing.T) { - // map map - m := Map[int, int]{m: make(map[int]int)} + "github.com/giongto35/cloud-game/v2/pkg/network" +) - if m.Len() > 0 { - t.Errorf("should be empty, %v %v", m.Len(), m.m) - } - k := 0 - m.Put(k, 0) - if m.Len() == 0 { - t.Errorf("should not be empty, %v", m.m) - } - if !m.Has(k) { - t.Errorf("should have the key %v, %v", k, m.m) - } - v, ok := m.Get(k) - if v != 0 && !ok { - t.Errorf("should have the key %v and ok, %v %v", k, ok, m.m) - } - _, ok = m.Get(k + 1) - if ok { - t.Errorf("should not find anything, %v %v", ok, m.m) - } - m.Put(1, 1) - v, ok = m.FindBy(func(v int) bool { return v == 1 }) - if v != 1 && !ok { - t.Errorf("should have the key %v and ok, %v %v", 1, ok, m.m) - } - sum := 0 - for v := range m.Values() { - sum += v - } - if sum != 1 { - t.Errorf("shoud have exact sum of 1, but have %v", sum) - } - m.Remove(1) - if !m.Has(0) || m.Len() > 1 { - t.Errorf("should remove only one element, but has %v", m.m) - } - m.Put(3, 3) - v = m.Pop(3) - if v != 3 { - t.Errorf("should have value %v, but has %v %v", 3, v, m.m) - } - m.Remove(3) - m.Remove(0) - if m.Len() != 0 { - t.Errorf("should be completely empty, but %v", m.m) - } +type testClient struct { + NetClient + id int + c int32 } -func TestMap_Concurrency(t *testing.T) { - m := Map[int, int]{m: make(map[int]int)} - for i := range 100 { - go m.Put(i, i) - go m.Has(i) - go m.Pop(i) +func (t *testClient) Id() network.Uid { return network.Uid(fmt.Sprintf("%v", t.id)) } +func (t *testClient) change(n int) { atomic.AddInt32(&t.c, int32(n)) } + +func TestPointerValue(t *testing.T) { + m := NewNetMap[*testClient]() + c := testClient{id: 1} + m.Add(&c) + fc, _ := m.FindBy(func(c *testClient) bool { return c.id == 1 }) + c.change(100) + fc2, _ := m.Find(fc.Id().String()) + + expected := c.c == fc.c && c.c == fc2.c + if !expected { + t.Errorf("not expected change, o: %v != %v != %v", c.c, fc.c, fc2.c) } } diff --git a/pkg/com/net.go b/pkg/com/net.go index 722ce9b5..5fee5938 100644 --- a/pkg/com/net.go +++ b/pkg/com/net.go @@ -2,177 +2,186 @@ package com import ( "errors" - "fmt" "net/http" "net/url" + "sync" "time" - "github.com/giongto35/cloud-game/v3/pkg/network/websocket" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/network/websocket" "github.com/goccy/go-json" - "github.com/rs/xid" ) -type Uid struct { - xid.ID -} - -var NilUid = Uid{xid.NilID()} - -func NewUid() Uid { return Uid{xid.New()} } - -func UidFromString(id string) (Uid, error) { - x, err := xid.FromString(id) - if err != nil { - return NilUid, err - } - return Uid{x}, nil -} - -func (u Uid) Short() string { return u.String()[:3] + "." + u.String()[len(u.String())-3:] } - -type HasCallId interface { - SetGetId(fmt.Stringer) -} - -type Writer interface { - Write([]byte) -} - -type Packet[T ~uint8] interface { - GetId() Uid - GetType() T - GetPayload() []byte -} - -type Packet2[T any] interface { - SetId(string) - SetType(uint8) - SetPayload(any) - SetGetId(fmt.Stringer) - GetPayload() any - *T // non-interface type constraint element -} - -type Transport interface { - SetMessageHandler(func([]byte, error)) -} - -type RPC[T ~uint8, P Packet[T]] struct { - CallTimeout time.Duration - Handler func(P) - Transport Transport - - calls Map[Uid, *request] -} - -type request struct { - done chan struct{} - err error - response []byte -} - -const DefaultCallTimeout = 10 * time.Second - -var errCanceled = errors.New("canceled") -var errTimeout = errors.New("timeout") - type ( + Connector struct { + tag string + wu *websocket.Upgrader + } Client struct { - websocket.Client + conn *websocket.WS + queue map[network.Uid]*call + onPacket func(packet In) + mu sync.Mutex } - Server struct { - websocket.Server - } - Connection struct { - conn *websocket.Connection + call struct { + done chan struct{} + err error + Response In } + Option = func(c *Connector) ) -func (c *Client) Connect(addr url.URL) (*Connection, error) { return connect(c.Client.Connect(addr)) } +var ( + errConnClosed = errors.New("connection closed") + errTimeout = errors.New("timeout") +) +var outPool = sync.Pool{New: func() any { o := Out{}; return &o }} -func (s *Server) Origin(host string) { s.Upgrader = websocket.NewUpgrader(host) } +func WithOrigin(url string) Option { return func(c *Connector) { c.wu = websocket.NewUpgrader(url) } } +func WithTag(tag string) Option { return func(c *Connector) { c.tag = tag } } -func (s *Server) Connect(w http.ResponseWriter, r *http.Request) (*Connection, error) { - return connect(s.Server.Connect(w, r, nil)) +const callTimeout = 5 * time.Second + +func NewConnector(opts ...Option) *Connector { + c := &Connector{} + for _, opt := range opts { + opt(c) + } + if c.wu == nil { + c.wu = &websocket.DefaultUpgrader + } + return c } -func (c *Connection) IsServer() bool { return c.conn.IsServer() } - -func (c *Connection) SetMaxReadSize(s int64) { c.conn.SetMaxMessageSize(s) } - -func connect(conn *websocket.Connection, err error) (*Connection, error) { +func (co *Connector) NewClientServer(w http.ResponseWriter, r *http.Request, log *logger.Logger) (*SocketClient, error) { + ws, err := co.wu.Upgrade(w, r, nil) if err != nil { return nil, err } - return &Connection{conn: conn}, nil + conn, err := connect(websocket.NewServerWithConn(ws, log)) + if err != nil { + return nil, err + } + c := New(conn, co.tag, network.NewUid(), log) + return &c, nil } -func NewRPC[T ~uint8, P Packet[T]]() *RPC[T, P] { - return &RPC[T, P]{calls: Map[Uid, *request]{m: make(map[Uid]*request, 10)}} +func (co *Connector) NewClient(address url.URL, log *logger.Logger) (*Client, error) { + return connect(websocket.NewClient(address, log)) } -func (t *RPC[_, _]) Send(w Writer, packet any) error { +func connect(conn *websocket.WS, err error) (*Client, error) { + if err != nil { + return nil, err + } + client := &Client{conn: conn, queue: make(map[network.Uid]*call, 1)} + client.conn.OnMessage = client.handleMessage + return client, nil +} + +func (c *Client) IsServer() bool { return c.conn.IsServer() } + +func (c *Client) OnPacket(fn func(packet In)) { c.mu.Lock(); c.onPacket = fn; c.mu.Unlock() } + +func (c *Client) Listen() { c.mu.Lock(); c.conn.Listen(); c.mu.Unlock() } + +func (c *Client) Close() { + // !to handle error + c.conn.Close() + c.drain(errConnClosed) +} + +func (c *Client) Call(type_ api.PT, payload any) ([]byte, error) { + // !to expose channel instead of results + rq := outPool.Get().(*Out) + rq.Id, rq.T, rq.Payload = network.NewUid(), type_, payload + r, err := json.Marshal(rq) + outPool.Put(rq) + if err != nil { + //delete(c.queue, id) + return nil, err + } + + task := &call{done: make(chan struct{})} + c.mu.Lock() + c.queue[rq.Id] = task + c.conn.Write(r) + c.mu.Unlock() + select { + case <-task.done: + case <-time.After(callTimeout): + task.err = errTimeout + } + return task.Response.Payload, task.err +} + +func (c *Client) Send(type_ api.PT, pl any) error { + rq := outPool.Get().(*Out) + rq.Id, rq.T, rq.Payload = "", type_, pl + defer outPool.Put(rq) + return c.SendPacket(rq) +} + +func (c *Client) Route(p In, pl Out) error { + rq := outPool.Get().(*Out) + rq.Id, rq.T, rq.Payload = p.Id, p.T, pl.Payload + defer outPool.Put(rq) + return c.SendPacket(rq) +} + +func (c *Client) SendPacket(packet *Out) error { r, err := json.Marshal(packet) if err != nil { return err } - w.Write(r) + c.mu.Lock() + c.conn.Write(r) + c.mu.Unlock() return nil } -func (t *RPC[_, _]) Call(w Writer, rq HasCallId) ([]byte, error) { - id := NewUid() - // set new request id for the external request structure as string - rq.SetGetId(id) +func (c *Client) Wait() chan struct{} { return c.conn.Done } - r, err := json.Marshal(rq) +func (c *Client) handleMessage(message []byte, err error) { if err != nil { - return nil, err + return } - task := &request{done: make(chan struct{})} - t.calls.Put(id, task) - w.Write(r) - select { - case <-task.done: - case <-time.After(t.callTimeout()): - task.err = errTimeout - } - return task.response, task.err -} -func (t *RPC[_, P]) handleMessage(message []byte) error { - res := *new(P) - if err := json.Unmarshal(message, &res); err != nil { - return err + var res In + if err = json.Unmarshal(message, &res); err != nil { + return } - // if we have an id, then unblock blocking call with that id - id := res.GetId() - if id != NilUid { - if blocked := t.calls.Pop(id); blocked != nil { - blocked.response = res.GetPayload() - close(blocked.done) - return nil + + // empty id implies that we won't track (wait) the response + if !res.Id.Empty() { + if task := c.pop(res.Id); task != nil { + task.Response = res + close(task.done) + return } } - if t.Handler != nil { - t.Handler(res) - } - return nil + c.onPacket(res) } -func (t *RPC[_, _]) callTimeout() time.Duration { - if t.CallTimeout > 0 { - return t.CallTimeout - } - return DefaultCallTimeout +// pop extracts and removes a task from the queue by its id. +func (c *Client) pop(id network.Uid) *call { + c.mu.Lock() + task := c.queue[id] + delete(c.queue, id) + c.mu.Unlock() + return task } -func (t *RPC[_, _]) Cleanup() { - // drain cancels all what's left in the task queue. - for task := range t.calls.Values() { +// drain cancels all what's left in the task queue. +func (c *Client) drain(err error) { + c.mu.Lock() + for _, task := range c.queue { if task.err == nil { - task.err = errCanceled + task.err = err } close(task.done) } + c.mu.Unlock() } diff --git a/pkg/com/net_test.go b/pkg/com/net_test.go index 2e0a6fc5..ca547bca 100644 --- a/pkg/com/net_test.go +++ b/pkg/com/net_test.go @@ -2,41 +2,29 @@ package com import ( "encoding/json" - "fmt" - "math/rand/v2" - "net" + "math/rand" "net/http" "net/url" "sync" "testing" "time" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/network/websocket" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network/websocket" ) -type TestIn struct { - Id Uid - T uint8 - Payload json.RawMessage +var log = logger.Default() + +func TestPackets(t *testing.T) { + r, err := json.Marshal(Out{Payload: "asd"}) + if err != nil { + t.Fatalf("can't marshal packet") + } + + t.Logf("PACKET: %v", string(r)) } -func (i TestIn) GetId() Uid { return i.Id } -func (i TestIn) GetType() uint8 { return i.T } -func (i TestIn) GetPayload() []byte { return i.Payload } - -type TestOut struct { - Id string - T uint8 - Payload any -} - -func (o *TestOut) SetId(s string) { o.Id = s } -func (o *TestOut) SetType(u uint8) { o.T = u } -func (o *TestOut) SetPayload(a any) { o.Payload = a } -func (o *TestOut) SetGetId(stringer fmt.Stringer) { o.Id = stringer.String() } -func (o *TestOut) GetPayload() any { return o.Payload } - func TestWebsocket(t *testing.T) { testCases := []struct { name string @@ -50,100 +38,108 @@ func TestWebsocket(t *testing.T) { } func testWebsocket(t *testing.T) { - port, err := getFreePort() - if err != nil { - t.Logf("couldn't get any free port") - t.Skip() - } - addr := fmt.Sprintf(":%v", port) + // setup + // socket handler + var socket *websocket.WS + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.DefaultUpgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("no socket, %v", err) + } + sock, err := websocket.NewServerWithConn(conn, log) + if err != nil { + t.Fatalf("couldn't init socket server") + } + socket = sock + socket.OnMessage = func(message []byte, err error) { + // echo response + socket.Write(message) + } + socket.Listen() + }) + // http handler + var wg sync.WaitGroup + wg.Add(1) + go func() { + wg.Done() + if err := http.ListenAndServe(":8080", nil); err != nil { + t.Errorf("no server") + return + } + }() + wg.Wait() - server := newServer(addr, t) - client := newClient(t, url.URL{Scheme: "ws", Host: "localhost" + addr, Path: "/ws"}) - clDone := client.ProcessPackets(func(in TestIn) error { return nil }) - - if server.conn == nil { - t.Fatalf("couldn't make new socket") - } + client := newClient(t, url.URL{Scheme: "ws", Host: "localhost:8080", Path: "/ws"}) + client.Listen() calls := []struct { - packet TestOut + typ api.PT + payload any concurrent bool value any }{ - {packet: TestOut{T: 10, Payload: "test"}, value: "test", concurrent: true}, - {packet: TestOut{T: 10, Payload: "test2"}, value: "test2"}, - {packet: TestOut{T: 11, Payload: "test3"}, value: "test3"}, - {packet: TestOut{T: 99, Payload: ""}, value: ""}, - {packet: TestOut{T: 0}}, - {packet: TestOut{T: 12, Payload: 123}, value: 123}, - {packet: TestOut{T: 10, Payload: false}, value: false}, - {packet: TestOut{T: 10, Payload: true}, value: true}, - {packet: TestOut{T: 11, Payload: []string{"test", "test", "test"}}, value: []string{"test", "test", "test"}}, - {packet: TestOut{T: 22, Payload: []string{}}, value: []string{}}, + {typ: 10, payload: "test", value: "test", concurrent: true}, + {typ: 10, payload: "test2", value: "test2"}, + {typ: 11, payload: "test3", value: "test3"}, + {typ: 99, payload: "", value: ""}, + {typ: 0}, + {typ: 12, payload: 123, value: 123}, + {typ: 10, payload: false, value: false}, + {typ: 10, payload: true, value: true}, + {typ: 11, payload: []string{"test", "test", "test"}, value: []string{"test", "test", "test"}}, + {typ: 22, payload: []string{}, value: []string{}}, } - const n = 42 + rand.Seed(time.Now().UnixNano()) + + n := 42 * 2 * 2 var wait sync.WaitGroup wait.Add(n * len(calls)) // test for _, call := range calls { - if call.concurrent { - for range n { - packet := call.packet + for i := 0; i < n; i++ { + if call.concurrent { + call := call go func() { - defer wait.Done() - time.Sleep(time.Duration(rand.IntN(200-100)+100) * time.Millisecond) - vv, err := client.rpc.Call(client.sock.conn, &packet) - err = checkCall(vv, err, call.value) - if err != nil { - t.Errorf("%v", err) - return - } + w := rand.Intn(600-100) + 100 + time.Sleep(time.Duration(w) * time.Millisecond) + vv, err := client.Call(call.typ, call.payload) + checkCall(t, vv, err, call.value) + wait.Done() }() - } - } else { - for range n { - packet := call.packet - vv, err := client.rpc.Call(client.sock.conn, &packet) - err = checkCall(vv, err, call.value) - if err != nil { - wait.Done() - t.Fatalf("%v", err) - } else { - wait.Done() - } + } else { + vv, err := client.Call(call.typ, call.payload) + checkCall(t, vv, err, call.value) + wait.Done() } } } wait.Wait() - client.sock.conn.Close() - client.rpc.Cleanup() - <-clDone - server.conn.Close() - <-server.done + client.Close() + + <-socket.Done + <-client.conn.Done } -func newClient(t *testing.T, addr url.URL) *SocketClient[uint8, TestIn, TestOut, *TestOut] { - connector := Client{} - conn, err := connector.Connect(addr) +func newClient(t *testing.T, addr url.URL) *Client { + conn, err := NewConnector().NewClient(addr, log) if err != nil { t.Fatalf("error: couldn't connect to %v because of %v", addr.String(), err) } - rpc := new(RPC[uint8, TestIn]) - rpc.calls = Map[Uid, *request]{m: make(map[Uid]*request, 10)} - return &SocketClient[uint8, TestIn, TestOut, *TestOut]{sock: conn, log: logger.Default(), rpc: rpc} + return conn } -func checkCall(v []byte, err error, need any) error { +func checkCall(t *testing.T, v []byte, err error, need any) { if err != nil { - return err + t.Fatalf("should be no error but %v", err) + return } var value any if v != nil { if err = json.Unmarshal(v, &value); err != nil { - return fmt.Errorf("can't unmarshal %v", v) + t.Fatalf("can't unmarshal %v", v) } } @@ -156,8 +152,6 @@ func checkCall(v []byte, err error, need any) error { nice = value == need.(bool) case float64: nice = value == float64(need.(int)) - case string: - nice = value == need.(string) case []any: // let's assume that's strings vv := value.([]any) @@ -172,54 +166,6 @@ func checkCall(v []byte, err error, need any) error { } if !nice { - return fmt.Errorf("expected %v, but got %v", need, v) - } - return nil -} - -type serverHandler struct { - conn *websocket.Connection // ws server reference made dynamically on HTTP request - done chan struct{} -} - -func (s *serverHandler) serve(t *testing.T) func(w http.ResponseWriter, r *http.Request) { - connector := Server{} - - return func(w http.ResponseWriter, r *http.Request) { - sock, err := connector.Server.Connect(w, r, nil) - if err != nil { - t.Fatalf("couldn't init socket server") - } - s.conn = sock - s.conn.SetMessageHandler(func(m []byte, err error) { s.conn.Write(m) }) // echo - s.done = s.conn.Listen() + t.Fatalf("expected %v is not expected %v", need, v) } } - -func newServer(addr string, t *testing.T) *serverHandler { - var wg sync.WaitGroup - handler := serverHandler{} - http.HandleFunc("/ws", handler.serve(t)) - wg.Add(1) - go func() { - wg.Done() - if err := http.ListenAndServe(addr, nil); err != nil { - t.Errorf("no server, %v", err) - return - } - }() - wg.Wait() - return &handler -} - -func getFreePort() (port int, err error) { - var a *net.TCPAddr - var l *net.TCPListener - if a, err = net.ResolveTCPAddr("tcp", ":0"); err == nil { - if l, err = net.ListenTCP("tcp", a); err == nil { - defer func() { _ = l.Close() }() - return l.Addr().(*net.TCPAddr).Port, nil - } - } - return -} diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml deleted file mode 100644 index c6332696..00000000 --- a/pkg/config/config.yaml +++ /dev/null @@ -1,446 +0,0 @@ -# The main config file - -# Note. -# Be aware that when this configuration is being overwritten -# by another configuration, any empty nested part -# in the further configurations will reset (empty out) all the values. -# For example: -# the main config second config result -# ... ... ... -# list: list: list: -# gba: gba: gba: -# lib: mgba_libretro lib: "" -# roms: [ "gba", "gbc" ] roms: [] -# ... ... -# -# So do not leave empty nested keys. - -# for the compatibility purposes -version: 3 - -# new decentralized library of games -library: - # optional alias file for overriding game names from the basePath path - aliasFile: alias.txt - # root folder for the library (where games are stored) - basePath: assets/games - # a list of ignored words in the ROM filenames - ignored: - - neogeo - - pgm - # DOSBox filesystem state - - .pure - # an explicit list of supported file extensions - # which overrides Libretro emulator ROMs configs - supported: - # print some additional info - verbose: true - # enable library directory live reload - # (experimental) - watchMode: false - -coordinator: - # debugging switch - # - shows debug logs - # - allows selecting worker instances - debug: false - # selects free workers: - # - empty value (default, any free) - # - ping (with the lowest ping) - selector: - monitoring: - port: 6601 - # enable Go profiler HTTP server - profilingEnabled: false - metricEnabled: false - urlPrefix: /coordinator - # a custom Origins for incoming Websocket connections: - # "" -- checks same origin policy - # "*" -- allows all - # "your address" -- checks for that address - origin: - userWs: - workerWs: - # max websocket message size in bytes - maxWsSize: 32000000 - # HTTP(S) server config - server: - address: :8000 - cacheControl: "max-age=259200, must-revalidate" - frameOptions: "" - https: false - # Letsencrypt or self cert config - tls: - address: :443 - # allowed host name - domain: - # if both are set then will use certs - # and Letsencryt instead - httpsCert: - httpsKey: - analytics: - inject: false - gtag: - -worker: - # show more logs - debug: false - library: - # root folder for the library (where games are stored) - basePath: assets/games - network: - # a coordinator address to connect to - coordinatorAddress: localhost:8000 - # where to connect - endpoint: /wso - # ping endpoint - pingEndpoint: /echo - # set public ping address (IP or hostname) - publicAddress: - # make coordinator connection secure (wss) - secure: false - # ISO Alpha-2 country code to group workers by zones - zone: - monitoring: - # monitoring server port - port: 6602 - profilingEnabled: false - # monitoring server URL prefix - metricEnabled: false - urlPrefix: /worker - server: - address: :9000 - https: false - tls: - address: :444 - # LetsEncrypt config - # allowed host name - domain: - # Own certs config - httpsCert: - httpsKey: - # optional server tag - tag: - -emulator: - # set the total number of threads for the image processing - # (removed) - threads: 0 - - # enable autosave for emulator states if set to a non-zero value of seconds - autosaveSec: 0 - - # save directory for emulator states - # special tag {user} will be replaced with current user's home dir - storage: "{user}/.cr/save" - - # path for storing emulator generated files - localPath: "./libretro" - - # checks if the system supports running an emulator at startup - failFast: true - - # do not send late video frames - skipLateFrames: false - - # log dropped frames (temp) - logDroppedFrames: false - - libretro: - # use zip compression for emulator save states - saveCompression: true - # Sets a limiter function for some spammy core callbacks. - # 0 - disabled, otherwise -- time in milliseconds for ignoring repeated calls except the last. - debounceMs: 0 - # Allow duplicate frames - dup: true - # Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX - logLevel: 1 - cores: - paths: - libs: assets/cores - # Config params for Libretro cores repository, - # available types are: - # - buildbot (the default Libretro nightly repository) - # - github (GitHub raw repository with a similar structure to buildbot) - # - raw (just a link to a zip file extracted as is) - repo: - # enable auto-download for the list of cores (list->lib) - sync: true - # external cross-process mutex lock - extLock: "{user}/.cr/cloud-game.lock" - map: - darwin: - amd64: - arch: x86_64 - ext: .dylib - os: osx - vendor: apple - arm64: - arch: arm64 - ext: .dylib - os: osx - vendor: apple - linux: - amd64: - arch: x86_64 - ext: .so - os: linux - arm: - arch: armv7-neon-hf - ext: .so - os: linux - windows: - amd64: - arch: x86_64 - ext: .dll - os: windows - main: - type: buildbot - url: https://buildbot.libretro.com/nightly - # if repo has file compression - compression: zip - # a secondary repo to use i.e. for not found in the main cores - secondary: - type: github - url: https://github.com/sergystepanov/libretro-spiegel/raw/main - compression: zip - # Libretro core configuration - # - # The emulator selection will happen in this order: - # - based on the folder name in the folder param - # - based on the folder name (core name) in the list (i.e. nes, snes) - # - based on the rom names in the roms param - # - # Available config params: - # - altRepo (bool) prioritize secondary repo as the download source - # - lib (string) - # - roms ([]string) - # - scale (int) scales the output video frames by this factor. - # - folder (string) - # By default emulator selection is based on the folder named as cores - # in the list (i.e. nes, snes) but if you specify folder param, - # then it will try to load the ROM file from that folder first. - # - width (int) -- broken - # - height (int) -- broken - # - ratio (float) - # - isGlAllowed (bool) - # - usesLibCo (bool) - # - hasMultitap (bool) -- (removed) - # - coreAspectRatio (bool) -- (deprecated) correct the aspect ratio on the client with the info from the core. - # - hid (map[int][]int) - # A list of device IDs to bind to the input ports. - # Can be seen in human readable form in the console when worker.debug is enabled. - # Some cores allow binding multiple devices to a single port (DosBox), but typically, - # you should bind just one device to one port. - # - kbMouseSupport (bool) -- (temp) a flag if the core needs the keyboard and mouse on the client - # - nonBlockingSave (bool) -- write save file in a non-blocking way, needed for huge save files - # - vfr (bool) - # (experimental) - # Enable variable frame rate only for cores that can't produce a constant frame rate. - # By default, we assume that cores output frames at a constant rate which equals - # their tick rate (1/system FPS), but OpenGL cores like N64 may have significant - # frame rendering time inconsistencies. In general, VFR for CFR cores leads to - # noticeable video stutter (with the current frame rendering time calculations). - # - options ([]string) a list of Libretro core options for tweaking. - # All keys of the options should be in the double quotes in order to preserve upper-case symbols. - # - options4rom (rom[[]string]) - # A list of core options to override for a specific core depending on the current ROM name. - # - hacks ([]string) a list of hacks. - # Available: - # - skip_hw_context_destroy -- don't destroy OpenGL context during Libretro core deinit. - # May help with crashes, for example, with PPSSPP. - # - skip_same_thread_save -- skip thread lock save (used with PPSSPP). - # - uniqueSaveDir (bool) -- needed only for cores (like DosBox) that persist their state into one shared file. - # This will allow for concurrent reading and saving of current states. - # - saveStateFs (string) -- the name of the file that will be initially copied into the save folder. - # All * symbols will be replaced to the name of the ROM. - list: - gba: - lib: mgba_libretro - roms: [ "gba", "gbc" ] - options: - mgba_audio_low_pass_filter: disabled - mgba_audio_low_pass_range: 50 - pcsx: - lib: pcsx_rearmed_libretro - roms: [ "cue", "chd" ] - # example of folder override - folder: psx - # see: https://github.com/libretro/pcsx_rearmed/blob/master/frontend/libretro_core_options.h - options: - "pcsx_rearmed_show_bios_bootlogo": enabled - "pcsx_rearmed_drc": enabled - "pcsx_rearmed_display_internal_fps": disabled - # MAME core requires additional manual setup, please read: - # https://docs.libretro.com/library/fbneo/ - mame: - lib: fbneo_libretro - folder: mame - roms: [ "zip" ] - nes: - lib: nestopia_libretro - roms: [ "nes" ] - options: - nestopia_aspect: "uncorrected" - snes: - lib: snes9x_libretro - roms: [ "smc", "sfc", "swc", "fig", "bs" ] - hid: - # set the 2nd port to RETRO_DEVICE_JOYPAD_MULTITAP ((1<<8) | 1) as SNES9x requires it - # in order to support up to 5-player games - # see: https://nintendo.fandom.com/wiki/Super_Multitap - 1: 257 - n64: - lib: mupen64plus_next_libretro - roms: [ "n64", "v64", "z64" ] - isGlAllowed: true - usesLibCo: true - vfr: true - # see: https://github.com/libretro/mupen64plus-libretro-nx/blob/master/libretro/libretro_core_options.h - options: - "mupen64plus-169screensize": 640x360 - "mupen64plus-43screensize": 320x240 - "mupen64plus-EnableCopyColorToRDRAM": Off - "mupen64plus-EnableCopyDepthToRDRAM": Off - "mupen64plus-EnableEnhancedTextureStorage": True - "mupen64plus-EnableFBEmulation": True - "mupen64plus-EnableLegacyBlending": True - "mupen64plus-FrameDuping": True - "mupen64plus-MaxTxCacheSize": 8000 - "mupen64plus-ThreadedRenderer": False - "mupen64plus-cpucore": dynamic_recompiler - "mupen64plus-pak1": memory - "mupen64plus-rdp-plugin": gliden64 - "mupen64plus-rsp-plugin": hle - "mupen64plus-astick-sensitivity": 100 - dos: - lib: dosbox_pure_libretro - roms: [ "zip", "cue" ] - folder: dos - kbMouseSupport: true - nonBlockingSave: true - saveStateFs: "*.pure.zip" - hid: - 0: [ 257, 513 ] - 1: [ 257, 513 ] - 2: [ 257, 513 ] - 3: [ 257, 513 ] - options: - "dosbox_pure_conf": "outside" - "dosbox_pure_force60fps": "true" - -encoder: - audio: - # audio frame duration needed for WebRTC (Opus) - # most of the emulators have ~1400 samples per a video frame, - # so we keep the frame buffer roughly half of that size or 2 RTC packets per frame - # (deprecated) due to frames - frame: 10 - # dynamic frames for Opus encoder - frames: - - 10 - - 5 - # speex (2), linear (1) or nearest neighbour (0) audio resampler - # linear should sound slightly better than 0 - resampler: 2 - video: - # h264, vpx (vp8) or vp9 - codec: h264 - # Threaded encoder if supported, 0 - auto, 1 - nope, >1 - multi-threaded - threads: 0 - # see: https://trac.ffmpeg.org/wiki/Encode/H.264 - h264: - # crf, cbr - mode: crf - # Constant Rate Factor (CRF) 0-51 (default: 23) - crf: 23 - # Rate control options - # set the maximum bitrate - maxRate: 0 - # set the expected client buffer size - bufSize: 0 - # ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo - preset: superfast - # baseline, main, high, high10, high422, high444 - profile: baseline - # film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency - tune: zerolatency - # 0-3 - logLevel: 0 - # see: https://www.webmproject.org/docs/encoder-parameters - vpx: - # target bitrate (KBit/s) - bitrate: 1200 - # force keyframe interval - keyframeInterval: 5 - -# game recording -# (experimental) -# recording allows export RAW a/v streams of games -# by default, it will export audio as WAV files, -# video as a list of PNG-encoded images, and -# one additional FFMPEG concat demux file -recording: - enabled: false - # name contains the name of the recording dir (or zip) - # format: - # %date:go_time_format% -- refer: https://go.dev/src/time/format.go - # %user% -- user name who started the recording - # %game% -- game name (game ROM name) - # %rand:len% -- a random string of given length - # as example: 20210101101010_yeE_user1_badApple - name: "%date:20060102150405%_%rand:3%_%user%_%game%" - # zip and remove recording dir on completion - zip: true - # save directory - folder: ./recording - -# cloud storage options -# it is mandatory to use a cloud storage when running -# a distributed multi-server configuration in order to -# share save states between nodes (resume games on a different worker) -storage: - # cloud storage provider: - # - empty (No op storage stub) - # - s3 (S3 API compatible object storage) - provider: - s3Endpoint: - s3BucketName: - s3AccessKeyId: - s3SecretAccessKey: - -webrtc: - # turn off default Pion interceptors (see: https://github.com/pion/interceptor) - # (performance) - disableDefaultInterceptors: false - # indicates the role of the DTLS transport (see: https://github.com/pion/webrtc/blob/master/dtlsrole.go) - # (debug) - # - (default) - # - 1 (auto) - # - 2 (client) - # - 3 (server) - dtlsRole: - # a list of STUN/TURN servers to use - iceServers: - - urls: stun:stun.l.google.com:19302 - # configures whether the ice agent should be a lite agent (true/false) - # (performance) - # don't use iceServers when enabled - iceLite: false - # ICE configuration - # by default, ICE ports are random and unlimited - # alternatives: - # 1. instead of random unlimited port range for - # WebRTC connections, these params limit port range of ICE connections - icePorts: - min: - max: - # 2. select a single port to forward all ICE connections there - singlePort: - # override ICE candidate IP, see: https://github.com/pion/webrtc/issues/835, - # can be used for Docker bridged network internal IP override - iceIpMap: - # set additional log level for WebRTC separately - # -1 - trace, 6 - nothing, ..., debug - 0 - logLevel: 6 diff --git a/pkg/config/coordinator.go b/pkg/config/coordinator.go deleted file mode 100644 index 6a41cce0..00000000 --- a/pkg/config/coordinator.go +++ /dev/null @@ -1,52 +0,0 @@ -package config - -import "flag" - -type CoordinatorConfig struct { - Coordinator Coordinator - Emulator Emulator - Library Library - Recording Recording - Version Version - Webrtc Webrtc -} - -type Coordinator struct { - Analytics Analytics - Debug bool - Library Library - MaxWsSize int64 - Monitoring Monitoring - Origin struct { - UserWs string - WorkerWs string - } - Selector string - Server Server -} - -// Analytics is optional Google Analytics -type Analytics struct { - Inject bool - Gtag string -} - -const SelectByPing = "ping" - -// allows custom config path -var coordinatorConfigPath string - -func NewCoordinatorConfig() (conf CoordinatorConfig, paths []string) { - paths, err := LoadConfig(&conf, coordinatorConfigPath) - if err != nil { - panic(err) - } - return -} - -func (c *CoordinatorConfig) ParseFlags() { - c.Coordinator.Server.WithFlags() - flag.IntVar(&c.Coordinator.Monitoring.Port, "monitoring.port", c.Coordinator.Monitoring.Port, "Monitoring server port") - flag.StringVar(&coordinatorConfigPath, "c-conf", coordinatorConfigPath, "Set custom configuration file path") - flag.Parse() -} diff --git a/pkg/config/coordinator/config.go b/pkg/config/coordinator/config.go new file mode 100644 index 00000000..892cf4cd --- /dev/null +++ b/pkg/config/coordinator/config.go @@ -0,0 +1,63 @@ +package coordinator + +import ( + "flag" + + "github.com/giongto35/cloud-game/v2/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/config/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/config/shared" + "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" + "github.com/giongto35/cloud-game/v2/pkg/games" +) + +type Config struct { + Coordinator Coordinator + Emulator emulator.Emulator + Recording shared.Recording + Version shared.Version + Webrtc webrtc.Webrtc +} + +type Coordinator struct { + Analytics Analytics + Debug bool + Library games.Config + Monitoring monitoring.Config + Origin struct { + UserWs string + WorkerWs string + } + Selector string + Server shared.Server +} + +// Analytics is optional Google Analytics +type Analytics struct { + Inject bool + Gtag string +} + +const ( + SelectAny = "any" + SelectByPing = "ping" +) + +// allows custom config path +var configPath string + +func NewConfig() (conf Config) { + err := config.LoadConfig(&conf, configPath) + if err != nil { + panic(err) + } + conf.Webrtc.AddIceServersEnv() + return +} + +func (c *Config) ParseFlags() { + c.Coordinator.Server.WithFlags() + flag.IntVar(&c.Coordinator.Monitoring.Port, "monitoring.port", c.Coordinator.Monitoring.Port, "Monitoring server port") + flag.StringVar(&configPath, "c-conf", configPath, "Set custom configuration file path") + flag.Parse() +} diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go deleted file mode 100644 index 6a0ad9bb..00000000 --- a/pkg/config/emulator.go +++ /dev/null @@ -1,154 +0,0 @@ -package config - -import ( - "errors" - "path" - "path/filepath" - "runtime" - "strings" -) - -type Emulator struct { - FailFast bool - Threads int - Storage string - LocalPath string - Libretro LibretroConfig - AutosaveSec int - SkipLateFrames bool - LogDroppedFrames bool -} - -type LibretroConfig struct { - Cores struct { - Paths struct { - Libs string - } - Repo LibretroRemoteRepo - List map[string]LibretroCoreConfig - } - DebounceMs int - Dup bool - SaveCompression bool - LogLevel int -} - -type LibretroRemoteRepo struct { - Sync bool - ExtLock string - Map map[string]map[string]LibretroRepoMapInfo - Main LibretroRepoConfig - Secondary LibretroRepoConfig -} - -// LibretroRepoMapInfo contains Libretro core lib platform info. -// And the cores are just C-compiled libraries. -// See: https://buildbot.libretro.com/nightly. -type LibretroRepoMapInfo struct { - Arch string // bottom: x86_64, x86, ... - Ext string // platform dependent library file extension (dot-prefixed) - Os string // middle: windows, ios, ... - Vendor string // top level: apple, nintendo, ... -} - -type LibretroRepoConfig struct { - Type string - Url string - Compression string -} - -// Guess tries to map OS + CPU architecture to the corresponding remote URL path. -// See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63. -func (lrp LibretroRemoteRepo) Guess() (LibretroRepoMapInfo, error) { - if os, ok := lrp.Map[runtime.GOOS]; ok { - if arch, ok2 := os[runtime.GOARCH]; ok2 { - return arch, nil - } - } - return LibretroRepoMapInfo{}, - errors.New("core mapping not found for " + runtime.GOOS + ":" + runtime.GOARCH) -} - -type LibretroCoreConfig struct { - AltRepo bool - AutoGlContext bool // hack: keep it here to pass it down the emulator - CoreAspectRatio bool - Folder string - Hacks []string - Height int - Hid map[int][]int - IsGlAllowed bool - KbMouseSupport bool - Lib string - NonBlockingSave bool - Options map[string]string - Options4rom map[string]map[string]string // <(^_^)> - Roms []string - SaveStateFs string - Scale float64 - UniqueSaveDir bool - UsesLibCo bool - VFR bool - Width int -} - -type CoreInfo struct { - Id string - Name string - AltRepo bool -} - -// GetLibretroCoreConfig returns a core config with expanded paths. -func (e Emulator) GetLibretroCoreConfig(emulator string) LibretroCoreConfig { - cores := e.Libretro.Cores - conf := cores.List[emulator] - conf.Lib = path.Join(cores.Paths.Libs, conf.Lib) - return conf -} - -// GetEmulator tries to find a suitable emulator. -// !to remove quadratic complexity -func (e Emulator) GetEmulator(rom string, path string) string { - found := "" - for emu, core := range e.Libretro.Cores.List { - for _, romName := range core.Roms { - if rom == romName { - found = emu - if p := strings.SplitN(filepath.ToSlash(path), "/", 2); len(p) > 1 { - folder := p[0] - if (folder != "" && folder == core.Folder) || folder == emu { - return emu - } - } - } - } - } - return found -} - -func (e Emulator) GetSupportedExtensions() []string { - var extensions []string - for _, core := range e.Libretro.Cores.List { - extensions = append(extensions, core.Roms...) - } - return extensions -} - -func (e Emulator) SessionStoragePath() string { - return e.Storage -} - -func (l *LibretroConfig) GetCores() (cores []CoreInfo) { - for k, core := range l.Cores.List { - cores = append(cores, CoreInfo{Id: k, Name: core.Lib, AltRepo: core.AltRepo}) - } - return -} - -func (l *LibretroConfig) GetCoresStorePath() string { - pth, err := filepath.Abs(l.Cores.Paths.Libs) - if err != nil { - return "" - } - return pth -} diff --git a/pkg/config/emulator/config.go b/pkg/config/emulator/config.go new file mode 100644 index 00000000..34aad237 --- /dev/null +++ b/pkg/config/emulator/config.go @@ -0,0 +1,134 @@ +package emulator + +import ( + "math" + "path" + "path/filepath" + "strings" +) + +type Emulator struct { + Scale int + Threads int + AspectRatio AspectRatio + Storage string + LocalPath string + Libretro LibretroConfig + AutosaveSec int +} + +type AspectRatio struct { + Keep bool + Width int + Height int +} + +func (a AspectRatio) ResizeToAspect(ratio float64, sw int, sh int) (dw int, dh int) { + // ratio is always > 0 + dw = int(math.Round(float64(sh)*ratio/2) * 2) + dh = sh + if dw > sw { + dw = sw + dh = int(math.Round(float64(sw)/ratio/2) * 2) + } + return +} + +type LibretroConfig struct { + Cores struct { + Paths struct { + Libs string + Configs string + } + Repo struct { + Sync bool + ExtLock string + Main LibretroRepoConfig + Secondary LibretroRepoConfig + } + List map[string]LibretroCoreConfig + } + SaveCompression bool + LogLevel int +} + +type LibretroRepoConfig struct { + Type string + Url string + Compression string +} + +type LibretroCoreConfig struct { + Lib string + Config string + Roms []string + Folder string + Width int + Height int + IsGlAllowed bool + UsesLibCo bool + HasMultitap bool + AltRepo bool + + // hack: keep it here to pass it down the emulator + AutoGlContext bool +} + +type CoreInfo struct { + Name string + AltRepo bool +} + +// GetLibretroCoreConfig returns a core config with expanded paths. +func (e Emulator) GetLibretroCoreConfig(emulator string) LibretroCoreConfig { + cores := e.Libretro.Cores + conf := cores.List[emulator] + conf.Lib = path.Join(cores.Paths.Libs, conf.Lib) + if conf.Config != "" { + conf.Config = path.Join(cores.Paths.Configs, conf.Config) + } + return conf +} + +// GetEmulator tries to find a suitable emulator. +// !to remove quadratic complexity +func (e Emulator) GetEmulator(rom string, path string) string { + found := "" + for emu, core := range e.Libretro.Cores.List { + for _, romName := range core.Roms { + if rom == romName { + found = emu + if p := strings.SplitN(filepath.ToSlash(path), "/", 2); len(p) > 1 { + folder := p[0] + if (folder != "" && folder == core.Folder) || folder == emu { + return emu + } + } + } + } + } + return found +} + +func (e Emulator) GetSupportedExtensions() []string { + var extensions []string + for _, core := range e.Libretro.Cores.List { + extensions = append(extensions, core.Roms...) + } + return extensions +} + +func (l *LibretroConfig) GetCores() (cores []CoreInfo) { + for _, core := range l.Cores.List { + cores = append(cores, CoreInfo{Name: core.Lib, AltRepo: core.AltRepo}) + } + return +} + +func (l *LibretroConfig) GetCoresStorePath() string { + pth, err := filepath.Abs(l.Cores.Paths.Libs) + if err != nil { + return "" + } + return pth +} diff --git a/pkg/config/emulator_test.go b/pkg/config/emulator/config_test.go similarity index 87% rename from pkg/config/emulator_test.go rename to pkg/config/emulator/config_test.go index 09afd2d4..ebef77c2 100644 --- a/pkg/config/emulator_test.go +++ b/pkg/config/emulator/config_test.go @@ -1,4 +1,4 @@ -package config +package emulator import "testing" @@ -29,17 +29,20 @@ func TestGetEmulator(t *testing.T) { }, { rom: "nes", - path: "test2/game.nes", + path: "test/game.nes", config: map[string]LibretroCoreConfig{ - "snes": {Roms: []string{"snes"}}, + "snes": {Roms: []string{"nes"}}, "nes": {Roms: []string{"nes"}}, }, emulator: "nes", }, } + emu := Emulator{ + Libretro: LibretroConfig{}, + } + for _, test := range tests { - emu := Emulator{Libretro: LibretroConfig{}} emu.Libretro.Cores.List = test.config em := emu.GetEmulator(test.rom, test.path) if test.emulator != em { diff --git a/pkg/config/encoder/config.go b/pkg/config/encoder/config.go new file mode 100644 index 00000000..f0b9426c --- /dev/null +++ b/pkg/config/encoder/config.go @@ -0,0 +1,26 @@ +package encoder + +type Encoder struct { + Audio Audio + Video Video +} + +type Audio struct { + Frame int +} + +type Video struct { + Codec string + Concurrency int + H264 struct { + Crf uint8 + Preset string + Profile string + Tune string + LogLevel int + } + Vpx struct { + Bitrate uint + KeyframeInterval uint + } +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go index a2fb6bd8..96869c79 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -1,163 +1,31 @@ package config import ( - "bytes" - "embed" "os" - "path/filepath" - "strings" - "github.com/knadh/koanf/maps" - "github.com/knadh/koanf/v2" - "gopkg.in/yaml.v3" + "github.com/kkyr/fig" ) -const EnvPrefix = "CLOUD_GAME_" - -var ( - //go:embed config.yaml - conf embed.FS -) - -type Kv = map[string]any -type Bytes []byte - -func (b *Bytes) ReadBytes() ([]byte, error) { return *b, nil } -func (b *Bytes) Read() (Kv, error) { return nil, nil } - -type File string - -func (f *File) ReadBytes() ([]byte, error) { return os.ReadFile(string(*f)) } -func (f *File) Read() (Kv, error) { return nil, nil } - -type YAML struct{} - -func (p *YAML) Marshal(Kv) ([]byte, error) { return nil, nil } -func (p *YAML) Unmarshal(b []byte) (Kv, error) { - var out Kv - klw := keysToLower(b) - decoder := yaml.NewDecoder(bytes.NewReader(klw)) - if err := decoder.Decode(&out); err != nil { - return nil, err - } - return out, nil -} - -// keysToLower iterates YAML bytes and tries to lower the keys. -// Used for merging with environment vars which are lowered as well. -func keysToLower(in []byte) []byte { - l, r, ignore := 0, 0, false - for i, b := range in { - switch b { - case '#': // skip comments - ignore = true - case ':': // lower left chunk before the next : symbol - if ignore { - continue - } - r = i - ignore = true - for j := l; j <= r; j++ { - c := in[j] - // we skip the line with the first explicit " string symbol - if c == '"' { - break - } - if 'A' <= c && c <= 'Z' { - in[j] += 'a' - 'A' - } - } - case '\n': - l = i - ignore = false - } - } - return in -} - -type Env string - -func (e *Env) ReadBytes() ([]byte, error) { return nil, nil } -func (e *Env) Read() (Kv, error) { - var keys []string - for _, k := range os.Environ() { - if strings.HasPrefix(k, string(*e)) { - keys = append(keys, k) - } - } - mp := make(Kv) - for _, k := range keys { - parts := strings.SplitN(k, "=", 2) - if parts == nil { - continue - } - n := strings.ToLower(strings.TrimPrefix(parts[0], string(*e))) - if n == "" { - continue - } - // convert VAR_VAR to VAR.VAR or if we need to preserve _ - // i.e. VAR_VAR__KEY_HAS_SLASHES to VAR.VAR.KEY_HAS_SLASHES - // with the result: VAR: { VAR: { KEY_HAS_SLASHES: '' } } } - x := strings.Index(n, "__") - var key string - if x == -1 { - key = strings.Replace(n, "_", ".", -1) - } else { - key = strings.Replace(n[:x+1], "_", ".", -1) + n[x+2:] - } - if len(parts) > 1 { - mp[key] = parts[1] - } - } - return maps.Unflatten(mp, "."), nil -} +const EnvPrefix = "CLOUD_GAME" // LoadConfig loads a configuration file into the given struct. // The path param specifies a custom path to the configuration file. // Reads and puts environment variables with the prefix CLOUD_GAME_. -func LoadConfig(config any, path string) (loaded []string, err error) { - dirs := []string{".", "configs", "../../../configs"} - if path != "" { - dirs = append([]string{path}, dirs...) - } - - homeDir := "" - if home, err := os.UserHomeDir(); err == nil { - homeDir = home + "/.cr" - dirs = append(dirs, homeDir) - } - - k := koanf.New("_") // move to global scope if configs become dynamic - defer k.Delete("") - data, err := conf.ReadFile("config.yaml") - if err != nil { - return nil, err - } - conf := Bytes(data) - if err := k.Load(&conf, &YAML{}); err != nil { - return nil, err - } - loaded = append(loaded, "default") - - for _, dir := range dirs { - path := filepath.Join(filepath.Clean(dir), "config.yaml") - f := File(path) - if _, err := os.Stat(string(f)); !os.IsNotExist(err) { - if err := k.Load(&f, &YAML{}); err != nil { - return loaded, err - } - loaded = append(loaded, path) +// Params from the config should be in uppercase separated with _. +func LoadConfig(config any, path string) error { + dirs := []string{path} + if path == "" { + dirs = append(dirs, ".", "configs", "../../../configs") + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, home+"/.cr") } } - - env := Env(EnvPrefix) - if err := k.Load(&env, nil); err != nil { - return loaded, err + if err := fig.Load(config, fig.Dirs(dirs...), fig.UseEnv(EnvPrefix)); err != nil { + return err } - - if err := k.Unmarshal("", config); err != nil { - return loaded, err - } - - return loaded, nil + return nil +} + +func LoadConfigEnv(config any) error { + return fig.Load(config, fig.IgnoreFile(), fig.UseEnv(EnvPrefix)) } diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go deleted file mode 100644 index 08e17dd3..00000000 --- a/pkg/config/loader_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package config - -import ( - "os" - "reflect" - "testing" -) - -func TestConfigEnv(t *testing.T) { - var out WorkerConfig - - _ = os.Setenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[0]", "10") - _ = os.Setenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[1]", "5") - defer func() { _ = os.Unsetenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[0]") }() - defer func() { _ = os.Unsetenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[1]") }() - - _ = os.Setenv("CLOUD_GAME_EMULATOR_LIBRETRO_CORES_LIST_PCSX_OPTIONS__PCSX_REARMED_DRC", "x") - defer func() { - _ = os.Unsetenv("CLOUD_GAME_EMULATOR_LIBRETRO_CORES_LIST_PCSX_OPTIONS__PCSX_REARMED_DRC") - }() - - _, err := LoadConfig(&out, "") - if err != nil { - t.Fatal(err) - } - - for i, x := range []float32{10, 5} { - if out.Encoder.Audio.Frames[i] != x { - t.Errorf("%v is not [10, 5]", out.Encoder.Audio.Frames) - t.Failed() - } - } - - v := out.Emulator.Libretro.Cores.List["pcsx"].Options["pcsx_rearmed_drc"] - if v != "x" { - t.Errorf("%v is not x", v) - } -} - -func Test_keysToLower(t *testing.T) { - type args struct { - in []byte - } - tests := []struct { - name string - args args - want []byte - }{ - {name: "empty", args: args{in: []byte{}}, want: []byte{}}, - {name: "case", args: args{ - in: []byte("KEY:1\n#Comment with:\n KeY123_NamE: 1\n\n\n\nAAA:123\n \"KeyKey\":2\n"), - }, - want: []byte("key:1\n#Comment with:\n key123_name: 1\n\n\n\naaa:123\n \"KeyKey\":2\n"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := keysToLower(tt.args.in); !reflect.DeepEqual(got, tt.want) { - t.Errorf("keysToLower() = %v, want %v", string(got), string(tt.want)) - } - }) - } -} diff --git a/pkg/config/monitoring/config.go b/pkg/config/monitoring/config.go new file mode 100644 index 00000000..da89ead0 --- /dev/null +++ b/pkg/config/monitoring/config.go @@ -0,0 +1,10 @@ +package monitoring + +type Config struct { + Port int + URLPrefix string + MetricEnabled bool `json:"metric_enabled"` + ProfilingEnabled bool `json:"profiling_enabled"` +} + +func (c *Config) IsEnabled() bool { return c.MetricEnabled || c.ProfilingEnabled } diff --git a/pkg/config/shared.go b/pkg/config/shared.go deleted file mode 100644 index e49eb3ce..00000000 --- a/pkg/config/shared.go +++ /dev/null @@ -1,66 +0,0 @@ -package config - -import "flag" - -type Version int - -type Library struct { - // filename of the alias' file - AliasFile string - // some directory which is going to be - // the root folder for the library - BasePath string - // a list of supported file extensions - Supported []string - // a list of ignored words in the files - Ignored []string - // print some additional info - Verbose bool - // enable directory changes watch - WatchMode bool -} - -func (l Library) GetSupportedExtensions() []string { return l.Supported } - -type Monitoring struct { - Port int - URLPrefix string - MetricEnabled bool `json:"metric_enabled"` - ProfilingEnabled bool `json:"profiling_enabled"` -} - -func (c *Monitoring) IsEnabled() bool { return c.MetricEnabled || c.ProfilingEnabled } - -type Server struct { - Address string - CacheControl string - FrameOptions string - Https bool - Tls struct { - Address string - Domain string - HttpsKey string - HttpsCert string - } -} - -type Recording struct { - Enabled bool - Name string - Folder string - Zip bool -} - -func (s *Server) WithFlags() { - flag.StringVar(&s.Address, "address", s.Address, "HTTP server address (host:port)") - flag.StringVar(&s.Tls.Address, "httpsAddress", s.Tls.Address, "HTTPS server address (host:port)") - flag.StringVar(&s.Tls.HttpsKey, "httpsKey", s.Tls.HttpsKey, "HTTPS key") - flag.StringVar(&s.Tls.HttpsCert, "httpsCert", s.Tls.HttpsCert, "HTTPS chain") -} - -func (s *Server) GetAddr() string { - if s.Https { - return s.Tls.Address - } - return s.Address -} diff --git a/pkg/config/shared/config.go b/pkg/config/shared/config.go new file mode 100644 index 00000000..3a92cd5e --- /dev/null +++ b/pkg/config/shared/config.go @@ -0,0 +1,38 @@ +package shared + +import "flag" + +type Version int + +type Server struct { + Address string + Https bool + Tls struct { + Address string + Domain string + HttpsKey string + HttpsCert string + } +} + +type Recording struct { + Enabled bool + CompressLevel int + Name string + Folder string + Zip bool +} + +func (s *Server) WithFlags() { + flag.StringVar(&s.Address, "address", s.Address, "HTTP server address (host:port)") + flag.StringVar(&s.Tls.Address, "httpsAddress", s.Tls.Address, "HTTPS server address (host:port)") + flag.StringVar(&s.Tls.HttpsKey, "httpsKey", s.Tls.HttpsKey, "HTTPS key") + flag.StringVar(&s.Tls.HttpsCert, "httpsCert", s.Tls.HttpsCert, "HTTPS chain") +} + +func (s *Server) GetAddr() string { + if s.Https { + return s.Tls.Address + } + return s.Address +} diff --git a/pkg/config/storage/config.go b/pkg/config/storage/config.go new file mode 100644 index 00000000..fd528c4d --- /dev/null +++ b/pkg/config/storage/config.go @@ -0,0 +1,6 @@ +package storage + +type Storage struct { + Provider string + Key string +} diff --git a/pkg/config/webrtc.go b/pkg/config/webrtc/config.go similarity index 52% rename from pkg/config/webrtc.go rename to pkg/config/webrtc/config.go index bc0b8e14..08da0795 100644 --- a/pkg/config/webrtc.go +++ b/pkg/config/webrtc/config.go @@ -1,4 +1,11 @@ -package config +package webrtc + +import ( + "log" + "strings" + + "github.com/giongto35/cloud-game/v2/pkg/config" +) type Webrtc struct { DisableDefaultInterceptors bool @@ -24,3 +31,23 @@ func (w *Webrtc) HasDtlsRole() bool { return w.DtlsRole > 0 } func (w *Webrtc) HasPortRange() bool { return w.IcePorts.Min > 0 && w.IcePorts.Max > 0 } func (w *Webrtc) HasSinglePort() bool { return w.SinglePort > 0 } func (w *Webrtc) HasIceIpMap() bool { return w.IceIpMap != "" } + +func (w *Webrtc) AddIceServersEnv() { + cfg := Webrtc{IceServers: []IceServer{{}, {}, {}, {}, {}}} + _ = config.LoadConfigEnv(&cfg) + for i, ice := range cfg.IceServers { + if ice.Urls == "" { + continue + } + if strings.HasPrefix(ice.Urls, "turn:") || strings.HasPrefix(ice.Urls, "turns:") { + if ice.Username == "" || ice.Credential == "" { + log.Fatalf("TURN or TURNS servers should have both username and credential: %+v", ice) + } + } + if i > len(w.IceServers)-1 { + w.IceServers = append(w.IceServers, ice) + } else { + w.IceServers[i] = ice + } + } +} diff --git a/pkg/config/worker.go b/pkg/config/worker/config.go similarity index 67% rename from pkg/config/worker.go rename to pkg/config/worker/config.go index 014ce644..6ecd75da 100644 --- a/pkg/config/worker.go +++ b/pkg/config/worker/config.go @@ -1,4 +1,4 @@ -package config +package worker import ( "flag" @@ -8,31 +8,29 @@ import ( "path/filepath" "strings" - "github.com/giongto35/cloud-game/v3/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/config/encoder" + "github.com/giongto35/cloud-game/v2/pkg/config/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/config/shared" + "github.com/giongto35/cloud-game/v2/pkg/config/storage" + "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" + "github.com/giongto35/cloud-game/v2/pkg/os" ) -type WorkerConfig struct { - Encoder Encoder - Emulator Emulator - Library Library - Recording Recording - Storage Storage +type Config struct { + Encoder encoder.Encoder + Emulator emulator.Emulator + Recording shared.Recording + Storage storage.Storage Worker Worker - Webrtc Webrtc - Version Version -} - -type Storage struct { - Provider string - S3Endpoint string - S3BucketName string - S3AccessKeyId string - S3SecretAccessKey string + Webrtc webrtc.Webrtc + Version shared.Version } type Worker struct { Debug bool - Monitoring Monitoring + Monitoring monitoring.Config Network struct { CoordinatorAddress string Endpoint string @@ -41,44 +39,15 @@ type Worker struct { Secure bool Zone string } - Server Server + Server shared.Server Tag string } -type Encoder struct { - Audio Audio - Video Video -} - -type Audio struct { - Frames []float32 - Resampler int -} - -type Video struct { - Codec string - Threads int - H264 struct { - Mode string - Crf uint8 - MaxRate int - BufSize int - LogLevel int32 - Preset string - Profile string - Tune string - } - Vpx struct { - Bitrate uint - KeyframeInterval uint - } -} - // allows custom config path -var workerConfigPath string +var configPath string -func NewWorkerConfig() (conf WorkerConfig, paths []string) { - paths, err := LoadConfig(&conf, workerConfigPath) +func NewConfig() (conf Config) { + err := config.LoadConfig(&conf, configPath) if err != nil { panic(err) } @@ -90,17 +59,17 @@ func NewWorkerConfig() (conf WorkerConfig, paths []string) { // ParseFlags updates config values from passed runtime flags. // Define own flags with default value set to the current config param. // Don't forget to call flag.Parse(). -func (c *WorkerConfig) ParseFlags() { +func (c *Config) ParseFlags() { c.Worker.Server.WithFlags() flag.IntVar(&c.Worker.Monitoring.Port, "monitoring.port", c.Worker.Monitoring.Port, "Monitoring server port") flag.StringVar(&c.Worker.Network.CoordinatorAddress, "coordinatorhost", c.Worker.Network.CoordinatorAddress, "Worker URL to connect") flag.StringVar(&c.Worker.Network.Zone, "zone", c.Worker.Network.Zone, "Worker network zone (us, eu, etc.)") - flag.StringVar(&workerConfigPath, "w-conf", workerConfigPath, "Set custom configuration file path") + flag.StringVar(&configPath, "w-conf", configPath, "Set custom configuration file path") flag.Parse() } // expandSpecialTags replaces all the special tags in the config. -func (c *WorkerConfig) expandSpecialTags() { +func (c *Config) expandSpecialTags() { tag := "{user}" for _, dir := range []*string{&c.Emulator.Storage, &c.Emulator.Libretro.Cores.Repo.ExtLock} { if *dir == "" || !strings.Contains(*dir, tag) { @@ -116,10 +85,12 @@ func (c *WorkerConfig) expandSpecialTags() { } // fixValues tries to fix some values otherwise hard to set externally. -func (c *WorkerConfig) fixValues() { +func (c *Config) fixValues() { // with ICE lite we clear ICE servers - if c.Webrtc.IceLite { - c.Webrtc.IceServers = []IceServer{} + if !c.Webrtc.IceLite { + c.Webrtc.AddIceServersEnv() + } else { + c.Webrtc.IceServers = []webrtc.IceServer{} } } diff --git a/pkg/coordinator/balancer.go b/pkg/coordinator/balancer.go new file mode 100644 index 00000000..1ee8cd4e --- /dev/null +++ b/pkg/coordinator/balancer.go @@ -0,0 +1,105 @@ +package coordinator + +import ( + "bytes" + + "github.com/rs/xid" +) + +func (h *Hub) findWorkerByRoom(id string, region string) *Worker { + if id == "" { + return nil + } + // if there is zone param, we need to ensure the worker in that zone, + // if not we consider the room is missing + w, _ := h.workers.FindBy(func(w *Worker) bool { return w.RoomId == id && w.In(region) }) + return w +} + +func (h *Hub) getAvailableWorkers(region string) []*Worker { + var workers []*Worker + h.workers.ForEach(func(w *Worker) { + if w.HasSlot() && w.In(region) { + workers = append(workers, w) + } + }) + return workers +} + +func (h *Hub) find1stFreeWorker(region string) *Worker { + workers := h.getAvailableWorkers(region) + if len(workers) > 0 { + return workers[0] + } + return nil +} + +// findFastestWorker returns the best server for a session. +// All workers addresses are sent to user and user will ping to get latency. +// !to rewrite +func (h *Hub) findFastestWorker(region string, fn func(addresses []string) (map[string]int64, error)) *Worker { + workers := h.getAvailableWorkers(region) + if len(workers) == 0 { + return nil + } + + var addresses []string + group := map[string][]struct{}{} + for _, w := range workers { + if _, ok := group[w.PingServer]; !ok { + addresses = append(addresses, w.PingServer) + } + group[w.PingServer] = append(group[w.PingServer], struct{}{}) + } + + latencies, err := fn(addresses) + if len(latencies) == 0 || err != nil { + return nil + } + + workers = h.getAvailableWorkers(region) + if len(workers) == 0 { + return nil + } + + var bestWorker *Worker + var minLatency int64 = 1<<31 - 1 + // get a worker with the lowest latency + for addr, ping := range latencies { + if ping < minLatency { + for _, w := range workers { + if w.PingServer == addr { + bestWorker = w + } + } + minLatency = ping + } + } + return bestWorker +} + +func (h *Hub) findWorkerById(workerId string, useAllWorkers bool) *Worker { + // when we select one particular worker + if workerId != "" { + if xid_, err := xid.FromString(workerId); err == nil { + if useAllWorkers { + for _, w := range h.getAvailableWorkers("") { + if xid_.String() == w.Id().String() { + return w + } + } + } else { + for _, w := range h.getAvailableWorkers("") { + xid__, err := xid.FromString(workerId) + if err != nil { + continue + } + if bytes.Equal(xid_.Machine(), xid__.Machine()) { + return w + } + } + } + } + } + return nil +} diff --git a/pkg/coordinator/coordinator.go b/pkg/coordinator/coordinator.go index ffc5c7de..88a649f8 100644 --- a/pkg/coordinator/coordinator.go +++ b/pkg/coordinator/coordinator.go @@ -1,119 +1,83 @@ package coordinator import ( - "errors" - "fmt" "html/template" "net/http" - "strings" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/monitoring" - "github.com/giongto35/cloud-game/v3/pkg/network/httpx" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/config/shared" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/network/httpx" + "github.com/giongto35/cloud-game/v2/pkg/service" ) -type Coordinator struct { - hub *Hub - services [2]interface { - Run() - Stop() error - } -} - -func New(conf config.CoordinatorConfig, log *logger.Logger) (*Coordinator, error) { - coordinator := &Coordinator{hub: NewHub(conf, log)} +func New(conf coordinator.Config, log *logger.Logger) (services service.Group) { + lib := games.NewLibWhitelisted(conf.Coordinator.Library, conf.Emulator, log) + lib.Scan() + hub := NewHub(conf, lib, log) h, err := NewHTTPServer(conf, log, func(mux *httpx.Mux) *httpx.Mux { - mux.HandleFunc("/ws", coordinator.hub.handleUserConnection()) - mux.HandleFunc("/wso", coordinator.hub.handleWorkerConnection()) + mux.HandleFunc("/ws", hub.handleUserConnection) + mux.HandleFunc("/wso", hub.handleWorkerConnection) return mux }) if err != nil { - return nil, fmt.Errorf("http init fail: %w", err) + log.Error().Err(err).Msg("http server init fail") + return } - coordinator.services[0] = h + services.Add(hub, h) if conf.Coordinator.Monitoring.IsEnabled() { - coordinator.services[1] = monitoring.New(conf.Coordinator.Monitoring, h.GetHost(), log) + services.Add(monitoring.New(conf.Coordinator.Monitoring, h.GetHost(), log)) } - return coordinator, nil + return } -func (c *Coordinator) Start() { - for _, s := range c.services { - if s != nil { - s.Run() - } - } -} - -func (c *Coordinator) Stop() error { - var err error - for _, s := range c.services { - if s != nil { - err0 := s.Stop() - err = errors.Join(err, err0) - } - } - return err -} - -func NewHTTPServer(conf config.CoordinatorConfig, log *logger.Logger, fnMux func(*httpx.Mux) *httpx.Mux) (*httpx.Server, error) { +func NewHTTPServer(conf coordinator.Config, log *logger.Logger, fnMux func(*httpx.Mux) *httpx.Mux) (*httpx.Server, error) { return httpx.NewServer( conf.Coordinator.Server.GetAddr(), - func(s *httpx.Server) httpx.Handler { return fnMux(s.Mux().Handle("/", index(conf, log))) }, + func(s *httpx.Server) httpx.Handler { + return fnMux(s.Mux(). + Handle("/", index(conf, log)). + Static("/static/", "./web")) + }, httpx.WithServerConfig(conf.Coordinator.Server), httpx.WithLogger(log), ) } -func index(conf config.CoordinatorConfig, log *logger.Logger) httpx.Handler { +func index(conf coordinator.Config, log *logger.Logger) httpx.Handler { const indexHTML = "./web/index.html" - indexTpl := template.Must(template.ParseFiles(indexHTML)) - - // render index page with some tpl values - tplData := struct { - Analytics config.Analytics - Recording config.Recording - }{conf.Coordinator.Analytics, conf.Recording} - handler := func(tpl *template.Template, w httpx.ResponseWriter, r *httpx.Request) { + if r.URL.Path != "/" { + httpx.NotFound(w) + return + } + // render index page with some tpl values + tplData := struct { + Analytics coordinator.Analytics + Recording shared.Recording + }{conf.Coordinator.Analytics, conf.Recording} if err := tpl.Execute(w, tplData); err != nil { - log.Error().Err(err).Msg("error with the analytics template file") + log.Fatal().Err(err).Msg("error with the analytics template file") } } - h := httpx.FileServer("./web") - if conf.Coordinator.Debug { log.Info().Msgf("Using auto-reloading index.html") return httpx.HandlerFunc(func(w httpx.ResponseWriter, r *httpx.Request) { - if conf.Coordinator.Server.CacheControl != "" { - w.Header().Add("Cache-Control", conf.Coordinator.Server.CacheControl) - } - if conf.Coordinator.Server.FrameOptions != "" { - w.Header().Add("X-Frame-Options", conf.Coordinator.Server.FrameOptions) - } - if r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") { - tpl := template.Must(template.ParseFiles(indexHTML)) - handler(tpl, w, r) - return - } - h.ServeHTTP(w, r) + tpl, _ := template.ParseFiles(indexHTML) + handler(tpl, w, r) }) } - return httpx.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if conf.Coordinator.Server.CacheControl != "" { - w.Header().Add("Cache-Control", conf.Coordinator.Server.CacheControl) - } - if conf.Coordinator.Server.FrameOptions != "" { - w.Header().Add("X-Frame-Options", conf.Coordinator.Server.FrameOptions) - } - if r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") { - handler(indexTpl, w, r) - return - } - h.ServeHTTP(w, r) + indexTpl, err := template.ParseFiles(indexHTML) + if err != nil { + log.Fatal().Err(err).Msg("error with the HTML index page") + } + + return httpx.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + handler(indexTpl, writer, request) }) } diff --git a/pkg/coordinator/hub.go b/pkg/coordinator/hub.go index 9e646ced..369a1ab0 100644 --- a/pkg/coordinator/hub.go +++ b/pkg/coordinator/hub.go @@ -1,337 +1,162 @@ package coordinator import ( - "bytes" - "encoding/base64" - "fmt" "net/http" - "net/url" - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/service" ) -type Connection interface { - Disconnect() - Id() com.Uid - ProcessPackets(func(api.In[com.Uid]) error) chan struct{} - - Send(api.PT, any) ([]byte, error) - Notify(api.PT, any) -} - type Hub struct { - conf config.CoordinatorConfig - log *logger.Logger - users com.NetMap[com.Uid, *User] - workers com.NetMap[com.Uid, *Worker] + service.Service + + conf coordinator.Config + launcher games.Launcher + users com.NetMap[*User] + workers com.NetMap[*Worker] + log *logger.Logger + + wConn, uConn *com.Connector } -func NewHub(conf config.CoordinatorConfig, log *logger.Logger) *Hub { +func NewHub(conf coordinator.Config, lib games.GameLibrary, log *logger.Logger) *Hub { return &Hub{ - conf: conf, - users: com.NewNetMap[com.Uid, *User](), - workers: com.NewNetMap[com.Uid, *Worker](), - log: log, + conf: conf, + users: com.NewNetMap[*User](), + workers: com.NewNetMap[*Worker](), + launcher: games.NewGameLauncher(lib), + log: log, + wConn: com.NewConnector( + com.WithOrigin(conf.Coordinator.Origin.WorkerWs), + com.WithTag("w"), + ), + uConn: com.NewConnector( + com.WithOrigin(conf.Coordinator.Origin.UserWs), + com.WithTag("u"), + ), } } // handleUserConnection handles all connections from user/frontend. -func (h *Hub) handleUserConnection() http.HandlerFunc { - var connector com.Server - connector.Origin(h.conf.Coordinator.Origin.UserWs) - - log := h.log.Extend(h.log.With(). - Str(logger.ClientField, "u"). - Str(logger.DirectionField, logger.MarkIn), - ) - - return func(w http.ResponseWriter, r *http.Request) { - h.log.Debug().Msgf("Handshake %v", r.Host) - - conn, err := connector.Connect(w, r) - if err != nil { - h.log.Error().Err(err).Msg("user connection fail") - return - } - - user := NewUser(conn, log) - defer h.users.RemoveDisconnect(user) - done := user.HandleRequests(h, h.conf) - params := r.URL.Query() - - worker := h.findWorkerFor(user, params, h.log.Extend(h.log.With().Str("cid", user.Id().Short()))) - if worker == nil { - user.Notify(api.ErrNoFreeSlots, "") - h.log.Info().Msg("no free workers") - return - } - - // Link the user to the selected worker. Slot reservation is handled later - // on game start; this keeps connections lightweight and lets deep-link - // joins share a worker without consuming its single game slot. - user.w = worker - - h.users.Add(user) - - apps := worker.AppNames() - list := make([]api.AppMeta, len(apps)) - for i := range apps { - list[i] = api.AppMeta{Alias: apps[i].Alias, Title: apps[i].Name, System: apps[i].System} - } - - user.InitSession(worker.Id().String(), h.conf.Webrtc.IceServers, list) - log.Info().Str(logger.DirectionField, logger.MarkPlus).Msgf("user %s", user.Id()) - <-done +func (h *Hub) handleUserConnection(w http.ResponseWriter, r *http.Request) { + h.log.Debug().Str("c", "u").Str("d", "←").Msgf("Handshake %v", r.Host) + conn, err := h.uConn.NewClientServer(w, r, h.log) + if err != nil { + h.log.Error().Err(err).Msg("couldn't init user connection") } -} + usr := NewUserConnection(conn) + defer func() { + if usr != nil { + usr.Disconnect() + h.users.Remove(usr) + } + }() + usr.HandleRequests(h, h.launcher, h.conf) -func RequestToHandshake(data string) (*api.ConnectionRequest[com.Uid], error) { - if data == "" { - return nil, api.ErrMalformed + q := r.URL.Query() + roomId := q.Get(api.RoomIdQueryParam) + zone := q.Get(api.ZoneQueryParam) + wid := q.Get(api.WorkerIdParam) + + usr.Log.Info().Msg("Search available workers") + var wkr *Worker + if wkr = h.findWorkerByRoom(roomId, zone); wkr != nil { + usr.Log.Info().Str("room", roomId).Msg("An existing worker has been found") + } else if wkr = h.findWorkerById(wid, h.conf.Coordinator.Debug); wkr != nil { + usr.Log.Info().Msgf("Worker with id: %v has been found", wid) + } else if h.conf.Coordinator.Selector == "" || h.conf.Coordinator.Selector == coordinator.SelectAny { + usr.Log.Debug().Msgf("Searching any free worker...") + if wkr = h.find1stFreeWorker(zone); wkr != nil { + usr.Log.Info().Msgf("Found next free worker") + } + } else if h.conf.Coordinator.Selector == coordinator.SelectByPing { + usr.Log.Debug().Msgf("Searching fastest free worker...") + if wkr = h.findFastestWorker(zone, + func(servers []string) (map[string]int64, error) { return usr.CheckLatency(servers) }); wkr != nil { + usr.Log.Info().Msg("The fastest worker has been found") + } } - handshake, err := api.UnwrapChecked[api.ConnectionRequest[com.Uid]](base64.URLEncoding.DecodeString(data)) - if err != nil || handshake == nil { - return nil, fmt.Errorf("%w (%v)", err, handshake) + + if wkr == nil { + usr.Log.Warn().Msg("no free workers") + return } - return handshake, nil + + usr.SetWorker(wkr) + h.users.Add(usr) + usr.InitSession(wkr.Id().String(), h.conf.Webrtc.IceServers, h.launcher.GetAppNames()) + <-usr.Done() } // handleWorkerConnection handles all connections from a new worker to coordinator. -func (h *Hub) handleWorkerConnection() http.HandlerFunc { - var connector com.Server - connector.Origin(h.conf.Coordinator.Origin.WorkerWs) +func (h *Hub) handleWorkerConnection(w http.ResponseWriter, r *http.Request) { + h.log.Debug().Str("c", "w").Str("d", "←").Msgf("Handshake %v", r.Host) - log := h.log.Extend(h.log.With(). - Str(logger.ClientField, "w"). - Str(logger.DirectionField, logger.MarkIn), - ) - - h.log.Debug().Msgf("WS max message size: %vb", h.conf.Coordinator.MaxWsSize) - - return func(w http.ResponseWriter, r *http.Request) { - h.log.Debug().Msgf("Handshake %v", r.Host) - - handshake, err := RequestToHandshake(r.URL.Query().Get(api.DataQueryParam)) - if err != nil { - h.log.Error().Err(err).Msg("handshake fail") - return - } - - if handshake.PingURL == "" { - h.log.Warn().Msg("Ping address is not set") - } - - if h.conf.Coordinator.Server.Https && !handshake.IsHTTPS { - h.log.Warn().Msg("Unsecure worker connection. Unsecure to secure may be bad.") - } - - // set connection uid from the handshake - if handshake.Id != com.NilUid { - h.log.Debug().Msgf("Worker uid will be set to %v", handshake.Id) - } - - conn, err := connector.Connect(w, r) - if err != nil { - log.Error().Err(err).Msg("worker connection fail") - return - } - conn.SetMaxReadSize(h.conf.Coordinator.MaxWsSize) - - worker := NewWorker(conn, *handshake, log) - defer h.workers.RemoveDisconnect(worker) - done := worker.HandleRequests(&h.users) - h.workers.Add(worker) - log.Info(). - Str(logger.DirectionField, logger.MarkPlus). - Msgf("worker %s", worker.PrintInfo()) - <-done + data := r.URL.Query().Get(api.DataQueryParam) + handshake, err := GetConnectionRequest(data) + if err != nil || handshake == nil { + h.log.Error().Err(err).Msg("got a malformed request") + return } + + if handshake.PingURL == "" { + h.log.Warn().Msg("Ping address is not set") + } + + if h.conf.Coordinator.Server.Https && !handshake.IsHTTPS { + h.log.Warn().Msg("Unsecure connection. The worker may not work properly without HTTPS on its side!") + } + + conn, err := h.wConn.NewClientServer(w, r, h.log) + if err != nil { + h.log.Error().Err(err).Msg("couldn't init worker connection") + return + } + + worker := &Worker{ + SocketClient: *conn, + Addr: handshake.Addr, + PingServer: handshake.PingURL, + Port: handshake.Port, + Tag: handshake.Tag, + Zone: handshake.Zone, + } + // we duplicate uid from the handshake + hid := network.Uid(handshake.Id) + if !(handshake.Id == "" || !network.ValidUid(hid)) { + conn.SetId(hid) + worker.Log.Debug().Msgf("connection id has been changed to %s", hid) + } + defer func() { + if worker != nil { + worker.Disconnect() + h.workers.Remove(worker) + } + }() + + h.log.Info().Msgf("New worker / addr: %v, port: %v, zone: %v, ping addr: %v, tag: %v", + worker.Addr, worker.Port, worker.Zone, worker.PingServer, worker.Tag) + worker.HandleRequests(&h.users) + h.workers.Add(worker) + worker.Listen() } func (h *Hub) GetServerList() (r []api.Server) { - debug := h.conf.Coordinator.Debug - for w := range h.workers.Values() { - server := api.Server{ + for _, w := range h.workers.List() { + r = append(r, api.Server{ Addr: w.Addr, Id: w.Id(), IsBusy: !w.HasSlot(), - Machine: string(w.Id().Machine()), PingURL: w.PingServer, Port: w.Port, Tag: w.Tag, Zone: w.Zone, - } - if debug { - server.Room = w.RoomId - } - r = append(r, server) + }) } return } - -// findWorkerFor searches a free worker for the user depending on -// various conditions. -func (h *Hub) findWorkerFor(usr *User, q url.Values, log *logger.Logger) *Worker { - log.Debug().Msg("Search available workers") - roomIdRaw := q.Get(api.RoomIdQueryParam) - sessionId, deepRoomId := api.ExplodeDeepLink(roomIdRaw) - roomId := roomIdRaw - if deepRoomId != "" { - roomId = deepRoomId - } - zone := q.Get(api.ZoneQueryParam) - wid := q.Get(api.WorkerIdParam) - - var worker *Worker - - if wid != "" { - if worker = h.findWorkerById(wid, h.conf.Coordinator.Debug); worker != nil { - log.Debug().Msgf("Worker with id: %v has been found", wid) - return worker - } else { - return nil - } - } - - if worker = h.findWorkerByRoom(roomIdRaw, roomId, zone); worker != nil { - log.Debug().Str("room", roomId).Msg("An existing worker has been found") - } else if worker = h.findWorkerByPreviousRoom(sessionId); worker != nil { - log.Debug().Msgf("Worker %v with the previous room: %v is found", wid, roomId) - } else { - switch h.conf.Coordinator.Selector { - case config.SelectByPing: - log.Debug().Msgf("Searching fastest free worker...") - if worker = h.findFastestWorker(zone, - func(servers []string) (map[string]int64, error) { return usr.CheckLatency(servers) }); worker != nil { - log.Debug().Msg("The fastest worker has been found") - } - default: - log.Debug().Msgf("Searching any free worker...") - if worker = h.find1stFreeWorker(zone); worker != nil { - log.Debug().Msgf("Found next free worker") - } - } - } - return worker -} - -func (h *Hub) findWorkerByPreviousRoom(id string) *Worker { - if id == "" { - return nil - } - w, _ := h.workers.FindBy(func(w *Worker) bool { - // session and room id are the same - return w.HadSession(id) && w.HasSlot() - }) - return w -} - -func (h *Hub) findWorkerByRoom(id string, deepId string, region string) *Worker { - if id == "" && deepId == "" { - return nil - } - // if there is zone param, we need to ensure the worker in that zone, - // if not we consider the room is missing - w, _ := h.workers.FindBy(func(w *Worker) bool { - matchId := w.RoomId == id - if !matchId && deepId != "" { - matchId = w.RoomId == deepId - } - return matchId && w.In(region) - }) - return w -} - -func (h *Hub) getAvailableWorkers(region string) []*Worker { - var workers []*Worker - for w := range h.workers.Values() { - if w.HasSlot() && w.In(region) { - workers = append(workers, w) - } - } - return workers -} - -func (h *Hub) find1stFreeWorker(region string) *Worker { - workers := h.getAvailableWorkers(region) - if len(workers) > 0 { - return workers[0] - } - return nil -} - -// findFastestWorker returns the best server for a session. -// All workers addresses are sent to user and user will ping to get latency. -// !to rewrite -func (h *Hub) findFastestWorker(region string, fn func(addresses []string) (map[string]int64, error)) *Worker { - workers := h.getAvailableWorkers(region) - if len(workers) == 0 { - return nil - } - - var addresses []string - group := map[string][]struct{}{} - for _, w := range workers { - if _, ok := group[w.PingServer]; !ok { - addresses = append(addresses, w.PingServer) - } - group[w.PingServer] = append(group[w.PingServer], struct{}{}) - } - - latencies, err := fn(addresses) - if len(latencies) == 0 || err != nil { - return nil - } - - workers = h.getAvailableWorkers(region) - if len(workers) == 0 { - return nil - } - - var bestWorker *Worker - var minLatency int64 = 1<<31 - 1 - // get a worker with the lowest latency - for addr, ping := range latencies { - if ping < minLatency { - for _, w := range workers { - if w.PingServer == addr { - bestWorker = w - } - } - minLatency = ping - } - } - return bestWorker -} - -func (h *Hub) findWorkerById(id string, useAllWorkers bool) *Worker { - if id == "" { - return nil - } - - uid, err := com.UidFromString(id) - if err != nil { - return nil - } - - for _, w := range h.getAvailableWorkers("") { - if w.Id() == com.NilUid { - continue - } - if useAllWorkers { - if uid == w.Id() { - return w - } - } else { - // select any worker on the same machine when workers are grouped on the client - if bytes.Equal(uid.Machine(), w.Id().Machine()) { - return w - } - } - } - - return nil -} diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go index e1efef49..f4325b2f 100644 --- a/pkg/coordinator/user.go +++ b/pkg/coordinator/user.go @@ -1,81 +1,89 @@ package coordinator import ( - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/games" ) type User struct { - Connection - w *Worker // linked worker - log *logger.Logger + com.SocketClient + w *Worker // linked worker } -type HasServerInfo interface { - GetServerList() []api.Server -} +// NewUserConnection supposed to be a bidirectional one. +func NewUserConnection(conn *com.SocketClient) *User { return &User{SocketClient: *conn} } -func NewUser(sock *com.Connection, log *logger.Logger) *User { - conn := com.NewConnection[api.PT, api.In[com.Uid], api.Out, *api.Out](sock, com.NewUid(), log) - return &User{ - Connection: conn, - log: log.Extend(log.With(). - Str(logger.ClientField, logger.MarkNone). - Str(logger.DirectionField, logger.MarkNone). - Str("cid", conn.Id().Short())), - } -} - -func (u *User) Bind(w *Worker) bool { - u.w = w - // Binding only links the worker; slot reservation is handled lazily on - // game start to avoid blocking deep-link joins or parallel connections - // that haven't started a game yet. - return true -} +func (u *User) SetWorker(w *Worker) { u.w = w; u.w.Reserve() } func (u *User) Disconnect() { - u.Connection.Disconnect() + u.SocketClient.Close() if u.w != nil { - u.w.TerminateSession(u.Id().String()) + u.w.UnReserve() + u.w.TerminateSession(u.Id()) } } -func (u *User) HandleRequests(info HasServerInfo, conf config.CoordinatorConfig) chan struct{} { - return u.ProcessPackets(func(x api.In[com.Uid]) (err error) { +func (u *User) HandleRequests(info api.HasServerInfo, launcher games.Launcher, conf coordinator.Config) { + u.ProcessMessages() + u.OnPacket(func(x com.In) error { + // !to use proper channels switch x.T { case api.WebrtcInit: if u.w != nil { u.HandleWebrtcInit() } case api.WebrtcAnswer: - err = api.Do(x, u.HandleWebrtcAnswer) + rq := api.Unwrap[api.WebrtcAnswerUserRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleWebrtcAnswer(*rq) case api.WebrtcIce: - err = api.Do(x, u.HandleWebrtcIceCandidate) + rq := api.Unwrap[api.WebrtcUserIceCandidate](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleWebrtcIceCandidate(*rq) case api.StartGame: - err = api.Do(x, func(d api.GameStartUserRequest) { u.HandleStartGame(d, conf) }) + rq := api.Unwrap[api.GameStartUserRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleStartGame(*rq, launcher, conf) case api.QuitGame: - err = api.Do(x, u.HandleQuitGame) + rq := api.Unwrap[api.GameQuitRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleQuitGame(*rq) case api.SaveGame: - err = u.HandleSaveGame() + return u.HandleSaveGame() case api.LoadGame: - err = u.HandleLoadGame() + return u.HandleLoadGame() case api.ChangePlayer: - err = api.Do(x, u.HandleChangePlayer) - case api.ResetGame: - err = api.Do(x, u.HandleResetGame) + rq := api.Unwrap[api.ChangePlayerUserRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleChangePlayer(*rq) + case api.ToggleMultitap: + u.HandleToggleMultitap() case api.RecordGame: if !conf.Recording.Enabled { return api.ErrForbidden } - err = api.Do(x, u.HandleRecordGame) + rq := api.Unwrap[api.RecordGameRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleRecordGame(*rq) case api.GetWorkerList: u.handleGetWorkerList(conf.Coordinator.Debug, info) default: - u.log.Warn().Msgf("Unknown packet: %+v", x) + u.Log.Warn().Msgf("Unknown packet: %+v", x) } - return + return nil }) } diff --git a/pkg/coordinator/userapi.go b/pkg/coordinator/userapi.go index fd8b7235..71c9d76b 100644 --- a/pkg/coordinator/userapi.go +++ b/pkg/coordinator/userapi.go @@ -3,27 +3,28 @@ package coordinator import ( "unsafe" - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" ) // CheckLatency sends a list of server addresses to the user // and waits get back this list with tested ping times for each server. func (u *User) CheckLatency(req api.CheckLatencyUserResponse) (api.CheckLatencyUserRequest, error) { - dat, err := api.UnwrapChecked[api.CheckLatencyUserRequest](u.Send(api.CheckLatency, req)) + data, err := u.Send(api.CheckLatency, req) + if err != nil || data == nil { + return nil, err + } + dat := api.Unwrap[api.CheckLatencyUserRequest](data) if dat == nil { return api.CheckLatencyUserRequest{}, err } - return *dat, nil + return *dat, err } // InitSession signals the user that the app is ready to go. -func (u *User) InitSession(wid string, ice []config.IceServer, games []api.AppMeta) { - u.Notify(api.InitSession, api.InitSessionUserResponse{ - Ice: *(*[]api.IceServer)(unsafe.Pointer(&ice)), // don't do this at home - Games: games, - Wid: wid, - }) +func (u *User) InitSession(wid string, ice []webrtc.IceServer, games []string) { + // don't do this at home + u.Notify(api.InitSessionResult(*(*[]api.IceServer)(unsafe.Pointer(&ice)), games, wid)) } // SendWebrtcOffer sends SDP offer back to the user. @@ -33,6 +34,4 @@ func (u *User) SendWebrtcOffer(sdp string) { u.Notify(api.WebrtcOffer, sdp) } func (u *User) SendWebrtcIceCandidate(candidate string) { u.Notify(api.WebrtcIce, candidate) } // StartGame signals the user that everything is ready to start a game. -func (u *User) StartGame(av *api.AppVideoInfo, kbMouse bool) { - u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av, KbMouse: kbMouse}) -} +func (u *User) StartGame() { u.Notify(api.StartGame, u.w.RoomId) } diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go index 6dddd30e..b5c2b238 100644 --- a/pkg/coordinator/userhandlers.go +++ b/pkg/coordinator/userhandlers.go @@ -2,91 +2,60 @@ package coordinator import ( "sort" - "time" - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/games" ) func (u *User) HandleWebrtcInit() { - uid := u.Id().String() - resp, err := u.w.WebrtcInit(uid) + resp, err := u.w.WebrtcInit(u.Id()) if err != nil || resp == nil || *resp == api.EMPTY { - u.log.Error().Err(err).Msg("malformed WebRTC init response") + u.Log.Error().Err(err).Msg("malformed WebRTC init response") return } u.SendWebrtcOffer(string(*resp)) } func (u *User) HandleWebrtcAnswer(rq api.WebrtcAnswerUserRequest) { - u.w.WebrtcAnswer(u.Id().String(), string(rq)) + u.w.WebrtcAnswer(u.Id(), string(rq)) } func (u *User) HandleWebrtcIceCandidate(rq api.WebrtcUserIceCandidate) { - u.w.WebrtcIceCandidate(u.Id().String(), string(rq)) + u.w.WebrtcIceCandidate(u.Id(), string(rq)) } -func (u *User) HandleStartGame(rq api.GameStartUserRequest, conf config.CoordinatorConfig) { - // Worker slot / room gating: - // - If the worker is BUSY (no free slot), we must not create another room. - // * If the worker has already reported a room id, only allow requests - // for that same room (deep-link joins / reloads). - // * If the worker hasn't reported a room yet, deny any new StartGame to - // avoid racing concurrent room creation on the worker. - // * When the user is starting a NEW game (empty room id), we give the - // worker a short grace period to close the previous room and free the - // slot before rejecting with "no slots". - // - If the worker is FREE, reserve the slot lazily before starting the - // game; the room id (if any) comes from the request / worker. - - // Grace period: when there's no room id in the request (new game) but the - // worker still appears busy, wait a bit for the previous room to close. - if rq.RoomId == "" && !u.w.HasSlot() { - const waitTotal = 3 * time.Second - const step = 100 * time.Millisecond - waited := time.Duration(0) - for waited < waitTotal { - if u.w.HasSlot() { - break - } - time.Sleep(step) - waited += step +func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launcher, conf coordinator.Config) { + // +injects game data into the original game request + // the name of the game either in the `room id` field or + // it's in the initial request + game := rq.GameName + if rq.RoomId != "" { + name := launcher.ExtractAppNameFromUrl(rq.RoomId) + if name == "" { + u.Log.Warn().Msg("couldn't decode game name from the room id") + return } + game = name } - busy := !u.w.HasSlot() - if busy { - if u.w.RoomId == "" { - u.Notify(api.ErrNoFreeSlots, "") - return - } - if rq.RoomId == "" { - // No room id but worker is busy -> assume user wants to continue - // the existing room instead of starting a parallel game. - rq.RoomId = u.w.RoomId - } else if rq.RoomId != u.w.RoomId { - u.Notify(api.ErrNoFreeSlots, "") - return - } - } else { - // Worker is free: try to reserve the single slot for this new room. - if !u.w.TryReserve() { - u.Notify(api.ErrNoFreeSlots, "") - return - } + gameInfo, err := launcher.FindAppByName(game) + if err != nil { + u.Log.Error().Err(err).Str("game", game).Msg("couldn't find game info") + return } - startGameResp, err := u.w.StartGame(u.Id().String(), rq) + startGameResp, err := u.w.StartGame(u.Id(), gameInfo, rq) if err != nil || startGameResp == nil { - u.log.Error().Err(err).Msg("malformed game start response") + u.Log.Error().Err(err).Msg("malformed game start response") return } if startGameResp.Rid == "" { - u.log.Error().Msg("there is no room") + u.Log.Error().Msg("there is no room") return } - u.log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker") - u.StartGame(startGameResp.AV, startGameResp.KbMouse) + u.Log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker") + u.StartGame() // send back recording status if conf.Recording.Enabled && rq.Record { @@ -95,36 +64,22 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, conf config.Coordina } func (u *User) HandleQuitGame(rq api.GameQuitRequest) { - if rq.Rid == u.w.RoomId { - u.w.QuitGame(u.Id().String()) + if rq.Room.Rid == u.w.RoomId { + u.w.QuitGame(u.Id()) } } -func (u *User) HandleResetGame(rq api.ResetGameRequest) { - if rq.Rid != u.w.RoomId { - return - } - u.w.ResetGame(u.Id().String()) -} - func (u *User) HandleSaveGame() error { - resp, err := u.w.SaveGame(u.Id().String()) + resp, err := u.w.SaveGame(u.Id()) if err != nil { return err } - - if *resp == api.OK { - if id, _ := api.ExplodeDeepLink(u.w.RoomId); id != "" { - u.w.AddSession(id) - } - } - u.Notify(api.SaveGame, resp) return nil } func (u *User) HandleLoadGame() error { - resp, err := u.w.LoadGame(u.Id().String()) + resp, err := u.w.LoadGame(u.Id()) if err != nil { return err } @@ -133,52 +88,52 @@ func (u *User) HandleLoadGame() error { } func (u *User) HandleChangePlayer(rq api.ChangePlayerUserRequest) { - resp, err := u.w.ChangePlayer(u.Id().String(), int(rq)) + resp, err := u.w.ChangePlayer(u.Id(), int(rq)) // !to make it a little less convoluted if err != nil || resp == nil || *resp == -1 { - u.log.Error().Err(err).Msgf("player select fail, req: %v", rq) + u.Log.Error().Err(err).Msg("player switch failed for some reason") return } u.Notify(api.ChangePlayer, rq) } +func (u *User) HandleToggleMultitap() { u.w.ToggleMultitap(u.Id()) } + func (u *User) HandleRecordGame(rq api.RecordGameRequest) { if u.w == nil { return } - u.log.Debug().Msgf("??? room: %v, rec: %v user: %v", u.w.RoomId, rq.Active, rq.User) + u.Log.Debug().Msgf("??? room: %v, rec: %v user: %v", u.w.RoomId, rq.Active, rq.User) if u.w.RoomId == "" { - u.log.Error().Msg("Recording in the empty room is not allowed!") + u.Log.Error().Msg("Recording in the empty room is not allowed!") return } - resp, err := u.w.RecordGame(u.Id().String(), rq.Active, rq.User) + resp, err := u.w.RecordGame(u.Id(), rq.Active, rq.User) if err != nil { - u.log.Error().Err(err).Msg("malformed game record request") + u.Log.Error().Err(err).Msg("malformed game record request") return } u.Notify(api.RecordGame, resp) } -func (u *User) handleGetWorkerList(debug bool, info HasServerInfo) { +func (u *User) handleGetWorkerList(debug bool, info api.HasServerInfo) { response := api.GetWorkerListResponse{} servers := info.GetServerList() if debug { response.Servers = servers } else { + // not sure if []byte to string always reversible :/ unique := map[string]*api.Server{} for _, s := range servers { - mid := s.Machine + mid := s.Id.Machine() if _, ok := unique[mid]; !ok { unique[mid] = &api.Server{Addr: s.Addr, PingURL: s.PingURL, Id: s.Id, InGroup: true} } - v := unique[mid] - if v != nil { - v.Replicas++ - } + unique[mid].Replicas++ } for _, v := range unique { response.Servers = append(response.Servers, *v) diff --git a/pkg/coordinator/worker.go b/pkg/coordinator/worker.go index 137d7777..dc10b727 100644 --- a/pkg/coordinator/worker.go +++ b/pkg/coordinator/worker.go @@ -1,20 +1,15 @@ package coordinator import ( - "errors" - "fmt" "sync/atomic" - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" ) type Worker struct { - AppLibrary - Connection - RegionalClient - Session + com.SocketClient + com.RegionalClient slotted Addr string @@ -23,111 +18,38 @@ type Worker struct { RoomId string // room reference Tag string Zone string - - Lib []api.GameInfo - Sessions map[string]struct{} - - log *logger.Logger } -type RegionalClient interface { - In(region string) bool -} - -type HasUserRegistry interface { - Find(id string) *User -} - -type AppLibrary interface { - SetLib([]api.GameInfo) - AppNames() []api.GameInfo -} - -type Session interface { - AddSession(id string) - // HadSession is true when an old session is found - HadSession(id string) bool - SetSessions(map[string]struct{}) -} - -type AppMeta struct { - Alias string - Base string - Name string - Path string - System string - Type string -} - -func NewWorker(sock *com.Connection, handshake api.ConnectionRequest[com.Uid], log *logger.Logger) *Worker { - conn := com.NewConnection[api.PT, api.In[com.Uid], api.Out, *api.Out](sock, handshake.Id, log) - return &Worker{ - Connection: conn, - Addr: handshake.Addr, - PingServer: handshake.PingURL, - Port: handshake.Port, - Tag: handshake.Tag, - Zone: handshake.Zone, - log: log.Extend(log.With(). - Str(logger.ClientField, logger.MarkNone). - Str(logger.DirectionField, logger.MarkNone). - Str("cid", conn.Id().Short())), - } -} - -func (w *Worker) HandleRequests(users HasUserRegistry) chan struct{} { - return w.ProcessPackets(func(p api.In[com.Uid]) (err error) { +func (w *Worker) HandleRequests(users *com.NetMap[*User]) { + // !to make a proper multithreading abstraction + w.OnPacket(func(p com.In) error { switch p.T { case api.RegisterRoom: - err = api.Do(p, func(d api.RegisterRoomRequest) { - w.log.Info().Msgf("set room [%v] = %v", w.Id(), d) - w.HandleRegisterRoom(d) - }) + rq := api.Unwrap[api.RegisterRoomRequest](p.Payload) + if rq == nil { + return api.ErrMalformed + } + w.Log.Info().Msgf("set room [%v] = %v", w.Id(), *rq) + w.HandleRegisterRoom(*rq) case api.CloseRoom: - err = api.Do(p, w.HandleCloseRoom) + rq := api.Unwrap[api.CloseRoomRequest](p.Payload) + if rq == nil { + return api.ErrMalformed + } + w.HandleCloseRoom(*rq) case api.IceCandidate: - err = api.DoE(p, func(d api.WebrtcIceCandidateRequest) error { - return w.HandleIceCandidate(d, users) - }) - case api.LibNewGameList: - err = api.DoE(p, w.HandleLibGameList) - case api.PrevSessions: - err = api.DoE(p, w.HandlePrevSessionList) + rq := api.Unwrap[api.WebrtcIceCandidateRequest](p.Payload) + if rq == nil { + return api.ErrMalformed + } + w.HandleIceCandidate(*rq, users) default: - w.log.Warn().Msgf("Unknown packet: %+v", p) + w.Log.Warn().Msgf("Unknown packet: %+v", p) } - if err != nil && !errors.Is(err, api.ErrMalformed) { - w.log.Error().Err(err).Send() - err = api.ErrMalformed - } - return + return nil }) } -func (w *Worker) SetLib(list []api.GameInfo) { w.Lib = list } - -func (w *Worker) AppNames() []api.GameInfo { - return w.Lib -} - -func (w *Worker) AddSession(id string) { - // sessions can be uninitialized until the coordinator pushes them to the worker - if w.Sessions == nil { - return - } - - w.Sessions[id] = struct{}{} -} - -func (w *Worker) HadSession(id string) bool { - _, ok := w.Sessions[id] - return ok -} - -func (w *Worker) SetSessions(sessions map[string]struct{}) { - w.Sessions = sessions -} - // In say whether some worker from this region (zone). // Empty region always returns true. func (w *Worker) In(region string) bool { return region == "" || region == w.Zone } @@ -140,52 +62,20 @@ type slotted int32 // there are no players in the room (worker). func (s *slotted) HasSlot() bool { return atomic.LoadInt32((*int32)(s)) == 0 } -// TryReserve reserves the slot only when it's free. -func (s *slotted) TryReserve() bool { - for { - current := atomic.LoadInt32((*int32)(s)) - if current != 0 { - return false - } - if atomic.CompareAndSwapInt32((*int32)(s), 0, 1) { - return true - } - } -} +// Reserve increments user counter of the worker. +func (s *slotted) Reserve() { atomic.AddInt32((*int32)(s), 1) } // UnReserve decrements user counter of the worker. func (s *slotted) UnReserve() { - for { - current := atomic.LoadInt32((*int32)(s)) - if current <= 0 { - // reset to zero - if current < 0 { - if atomic.CompareAndSwapInt32((*int32)(s), current, 0) { - return - } - continue - } - - return - } - - // Regular decrement for positive values - newVal := current - 1 - if atomic.CompareAndSwapInt32((*int32)(s), current, newVal) { - return - } + if atomic.AddInt32((*int32)(s), -1) < 0 { + atomic.StoreInt32((*int32)(s), 0) } } func (s *slotted) FreeSlots() { atomic.StoreInt32((*int32)(s), 0) } func (w *Worker) Disconnect() { - w.Connection.Disconnect() + w.SocketClient.Close() w.RoomId = "" w.FreeSlots() } - -func (w *Worker) PrintInfo() string { - return fmt.Sprintf("id: %v, addr: %v, port: %v, zone: %v, ping addr: %v, tag: %v", - w.Id(), w.Addr, w.Port, w.Zone, w.PingServer, w.Tag) -} diff --git a/pkg/coordinator/worker_test.go b/pkg/coordinator/worker_test.go deleted file mode 100644 index fe4f7a1a..00000000 --- a/pkg/coordinator/worker_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package coordinator - -import ( - "sync" - "sync/atomic" - "testing" -) - -func TestSlotted(t *testing.T) { - t.Run("UnReserve", func(t *testing.T) { - t.Run("BasicDecrement", testUnReserveBasic) - t.Run("PreventUnderflow", testUnReserveUnderflow) - t.Run("ConcurrentDecrement", testUnReserveConcurrent) - }) - - t.Run("TryReserve", func(t *testing.T) { - t.Run("SuccessWhenZero", testTryReserveSuccess) - t.Run("FailWhenNonZero", testTryReserveFailure) - t.Run("ConcurrentReservations", testTryReserveConcurrent) - }) - - t.Run("Integration", func(t *testing.T) { - t.Run("ReserveUnreserveFlow", testReserveUnreserveFlow) - t.Run("FreeSlots", testFreeSlots) - t.Run("HasSlot", testHasSlot) - }) -} - -func testUnReserveBasic(t *testing.T) { - t.Parallel() - var s slotted - - // Initial state - if atomic.LoadInt32((*int32)(&s)) != 0 { - t.Fatal("initial state not zero") - } - - // Test normal decrement - s.TryReserve() // 0 -> 1 - s.UnReserve() - if atomic.LoadInt32((*int32)(&s)) != 0 { - t.Error("failed to decrement to zero") - } - - // Test multiple decrements - s.TryReserve() // 0 -> 1 - s.TryReserve() // 1 -> 2 - s.UnReserve() - s.UnReserve() - if atomic.LoadInt32((*int32)(&s)) != 0 { - t.Error("failed to decrement multiple times") - } -} - -func testUnReserveUnderflow(t *testing.T) { - t.Parallel() - var s slotted - - t.Run("PreventNewUnderflow", func(t *testing.T) { - s.UnReserve() // Start at 0 - if atomic.LoadInt32((*int32)(&s)) != 0 { - t.Error("should remain at 0 when unreserving from 0") - } - }) - - t.Run("FixExistingNegative", func(t *testing.T) { - atomic.StoreInt32((*int32)(&s), -5) - s.UnReserve() - if current := atomic.LoadInt32((*int32)(&s)); current != 0 { - t.Errorf("should fix negative value to 0, got %d", current) - } - }) -} - -func testUnReserveConcurrent(t *testing.T) { - t.Parallel() - - var s slotted - const workers = 100 - var wg sync.WaitGroup - - atomic.StoreInt32((*int32)(&s), int32(workers)) - wg.Add(workers) - - for range workers { - go func() { - defer wg.Done() - s.UnReserve() - }() - } - - wg.Wait() - - if current := atomic.LoadInt32((*int32)(&s)); current != 0 { - t.Errorf("unexpected final value: %d (want 0)", current) - } -} - -func testTryReserveSuccess(t *testing.T) { - t.Parallel() - var s slotted - - if !s.TryReserve() { - t.Error("should succeed when zero") - } - if atomic.LoadInt32((*int32)(&s)) != 1 { - t.Error("failed to increment") - } -} - -func testTryReserveFailure(t *testing.T) { - t.Parallel() - var s slotted - - atomic.StoreInt32((*int32)(&s), 1) - if s.TryReserve() { - t.Error("should fail when non-zero") - } -} - -func testTryReserveConcurrent(t *testing.T) { - t.Parallel() - var s slotted - const workers = 100 - var success int32 - var wg sync.WaitGroup - - wg.Add(workers) - for range workers { - go func() { - defer wg.Done() - if s.TryReserve() { - atomic.AddInt32(&success, 1) - } - }() - } - - wg.Wait() - - if success != 1 { - t.Errorf("unexpected success count: %d (want 1)", success) - } - if atomic.LoadInt32((*int32)(&s)) != 1 { - t.Error("counter not properly incremented") - } -} - -func testReserveUnreserveFlow(t *testing.T) { - t.Parallel() - var s slotted - - // Successful reservation - if !s.TryReserve() { - t.Fatal("failed initial reservation") - } - - // Second reservation should fail - if s.TryReserve() { - t.Error("unexpected successful second reservation") - } - - // Unreserve and try again - s.UnReserve() - if !s.TryReserve() { - t.Error("failed reservation after unreserve") - } -} - -func testFreeSlots(t *testing.T) { - t.Parallel() - var s slotted - - // Set to arbitrary value - atomic.StoreInt32((*int32)(&s), 5) - s.FreeSlots() - if atomic.LoadInt32((*int32)(&s)) != 0 { - t.Error("FreeSlots failed to reset counter") - } -} - -func testHasSlot(t *testing.T) { - t.Parallel() - var s slotted - - if !s.HasSlot() { - t.Error("should have slot when zero") - } - - s.TryReserve() - if s.HasSlot() { - t.Error("shouldn't have slot when reserved") - } -} diff --git a/pkg/coordinator/workerapi.go b/pkg/coordinator/workerapi.go index ccf8c700..5a69e033 100644 --- a/pkg/coordinator/workerapi.go +++ b/pkg/coordinator/workerapi.go @@ -1,68 +1,62 @@ package coordinator -import "github.com/giongto35/cloud-game/v3/pkg/api" +import ( + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/network" +) -func (w *Worker) WebrtcInit(id string) (*api.WebrtcInitResponse, error) { +func (w *Worker) WebrtcInit(id network.Uid) (*api.WebrtcInitResponse, error) { return api.UnwrapChecked[api.WebrtcInitResponse]( - w.Send(api.WebrtcInit, api.WebrtcInitRequest{Id: id})) + w.Send(api.WebrtcInit, api.WebrtcInitRequest{Stateful: api.Stateful{Id: id}})) } -func (w *Worker) WebrtcAnswer(id string, sdp string) { - w.Notify(api.WebrtcAnswer, - api.WebrtcAnswerRequest{Stateful: api.Stateful{Id: id}, Sdp: sdp}) +func (w *Worker) WebrtcAnswer(id network.Uid, sdp string) { + w.Notify(api.WebrtcAnswer, api.WebrtcAnswerRequest{Stateful: api.Stateful{Id: id}, Sdp: sdp}) } -func (w *Worker) WebrtcIceCandidate(id string, candidate string) { - w.Notify(api.WebrtcIce, - api.WebrtcIceCandidateRequest{Stateful: api.Stateful{Id: id}, Candidate: candidate}) +func (w *Worker) WebrtcIceCandidate(id network.Uid, can string) { + w.Notify(api.NewWebrtcIceCandidateRequest(id, can)) } -func (w *Worker) StartGame(id string, req api.GameStartUserRequest) (*api.StartGameResponse, error) { - return api.UnwrapChecked[api.StartGameResponse]( - w.Send(api.StartGame, api.StartGameRequest{ - StatefulRoom: api.StatefulRoom{Id: id, Rid: req.RoomId}, - Game: req.GameName, - PlayerIndex: req.PlayerIndex, - Record: req.Record, - RecordUser: req.RecordUser, - })) +func (w *Worker) StartGame(id network.Uid, app games.AppMeta, req api.GameStartUserRequest) (*api.StartGameResponse, error) { + return api.UnwrapChecked[api.StartGameResponse](w.Send(api.StartGame, api.StartGameRequest{ + StatefulRoom: api.StateRoom(id, req.RoomId), + Game: api.GameInfo{Name: app.Name, Base: app.Base, Path: app.Path, Type: app.Type}, + PlayerIndex: req.PlayerIndex, + Record: req.Record, + RecordUser: req.RecordUser, + })) } -func (w *Worker) QuitGame(id string) { - w.Notify(api.QuitGame, api.GameQuitRequest{Id: id, Rid: w.RoomId}) +func (w *Worker) QuitGame(id network.Uid) { + w.Notify(api.QuitGame, api.GameQuitRequest{StatefulRoom: api.StateRoom(id, w.RoomId)}) } -func (w *Worker) SaveGame(id string) (*api.SaveGameResponse, error) { +func (w *Worker) SaveGame(id network.Uid) (*api.SaveGameResponse, error) { return api.UnwrapChecked[api.SaveGameResponse]( - w.Send(api.SaveGame, api.SaveGameRequest{Id: id, Rid: w.RoomId})) + w.Send(api.SaveGame, api.SaveGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId)})) } -func (w *Worker) LoadGame(id string) (*api.LoadGameResponse, error) { +func (w *Worker) LoadGame(id network.Uid) (*api.LoadGameResponse, error) { return api.UnwrapChecked[api.LoadGameResponse]( - w.Send(api.LoadGame, api.LoadGameRequest{Id: id, Rid: w.RoomId})) + w.Send(api.LoadGame, api.LoadGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId)})) } -func (w *Worker) ChangePlayer(id string, index int) (*api.ChangePlayerResponse, error) { +func (w *Worker) ChangePlayer(id network.Uid, index int) (*api.ChangePlayerResponse, error) { return api.UnwrapChecked[api.ChangePlayerResponse]( - w.Send(api.ChangePlayer, api.ChangePlayerRequest{ - StatefulRoom: api.StatefulRoom{Id: id, Rid: w.RoomId}, - Index: index, - })) + w.Send(api.ChangePlayer, api.ChangePlayerRequest{StatefulRoom: api.StateRoom(id, w.RoomId), Index: index})) } -func (w *Worker) ResetGame(id string) { - w.Notify(api.ResetGame, api.ResetGameRequest{Id: id, Rid: w.RoomId}) +func (w *Worker) ToggleMultitap(id network.Uid) { + _, _ = w.Send(api.ToggleMultitap, api.ToggleMultitapRequest{StatefulRoom: api.StateRoom(id, w.RoomId)}) } -func (w *Worker) RecordGame(id string, rec bool, recUser string) (*api.RecordGameResponse, error) { +func (w *Worker) RecordGame(id network.Uid, rec bool, recUser string) (*api.RecordGameResponse, error) { return api.UnwrapChecked[api.RecordGameResponse]( - w.Send(api.RecordGame, api.RecordGameRequest{ - StatefulRoom: api.StatefulRoom{Id: id, Rid: w.RoomId}, - Active: rec, - User: recUser, - })) + w.Send(api.RecordGame, api.RecordGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId), Active: rec, User: recUser})) } -func (w *Worker) TerminateSession(id string) { - _, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest{Id: id}) +func (w *Worker) TerminateSession(id network.Uid) { + _, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest{Stateful: api.Stateful{Id: id}}) } diff --git a/pkg/coordinator/workerhandlers.go b/pkg/coordinator/workerhandlers.go index 35609e06..ee4e7137 100644 --- a/pkg/coordinator/workerhandlers.go +++ b/pkg/coordinator/workerhandlers.go @@ -1,39 +1,31 @@ package coordinator -import "github.com/giongto35/cloud-game/v3/pkg/api" +import ( + "encoding/base64" + "fmt" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" +) + +func GetConnectionRequest(data string) (*api.ConnectionRequest, error) { + if data == "" { + return nil, fmt.Errorf("no data") + } + return api.UnwrapChecked[api.ConnectionRequest](base64.URLEncoding.DecodeString(data)) +} func (w *Worker) HandleRegisterRoom(rq api.RegisterRoomRequest) { w.RoomId = string(rq) } - func (w *Worker) HandleCloseRoom(rq api.CloseRoomRequest) { if string(rq) == w.RoomId { w.RoomId = "" - w.FreeSlots() } } -func (w *Worker) HandleIceCandidate(rq api.WebrtcIceCandidateRequest, users HasUserRegistry) error { - if usr := users.Find(rq.Id); usr != nil { +func (w *Worker) HandleIceCandidate(rq api.WebrtcIceCandidateRequest, users *com.NetMap[*User]) { + if usr, err := users.Find(string(rq.Id)); err == nil { usr.SendWebrtcIceCandidate(rq.Candidate) } else { - w.log.Warn().Str("id", rq.Id).Msg("unknown session") + w.Log.Warn().Str("id", rq.Id.String()).Msg("unknown session") } - return nil -} - -func (w *Worker) HandleLibGameList(inf api.LibGameListInfo) error { - w.SetLib(inf.List) - return nil -} - -func (w *Worker) HandlePrevSessionList(sess api.PrevSessionInfo) error { - if len(sess.List) == 0 { - return nil - } - - m := make(map[string]struct{}) - for _, v := range sess.List { - m[v] = struct{}{} - } - w.SetSessions(m) - return nil } diff --git a/pkg/encoder/color/bgra/bgra.go b/pkg/encoder/color/bgra/bgra.go deleted file mode 100644 index 39a50c22..00000000 --- a/pkg/encoder/color/bgra/bgra.go +++ /dev/null @@ -1,56 +0,0 @@ -package bgra - -import ( - "image" - "image/color" -) - -type BGRA struct { - image.RGBA -} - -var BGRAModel = color.ModelFunc(func(c color.Color) color.Color { - if _, ok := c.(BGRAColor); ok { - return c - } - r, g, b, a := c.RGBA() - return BGRAColor{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)} -}) - -// BGRAColor represents a BGRA color. -type BGRAColor struct { - R, G, B, A uint8 -} - -func (c BGRAColor) RGBA() (r, g, b, a uint32) { - r = uint32(c.B) - r |= r << 8 - g = uint32(c.G) - g |= g << 8 - b = uint32(c.R) - b |= b << 8 - a = uint32(255) //uint32(c.A) - a |= a << 8 - return -} - -func NewBGRA(r image.Rectangle) *BGRA { - return &BGRA{*image.NewRGBA(r)} -} - -func (p *BGRA) ColorModel() color.Model { return BGRAModel } -func (p *BGRA) At(x, y int) color.Color { - i := p.PixOffset(x, y) - s := p.Pix[i : i+4 : i+4] - return BGRAColor{s[0], s[1], s[2], s[3]} -} - -func (p *BGRA) Set(x, y int, c color.Color) { - i := p.PixOffset(x, y) - c1 := BGRAModel.Convert(c).(BGRAColor) - s := p.Pix[i : i+4 : i+4] - s[0] = c1.R - s[1] = c1.G - s[2] = c1.B - s[3] = 255 -} diff --git a/pkg/encoder/color/rgb565/rgb565.go b/pkg/encoder/color/rgb565/rgb565.go deleted file mode 100644 index 11c66c8b..00000000 --- a/pkg/encoder/color/rgb565/rgb565.go +++ /dev/null @@ -1,62 +0,0 @@ -package rgb565 - -import ( - "encoding/binary" - "image" - "image/color" - "math" -) - -// RGB565 is an in-memory image whose At method returns RGB565 values. -type RGB565 struct { - // Pix holds the image's pixels, as RGB565 values in big-endian format. The pixel at - // (x, y) starts at Pix[(y-p.Rect.Min.Y)*p.Stride + (x-p.Rect.Min.X)*2]. - Pix []uint8 - // Stride is the Pix stride (in bytes) between vertically adjacent pixels. - Stride int - // Rect is the image's bounds. - Rect image.Rectangle -} - -// Model is the model for RGB565 colors. -var Model = color.ModelFunc(func(c color.Color) color.Color { - //if _, ok := c.(Color); ok { - // return c - //} - r, g, b, _ := c.RGBA() - return Color(uint16((r<<8)&rMask | (g<<3)&gMask | (b>>3)&bMask)) -}) - -const ( - rMask = 0b1111100000000000 - gMask = 0b0000011111100000 - bMask = 0b0000000000011111 -) - -// Color represents an RGB565 color. -type Color uint16 - -func (c Color) RGBA() (r, g, b, a uint32) { - return uint32(math.Round(float64(c&rMask>>11)*255.0/31.0)) << 8, - uint32(math.Round(float64(c&gMask>>5)*255.0/63.0)) << 8, - uint32(math.Round(float64(c&bMask)*255.0/31.0)) << 8, - 0xffff -} - -func NewRGB565(r image.Rectangle) *RGB565 { - return &RGB565{Pix: make([]uint8, r.Dx()*r.Dy()<<1), Stride: r.Dx() << 1, Rect: r} -} - -func (p *RGB565) Bounds() image.Rectangle { return p.Rect } -func (p *RGB565) ColorModel() color.Model { return Model } -func (p *RGB565) PixOffset(x, y int) int { return (x-p.Rect.Min.X)<<1 + (y-p.Rect.Min.Y)*p.Stride } - -func (p *RGB565) At(x, y int) color.Color { - i := p.PixOffset(x, y) - return Color(binary.LittleEndian.Uint16(p.Pix[i : i+2])) -} - -func (p *RGB565) Set(x, y int, c color.Color) { - i := p.PixOffset(x, y) - binary.LittleEndian.PutUint16(p.Pix[i:i+2], uint16(Model.Convert(c).(Color))) -} diff --git a/pkg/encoder/color/rgba/rgba.go b/pkg/encoder/color/rgba/rgba.go deleted file mode 100644 index 5bb2e9bc..00000000 --- a/pkg/encoder/color/rgba/rgba.go +++ /dev/null @@ -1,24 +0,0 @@ -package rgba - -import ( - "image" - "image/color" -) - -func ToRGBA(img image.Image, flipped bool) *image.RGBA { - bounds := img.Bounds() - sw, sh := bounds.Dx(), bounds.Dy() - dst := image.NewRGBA(image.Rect(0, 0, sw, sh)) - for y := range sh { - yy := y - if flipped { - yy = sh - y - } - for x := range sw { - px := img.At(x, y) - rgba := color.RGBAModel.Convert(px).(color.RGBA) - dst.Set(x, yy, rgba) - } - } - return dst -} diff --git a/pkg/encoder/encoder.go b/pkg/encoder/encoder.go deleted file mode 100644 index 0372c2c5..00000000 --- a/pkg/encoder/encoder.go +++ /dev/null @@ -1,146 +0,0 @@ -package encoder - -import ( - "fmt" - "sync/atomic" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/encoder/h264" - "github.com/giongto35/cloud-game/v3/pkg/encoder/vpx" - "github.com/giongto35/cloud-game/v3/pkg/encoder/yuv" - "github.com/giongto35/cloud-game/v3/pkg/logger" -) - -type ( - InFrame yuv.RawFrame - OutFrame []byte - Encoder interface { - Encode([]byte) []byte - IntraRefresh() - Info() string - SetFlip(bool) - Shutdown() error - } -) - -type Video struct { - codec Encoder - log *logger.Logger - stopped atomic.Bool - y yuv.Conv - pf yuv.PixFmt - rot uint -} - -type VideoCodec string - -const ( - H264 VideoCodec = "h264" - VP8 VideoCodec = "vp8" - VP9 VideoCodec = "vp9" - VPX VideoCodec = "vpx" -) - -// NewVideoEncoder returns new video encoder. -// By default, it waits for RGBA images on the input channel, -// converts them into YUV I420 format, -// encodes with provided video encoder, and -// puts the result into the output channel. -func NewVideoEncoder(w, h, dw, dh int, scale float64, conf config.Video, log *logger.Logger) (*Video, error) { - var enc Encoder - var err error - codec := VideoCodec(conf.Codec) - switch codec { - case H264: - opts := h264.Options(conf.H264) - enc, err = h264.NewEncoder(dw, dh, conf.Threads, &opts) - case VP8, VP9, VPX: - opts := vpx.Options(conf.Vpx) - v := 8 - if codec == VP9 { - v = 9 - } - enc, err = vpx.NewEncoder(dw, dh, conf.Threads, v, &opts) - default: - err = fmt.Errorf("unsupported codec: %v", conf.Codec) - } - if err != nil { - return nil, err - } - if enc == nil { - return nil, fmt.Errorf("no encoder") - } - - return &Video{codec: enc, y: yuv.NewYuvConv(w, h, scale), log: log}, nil -} - -func (v *Video) Encode(frame InFrame) OutFrame { - if v.stopped.Load() { - return nil - } - - yCbCr := v.y.Process(yuv.RawFrame(frame), v.rot, v.pf) - //defer v.y.Put(&yCbCr) - if bytes := v.codec.Encode(yCbCr); len(bytes) > 0 { - return bytes - } - return nil -} - -func (v *Video) Info() string { - return fmt.Sprintf("%v, libyuv: %v", v.codec.Info(), v.y.Version()) -} - -func (v *Video) SetPixFormat(f uint32) { - if v == nil { - return - } - - switch f { - case 0: - v.pf = yuv.PixFmt(yuv.FourccRgb0) - case 1: - v.pf = yuv.PixFmt(yuv.FourccArgb) - case 2: - v.pf = yuv.PixFmt(yuv.FourccRgbp) - default: - v.pf = yuv.PixFmt(yuv.FourccAbgr) - } -} - -// SetRot sets the de-rotation angle of the frames. -func (v *Video) SetRot(a uint) { - if v == nil { - return - } - - if a > 0 { - v.rot = (a + 180) % 360 - } -} - -// SetFlip tells the encoder to flip the frames vertically. -func (v *Video) SetFlip(b bool) { - if v == nil { - return - } - v.codec.SetFlip(b) -} - -func (v *Video) Stop() { - if v == nil { - return - } - - if v.stopped.Swap(true) { - return - } - v.rot = 0 - - defer func() { v.codec = nil }() - if err := v.codec.Shutdown(); err != nil { - if v.log != nil { - v.log.Error().Err(err).Msg("failed to close the encoder") - } - } -} diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go deleted file mode 100644 index 58259ff4..00000000 --- a/pkg/encoder/h264/x264.go +++ /dev/null @@ -1,206 +0,0 @@ -package h264 - -/* -// See: [x264](https://www.videolan.org/developers/x264.html) -#cgo !st pkg-config: x264 -#cgo st LDFLAGS: -l:libx264.a - -#include "stdint.h" -#include "x264.h" -#include - -typedef struct -{ - x264_t *h; - x264_nal_t *nal; // array of NALs - int i_nal; // number of NALs - int y; // Y size - int uv; // U or V size - x264_picture_t pic; - x264_picture_t pic_out; -} h264; - -h264 *h264_new(x264_param_t *param) -{ - h264 tmp; - x264_picture_t pic; - - tmp.h = x264_encoder_open(param); - if (!tmp.h) - return NULL; - - x264_picture_init(&pic); - pic.img.i_csp = param->i_csp; - pic.img.i_plane = 3; - pic.img.i_stride[0] = param->i_width; - pic.img.i_stride[1] = param->i_width >> 1; - pic.img.i_stride[2] = param->i_width >> 1; - tmp.pic = pic; - - // crashes during x264_picture_clean :/ - //if (x264_picture_alloc(&pic, param->i_csp, param->i_width, param->i_height) < 0) - // return NULL; - - tmp.y = param->i_width * param->i_height; - tmp.uv = tmp.y >> 2; - - h264 *h = malloc(sizeof(h264)); - *h = tmp; - return h; -} - -int h264_encode(h264 *h, uint8_t *yuv) -{ - h->pic.img.plane[0] = yuv; - h->pic.img.plane[1] = h->pic.img.plane[0] + h->y; - h->pic.img.plane[2] = h->pic.img.plane[1] + h->uv; - h->pic.i_pts += 1; - return x264_encoder_encode(h->h, &h->nal, &h->i_nal, &h->pic, &h->pic_out); -} - -void h264_destroy(h264 *h) -{ - if (h == NULL) return; - x264_encoder_close(h->h); - free(h); -} -*/ -import "C" - -import ( - "fmt" - "strings" - "unsafe" -) - -type H264 struct { - h *C.h264 -} - -type Options struct { - Mode string - // Constant Rate Factor (CRF) - // This method allows the encoder to attempt to achieve a certain output quality for the whole file - // when output file size is of less importance. - // The range of the CRF scale is 0–51, where 0 is lossless, 23 is the default, and 51 is the worst quality possible. - Crf uint8 - // vbv-maxrate - MaxRate int - // vbv-bufsize - BufSize int - LogLevel int32 - // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo. - Preset string - // baseline, main, high, high10, high422, high444. - Profile string - // film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency. - Tune string -} - -func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { - ver := Version() - - if ver < 150 { - return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", ver) - } - - if opts == nil { - opts = &Options{ - Mode: "crf", - Crf: 23, - Tune: "zerolatency", - Preset: "superfast", - Profile: "baseline", - } - } - - param := C.x264_param_t{} - - if opts.Preset != "" && opts.Tune != "" { - preset := C.CString(opts.Preset) - tune := C.CString(opts.Tune) - defer C.free(unsafe.Pointer(preset)) - defer C.free(unsafe.Pointer(tune)) - if C.x264_param_default_preset(¶m, preset, tune) < 0 { - return nil, fmt.Errorf("x264: invalid preset/tune name") - } - } else { - C.x264_param_default(¶m) - } - - if opts.Profile != "" { - profile := C.CString(opts.Profile) - defer C.free(unsafe.Pointer(profile)) - if C.x264_param_apply_profile(¶m, profile) < 0 { - return nil, fmt.Errorf("x264: invalid profile name") - } - } - - param.i_bitdepth = 8 - if ver > 155 { - param.i_csp = C.X264_CSP_I420 - } else { - param.i_csp = 1 - } - param.i_width = C.int(w) - param.i_height = C.int(h) - param.i_log_level = C.int(opts.LogLevel) - param.i_keyint_max = 120 - param.i_sync_lookahead = 0 - param.i_threads = C.int(th) - if th != 1 { - param.b_sliced_threads = 1 - } - - param.rc.i_rc_method = C.X264_RC_CRF - param.rc.f_rf_constant = C.float(opts.Crf) - - if strings.ToLower(opts.Mode) == "cbr" { - param.rc.i_rc_method = C.X264_RC_ABR - param.i_nal_hrd = C.X264_NAL_HRD_CBR - } - - if opts.MaxRate > 0 { - param.rc.i_bitrate = C.int(opts.MaxRate) - param.rc.i_vbv_max_bitrate = C.int(opts.MaxRate) - } - if opts.BufSize > 0 { - param.rc.i_vbv_buffer_size = C.int(opts.BufSize) - } - - h264 := C.h264_new(¶m) - if h264 == nil { - return nil, fmt.Errorf("x264: cannot open the encoder") - } - return &H264{h264}, nil -} - -func (e *H264) Encode(yuv []byte) []byte { - bytes := C.h264_encode(e.h, (*C.uchar)(unsafe.SliceData(yuv))) - // we merge multiple NALs stored in **nal into a single byte stream - // ret contains the total size of NALs in bytes, i.e. each e.nal[...].p_payload * i_payload - return unsafe.Slice((*byte)(e.h.nal.p_payload), bytes) -} - -func (e *H264) IntraRefresh() { - // !to implement -} - -func (e *H264) Info() string { return fmt.Sprintf("x264: v%v", Version()) } - -func (e *H264) SetFlip(b bool) { - if b { - (*e.h).pic.img.i_csp |= C.X264_CSP_VFLIP - } else { - (*e.h).pic.img.i_csp &= ^C.X264_CSP_VFLIP - } -} - -func (e *H264) Shutdown() error { - if e.h != nil { - C.h264_destroy(e.h) - } - return nil -} - -func Version() int { return int(C.X264_BUILD) } diff --git a/pkg/encoder/h264/x264_test.go b/pkg/encoder/h264/x264_test.go deleted file mode 100644 index 822e4cec..00000000 --- a/pkg/encoder/h264/x264_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package h264 - -import "testing" - -func TestH264Encode(t *testing.T) { - h264, err := NewEncoder(120, 120, 0, nil) - if err != nil { - t.Error(err) - return - } - data := make([]byte, 120*120*1.5) - h264.Encode(data) - if err := h264.Shutdown(); err != nil { - t.Error(err) - } -} - -func Benchmark(b *testing.B) { - w, h := 1920, 1080 - h264, err := NewEncoder(w, h, 0, nil) - if err != nil { - b.Error(err) - return - } - data := make([]byte, int(float64(w)*float64(h)*1.5)) - for b.Loop() { - h264.Encode(data) - } -} diff --git a/pkg/encoder/yuv/libyuv/libyuv.go b/pkg/encoder/yuv/libyuv/libyuv.go deleted file mode 100644 index 0848c095..00000000 --- a/pkg/encoder/yuv/libyuv/libyuv.go +++ /dev/null @@ -1,274 +0,0 @@ -// Package libyuv contains the wrapper for: https://chromium.googlesource.com/libyuv/libyuv. -// MacOS libs are from: https://packages.macports.org/libyuv/. -package libyuv - -/* -#cgo !darwin,!st LDFLAGS: -lyuv -#cgo !darwin,st LDFLAGS: -l:libyuv.a -l:libjpeg.a -l:libstdc++.a -static-libgcc - -#cgo darwin CFLAGS: -DINCLUDE_LIBYUV_VERSION_H_ -#cgo darwin LDFLAGS: -L${SRCDIR} -lstdc++ -#cgo darwin,amd64 LDFLAGS: -lyuv_darwin_x86_64 -ljpeg -lstdc++ -#cgo darwin,arm64 LDFLAGS: -lyuv_darwin_arm64 -ljpeg -lstdc++ - -#include // for uintptr_t and C99 types -#include - -#if !defined(LIBYUV_API) -#define LIBYUV_API -#endif // LIBYUV_API - -#ifndef INCLUDE_LIBYUV_VERSION_H_ -#include "libyuv/version.h" -#else -#define LIBYUV_VERSION 1874 // darwin static libs version -#endif // INCLUDE_LIBYUV_VERSION_H_ - -// Supported rotation. -typedef enum RotationMode { - kRotate0 = 0, // No rotation. - kRotate90 = 90, // Rotate 90 degrees clockwise. - kRotate180 = 180, // Rotate 180 degrees. - kRotate270 = 270, // Rotate 270 degrees clockwise. -} RotationModeEnum; - -// RGB16 (RGBP fourcc) little endian to I420. -LIBYUV_API -int RGB565ToI420(const uint8_t* src_rgb565, int src_stride_rgb565, uint8_t* dst_y, int dst_stride_y, - uint8_t* dst_u, int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height); - -// Rotate I420 frame. -LIBYUV_API -int I420Rotate(const uint8_t* src_y, int src_stride_y, const uint8_t* src_u, int src_stride_u, - const uint8_t* src_v, int src_stride_v, uint8_t* dst_y, int dst_stride_y, uint8_t* dst_u, - int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height, enum RotationMode mode); - -// RGB15 (RGBO fourcc) little endian to I420. -LIBYUV_API -int ARGB1555ToI420(const uint8_t* src_argb1555, int src_stride_argb1555, uint8_t* dst_y, int dst_stride_y, - uint8_t* dst_u, int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height); - -// ABGR little endian (rgba in memory) to I420. -LIBYUV_API -int ABGRToI420(const uint8_t* src_abgr, int src_stride_abgr, uint8_t* dst_y, int dst_stride_y, uint8_t* dst_u, - int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height); - -// ARGB little endian (bgra in memory) to I420. -LIBYUV_API -int ARGBToI420(const uint8_t* src_argb, int src_stride_argb, uint8_t* dst_y, int dst_stride_y, uint8_t* dst_u, - int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height); - - -void ConvertToI420Custom(const uint8_t* sample, - uint8_t* dst_y, - int dst_stride_y, - uint8_t* dst_u, - int dst_stride_u, - uint8_t* dst_v, - int dst_stride_v, - int src_width, - int src_height, - int crop_width, - int crop_height, - uint32_t fourcc); - -#ifdef __cplusplus -namespace libyuv { -extern "C" { -#endif - -#define FOURCC(a, b, c, d) \ - (((uint32_t)(a)) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24)) - -enum FourCC { - FOURCC_I420 = FOURCC('I', '4', '2', '0'), - FOURCC_ARGB = FOURCC('A', 'R', 'G', 'B'), - FOURCC_ABGR = FOURCC('A', 'B', 'G', 'R'), - FOURCC_RGBO = FOURCC('R', 'G', 'B', 'O'), - FOURCC_RGBP = FOURCC('R', 'G', 'B', 'P'), // rgb565 LE. - FOURCC_ANY = -1, -}; - -inline void ConvertToI420Custom(const uint8_t* sample, - uint8_t* dst_y, - int dst_stride_y, - uint8_t* dst_u, - int dst_stride_u, - uint8_t* dst_v, - int dst_stride_v, - int src_width, - int src_height, - int crop_width, - int crop_height, - uint32_t fourcc) { - const int stride = src_width << 1; - - switch (fourcc) { - case FOURCC_RGBP: - RGB565ToI420(sample, stride, dst_y, dst_stride_y, dst_u, - dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height); - break; - case FOURCC_RGBO: - ARGB1555ToI420(sample, stride, dst_y, dst_stride_y, dst_u, - dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height); - break; - case FOURCC_ARGB: - ARGBToI420(sample, stride << 1, dst_y, dst_stride_y, dst_u, - dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height); - break; - case FOURCC_ABGR: - ABGRToI420(sample, stride << 1, dst_y, dst_stride_y, dst_u, - dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height); - break; - } -} - -void rotateI420(const uint8_t* sample, - uint8_t* dst_y, - int dst_stride_y, - uint8_t* dst_u, - int dst_stride_u, - uint8_t* dst_v, - int dst_stride_v, - int src_width, - int src_height, - int crop_width, - int crop_height, - enum RotationMode rotation, - uint32_t fourcc) { - - uint8_t* tmp_y = dst_y; - uint8_t* tmp_u = dst_u; - uint8_t* tmp_v = dst_v; - int tmp_y_stride = dst_stride_y; - int tmp_u_stride = dst_stride_u; - int tmp_v_stride = dst_stride_v; - - uint8_t* rotate_buffer = NULL; - - int y_size = crop_width * crop_height; - int uv_size = y_size >> 1; - rotate_buffer = (uint8_t*)malloc(y_size + y_size); - if (!rotate_buffer) { - return; - } - dst_y = rotate_buffer; - dst_u = dst_y + y_size; - dst_v = dst_u + uv_size; - dst_stride_y = crop_width; - dst_stride_u = dst_stride_v = crop_width >> 1; - ConvertToI420Custom(sample, dst_y, dst_stride_y, dst_u, dst_stride_u, dst_v, dst_stride_v, - src_width, src_height, crop_width, crop_height, fourcc); - I420Rotate(dst_y, dst_stride_y, dst_u, dst_stride_u, dst_v, - dst_stride_v, tmp_y, tmp_y_stride, tmp_u, tmp_u_stride, - tmp_v, tmp_v_stride, crop_width, crop_height, rotation); - free(rotate_buffer); -} - -// Supported filtering. -typedef enum FilterMode { - kFilterNone = 0, // Point sample; Fastest. - kFilterLinear = 1, // Filter horizontally only. - kFilterBilinear = 2, // Faster than box, but lower quality scaling down. - kFilterBox = 3 // Highest quality. -} FilterModeEnum; - -LIBYUV_API -int I420Scale(const uint8_t *src_y, int src_stride_y, const uint8_t *src_u, int src_stride_u, - const uint8_t *src_v, int src_stride_v, int src_width, int src_height, uint8_t *dst_y, - int dst_stride_y, uint8_t *dst_u, int dst_stride_u, uint8_t *dst_v, int dst_stride_v, - int dst_width, int dst_height, enum FilterMode filtering); - -#ifdef __cplusplus -} // extern "C" -} // namespace libyuv -#endif -*/ -import "C" -import "fmt" - -const FourccRgbp uint32 = C.FOURCC_RGBP -const FourccArgb uint32 = C.FOURCC_ARGB -const FourccAbgr uint32 = C.FOURCC_ABGR -const FourccRgb0 uint32 = C.FOURCC_RGBO - -func Y420(src []byte, dst []byte, _, h, stride int, dw, dh int, rot uint, pix uint32, cx, cy int) { - cw := (dw + 1) / 2 - ch := (dh + 1) / 2 - i0 := dw * dh - i1 := i0 + cw*ch - yStride := dw - cStride := cw - - if rot == 0 { - C.ConvertToI420Custom( - (*C.uchar)(&src[0]), - (*C.uchar)(&dst[0]), - C.int(yStride), - (*C.uchar)(&dst[i0]), - C.int(cStride), - (*C.uchar)(&dst[i1]), - C.int(cStride), - C.int(stride), - C.int(h), - C.int(cx), - C.int(cy), - C.uint32_t(pix)) - } else { - C.rotateI420( - (*C.uchar)(&src[0]), - (*C.uchar)(&dst[0]), - C.int(yStride), - (*C.uchar)(&dst[i0]), - C.int(cStride), - (*C.uchar)(&dst[i1]), - C.int(cStride), - C.int(stride), - C.int(h), - C.int(cx), - C.int(cy), - C.enum_RotationMode(rot), - C.uint32_t(pix)) - } -} - -func Y420Scale(src []byte, dst []byte, w, h int, dw, dh int) { - srcWidthUV, dstWidthUV := (w+1)>>1, (dw+1)>>1 - srcHeightUV, dstHeightUV := (h+1)>>1, (dh+1)>>1 - - srcYPlaneSize, dstYPlaneSize := w*h, dw*dh - srcUVPlaneSize, dstUVPlaneSize := srcWidthUV*srcHeightUV, dstWidthUV*dstHeightUV - - srcStrideY, dstStrideY := w, dw - srcStrideU, dstStrideU := srcWidthUV, dstWidthUV - srcStrideV, dstStrideV := srcWidthUV, dstWidthUV - - srcY := (*C.uchar)(&src[0]) - srcU := (*C.uchar)(&src[srcYPlaneSize]) - srcV := (*C.uchar)(&src[srcYPlaneSize+srcUVPlaneSize]) - - dstY := (*C.uchar)(&dst[0]) - dstU := (*C.uchar)(&dst[dstYPlaneSize]) - dstV := (*C.uchar)(&dst[dstYPlaneSize+dstUVPlaneSize]) - - C.I420Scale( - srcY, - C.int(srcStrideY), - srcU, - C.int(srcStrideU), - srcV, - C.int(srcStrideV), - C.int(w), - C.int(h), - dstY, - C.int(dstStrideY), - dstU, - C.int(dstStrideU), - dstV, - C.int(dstStrideV), - C.int(dw), - C.int(dh), - C.enum_FilterMode(C.kFilterNone)) -} - -func Version() string { return fmt.Sprintf("%v", int(C.LIBYUV_VERSION)) } diff --git a/pkg/encoder/yuv/libyuv/libyuv_darwin_arm64.a b/pkg/encoder/yuv/libyuv/libyuv_darwin_arm64.a deleted file mode 100644 index f399a41c..00000000 Binary files a/pkg/encoder/yuv/libyuv/libyuv_darwin_arm64.a and /dev/null differ diff --git a/pkg/encoder/yuv/libyuv/libyuv_darwin_x86_64.a b/pkg/encoder/yuv/libyuv/libyuv_darwin_x86_64.a deleted file mode 100644 index 63cd5c74..00000000 Binary files a/pkg/encoder/yuv/libyuv/libyuv_darwin_x86_64.a and /dev/null differ diff --git a/pkg/encoder/yuv/yuv.go b/pkg/encoder/yuv/yuv.go deleted file mode 100644 index 4718c7c1..00000000 --- a/pkg/encoder/yuv/yuv.go +++ /dev/null @@ -1,92 +0,0 @@ -package yuv - -import ( - "image" - - "github.com/giongto35/cloud-game/v3/pkg/encoder/yuv/libyuv" -) - -type Conv struct { - w, h int - sw, sh int - scale float64 - frame []byte - frameSc []byte -} - -type RawFrame struct { - Data []byte - Stride int - W, H int -} - -type PixFmt uint32 - -const FourccRgbp = libyuv.FourccRgbp -const FourccArgb = libyuv.FourccArgb -const FourccAbgr = libyuv.FourccAbgr -const FourccRgb0 = libyuv.FourccRgb0 - -func NewYuvConv(w, h int, scale float64) Conv { - if scale < 1 { - scale = 1 - } - - sw, sh := round(w, scale), round(h, scale) - conv := Conv{w: w, h: h, sw: sw, sh: sh, scale: scale} - bufSize := int(float64(w) * float64(h) * 1.5) - - if scale == 1 { - conv.frame = make([]byte, bufSize) - } else { - bufSizeSc := int(float64(sw) * float64(sh) * 1.5) - // [original frame][scaled frame ] - frames := make([]byte, bufSize+bufSizeSc) - conv.frame = frames[:bufSize] - conv.frameSc = frames[bufSize:] - } - - return conv -} - -// Process converts an image to YUV I420 format inside the internal buffer. -func (c *Conv) Process(frame RawFrame, rot uint, pf PixFmt) []byte { - cx, cy := c.w, c.h // crop - if rot == 90 || rot == 270 { - cx, cy = cy, cx - } - - var stride int - switch pf { - case PixFmt(libyuv.FourccRgbp), PixFmt(libyuv.FourccRgb0): - stride = frame.Stride >> 1 - default: - stride = frame.Stride >> 2 - } - - libyuv.Y420(frame.Data, c.frame, frame.W, frame.H, stride, c.w, c.h, rot, uint32(pf), cx, cy) - - if c.scale > 1 { - libyuv.Y420Scale(c.frame, c.frameSc, c.w, c.h, c.sw, c.sh) - return c.frameSc - } - - return c.frame -} - -func (c *Conv) Version() string { return libyuv.Version() } -func round(x int, scale float64) int { return (int(float64(x)*scale) + 1) & ^1 } - -func ToYCbCr(bytes []byte, w, h int) *image.YCbCr { - cw, ch := (w+1)/2, (h+1)/2 - - i0 := w*h + 0*cw*ch - i1 := w*h + 1*cw*ch - i2 := w*h + 2*cw*ch - - yuv := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420) - yuv.Y = bytes[:i0:i0] - yuv.Cb = bytes[i0:i1:i1] - yuv.Cr = bytes[i1:i2:i2] - return yuv -} diff --git a/pkg/games/launcher.go b/pkg/games/launcher.go index 8850ea7f..4ef5512e 100644 --- a/pkg/games/launcher.go +++ b/pkg/games/launcher.go @@ -1,25 +1,18 @@ package games -import ( - "fmt" - "math/rand/v2" - "strconv" - "strings" -) +import "fmt" type Launcher interface { FindAppByName(name string) (AppMeta, error) ExtractAppNameFromUrl(name string) string - GetAppNames() []AppMeta + GetAppNames() []string } type AppMeta struct { - Alias string - Base string - Name string - Path string - System string - Type string + Name string + Type string + Base string + Path string } type GameLauncher struct { @@ -33,32 +26,17 @@ func (gl GameLauncher) FindAppByName(name string) (AppMeta, error) { if game.Path == "" { return AppMeta{}, fmt.Errorf("couldn't find game info for the game %v", name) } - return AppMeta(game), nil + return AppMeta{Name: game.Name, Base: game.Base, Type: game.Type, Path: game.Path}, nil } -func (gl GameLauncher) ExtractAppNameFromUrl(name string) string { return ExtractGame(name) } +func (gl GameLauncher) ExtractAppNameFromUrl(name string) string { + return GetGameNameFromRoomID(name) +} -func (gl GameLauncher) GetAppNames() (apps []AppMeta) { +func (gl GameLauncher) GetAppNames() []string { + var gameList []string for _, game := range gl.lib.GetAll() { - apps = append(apps, AppMeta{Alias: game.Alias, Name: game.Name, System: game.System}) + gameList = append(gameList, game.Name) } - return -} - -const separator = "___" - -// ExtractGame parses game room link returning the name of the game "encoded" there. -func ExtractGame(roomID string) string { - parts := strings.Split(roomID, separator) - if len(parts) > 1 { - return parts[1] - } - return "" -} - -// GenerateRoomID generate a unique room ID containing 16 digits. -// RoomID contains random number + gameName -// Next time when we only get roomID, we can launch game based on gameName -func GenerateRoomID(title string) string { - return strconv.FormatInt(rand.Int64(), 16) + separator + title + return gameList } diff --git a/pkg/games/library.go b/pkg/games/library.go index 5586f464..4d456e2d 100644 --- a/pkg/games/library.go +++ b/pkg/games/library.go @@ -1,30 +1,41 @@ package games import ( - "bufio" + "crypto/md5" "fmt" - "io/fs" + "io" "os" "path/filepath" - "sort" "strings" "sync" "time" "github.com/fsnotify/fsnotify" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) +// Config is an external configuration +type Config struct { + // some directory which is going to be + // the root folder for the library + BasePath string + // a list of supported file extensions + Supported []string + // a list of ignored words in the files + Ignored []string + // print some additional info + Verbose bool + // enable directory changes watch + WatchMode bool +} + // libConf is an optimized internal library configuration type libConf struct { - aliasFile string - path string - supported map[string]struct{} - ignored []string - verbose bool - watchMode bool - sessionPath string + path string + supported map[string]bool + ignored map[string]bool + verbose bool + watchMode bool } type library struct { @@ -40,13 +51,9 @@ type library struct { games map[string]GameMetadata log *logger.Logger - // ids of saved games to find closed sessions - sessions []string - - emuConf WithEmulatorInfo - - // to restrict parallel execution or throttling - // for file watch mode + // to restrict parallel execution + // or throttling + // !CAS would be better mu sync.Mutex isScanning bool isScanningDelayed bool @@ -55,33 +62,31 @@ type library struct { type GameLibrary interface { GetAll() []GameMetadata FindGameByName(name string) GameMetadata - Sessions() []string Scan() } -type WithEmulatorInfo interface { +type FileExtensionWhitelist interface { GetSupportedExtensions() []string - GetEmulator(rom string, path string) string - SessionStoragePath() string } type GameMetadata struct { - Alias string - Base string - Name string // the display name of the game - Path string // the game path relative to the library base path - System string - Type string // the game file extension (e.g. nes, n64) + uid string + // the display name of the game + Name string + // the game file extension (e.g. nes, n64) + Type string + Base string + // the game path relative to the library base path + Path string } -func (g GameMetadata) FullPath(base string) string { - if base == "" { - return filepath.Join(g.Base, g.Path) - } - return filepath.Join(base, g.Path) -} +func (g GameMetadata) FullPath() string { return filepath.Join(g.Base, g.Path) } -func NewLib(conf config.Library, emu WithEmulatorInfo, log *logger.Logger) GameLibrary { +func (c Config) GetSupportedExtensions() []string { return c.Supported } + +func NewLib(conf Config, log *logger.Logger) GameLibrary { return NewLibWhitelisted(conf, conf, log) } + +func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist, log *logger.Logger) GameLibrary { hasSource := true dir, err := filepath.Abs(conf.BasePath) if err != nil { @@ -90,24 +95,21 @@ func NewLib(conf config.Library, emu WithEmulatorInfo, log *logger.Logger) GameL } if len(conf.Supported) == 0 { - conf.Supported = emu.GetSupportedExtensions() + conf.Supported = filter.GetSupportedExtensions() } library := &library{ config: libConf{ - aliasFile: conf.AliasFile, - path: dir, - supported: toMap(conf.Supported), - ignored: conf.Ignored, - verbose: conf.Verbose, - watchMode: conf.WatchMode, - sessionPath: emu.SessionStoragePath(), + path: dir, + supported: toMap(conf.Supported), + ignored: toMap(conf.Ignored), + verbose: conf.Verbose, + watchMode: conf.WatchMode, }, mu: sync.Mutex{}, games: map[string]GameMetadata{}, hasSource: hasSource, log: log, - emuConf: emu, } if conf.WatchMode && hasSource { @@ -117,10 +119,6 @@ func NewLib(conf config.Library, emu WithEmulatorInfo, log *logger.Logger) GameL return library } -func (lib *library) Sessions() []string { - return lib.sessions -} - func (lib *library) GetAll() []GameMetadata { var res []GameMetadata for _, value := range lib.games { @@ -139,39 +137,6 @@ func (lib *library) FindGameByName(name string) GameMetadata { return game } -func (lib *library) AliasFileMaybe() map[string]string { - if lib.config.aliasFile == "" { - return nil - } - - path := filepath.Join(lib.config.path, lib.config.aliasFile) - - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil - } - - file, err := os.Open(path) - if err != nil { - lib.log.Error().Msgf("couldn't open alias file, %v", err) - return nil - } - defer func() { _ = file.Close() }() - - aliases := make(map[string]string) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - if id, alias, found := strings.Cut(scanner.Text(), "="); found { - aliases[id] = alias - } - } - - if err := scanner.Err(); err != nil { - lib.log.Error().Msgf("alias file read error, %v", err) - } - - return aliases -} - func (lib *library) Scan() { if !lib.hasSource { lib.log.Info().Msg("Lib scan... skipped (no source)") @@ -191,78 +156,33 @@ func (lib *library) Scan() { lib.log.Debug().Msg("Lib scan... started") - // game name aliases - aliases := lib.AliasFileMaybe() - - if aliases != nil { - lib.log.Debug().Msgf("Lib game alises found") - lib.log.Debug().Msgf(">>> %v", aliases) - } - start := time.Now() var games []GameMetadata dir := lib.config.path - err := filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - if info == nil || info.IsDir() || !lib.isExtAllowed(path) { - return nil - } + if info != nil && !info.IsDir() && lib.isFileExtensionSupported(path) { + meta := getMetadata(path, dir) + meta.uid = hash(path) - meta := metadata(path, dir) - meta.System = lib.emuConf.GetEmulator(meta.Type, meta.Path) - - if aliases != nil { - if k, ok := aliases[meta.Name]; ok { - meta.Alias = k + if !lib.config.ignored[meta.Name] { + games = append(games, meta) } } - - ignored := false - for _, k := range lib.config.ignored { - if meta.Name == k { - ignored = true - break - } - - if len(k) > 0 && k[0] == '.' && strings.Contains(meta.Name, k) { - ignored = true - break - } - } - - if !ignored { - games = append(games, meta) - } - return nil }) if err != nil { - lib.log.Error().Err(err).Str("dir", dir).Msgf("Lib scan... failed") - return + lib.log.Error().Err(err).Str("dir", dir).Msgf("Lib scan error") } if len(games) > 0 { lib.set(games) } - var sessions []string - dir = lib.config.sessionPath - err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error { - if err != nil { - return err - } - - if info != nil && !info.IsDir() { - sessions = append(sessions, info.Name()) - } - return nil - }) - lib.sessions = sessions - lib.lastScanDuration = time.Since(start) if lib.config.verbose { lib.dumpLibrary() @@ -327,24 +247,23 @@ func (lib *library) set(games []GameMetadata) { lib.games = res } -func (lib *library) isExtAllowed(path string) bool { - ext := strings.ToLower(filepath.Ext(path)) +func (lib *library) isFileExtensionSupported(path string) bool { + ext := filepath.Ext(path) if ext == "" { return false } - _, ok := lib.config.supported[ext[1:]] - return ok + return lib.config.supported[ext[1:]] } -// metadata returns game info from a path -func metadata(path string, basePath string) GameMetadata { +// getMetadata returns game info from a path +func getMetadata(path string, basePath string) GameMetadata { name := filepath.Base(path) ext := filepath.Ext(name) relPath, _ := filepath.Rel(basePath, path) return GameMetadata{ Name: strings.TrimSuffix(name, ext), - Type: strings.ToLower(ext[1:]), + Type: ext[1:], Path: relPath, } } @@ -352,21 +271,8 @@ func metadata(path string, basePath string) GameMetadata { // dumpLibrary printouts the current library snapshot of games func (lib *library) dumpLibrary() { var gameList strings.Builder - - // oof - keys := make([]string, 0, len(lib.games)) - for k := range lib.games { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - game := lib.games[k] - alias := game.Alias - if alias != "" { - alias = fmt.Sprintf("[%s] ", game.Alias) - } - gameList.WriteString(fmt.Sprintf(" %7s %s %s(%s)\n", game.System, game.Name, alias, game.Path)) + for _, game := range lib.games { + gameList.WriteString(" " + game.Name + " (" + game.Path + ")" + "\n") } lib.log.Debug().Msgf("Lib dump\n"+ @@ -375,15 +281,25 @@ func (lib *library) dumpLibrary() { "--------------------------------------------\n"+ "%v"+ "--------------------------------------------\n"+ - "--- ROMs: %03d --- Saves: %04d %10s ---\n"+ + "--- ROMs: %03d %26s ---\n"+ "--------------------------------------------", - gameList.String(), len(lib.games), len(lib.sessions), lib.lastScanDuration) + gameList.String(), len(lib.games), lib.lastScanDuration) } -func toMap(list []string) map[string]struct{} { - res := make(map[string]struct{}, len(list)) +// hash makes an MD5 hash of the string +func hash(str string) string { + h := md5.New() + _, err := io.WriteString(h, str) + if err != nil { + return "" + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func toMap(list []string) map[string]bool { + res := make(map[string]bool) for _, s := range list { - res[s] = struct{}{} + res[s] = true } return res } diff --git a/pkg/games/library_test.go b/pkg/games/library_test.go index 28975647..6693572c 100644 --- a/pkg/games/library_test.go +++ b/pkg/games/library_test.go @@ -1,56 +1,44 @@ package games import ( - "os" - "path/filepath" - "reflect" "testing" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) func TestLibraryScan(t *testing.T) { tests := []struct { directory string - expected []struct { - name string - system string - } + expected []string }{ { directory: "../../assets/games", - expected: []struct { - name string - system string - }{ - {name: "Alwa's Awakening (Demo)", system: "nes"}, - {name: "Sushi The Cat", system: "gba"}, - {name: "anguna", system: "gba"}, + expected: []string{ + "Super Mario Bros", "Sushi The Cat", "anguna", }, }, } - emuConf := config.Emulator{Libretro: config.LibretroConfig{}} - emuConf.Libretro.Cores.List = map[string]config.LibretroCoreConfig{ - "nes": {Roms: []string{"nes"}}, - "gba": {Roms: []string{"gba"}}, - } - - l := logger.NewConsole(false, "w", false) + l := logger.NewConsole(false, "w", true) for _, test := range tests { - library := NewLib(config.Library{ + library := NewLib(Config{ BasePath: test.directory, Supported: []string{"gba", "zip", "nes"}, - }, emuConf, l) + Ignored: []string{"neogeo", "pgm"}, + }, l) library.Scan() games := library.GetAll() + list := _map(games, func(meta GameMetadata) string { + return meta.Name + }) + + // ^2 complexity (; all := true for _, expect := range test.expected { found := false - for _, game := range games { - if game.Name == expect.name && (expect.system != "" && expect.system == game.System) { + for _, game := range list { + if game == expect { found = true break } @@ -58,67 +46,15 @@ func TestLibraryScan(t *testing.T) { all = all && found } if !all { - t.Errorf("Test fail for dir %v with %v != %v", test.directory, games, test.expected) + t.Errorf("Test fail for dir %v with %v != %v", test.directory, list, test.expected) } } } -func TestAliasFileMaybe(t *testing.T) { - lib := &library{ - config: libConf{ - aliasFile: "alias", - path: os.TempDir(), - }, - log: logger.NewConsole(false, "w", false), - } - - contents := "a=b\nc=d\n" - - path := filepath.Join(lib.config.path, lib.config.aliasFile) - if err := os.WriteFile(path, []byte(contents), 0644); err != nil { - t.Error(err) - } - defer func() { - if err := os.RemoveAll(path); err != nil { - t.Error(err) - } - }() - - want := map[string]string{} - want["a"] = "b" - want["c"] = "d" - - aliases := lib.AliasFileMaybe() - - if !reflect.DeepEqual(aliases, want) { - t.Errorf("AliasFileMaybe() = %v, want %v", aliases, want) - } -} - -func TestAliasFileMaybeNot(t *testing.T) { - lib := &library{ - config: libConf{ - path: os.TempDir(), - }, - log: logger.NewConsole(false, "w", false), - } - - aliases := lib.AliasFileMaybe() - if aliases != nil { - t.Errorf("should be nil, but %v", aliases) - } -} - -func Benchmark(b *testing.B) { - log := logger.Default() - logger.SetGlobalLevel(logger.Disabled) - library := NewLib(config.Library{ - BasePath: "../../assets/games", - Supported: []string{"gba", "zip", "nes"}, - }, config.Emulator{}, log) - - for b.Loop() { - library.Scan() - _ = library.GetAll() +func _map(vs []GameMetadata, f func(info GameMetadata) string) []string { + vsm := make([]string, len(vs)) + for i, v := range vs { + vsm[i] = f(v) } + return vsm } diff --git a/pkg/games/session.go b/pkg/games/session.go new file mode 100644 index 00000000..79ea326d --- /dev/null +++ b/pkg/games/session.go @@ -0,0 +1,26 @@ +package games + +import ( + "math/rand" + "strconv" + "strings" +) + +const separator = "___" + +// GetGameNameFromRoomID parse roomID to get roomID and gameName. +func GetGameNameFromRoomID(roomID string) string { + parts := strings.Split(roomID, separator) + if len(parts) > 1 { + return parts[1] + } + return "" +} + +// GenerateRoomID generate a unique room ID containing 16 digits. +func GenerateRoomID(gameName string) string { + // RoomID contains random number + gameName + // Next time when we only get roomID, we can launch game based on gameName + roomID := strconv.FormatInt(rand.Int63(), 16) + separator + gameName + return roomID +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 53c3a2d2..02945982 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -27,16 +27,6 @@ const ( // Values less than TraceLevel are handled as numbers. ) -const ( - ClientField = "c" - DirectionField = "d" - MarkNone = " " - MarkIn = "←" - MarkOut = "→" - MarkPlus = "+" - MarkCross = "x" -) - func (l Level) String() string { switch l { case TraceLevel: @@ -91,12 +81,12 @@ func NewConsole(isDebug bool, tag string, noColor bool) *Logger { zerolog.LevelFieldName, zerolog.CallerFieldName, "s", - DirectionField, - ClientField, + "d", + "c", "m", zerolog.MessageFieldName, }, - FieldsExclude: []string{"s", ClientField, DirectionField, "m", "pid"}, + FieldsExclude: []string{"s", "c", "d", "m", "pid"}, } if output.NoColor { @@ -113,8 +103,8 @@ func NewConsole(isDebug bool, tag string, noColor bool) *Logger { Str("pid", fmt.Sprintf("%4x", pid)). Str("s", tag). Str("m", ""). - Str(DirectionField, MarkNone). - Str(ClientField, MarkNone). + Str("d", " "). + Str("c", " "). // Str("tag", tag). use when a file writer Timestamp().Logger() return &Logger{logger: &logger} diff --git a/pkg/monitoring/monitoring.go b/pkg/monitoring/monitoring.go index 6ed7d3da..b153f9c5 100644 --- a/pkg/monitoring/monitoring.go +++ b/pkg/monitoring/monitoring.go @@ -7,23 +7,23 @@ import ( "strconv" "github.com/VictoriaMetrics/metrics" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/network/httpx" + "github.com/giongto35/cloud-game/v2/pkg/config/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network/httpx" ) const debugEndpoint = "/debug/pprof" const metricsEndpoint = "/metrics" type Monitoring struct { - conf config.Monitoring + conf monitoring.Config server *httpx.Server log *logger.Logger } // New creates new monitoring service. // The tag param specifies owner label for logs. -func New(conf config.Monitoring, baseAddr string, log *logger.Logger) *Monitoring { +func New(conf monitoring.Config, baseAddr string, log *logger.Logger) *Monitoring { serv, err := httpx.NewServer( net.JoinHostPort(baseAddr, strconv.Itoa(conf.Port)), func(s *httpx.Server) httpx.Handler { @@ -52,7 +52,6 @@ func New(conf config.Monitoring, baseAddr string, log *logger.Logger) *Monitorin return h }, httpx.WithPortRoll(true), - httpx.HttpsRedirect(false), httpx.WithLogger(log), ) if err != nil { diff --git a/pkg/network/address.go b/pkg/network/address.go index 318c9cd5..5fcaca49 100644 --- a/pkg/network/address.go +++ b/pkg/network/address.go @@ -2,7 +2,6 @@ package network import ( "errors" - "net" "strconv" "strings" ) @@ -13,17 +12,15 @@ func (a *Address) Port() (int, error) { if len(string(*a)) == 0 { return 0, errors.New("no address") } - addr := replaceAllExceptLast(string(*a), ":", "_") - _, port, err := net.SplitHostPort(addr) - if err != nil { - return 0, err + parts := strings.Split(string(*a), ":") + var port string + if len(parts) == 1 { + port = parts[0] + } else { + port = parts[len(parts)-1] } if val, err := strconv.Atoi(port); err == nil { return val, nil } return 0, errors.New("port is not a number") } - -func replaceAllExceptLast(s, c, x string) string { - return strings.Replace(s, c, x, strings.Count(s, c)-1) -} diff --git a/pkg/network/httpx/listener.go b/pkg/network/httpx/listener.go index 045cf641..19e19392 100644 --- a/pkg/network/httpx/listener.go +++ b/pkg/network/httpx/listener.go @@ -5,7 +5,7 @@ import ( "net" "strconv" - "github.com/giongto35/cloud-game/v3/pkg/network/socket" + "github.com/giongto35/cloud-game/v2/pkg/network/socket" ) const listenAttempts = 42 diff --git a/pkg/network/httpx/listener_test.go b/pkg/network/httpx/listener_test.go index c8d06eb4..49be5aac 100644 --- a/pkg/network/httpx/listener_test.go +++ b/pkg/network/httpx/listener_test.go @@ -13,6 +13,7 @@ func TestListenerCreation(t *testing.T) { random bool error bool }{ + {addr: ":80", port: "80"}, {addr: ":", random: true}, {addr: ":0", random: true}, {addr: "", random: true}, @@ -37,14 +38,14 @@ func TestListenerCreation(t *testing.T) { continue } + defer func() { _ = ls.Close() }() + addr := ls.Addr().(*net.TCPAddr) port := ls.GetPort() hasPort := port > 0 isPortSame := strings.HasSuffix(addr.String(), ":"+test.port) - _ = ls.Close() - if test.random { if !hasPort { t.Errorf("expected a random port, got %v", port) @@ -63,7 +64,7 @@ func TestFailOnPortInUse(t *testing.T) { if err != nil { t.Errorf("expected no error, got %v", err) } - defer func() { _ = a.Close() }() + defer a.Close() _, err = NewListener(":3333", false) if err == nil { t.Errorf("expected busy port error, but got none") @@ -75,10 +76,10 @@ func TestListenerPortRoll(t *testing.T) { if err != nil { t.Errorf("expected no error, got %v", err) } - defer func() { _ = a.Close() }() + defer a.Close() b, err := NewListener("127.0.0.1:3333", true) if err != nil { t.Errorf("expected no port error, but got %v", err) } - _ = b.Close() + b.Close() } diff --git a/pkg/network/httpx/options.go b/pkg/network/httpx/options.go index 0a30de7e..930ae08e 100644 --- a/pkg/network/httpx/options.go +++ b/pkg/network/httpx/options.go @@ -3,8 +3,8 @@ package httpx import ( "time" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/config/shared" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) type ( @@ -47,7 +47,7 @@ func HttpsRedirect(redirect bool) Option { func WithPortRoll(roll bool) Option { return func(opts *Options) { opts.PortRoll = roll } } func WithZone(zone string) Option { return func(opts *Options) { opts.Zone = zone } } -func WithServerConfig(conf config.Server) Option { +func WithServerConfig(conf shared.Server) Option { return func(opts *Options) { opts.Https = conf.Https opts.HttpsCert = conf.Tls.HttpsCert diff --git a/pkg/network/httpx/server.go b/pkg/network/httpx/server.go index 5486c05c..a5e40063 100644 --- a/pkg/network/httpx/server.go +++ b/pkg/network/httpx/server.go @@ -1,13 +1,12 @@ package httpx import ( - "errors" "fmt" "net/http" "net/url" "time" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" "golang.org/x/crypto/acme/autocert" ) @@ -54,9 +53,14 @@ func (m *Mux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) m.ServeMux.HandleFunc(m.prefix+pattern, handler) return m } - func (m *Mux) ServeHTTP(w ResponseWriter, r *Request) { m.ServeMux.ServeHTTP(w, r) } +func NotFound(w ResponseWriter) { http.Error(w, "404 page not found", http.StatusNotFound) } + +func (m *Mux) Static(prefix string, path string) *Mux { + return m.Handle(m.prefix+prefix, http.StripPrefix(prefix, http.FileServer(http.Dir(path)))) +} + func NewServer(address string, handler func(*Server) Handler, options ...Option) (*Server, error) { opts := &Options{ Https: false, @@ -120,12 +124,12 @@ func (s *Server) run() { s.log.Debug().Msgf("Starting %s server on %s", protocol, s.Addr) if s.opts.Https && s.opts.HttpsRedirect { - if rdr, err := s.redirection(); err == nil { - s.redirect = rdr - go s.redirect.Run() - } else { + rdr, err := s.redirection() + if err != nil { s.log.Error().Err(err).Msg("couldn't init redirection server") } + s.redirect = rdr + go s.redirect.Run() } var err error @@ -134,12 +138,13 @@ func (s *Server) run() { } else { err = s.Serve(*s.listener) } - - if errors.Is(err, http.ErrServerClosed) { + switch err { + case http.ErrServerClosed: s.log.Debug().Msgf("%s server was closed", protocol) return + default: + s.log.Error().Err(err) } - s.log.Error().Err(err) } func (s *Server) Stop() error { @@ -165,7 +170,6 @@ func (s *Server) redirection() (*Server, error) { address = s.opts.HttpsDomain } addr := buildAddress(address, s.opts.Zone, *s.listener) - s.log.Info().Str("addr", addr).Msg("Start HTTPS redirect server") srv, err := NewServer(s.opts.HttpsRedirectAddress, func(serv *Server) Handler { h := NewServeMux("") @@ -187,7 +191,6 @@ func (s *Server) redirection() (*Server, error) { }, WithLogger(s.log), ) + s.log.Info().Str("addr", addr).Msg("Start HTTPS redirect server") return srv, err } - -func FileServer(dir string) http.Handler { return http.FileServer(http.Dir(dir)) } diff --git a/pkg/network/retry.go b/pkg/network/retry.go deleted file mode 100644 index 9fb706dc..00000000 --- a/pkg/network/retry.go +++ /dev/null @@ -1,19 +0,0 @@ -package network - -import "time" - -const retry = 10 * time.Second - -type Retry struct { - t time.Duration - fail bool -} - -func NewRetry() Retry { - return Retry{t: retry} -} - -func (r *Retry) Fail() *Retry { r.fail = true; time.Sleep(r.t); return r } -func (r *Retry) Multiply(x int) { r.t *= time.Duration(x) } -func (r *Retry) Success() { r.t = retry; r.fail = false } -func (r *Retry) Time() time.Duration { return r.t } diff --git a/pkg/network/uid.go b/pkg/network/uid.go new file mode 100644 index 00000000..5d5b464a --- /dev/null +++ b/pkg/network/uid.go @@ -0,0 +1,22 @@ +package network + +import "github.com/rs/xid" + +type Uid string + +func NewUid() Uid { return Uid(xid.New().String()) } + +func ValidUid(u Uid) bool { + _, err := xid.FromString(string(u)) + return err == nil +} +func (u Uid) Empty() bool { return u == "" } +func (u Uid) Short() string { return string(u)[:3] + "." + string(u)[len(u)-3:] } +func (u Uid) String() string { return string(u) } +func (u Uid) Machine() string { + id, err := xid.FromString(string(u)) + if err != nil { + return "" + } + return string(id.Machine()) +} diff --git a/pkg/network/webrtc/factory.go b/pkg/network/webrtc/factory.go index c8b37ab8..84c30c45 100644 --- a/pkg/network/webrtc/factory.go +++ b/pkg/network/webrtc/factory.go @@ -4,13 +4,12 @@ import ( "fmt" "net" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/network/socket" - "github.com/pion/ice/v4" + conf "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network/socket" "github.com/pion/interceptor" "github.com/pion/interceptor/pkg/report" - "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v3" ) type ApiFactory struct { @@ -20,7 +19,7 @@ type ApiFactory struct { type ModApiFun func(m *webrtc.MediaEngine, i *interceptor.Registry, s *webrtc.SettingEngine) -func NewApiFactory(conf config.Webrtc, log *logger.Logger, mod ModApiFun) (api *ApiFactory, err error) { +func NewApiFactory(conf conf.Webrtc, log *logger.Logger, mod ModApiFun) (api *ApiFactory, err error) { m := &webrtc.MediaEngine{} if err = m.RegisterDefaultCodecs(); err != nil { return @@ -73,9 +72,6 @@ func NewApiFactory(conf config.Webrtc, log *logger.Logger, mod ModApiFun) (api * log.Info().Msgf("The NAT mapping is active for %v", conf.IceIpMap) } - s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled) - s.EnableSCTPZeroChecksum(true) - if mod != nil { mod(m, i, &s) } diff --git a/pkg/network/webrtc/pionlogger.go b/pkg/network/webrtc/pionlogger.go index ec546f3a..4d583e95 100644 --- a/pkg/network/webrtc/pionlogger.go +++ b/pkg/network/webrtc/pionlogger.go @@ -4,7 +4,7 @@ import ( "github.com/pion/logging" "github.com/rs/zerolog" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) type PionLog struct { diff --git a/pkg/network/webrtc/webrtc.go b/pkg/network/webrtc/webrtc.go index 37b99e79..39ea1f47 100644 --- a/pkg/network/webrtc/webrtc.go +++ b/pkg/network/webrtc/webrtc.go @@ -3,12 +3,10 @@ package webrtc import ( "fmt" "strings" - "sync" - "time" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/pion/webrtc/v4" - "github.com/pion/webrtc/v4/pkg/media" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v3/pkg/media" ) type Peer struct { @@ -17,87 +15,61 @@ type Peer struct { log *logger.Logger OnMessage func(data []byte) - a *webrtc.TrackLocalStaticSample - v *webrtc.TrackLocalStaticSample - d *webrtc.DataChannel + aTrack *webrtc.TrackLocalStaticSample + vTrack *webrtc.TrackLocalStaticSample + dTrack *webrtc.DataChannel } -var samplePool sync.Pool - type Decoder func(data string, obj any) error func New(log *logger.Logger, api *ApiFactory) *Peer { return &Peer{api: api, log: log} } func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp any, err error) { - if p.conn != nil && p.conn.ConnectionState() == webrtc.PeerConnectionStateConnected { + if p.IsConnected() { return } - p.log.Debug().Msg("WebRTC start") + p.log.Info().Msg("WebRTC start") if p.conn, err = p.api.NewPeer(); err != nil { - return + return "", err } p.conn.OnICECandidate(p.handleICECandidate(onICECandidate)) // plug in the [video] track (out) - video, err := newTrack("video", "video", vCodec) + video, err := newTrack("video", "game-video", vCodec) if err != nil { return "", err } - vs, err := p.conn.AddTrack(video) - if err != nil { + if _, err = p.conn.AddTrack(video); err != nil { return "", err } - // Read incoming RTCP packets - go func() { - rtcpBuf := make([]byte, 1500) - for { - _, _, rtcpErr := vs.Read(rtcpBuf) - if rtcpErr != nil { - return - } - } - }() - p.v = video + p.vTrack = video p.log.Debug().Msgf("Added [%s] track", video.Codec().MimeType) // plug in the [audio] track (out) - audio, err := newTrack("audio", "audio", aCodec) + audio, err := newTrack("audio", "game-audio", aCodec) if err != nil { return "", err } - as, err := p.conn.AddTrack(audio) - if err != nil { + if _, err = p.conn.AddTrack(audio); err != nil { return "", err } - // Read incoming RTCP packets - go func() { - rtcpBuf := make([]byte, 1500) - for { - _, _, rtcpErr := as.Read(rtcpBuf) - if rtcpErr != nil { - return - } - } - }() p.log.Debug().Msgf("Added [%s] track", audio.Codec().MimeType) - p.a = audio + p.aTrack = audio - err = p.AddChannel("data", func(data []byte) { - if len(data) == 0 || p.OnMessage == nil { - return - } - p.OnMessage(data) - }) - if err != nil { + // plug in the [input] data channel (in) + if err = p.addInputChannel("game-input"); err != nil { return "", err } + p.log.Debug().Msg("Added [input/bytes] chan") - p.conn.OnICEConnectionStateChange(p.handleICEState(func() { p.log.Info().Msg("Connected") })) + p.conn.OnICEConnectionStateChange(p.handleICEState(func() { + p.log.Info().Msg("Start streaming") + })) // Stream provider supposes to send offer offer, err := p.conn.CreateOffer(nil) if err != nil { return "", err } - p.log.Debug().Msg("Created Offer") + p.log.Info().Msg("Created Offer") err = p.conn.SetLocalDescription(offer) if err != nil { @@ -107,35 +79,6 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp return offer, nil } -func (p *Peer) SendAudio(dat []byte, dur int32) { - if err := p.send(dat, int64(dur), p.a.WriteSample); err != nil { - p.log.Error().Err(err).Send() - } -} - -func (p *Peer) SendVideo(data []byte, dur int32) { - if err := p.send(data, int64(dur), p.v.WriteSample); err != nil { - p.log.Error().Err(err).Send() - } -} - -func (p *Peer) SendData(data []byte) { _ = p.d.Send(data) } - -func (p *Peer) send(data []byte, duration int64, fn func(media.Sample) error) error { - sample, _ := samplePool.Get().(*media.Sample) - if sample == nil { - sample = new(media.Sample) - } - sample.Data = data - sample.Duration = time.Duration(duration) - err := fn(*sample) - if err != nil { - return err - } - samplePool.Put(sample) - return nil -} - func (p *Peer) SetRemoteSDP(sdp string, decoder Decoder) error { var answer webrtc.SessionDescription if err := decoder(sdp, &answer); err != nil { @@ -149,6 +92,10 @@ func (p *Peer) SetRemoteSDP(sdp string, decoder Decoder) error { return nil } +func (p *Peer) WriteVideo(sample *media.Sample) error { return p.vTrack.WriteSample(*sample) } + +func (p *Peer) WriteAudio(sample *media.Sample) error { return p.aTrack.WriteSample(*sample) } + func newTrack(id string, label string, codec string) (*webrtc.TrackLocalStaticSample, error) { codec = strings.ToLower(codec) var mime string @@ -164,8 +111,6 @@ func newTrack(id string, label string, codec string) (*webrtc.TrackLocalStaticSa mime = webrtc.MimeTypeH264 case "vpx", "vp8": mime = webrtc.MimeTypeVP8 - case "vp9": - mime = webrtc.MimeTypeVP9 } } if mime == "" { @@ -196,12 +141,8 @@ func (p *Peer) handleICEState(onConnect func()) func(webrtc.ICEConnectionState) // nothing case webrtc.ICEConnectionStateConnected: onConnect() - case webrtc.ICEConnectionStateFailed: - p.log.Error().Msgf("WebRTC connection fail! connection: %v, ice: %v, gathering: %v, signalling: %v", - p.conn.ConnectionState(), p.conn.ICEConnectionState(), p.conn.ICEGatheringState(), - p.conn.SignalingState()) - p.Disconnect() - case webrtc.ICEConnectionStateClosed, + case webrtc.ICEConnectionStateFailed, + webrtc.ICEConnectionStateClosed, webrtc.ICEConnectionStateDisconnected: p.Disconnect() default: @@ -211,9 +152,6 @@ func (p *Peer) handleICEState(onConnect func()) func(webrtc.ICEConnectionState) } func (p *Peer) AddCandidate(candidate string, decoder Decoder) error { - // !to add test when the connection is closed but it is still - // receiving ice candidates - var iceCandidate webrtc.ICECandidateInit if err := decoder(candidate, &iceCandidate); err != nil { return err @@ -225,43 +163,50 @@ func (p *Peer) AddCandidate(candidate string, decoder Decoder) error { return nil } -func (p *Peer) AddChannel(label string, onMessage func([]byte)) error { - ch, err := p.addDataChannel(label) - if err != nil { - return err - } - if label == "data" { - p.d = ch - } - ch.OnMessage(func(m webrtc.DataChannelMessage) { onMessage(m.Data) }) - p.log.Debug().Msgf("Added [%v] chan", label) - return nil -} - func (p *Peer) Disconnect() { if p.conn == nil { return } if p.conn.ConnectionState() < webrtc.PeerConnectionStateDisconnected { - // ignore this due to DTLS fatal: conn is closed _ = p.conn.Close() } + p.conn = nil p.log.Debug().Msg("WebRTC stop") } -// addDataChannel creates new WebRTC data channel. +func (p *Peer) IsConnected() bool { + return p.conn != nil && p.conn.ConnectionState() == webrtc.PeerConnectionStateConnected +} + +func (p *Peer) SendMessage(data []byte) { _ = p.dTrack.Send(data) } + +// addInputChannel creates a new WebRTC data channel for user input. // Default params -- ordered: true, negotiated: false. -func (p *Peer) addDataChannel(label string) (*webrtc.DataChannel, error) { +func (p *Peer) addInputChannel(label string) error { ch, err := p.conn.CreateDataChannel(label, nil) if err != nil { - return nil, err + return err } ch.OnOpen(func() { - p.log.Debug().Uint16("id", *ch.ID()).Msgf("Data channel [%v] opened", ch.Label()) + p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).Msg("Data channel [input] opened") }) ch.OnError(p.logx) - ch.OnClose(func() { p.log.Debug().Msgf("Data channel [%v] has been closed", ch.Label()) }) - return ch, nil + ch.OnMessage(func(mess webrtc.DataChannelMessage) { + if len(mess.Data) == 0 { + return + } + // echo string messages (e.g. ping/pong) + if mess.IsString { + p.logx(ch.Send(mess.Data)) + return + } + if p.OnMessage != nil { + p.OnMessage(mess.Data) + } + }) + p.dTrack = ch + ch.OnClose(func() { p.log.Debug().Msg("Data channel [input] has been closed") }) + return nil } func (p *Peer) logx(err error) { p.log.Error().Err(err) } diff --git a/pkg/network/websocket/websocket.go b/pkg/network/websocket/websocket.go index 85fa3b9e..3a53f37d 100644 --- a/pkg/network/websocket/websocket.go +++ b/pkg/network/websocket/websocket.go @@ -2,108 +2,48 @@ package websocket import ( "crypto/tls" + "errors" "net" "net/http" "net/url" "sync" + "sync/atomic" "time" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/gorilla/websocket" ) const ( maxMessageSize = 10 * 1024 pingTime = pongTime * 9 / 10 - pongTime = 7 * time.Second - writeWait = 5 * time.Second + pongTime = 5 * time.Second + writeWait = 1 * time.Second ) -type Client struct { - Dialer *websocket.Dialer -} - -type Server struct { - Upgrader *Upgrader -} - -type Connection struct { - alive bool - callback MessageHandler - conn deadlineConn - done chan struct{} - errorHandler ErrorHandler - once sync.Once - pingPong bool - send chan []byte - messSize int64 -} - -type deadlineConn struct { - *websocket.Conn - wt time.Duration - mu sync.Mutex // needed for concurrent writes of Gorilla -} - -type MessageHandler func([]byte, error) -type ErrorHandler func(err error) - -type Upgrader struct { - websocket.Upgrader - Origin string -} - -var DefaultDialer = websocket.DefaultDialer -var DefaultUpgrader = Upgrader{Upgrader: websocket.Upgrader{ - ReadBufferSize: 2048, - WriteBufferSize: 2048, - WriteBufferPool: &sync.Pool{}, - EnableCompression: true, -}} - -func NewUpgrader(origin string) *Upgrader { - u := DefaultUpgrader - switch { - case origin == "*": - u.CheckOrigin = func(r *http.Request) bool { return true } - case origin != "": - u.CheckOrigin = func(r *http.Request) bool { return r.Header.Get("Origin") == origin } +type ( + WS struct { + conn deadlineConn + send chan []byte + OnMessage WSMessageHandler + pingPong bool + once sync.Once + Done chan struct{} + closed uint32 + log *logger.Logger + server bool } - return &u -} - -func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*websocket.Conn, error) { - if u.Origin != "" { - w.Header().Set("Access-Control-Allow-Origin", u.Origin) + WSMessageHandler func(message []byte, err error) + Upgrader struct { + websocket.Upgrader + origin string } - return u.Upgrader.Upgrade(w, r, responseHeader) -} - -func (s *Server) Connect(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Connection, error) { - u := s.Upgrader - if u == nil { - u = &DefaultUpgrader + deadlineConn struct { + *websocket.Conn + wt time.Duration + mu sync.Mutex // needed for concurrent writes of Gorilla } - conn, err := u.Upgrade(w, r, responseHeader) - if err != nil { - return nil, err - } - return newSocket(conn, true), nil -} - -func (c *Client) Connect(address url.URL) (*Connection, error) { - dialer := c.Dialer - if dialer == nil { - dialer = DefaultDialer - } - if address.Scheme == "wss" { - dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } - conn, _, err := dialer.Dial(address.String(), nil) - if err != nil { - return nil, err - } - return newSocket(conn, false), nil -} +) func (conn *deadlineConn) write(t int, mess []byte) error { conn.mu.Lock() @@ -120,27 +60,73 @@ func (conn *deadlineConn) writeControl(messageType int, data []byte, deadline ti return conn.Conn.WriteControl(messageType, data, deadline) } -// reader pumps messages from the websocket connection to the SetMessageHandler callback. +var DefaultUpgrader = Upgrader{ + Upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + WriteBufferPool: &sync.Pool{}, + EnableCompression: true, + }, +} + +var ErrNilConnection = errors.New("nil connection") + +func NewUpgrader(origin string) *Upgrader { + u := DefaultUpgrader + switch { + case origin == "*": + u.CheckOrigin = func(r *http.Request) bool { return true } + case origin != "": + u.CheckOrigin = func(r *http.Request) bool { return r.Header.Get("Origin") == origin } + } + return &u +} + +func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*websocket.Conn, error) { + if u.origin != "" { + w.Header().Set("Access-Control-Allow-Origin", u.origin) + } + return u.Upgrader.Upgrade(w, r, responseHeader) +} + +func NewServerWithConn(conn *websocket.Conn, log *logger.Logger) (*WS, error) { + if conn == nil { + return nil, ErrNilConnection + } + return newSocket(conn, true, true, log), nil +} + +func NewClient(address url.URL, log *logger.Logger) (*WS, error) { + dialer := websocket.DefaultDialer + if address.Scheme == "wss" { + dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + conn, _, err := dialer.Dial(address.String(), nil) + if err != nil { + return nil, err + } + return newSocket(conn, false, false, log), nil +} + +func (ws *WS) IsServer() bool { return ws.server } + +// reader pumps messages from the websocket connection to the OnMessage callback. // Blocking, must be called as goroutine. Serializes all websocket reads. -func (c *Connection) reader() { +func (ws *WS) reader() { defer func() { - close(c.send) - c.close() + atomic.StoreUint32(&ws.closed, 1) + close(ws.send) + ws.shutdown() }() - var s int64 = maxMessageSize - if c.messSize > 0 { - s = c.messSize - } - c.conn.SetReadLimit(s) - - _ = c.conn.SetReadDeadline(time.Now().Add(pongTime)) - if c.pingPong { - c.conn.SetPongHandler(func(string) error { _ = c.conn.SetReadDeadline(time.Now().Add(pongTime)); return nil }) + ws.conn.SetReadLimit(maxMessageSize) + _ = ws.conn.SetReadDeadline(time.Now().Add(pongTime)) + if ws.pingPong { + ws.conn.SetPongHandler(func(string) error { _ = ws.conn.SetReadDeadline(time.Now().Add(pongTime)); return nil }) } else { - c.conn.SetPingHandler(func(string) error { - _ = c.conn.SetReadDeadline(time.Now().Add(pongTime)) - err := c.conn.writeControl(websocket.PongMessage, nil, time.Now().Add(writeWait)) + ws.conn.SetPingHandler(func(string) error { + _ = ws.conn.SetReadDeadline(time.Now().Add(pongTime)) + err := ws.conn.writeControl(websocket.PongMessage, nil, time.Now().Add(writeWait)) if err == websocket.ErrCloseSent { return nil } else if e, ok := err.(net.Error); ok && e.Timeout() { @@ -150,109 +136,89 @@ func (c *Connection) reader() { }) } for { - _, message, err := c.conn.ReadMessage() + _, message, err := ws.conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - if c.errorHandler != nil { - c.errorHandler(err) - } - } else { - c.callback(message, err) + ws.log.Error().Err(err).Msg("WebSocket read fail") } break } - c.callback(message, err) + ws.OnMessage(message, err) } } // writer pumps messages from the send channel to the websocket connection. // Blocking, must be called as goroutine. Serializes all websocket writes. -func (c *Connection) writer() { - defer c.close() +func (ws *WS) writer() { + defer ws.shutdown() - if c.pingPong { + if ws.pingPong { ticker := time.NewTicker(pingTime) defer ticker.Stop() for { select { - case message, ok := <-c.send: - if !c.handleMessage(message, ok) { + case message, ok := <-ws.send: + if !ws.handleMessage(message, ok) { return } case <-ticker.C: - if err := c.conn.write(websocket.PingMessage, nil); err != nil { + if err := ws.conn.write(websocket.PingMessage, nil); err != nil { return } } } } else { - for message := range c.send { - if !c.handleMessage(message, true) { + for message := range ws.send { + if !ws.handleMessage(message, true) { return } } } } -func (c *Connection) handleMessage(message []byte, ok bool) bool { +func (ws *WS) handleMessage(message []byte, ok bool) bool { if !ok { - _ = c.conn.write(websocket.CloseMessage, nil) + _ = ws.conn.write(websocket.CloseMessage, nil) return false } - if err := c.conn.write(websocket.TextMessage, message); err != nil { + if err := ws.conn.write(websocket.TextMessage, message); err != nil { return false } return true } -func (c *Connection) close() { - c.once.Do(func() { - c.alive = false - _ = c.conn.Close() - close(c.done) +func newSocket(conn *websocket.Conn, pingPong bool, server bool, log *logger.Logger) *WS { + return &WS{ + conn: deadlineConn{Conn: conn, wt: writeWait}, + send: make(chan []byte), + once: sync.Once{}, + Done: make(chan struct{}, 1), + pingPong: pingPong, + server: server, + OnMessage: func(message []byte, err error) {}, + log: log, + } +} + +func (ws *WS) Listen() { + go ws.writer() + go ws.reader() +} + +func (ws *WS) Write(data []byte) { + if atomic.LoadUint32(&ws.closed) == 0 { + ws.send <- data + } +} + +func (ws *WS) Close() { _ = ws.conn.write(websocket.CloseMessage, nil) } + +func (ws *WS) shutdown() { + ws.once.Do(func() { + atomic.StoreUint32(&ws.closed, 1) + _ = ws.conn.Close() + close(ws.Done) + ws.log.Debug().Msg("WebSocket should be closed now") }) } - -func newSocket(conn *websocket.Conn, pingPong bool) *Connection { - return &Connection{ - callback: func(message []byte, err error) {}, - conn: deadlineConn{Conn: conn, wt: writeWait}, - done: make(chan struct{}, 1), - once: sync.Once{}, - pingPong: pingPong, - send: make(chan []byte), - } -} - -// IsServer returns true if the connection has server capabilities and not just a client. -// For now, we assume every connection with ping/pong handler is a server. -func (c *Connection) IsServer() bool { return c.pingPong } - -func (c *Connection) SetMessageHandler(fn MessageHandler) { c.callback = fn } - -func (c *Connection) SetErrorHandler(fn ErrorHandler) { c.errorHandler = fn } - -func (c *Connection) SetMaxMessageSize(s int64) { c.messSize = s } - -func (c *Connection) Listen() chan struct{} { - if c.alive { - return c.done - } - c.alive = true - go c.writer() - go c.reader() - return c.done -} - -func (c *Connection) Write(data []byte) { - if c.alive { - c.send <- data - } -} - -func (c *Connection) Close() { - if c.alive { - _ = c.conn.write(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) - } -} diff --git a/pkg/os/flock.go b/pkg/os/flock.go deleted file mode 100644 index 5dd7d499..00000000 --- a/pkg/os/flock.go +++ /dev/null @@ -1,37 +0,0 @@ -package os - -import ( - "os" - "path/filepath" - - "github.com/gofrs/flock" -) - -type Flock struct { - f *flock.Flock -} - -func NewFileLock(path string) (*Flock, error) { - if path == "" { - path = os.TempDir() + string(os.PathSeparator) + "cloud_game.lock" - } - - if err := os.MkdirAll(filepath.Dir(path), 0770); err != nil { - return nil, err - } else { - f, err := os.Create(path) - defer func() { _ = f.Close() }() - if err != nil { - return nil, err - } - } - - f := Flock{ - f: flock.New(path), - } - - return &f, nil -} - -func (f *Flock) Lock() error { return f.f.Lock() } -func (f *Flock) Unlock() error { return f.f.Unlock() } diff --git a/pkg/os/os.go b/pkg/os/os.go index 42e8a100..117e41ad 100644 --- a/pkg/os/os.go +++ b/pkg/os/os.go @@ -1,10 +1,7 @@ package os import ( - "bufio" - "bytes" "errors" - "io" "io/fs" "os" "os/signal" @@ -12,8 +9,6 @@ import ( "syscall" ) -const ReadChunk = 1024 - var ErrNotExist = os.ErrNotExist func Exists(path string) bool { @@ -28,10 +23,6 @@ func CheckCreateDir(path string) error { return nil } -func MakeDirAll(path string) error { - return os.MkdirAll(path, os.ModeDir|os.ModePerm) -} - func ExpectTermination() chan struct{} { signals := make(chan os.Signal, 1) signal.Notify(signals, os.Interrupt, syscall.SIGTERM) @@ -51,75 +42,6 @@ func GetUserHome() (string, error) { return me.HomeDir, nil } -func CopyFile(from string, to string) (err error) { - f, err := os.Open(from) - if err != nil { - return err - } - defer func() { - if err2 := f.Close(); err2 != nil { - err = errors.Join(err, err2) - } - }() - - destFile, err := os.Create(to) - if err != nil { - return err - } - defer func() { - if err2 := destFile.Close(); err != nil { - err = errors.Join(err, err2) - } - }() - - n, err := f.WriteTo(destFile) - if n == 0 { - return errors.New("nothing was written") - } - if err != nil { - return err - } - return nil -} - func WriteFile(name string, data []byte, perm os.FileMode) error { return os.WriteFile(name, data, perm) } - -func ReadFile(name string) (dat []byte, err error) { - f, err := os.Open(name) - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - r := bufio.NewReader(f) - buf := bytes.NewBuffer(make([]byte, 0)) - chunk := make([]byte, ReadChunk) - - c := 0 - for { - if c, err = r.Read(chunk); err != nil { - break - } - buf.Write(chunk[:c]) - } - - if err == io.EOF { - err = nil - } - - return buf.Bytes(), err -} - -func StatSize(path string) (int64, error) { - fi, err := os.Stat(path) - if err != nil { - return 0, err - } - return fi.Size(), nil -} - -func RemoveAll(path string) error { - return os.RemoveAll(path) -} diff --git a/pkg/resampler/simple.go b/pkg/resampler/simple.go deleted file mode 100644 index 39e509c0..00000000 --- a/pkg/resampler/simple.go +++ /dev/null @@ -1,62 +0,0 @@ -package resampler - -func Linear(dst, src []int16) { - nSrc, nDst := len(src), len(dst) - if nSrc < 2 || nDst < 2 { - return - } - - srcPairs, dstPairs := nSrc>>1, nDst>>1 - - // replicate single pair input or output - if srcPairs == 1 || dstPairs == 1 { - for i := range dstPairs { - dst[i*2], dst[i*2+1] = src[0], src[1] - } - return - } - - ratio := ((srcPairs - 1) << 16) / (dstPairs - 1) - lastSrc := nSrc - 2 - - // interpolate all pairs except the last - for i, pos := 0, 0; i < dstPairs-1; i, pos = i+1, pos+ratio { - idx := (pos >> 16) << 1 - di := i << 1 - frac := int32(pos & 0xFFFF) - l0, r0 := int32(src[idx]), int32(src[idx+1]) - - // L = L0 + (L1-L0)*frac - dst[di] = int16(l0 + ((int32(src[idx+2])-l0)*frac)>>16) - // R = R0 + (R1-R0)*frac - dst[di+1] = int16(r0 + ((int32(src[idx+3])-r0)*frac)>>16) - } - - // last output pair = last input pair (avoids precision loss at the edge) - lastDst := (dstPairs - 1) << 1 - dst[lastDst], dst[lastDst+1] = src[lastSrc], src[lastSrc+1] -} - -func Nearest(dst, src []int16) { - nSrc, nDst := len(src), len(dst) - if nSrc < 2 || nDst < 2 { - return - } - - srcPairs, dstPairs := nSrc>>1, nDst>>1 - - if srcPairs == 1 || dstPairs == 1 { - for i := range dstPairs { - dst[i*2], dst[i*2+1] = src[0], src[1] - } - return - } - - ratio := (srcPairs << 16) / dstPairs - - for i, pos := 0, 0; i < dstPairs; i, pos = i+1, pos+ratio { - si := (pos >> 16) << 1 - di := i << 1 - dst[di], dst[di+1] = src[si], src[si+1] - } -} diff --git a/pkg/resampler/speex.go b/pkg/resampler/speex.go deleted file mode 100644 index b62d2be1..00000000 --- a/pkg/resampler/speex.go +++ /dev/null @@ -1,106 +0,0 @@ -package resampler - -/* - #cgo pkg-config: speexdsp - #cgo st LDFLAGS: -l:libspeexdsp.a - - #include - #include "speex_resampler.h" -*/ -import "C" - -import ( - "errors" - "unsafe" -) - -// Quality -const ( - QualityMax = 10 - QualityMin = 0 - QualityDefault = 4 - QualityDesktop = 5 - QualityVoid = 3 -) - -// Errors -const ( - ErrorSuccess = iota - ErrorAllocFailed - ErrorBadState - ErrorInvalidArg - ErrorPtrOverlap - ErrorMaxError -) - -type Resampler struct { - resampler *C.SpeexResamplerState - channels int - inRate int - outRate int -} - -func Init(channels, inRate, outRate, quality int) (*Resampler, error) { - var err C.int - r := &Resampler{ - channels: channels, - inRate: inRate, - outRate: outRate, - } - - r.resampler = C.speex_resampler_init( - C.spx_uint32_t(channels), - C.spx_uint32_t(inRate), - C.spx_uint32_t(outRate), - C.int(quality), - &err, - ) - - if r.resampler == nil { - return nil, StrError(int(err)) - } - - C.speex_resampler_skip_zeros(r.resampler) - - return r, nil -} - -func (r *Resampler) Destroy() { - if r.resampler != nil { - C.speex_resampler_destroy(r.resampler) - r.resampler = nil - } -} - -// Process performs resampling. -// Returns written samples count and error if any. -func (r *Resampler) Process(out, in []int16) (int, error) { - if len(in) == 0 || len(out) == 0 { - return 0, nil - } - - inLen := C.spx_uint32_t(len(in) / r.channels) - outLen := C.spx_uint32_t(len(out) / r.channels) - - res := C.speex_resampler_process_interleaved_int( - r.resampler, - (*C.spx_int16_t)(unsafe.Pointer(&in[0])), - &inLen, - (*C.spx_int16_t)(unsafe.Pointer(&out[0])), - &outLen, - ) - - if res != ErrorSuccess { - return 0, StrError(int(res)) - } - - return int(outLen) * r.channels, nil -} - -func StrError(errorCode int) error { - cS := C.speex_resampler_strerror(C.int(errorCode)) - if cS == nil { - return nil - } - return errors.New(C.GoString(cS)) -} diff --git a/pkg/resampler/speex_resampler.h b/pkg/resampler/speex_resampler.h deleted file mode 100644 index 9e046ed7..00000000 --- a/pkg/resampler/speex_resampler.h +++ /dev/null @@ -1,70 +0,0 @@ -#ifndef SPEEX_RESAMPLER_H -#define SPEEX_RESAMPLER_H - -#define spx_int16_t short -#define spx_int32_t int -#define spx_uint16_t unsigned short -#define spx_uint32_t unsigned int - -#define SPEEX_RESAMPLER_QUALITY_MAX 10 -#define SPEEX_RESAMPLER_QUALITY_MIN 0 -#define SPEEX_RESAMPLER_QUALITY_DEFAULT 4 -#define SPEEX_RESAMPLER_QUALITY_VOIP 3 -#define SPEEX_RESAMPLER_QUALITY_DESKTOP 5 -enum { - RESAMPLER_ERR_SUCCESS = 0, - RESAMPLER_ERR_ALLOC_FAILED = 1, - RESAMPLER_ERR_BAD_STATE = 2, - RESAMPLER_ERR_INVALID_ARG = 3, - RESAMPLER_ERR_PTR_OVERLAP = 4, - - RESAMPLER_ERR_MAX_ERROR -}; -struct SpeexResamplerState_; -typedef struct SpeexResamplerState_ SpeexResamplerState; -/** Create a new resampler with integer input and output rates. - * @param nb_channels Number of channels to be processed - * @param in_rate Input sampling rate (integer number of Hz). - * @param out_rate Output sampling rate (integer number of Hz). - * @param quality Resampling quality between 0 and 10, where 0 has poor quality - * and 10 has very high quality. - * @return Newly created resampler state - * @retval NULL Error: not enough memory - */ -SpeexResamplerState *speex_resampler_init(spx_uint32_t nb_channels, - spx_uint32_t in_rate, - spx_uint32_t out_rate, - int quality, - int *err); -/** Destroy a resampler state. - * @param st Resampler state - */ -void speex_resampler_destroy(SpeexResamplerState *st); - - -/** Make sure that the first samples to go out of the resamplers don't have - * leading zeros. This is only useful before starting to use a newly created - * resampler. It is recommended to use that when resampling an audio file, as - * it will generate a file with the same length. For real-time processing, - * it is probably easier not to use this call (so that the output duration - * is the same for the first frame). - * @param st Resampler state - */ -int speex_resampler_skip_zeros(SpeexResamplerState *st); - -/** Resample an interleaved int array. The input and output buffers must *not* overlap. - * @param st Resampler state - * @param in Input buffer - * @param in_len Number of input samples in the input buffer. Returns the number - * of samples processed. This is all per-channel. - * @param out Output buffer - * @param out_len Size of the output buffer. Returns the number of samples written. - * This is all per-channel. - */ -int speex_resampler_process_interleaved_int(SpeexResamplerState *st, - const spx_int16_t *in, - spx_uint32_t *in_len, - spx_int16_t *out, - spx_uint32_t *out_len); -const char *speex_resampler_strerror(int err); -#endif \ No newline at end of file diff --git a/pkg/service/service.go b/pkg/service/service.go new file mode 100644 index 00000000..48c3f063 --- /dev/null +++ b/pkg/service/service.go @@ -0,0 +1,46 @@ +package service + +import "fmt" + +// Service defines a generic service. +type Service any + +// RunnableService defines a service that can be run. +type RunnableService interface { + Service + + Run() + Stop() error +} + +// Group is a container for managing a bunch of services. +type Group struct { + list []Service +} + +func (g *Group) Add(services ...Service) { g.list = append(g.list, services...) } + +// Start starts each service in the group. +func (g *Group) Start() { + for _, s := range g.list { + if v, ok := s.(RunnableService); ok { + v.Run() + } + } +} + +// Stop terminates a group of services. +func (g *Group) Stop() (err error) { + var errs []error + for _, s := range g.list { + if v, ok := s.(RunnableService); ok { + if err := v.Stop(); err != nil { + errs = append(errs, fmt.Errorf("error: failed to stop [%s] because of %v", s, err)) + } + } + } + if len(errs) > 0 { + err = fmt.Errorf("%s", errs) + } + return +} diff --git a/pkg/worker/caged/app/app.go b/pkg/worker/caged/app/app.go deleted file mode 100644 index 74d89432..00000000 --- a/pkg/worker/caged/app/app.go +++ /dev/null @@ -1,34 +0,0 @@ -package app - -type App interface { - AudioSampleRate() int - AspectRatio() float32 - AspectEnabled() bool - Init() error - ViewportSize() (int, int) - Scale() float64 - Start() - Close() - - SetAudioCb(func(Audio)) - SetVideoCb(func(Video)) - SetDataCb(func([]byte)) - Input(port int, device byte, data []byte) - KbMouseSupport() bool -} - -type Audio struct { - Data []int16 - Duration int32 // up to 6y nanosecond-wise -} - -type Video struct { - Frame RawFrame - Duration int32 -} - -type RawFrame struct { - Data []byte - Stride int - W, H int -} diff --git a/pkg/worker/caged/caged.go b/pkg/worker/caged/caged.go deleted file mode 100644 index 85ede127..00000000 --- a/pkg/worker/caged/caged.go +++ /dev/null @@ -1,67 +0,0 @@ -package caged - -import ( - "errors" - "reflect" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro" -) - -type Manager struct { - list map[ModName]app.App - log *logger.Logger -} - -const ( - RetroPad = libretro.RetroPad - Keyboard = libretro.Keyboard - Mouse = libretro.Mouse -) - -type ModName string - -const Libretro ModName = "libretro" - -func NewManager(log *logger.Logger) *Manager { - return &Manager{log: log, list: make(map[ModName]app.App)} -} - -func (m *Manager) Get(name ModName) app.App { return m.list[name] } - -func (m *Manager) Load(name ModName, conf any) error { - if name == Libretro { - caged, err := m.loadLibretro(conf) - if err != nil { - return err - } - m.list[name] = caged - } - return nil -} - -func (m *Manager) loadLibretro(conf any) (*libretro.Caged, error) { - s := reflect.ValueOf(conf) - - e := s.FieldByName("Emulator") - if !e.IsValid() { - return nil, errors.New("no emulator conf") - } - r := s.FieldByName("Recording") - if !r.IsValid() { - return nil, errors.New("no recording conf") - } - - c := libretro.CagedConf{ - Emulator: e.Interface().(config.Emulator), - Recording: r.Interface().(config.Recording), - } - - caged := libretro.Cage(c, m.log) - if err := caged.Init(); err != nil { - return nil, err - } - return &caged, nil -} diff --git a/pkg/worker/caged/libretro/caged.go b/pkg/worker/caged/libretro/caged.go deleted file mode 100644 index 3d21db11..00000000 --- a/pkg/worker/caged/libretro/caged.go +++ /dev/null @@ -1,102 +0,0 @@ -package libretro - -import ( - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/games" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/manager" - "github.com/giongto35/cloud-game/v3/pkg/worker/cloud" -) - -type Caged struct { - Emulator - - base *Frontend // maintains the root for mad embedding - conf CagedConf - log *logger.Logger -} - -type CagedConf struct { - Emulator config.Emulator - Recording config.Recording -} - -func (c *Caged) Name() string { return "libretro" } - -func Cage(conf CagedConf, log *logger.Logger) Caged { - return Caged{conf: conf, log: log} -} - -func (c *Caged) Init() error { - if err := manager.CheckCores(c.conf.Emulator, c.log); err != nil { - c.log.Warn().Err(err).Msgf("a Libretro cores sync fail") - } - - if c.conf.Emulator.FailFast { - if err := c.IsSupported(); err != nil { - return err - } - } - - return nil -} - -func (c *Caged) ReloadFrontend() { - frontend, err := NewFrontend(c.conf.Emulator, c.log) - if err != nil { - c.log.Fatal().Err(err).Send() - return - } - c.Emulator = frontend - c.base = frontend -} - -// VideoChangeCb adds a callback when video params are changed by the app. -func (c *Caged) VideoChangeCb(fn func()) { c.base.SetVideoChangeCb(fn) } - -func (c *Caged) Load(game games.GameMetadata, path string) error { - if c.Emulator == nil { - return nil - } - c.Emulator.LoadCore(game.System) - if err := c.Emulator.LoadGame(game.FullPath(path)); err != nil { - return err - } - c.ViewportRecalculate() - return nil -} - -func (c *Caged) EnableRecording(nowait bool, user string, game string) { - if c.conf.Recording.Enabled { - // !to fix races with canvas pool when recording - c.base.DisableCanvasPool = true - c.Emulator = WithRecording(c.Emulator, nowait, user, game, c.conf.Recording, c.log) - } -} - -func (c *Caged) EnableCloudStorage(uid string, storage cloud.Storage) { - if storage == nil { - return - } - if wc, err := WithCloud(c.Emulator, uid, storage); err == nil { - c.Emulator = wc - c.log.Info().Msgf("cloud storage has been initialized") - } else { - c.log.Error().Err(err).Msgf("couldn't init cloud storage") - } -} - -func (c *Caged) AspectEnabled() bool { return c.base.nano.Aspect } -func (c *Caged) AspectRatio() float32 { return c.base.AspectRatio() } -func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() } -func (c *Caged) Rotation() uint { return c.Emulator.Rotation() } -func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() } -func (c *Caged) ViewportSize() (int, int) { return c.base.ViewportSize() } -func (c *Caged) Scale() float64 { return c.Emulator.Scale() } -func (c *Caged) Input(p int, d byte, data []byte) { c.base.Input(p, d, data) } -func (c *Caged) KbMouseSupport() bool { return c.base.KbMouseSupport() } -func (c *Caged) Start() { go c.Emulator.Start() } -func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v } -func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) } -func (c *Caged) Close() { c.Emulator.Close() } -func (c *Caged) IsSupported() error { return c.base.IsSupported() } diff --git a/pkg/worker/caged/libretro/cloud.go b/pkg/worker/caged/libretro/cloud.go deleted file mode 100644 index 67f8da14..00000000 --- a/pkg/worker/caged/libretro/cloud.go +++ /dev/null @@ -1,60 +0,0 @@ -package libretro - -import ( - "github.com/giongto35/cloud-game/v3/pkg/os" - "github.com/giongto35/cloud-game/v3/pkg/worker/cloud" -) - -type CloudFrontend struct { - Emulator - uid string - storage cloud.Storage // a cloud storage to store room state online -} - -// WithCloud adds the ability to keep game states in the cloud storage like Amazon S3. -// It supports only one file of main save state. -func WithCloud(fe Emulator, uid string, storage cloud.Storage) (*CloudFrontend, error) { - r := &CloudFrontend{Emulator: fe, uid: uid, storage: storage} - - name := fe.SaveStateName() - - if r.storage.Has(name) { - data, err := r.storage.Load(fe.SaveStateName()) - if err != nil { - return nil, err - } - // save the data fetched from the cloud to a local directory - if data != nil { - if err := os.WriteFile(fe.HashPath(), data, 0644); err != nil { - return nil, err - } - } - } - - return r, nil -} - -// !to use emulator save/load calls instead of the storage - -func (c *CloudFrontend) HasSave() bool { - _, err := c.storage.Load(c.SaveStateName()) - if err == nil { - return true - } - return c.Emulator.HasSave() -} - -func (c *CloudFrontend) SaveGameState() error { - if err := c.Emulator.SaveGameState(); err != nil { - return err - } - path := c.Emulator.HashPath() - data, err := os.ReadFile(path) - if err != nil { - return err - } - return c.storage.Save(c.SaveStateName(), data, map[string]string{ - "uid": c.uid, - "type": "cloudretro-main-save", - }) -} diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go deleted file mode 100644 index b3baecde..00000000 --- a/pkg/worker/caged/libretro/frontend.go +++ /dev/null @@ -1,545 +0,0 @@ -package libretro - -import ( - "errors" - "fmt" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - "unsafe" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/os" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/nanoarch" -) - -type Emulator interface { - SetAudioCb(func(app.Audio)) - SetVideoCb(func(app.Video)) - SetDataCb(func([]byte)) - LoadCore(name string) - LoadGame(path string) error - FPS() int - Flipped() bool - Rotation() uint - PixFormat() uint32 - AudioSampleRate() int - IsPortrait() bool - // Start is called after LoadGame - Start() - // ViewportRecalculate calculates output resolution with aspect and scale - ViewportRecalculate() - RestoreGameState() error - // SetSessionId sets distinct name for the game session (in order to save/load it later) - SetSessionId(name string) - SaveGameState() error - SaveStateName() string - // HashPath returns the path emulator will save state to - HashPath() string - // HasSave returns true if the current ROM was saved before - HasSave() bool - // Close will be called when the game is done - Close() - // Input passes input to the emulator - Input(player int, device byte, data []byte) - // Scale returns set video scale factor - Scale() float64 - Reset() -} - -type Frontend struct { - conf config.Emulator - done chan struct{} - log *logger.Logger - nano *nanoarch.Nanoarch - onAudio func(app.Audio) - onData func([]byte) - onVideo func(app.Video) - storage Storage - scale float64 - th int // draw threads - vw, vh int // out frame size - - // directives - - // skipVideo used when new frame was too late - skipVideo bool - - mu sync.Mutex - mui sync.Mutex - - DisableCanvasPool bool - SaveOnClose bool - UniqueSaveDir bool - SaveStateFs string -} - -type Device byte - -const ( - RetroPad = Device(nanoarch.RetroPad) - Keyboard = Device(nanoarch.Keyboard) - Mouse = Device(nanoarch.Mouse) -) - -var ( - audioPool sync.Pool - noAudio = func(app.Audio) {} - noData = func([]byte) {} - noVideo = func(app.Video) {} - videoPool sync.Pool - lastFrame *app.Video -) - -// NewFrontend implements Emulator interface for a Libretro frontend. -func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) { - path, err := filepath.Abs(conf.LocalPath) - if err != nil { - return nil, fmt.Errorf("failed to use emulator path: %v, %w", conf.LocalPath, err) - } - if err := os.CheckCreateDir(path); err != nil { - return nil, fmt.Errorf("failed to create local path: %v, %w", conf.LocalPath, err) - } - log.Info().Msgf("Emulator save path is %v", path) - - // we use the global Nanoarch instance from nanoarch - nano := nanoarch.NewNano(path) - - log = log.Extend(log.With().Str("m", "Libretro")) - level := logger.Level(conf.Libretro.LogLevel) - nano.SetLogger(log.Extend(log.Level(level).With())) - - // Check if room is on local storage, if not, pull from GCS to local storage - log.Info().Msgf("Local storage path: %v", conf.Storage) - if err := os.CheckCreateDir(conf.Storage); err != nil { - return nil, fmt.Errorf("failed to create local storage path: %v, %w", conf.Storage, err) - } - - var store Storage = &StateStorage{Path: conf.Storage} - if conf.Libretro.SaveCompression { - store = &ZipStorage{Storage: store} - } - - // set global link to the Libretro - f := &Frontend{ - conf: conf, - done: make(chan struct{}), - log: log, - onAudio: noAudio, - onData: noData, - onVideo: noVideo, - storage: store, - th: conf.Threads, - } - f.linkNano(nano) - - if conf.Libretro.DebounceMs > 0 { - t := time.Duration(conf.Libretro.DebounceMs) * time.Millisecond - f.nano.SetVideoDebounce(t) - f.log.Debug().Msgf("set debounce time: %v", t) - } - - return f, nil -} - -func (f *Frontend) LoadCore(emu string) { - conf := f.conf.GetLibretroCoreConfig(emu) - - libExt := "" - if ar, err := f.conf.Libretro.Cores.Repo.Guess(); err == nil { - libExt = ar.Ext - } else { - f.log.Warn().Err(err).Msg("system arch guesser failed") - } - - meta := nanoarch.Metadata{ - AutoGlContext: conf.AutoGlContext, - FrameDup: f.conf.Libretro.Dup, - Hacks: conf.Hacks, - HasVFR: conf.VFR, - Hid: conf.Hid, - IsGlAllowed: conf.IsGlAllowed, - LibPath: conf.Lib, - Options: conf.Options, - Options4rom: conf.Options4rom, - UsesLibCo: conf.UsesLibCo, - CoreAspectRatio: conf.CoreAspectRatio, - KbMouseSupport: conf.KbMouseSupport, - LibExt: libExt, - } - f.mu.Lock() - f.SaveStateFs = conf.SaveStateFs - if conf.UniqueSaveDir { - f.UniqueSaveDir = true - f.nano.SetSaveDirSuffix(f.storage.MainPath()) - f.log.Debug().Msgf("Using unique dir for saves: %v", f.storage.MainPath()) - } - scale := 1.0 - if conf.Scale > 1 { - scale = conf.Scale - f.log.Debug().Msgf("Scale: x%v", scale) - } - f.storage.SetNonBlocking(conf.NonBlockingSave) - f.scale = scale - f.nano.CoreLoad(meta) - f.mu.Unlock() -} - -func (f *Frontend) handleAudio(audio unsafe.Pointer, samples int) { - fr, _ := audioPool.Get().(*app.Audio) - if fr == nil { - fr = new(app.Audio) - } - // !to look if we need a copy - fr.Data = unsafe.Slice((*int16)(audio), samples) - // due to audio buffering for opus fixed frames and const duration up in the hierarchy, - // we skip Duration here - f.onAudio(*fr) - audioPool.Put(fr) -} - -func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) { - if f.conf.SkipLateFrames && f.skipVideo { - return - } - - fr, _ := videoPool.Get().(*app.Video) - if fr == nil { - fr = new(app.Video) - } - fr.Frame.Data = data - fr.Frame.W = int(fi.W) - fr.Frame.H = int(fi.H) - fr.Frame.Stride = int(fi.Stride) - fr.Duration = delta - - lastFrame = fr - f.onVideo(*fr) - - videoPool.Put(fr) -} - -func (f *Frontend) handleDup() { - if lastFrame != nil { - f.onVideo(*lastFrame) - } -} - -func (f *Frontend) Shutdown() { - f.mu.Lock() - f.nano.Shutdown() - f.SetAudioCb(noAudio) - f.SetVideoCb(noVideo) - lastFrame = nil - f.mu.Unlock() - f.log.Debug().Msgf("frontend shutdown done") -} - -func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) { - f.nano = nano - if nano == nil { - return - } - f.nano.WaitReady() // start only when nano is available - - f.nano.OnVideo = f.handleVideo - f.nano.OnAudio = f.handleAudio - f.nano.OnDup = f.handleDup -} - -func (f *Frontend) SetVideoChangeCb(fn func()) { - if f.nano != nil { - f.nano.OnSystemAvInfo = fn - } -} - -func (f *Frontend) Start() { - f.log.Debug().Msgf("frontend start") - if f.nano.Stopped.Load() { - f.log.Warn().Msgf("frontend stopped during the start") - f.mui.Lock() - defer f.mui.Unlock() - f.Shutdown() - return - } - - // don't jump between threads - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - f.mui.Lock() - f.done = make(chan struct{}) - f.nano.LastFrameTime = time.Now().UnixNano() - - defer func() { - // Save game on quit if it was saved before (shared or click-saved). - if f.SaveOnClose && f.HasSave() { - f.log.Debug().Msg("save on quit") - if err := f.Save(); err != nil { - f.log.Error().Err(err).Msg("save on quit failed") - } - } - f.mui.Unlock() - f.Shutdown() - }() - - if f.HasSave() { - // advance 1 frame for Mupen, DOSBox save states - // loading will work if autostart is selected for DOSBox apps - f.Tick() - if err := f.RestoreGameState(); err != nil { - f.log.Error().Err(err).Msg("couldn't load a save file") - } - } - - if f.conf.AutosaveSec > 0 { - // !to sync both for loops, can crash if the emulator starts later - go f.autosave(f.conf.AutosaveSec) - } - - // The main loop of Libretro - - // calculate the exact duration required for a frame (e.g., 16.666ms = 60 FPS) - targetFrameTime := time.Second / time.Duration(f.nano.VideoFramerate()) - - // stop sleeping and start spinning in the remaining 1ms - const spinThreshold = 1 * time.Millisecond - - // how many frames will be considered not normal - const lateFramesThreshold = 3 - - lastFrameStart := time.Now() - - for { - select { - case <-f.done: - return - default: - // run one tick of the emulation - f.Tick() - - elapsed := time.Since(lastFrameStart) - sleepTime := targetFrameTime - elapsed - - if sleepTime > 0 { - // SLEEP - // if we have plenty of time, sleep to save CPU and - // wake up slightly before the target time - if sleepTime > spinThreshold { - time.Sleep(sleepTime - spinThreshold) - } - - // SPIN - // if we are close to the target, - // burn CPU and check the clock with ns resolution - for time.Since(lastFrameStart) < targetFrameTime { - // CPU burn! - } - f.skipVideo = false - } else { - // lagging behind the target framerate so we don't sleep - if f.conf.LogDroppedFrames { - // !to make some stats counter instead - f.log.Debug().Msgf("[] Frame drop: %v", elapsed) - } - f.skipVideo = true - } - - // timer reset - // - // adding targetFrameTime to the previous start - // prevents drift, if one frame was late, - // we try to catch up in the next frame - lastFrameStart = lastFrameStart.Add(targetFrameTime) - - // if execution was paused or heavily delayed, - // reset lastFrameStart so we don't try to run - // a bunch of frames instantly to catch up - if time.Since(lastFrameStart) > targetFrameTime*lateFramesThreshold { - lastFrameStart = time.Now() - } - } - } -} - -func (f *Frontend) LoadGame(path string) error { - if f.UniqueSaveDir { - f.copyFsMaybe(path) - } - return f.nano.LoadGame(path) -} - -func (f *Frontend) AspectRatio() float32 { return f.nano.AspectRatio() } -func (f *Frontend) AudioSampleRate() int { return f.nano.AudioSampleRate() } -func (f *Frontend) FPS() int { return f.nano.VideoFramerate() } -func (f *Frontend) Flipped() bool { return f.nano.IsGL() } -func (f *Frontend) FrameSize() (int, int) { return f.nano.BaseWidth(), f.nano.BaseHeight() } -func (f *Frontend) HasSave() bool { return os.Exists(f.HashPath()) } -func (f *Frontend) HashPath() string { return f.storage.GetSavePath() } -func (f *Frontend) IsPortrait() bool { return f.nano.IsPortrait() } -func (f *Frontend) KbMouseSupport() bool { return f.nano.KbMouseSupport() } -func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C } -func (f *Frontend) Reset() { f.mu.Lock(); defer f.mu.Unlock(); f.nano.Reset() } -func (f *Frontend) RestoreGameState() error { return f.Load() } -func (f *Frontend) Rotation() uint { return f.nano.Rot } -func (f *Frontend) SRAMPath() string { return f.storage.GetSRAMPath() } -func (f *Frontend) SaveGameState() error { return f.Save() } -func (f *Frontend) SaveStateName() string { return filepath.Base(f.HashPath()) } -func (f *Frontend) Scale() float64 { return f.scale } -func (f *Frontend) SetAudioCb(cb func(app.Audio)) { f.onAudio = cb } -func (f *Frontend) SetSessionId(name string) { f.storage.SetMainSaveName(name) } -func (f *Frontend) SetDataCb(cb func([]byte)) { f.onData = cb } -func (f *Frontend) SetVideoCb(ff func(app.Video)) { f.onVideo = ff } -func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f.mu.Unlock() } -func (f *Frontend) ViewportRecalculate() { f.mu.Lock(); f.vw, f.vh = f.ViewportCalc(); f.mu.Unlock() } -func (f *Frontend) ViewportSize() (int, int) { return f.vw, f.vh } - -func (f *Frontend) Input(port int, device byte, data []byte) { - switch Device(device) { - case RetroPad: - f.nano.InputRetropad(port, data) - case Keyboard: - f.nano.InputKeyboard(port, data) - case Mouse: - f.nano.InputMouse(port, data) - } -} - -func (f *Frontend) ViewportCalc() (nw int, nh int) { - w, h := f.FrameSize() - nw, nh = w, h - - if f.IsPortrait() { - nw, nh = nh, nw - } - - f.log.Debug().Msgf("viewport: %dx%d -> %dx%d", w, h, nw, nh) - - return -} - -func (f *Frontend) Close() { - f.log.Debug().Msgf("frontend close") - close(f.done) - - f.mui.Lock() - f.nano.Close() - - if f.UniqueSaveDir && !f.HasSave() { - if err := f.nano.DeleteSaveDir(); err != nil { - f.log.Error().Msgf("couldn't delete save dir: %v", err) - } - } - - f.UniqueSaveDir = false - f.SaveStateFs = "" - - f.mui.Unlock() - f.log.Debug().Msgf("frontend closed") -} - -// Save writes the current state to the filesystem. -func (f *Frontend) Save() error { - f.mu.Lock() - defer f.mu.Unlock() - - ss, err := nanoarch.SaveState() - if err != nil { - return err - } - if err := f.storage.Save(f.HashPath(), ss); err != nil { - return err - } - ss = nil - - if sram := nanoarch.SaveRAM(); sram != nil { - if err := f.storage.Save(f.SRAMPath(), sram); err != nil { - return err - } - sram = nil - } - return nil -} - -// Load restores the state from the filesystem. -func (f *Frontend) Load() error { - f.mu.Lock() - defer f.mu.Unlock() - - ss, err := f.storage.Load(f.HashPath()) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - if err := nanoarch.RestoreSaveState(ss); err != nil { - return err - } - - sram, err := f.storage.Load(f.SRAMPath()) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - if sram != nil { - nanoarch.RestoreSaveRAM(sram) - } - return nil -} - -func (f *Frontend) IsSupported() error { - return graphics.TryInit() -} - -func (f *Frontend) autosave(periodSec int) { - f.log.Info().Msgf("Autosave every [%vs]", periodSec) - ticker := time.NewTicker(time.Duration(periodSec) * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if f.nano.IsStopped() { - return - } - if err := f.Save(); err != nil { - f.log.Error().Msgf("Autosave failed: %v", err) - } else { - f.log.Debug().Msgf("Autosave done") - } - case <-f.done: - return - } - } -} - -func (f *Frontend) copyFsMaybe(path string) { - if f.SaveStateFs == "" { - return - } - - fileName := f.SaveStateFs - hasPlaceholder := strings.HasPrefix(f.SaveStateFs, "*") - if hasPlaceholder { - game := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - fileName = strings.Replace(f.SaveStateFs, "*", game, 1) - } - - fullPath := filepath.Join(f.nano.SaveDir(), fileName) - - if os.Exists(fullPath) { - return - } - - storePath := filepath.Dir(path) - fsPath := filepath.Join(storePath, fileName) - if os.Exists(fsPath) { - if err := os.CopyFile(fsPath, fullPath); err != nil { - f.log.Error().Err(err).Msgf("fs copy fail") - } else { - f.log.Debug().Msgf("copied fs %v to %v", fsPath, fullPath) - } - } -} diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go deleted file mode 100644 index 2cacd5a4..00000000 --- a/pkg/worker/caged/libretro/frontend_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package libretro - -import ( - "crypto/md5" - "fmt" - "io" - "log" - "math/rand/v2" - "os" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/manager" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/nanoarch" - "github.com/giongto35/cloud-game/v3/pkg/worker/thread" - - _ "github.com/giongto35/cloud-game/v3/test" -) - -type TestFrontend struct { - *Frontend - - corePath string - coreExt string - gamePath string - system string -} - -type testRun struct { - name string - room string - system string - rom string - frames int -} - -type game struct { - rom string - system string -} - -var ( - alwa = game{system: "nes", rom: "nes/Alwa's Awakening (Demo).nes"} - sushi = game{system: "gba", rom: "gba/Sushi The Cat.gba"} - angua = game{system: "gba", rom: "gba/anguna.gba"} - rogue = game{system: "dos", rom: "dos/rogue.zip"} -) - -// TestMain runs all tests in the main thread in macOS. -func TestMain(m *testing.M) { - thread.Wrap(func() { os.Exit(m.Run()) }) -} - -// EmulatorMock returns a properly stubbed emulator instance. -// Due to extensive use of globals -- one mock instance is allowed per a test run. -// Don't forget to init one image channel consumer, it will lock-out otherwise. -// Make sure you call Shutdown(). -func EmulatorMock(room string, system string) *TestFrontend { - var conf config.WorkerConfig - if _, err := config.LoadConfig(&conf, ""); err != nil { - panic(err) - } - - conf.Emulator.Libretro.Cores.Repo.ExtLock = expand("tests", ".cr", "cloud-game.lock") - conf.Emulator.LocalPath = expand("tests", conf.Emulator.LocalPath) - conf.Emulator.Storage = expand("tests", "storage") - - l := logger.Default() - l2 := l.Extend(l.Level(logger.WarnLevel).With()) - - if err := manager.CheckCores(conf.Emulator, l2); err != nil { - panic(err) - } - - nano := nanoarch.NewNano(conf.Emulator.LocalPath) - nano.SetLogger(l2) - - arch, err := conf.Emulator.Libretro.Cores.Repo.Guess() - if err != nil { - panic(err) - } - - // an emu - emu := &TestFrontend{ - Frontend: &Frontend{ - conf: conf.Emulator, - storage: &StateStorage{ - Path: os.TempDir(), - MainSave: room, - }, - done: make(chan struct{}), - th: conf.Emulator.Threads, - log: l2, - SaveOnClose: false, - }, - corePath: expand(conf.Emulator.GetLibretroCoreConfig(system).Lib), - coreExt: arch.Ext, - gamePath: expand(conf.Library.BasePath), - system: system, - } - emu.linkNano(nano) - - return emu -} - -// DefaultFrontend returns initialized emulator mock with default params. -// Spawns audio/image channels consumers. -// Don't forget to close emulator mock with Shutdown(). -func DefaultFrontend(room string, system string, rom string) *TestFrontend { - mock := EmulatorMock(room, system) - mock.loadRom(rom) - mock.SetVideoCb(func(app.Video) {}) - mock.SetAudioCb(func(app.Audio) {}) - return mock -} - -// loadRom loads a ROM into the emulator. -// The rom will be loaded from emulators' games path. -func (emu *TestFrontend) loadRom(game string) { - conf := emu.conf.GetLibretroCoreConfig(emu.system) - scale := 1.0 - if conf.Scale > 1 { - scale = conf.Scale - } - emu.scale = scale - - meta := nanoarch.Metadata{ - AutoGlContext: conf.AutoGlContext, - //FrameDup: f.conf.Libretro.Dup, - Hacks: conf.Hacks, - HasVFR: conf.VFR, - Hid: conf.Hid, - IsGlAllowed: conf.IsGlAllowed, - LibPath: emu.corePath, - Options: conf.Options, - Options4rom: conf.Options4rom, - UsesLibCo: conf.UsesLibCo, - CoreAspectRatio: conf.CoreAspectRatio, - LibExt: emu.coreExt, - } - - emu.nano.CoreLoad(meta) - - gamePath := expand(emu.gamePath, game) - err := emu.nano.LoadGame(gamePath) - if err != nil { - log.Fatal(err) - } - emu.ViewportRecalculate() -} - -// Shutdown closes the emulator and cleans its resources. -func (emu *TestFrontend) Shutdown() { - _ = os.Remove(emu.HashPath()) - _ = os.Remove(emu.SRAMPath()) - emu.Frontend.Close() - emu.Frontend.Shutdown() -} - -// dumpState returns both current and previous emulator save state as MD5 hash string. -func (emu *TestFrontend) dumpState() (cur string, prev string) { - emu.mu.Lock() - b, _ := os.ReadFile(emu.HashPath()) - prev = hash(b) - emu.mu.Unlock() - - emu.mu.Lock() - b, _ = nanoarch.SaveState() - emu.mu.Unlock() - cur = hash(b) - - return -} - -func (emu *TestFrontend) save() ([]byte, error) { - emu.mu.Lock() - defer emu.mu.Unlock() - - return nanoarch.SaveState() -} - -func BenchmarkEmulators(b *testing.B) { - log.SetOutput(io.Discard) - os.Stdout, _ = os.Open(os.DevNull) - - benchmarks := []struct { - name string - system string - rom string - }{ - {name: "GBA Sushi", system: sushi.system, rom: sushi.rom}, - {name: "NES Alwa", system: alwa.system, rom: alwa.rom}, - } - - for _, bench := range benchmarks { - b.Run(bench.name, func(b *testing.B) { - s := DefaultFrontend("bench_"+bench.system+"_performance", bench.system, bench.rom) - for range b.N { - s.nano.Run() - } - s.Shutdown() - }) - } -} - -func TestSavePersistence(t *testing.T) { - tests := []testRun{ - {system: sushi.system, rom: sushi.rom, frames: 100}, - {system: angua.system, rom: angua.rom, frames: 100}, - {system: rogue.system, rom: rogue.rom, frames: 200}, - } - - for _, test := range tests { - t.Run(fmt.Sprintf("If saves persistent on %v - %v", test.system, test.rom), func(t *testing.T) { - front := DefaultFrontend(test.room, test.system, test.rom) - - for test.frames > 0 { - front.Tick() - test.frames-- - } - - for range 10 { - v, _ := front.save() - if v == nil || len(v) == 0 { - t.Errorf("couldn't persist the state") - t.Fail() - } - } - - front.Shutdown() - }) - } -} - -// Tests save and restore function: -// -// Emulate n ticks. -// Call save (a). -// Emulate n ticks again. -// Call load from the save (b). -// Compare states (a) and (b), should be =. -func TestLoad(t *testing.T) { - tests := []testRun{ - {room: "test_load_00", system: alwa.system, rom: alwa.rom, frames: 100}, - //{room: "test_load_01", system: sushi.system, rom: sushi.rom, frames: 1000}, - //{room: "test_load_02", system: angua.system, rom: angua.rom, frames: 100}, - } - - for _, test := range tests { - t.Logf("Testing [%v] load with [%v]\n", test.system, test.rom) - - mock := DefaultFrontend(test.room, test.system, test.rom) - - mock.dumpState() - - for ticks := test.frames; ticks > 0; ticks-- { - mock.Tick() - } - mock.dumpState() - - if err := mock.Save(); err != nil { - t.Errorf("Save fail %v", err) - } - snapshot1, _ := mock.dumpState() - - for ticks := test.frames; ticks > 0; ticks-- { - mock.Tick() - } - mock.dumpState() - - if err := mock.Load(); err != nil { - t.Errorf("Load fail %v", err) - } - snapshot2, _ := mock.dumpState() - - if snapshot1 != snapshot2 { - t.Errorf("It seems rom state restore has failed: %v != %v", snapshot1, snapshot2) - } - - mock.Shutdown() - } -} - -func TestStateConcurrency(t *testing.T) { - tests := []struct { - run testRun - seed int - }{ - { - run: testRun{room: "test_concurrency_00", system: alwa.system, rom: alwa.rom, frames: 120}, - seed: 42, - }, - { - run: testRun{room: "test_concurrency_01", system: alwa.system, rom: alwa.rom, frames: 300}, - seed: 42 + 42, - }, - } - - for _, test := range tests { - t.Logf("Testing [%v] concurrency with [%v]\n", test.run.system, test.run.rom) - - mock := EmulatorMock(test.run.room, test.run.system) - - ops := &sync.WaitGroup{} - // quantum lock - qLock := &sync.Mutex{} - - mock.loadRom(test.run.rom) - mock.SetVideoCb(func(v app.Video) { - if len(v.Frame.Data) == 0 { - t.Errorf("It seems that rom video frame was empty, which is strange!") - } - }) - mock.SetAudioCb(func(app.Audio) {}) - - t.Logf("Random seed is [%v]\n", test.seed) - t.Logf("Save path is [%v]\n", mock.HashPath()) - - _ = mock.Save() - - for i := range test.run.frames { - qLock.Lock() - mock.Tick() - qLock.Unlock() - - if lucky() && !lucky() { - ops.Go(func() { - qLock.Lock() - defer qLock.Unlock() - - mock.dumpState() - // remove save to reproduce the bug - _ = mock.Save() - _, snapshot1 := mock.dumpState() - _ = mock.Load() - snapshot2, _ := mock.dumpState() - - if snapshot1 != snapshot2 { - t.Errorf("States are inconsistent %v != %v on tick %v\n", snapshot1, snapshot2, i+1) - } - }) - } - } - - ops.Wait() - mock.Shutdown() - } -} - -func TestStartStop(t *testing.T) { - f1 := DefaultFrontend("sushi", sushi.system, sushi.rom) - go f1.Start() - time.Sleep(1 * time.Second) - f1.Close() - - f2 := DefaultFrontend("sushi", sushi.system, sushi.rom) - go f2.Start() - time.Sleep(100 * time.Millisecond) - f2.Close() -} - -// expand joins a list of file path elements. -func expand(p ...string) string { - ph, _ := filepath.Abs(filepath.FromSlash(filepath.Join(p...))) - return ph -} - -// hash returns MD5 hash. -func hash(bytes []byte) string { return fmt.Sprintf("%x", md5.Sum(bytes)) } - -// lucky returns random boolean. -func lucky() bool { return rand.IntN(2) == 1 } diff --git a/pkg/worker/caged/libretro/graphics/opengl.go b/pkg/worker/caged/libretro/graphics/opengl.go deleted file mode 100644 index fca78a6b..00000000 --- a/pkg/worker/caged/libretro/graphics/opengl.go +++ /dev/null @@ -1,122 +0,0 @@ -package graphics - -import ( - "errors" - "fmt" - "math" - "unsafe" - - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics/gl" -) - -type Context int - -const ( - CtxNone Context = iota - CtxOpenGl - CtxOpenGlEs2 - CtxOpenGlCore - CtxOpenGlEs3 - CtxOpenGlEsVersion - CtxVulkan - CtxUnknown = math.MaxInt32 - 1 - CtxDummy = math.MaxInt32 -) - -type PixelFormat int - -const ( - UnsignedShort5551 PixelFormat = iota - UnsignedShort565 - UnsignedInt8888Rev -) - -var ( - fbo, tex, rbo uint32 - hasDepth bool - pixType, pixFormat uint32 - buf []byte - bufPtr unsafe.Pointer -) - -func initContext(getProcAddr func(name string) unsafe.Pointer) { - if err := gl.InitWithProcAddrFunc(getProcAddr); err != nil { - panic(err) - } - gl.PixelStorei(gl.PackAlignment, 1) -} - -func initFramebuffer(width, height int, depth, stencil bool) error { - w, h := int32(width), int32(height) - hasDepth = depth - - gl.GenTextures(1, &tex) - gl.BindTexture(gl.Texture2d, tex) - gl.TexParameteri(gl.Texture2d, gl.TextureMinFilter, gl.NEAREST) - gl.TexParameteri(gl.Texture2d, gl.TextureMagFilter, gl.NEAREST) - gl.TexImage2D(gl.Texture2d, 0, gl.RGBA8, w, h, 0, pixType, pixFormat, nil) - gl.BindTexture(gl.Texture2d, 0) - - gl.GenFramebuffers(1, &fbo) - gl.BindFramebuffer(gl.FRAMEBUFFER, fbo) - gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.ColorAttachment0, gl.Texture2d, tex, 0) - - if depth { - gl.GenRenderbuffers(1, &rbo) - gl.BindRenderbuffer(gl.RENDERBUFFER, rbo) - format, attachment := uint32(gl.DepthComponent24), uint32(gl.DepthAttachment) - if stencil { - format, attachment = gl.Depth24Stencil8, gl.DepthStencilAttachment - } - gl.RenderbufferStorage(gl.RENDERBUFFER, format, w, h) - gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, attachment, gl.RENDERBUFFER, rbo) - gl.BindRenderbuffer(gl.RENDERBUFFER, 0) - } - - if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FramebufferComplete { - return fmt.Errorf("framebuffer incomplete: 0x%X", status) - } - return nil -} - -func destroyFramebuffer() { - if hasDepth { - gl.DeleteRenderbuffers(1, &rbo) - } - gl.DeleteFramebuffers(1, &fbo) - gl.DeleteTextures(1, &tex) -} - -func ReadFramebuffer(size, w, h uint) []byte { - gl.BindFramebuffer(gl.FRAMEBUFFER, fbo) - gl.ReadPixels(0, 0, int32(w), int32(h), pixType, pixFormat, bufPtr) - return buf[:size] -} - -func SetBuffer(size int) { - buf = make([]byte, size) - bufPtr = unsafe.Pointer(&buf[0]) -} - -func SetPixelFormat(format PixelFormat) error { - switch format { - case UnsignedShort5551: - pixFormat, pixType = gl.UnsignedShort5551, gl.BGRA - case UnsignedShort565: - pixFormat, pixType = gl.UnsignedShort565, gl.RGB - case UnsignedInt8888Rev: - pixFormat, pixType = gl.UnsignedInt8888Rev, gl.BGRA - default: - return errors.New("unknown pixel format") - } - return nil -} - -func GLInfo() (version, vendor, renderer, glsl string) { - return gl.GoStr(gl.GetString(gl.VERSION)), - gl.GoStr(gl.GetString(gl.VENDOR)), - gl.GoStr(gl.GetString(gl.RENDERER)), - gl.GoStr(gl.GetString(gl.ShadingLanguageVersion)) -} - -func GlFbo() uint32 { return fbo } diff --git a/pkg/worker/caged/libretro/graphics/sdl.go b/pkg/worker/caged/libretro/graphics/sdl.go deleted file mode 100644 index 7c885d88..00000000 --- a/pkg/worker/caged/libretro/graphics/sdl.go +++ /dev/null @@ -1,100 +0,0 @@ -package graphics - -import ( - "fmt" - "unsafe" - - "github.com/veandco/go-sdl2/sdl" -) - -type SDL struct { - w *sdl.Window - ctx sdl.GLContext -} - -type Config struct { - Ctx Context - W, H int - GLAutoContext bool - GLVersionMajor uint - GLVersionMinor uint - GLHasDepth bool - GLHasStencil bool -} - -func NewSDLContext(cfg Config) (*SDL, error) { - if err := sdl.Init(sdl.INIT_VIDEO); err != nil { - return nil, fmt.Errorf("sdl: %w", err) - } - - if !cfg.GLAutoContext { - if err := setGLAttrs(cfg.Ctx); err != nil { - return nil, err - } - } - - w, err := sdl.CreateWindow("cloud-retro", sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, 1, 1, sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN) - if err != nil { - return nil, fmt.Errorf("window: %w", err) - } - - ctx, err := w.GLCreateContext() - if err != nil { - err1 := w.Destroy() - return nil, fmt.Errorf("gl context: %w, destroy err: %w", err, err1) - } - - if err = w.GLMakeCurrent(ctx); err != nil { - return nil, fmt.Errorf("gl bind: %w", err) - } - - initContext(sdl.GLGetProcAddress) - - if err = initFramebuffer(cfg.W, cfg.H, cfg.GLHasDepth, cfg.GLHasStencil); err != nil { - return nil, fmt.Errorf("fbo: %w", err) - } - - return &SDL{w: w, ctx: ctx}, nil -} - -func setGLAttrs(ctx Context) error { - set := sdl.GLSetAttribute - switch ctx { - case CtxOpenGlCore: - return set(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE) - case CtxOpenGlEs2: - for _, a := range [][2]int{ - {sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_ES}, - {sdl.GL_CONTEXT_MAJOR_VERSION, 3}, - {sdl.GL_CONTEXT_MINOR_VERSION, 0}, - } { - if err := set(sdl.GLattr(a[0]), a[1]); err != nil { - return err - } - } - return nil - case CtxOpenGl: - return set(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_COMPATIBILITY) - default: - return fmt.Errorf("unsupported gl context: %v", ctx) - } -} - -func (s *SDL) Deinit() error { - destroyFramebuffer() - sdl.GLDeleteContext(s.ctx) - err := s.w.Destroy() - sdl.Quit() - return err -} - -func (s *SDL) BindContext() error { return s.w.GLMakeCurrent(s.ctx) } -func GlProcAddress(proc string) unsafe.Pointer { return sdl.GLGetProcAddress(proc) } - -func TryInit() error { - if err := sdl.Init(sdl.INIT_VIDEO); err != nil { - return err - } - sdl.Quit() - return nil -} diff --git a/pkg/worker/caged/libretro/manager/manager.go b/pkg/worker/caged/libretro/manager/manager.go deleted file mode 100644 index 9ccfa022..00000000 --- a/pkg/worker/caged/libretro/manager/manager.go +++ /dev/null @@ -1,32 +0,0 @@ -package manager - -import ( - "os" - "path/filepath" - "strings" - - "github.com/giongto35/cloud-game/v3/pkg/config" -) - -type BasicManager struct { - Conf config.LibretroConfig -} - -func (m BasicManager) GetInstalled(libExt string) (installed []config.CoreInfo, err error) { - if libExt == "" { - return - } - dir := m.Conf.GetCoresStorePath() - files, err := os.ReadDir(dir) - if err != nil { - return - } - - for _, file := range files { - name := file.Name() - if filepath.Ext(name) == libExt { - installed = append(installed, config.CoreInfo{Name: strings.TrimSuffix(name, libExt)}) - } - } - return -} diff --git a/pkg/worker/caged/libretro/manager/repository.go b/pkg/worker/caged/libretro/manager/repository.go deleted file mode 100644 index 3dbe0686..00000000 --- a/pkg/worker/caged/libretro/manager/repository.go +++ /dev/null @@ -1,65 +0,0 @@ -package manager - -import "strings" - -type ArchInfo struct { - Arch string - Ext string - Os string - Vendor string -} - -type Data struct { - Url string - Compression string -} - -type Repository interface { - CoreUrl(file string, info ArchInfo) (url string) -} - -// Repo defines a simple zip file containing all the cores that will be extracted as is. -type Repo struct { - Address string - Compression string -} - -func (r Repo) CoreUrl(_ string, _ ArchInfo) string { return r.Address } - -type Buildbot struct{ Repo } - -func (r Buildbot) CoreUrl(file string, info ArchInfo) string { - var sb strings.Builder - sb.WriteString(r.Address + "/") - if info.Vendor != "" { - sb.WriteString(info.Vendor + "/") - } - sb.WriteString(info.Os + "/" + info.Arch + "/latest/" + file + info.Ext) - if r.Compression != "" { - sb.WriteString("." + r.Compression) - } - return sb.String() -} - -type Github struct{ Buildbot } - -func (r Github) CoreUrl(file string, info ArchInfo) string { - return r.Buildbot.CoreUrl(file, info) + "?raw=true" -} - -func NewRepo(kind string, url string, compression string, defaultRepo string) Repository { - var repository Repository - switch kind { - case "buildbot": - repository = Buildbot{Repo{Address: url, Compression: compression}} - case "github": - repository = Github{Buildbot{Repo{Address: url, Compression: compression}}} - case "raw": - repository = Repo{Address: url, Compression: "zip"} - default: - if defaultRepo != "" { - repository = NewRepo(defaultRepo, url, compression, "") - } - } - return repository -} diff --git a/pkg/worker/caged/libretro/manager/repository_test.go b/pkg/worker/caged/libretro/manager/repository_test.go deleted file mode 100644 index bff2c16a..00000000 --- a/pkg/worker/caged/libretro/manager/repository_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package manager - -import "testing" - -func TestCoreUrl(t *testing.T) { - testAddress := "https://test.me" - tests := []struct { - arch ArchInfo - compress string - f string - repo string - result string - }{ - { - arch: ArchInfo{Arch: "x86_64", Ext: ".so", Os: "linux"}, - f: "uber_core", - repo: "buildbot", - result: testAddress + "/" + "linux/x86_64/latest/uber_core.so", - }, - { - arch: ArchInfo{Arch: "x86_64", Ext: ".so", Os: "linux"}, - compress: "zip", - f: "uber_core", - repo: "buildbot", - result: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip", - }, - { - arch: ArchInfo{Arch: "x86_64", Ext: ".dylib", Os: "osx", Vendor: "apple"}, - f: "uber_core", - repo: "buildbot", - result: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib", - }, - { - arch: ArchInfo{Os: "linux", Arch: "x86_64", Ext: ".so"}, - f: "uber_core", - repo: "github", - result: testAddress + "/" + "linux/x86_64/latest/uber_core.so?raw=true", - }, - { - arch: ArchInfo{Os: "linux", Arch: "x86_64", Ext: ".so"}, - compress: "zip", - f: "uber_core", - repo: "github", - result: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip?raw=true", - }, - { - arch: ArchInfo{Os: "osx", Arch: "x86_64", Vendor: "apple", Ext: ".dylib"}, - f: "uber_core", - repo: "github", - result: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib?raw=true", - }, - } - - for _, test := range tests { - r := NewRepo(test.repo, testAddress, test.compress, "") - url := r.CoreUrl(test.f, test.arch) - if url != test.result { - t.Errorf("seems that expected link address is incorrect (%v) for file %s %+v", url, test.f, test.arch) - } - } -} diff --git a/pkg/worker/caged/libretro/nanoarch/input.go b/pkg/worker/caged/libretro/nanoarch/input.go deleted file mode 100644 index eb6080c5..00000000 --- a/pkg/worker/caged/libretro/nanoarch/input.go +++ /dev/null @@ -1,167 +0,0 @@ -package nanoarch - -import ( - "encoding/binary" - "sync/atomic" -) - -/* -#include -#include "libretro.h" - -void input_cache_set_port(unsigned port, uint32_t buttons, - int16_t lx, int16_t ly, int16_t rx, int16_t ry, - int16_t l2, int16_t r2); -void input_cache_set_keyboard_key(unsigned id, uint8_t pressed); -void input_cache_set_mouse(int16_t dx, int16_t dy, uint8_t buttons); -void input_cache_clear(void); -*/ -import "C" - -const ( - maxPort = 4 - numAxes = 4 - RetrokLast = int(C.RETROK_LAST) -) - -type Device byte - -const ( - RetroPad Device = iota - Keyboard - Mouse -) - -const ( - MouseMove = iota - MouseButton -) - -type MouseBtnState int32 - -const ( - MouseLeft MouseBtnState = 1 << iota - MouseRight - MouseMiddle -) - -// InputState stores controller state for all ports. -// - uint16 button bitmask -// - int16 analog axes x4 (left stick, right stick) -// - int16 analog triggers x2 (L2, R2) -type InputState [maxPort]struct { - keys uint32 // lower 16 bits used - axes int64 // packed: [LX:16][LY:16][RX:16][RY:16] - triggers int32 // packed: [L2:16][R2:16] -} - -// SetInput sets input state for a player. -// -// [BTN:2][LX:2][LY:2][RX:2][RY:2][L2:2][R2:2] -func (s *InputState) SetInput(port int, data []byte) { - if len(data) < 2 { - return - } - - // Buttons - atomic.StoreUint32(&s[port].keys, uint32(binary.LittleEndian.Uint16(data))) - - // Axes - pack into int64 - var packedAxes int64 - for i := 0; i < numAxes && i*2+3 < len(data); i++ { - axis := int64(int16(binary.LittleEndian.Uint16(data[i*2+2:]))) - packedAxes |= (axis & 0xFFFF) << (i * 16) - } - atomic.StoreInt64(&s[port].axes, packedAxes) - - // Analog triggers L2, R2 - pack into int32 - if len(data) >= 14 { - l2 := int32(int16(binary.LittleEndian.Uint16(data[10:]))) - r2 := int32(int16(binary.LittleEndian.Uint16(data[12:]))) - atomic.StoreInt32(&s[port].triggers, (l2&0xFFFF)|((r2&0xFFFF)<<16)) - } -} - -// SyncToCache syncs input state to C-side cache before Run(). -func (s *InputState) SyncToCache() { - for p := uint(0); p < maxPort; p++ { - keys := atomic.LoadUint32(&s[p].keys) - axes := atomic.LoadInt64(&s[p].axes) - triggers := atomic.LoadInt32(&s[p].triggers) - - C.input_cache_set_port(C.uint(p), C.uint32_t(keys), - C.int16_t(axes), - C.int16_t(axes>>16), - C.int16_t(axes>>32), - C.int16_t(axes>>48), - C.int16_t(triggers), - C.int16_t(triggers>>16)) - } -} - -// KeyboardState tracks keys of the keyboard. -type KeyboardState struct { - keys [6]atomic.Uint64 // 342 keys packed into 6 uint64s (384 bits) - mod atomic.Uint32 -} - -// SetKey sets keyboard state. -// -// [KEY:4][P:1][MOD:2] -// -// KEY - Libretro key code, P - pressed (0/1), MOD - modifier bitmask -func (ks *KeyboardState) SetKey(data []byte) (pressed bool, key uint, mod uint16) { - if len(data) != 7 { - return - } - key = uint(binary.BigEndian.Uint32(data)) - mod = binary.BigEndian.Uint16(data[5:]) - pressed = data[4] == 1 - - idx, bit := key/64, uint64(1)<<(key%64) - if pressed { - ks.keys[idx].Or(bit) - } else { - ks.keys[idx].And(^bit) - } - ks.mod.Store(uint32(mod)) - - return -} - -// SyncToCache syncs keyboard state to C-side cache. -func (ks *KeyboardState) SyncToCache() { - for id := 0; id < RetrokLast; id++ { - pressed := (ks.keys[id/64].Load() >> (id % 64)) & 1 - C.input_cache_set_keyboard_key(C.uint(id), C.uint8_t(pressed)) - } -} - -// MouseState tracks mouse delta and buttons. -type MouseState struct { - dx, dy atomic.Int32 - buttons atomic.Int32 -} - -// ShiftPos adds relative mouse movement. -// -// [dx:2][dy:2] -func (ms *MouseState) ShiftPos(data []byte) { - if len(data) != 4 { - return - } - ms.dx.Add(int32(int16(binary.BigEndian.Uint16(data[:2])))) - ms.dy.Add(int32(int16(binary.BigEndian.Uint16(data[2:])))) -} - -func (ms *MouseState) SetButtons(b byte) { ms.buttons.Store(int32(b)) } - -func (ms *MouseState) Buttons() (l, r, m bool) { - b := MouseBtnState(ms.buttons.Load()) - return b&MouseLeft != 0, b&MouseRight != 0, b&MouseMiddle != 0 -} - -// SyncToCache syncs mouse state to C-side cache, consuming deltas. -func (ms *MouseState) SyncToCache() { - C.input_cache_set_mouse(C.int16_t(ms.dx.Swap(0)), C.int16_t(ms.dy.Swap(0)), C.uint8_t(ms.buttons.Load())) -} diff --git a/pkg/worker/caged/libretro/nanoarch/input_test.go b/pkg/worker/caged/libretro/nanoarch/input_test.go deleted file mode 100644 index 1df81da7..00000000 --- a/pkg/worker/caged/libretro/nanoarch/input_test.go +++ /dev/null @@ -1,514 +0,0 @@ -package nanoarch - -import ( - "encoding/binary" - "math/rand" - "sync" - "testing" -) - -func TestInputState_SetInput(t *testing.T) { - tests := []struct { - name string - port int - data []byte - keys uint32 - axes [4]int16 - triggers [2]int16 - }{ - { - name: "buttons only", - port: 0, - data: []byte{0xFF, 0x01}, - keys: 0x01FF, - }, - { - name: "buttons and axes", - port: 1, - data: []byte{0x03, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x00, 0x80, 0xFF, 0x7F}, - keys: 0x0003, - axes: [4]int16{10000, -10000, -32768, 32767}, - }, - { - name: "partial axes", - port: 2, - data: []byte{0x01, 0x00, 0x64, 0x00}, - keys: 0x0001, - axes: [4]int16{100, 0, 0, 0}, - }, - { - name: "max port", - port: 3, - data: []byte{0xFF, 0xFF}, - keys: 0xFFFF, - }, - { - name: "full input with triggers", - port: 0, - data: []byte{ - 0x03, 0x00, // buttons - 0x10, 0x27, // LX: 10000 - 0xF0, 0xD8, // LY: -10000 - 0x00, 0x80, // RX: -32768 - 0xFF, 0x7F, // RY: 32767 - 0xFF, 0x3F, // L2: 16383 - 0xFF, 0x7F, // R2: 32767 - }, - keys: 0x0003, - axes: [4]int16{10000, -10000, -32768, 32767}, - triggers: [2]int16{16383, 32767}, - }, - { - name: "axes without triggers", - port: 1, - data: []byte{ - 0x01, 0x00, - 0x64, 0x00, // LX: 100 - 0xC8, 0x00, // LY: 200 - 0x2C, 0x01, // RX: 300 - 0x90, 0x01, // RY: 400 - }, - keys: 0x0001, - axes: [4]int16{100, 200, 300, 400}, - }, - { - name: "zero triggers", - port: 2, - data: []byte{ - 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, // L2: 0 - 0x00, 0x00, // R2: 0 - }, - keys: 0x0000, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - state := InputState{} - state.SetInput(test.port, test.data) - - if state[test.port].keys != test.keys { - t.Errorf("keys: got %v, want %v", state[test.port].keys, test.keys) - } - - // Check axes from packed int64 - axes := state[test.port].axes - for i, want := range test.axes { - got := int16(axes >> (i * 16)) - if got != want { - t.Errorf("axes[%d]: got %v, want %v", i, got, want) - } - } - - // Check triggers from packed int32 - triggers := state[test.port].triggers - l2 := int16(triggers) - r2 := int16(triggers >> 16) - if l2 != test.triggers[0] { - t.Errorf("L2: got %v, want %v", l2, test.triggers[0]) - } - if r2 != test.triggers[1] { - t.Errorf("R2: got %v, want %v", r2, test.triggers[1]) - } - }) - } -} - -func TestInputState_AxisExtraction(t *testing.T) { - state := InputState{} - data := []byte{ - 0x00, 0x00, // buttons - 0x01, 0x00, // LX: 1 - 0x02, 0x00, // LY: 2 - 0x03, 0x00, // RX: 3 - 0x04, 0x00, // RY: 4 - 0x05, 0x00, // L2: 5 - 0x06, 0x00, // R2: 6 - } - state.SetInput(0, data) - - axes := state[0].axes - expected := []int16{1, 2, 3, 4} - for i, want := range expected { - got := int16(axes >> (i * 16)) - if got != want { - t.Errorf("axis[%d]: got %v, want %v", i, got, want) - } - } - - triggers := state[0].triggers - if got := int16(triggers); got != 5 { - t.Errorf("L2: got %v, want 5", got) - } - if got := int16(triggers >> 16); got != 6 { - t.Errorf("R2: got %v, want 6", got) - } -} - -func TestInputState_NegativeAxes(t *testing.T) { - state := InputState{} - data := []byte{ - 0x00, 0x00, // buttons - 0x00, 0x80, // LX: -32768 - 0xFF, 0xFF, // LY: -1 - 0x01, 0x80, // RX: -32767 - 0xFE, 0xFF, // RY: -2 - } - state.SetInput(0, data) - - axes := state[0].axes - expected := []int16{-32768, -1, -32767, -2} - for i, want := range expected { - got := int16(axes >> (i * 16)) - if got != want { - t.Errorf("axis[%d]: got %v, want %v", i, got, want) - } - } -} - -func TestInputState_Concurrent(t *testing.T) { - var wg sync.WaitGroup - state := InputState{} - events := 1000 - wg.Add(events) - - for range events { - player := rand.Intn(maxPort) - go func() { - // Full 14-byte input - state.SetInput(player, []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) - wg.Done() - }() - } - wg.Wait() -} - -func TestKeyboardState_SetKey(t *testing.T) { - tests := []struct { - name string - data []byte - pressed bool - key uint - mod uint16 - }{ - { - name: "key pressed", - data: []byte{0, 0, 0, 42, 1, 0, 3}, - pressed: true, - key: 42, - mod: 3, - }, - { - name: "key released", - data: []byte{0, 0, 0, 100, 0, 0, 0}, - pressed: false, - key: 100, - mod: 0, - }, - { - name: "high key code", - data: []byte{0, 0, 1, 50, 1, 0xFF, 0xFF}, - pressed: true, - key: 306, - mod: 0xFFFF, - }, - { - name: "invalid length", - data: []byte{0, 0, 0}, - pressed: false, - key: 0, - mod: 0, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ks := KeyboardState{} - pressed, key, mod := ks.SetKey(test.data) - - if pressed != test.pressed { - t.Errorf("pressed: got %v, want %v", pressed, test.pressed) - } - if key != test.key { - t.Errorf("key: got %v, want %v", key, test.key) - } - if mod != test.mod { - t.Errorf("mod: got %v, want %v", mod, test.mod) - } - }) - } -} - -func TestKeyboardState_IsPressed(t *testing.T) { - ks := KeyboardState{} - - // Initially not pressed - if ks.keys[0].Load() != 0 { - t.Error("key should not be pressed initially") - } - - // Press key - ks.SetKey([]byte{0, 0, 0, 42, 1, 0, 0}) - if (ks.keys[42/64].Load()>>(42%64))&1 != 1 { - t.Error("key should be pressed") - } - - // Release key - ks.SetKey([]byte{0, 0, 0, 42, 0, 0, 0}) - if (ks.keys[42/64].Load()>>(42%64))&1 != 0 { - t.Error("key should be released") - } -} - -func TestKeyboardState_MultipleBits(t *testing.T) { - ks := KeyboardState{} - - // Press keys in different uint64 slots - keys := []uint{0, 63, 64, 127, 128, 200, 300, 341} - for _, k := range keys { - data := make([]byte, 7) - binary.BigEndian.PutUint32(data, uint32(k)) - data[4] = 1 - ks.SetKey(data) - } - - // Check all pressed - for _, k := range keys { - if (ks.keys[k/64].Load()>>(k%64))&1 != 1 { - t.Errorf("key %d should be pressed", k) - } - } - - // Release some - for _, k := range []uint{0, 128, 341} { - data := make([]byte, 7) - binary.BigEndian.PutUint32(data, uint32(k)) - data[4] = 0 - ks.SetKey(data) - } - - // Check states - expected := map[uint]uint64{ - 0: 0, 63: 1, 64: 1, 127: 1, 128: 0, 200: 1, 300: 1, 341: 0, - } - for k, want := range expected { - got := (ks.keys[k/64].Load() >> (k % 64)) & 1 - if got != want { - t.Errorf("key %d: got %v, want %v", k, got, want) - } - } -} - -func TestKeyboardState_Concurrent(t *testing.T) { - var wg sync.WaitGroup - ks := KeyboardState{} - events := 1000 - wg.Add(events * 2) - - for range events { - key := uint(rand.Intn(RetrokLast)) - go func() { - data := make([]byte, 7) - binary.BigEndian.PutUint32(data, uint32(key)) - data[4] = byte(rand.Intn(2)) - ks.SetKey(data) - wg.Done() - }() - go func() { - _ = (ks.keys[key/64].Load() >> (key % 64)) & 1 - wg.Done() - }() - } - wg.Wait() -} - -func TestMouseState_ShiftPos(t *testing.T) { - tests := []struct { - name string - dx int16 - dy int16 - rx int16 - ry int16 - b func(dx, dy int16) []byte - }{ - { - name: "positive values", - dx: 100, - dy: 200, - rx: 100, - ry: 200, - b: func(dx, dy int16) []byte { - data := make([]byte, 4) - binary.BigEndian.PutUint16(data, uint16(dx)) - binary.BigEndian.PutUint16(data[2:], uint16(dy)) - return data - }, - }, - { - name: "negative values", - dx: -10123, - dy: 5678, - rx: -10123, - ry: 5678, - b: func(dx, dy int16) []byte { - data := make([]byte, 4) - binary.BigEndian.PutUint16(data, uint16(dx)) - binary.BigEndian.PutUint16(data[2:], uint16(dy)) - return data - }, - }, - { - name: "wrong endian", - dx: -1234, - dy: 5678, - rx: 12027, - ry: 11798, - b: func(dx, dy int16) []byte { - data := make([]byte, 4) - binary.LittleEndian.PutUint16(data, uint16(dx)) - binary.LittleEndian.PutUint16(data[2:], uint16(dy)) - return data - }, - }, - { - name: "max values", - dx: 32767, - dy: -32768, - rx: 32767, - ry: -32768, - b: func(dx, dy int16) []byte { - data := make([]byte, 4) - binary.BigEndian.PutUint16(data, uint16(dx)) - binary.BigEndian.PutUint16(data[2:], uint16(dy)) - return data - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ms := MouseState{} - ms.ShiftPos(test.b(test.dx, test.dy)) - - x, y := int16(ms.dx.Swap(0)), int16(ms.dy.Swap(0)) - - if x != test.rx || y != test.ry { - t.Errorf("got (%v, %v), want (%v, %v)", x, y, test.rx, test.ry) - } - - if ms.dx.Load() != 0 || ms.dy.Load() != 0 { - t.Error("coordinates weren't cleared") - } - }) - } -} - -func TestMouseState_ShiftPosAccumulates(t *testing.T) { - ms := MouseState{} - - data := make([]byte, 4) - binary.BigEndian.PutUint16(data, uint16(10)) - binary.BigEndian.PutUint16(data[2:], uint16(20)) - - ms.ShiftPos(data) - ms.ShiftPos(data) - ms.ShiftPos(data) - - if got := ms.dx.Load(); got != 30 { - t.Errorf("dx: got %v, want 30", got) - } - if got := ms.dy.Load(); got != 60 { - t.Errorf("dy: got %v, want 60", got) - } -} - -func TestMouseState_ShiftPosInvalidLength(t *testing.T) { - ms := MouseState{} - - ms.ShiftPos([]byte{1, 2, 3}) - ms.ShiftPos([]byte{1, 2, 3, 4, 5}) - - if ms.dx.Load() != 0 || ms.dy.Load() != 0 { - t.Error("invalid data should be ignored") - } -} - -func TestMouseState_Buttons(t *testing.T) { - tests := []struct { - name string - data byte - l bool - r bool - m bool - }{ - {name: "none", data: 0}, - {name: "left", data: 1, l: true}, - {name: "right", data: 2, r: true}, - {name: "middle", data: 4, m: true}, - {name: "left+right", data: 3, l: true, r: true}, - {name: "all", data: 7, l: true, r: true, m: true}, - {name: "left+middle", data: 5, l: true, m: true}, - } - - ms := MouseState{} - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ms.SetButtons(test.data) - l, r, m := ms.Buttons() - if l != test.l || r != test.r || m != test.m { - t.Errorf("got (%v, %v, %v), want (%v, %v, %v)", l, r, m, test.l, test.r, test.m) - } - }) - } -} - -func TestMouseState_Concurrent(t *testing.T) { - var wg sync.WaitGroup - ms := MouseState{} - events := 1000 - wg.Add(events * 3) - - for range events { - go func() { - data := make([]byte, 4) - binary.BigEndian.PutUint16(data, uint16(rand.Int31n(100)-50)) - binary.BigEndian.PutUint16(data[2:], uint16(rand.Int31n(100)-50)) - ms.ShiftPos(data) - wg.Done() - }() - go func() { - ms.SetButtons(byte(rand.Intn(8))) - wg.Done() - }() - go func() { - ms.Buttons() - wg.Done() - }() - } - wg.Wait() -} - -func TestConstants(t *testing.T) { - // MouseBtnState - if MouseLeft != 1 || MouseRight != 2 || MouseMiddle != 4 { - t.Error("invalid MouseBtnState constants") - } - - // Device - if RetroPad != 0 || Keyboard != 1 || Mouse != 2 { - t.Error("invalid Device constants") - } - - // Mouse events - if MouseMove != 0 || MouseButton != 1 { - t.Error("invalid mouse event constants") - } - - // Limits - if maxPort != 4 || numAxes != 4 || RetrokLast != 342 { - t.Error("invalid limit constants") - } -} diff --git a/pkg/worker/caged/libretro/nanoarch/libretro.h b/pkg/worker/caged/libretro/nanoarch/libretro.h deleted file mode 100644 index c549976d..00000000 --- a/pkg/worker/caged/libretro/nanoarch/libretro.h +++ /dev/null @@ -1,7846 +0,0 @@ -/*! - * libretro.h is a simple API that allows for the creation of games and emulators. - * - * @file libretro.h - * @version 1 - * @author libretro - * @copyright Copyright (C) 2010-2024 The RetroArch team - * - * @paragraph LICENSE - * The following license statement only applies to this libretro API header (libretro.h). - * - * Copyright (C) 2010-2024 The RetroArch team - * - * Permission is hereby granted, free of charge, - * to any person obtaining a copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, - * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, - * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -#ifndef LIBRETRO_H__ -#define LIBRETRO_H__ - -#include -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#ifndef __cplusplus -#if defined(_MSC_VER) && _MSC_VER < 1800 && !defined(SN_TARGET_PS3) -/* Hack applied for MSVC when compiling in C89 mode - * as it isn't C99-compliant. */ -#define bool unsigned char -#define true 1 -#define false 0 -#else -#include -#endif -#endif - -#ifndef RETRO_CALLCONV -# if defined(__GNUC__) && defined(__i386__) && !defined(__x86_64__) -# define RETRO_CALLCONV __attribute__((cdecl)) -# elif defined(_MSC_VER) && defined(_M_X86) && !defined(_M_X64) -# define RETRO_CALLCONV __cdecl -# else -# define RETRO_CALLCONV /* all other platforms only have one calling convention each */ -# endif -#endif - -#ifndef RETRO_API -# if defined(_WIN32) || defined(__CYGWIN__) || defined(__MINGW32__) -# ifdef RETRO_IMPORT_SYMBOLS -# ifdef __GNUC__ -# define RETRO_API RETRO_CALLCONV __attribute__((__dllimport__)) -# else -# define RETRO_API RETRO_CALLCONV __declspec(dllimport) -# endif -# else -# ifdef __GNUC__ -# define RETRO_API RETRO_CALLCONV __attribute__((__dllexport__)) -# else -# define RETRO_API RETRO_CALLCONV __declspec(dllexport) -# endif -# endif -# else -# if defined(__GNUC__) && __GNUC__ >= 4 -# define RETRO_API RETRO_CALLCONV __attribute__((__visibility__("default"))) -# else -# define RETRO_API RETRO_CALLCONV -# endif -# endif -#endif - -/** - * The major version of the libretro API and ABI. - * Cores may support multiple versions, - * or they may reject cores with unsupported versions. - * It is only incremented for incompatible API/ABI changes; - * this generally implies a function was removed or changed, - * or that a \c struct had fields removed or changed. - * @note A design goal of libretro is to avoid having to increase this value at all costs. - * This is why there are APIs that are "extended" or "V2". - */ -#define RETRO_API_VERSION 1 - -/** - * @defgroup RETRO_DEVICE Input Devices - * @brief Libretro's fundamental device abstractions. - * - * Libretro's input system consists of abstractions over standard device types, - * such as a joypad (with or without analog), mouse, keyboard, light gun, or an abstract pointer. - * Instead of managing input devices themselves, - * cores need only to map their own concept of a controller to libretro's abstractions. - * This makes it possible for frontends to map the abstract types to a real input device - * without having to worry about the correct use of arbitrary (real) controller layouts. - * @{ - */ - -#define RETRO_DEVICE_TYPE_SHIFT 8 -#define RETRO_DEVICE_MASK ((1 << RETRO_DEVICE_TYPE_SHIFT) - 1) - -/** - * Defines an ID for a subclass of a known device type. - * - * To define a subclass ID, use this macro like so: - * @code{c} - * #define RETRO_DEVICE_SUPER_SCOPE RETRO_DEVICE_SUBCLASS(RETRO_DEVICE_LIGHTGUN, 1) - * #define RETRO_DEVICE_JUSTIFIER RETRO_DEVICE_SUBCLASS(RETRO_DEVICE_LIGHTGUN, 2) - * @endcode - * - * Correct use of this macro allows a frontend to select a suitable physical device - * to map to the emulated device. - * - * @note Cores must use the base ID when polling for input, - * and frontends must only accept the base ID for this purpose. - * Polling for input using subclass IDs is reserved for future definition. - * - * @param base One of the \ref RETRO_DEVICE "base device types". - * @param id A unique ID, with respect to \c base. - * Must be a non-negative integer. - * @return A unique subclass ID. - * @see retro_controller_description - * @see retro_set_controller_port_device - */ -#define RETRO_DEVICE_SUBCLASS(base, id) (((id + 1) << RETRO_DEVICE_TYPE_SHIFT) | base) - -/** - * @defgroup RETRO_DEVICE Input Device Classes - * @{ - */ - -/** - * Indicates no input. - * - * When provided as the \c device argument to \c retro_input_state_t, - * all other arguments are ignored and zero is returned. - * - * @see retro_input_state_t - */ -#define RETRO_DEVICE_NONE 0 - -/** - * An abstraction around a game controller, known as a "RetroPad". - * - * The RetroPad is modelled after a SNES controller, - * but with additional L2/R2/L3/R3 buttons - * (similar to a PlayStation controller). - * - * When provided as the \c device argument to \c retro_input_state_t, - * the \c id argument denotes the button (including D-Pad directions) to query. - * The result of said query will be 1 if the button is down, 0 if not. - * - * There is one exception; if \c RETRO_DEVICE_ID_JOYPAD_MASK is queried - * (and the frontend supports this query), - * the result will be a bitmask of all pressed buttons. - * - * @see retro_input_state_t - * @see RETRO_DEVICE_ANALOG - * @see RETRO_DEVICE_ID_JOYPAD - * @see RETRO_DEVICE_ID_JOYPAD_MASK - * @see RETRO_ENVIRONMENT_GET_INPUT_BITMASKS - */ -#define RETRO_DEVICE_JOYPAD 1 - -/** - * An abstraction around a mouse, similar to the SNES Mouse but with more buttons. - * - * When provided as the \c device argument to \c retro_input_state_t, - * the \c id argument denotes the button or axis to query. - * For buttons, the result of said query - * will be 1 if the button is down or 0 if not. - * For mouse wheel axes, the result - * will be 1 if the wheel was rotated in that direction and 0 if not. - * For the mouse pointer axis, the result will be thee mouse's movement - * relative to the last poll. - * The core is responsible for tracking the mouse's position, - * and the frontend is responsible for preventing interference - * by the real hardware pointer (if applicable). - * - * @note This should only be used for cores that emulate mouse input, - * such as for home computers - * or consoles with mouse attachments. - * Cores that emulate light guns should use \c RETRO_DEVICE_LIGHTGUN, - * and cores that emulate touch screens should use \c RETRO_DEVICE_POINTER. - * - * @see RETRO_DEVICE_POINTER - * @see RETRO_DEVICE_LIGHTGUN - */ -#define RETRO_DEVICE_MOUSE 2 - -/** - * An abstraction around a keyboard. - * - * When provided as the \c device argument to \c retro_input_state_t, - * the \c id argument denotes the key to poll. - * - * @note This should only be used for cores that emulate keyboard input, - * such as for home computers - * or consoles with keyboard attachments. - * Cores that emulate gamepads should use \c RETRO_DEVICE_JOYPAD or \c RETRO_DEVICE_ANALOG, - * and leave keyboard compatibility to the frontend. - * - * @see RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK - * @see retro_key - */ -#define RETRO_DEVICE_KEYBOARD 3 - -/** - * An abstraction around a light gun, similar to the PlayStation's Guncon. - * - * When provided as the \c device argument to \c retro_input_state_t, - * the \c id argument denotes one of several possible inputs. - * - * The gun's coordinates are reported in screen space (similar to the pointer) - * in the range of [-0x8000, 0x7fff]. - * Zero is the center of the game's screen - * and -0x8000 represents out-of-bounds. - * The trigger and various auxiliary buttons are also reported. - * - * @note A forced off-screen shot can be requested for auto-reloading - * function in some games. - * - * @see RETRO_DEVICE_POINTER - */ -#define RETRO_DEVICE_LIGHTGUN 4 - -/** - * An extension of the RetroPad that supports analog input. - * - * The analog RetroPad provides two virtual analog sticks (similar to DualShock controllers) - * and allows any button to be treated as analog (similar to Xbox shoulder triggers). - * - * When provided as the \c device argument to \c retro_input_state_t, - * the \c id argument denotes an analog axis or an analog button. - * - * Analog axes are reported in the range of [-0x8000, 0x7fff], - * with the X axis being positive towards the right - * and the Y axis being positive towards the bottom. - * - * Analog buttons are reported in the range of [0, 0x7fff], - * where 0 is unpressed and 0x7fff is fully pressed. - * - * @note Cores should only use this type if they need analog input. - * Otherwise, \c RETRO_DEVICE_JOYPAD should be used. - * @see RETRO_DEVICE_JOYPAD - */ -#define RETRO_DEVICE_ANALOG 5 - -/** - * Input Device: Pointer. - * - * Abstracts the concept of a pointing mechanism, e.g. touch. - * This allows libretro to query in absolute coordinates where on the - * screen a mouse (or something similar) is being placed. - * For a touch centric device, coordinates reported are the coordinates - * of the press. - * - * Coordinates in X and Y are reported as: - * [-0x7fff, 0x7fff]: -0x7fff corresponds to the far left/top of the screen, - * and 0x7fff corresponds to the far right/bottom of the screen. - * The "screen" is here defined as area that is passed to the frontend and - * later displayed on the monitor. If the pointer is outside this screen, - * such as in the black surrounding areas when actual display is larger, - * edge position is reported. An explicit edge detection is also provided, - * that will return 1 if the pointer is near the screen edge or actually outside it. - * - * The frontend is free to scale/resize this screen as it sees fit, however, - * (X, Y) = (-0x7fff, -0x7fff) will correspond to the top-left pixel of the - * game image, etc. - * - * To check if the pointer coordinates are valid (e.g. a touch display - * actually being touched), \c RETRO_DEVICE_ID_POINTER_PRESSED returns 1 or 0. - * - * If using a mouse on a desktop, \c RETRO_DEVICE_ID_POINTER_PRESSED will - * usually correspond to the left mouse button, but this is a frontend decision. - * \c RETRO_DEVICE_ID_POINTER_PRESSED will only return 1 if the pointer is - * inside the game screen. - * - * For multi-touch, the index variable can be used to successively query - * more presses. - * If index = 0 returns true for \c _PRESSED, coordinates can be extracted - * with \c _X, \c _Y for index = 0. One can then query \c _PRESSED, \c _X, \c _Y with - * index = 1, and so on. - * Eventually \c _PRESSED will return false for an index. No further presses - * are registered at this point. - * - * @see RETRO_DEVICE_MOUSE - * @see RETRO_DEVICE_ID_POINTER_X - * @see RETRO_DEVICE_ID_POINTER_Y - * @see RETRO_DEVICE_ID_POINTER_PRESSED - */ -#define RETRO_DEVICE_POINTER 6 - -/** @} */ - -/** @defgroup RETRO_DEVICE_ID_JOYPAD RetroPad Input - * @brief Digital buttons for the RetroPad. - * - * Button placement is comparable to that of a SNES controller, - * combined with the shoulder buttons of a PlayStation controller. - * These values can also be used for the \c id field of \c RETRO_DEVICE_INDEX_ANALOG_BUTTON - * to represent analog buttons (usually shoulder triggers). - * @{ - */ - -/** The equivalent of the SNES controller's south face button. */ -#define RETRO_DEVICE_ID_JOYPAD_B 0 - -/** The equivalent of the SNES controller's west face button. */ -#define RETRO_DEVICE_ID_JOYPAD_Y 1 - -/** The equivalent of the SNES controller's left-center button. */ -#define RETRO_DEVICE_ID_JOYPAD_SELECT 2 - -/** The equivalent of the SNES controller's right-center button. */ -#define RETRO_DEVICE_ID_JOYPAD_START 3 - -/** Up on the RetroPad's D-pad. */ -#define RETRO_DEVICE_ID_JOYPAD_UP 4 - -/** Down on the RetroPad's D-pad. */ -#define RETRO_DEVICE_ID_JOYPAD_DOWN 5 - -/** Left on the RetroPad's D-pad. */ -#define RETRO_DEVICE_ID_JOYPAD_LEFT 6 - -/** Right on the RetroPad's D-pad. */ -#define RETRO_DEVICE_ID_JOYPAD_RIGHT 7 - -/** The equivalent of the SNES controller's east face button. */ -#define RETRO_DEVICE_ID_JOYPAD_A 8 - -/** The equivalent of the SNES controller's north face button. */ -#define RETRO_DEVICE_ID_JOYPAD_X 9 - -/** The equivalent of the SNES controller's left shoulder button. */ -#define RETRO_DEVICE_ID_JOYPAD_L 10 - -/** The equivalent of the SNES controller's right shoulder button. */ -#define RETRO_DEVICE_ID_JOYPAD_R 11 - -/** The equivalent of the PlayStation's rear left shoulder button. */ -#define RETRO_DEVICE_ID_JOYPAD_L2 12 - -/** The equivalent of the PlayStation's rear right shoulder button. */ -#define RETRO_DEVICE_ID_JOYPAD_R2 13 - -/** - * The equivalent of the PlayStation's left analog stick button, - * although the actual button need not be in this position. - */ -#define RETRO_DEVICE_ID_JOYPAD_L3 14 - -/** - * The equivalent of the PlayStation's right analog stick button, - * although the actual button need not be in this position. - */ -#define RETRO_DEVICE_ID_JOYPAD_R3 15 - -/** - * Represents a bitmask that describes the state of all \c RETRO_DEVICE_ID_JOYPAD button constants, - * rather than the state of a single button. - * - * @see RETRO_ENVIRONMENT_GET_INPUT_BITMASKS - * @see RETRO_DEVICE_JOYPAD - */ -#define RETRO_DEVICE_ID_JOYPAD_MASK 256 - -/** @} */ - -/** @defgroup RETRO_DEVICE_ID_ANALOG Analog RetroPad Input - * @{ - */ - -/* Index / Id values for ANALOG device. */ -#define RETRO_DEVICE_INDEX_ANALOG_LEFT 0 -#define RETRO_DEVICE_INDEX_ANALOG_RIGHT 1 -#define RETRO_DEVICE_INDEX_ANALOG_BUTTON 2 -#define RETRO_DEVICE_ID_ANALOG_X 0 -#define RETRO_DEVICE_ID_ANALOG_Y 1 - -/** @} */ - -/* Id values for MOUSE. */ -#define RETRO_DEVICE_ID_MOUSE_X 0 -#define RETRO_DEVICE_ID_MOUSE_Y 1 -#define RETRO_DEVICE_ID_MOUSE_LEFT 2 -#define RETRO_DEVICE_ID_MOUSE_RIGHT 3 -#define RETRO_DEVICE_ID_MOUSE_WHEELUP 4 -#define RETRO_DEVICE_ID_MOUSE_WHEELDOWN 5 -#define RETRO_DEVICE_ID_MOUSE_MIDDLE 6 -#define RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELUP 7 -#define RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELDOWN 8 -#define RETRO_DEVICE_ID_MOUSE_BUTTON_4 9 -#define RETRO_DEVICE_ID_MOUSE_BUTTON_5 10 - -/* Id values for LIGHTGUN. */ -#define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_X 13 /*Absolute Position*/ -#define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_Y 14 /*Absolute Position*/ -/** Indicates if lightgun points off the screen or near the edge */ -#define RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN 15 /*Status Check*/ -#define RETRO_DEVICE_ID_LIGHTGUN_TRIGGER 2 -#define RETRO_DEVICE_ID_LIGHTGUN_RELOAD 16 /*Forced off-screen shot*/ -#define RETRO_DEVICE_ID_LIGHTGUN_AUX_A 3 -#define RETRO_DEVICE_ID_LIGHTGUN_AUX_B 4 -#define RETRO_DEVICE_ID_LIGHTGUN_START 6 -#define RETRO_DEVICE_ID_LIGHTGUN_SELECT 7 -#define RETRO_DEVICE_ID_LIGHTGUN_AUX_C 8 -#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_UP 9 -#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_DOWN 10 -#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_LEFT 11 -#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_RIGHT 12 -/* deprecated */ -#define RETRO_DEVICE_ID_LIGHTGUN_X 0 /*Relative Position*/ -#define RETRO_DEVICE_ID_LIGHTGUN_Y 1 /*Relative Position*/ -#define RETRO_DEVICE_ID_LIGHTGUN_CURSOR 3 /*Use Aux:A instead*/ -#define RETRO_DEVICE_ID_LIGHTGUN_TURBO 4 /*Use Aux:B instead*/ -#define RETRO_DEVICE_ID_LIGHTGUN_PAUSE 5 /*Use Start instead*/ - -/* Id values for POINTER. */ -#define RETRO_DEVICE_ID_POINTER_X 0 -#define RETRO_DEVICE_ID_POINTER_Y 1 -#define RETRO_DEVICE_ID_POINTER_PRESSED 2 -#define RETRO_DEVICE_ID_POINTER_COUNT 3 -/** Indicates if pointer is off the screen or near the edge */ -#define RETRO_DEVICE_ID_POINTER_IS_OFFSCREEN 15 -/** @} */ - -/* Returned from retro_get_region(). */ -#define RETRO_REGION_NTSC 0 -#define RETRO_REGION_PAL 1 - -/** - * Identifiers for supported languages. - * @see RETRO_ENVIRONMENT_GET_LANGUAGE - */ -enum retro_language -{ - RETRO_LANGUAGE_ENGLISH = 0, - RETRO_LANGUAGE_JAPANESE = 1, - RETRO_LANGUAGE_FRENCH = 2, - RETRO_LANGUAGE_SPANISH = 3, - RETRO_LANGUAGE_GERMAN = 4, - RETRO_LANGUAGE_ITALIAN = 5, - RETRO_LANGUAGE_DUTCH = 6, - RETRO_LANGUAGE_PORTUGUESE_BRAZIL = 7, - RETRO_LANGUAGE_PORTUGUESE_PORTUGAL = 8, - RETRO_LANGUAGE_RUSSIAN = 9, - RETRO_LANGUAGE_KOREAN = 10, - RETRO_LANGUAGE_CHINESE_TRADITIONAL = 11, - RETRO_LANGUAGE_CHINESE_SIMPLIFIED = 12, - RETRO_LANGUAGE_ESPERANTO = 13, - RETRO_LANGUAGE_POLISH = 14, - RETRO_LANGUAGE_VIETNAMESE = 15, - RETRO_LANGUAGE_ARABIC = 16, - RETRO_LANGUAGE_GREEK = 17, - RETRO_LANGUAGE_TURKISH = 18, - RETRO_LANGUAGE_SLOVAK = 19, - RETRO_LANGUAGE_PERSIAN = 20, - RETRO_LANGUAGE_HEBREW = 21, - RETRO_LANGUAGE_ASTURIAN = 22, - RETRO_LANGUAGE_FINNISH = 23, - RETRO_LANGUAGE_INDONESIAN = 24, - RETRO_LANGUAGE_SWEDISH = 25, - RETRO_LANGUAGE_UKRAINIAN = 26, - RETRO_LANGUAGE_CZECH = 27, - RETRO_LANGUAGE_CATALAN_VALENCIA = 28, - RETRO_LANGUAGE_CATALAN = 29, - RETRO_LANGUAGE_BRITISH_ENGLISH = 30, - RETRO_LANGUAGE_HUNGARIAN = 31, - RETRO_LANGUAGE_BELARUSIAN = 32, - RETRO_LANGUAGE_GALICIAN = 33, - RETRO_LANGUAGE_NORWEGIAN = 34, - RETRO_LANGUAGE_IRISH = 35, - RETRO_LANGUAGE_LAST, - - /** Defined to ensure that sizeof(retro_language) == sizeof(int). Do not use. */ - RETRO_LANGUAGE_DUMMY = INT_MAX -}; - -/** @defgroup RETRO_MEMORY Memory Types - * @{ - */ - -/* Passed to retro_get_memory_data/size(). - * If the memory type doesn't apply to the - * implementation NULL/0 can be returned. - */ -#define RETRO_MEMORY_MASK 0xff - -/* Regular save RAM. This RAM is usually found on a game cartridge, - * backed up by a battery. - * If save game data is too complex for a single memory buffer, - * the SAVE_DIRECTORY (preferably) or SYSTEM_DIRECTORY environment - * callback can be used. */ -#define RETRO_MEMORY_SAVE_RAM 0 - -/* Some games have a built-in clock to keep track of time. - * This memory is usually just a couple of bytes to keep track of time. - */ -#define RETRO_MEMORY_RTC 1 - -/* System ram lets a frontend peek into a game systems main RAM. */ -#define RETRO_MEMORY_SYSTEM_RAM 2 - -/* Video ram lets a frontend peek into a game systems video RAM (VRAM). */ -#define RETRO_MEMORY_VIDEO_RAM 3 - -/** @} */ - -/* Keysyms used for ID in input state callback when polling RETRO_KEYBOARD. */ -enum retro_key -{ - RETROK_UNKNOWN = 0, - RETROK_FIRST = 0, - RETROK_BACKSPACE = 8, - RETROK_TAB = 9, - RETROK_CLEAR = 12, - RETROK_RETURN = 13, - RETROK_PAUSE = 19, - RETROK_ESCAPE = 27, - RETROK_SPACE = 32, - RETROK_EXCLAIM = 33, - RETROK_QUOTEDBL = 34, - RETROK_HASH = 35, - RETROK_DOLLAR = 36, - RETROK_AMPERSAND = 38, - RETROK_QUOTE = 39, - RETROK_LEFTPAREN = 40, - RETROK_RIGHTPAREN = 41, - RETROK_ASTERISK = 42, - RETROK_PLUS = 43, - RETROK_COMMA = 44, - RETROK_MINUS = 45, - RETROK_PERIOD = 46, - RETROK_SLASH = 47, - RETROK_0 = 48, - RETROK_1 = 49, - RETROK_2 = 50, - RETROK_3 = 51, - RETROK_4 = 52, - RETROK_5 = 53, - RETROK_6 = 54, - RETROK_7 = 55, - RETROK_8 = 56, - RETROK_9 = 57, - RETROK_COLON = 58, - RETROK_SEMICOLON = 59, - RETROK_LESS = 60, - RETROK_EQUALS = 61, - RETROK_GREATER = 62, - RETROK_QUESTION = 63, - RETROK_AT = 64, - RETROK_LEFTBRACKET = 91, - RETROK_BACKSLASH = 92, - RETROK_RIGHTBRACKET = 93, - RETROK_CARET = 94, - RETROK_UNDERSCORE = 95, - RETROK_BACKQUOTE = 96, - RETROK_a = 97, - RETROK_b = 98, - RETROK_c = 99, - RETROK_d = 100, - RETROK_e = 101, - RETROK_f = 102, - RETROK_g = 103, - RETROK_h = 104, - RETROK_i = 105, - RETROK_j = 106, - RETROK_k = 107, - RETROK_l = 108, - RETROK_m = 109, - RETROK_n = 110, - RETROK_o = 111, - RETROK_p = 112, - RETROK_q = 113, - RETROK_r = 114, - RETROK_s = 115, - RETROK_t = 116, - RETROK_u = 117, - RETROK_v = 118, - RETROK_w = 119, - RETROK_x = 120, - RETROK_y = 121, - RETROK_z = 122, - RETROK_LEFTBRACE = 123, - RETROK_BAR = 124, - RETROK_RIGHTBRACE = 125, - RETROK_TILDE = 126, - RETROK_DELETE = 127, - - RETROK_KP0 = 256, - RETROK_KP1 = 257, - RETROK_KP2 = 258, - RETROK_KP3 = 259, - RETROK_KP4 = 260, - RETROK_KP5 = 261, - RETROK_KP6 = 262, - RETROK_KP7 = 263, - RETROK_KP8 = 264, - RETROK_KP9 = 265, - RETROK_KP_PERIOD = 266, - RETROK_KP_DIVIDE = 267, - RETROK_KP_MULTIPLY = 268, - RETROK_KP_MINUS = 269, - RETROK_KP_PLUS = 270, - RETROK_KP_ENTER = 271, - RETROK_KP_EQUALS = 272, - - RETROK_UP = 273, - RETROK_DOWN = 274, - RETROK_RIGHT = 275, - RETROK_LEFT = 276, - RETROK_INSERT = 277, - RETROK_HOME = 278, - RETROK_END = 279, - RETROK_PAGEUP = 280, - RETROK_PAGEDOWN = 281, - - RETROK_F1 = 282, - RETROK_F2 = 283, - RETROK_F3 = 284, - RETROK_F4 = 285, - RETROK_F5 = 286, - RETROK_F6 = 287, - RETROK_F7 = 288, - RETROK_F8 = 289, - RETROK_F9 = 290, - RETROK_F10 = 291, - RETROK_F11 = 292, - RETROK_F12 = 293, - RETROK_F13 = 294, - RETROK_F14 = 295, - RETROK_F15 = 296, - - RETROK_NUMLOCK = 300, - RETROK_CAPSLOCK = 301, - RETROK_SCROLLOCK = 302, - RETROK_RSHIFT = 303, - RETROK_LSHIFT = 304, - RETROK_RCTRL = 305, - RETROK_LCTRL = 306, - RETROK_RALT = 307, - RETROK_LALT = 308, - RETROK_RMETA = 309, - RETROK_LMETA = 310, - RETROK_LSUPER = 311, - RETROK_RSUPER = 312, - RETROK_MODE = 313, - RETROK_COMPOSE = 314, - - RETROK_HELP = 315, - RETROK_PRINT = 316, - RETROK_SYSREQ = 317, - RETROK_BREAK = 318, - RETROK_MENU = 319, - RETROK_POWER = 320, - RETROK_EURO = 321, - RETROK_UNDO = 322, - RETROK_OEM_102 = 323, - - RETROK_BROWSER_BACK = 324, - RETROK_BROWSER_FORWARD = 325, - RETROK_BROWSER_REFRESH = 326, - RETROK_BROWSER_STOP = 327, - RETROK_BROWSER_SEARCH = 328, - RETROK_BROWSER_FAVORITES = 329, - RETROK_BROWSER_HOME = 330, - RETROK_VOLUME_MUTE = 331, - RETROK_VOLUME_DOWN = 332, - RETROK_VOLUME_UP = 333, - RETROK_MEDIA_NEXT = 334, - RETROK_MEDIA_PREV = 335, - RETROK_MEDIA_STOP = 336, - RETROK_MEDIA_PLAY_PAUSE = 337, - RETROK_LAUNCH_MAIL = 338, - RETROK_LAUNCH_MEDIA = 339, - RETROK_LAUNCH_APP1 = 340, - RETROK_LAUNCH_APP2 = 341, - - RETROK_LAST, - - RETROK_DUMMY = INT_MAX /* Ensure sizeof(enum) == sizeof(int) */ -}; - -enum retro_mod -{ - RETROKMOD_NONE = 0x0000, - - RETROKMOD_SHIFT = 0x01, - RETROKMOD_CTRL = 0x02, - RETROKMOD_ALT = 0x04, - RETROKMOD_META = 0x08, - - RETROKMOD_NUMLOCK = 0x10, - RETROKMOD_CAPSLOCK = 0x20, - RETROKMOD_SCROLLOCK = 0x40, - - RETROKMOD_DUMMY = INT_MAX /* Ensure sizeof(enum) == sizeof(int) */ -}; - -/** - * @defgroup RETRO_ENVIRONMENT Environment Callbacks - * @{ - */ - -/** - * This bit indicates that the associated environment call is experimental, - * and may be changed or removed in the future. - * Frontends should mask out this bit before handling the environment call. - */ -#define RETRO_ENVIRONMENT_EXPERIMENTAL 0x10000 - -/** Frontend-internal environment callbacks should include this bit. */ -#define RETRO_ENVIRONMENT_PRIVATE 0x20000 - -/* Environment commands. */ -/** - * Requests the frontend to set the screen rotation. - * - * @param[in] data const unsigned*. - * Valid values are 0, 1, 2, and 3. - * These numbers respectively set the screen rotation to 0, 90, 180, and 270 degrees counter-clockwise. - * @returns \c true if the screen rotation was set successfully. - */ -#define RETRO_ENVIRONMENT_SET_ROTATION 1 - -/** - * Queries whether the core should use overscan or not. - * - * @param[out] data bool*. - * Set to \c true if the core should use overscan, - * \c false if it should be cropped away. - * @returns \c true if the environment call is available. - * Does \em not indicate whether overscan should be used. - * @deprecated As of 2019 this callback is considered deprecated in favor of - * using core options to manage overscan in a more nuanced, core-specific way. - */ -#define RETRO_ENVIRONMENT_GET_OVERSCAN 2 - -/** - * Queries whether the frontend supports frame duping, - * in the form of passing \c NULL to the video frame callback. - * - * @param[out] data bool*. - * Set to \c true if the frontend supports frame duping. - * @returns \c true if the environment call is available. - * @see retro_video_refresh_t - */ -#define RETRO_ENVIRONMENT_GET_CAN_DUPE 3 - -/* - * Environ 4, 5 are no longer supported (GET_VARIABLE / SET_VARIABLES), - * and reserved to avoid possible ABI clash. - */ - -/** - * @brief Displays a user-facing message for a short time. - * - * Use this callback to convey important status messages, - * such as errors or the result of long-running operations. - * For trivial messages or logging, use \c RETRO_ENVIRONMENT_GET_LOG_INTERFACE or \c stderr. - * - * \code{.c} - * void set_message_example(void) - * { - * struct retro_message msg; - * msg.frames = 60 * 5; // 5 seconds - * msg.msg = "Hello world!"; - * - * environ_cb(RETRO_ENVIRONMENT_SET_MESSAGE, &msg); - * } - * \endcode - * - * @deprecated Prefer using \c RETRO_ENVIRONMENT_SET_MESSAGE_EXT for new code, - * as it offers more features. - * Only use this environment call for compatibility with older cores or frontends. - * - * @param[in] data const struct retro_message*. - * Details about the message to show to the user. - * Behavior is undefined if NULL. - * @returns \c true if the environment call is available. - * @see retro_message - * @see RETRO_ENVIRONMENT_GET_LOG_INTERFACE - * @see RETRO_ENVIRONMENT_SET_MESSAGE_EXT - * @see RETRO_ENVIRONMENT_SET_MESSAGE - * @see RETRO_ENVIRONMENT_GET_MESSAGE_INTERFACE_VERSION - * @note The frontend must make its own copy of the message and the underlying string. - */ -#define RETRO_ENVIRONMENT_SET_MESSAGE 6 - -/** - * Requests the frontend to shutdown the core. - * Should only be used if the core can exit on its own, - * such as from a menu item in a game - * or an emulated power-off in an emulator. - * - * @param data Ignored. - * @returns \c true if the environment call is available. - */ -#define RETRO_ENVIRONMENT_SHUTDOWN 7 - -/** - * Gives a hint to the frontend of how demanding this core is on the system. - * For example, reporting a level of 2 means that - * this implementation should run decently on frontends - * of level 2 and above. - * - * It can be used by the frontend to potentially warn - * about too demanding implementations. - * - * The levels are "floating". - * - * This function can be called on a per-game basis, - * as a core may have different demands for different games or settings. - * If called, it should be called in retro_load_game(). - * @param[in] data const unsigned*. -*/ -#define RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL 8 - -/** - * Returns the path to the frontend's system directory, - * which can be used to store system-specific configuration - * such as BIOS files or cached data. - * - * @param[out] data const char**. - * Pointer to the \c char* in which the system directory will be saved. - * The string is managed by the frontend and must not be modified or freed by the core. - * May be \c NULL if no system directory is defined, - * in which case the core should find an alternative directory. - * @return \c true if the environment call is available, - * even if the value returned in \c data is NULL. - * @note Historically, some cores would use this folder for save data such as memory cards or SRAM. - * This is now discouraged in favor of \c RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY. - * @see RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY - */ -#define RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY 9 - -/** - * Sets the internal pixel format used by the frontend for rendering. - * The default pixel format is \c RETRO_PIXEL_FORMAT_0RGB1555 for compatibility reasons, - * although it's considered deprecated and shouldn't be used by new code. - * - * @param[in] data const enum retro_pixel_format *. - * Pointer to the pixel format to use. - * @returns \c true if the pixel format was set successfully, - * \c false if it's not supported or this callback is unavailable. - * @note This function should be called inside \c retro_load_game() - * or retro_get_system_av_info(). - * @see retro_pixel_format - */ -#define RETRO_ENVIRONMENT_SET_PIXEL_FORMAT 10 - -/** - * Sets an array of input descriptors for the frontend - * to present to the user for configuring the core's controls. - * - * This function can be called at any time, - * preferably early in the core's life cycle. - * Ideally, no later than \c retro_load_game(). - * - * @param[in] data const struct retro_input_descriptor *. - * An array of input descriptors terminated by one whose - * \c retro_input_descriptor::description field is set to \c NULL. - * Behavior is undefined if \c NULL. - * @return \c true if the environment call is recognized. - * @see retro_input_descriptor - */ -#define RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS 11 - -/** - * Sets a callback function used to notify the core about keyboard events. - * This should only be used for cores that specifically need keyboard input, - * such as for home computer emulators or games with text entry. - * - * @param[in] data const struct retro_keyboard_callback *. - * Pointer to the callback function. - * Behavior is undefined if NULL. - * @return \c true if the environment call is recognized. - * @see retro_keyboard_callback - * @see retro_key - */ -#define RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK 12 - -/** - * Sets an interface that the frontend can use to insert and remove disks - * from the emulated console's disk drive. - * Can be used for optical disks, floppy disks, or any other game storage medium - * that can be swapped at runtime. - * - * This is intended for multi-disk games that expect the player - * to manually swap disks at certain points in the game. - * - * @deprecated Prefer using \c RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE - * over this environment call, as it supports additional features. - * Only use this callback to maintain compatibility - * with older cores or frontends. - * - * @param[in] data const struct retro_disk_control_callback *. - * Pointer to the callback functions to use. - * May be \c NULL, in which case the existing disk callback is deregistered. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * @see retro_disk_control_callback - * @see RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE - */ -#define RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE 13 - -/** - * Requests that a frontend enable a particular hardware rendering API. - * - * If successful, the frontend will create a context (and other related resources) - * that the core can use for rendering. - * The framebuffer will be at least as large as - * the maximum dimensions provided in retro_get_system_av_info. - * - * @param[in, out] data struct retro_hw_render_callback *. - * Pointer to the hardware render callback struct. - * Used to define callbacks for the hardware-rendering life cycle, - * as well as to request a particular rendering API. - * @return \c true if the environment call is recognized - * and the requested rendering API is supported. - * \c false if \c data is \c NULL - * or the frontend can't provide the requested rendering API. - * @see retro_hw_render_callback - * @see retro_video_refresh_t - * @see RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER - * @note Should be called in retro_load_game(). - * @note If HW rendering is used, pass only \c RETRO_HW_FRAME_BUFFER_VALID or - * \c NULL to retro_video_refresh_t. - */ -#define RETRO_ENVIRONMENT_SET_HW_RENDER 14 - -/** - * Retrieves a core option's value from the frontend. - * \c retro_variable::key should be set to an option key - * that was previously set in \c RETRO_ENVIRONMENT_SET_VARIABLES - * (or a similar environment call). - * - * @param[in,out] data struct retro_variable *. - * Pointer to a single \c retro_variable struct. - * See the documentation for \c retro_variable for details - * on which fields are set by the frontend or core. - * May be \c NULL. - * @returns \c true if the environment call is available, - * even if \c data is \c NULL or the key it specifies is not found. - * @note Passing \c NULL in to \c data can be useful to - * test for support of this environment call without looking up any variables. - * @see retro_variable - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * @see RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE - */ -#define RETRO_ENVIRONMENT_GET_VARIABLE 15 - -/** - * Notifies the frontend of the core's available options. - * - * The core may check these options later using \c RETRO_ENVIRONMENT_GET_VARIABLE. - * The frontend may also present these options to the user - * in its own configuration UI. - * - * This should be called the first time as early as possible, - * ideally in \c retro_set_environment. - * The core may later call this function again - * to communicate updated options to the frontend, - * but the number of core options must not change. - * - * Here's an example that sets two options. - * - * @code - * void set_variables_example(void) - * { - * struct retro_variable options[] = { - * { "foo_speedhack", "Speed hack; false|true" }, // false by default - * { "foo_displayscale", "Display scale factor; 1|2|3|4" }, // 1 by default - * { NULL, NULL }, - * }; - * - * environ_cb(RETRO_ENVIRONMENT_SET_VARIABLES, &options); - * } - * @endcode - * - * The possible values will generally be displayed and stored as-is by the frontend. - * - * @deprecated Prefer using \c RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 for new code, - * as it offers more features such as categories and translation. - * Only use this environment call to maintain compatibility - * with older frontends or cores. - * @note Keep the available options (and their possible values) as low as possible; - * it should be feasible to cycle through them without a keyboard. - * @param[in] data const struct retro_variable *. - * Pointer to an array of \c retro_variable structs that define available core options, - * terminated by a { NULL, NULL } element. - * The frontend must maintain its own copy of this array. - * - * @returns \c true if the environment call is available, - * even if \c data is NULL. - * @see retro_variable - * @see RETRO_ENVIRONMENT_GET_VARIABLE - * @see RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - */ -#define RETRO_ENVIRONMENT_SET_VARIABLES 16 - -/** - * Queries whether at least one core option was updated by the frontend - * since the last call to \ref RETRO_ENVIRONMENT_GET_VARIABLE. - * This typically means that the user opened the core options menu and made some changes. - * - * Cores usually call this each frame before the core's main emulation logic. - * Specific options can then be queried with \ref RETRO_ENVIRONMENT_GET_VARIABLE. - * - * @param[out] data bool *. - * Set to \c true if at least one core option was updated - * since the last call to \ref RETRO_ENVIRONMENT_GET_VARIABLE. - * Behavior is undefined if this pointer is \c NULL. - * @returns \c true if the environment call is available. - * @see RETRO_ENVIRONMENT_GET_VARIABLE - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - */ -#define RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE 17 - -/** - * Notifies the frontend that this core can run without loading any content, - * such as when emulating a console that has built-in software. - * When a core is loaded without content, - * \c retro_load_game receives an argument of NULL. - * This should be called within \c retro_set_environment() only. - * - * @param[in] data const bool *. - * Pointer to a single \c bool that indicates whether this frontend can run without content. - * Can point to a value of \c false but this isn't necessary, - * as contentless support is opt-in. - * The behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available. - * @see retro_load_game - */ -#define RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME 18 - -/** - * Retrieves the absolute path from which this core was loaded. - * Useful when loading assets from paths relative to the core, - * as is sometimes the case when using RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME. - * - * @param[out] data const char **. - * Pointer to a string in which the core's path will be saved. - * The string is managed by the frontend and must not be modified or freed by the core. - * May be \c NULL if the core is statically linked to the frontend - * or if the core's path otherwise cannot be determined. - * Behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available. - */ -#define RETRO_ENVIRONMENT_GET_LIBRETRO_PATH 19 - -/* Environment call 20 was an obsolete version of SET_AUDIO_CALLBACK. - * It was not used by any known core at the time, and was removed from the API. - * The number 20 is reserved to prevent ABI clashes. - */ - -/** - * Sets a callback that notifies the core of how much time has passed - * since the last iteration of retro_run. - * If the frontend is not running the core in real time - * (e.g. it's frame-stepping or running in slow motion), - * then the reference value will be provided to the callback instead. - * - * @param[in] data const struct retro_frame_time_callback *. - * Pointer to a single \c retro_frame_time_callback struct. - * Behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available. - * @note Frontends may disable this environment call in certain situations. - * It will return \c false in those cases. - * @see retro_frame_time_callback - */ -#define RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK 21 - -/** - * Registers a set of functions that the frontend can use - * to tell the core it's ready for audio output. - * - * It is intended for games that feature asynchronous audio. - * It should not be used for emulators unless their audio is asynchronous. - * - * - * The callback only notifies about writability; the libretro core still - * has to call the normal audio callbacks - * to write audio. The audio callbacks must be called from within the - * notification callback. - * The amount of audio data to write is up to the core. - * Generally, the audio callback will be called continuously in a loop. - * - * A frontend may disable this callback in certain situations. - * The core must be able to render audio with the "normal" interface. - * - * @param[in] data const struct retro_audio_callback *. - * Pointer to a set of functions that the frontend will call to notify the core - * when it's ready to receive audio data. - * May be \c NULL, in which case the frontend will return - * whether this environment callback is available. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * @warning The provided callbacks can be invoked from any thread, - * so their implementations \em must be thread-safe. - * @note If a core uses this callback, - * it should also use RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK. - * @see retro_audio_callback - * @see retro_audio_sample_t - * @see retro_audio_sample_batch_t - * @see RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK - */ -#define RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK 22 - -/** - * Gets an interface that a core can use to access a controller's rumble motors. - * - * The interface supports two independently-controlled motors, - * one strong and one weak. - * - * Should be called from either \c retro_init() or \c retro_load_game(), - * but not from \c retro_set_environment(). - * - * @param[out] data struct retro_rumble_interface *. - * Pointer to the interface struct. - * Behavior is undefined if \c NULL. - * @returns \c true if the environment call is available, - * even if the current device doesn't support vibration. - * @see retro_rumble_interface - * @defgroup GET_RUMBLE_INTERFACE Rumble Interface - */ -#define RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE 23 - -/** - * Returns the frontend's supported input device types. - * - * The supported device types are returned as a bitmask, - * with each value of \ref RETRO_DEVICE corresponding to a bit. - * - * Should only be called in \c retro_run(). - * - * @code - * #define REQUIRED_DEVICES ((1 << RETRO_DEVICE_JOYPAD) | (1 << RETRO_DEVICE_ANALOG)) - * void get_input_device_capabilities_example(void) - * { - * uint64_t capabilities; - * environ_cb(RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES, &capabilities); - * if ((capabilities & REQUIRED_DEVICES) == REQUIRED_DEVICES) - * printf("Joypad and analog device types are supported"); - * } - * @endcode - * - * @param[out] data uint64_t *. - * Pointer to a bitmask of supported input device types. - * If the frontend supports a particular \c RETRO_DEVICE_* type, - * then the bit (1 << RETRO_DEVICE_*) will be set. - * - * Each bit represents a \c RETRO_DEVICE constant, - * e.g. bit 1 represents \c RETRO_DEVICE_JOYPAD, - * bit 2 represents \c RETRO_DEVICE_MOUSE, and so on. - * - * Bits that do not correspond to known device types will be set to zero - * and are reserved for future use. - * - * Behavior is undefined if \c NULL. - * @returns \c true if the environment call is available. - * @note If the frontend supports multiple input drivers, - * availability of this environment call (and the reported capabilities) - * may depend on the active driver. - * @see RETRO_DEVICE - */ -#define RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES 24 - -/** - * Returns an interface that the core can use to access and configure available sensors, - * such as an accelerometer or gyroscope. - * - * @param[out] data struct retro_sensor_interface *. - * Pointer to the sensor interface that the frontend will populate. - * Behavior is undefined if is \c NULL. - * @returns \c true if the environment call is available, - * even if the device doesn't have any supported sensors. - * @see retro_sensor_interface - * @see retro_sensor_action - * @see RETRO_SENSOR - * @addtogroup RETRO_SENSOR - */ -#define RETRO_ENVIRONMENT_GET_SENSOR_INTERFACE (25 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Gets an interface to the device's video camera. - * - * The frontend delivers new video frames via a user-defined callback - * that runs in the same thread as \c retro_run(). - * Should be called in \c retro_load_game(). - * - * @param[in,out] data struct retro_camera_callback *. - * Pointer to the camera driver interface. - * Some fields in the struct must be filled in by the core, - * others are provided by the frontend. - * Behavior is undefined if \c NULL. - * @returns \c true if this environment call is available, - * even if an actual camera isn't. - * @note This API only supports one video camera at a time. - * If the device provides multiple cameras (e.g. inner/outer cameras on a phone), - * the frontend will choose one to use. - * @see retro_camera_callback - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - */ -#define RETRO_ENVIRONMENT_GET_CAMERA_INTERFACE (26 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Gets an interface that the core can use for cross-platform logging. - * Certain platforms don't have a console or stderr, - * or they have their own preferred logging methods. - * The frontend itself may also display log output. - * - * @attention This should not be used for information that the player must immediately see, - * such as major errors or warnings. - * In most cases, this is best for information that will help you (the developer) - * identify problems when debugging or providing support. - * Unless a core or frontend is intended for advanced users, - * the player might not check (or even know about) their logs. - * - * @param[out] data struct retro_log_callback *. - * Pointer to the callback where the function pointer will be saved. - * Behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available. - * @see retro_log_callback - * @note Cores can fall back to \c stderr if this interface is not available. - */ -#define RETRO_ENVIRONMENT_GET_LOG_INTERFACE 27 - -/** - * Returns an interface that the core can use for profiling code - * and to access performance-related information. - * - * This callback supports performance counters, a high-resolution timer, - * and listing available CPU features (mostly SIMD instructions). - * - * @param[out] data struct retro_perf_callback *. - * Pointer to the callback interface. - * Behavior is undefined if \c NULL. - * @returns \c true if the environment call is available. - * @see retro_perf_callback - */ -#define RETRO_ENVIRONMENT_GET_PERF_INTERFACE 28 - -/** - * Returns an interface that the core can use to retrieve the device's location, - * including its current latitude and longitude. - * - * @param[out] data struct retro_location_callback *. - * Pointer to the callback interface. - * Behavior is undefined if \c NULL. - * @return \c true if the environment call is available, - * even if there's no location information available. - * @see retro_location_callback - */ -#define RETRO_ENVIRONMENT_GET_LOCATION_INTERFACE 29 - -/** - * @deprecated An obsolete alias to \c RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY kept for compatibility. - * @see RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY - **/ -#define RETRO_ENVIRONMENT_GET_CONTENT_DIRECTORY 30 - -/** - * Returns the frontend's "core assets" directory, - * which can be used to store assets that the core needs - * such as art assets or level data. - * - * @param[out] data const char **. - * Pointer to a string in which the core assets directory will be saved. - * This string is managed by the frontend and must not be modified or freed by the core. - * May be \c NULL if no core assets directory is defined, - * in which case the core should find an alternative directory. - * Behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available, - * even if the value returned in \c data is NULL. - */ -#define RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY 30 - -/** - * Returns the frontend's save data directory, if available. - * This directory should be used to store game-specific save data, - * including memory card images. - * - * Although libretro provides an interface for cores to expose SRAM to the frontend, - * not all cores can support it correctly. - * In this case, cores should use this environment callback - * to save their game data to disk manually. - * - * Cores that use this environment callback - * should flush their save data to disk periodically and when unloading. - * - * @param[out] data const char **. - * Pointer to the string in which the save data directory will be saved. - * This string is managed by the frontend and must not be modified or freed by the core. - * May return \c NULL if no save data directory is defined. - * Behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available, - * even if the value returned in \c data is NULL. - * @note Early libretro cores used \c RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY for save data. - * This is still supported for backwards compatibility, - * but new cores should use this environment call instead. - * \c RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY should be used for game-agnostic data - * such as BIOS files or core-specific configuration. - * @note The returned directory may or may not be the same - * as the one used for \c retro_get_memory_data. - * - * @see retro_get_memory_data - * @see RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY - */ -#define RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY 31 - -/** - * Sets new video and audio parameters for the core. - * This can only be called from within retro_run. - * - * This environment call may entail a full reinitialization of the frontend's audio/video drivers, - * hence it should \em only be used if the core needs to make drastic changes - * to audio/video parameters. - * - * This environment call should \em not be used when: - *
    - *
  • Changing the emulated system's internal resolution, - * within the limits defined by the existing values of \c max_width and \c max_height. - * Use \c RETRO_ENVIRONMENT_SET_GEOMETRY instead, - * and adjust \c retro_get_system_av_info to account for - * supported scale factors and screen layouts - * when computing \c max_width and \c max_height. - * Only use this environment call if \c max_width or \c max_height needs to increase. - *
  • Adjusting the screen's aspect ratio, - * e.g. when changing the layout of the screen(s). - * Use \c RETRO_ENVIRONMENT_SET_GEOMETRY or \c RETRO_ENVIRONMENT_SET_ROTATION instead. - *
- * - * The frontend will reinitialize its audio and video drivers within this callback; - * after that happens, audio and video callbacks will target the newly-initialized driver, - * even within the same \c retro_run call. - * - * This callback makes it possible to support configurable resolutions - * while avoiding the need to compute the "worst case" values of \c max_width and \c max_height. - * - * @param[in] data const struct retro_system_av_info *. - * Pointer to the new video and audio parameters that the frontend should adopt. - * @returns \c true if the environment call is available - * and the new av_info struct was accepted. - * \c false if the environment call is unavailable or \c data is NULL. - * @see retro_system_av_info - * @see RETRO_ENVIRONMENT_SET_GEOMETRY - */ -#define RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO 32 - -/** - * Provides an interface that a frontend can use - * to get function pointers from the core. - * - * This allows cores to define their own extensions to the libretro API, - * or to expose implementations of a frontend's libretro extensions. - * - * @param[in] data const struct retro_get_proc_address_interface *. - * Pointer to the interface that the frontend can use to get function pointers from the core. - * The frontend must maintain its own copy of this interface. - * @returns \c true if the environment call is available - * and the returned interface was accepted. - * @note The provided interface may be called at any time, - * even before this environment call returns. - * @note Extensions should be prefixed with the name of the frontend or core that defines them. - * For example, a frontend named "foo" that defines a debugging extension - * should expect the core to define functions prefixed with "foo_debug_". - * @warning If a core wants to use this environment call, - * it \em must do so from within \c retro_set_environment(). - * @see retro_get_proc_address_interface - */ -#define RETRO_ENVIRONMENT_SET_PROC_ADDRESS_CALLBACK 33 - -/** - * Registers a core's ability to handle "subsystems", - * which are secondary platforms that augment a core's primary emulated hardware. - * - * A core doesn't need to emulate a secondary platform - * in order to use it as a subsystem; - * as long as it can load a secondary file for some practical use, - * then this environment call is most likely suitable. - * - * Possible use cases of a subsystem include: - * - * \li Installing software onto an emulated console's internal storage, - * such as the Nintendo DSi. - * \li Emulating accessories that are used to support another console's games, - * such as the Super Game Boy or the N64 Transfer Pak. - * \li Inserting a secondary ROM into a console - * that features multiple cartridge ports, - * such as the Nintendo DS's Slot-2. - * \li Loading a save data file created and used by another core. - * - * Cores should \em not use subsystems for: - * - * \li Emulators that support multiple "primary" platforms, - * such as a Game Boy/Game Boy Advance core - * or a Sega Genesis/Sega CD/32X core. - * Use \c retro_system_content_info_override, \c retro_system_info, - * and/or runtime detection instead. - * \li Selecting different memory card images. - * Use dynamically-populated core options instead. - * \li Different variants of a single console, - * such the Game Boy vs. the Game Boy Color. - * Use core options or runtime detection instead. - * \li Games that span multiple disks. - * Use \c RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE - * and m3u-formatted playlists instead. - * \li Console system files (BIOS, firmware, etc.). - * Use \c RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY - * and a common naming convention instead. - * - * When the frontend loads a game via a subsystem, - * it must call \c retro_load_game_special() instead of \c retro_load_game(). - * - * @param[in] data const struct retro_subsystem_info *. - * Pointer to an array of subsystem descriptors, - * terminated by a zeroed-out \c retro_subsystem_info struct. - * The frontend should maintain its own copy - * of this array and the strings within it. - * Behavior is undefined if \c NULL. - * @returns \c true if this environment call is available. - * @note This environment call \em must be called from within \c retro_set_environment(), - * as frontends may need the registered information before loading a game. - * @see retro_subsystem_info - * @see retro_load_game_special - */ -#define RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO 34 - -/** - * Declares one or more types of controllers supported by this core. - * The frontend may then allow the player to select one of these controllers in its menu. - * - * Many consoles had controllers that came in different versions, - * were extensible with peripherals, - * or could be held in multiple ways; - * this environment call can be used to represent these differences - * and adjust the core's behavior to match. - * - * Possible use cases include: - * - * \li Supporting different classes of a single controller that supported their own sets of games. - * For example, the SNES had two different lightguns (the Super Scope and the Justifier) - * whose games were incompatible with each other. - * \li Representing a platform's alternative controllers. - * For example, several platforms had music/rhythm games that included controllers - * shaped like musical instruments. - * \li Representing variants of a standard controller with additional inputs. - * For example, numerous consoles in the 90's introduced 6-button controllers for fighting games, - * steering wheels for racing games, - * or analog sticks for 3D platformers. - * \li Representing add-ons for consoles or standard controllers. - * For example, the 3DS had a Circle Pad Pro attachment that added a second analog stick. - * \li Selecting different configurations for a single controller. - * For example, the Wii Remote could be held sideways like a traditional game pad - * or in one hand like a wand. - * \li Providing multiple ways to simulate the experience of using a particular controller. - * For example, the Game Boy Advance featured several games - * with motion or light sensors in their cartridges; - * a core could provide controller configurations - * that allow emulating the sensors with either analog axes - * or with their host device's sensors. - * - * Should be called in retro_load_game. - * The frontend must maintain its own copy of the provided array, - * including all strings and subobjects. - * A core may exclude certain controllers for known incompatible games. - * - * When the frontend changes the active device for a particular port, - * it must call \c retro_set_controller_port_device() with that port's index - * and one of the IDs defined in its retro_controller_info::types field. - * - * Input ports are generally associated with different players - * (and the frontend's UI may reflect this with "Player 1" labels), - * but this is not required. - * Some games use multiple controllers for a single player, - * or some cores may use port indexes to represent an emulated console's - * alternative input peripherals. - * - * @param[in] data const struct retro_controller_info *. - * Pointer to an array of controller types defined by this core, - * terminated by a zeroed-out \c retro_controller_info. - * Each element of this array represents a controller port on the emulated device. - * Behavior is undefined if \c NULL. - * @returns \c true if this environment call is available. - * @see retro_controller_info - * @see retro_set_controller_port_device - * @see RETRO_DEVICE_SUBCLASS - */ -#define RETRO_ENVIRONMENT_SET_CONTROLLER_INFO 35 - -/** - * Notifies the frontend of the address spaces used by the core's emulated hardware, - * and of the memory maps within these spaces. - * This can be used by the frontend to provide cheats, achievements, or debugging capabilities. - * Should only be used by emulators, as it makes little sense for game engines. - * - * @note Cores should also expose these address spaces - * through retro_get_memory_data and \c retro_get_memory_size if applicable; - * this environment call is not intended to replace those two functions, - * as the emulated hardware may feature memory regions outside of its own address space - * that are nevertheless useful for the frontend. - * - * @param[in] data const struct retro_memory_map *. - * Pointer to a single memory-map listing. - * The frontend must maintain its own copy of this object and its contents, - * including strings and nested objects. - * Behavior is undefined if \c NULL. - * @returns \c true if this environment call is available. - * @see retro_memory_map - * @see retro_get_memory_data - * @see retro_memory_descriptor - */ -#define RETRO_ENVIRONMENT_SET_MEMORY_MAPS (36 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Resizes the viewport without reinitializing the video driver. - * - * Similar to \c RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO, - * but any changes that would require video reinitialization will not be performed. - * Can only be called from within \c retro_run(). - * - * This environment call allows a core to revise the size of the viewport at will, - * which can be useful for emulated platforms that support dynamic resolution changes - * or for cores that support multiple screen layouts. - * - * A frontend must guarantee that this environment call completes in - * constant time. - * - * @param[in] data const struct retro_game_geometry *. - * Pointer to the new video parameters that the frontend should adopt. - * \c retro_game_geometry::max_width and \c retro_game_geometry::max_height - * will be ignored. - * Behavior is undefined if \c data is NULL. - * @return \c true if the environment call is available. - * @see RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO - */ -#define RETRO_ENVIRONMENT_SET_GEOMETRY 37 - -/** - * Returns the name of the user, if possible. - * This callback is suitable for cores that offer personalization, - * such as online facilities or user profiles on the emulated system. - * @param[out] data const char **. - * Pointer to the user name string. - * May be \c NULL, in which case the core should use a default name. - * The returned pointer is owned by the frontend and must not be modified or freed by the core. - * Behavior is undefined if \c NULL. - * @returns \c true if the environment call is available, - * even if the frontend couldn't provide a name. - */ -#define RETRO_ENVIRONMENT_GET_USERNAME 38 - -/** - * Returns the frontend's configured language. - * It can be used to localize the core's UI, - * or to customize the emulated firmware if applicable. - * - * @param[out] data retro_language *. - * Pointer to the language identifier. - * Behavior is undefined if \c NULL. - * @returns \c true if the environment call is available. - * @note The returned language may not be the same as the operating system's language. - * Cores should fall back to the operating system's language (or to English) - * if the environment call is unavailable or the returned language is unsupported. - * @see retro_language - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL - */ -#define RETRO_ENVIRONMENT_GET_LANGUAGE 39 - -/** - * Returns a frontend-managed framebuffer - * that the core may render directly into - * - * This environment call is provided as an optimization - * for cores that use software rendering - * (i.e. that don't use \refitem RETRO_ENVIRONMENT_SET_HW_RENDER "a graphics hardware API"); - * specifically, the intended use case is to allow a core - * to render directly into frontend-managed video memory, - * avoiding the bandwidth use that copying a whole framebuffer from core to video memory entails. - * - * Must be called every frame if used, - * as this may return a different framebuffer each frame - * (e.g. for swap chains). - * However, a core may render to a different buffer even if this call succeeds. - * - * @param[in,out] data struct retro_framebuffer *. - * Pointer to a frontend's frame buffer and accompanying data. - * Some fields are set by the core, others are set by the frontend. - * Only guaranteed to be valid for the duration of the current \c retro_run call, - * and must not be used afterwards. - * Behavior is undefined if \c NULL. - * @return \c true if the environment call was recognized - * and the framebuffer was successfully returned. - * @see retro_framebuffer - */ -#define RETRO_ENVIRONMENT_GET_CURRENT_SOFTWARE_FRAMEBUFFER (40 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns an interface for accessing the data of specific rendering APIs. - * Not all hardware rendering APIs support or need this. - * - * The details of these interfaces are specific to each rendering API. - * - * @note \c retro_hw_render_callback::context_reset must be called by the frontend - * before this environment call can be used. - * Additionally, the contents of the returned interface are invalidated - * after \c retro_hw_render_callback::context_destroyed has been called. - * @param[out] data const struct retro_hw_render_interface **. - * The render interface for the currently-enabled hardware rendering API, if any. - * The frontend will store a pointer to the interface at the address provided here. - * The returned interface is owned by the frontend and must not be modified or freed by the core. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is available, - * the active graphics API has a libretro rendering interface, - * and the frontend is able to return said interface. - * \c false otherwise. - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - * @see retro_hw_render_interface - * @note Since not every libretro-supported hardware rendering API - * has a \c retro_hw_render_interface implementation, - * a result of \c false is not necessarily an error. - */ -#define RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE (41 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Explicitly notifies the frontend of whether this core supports achievements. - * The core must expose its emulated address space via - * \c retro_get_memory_data or \c RETRO_ENVIRONMENT_GET_MEMORY_MAPS. - * Must be called before the first call to retro_run. - * - * If \ref retro_get_memory_data returns a valid address - * but this environment call is not used, - * the frontend (at its discretion) may or may not opt in the core to its achievements support. - * whether this core is opted in to the frontend's achievement support - * is left to the frontend's discretion. - * @param[in] data const bool *. - * Pointer to a single \c bool that indicates whether this core supports achievements. - * Behavior is undefined if \c data is NULL. - * @returns \c true if the environment call is available. - * @see RETRO_ENVIRONMENT_SET_MEMORY_MAPS - * @see retro_get_memory_data - */ -#define RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS (42 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Defines an interface that the frontend can use - * to ask the core for the parameters it needs for a hardware rendering context. - * The exact semantics depend on \ref RETRO_ENVIRONMENT_SET_HW_RENDER "the active rendering API". - * Will be used some time after \c RETRO_ENVIRONMENT_SET_HW_RENDER is called, - * but before \c retro_hw_render_callback::context_reset is called. - * - * @param[in] data const struct retro_hw_render_context_negotiation_interface *. - * Pointer to the context negotiation interface. - * Will be populated by the frontend. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is supported, - * even if the current graphics API doesn't use - * a context negotiation interface (in which case the argument is ignored). - * @see retro_hw_render_context_negotiation_interface - * @see RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - */ -#define RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE (43 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Notifies the frontend of any quirks associated with serialization. - * - * Should be set in either \c retro_init or \c retro_load_game, but not both. - * @param[in, out] data uint64_t *. - * Pointer to the core's serialization quirks. - * The frontend will set the flags of the quirks it supports - * and clear the flags of those it doesn't. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is supported. - * @see retro_serialize - * @see retro_unserialize - * @see RETRO_SERIALIZATION_QUIRK - */ -#define RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS 44 - -/** - * The frontend will try to use a "shared" context when setting up a hardware context. - * Mostly applicable to OpenGL. - * - * In order for this to have any effect, - * the core must call \c RETRO_ENVIRONMENT_SET_HW_RENDER at some point - * if it hasn't already. - * - * @param data Ignored. - * @returns \c true if the environment call is available - * and the frontend supports shared hardware contexts. - */ -#define RETRO_ENVIRONMENT_SET_HW_SHARED_CONTEXT (44 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns an interface that the core can use to access the file system. - * Should be called as early as possible. - * - * @param[in,out] data struct retro_vfs_interface_info *. - * Information about the desired VFS interface, - * as well as the interface itself. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is available - * and the frontend can provide a VFS interface of the requested version or newer. - * @see retro_vfs_interface_info - * @see file_path - * @see retro_dirent - * @see file_stream - */ -#define RETRO_ENVIRONMENT_GET_VFS_INTERFACE (45 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns an interface that the core can use - * to set the state of any accessible device LEDs. - * - * @param[out] data struct retro_led_interface *. - * Pointer to the LED interface that the frontend will populate. - * May be \c NULL, in which case the frontend will only return - * whether this environment callback is available. - * @returns \c true if the environment call is available, - * even if \c data is \c NULL - * or no LEDs are accessible. - * @see retro_led_interface - */ -#define RETRO_ENVIRONMENT_GET_LED_INTERFACE (46 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns hints about certain steps that the core may skip for this frame. - * - * A frontend may not need a core to generate audio or video in certain situations; - * this environment call sets a bitmask that indicates - * which steps the core may skip for this frame. - * - * This can be used to increase performance for some frontend features. - * - * @note Emulation accuracy should not be compromised; - * for example, if a core emulates a platform that supports display capture - * (i.e. looking at its own VRAM), then it should perform its rendering as normal - * unless it can prove that the emulated game is not using display capture. - * - * @param[out] data retro_av_enable_flags *. - * Pointer to the bitmask of steps that the frontend will skip. - * Other bits are set to zero and are reserved for future use. - * If \c NULL, the frontend will only return whether this environment callback is available. - * @returns \c true if the environment call is available, - * regardless of the value output to \c data. - * If \c false, the core should assume that the frontend will not skip any steps. - * @see retro_av_enable_flags - */ -#define RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE (47 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Gets an interface that the core can use for raw MIDI I/O. - * - * @param[out] data struct retro_midi_interface *. - * Pointer to the MIDI interface. - * May be \c NULL. - * @return \c true if the environment call is available, - * even if \c data is \c NULL. - * @see retro_midi_interface - */ -#define RETRO_ENVIRONMENT_GET_MIDI_INTERFACE (48 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Asks the frontend if it's currently in fast-forward mode. - * @param[out] data bool *. - * Set to \c true if the frontend is currently fast-forwarding its main loop. - * Behavior is undefined if \c data is NULL. - * @returns \c true if this environment call is available, - * regardless of the value returned in \c data. - * - * @see RETRO_ENVIRONMENT_SET_FASTFORWARDING_OVERRIDE - */ -#define RETRO_ENVIRONMENT_GET_FASTFORWARDING (49 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns the refresh rate the frontend is targeting, in Hz. - * The intended use case is for the core to use the result to select an ideal refresh rate. - * - * @param[out] data float *. - * Pointer to the \c float in which the frontend will store its target refresh rate. - * Behavior is undefined if \c data is NULL. - * @return \c true if this environment call is available, - * regardless of the value returned in \c data. -*/ -#define RETRO_ENVIRONMENT_GET_TARGET_REFRESH_RATE (50 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns whether the frontend can return the state of all buttons at once as a bitmask, - * rather than requiring a series of individual calls to \c retro_input_state_t. - * - * If this callback returns \c true, - * you can get the state of all buttons by passing \c RETRO_DEVICE_ID_JOYPAD_MASK - * as the \c id parameter to \c retro_input_state_t. - * Bit #N represents the RETRO_DEVICE_ID_JOYPAD constant of value N, - * e.g. (1 << RETRO_DEVICE_ID_JOYPAD_A) represents the A button. - * - * @param data Ignored. - * @returns \c true if the frontend can report the complete digital joypad state as a bitmask. - * @see retro_input_state_t - * @see RETRO_DEVICE_JOYPAD - * @see RETRO_DEVICE_ID_JOYPAD_MASK - */ -#define RETRO_ENVIRONMENT_GET_INPUT_BITMASKS (51 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns the version of the core options API supported by the frontend. - * - * Over the years, libretro has used several interfaces - * for allowing cores to define customizable options. - * \ref SET_CORE_OPTIONS_V2 "Version 2 of the interface" - * is currently preferred due to its extra features, - * but cores and frontends should strive to support - * versions \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS "1" - * and \ref RETRO_ENVIRONMENT_SET_VARIABLES "0" as well. - * This environment call provides the information that cores need for that purpose. - * - * If this environment call returns \c false, - * then the core should assume version 0 of the core options API. - * - * @param[out] data unsigned *. - * Pointer to the integer that will store the frontend's - * supported core options API version. - * Behavior is undefined if \c NULL. - * @returns \c true if the environment call is available, - * \c false otherwise. - * @see RETRO_ENVIRONMENT_SET_VARIABLES - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - */ -#define RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION 52 - -/** - * @copybrief RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * - * @deprecated This environment call has been superseded - * by RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2, - * which supports categorizing options into groups. - * This environment call should only be used to maintain compatibility - * with older cores and frontends. - * - * This environment call is intended to replace \c RETRO_ENVIRONMENT_SET_VARIABLES, - * and should only be called if \c RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION - * returns an API version of at least 1. - * - * This should be called the first time as early as possible, - * ideally in \c retro_set_environment (but \c retro_load_game is acceptable). - * It may then be called again later to update - * the core's options and their associated values, - * as long as the number of options doesn't change - * from the number given in the first call. - * - * The core can retrieve option values at any time with \c RETRO_ENVIRONMENT_GET_VARIABLE. - * If a saved value for a core option doesn't match the option definition's values, - * the frontend may treat it as incorrect and revert to the default. - * - * Core options and their values are usually defined in a large static array, - * but they may be generated at runtime based on the loaded game or system state. - * Here are some use cases for that: - * - * @li Selecting a particular file from one of the - * \ref RETRO_ENVIRONMENT_GET_ASSET_DIRECTORY "frontend's" - * \ref RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY "content" - * \ref RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY "directories", - * such as a memory card image or figurine data file. - * @li Excluding options that are not relevant to the current game, - * for cores that define a large number of possible options. - * @li Choosing a default value at runtime for a specific game, - * such as a BIOS file whose region matches that of the loaded content. - * - * @note A guiding principle of libretro's API design is that - * all common interactions (gameplay, menu navigation, etc.) - * should be possible without a keyboard. - * This implies that cores should keep the number of options and values - * as low as possible. - * - * Example entry: - * @code - * { - * "foo_option", - * "Speed hack coprocessor X", - * "Provides increased performance at the expense of reduced accuracy", - * { - * { "false", NULL }, - * { "true", NULL }, - * { "unstable", "Turbo (Unstable)" }, - * { NULL, NULL }, - * }, - * "false" - * } - * @endcode - * - * @param[in] data const struct retro_core_option_definition *. - * Pointer to one or more core option definitions, - * terminated by a \ref retro_core_option_definition whose values are all zero. - * May be \c NULL, in which case the frontend will remove all existing core options. - * The frontend must maintain its own copy of this object, - * including all strings and subobjects. - * @return \c true if this environment call is available. - * - * @see retro_core_option_definition - * @see RETRO_ENVIRONMENT_GET_VARIABLE - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL - */ -#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS 53 - -/** - * A variant of \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS - * that supports internationalization. - * - * @deprecated This environment call has been superseded - * by \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL, - * which supports categorizing options into groups - * (plus translating the groups themselves). - * Only use this environment call to maintain compatibility - * with older cores and frontends. - * - * This should be called instead of \c RETRO_ENVIRONMENT_SET_CORE_OPTIONS - * if the core provides translations for its options. - * General use is largely the same, - * but see \ref retro_core_options_intl for some important details. - * - * @param[in] data const struct retro_core_options_intl *. - * Pointer to a core's option values and their translations. - * @see retro_core_options_intl - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS - */ -#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL 54 - -/** - * Notifies the frontend that it should show or hide the named core option. - * - * Some core options aren't relevant in all scenarios, - * such as a submenu for hardware rendering flags - * when the software renderer is configured. - * This environment call asks the frontend to stop (or start) - * showing the named core option to the player. - * This is only a hint, not a requirement; - * the frontend may ignore this environment call. - * By default, all core options are visible. - * - * @note This environment call must \em only affect a core option's visibility, - * not its functionality or availability. - * \ref RETRO_ENVIRONMENT_GET_VARIABLE "Getting an invisible core option" - * must behave normally. - * - * @param[in] data const struct retro_core_option_display *. - * Pointer to a descriptor for the option that the frontend should show or hide. - * May be \c NULL, in which case the frontend will only return - * whether this environment callback is available. - * @return \c true if this environment call is available, - * even if \c data is \c NULL - * or the specified option doesn't exist. - * @see retro_core_option_display - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK - */ -#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY 55 - -/** - * Returns the frontend's preferred hardware rendering API. - * Cores should use this information to decide which API to use with \c RETRO_ENVIRONMENT_SET_HW_RENDER. - * @param[out] data retro_hw_context_type *. - * Pointer to the hardware context type. - * Behavior is undefined if \c data is NULL. - * This value will be set even if the environment call returns false, - * unless the frontend doesn't implement it. - * @returns \c true if the environment call is available - * and the frontend is able to use a hardware rendering API besides the one returned. - * If \c false is returned and the core cannot use the preferred rendering API, - * then it should exit or fall back to software rendering. - * @note The returned value does not indicate which API is currently in use. - * For example, the frontend may return \c RETRO_HW_CONTEXT_OPENGL - * while a Direct3D context from a previous session is active; - * this would signal that the frontend's current preference is for OpenGL, - * possibly because the user changed their frontend's video driver while a game is running. - * @see retro_hw_context_type - * @see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - */ -#define RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER 56 - -/** - * Returns the minimum version of the disk control interface supported by the frontend. - * - * If this environment call returns \c false or \c data is 0 or greater, - * then cores may use disk control callbacks - * with \c RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE. - * If the reported version is 1 or greater, - * then cores should use \c RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE instead. - * - * @param[out] data unsigned *. - * Pointer to the unsigned integer that the frontend's supported disk control interface version will be stored in. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is available. - * @see RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE - */ -#define RETRO_ENVIRONMENT_GET_DISK_CONTROL_INTERFACE_VERSION 57 - -/** - * @copybrief RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE - * - * This is intended for multi-disk games that expect the player - * to manually swap disks at certain points in the game. - * This version of the disk control interface provides - * more information about disk images. - * Should be called in \c retro_init. - * - * @param[in] data const struct retro_disk_control_ext_callback *. - * Pointer to the callback functions to use. - * May be \c NULL, in which case the existing disk callback is deregistered. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * @see retro_disk_control_ext_callback - */ -#define RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE 58 - -/** - * Returns the version of the message interface supported by the frontend. - * - * A version of 0 indicates that the frontend - * only supports the legacy \c RETRO_ENVIRONMENT_SET_MESSAGE interface. - * A version of 1 indicates that the frontend - * supports \c RETRO_ENVIRONMENT_SET_MESSAGE_EXT as well. - * If this environment call returns \c false, - * the core should behave as if it had returned 0. - * - * @param[out] data unsigned *. - * Pointer to the result returned by the frontend. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is available. - * @see RETRO_ENVIRONMENT_SET_MESSAGE_EXT - * @see RETRO_ENVIRONMENT_SET_MESSAGE - */ -#define RETRO_ENVIRONMENT_GET_MESSAGE_INTERFACE_VERSION 59 - -/** - * Displays a user-facing message for a short time. - * - * Use this callback to convey important status messages, - * such as errors or the result of long-running operations. - * For trivial messages or logging, use \c RETRO_ENVIRONMENT_GET_LOG_INTERFACE or \c stderr. - * - * This environment call supersedes \c RETRO_ENVIRONMENT_SET_MESSAGE, - * as it provides many more ways to customize - * how a message is presented to the player. - * However, a frontend that supports this environment call - * must still support \c RETRO_ENVIRONMENT_SET_MESSAGE. - * - * @param[in] data const struct retro_message_ext *. - * Pointer to the message to display to the player. - * Behavior is undefined if \c NULL. - * @returns \c true if this environment call is available. - * @see retro_message_ext - * @see RETRO_ENVIRONMENT_GET_MESSAGE_INTERFACE_VERSION - */ -#define RETRO_ENVIRONMENT_SET_MESSAGE_EXT 60 - -/** - * Returns the number of active input devices currently provided by the frontend. - * - * This may change between frames, - * but will remain constant for the duration of each frame. - * - * If this callback returns \c true, - * a core need not poll any input device - * with an index greater than or equal to the returned value. - * - * If callback returns \c false, - * the number of active input devices is unknown. - * In this case, all input devices should be considered active. - * - * @param[out] data unsigned *. - * Pointer to the result returned by the frontend. - * Behavior is undefined if \c NULL. - * @return \c true if this environment call is available. - */ -#define RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS 61 - -/** - * Registers a callback that the frontend can use to notify the core - * of the audio output buffer's occupancy. - * Can be used by a core to attempt frame-skipping to avoid buffer under-runs - * (i.e. "crackling" sounds). - * - * @param[in] data const struct retro_audio_buffer_status_callback *. - * Pointer to the the buffer status callback, - * or \c NULL to unregister any existing callback. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * - * @see retro_audio_buffer_status_callback - */ -#define RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK 62 - -/** - * Requests a minimum frontend audio latency in milliseconds. - * - * This is a hint; the frontend may assign a different audio latency - * to accommodate hardware limits, - * although it should try to honor requests up to 512ms. - * - * This callback has no effect if the requested latency - * is less than the frontend's current audio latency. - * If value is zero or \c data is \c NULL, - * the frontend should set its default audio latency. - * - * May be used by a core to increase audio latency and - * reduce the risk of buffer under-runs (crackling) - * when performing 'intensive' operations. - * - * A core using RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK - * to implement audio-buffer-based frame skipping can get good results - * by setting the audio latency to a high (typically 6x or 8x) - * integer multiple of the expected frame time. - * - * This can only be called from within \c retro_run(). - * - * @warning This environment call may require the frontend to reinitialize its audio system. - * This environment call should be used sparingly. - * If the driver is reinitialized, - * \ref retro_audio_callback_t "all audio callbacks" will be updated - * to target the newly-initialized driver. - * - * @param[in] data const unsigned *. - * Minimum audio latency, in milliseconds. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * - * @see RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK - */ -#define RETRO_ENVIRONMENT_SET_MINIMUM_AUDIO_LATENCY 63 - -/** - * Allows the core to tell the frontend when it should enable fast-forwarding, - * rather than relying solely on the frontend and user interaction. - * - * Possible use cases include: - * - * \li Temporarily disabling a core's fastforward support - * while investigating a related bug. - * \li Disabling fastforward during netplay sessions, - * or when using an emulated console's network features. - * \li Automatically speeding up the game when in a loading screen - * that cannot be shortened with high-level emulation. - * - * @param[in] data const struct retro_fastforwarding_override *. - * Pointer to the parameters that decide when and how - * the frontend is allowed to enable fast-forward mode. - * May be \c NULL, in which case the frontend will return \c true - * without updating the fastforward state, - * which can be used to detect support for this environment call. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * - * @see retro_fastforwarding_override - * @see RETRO_ENVIRONMENT_GET_FASTFORWARDING - */ -#define RETRO_ENVIRONMENT_SET_FASTFORWARDING_OVERRIDE 64 - -#define RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE 65 - /* const struct retro_system_content_info_override * -- - * Allows an implementation to override 'global' content - * info parameters reported by retro_get_system_info(). - * Overrides also affect subsystem content info parameters - * set via RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO. - * This function must be called inside retro_set_environment(). - * If callback returns false, content info overrides - * are unsupported by the frontend, and will be ignored. - * If callback returns true, extended game info may be - * retrieved by calling RETRO_ENVIRONMENT_GET_GAME_INFO_EXT - * in retro_load_game() or retro_load_game_special(). - * - * 'data' points to an array of retro_system_content_info_override - * structs terminated by a { NULL, false, false } element. - * If 'data' is NULL, no changes will be made to the frontend; - * a core may therefore pass NULL in order to test whether - * the RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE and - * RETRO_ENVIRONMENT_GET_GAME_INFO_EXT callbacks are supported - * by the frontend. - * - * For struct member descriptions, see the definition of - * struct retro_system_content_info_override. - * - * Example: - * - * - struct retro_system_info: - * { - * "My Core", // library_name - * "v1.0", // library_version - * "m3u|md|cue|iso|chd|sms|gg|sg", // valid_extensions - * true, // need_fullpath - * false // block_extract - * } - * - * - Array of struct retro_system_content_info_override: - * { - * { - * "md|sms|gg", // extensions - * false, // need_fullpath - * true // persistent_data - * }, - * { - * "sg", // extensions - * false, // need_fullpath - * false // persistent_data - * }, - * { NULL, false, false } - * } - * - * Result: - * - Files of type m3u, cue, iso, chd will not be - * loaded by the frontend. Frontend will pass a - * valid path to the core, and core will handle - * loading internally - * - Files of type md, sms, gg will be loaded by - * the frontend. A valid memory buffer will be - * passed to the core. This memory buffer will - * remain valid until retro_deinit() returns - * - Files of type sg will be loaded by the frontend. - * A valid memory buffer will be passed to the core. - * This memory buffer will remain valid until - * retro_load_game() (or retro_load_game_special()) - * returns - * - * NOTE: If an extension is listed multiple times in - * an array of retro_system_content_info_override - * structs, only the first instance will be registered - */ - -#define RETRO_ENVIRONMENT_GET_GAME_INFO_EXT 66 - /* const struct retro_game_info_ext ** -- - * Allows an implementation to fetch extended game - * information, providing additional content path - * and memory buffer status details. - * This function may only be called inside - * retro_load_game() or retro_load_game_special(). - * If callback returns false, extended game information - * is unsupported by the frontend. In this case, only - * regular retro_game_info will be available. - * RETRO_ENVIRONMENT_GET_GAME_INFO_EXT is guaranteed - * to return true if RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE - * returns true. - * - * 'data' points to an array of retro_game_info_ext structs. - * - * For struct member descriptions, see the definition of - * struct retro_game_info_ext. - * - * - If function is called inside retro_load_game(), - * the retro_game_info_ext array is guaranteed to - * have a size of 1 - i.e. the returned pointer may - * be used to access directly the members of the - * first retro_game_info_ext struct, for example: - * - * struct retro_game_info_ext *game_info_ext; - * if (environ_cb(RETRO_ENVIRONMENT_GET_GAME_INFO_EXT, &game_info_ext)) - * printf("Content Directory: %s\n", game_info_ext->dir); - * - * - If the function is called inside retro_load_game_special(), - * the retro_game_info_ext array is guaranteed to have a - * size equal to the num_info argument passed to - * retro_load_game_special() - */ - -/** - * Defines a set of core options that can be shown and configured by the frontend, - * so that the player may customize their gameplay experience to their liking. - * - * @note This environment call is intended to replace - * \c RETRO_ENVIRONMENT_SET_VARIABLES and \c RETRO_ENVIRONMENT_SET_CORE_OPTIONS, - * and should only be called if \c RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION - * returns an API version of at least 2. - * - * This should be called the first time as early as possible, - * ideally in \c retro_set_environment (but \c retro_load_game is acceptable). - * It may then be called again later to update - * the core's options and their associated values, - * as long as the number of options doesn't change - * from the number given in the first call. - * - * The core can retrieve option values at any time with \c RETRO_ENVIRONMENT_GET_VARIABLE. - * If a saved value for a core option doesn't match the option definition's values, - * the frontend may treat it as incorrect and revert to the default. - * - * Core options and their values are usually defined in a large static array, - * but they may be generated at runtime based on the loaded game or system state. - * Here are some use cases for that: - * - * @li Selecting a particular file from one of the - * \ref RETRO_ENVIRONMENT_GET_ASSET_DIRECTORY "frontend's" - * \ref RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY "content" - * \ref RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY "directories", - * such as a memory card image or figurine data file. - * @li Excluding options that are not relevant to the current game, - * for cores that define a large number of possible options. - * @li Choosing a default value at runtime for a specific game, - * such as a BIOS file whose region matches that of the loaded content. - * - * @note A guiding principle of libretro's API design is that - * all common interactions (gameplay, menu navigation, etc.) - * should be possible without a keyboard. - * This implies that cores should keep the number of options and values - * as low as possible. - * - * @param[in] data const struct retro_core_options_v2 *. - * Pointer to a core's options and their associated categories. - * May be \c NULL, in which case the frontend will remove all existing core options. - * The frontend must maintain its own copy of this object, - * including all strings and subobjects. - * @return \c true if this environment call is available - * and the frontend supports categories. - * Note that this environment call is guaranteed to successfully register - * the provided core options, - * so the return value does not indicate success or failure. - * - * @see retro_core_options_v2 - * @see retro_core_option_v2_category - * @see retro_core_option_v2_definition - * @see RETRO_ENVIRONMENT_GET_VARIABLE - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL - */ -#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 67 - -/** - * A variant of \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * that supports internationalization. - * - * This should be called instead of \c RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * if the core provides translations for its options. - * General use is largely the same, - * but see \ref retro_core_options_v2_intl for some important details. - * - * @param[in] data const struct retro_core_options_v2_intl *. - * Pointer to a core's option values and categories, - * plus a translation for each option and category. - * @see retro_core_options_v2_intl - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - */ -#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL 68 - -/** - * Registers a callback that the frontend can use - * to notify the core that at least one core option - * should be made hidden or visible. - * Allows a frontend to signal that a core must update - * the visibility of any dynamically hidden core options, - * and enables the frontend to detect visibility changes. - * Used by the frontend to update the menu display status - * of core options without requiring a call of retro_run(). - * Must be called in retro_set_environment(). - * - * @param[in] data const struct retro_core_options_update_display_callback *. - * The callback that the frontend should use. - * May be \c NULL, in which case the frontend will unset any existing callback. - * Can be used to query visibility support. - * @return \c true if this environment call is available, - * even if \c data is \c NULL. - * @see retro_core_options_update_display_callback - */ -#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK 69 - -/** - * Forcibly sets a core option's value. - * - * After changing a core option value with this callback, - * it will be reflected in the frontend - * and \ref RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE will return \c true. - * \ref retro_variable::key must match - * a \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 "previously-set core option", - * and \ref retro_variable::value must match one of its defined values. - * - * Possible use cases include: - * - * @li Allowing the player to set certain core options - * without entering the frontend's option menu, - * using an in-core hotkey. - * @li Adjusting invalid combinations of settings. - * @li Migrating settings from older releases of a core. - * - * @param[in] data const struct retro_variable *. - * Pointer to a single option that the core is changing. - * May be \c NULL, in which case the frontend will return \c true - * to indicate that this environment call is available. - * @return \c true if this environment call is available - * and the option named by \c key was successfully - * set to the given \c value. - * \c false if the \c key or \c value fields are \c NULL, empty, - * or don't match a previously set option. - * - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * @see RETRO_ENVIRONMENT_GET_VARIABLE - * @see RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE - */ -#define RETRO_ENVIRONMENT_SET_VARIABLE 70 - -#define RETRO_ENVIRONMENT_GET_THROTTLE_STATE (71 | RETRO_ENVIRONMENT_EXPERIMENTAL) - /* struct retro_throttle_state * -- - * Allows an implementation to get details on the actual rate - * the frontend is attempting to call retro_run(). - */ - -/** - * Returns information about how the frontend will use savestates. - * - * @param[out] data retro_savestate_context *. - * Pointer to the current savestate context. - * May be \c NULL, in which case the environment call - * will return \c true to indicate its availability. - * @returns \c true if the environment call is available, - * even if \c data is \c NULL. - * @see retro_savestate_context - */ -#define RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT (72 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Before calling \c SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE, will query which interface is supported. - * - * Frontend looks at \c retro_hw_render_interface_type and returns the maximum supported - * context negotiation interface version. If the \c retro_hw_render_interface_type is not - * supported or recognized by the frontend, a version of 0 must be returned in - * \c retro_hw_render_interface's \c interface_version and \c true is returned by frontend. - * - * If this environment call returns true with a \c interface_version greater than 0, - * a core can always use a negotiation interface version larger than what the frontend returns, - * but only earlier versions of the interface will be used by the frontend. - * - * A frontend must not reject a negotiation interface version that is larger than what the - * frontend supports. Instead, the frontend will use the older entry points that it recognizes. - * If this is incompatible with a particular core's requirements, it can error out early. - * - * @note Regarding backwards compatibility, this environment call was introduced after Vulkan v1 - * context negotiation. If this environment call is not supported by frontend, i.e. the environment - * call returns \c false , only Vulkan v1 context negotiation is supported (if Vulkan HW rendering - * is supported at all). If a core uses Vulkan negotiation interface with version > 1, negotiation - * may fail unexpectedly. All future updates to the context negotiation interface implies that - * frontend must support this environment call to query support. - * - * @param[out] data struct retro_hw_render_context_negotiation_interface *. - * @return \c true if the environment call is available. - * @see SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE - * @see retro_hw_render_interface_type - * @see retro_hw_render_context_negotiation_interface - */ -#define RETRO_ENVIRONMENT_GET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_SUPPORT (73 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Asks the frontend whether JIT compilation can be used. - * Primarily used by iOS and tvOS. - * @param[out] data bool *. - * Set to \c true if the frontend has verified that JIT compilation is possible. - * @return \c true if the environment call is available. - */ -#define RETRO_ENVIRONMENT_GET_JIT_CAPABLE 74 - -/** - * Returns an interface that the core can use to receive microphone input. - * - * @param[out] data retro_microphone_interface *. - * Pointer to the microphone interface. - * @return \true if microphone support is available, - * even if no microphones are plugged in. - * \c false if microphone support is disabled unavailable, - * or if \c data is \c NULL. - * @see retro_microphone_interface - */ -#define RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE (75 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/* Environment 76 was an obsolete version of RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE. -* It was not used by any known core at the time, and was removed from the API. */ - -/** - * Returns the device's current power state as reported by the frontend. - * - * This is useful for emulating the battery level in handheld consoles, - * or for reducing power consumption when on battery power. - * - * @note This environment call describes the power state for the entire device, - * not for individual peripherals like controllers. - * - * @param[out] data . - * Indicates whether the frontend can provide this information, even if the parameter - * is \c NULL. If the frontend does not support this functionality, then the provided - * argument will remain unchanged. - * @return \c true if the environment call is available. - * @see retro_device_power - */ -#define RETRO_ENVIRONMENT_GET_DEVICE_POWER (77 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -#define RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE 78 - /* const struct retro_netpacket_callback * -- - * When set, a core gains control over network packets sent and - * received during a multiplayer session. This can be used to - * emulate multiplayer games that were originally played on two - * or more separate consoles or computers connected together. - * - * The frontend will take care of connecting players together, - * and the core only needs to send the actual data as needed for - * the emulation, while handshake and connection management happen - * in the background. - * - * When two or more players are connected and this interface has - * been set, time manipulation features (such as pausing, slow motion, - * fast forward, rewinding, save state loading, etc.) are disabled to - * avoid interrupting communication. - * - * Should be set in either retro_init or retro_load_game, but not both. - * - * When not set, a frontend may use state serialization-based - * multiplayer, where a deterministic core supporting multiple - * input devices does not need to take any action on its own. - */ - -/** - * Returns the device's current power state as reported by the frontend. - * This is useful for emulating the battery level in handheld consoles, - * or for reducing power consumption when on battery power. - * - * The return value indicates whether the frontend can provide this information, - * even if the parameter is \c NULL. - * - * If the frontend does not support this functionality, - * then the provided argument will remain unchanged. - * @param[out] data retro_device_power *. - * Pointer to the information that the frontend returns about its power state. - * May be \c NULL. - * @return \c true if the environment call is available, - * even if \c data is \c NULL. - * @see retro_device_power - * @note This environment call describes the power state for the entire device, - * not for individual peripherals like controllers. -*/ -#define RETRO_ENVIRONMENT_GET_DEVICE_POWER (77 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/** - * Returns the "playlist" directory of the frontend. - * - * This directory can be used to store core generated playlists, in case - * this internal functionality is available (e.g. internal core game detection - * engine). - * - * @param[out] data const char **. - * May be \c NULL. If so, no such directory is defined, and it's up to the - * implementation to find a suitable directory. - * @return \c true if the environment call is available. - */ -#define RETRO_ENVIRONMENT_GET_PLAYLIST_DIRECTORY 79 - -/** - * Returns the "file browser" start directory of the frontend. - * - * This directory can serve as a start directory for the core in case it - * provides an internal way of loading content. - * - * @param[out] data const char **. - * May be \c NULL. If so, no such directory is defined, and it's up to the - * implementation to find a suitable directory. - * @return \c true if the environment call is available. - */ -#define RETRO_ENVIRONMENT_GET_FILE_BROWSER_START_DIRECTORY 80 - -/** - * Returns the audio sample rate the frontend is targeting, in Hz. - * The intended use case is for the core to use the result to select an ideal sample rate. - * - * @param[out] data unsigned *. - * Pointer to the \c unsigned integer in which the frontend will store its target sample rate. - * Behavior is undefined if \c data is NULL. - * @return \c true if this environment call is available, - * regardless of the value returned in \c data. -*/ -#define RETRO_ENVIRONMENT_GET_TARGET_SAMPLE_RATE (81 | RETRO_ENVIRONMENT_EXPERIMENTAL) - -/**@}*/ - -/** - * @defgroup GET_VFS_INTERFACE File System Interface - * @brief File system functionality. - * - * @section File Paths - * File paths passed to all libretro filesystem APIs shall be well formed UNIX-style, - * using "/" (unquoted forward slash) as the directory separator - * regardless of the platform's native separator. - * - * Paths shall also include at least one forward slash - * (e.g. use "./game.bin" instead of "game.bin"). - * - * Other than the directory separator, cores shall not make assumptions about path format. - * The following paths are all valid: - * @li \c C:/path/game.bin - * @li \c http://example.com/game.bin - * @li \c #game/game.bin - * @li \c ./game.bin - * - * Cores may replace the basename or remove path components from the end, and/or add new components; - * however, cores shall not append "./", "../" or multiple consecutive forward slashes ("//") to paths they request from the front end. - * - * The frontend is encouraged to do the best it can when given an ill-formed path, - * but it is allowed to give up. - * - * Frontends are encouraged, but not required, to support native file system paths - * (including replacing the directory separator, if applicable). - * - * Cores are allowed to try using them, but must remain functional if the frontend rejects such requests. - * - * Cores are encouraged to use the libretro-common filestream functions for file I/O, - * as they seamlessly integrate with VFS, - * deal with directory separator replacement as appropriate - * and provide platform-specific fallbacks - * in cases where front ends do not provide their own VFS interface. - * - * @see RETRO_ENVIRONMENT_GET_VFS_INTERFACE - * @see retro_vfs_interface_info - * @see file_path - * @see retro_dirent - * @see file_stream - * - * @{ - */ - -/** - * Opaque file handle. - * @since VFS API v1 - */ -struct retro_vfs_file_handle; - -/** - * Opaque directory handle. - * @since VFS API v3 - */ -struct retro_vfs_dir_handle; - -/** @defgroup RETRO_VFS_FILE_ACCESS File Access Flags - * File access flags. - * @since VFS API v1 - * @{ - */ - -/** Opens a file for read-only access. */ -#define RETRO_VFS_FILE_ACCESS_READ (1 << 0) - -/** - * Opens a file for write-only access. - * Any existing file at this path will be discarded and overwritten - * unless \c RETRO_VFS_FILE_ACCESS_UPDATE_EXISTING is also specified. - */ -#define RETRO_VFS_FILE_ACCESS_WRITE (1 << 1) - -/** - * Opens a file for reading and writing. - * Any existing file at this path will be discarded and overwritten - * unless \c RETRO_VFS_FILE_ACCESS_UPDATE_EXISTING is also specified. - */ -#define RETRO_VFS_FILE_ACCESS_READ_WRITE (RETRO_VFS_FILE_ACCESS_READ | RETRO_VFS_FILE_ACCESS_WRITE) - -/** - * Opens a file without discarding its existing contents. - * Only meaningful if \c RETRO_VFS_FILE_ACCESS_WRITE is specified. - */ -#define RETRO_VFS_FILE_ACCESS_UPDATE_EXISTING (1 << 2) /* Prevents discarding content of existing files opened for writing */ - -/** @} */ - -/** @defgroup RETRO_VFS_FILE_ACCESS_HINT File Access Hints - * - * Hints to the frontend for how a file will be accessed. - * The VFS implementation may use these to optimize performance, - * react to external interference (such as concurrent writes), - * or it may ignore them entirely. - * - * Hint flags do not change the behavior of each VFS function - * unless otherwise noted. - * @{ - */ - -/** No particular hints are given. */ -#define RETRO_VFS_FILE_ACCESS_HINT_NONE (0) - -/** - * Indicates that the file will be accessed frequently. - * - * The frontend should cache it or map it into memory. - */ -#define RETRO_VFS_FILE_ACCESS_HINT_FREQUENT_ACCESS (1 << 0) - -/** @} */ - -/** @defgroup RETRO_VFS_SEEK_POSITION File Seek Positions - * File access flags and hints. - * @{ - */ - -/** - * Indicates a seek relative to the start of the file. - */ -#define RETRO_VFS_SEEK_POSITION_START 0 - -/** - * Indicates a seek relative to the current stream position. - */ -#define RETRO_VFS_SEEK_POSITION_CURRENT 1 - -/** - * Indicates a seek relative to the end of the file. - * @note The offset passed to \c retro_vfs_seek_t should be negative. - */ -#define RETRO_VFS_SEEK_POSITION_END 2 - -/** @} */ - -/** @defgroup RETRO_VFS_STAT File Status Flags - * File stat flags. - * @see retro_vfs_stat_t - * @since VFS API v3 - * @{ - */ - -/** Indicates that the given path refers to a valid file. */ -#define RETRO_VFS_STAT_IS_VALID (1 << 0) - -/** Indicates that the given path refers to a directory. */ -#define RETRO_VFS_STAT_IS_DIRECTORY (1 << 1) - -/** - * Indicates that the given path refers to a character special file, - * such as \c /dev/null. - */ -#define RETRO_VFS_STAT_IS_CHARACTER_SPECIAL (1 << 2) - -/** @} */ - -/** - * Returns the path that was used to open this file. - * - * @param stream The opened file handle to get the path of. - * Behavior is undefined if \c NULL or closed. - * @return The path that was used to open \c stream. - * The string is owned by \c stream and must not be modified. - * @since VFS API v1 - * @see filestream_get_path - */ -typedef const char *(RETRO_CALLCONV *retro_vfs_get_path_t)(struct retro_vfs_file_handle *stream); - -/** - * Open a file for reading or writing. - * - * @param path The path to open. - * @param mode A bitwise combination of \c RETRO_VFS_FILE_ACCESS flags. - * At a minimum, one of \c RETRO_VFS_FILE_ACCESS_READ or \c RETRO_VFS_FILE_ACCESS_WRITE must be specified. - * @param hints A bitwise combination of \c RETRO_VFS_FILE_ACCESS_HINT flags. - * @return A handle to the opened file, - * or \c NULL upon failure. - * Note that this will return \c NULL if \c path names a directory. - * The returned file handle must be closed with \c retro_vfs_close_t. - * @since VFS API v1 - * @see File Paths - * @see RETRO_VFS_FILE_ACCESS - * @see RETRO_VFS_FILE_ACCESS_HINT - * @see retro_vfs_close_t - * @see filestream_open - */ -typedef struct retro_vfs_file_handle *(RETRO_CALLCONV *retro_vfs_open_t)(const char *path, unsigned mode, unsigned hints); - -/** - * Close the file and release its resources. - * All files returned by \c retro_vfs_open_t must be closed with this function. - * - * @param stream The file handle to close. - * Behavior is undefined if already closed. - * Upon completion of this function, \c stream is no longer valid - * (even if it returns failure). - * @return 0 on success, -1 on failure or if \c stream is \c NULL. - * @see retro_vfs_open_t - * @see filestream_close - * @since VFS API v1 - */ -typedef int (RETRO_CALLCONV *retro_vfs_close_t)(struct retro_vfs_file_handle *stream); - -/** - * Return the size of the file in bytes. - * - * @param stream The file to query the size of. - * @return Size of the file in bytes, or -1 if there was an error. - * @see filestream_get_size - * @since VFS API v1 - */ -typedef int64_t (RETRO_CALLCONV *retro_vfs_size_t)(struct retro_vfs_file_handle *stream); - -/** - * Set the file's length. - * - * @param stream The file whose length will be adjusted. - * @param length The new length of the file, in bytes. - * If shorter than the original length, the extra bytes will be discarded. - * If longer, the file's padding is unspecified (and likely platform-dependent). - * @return 0 on success, - * -1 on failure. - * @see filestream_truncate - * @since VFS API v2 - */ -typedef int64_t (RETRO_CALLCONV *retro_vfs_truncate_t)(struct retro_vfs_file_handle *stream, int64_t length); - -/** - * Gets the given file's current read/write position. - * This position is advanced with each call to \c retro_vfs_read_t or \c retro_vfs_write_t. - * - * @param stream The file to query the position of. - * @return The current stream position in bytes - * or -1 if there was an error. - * @see filestream_tell - * @since VFS API v1 - */ -typedef int64_t (RETRO_CALLCONV *retro_vfs_tell_t)(struct retro_vfs_file_handle *stream); - -/** - * Sets the given file handle's current read/write position. - * - * @param stream The file to set the position of. - * @param offset The new position, in bytes. - * @param seek_position The position to seek from. - * @return The new position, - * or -1 if there was an error. - * @since VFS API v1 - * @see File Seek Positions - * @see filestream_seek - */ -typedef int64_t (RETRO_CALLCONV *retro_vfs_seek_t)(struct retro_vfs_file_handle *stream, int64_t offset, int seek_position); - -/** - * Read data from a file, if it was opened for reading. - * - * @param stream The file to read from. - * @param s The buffer to read into. - * @param len The number of bytes to read. - * The buffer pointed to by \c s must be this large. - * @return The number of bytes read, - * or -1 if there was an error. - * @since VFS API v1 - * @see filestream_read - */ -typedef int64_t (RETRO_CALLCONV *retro_vfs_read_t)(struct retro_vfs_file_handle *stream, void *s, uint64_t len); - -/** - * Write data to a file, if it was opened for writing. - * - * @param stream The file handle to write to. - * @param s The buffer to write from. - * @param len The number of bytes to write. - * The buffer pointed to by \c s must be this large. - * @return The number of bytes written, - * or -1 if there was an error. - * @since VFS API v1 - * @see filestream_write - */ -typedef int64_t (RETRO_CALLCONV *retro_vfs_write_t)(struct retro_vfs_file_handle *stream, const void *s, uint64_t len); - -/** - * Flush pending writes to the file, if applicable. - * - * This does not mean that the changes will be immediately persisted to disk; - * that may be scheduled for later, depending on the platform. - * - * @param stream The file handle to flush. - * @return 0 on success, -1 on failure. - * @since VFS API v1 - * @see filestream_flush - */ -typedef int (RETRO_CALLCONV *retro_vfs_flush_t)(struct retro_vfs_file_handle *stream); - -/** - * Deletes the file at the given path. - * - * @param path The path to the file that will be deleted. - * @return 0 on success, -1 on failure. - * @see filestream_delete - * @since VFS API v1 - */ -typedef int (RETRO_CALLCONV *retro_vfs_remove_t)(const char *path); - -/** - * Rename the specified file. - * - * @param old_path Path to an existing file. - * @param new_path The destination path. - * Must not name an existing file. - * @return 0 on success, -1 on failure - * @see filestream_rename - * @since VFS API v1 - */ -typedef int (RETRO_CALLCONV *retro_vfs_rename_t)(const char *old_path, const char *new_path); - -/** - * Gets information about the given file. - * - * @param path The path to the file to query. - * @param[out] size The reported size of the file in bytes. - * May be \c NULL, in which case this value is ignored. - * @return A bitmask of \c RETRO_VFS_STAT flags, - * or 0 if \c path doesn't refer to a valid file. - * @see path_stat - * @see path_get_size - * @see RETRO_VFS_STAT - * @since VFS API v3 - */ -typedef int (RETRO_CALLCONV *retro_vfs_stat_t)(const char *path, int32_t *size); - -/** - * Creates a directory at the given path. - * - * @param dir The desired location of the new directory. - * @return 0 if the directory was created, - * -2 if the directory already exists, - * or -1 if some other error occurred. - * @see path_mkdir - * @since VFS API v3 - */ -typedef int (RETRO_CALLCONV *retro_vfs_mkdir_t)(const char *dir); - -/** - * Opens a handle to a directory so its contents can be inspected. - * - * @param dir The path to the directory to open. - * Must be an existing directory. - * @param include_hidden Whether to include hidden files in the directory listing. - * The exact semantics of this flag will depend on the platform. - * @return A handle to the opened directory, - * or \c NULL if there was an error. - * @see retro_opendir - * @since VFS API v3 - */ -typedef struct retro_vfs_dir_handle *(RETRO_CALLCONV *retro_vfs_opendir_t)(const char *dir, bool include_hidden); - -/** - * Gets the next dirent ("directory entry") - * within the given directory. - * - * @param[in,out] dirstream The directory to read from. - * Updated to point to the next file, directory, or other path. - * @return \c true when the next dirent was retrieved, - * \c false if there are no more dirents to read. - * @note This API iterates over all files and directories within \c dirstream. - * Remember to check what the type of the current dirent is. - * @note This function does not recurse, - * i.e. it does not return the contents of subdirectories. - * @note This may include "." and ".." on Unix-like platforms. - * @see retro_readdir - * @see retro_vfs_dirent_is_dir_t - * @since VFS API v3 - */ -typedef bool (RETRO_CALLCONV *retro_vfs_readdir_t)(struct retro_vfs_dir_handle *dirstream); - -/** - * Gets the filename of the current dirent. - * - * The returned string pointer is valid - * until the next call to \c retro_vfs_readdir_t or \c retro_vfs_closedir_t. - * - * @param dirstream The directory to read from. - * @return The current dirent's name, - * or \c NULL if there was an error. - * @note This function only returns the file's \em name, - * not a complete path to it. - * @see retro_dirent_get_name - * @since VFS API v3 - */ -typedef const char *(RETRO_CALLCONV *retro_vfs_dirent_get_name_t)(struct retro_vfs_dir_handle *dirstream); - -/** - * Checks whether the current dirent names a directory. - * - * @param dirstream The directory to read from. - * @return \c true if \c dirstream's current dirent points to a directory, - * \c false if not or if there was an error. - * @see retro_dirent_is_dir - * @since VFS API v3 - */ -typedef bool (RETRO_CALLCONV *retro_vfs_dirent_is_dir_t)(struct retro_vfs_dir_handle *dirstream); - -/** - * Closes the given directory and release its resources. - * - * Must be called on any \c retro_vfs_dir_handle returned by \c retro_vfs_open_t. - * - * @param dirstream The directory to close. - * When this function returns (even failure), - * \c dirstream will no longer be valid and must not be used. - * @return 0 on success, -1 on failure. - * @see retro_closedir - * @since VFS API v3 - */ -typedef int (RETRO_CALLCONV *retro_vfs_closedir_t)(struct retro_vfs_dir_handle *dirstream); - -/** - * File system interface exposed by the frontend. - * - * @see dirent_vfs_init - * @see filestream_vfs_init - * @see path_vfs_init - * @see RETRO_ENVIRONMENT_GET_VFS_INTERFACE - */ -struct retro_vfs_interface -{ - /* VFS API v1 */ - /** @copydoc retro_vfs_get_path_t */ - retro_vfs_get_path_t get_path; - - /** @copydoc retro_vfs_open_t */ - retro_vfs_open_t open; - - /** @copydoc retro_vfs_close_t */ - retro_vfs_close_t close; - - /** @copydoc retro_vfs_size_t */ - retro_vfs_size_t size; - - /** @copydoc retro_vfs_tell_t */ - retro_vfs_tell_t tell; - - /** @copydoc retro_vfs_seek_t */ - retro_vfs_seek_t seek; - - /** @copydoc retro_vfs_read_t */ - retro_vfs_read_t read; - - /** @copydoc retro_vfs_write_t */ - retro_vfs_write_t write; - - /** @copydoc retro_vfs_flush_t */ - retro_vfs_flush_t flush; - - /** @copydoc retro_vfs_remove_t */ - retro_vfs_remove_t remove; - - /** @copydoc retro_vfs_rename_t */ - retro_vfs_rename_t rename; - /* VFS API v2 */ - - /** @copydoc retro_vfs_truncate_t */ - retro_vfs_truncate_t truncate; - /* VFS API v3 */ - - /** @copydoc retro_vfs_stat_t */ - retro_vfs_stat_t stat; - - /** @copydoc retro_vfs_mkdir_t */ - retro_vfs_mkdir_t mkdir; - - /** @copydoc retro_vfs_opendir_t */ - retro_vfs_opendir_t opendir; - - /** @copydoc retro_vfs_readdir_t */ - retro_vfs_readdir_t readdir; - - /** @copydoc retro_vfs_dirent_get_name_t */ - retro_vfs_dirent_get_name_t dirent_get_name; - - /** @copydoc retro_vfs_dirent_is_dir_t */ - retro_vfs_dirent_is_dir_t dirent_is_dir; - - /** @copydoc retro_vfs_closedir_t */ - retro_vfs_closedir_t closedir; -}; - -/** - * Represents a request by the core for the frontend's file system interface, - * as well as the interface itself returned by the frontend. - * - * You do not need to use these functions directly; - * you may pass this struct to \c dirent_vfs_init, - * \c filestream_vfs_init, or \c path_vfs_init - * so that you can use the wrappers provided by these modules. - * - * @see dirent_vfs_init - * @see filestream_vfs_init - * @see path_vfs_init - * @see RETRO_ENVIRONMENT_GET_VFS_INTERFACE - */ -struct retro_vfs_interface_info -{ - /** - * The minimum version of the VFS API that the core requires. - * libretro-common's wrapper API initializers will check this value as well. - * - * Set to the core's desired VFS version when requesting an interface, - * and set by the frontend to indicate its actual API version. - * - * If the core asks for a newer VFS API version than the frontend supports, - * the frontend must return \c false within the \c RETRO_ENVIRONMENT_GET_VFS_INTERFACE call. - * @since VFS API v1 - */ - uint32_t required_interface_version; - - /** - * Set by the frontend. - * The frontend will set this to the VFS interface it provides. - * - * The interface is owned by the frontend - * and must not be modified or freed by the core. - * @since VFS API v1 */ - struct retro_vfs_interface *iface; -}; - -/** @} */ - -/** @defgroup GET_HW_RENDER_INTERFACE Hardware Rendering Interface - * @{ - */ - -/** - * Describes the hardware rendering API supported by - * a particular subtype of \c retro_hw_render_interface. - * - * Not every rendering API supported by libretro has its own interface, - * or even needs one. - * - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - * @see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE - */ -enum retro_hw_render_interface_type -{ - /** - * Indicates a \c retro_hw_render_interface for Vulkan. - * @see retro_hw_render_interface_vulkan - */ - RETRO_HW_RENDER_INTERFACE_VULKAN = 0, - - /** Indicates a \c retro_hw_render_interface for Direct3D 9. */ - RETRO_HW_RENDER_INTERFACE_D3D9 = 1, - - /** Indicates a \c retro_hw_render_interface for Direct3D 10. */ - RETRO_HW_RENDER_INTERFACE_D3D10 = 2, - - /** - * Indicates a \c retro_hw_render_interface for Direct3D 11. - * @see retro_hw_render_interface_d3d11 - */ - RETRO_HW_RENDER_INTERFACE_D3D11 = 3, - - /** - * Indicates a \c retro_hw_render_interface for Direct3D 12. - * @see retro_hw_render_interface_d3d12 - */ - RETRO_HW_RENDER_INTERFACE_D3D12 = 4, - - /** - * Indicates a \c retro_hw_render_interface for - * the PlayStation's 2 PSKit API. - * @see retro_hw_render_interface_gskit_ps2 - */ - RETRO_HW_RENDER_INTERFACE_GSKIT_PS2 = 5, - - /** @private Defined to ensure sizeof(retro_hw_render_interface_type) == sizeof(int). - * Do not use. */ - RETRO_HW_RENDER_INTERFACE_DUMMY = INT_MAX -}; - -/** - * Base render interface type. - * All \c retro_hw_render_interface implementations - * will start with these two fields set to particular values. - * - * @see retro_hw_render_interface_type - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - * @see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE - */ -struct retro_hw_render_interface -{ - /** - * Denotes the particular rendering API that this interface is for. - * Each interface requires this field to be set to a particular value. - * Use it to cast this interface to the appropriate pointer. - */ - enum retro_hw_render_interface_type interface_type; - - /** - * The version of this rendering interface. - * @note This is not related to the version of the API itself. - */ - unsigned interface_version; -}; - -/** @} */ - -/** - * @defgroup GET_LED_INTERFACE LED Interface - * @{ - */ - -/** @copydoc retro_led_interface::set_led_state */ -typedef void (RETRO_CALLCONV *retro_set_led_state_t)(int led, int state); - -/** - * Interface that the core can use to set the state of available LEDs. - * @see RETRO_ENVIRONMENT_GET_LED_INTERFACE - */ -struct retro_led_interface -{ - /** - * Sets the state of an LED. - * - * @param led The LED to set the state of. - * @param state The state to set the LED to. - * \c true to enable, \c false to disable. - */ - retro_set_led_state_t set_led_state; -}; - -/** @} */ - -/** @defgroup GET_AUDIO_VIDEO_ENABLE Skipped A/V Steps - * @{ - */ - -/** - * Flags that define A/V steps that the core may skip for this frame. - * - * @see RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE - */ -enum retro_av_enable_flags -{ - /** - * If set, the core should render video output with \c retro_video_refresh_t as normal. - * - * Otherwise, the frontend will discard any video data received this frame, - * including frames presented via hardware acceleration. - * \c retro_video_refresh_t will do nothing. - * - * @note After running the frame, the video output of the next frame - * should be no different than if video was enabled, - * and saving and loading state should have no issues. - * This implies that the emulated console's graphics pipeline state - * should not be affected by this flag. - * - * @note If emulating a platform that supports display capture - * (i.e. reading its own VRAM), - * the core may not be able to completely skip rendering, - * as the VRAM is part of the graphics pipeline's state. - */ - RETRO_AV_ENABLE_VIDEO = (1 << 0), - - /** - * If set, the core should render audio output - * with \c retro_audio_sample_t or \c retro_audio_sample_batch_t as normal. - * - * Otherwise, the frontend will discard any audio data received this frame. - * The core should skip audio rendering if possible. - * - * @note After running the frame, the audio output of the next frame - * should be no different than if audio was enabled, - * and saving and loading state should have no issues. - * This implies that the emulated console's audio pipeline state - * should not be affected by this flag. - */ - RETRO_AV_ENABLE_AUDIO = (1 << 1), - - /** - * If set, indicates that any savestates taken this frame - * are guaranteed to be created by the same binary that will load them, - * and will not be written to or read from the disk. - * - * The core may use these guarantees to: - * - * @li Assume that loading state will succeed. - * @li Update its memory buffers in-place if possible. - * @li Skip clearing memory. - * @li Skip resetting the system. - * @li Skip validation steps. - * - * @deprecated Use \c RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT instead, - * except for compatibility purposes. - */ - RETRO_AV_ENABLE_FAST_SAVESTATES = (1 << 2), - - /** - * If set, indicates that the frontend will never need audio from the core. - * Used by a frontend for implementing runahead via a secondary core instance. - * - * The core may stop synthesizing audio if it can do so - * without compromising emulation accuracy. - * - * Audio output for the next frame does not matter, - * and the frontend will never need an accurate audio state in the future. - * - * State will never be saved while this flag is set. - */ - RETRO_AV_ENABLE_HARD_DISABLE_AUDIO = (1 << 3), - - /** - * @private Defined to ensure sizeof(retro_av_enable_flags) == sizeof(int). - * Do not use. - */ - RETRO_AV_ENABLE_DUMMY = INT_MAX -}; - -/** @} */ - -/** - * @defgroup GET_MIDI_INTERFACE MIDI Interface - * @{ - */ - -/** @copydoc retro_midi_interface::input_enabled */ -typedef bool (RETRO_CALLCONV *retro_midi_input_enabled_t)(void); - -/** @copydoc retro_midi_interface::output_enabled */ -typedef bool (RETRO_CALLCONV *retro_midi_output_enabled_t)(void); - -/** @copydoc retro_midi_interface::read */ -typedef bool (RETRO_CALLCONV *retro_midi_read_t)(uint8_t *byte); - -/** @copydoc retro_midi_interface::write */ -typedef bool (RETRO_CALLCONV *retro_midi_write_t)(uint8_t byte, uint32_t delta_time); - -/** @copydoc retro_midi_interface::flush */ -typedef bool (RETRO_CALLCONV *retro_midi_flush_t)(void); - -/** - * Interface that the core can use for raw MIDI I/O. - */ -struct retro_midi_interface -{ - /** - * Retrieves the current state of MIDI input. - * - * @return \c true if MIDI input is enabled. - */ - retro_midi_input_enabled_t input_enabled; - - /** - * Retrieves the current state of MIDI output. - * @return \c true if MIDI output is enabled. - */ - retro_midi_output_enabled_t output_enabled; - - /** - * Reads a byte from the MIDI input stream. - * - * @param[out] byte The byte received from the input stream. - * @return \c true if a byte was read, - * \c false if MIDI input is disabled or \c byte is \c NULL. - */ - retro_midi_read_t read; - - /** - * Writes a byte to the output stream. - * - * @param byte The byte to write to the output stream. - * @param delta_time Time since the previous write, in microseconds. - * @return \c true if c\ byte was written, false otherwise. - */ - retro_midi_write_t write; - - /** - * Flushes previously-written data. - * - * @return \c true if successful. - */ - retro_midi_flush_t flush; -}; - -/** @} */ - -/** @defgroup SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE Render Context Negotiation - * @{ - */ - -/** - * Describes the hardware rendering API used by - * a particular subtype of \c retro_hw_render_context_negotiation_interface. - * - * Not every rendering API supported by libretro has a context negotiation interface, - * or even needs one. - * - * @see RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - * @see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE - */ -enum retro_hw_render_context_negotiation_interface_type -{ - /** - * Denotes a context negotiation interface for Vulkan. - * @see retro_hw_render_context_negotiation_interface_vulkan - */ - RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN = 0, - - /** - * @private Defined to ensure sizeof(retro_hw_render_context_negotiation_interface_type) == sizeof(int). - * Do not use. - */ - RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_DUMMY = INT_MAX -}; - -/** - * Base context negotiation interface type. - * All \c retro_hw_render_context_negotiation_interface implementations - * will start with these two fields set to particular values. - * - * @see retro_hw_render_interface_type - * @see RETRO_ENVIRONMENT_SET_HW_RENDER - * @see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE - * @see RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE - */ -struct retro_hw_render_context_negotiation_interface -{ - /** - * Denotes the particular rendering API that this interface is for. - * Each interface requires this field to be set to a particular value. - * Use it to cast this interface to the appropriate pointer. - */ - enum retro_hw_render_context_negotiation_interface_type interface_type; - - /** - * The version of this negotiation interface. - * @note This is not related to the version of the API itself. - */ - unsigned interface_version; -}; - -/** @} */ - -/** @defgroup RETRO_SERIALIZATION_QUIRK Serialization Quirks - * @{ - */ - -/** - * Indicates that serialized state is incomplete in some way. - * - * Set if serialization is usable for the common case of saving and loading game state, - * but should not be relied upon for frame-sensitive frontend features - * such as netplay or rerecording. - */ -#define RETRO_SERIALIZATION_QUIRK_INCOMPLETE (1 << 0) - -/** - * Indicates that core must spend some time initializing before serialization can be done. - * - * \c retro_serialize(), \c retro_unserialize(), and \c retro_serialize_size() will initially fail. - */ -#define RETRO_SERIALIZATION_QUIRK_MUST_INITIALIZE (1 << 1) - -/** Set by the core to indicate that serialization size may change within a session. */ -#define RETRO_SERIALIZATION_QUIRK_CORE_VARIABLE_SIZE (1 << 2) - -/** Set by the frontend to acknowledge that it supports variable-sized states. */ -#define RETRO_SERIALIZATION_QUIRK_FRONT_VARIABLE_SIZE (1 << 3) - -/** Serialized state can only be loaded during the same session. */ -#define RETRO_SERIALIZATION_QUIRK_SINGLE_SESSION (1 << 4) - -/** - * Serialized state cannot be loaded on an architecture - * with a different endianness from the one it was saved on. - */ -#define RETRO_SERIALIZATION_QUIRK_ENDIAN_DEPENDENT (1 << 5) - -/** - * Serialized state cannot be loaded on a different platform - * from the one it was saved on for reasons other than endianness, - * such as word size dependence. - */ -#define RETRO_SERIALIZATION_QUIRK_PLATFORM_DEPENDENT (1 << 6) - -/** @} */ - -/** @defgroup SET_MEMORY_MAPS Memory Descriptors - * @{ - */ - -/** @defgroup RETRO_MEMDESC Memory Descriptor Flags - * Information about how the emulated hardware uses this portion of its address space. - * @{ - */ - -/** - * Indicates that this memory area won't be modified - * once \c retro_load_game has returned. - */ -#define RETRO_MEMDESC_CONST (1 << 0) - -/** - * Indicates a memory area with big-endian byte ordering, - * as opposed to the default of little-endian. - */ -#define RETRO_MEMDESC_BIGENDIAN (1 << 1) - -/** - * Indicates a memory area that is used for the emulated system's main RAM. - */ -#define RETRO_MEMDESC_SYSTEM_RAM (1 << 2) - -/** - * Indicates a memory area that is used for the emulated system's save RAM, - * usually found on a game cartridge as battery-backed RAM or flash memory. - */ -#define RETRO_MEMDESC_SAVE_RAM (1 << 3) - -/** - * Indicates a memory area that is used for the emulated system's video RAM, - * usually found on a console's GPU (or local equivalent). - */ -#define RETRO_MEMDESC_VIDEO_RAM (1 << 4) - -/** - * Indicates a memory area that requires all accesses - * to be aligned to 2 bytes or their own size - * (whichever is smaller). - */ -#define RETRO_MEMDESC_ALIGN_2 (1 << 16) - -/** - * Indicates a memory area that requires all accesses - * to be aligned to 4 bytes or their own size - * (whichever is smaller). - */ -#define RETRO_MEMDESC_ALIGN_4 (2 << 16) - -/** - * Indicates a memory area that requires all accesses - * to be aligned to 8 bytes or their own size - * (whichever is smaller). - */ -#define RETRO_MEMDESC_ALIGN_8 (3 << 16) - -/** - * Indicates a memory area that requires all accesses - * to be at least 2 bytes long. - */ -#define RETRO_MEMDESC_MINSIZE_2 (1 << 24) - -/** - * Indicates a memory area that requires all accesses - * to be at least 4 bytes long. - */ -#define RETRO_MEMDESC_MINSIZE_4 (2 << 24) - -/** - * Indicates a memory area that requires all accesses - * to be at least 8 bytes long. - */ -#define RETRO_MEMDESC_MINSIZE_8 (3 << 24) - -/** @} */ - -/** - * A mapping from a region of the emulated console's address space - * to the host's address space. - * - * Can be used to map an address in the console's address space - * to the host's address space, like so: - * - * @code - * void* emu_to_host(void* addr, struct retro_memory_descriptor* descriptor) - * { - * return descriptor->ptr + (addr & ~descriptor->disconnect) - descriptor->start; - * } - * @endcode - * - * @see RETRO_ENVIRONMENT_SET_MEMORY_MAPS - */ -struct retro_memory_descriptor -{ - /** - * A bitwise \c OR of one or more \ref RETRO_MEMDESC "flags" - * that describe how the emulated system uses this descriptor's address range. - * - * @note If \c ptr is \c NULL, - * then no flags should be set. - * @see RETRO_MEMDESC - */ - uint64_t flags; - - /** - * Pointer to the start of this memory region's buffer - * within the \em host's address space. - * The address listed here must be valid for the duration of the session; - * it must not be freed or modified by the frontend - * and it must not be moved by the core. - * - * May be \c NULL to indicate a lack of accessible memory - * at the emulated address given in \c start. - * - * @note Overlapping descriptors that include the same byte - * must have the same \c ptr value. - */ - void *ptr; - - /** - * The offset of this memory region, - * relative to the address given by \c ptr. - * - * @note It is recommended to use this field for address calculations - * instead of performing arithmetic on \c ptr. - */ - size_t offset; - - /** - * The starting address of this memory region - * within the emulated hardware's address space. - * - * @note Not represented as a pointer - * because it's unlikely to be valid on the host device. - */ - size_t start; - - /** - * A bitmask that specifies which bits of an address must match - * the bits of the \ref start address. - * - * Combines with \c disconnect to map an address to a memory block. - * - * If multiple memory descriptors can claim a particular byte, - * the first one defined in the \ref retro_memory_descriptor array applies. - * A bit which is set in \c start must also be set in this. - * - * Can be zero, in which case \c start and \c len represent - * the complete mapping for this region of memory - * (i.e. each byte is mapped exactly once). - * In this case, \c len must be a power of two. - */ - size_t select; - - /** - * A bitmask of bits that are \em not used for addressing. - * - * Any set bits are assumed to be disconnected from - * the emulated memory chip's address pins, - * and are therefore ignored when memory-mapping. - */ - size_t disconnect; - - /** - * The length of this memory region, in bytes. - * - * If applying \ref start and \ref disconnect to an address - * results in a value higher than this, - * the highest bit of the address is cleared. - * - * If the address is still too high, the next highest bit is cleared. - * Can be zero, in which case it's assumed to be - * bounded only by \ref select and \ref disconnect. - */ - size_t len; - - /** - * A short name for this address space. - * - * Names must meet the following requirements: - * - * \li Characters must be in the set [a-zA-Z0-9_-]. - * \li No more than 8 characters, plus a \c NULL terminator. - * \li Names are case-sensitive, but lowercase characters are discouraged. - * \li A name must not be the same as another name plus a character in the set \c [A-F0-9] - * (i.e. if an address space named "RAM" exists, - * then the names "RAM0", "RAM1", ..., "RAMF" are forbidden). - * This is to allow addresses to be named by each descriptor unambiguously, - * even if the areas overlap. - * \li May be \c NULL or empty (both are considered equivalent). - * - * Here are some examples of pairs of address space names: - * - * \li \em blank + \em blank: valid (multiple things may be mapped in the same namespace) - * \li \c Sp + \c Sp: valid (multiple things may be mapped in the same namespace) - * \li \c SRAM + \c VRAM: valid (neither is a prefix of the other) - * \li \c V + \em blank: valid (\c V is not in \c [A-F0-9]) - * \li \c a + \em blank: valid but discouraged (\c a is not in \c [A-F0-9]) - * \li \c a + \c A: valid but discouraged (neither is a prefix of the other) - * \li \c AR + \em blank: valid (\c R is not in \c [A-F0-9]) - * \li \c ARB + \em blank: valid (there's no \c AR namespace, - * so the \c B doesn't cause ambiguity). - * \li \em blank + \c B: invalid, because it's ambiguous which address space \c B1234 would refer to. - * - * The length of the address space's name can't be used to disambugiate, - * as extra information may be appended to it without a separator. - */ - const char *addrspace; - - /* TODO: When finalizing this one, add a description field, which should be - * "WRAM" or something roughly equally long. */ - - /* TODO: When finalizing this one, replace 'select' with 'limit', which tells - * which bits can vary and still refer to the same address (limit = ~select). - * TODO: limit? range? vary? something else? */ - - /* TODO: When finalizing this one, if 'len' is above what 'select' (or - * 'limit') allows, it's bankswitched. Bankswitched data must have both 'len' - * and 'select' != 0, and the mappings don't tell how the system switches the - * banks. */ - - /* TODO: When finalizing this one, fix the 'len' bit removal order. - * For len=0x1800, pointer 0x1C00 should go to 0x1400, not 0x0C00. - * Algorithm: Take bits highest to lowest, but if it goes above len, clear - * the most recent addition and continue on the next bit. - * TODO: Can the above be optimized? Is "remove the lowest bit set in both - * pointer and 'len'" equivalent? */ - - /* TODO: Some emulators (MAME?) emulate big endian systems by only accessing - * the emulated memory in 32-bit chunks, native endian. But that's nothing - * compared to Darek Mihocka - * (section Emulation 103 - Nearly Free Byte Reversal) - he flips the ENTIRE - * RAM backwards! I'll want to represent both of those, via some flags. - * - * I suspect MAME either didn't think of that idea, or don't want the #ifdef. - * Not sure which, nor do I really care. */ - - /* TODO: Some of those flags are unused and/or don't really make sense. Clean - * them up. */ -}; - -/** - * A list of regions within the emulated console's address space. - * - * The frontend may use the largest value of - * \ref retro_memory_descriptor::start + \ref retro_memory_descriptor::select - * in a certain namespace to infer the overall size of the address space. - * If the address space is larger than that, - * the last mapping in \ref descriptors should have \ref retro_memory_descriptor::ptr set to \c NULL - * and \ref retro_memory_descriptor::select should have all bits used in the address space set to 1. - * - * Here's an example set of descriptors for the SNES. - * - * @code{.c} - * struct retro_memory_map snes_descriptors = retro_memory_map - * { - * .descriptors = (struct retro_memory_descriptor[]) - * { - * // WRAM; must usually be mapped before the ROM, - * // as some SNES ROM mappers try to claim 0x7E0000 - * { .addrspace="WRAM", .start=0x7E0000, .len=0x20000 }, - * - * // SPC700 RAM - * { .addrspace="SPC700", .len=0x10000 }, - * - * // WRAM mirrors - * { .addrspace="WRAM", .start=0x000000, .select=0xC0E000, .len=0x2000 }, - * { .addrspace="WRAM", .start=0x800000, .select=0xC0E000, .len=0x2000 }, - * - * // WRAM mirror, alternate equivalent descriptor - * // (Various similar constructions can be created by combining parts of the above two.) - * { .addrspace="WRAM", .select=0x40E000, .disconnect=~0x1FFF }, - * - * // LoROM (512KB, mirrored a couple of times) - * { .addrspace="LoROM", .start=0x008000, .select=0x408000, .disconnect=0x8000, .len=512*1024, .flags=RETRO_MEMDESC_CONST }, - * { .addrspace="LoROM", .start=0x400000, .select=0x400000, .disconnect=0x8000, .len=512*1024, .flags=RETRO_MEMDESC_CONST }, - * - * // HiROM (4MB) - * { .addrspace="HiROM", .start=0x400000, .select=0x400000, .len=4*1024*1024, .flags=RETRO_MEMDESC_CONST }, - * { .addrspace="HiROM", .start=0x008000, .select=0x408000, .len=4*1024*1024, .offset=0x8000, .flags=RETRO_MEMDESC_CONST }, - * - * // ExHiROM (8MB) - * { .addrspace="ExHiROM", .start=0xC00000, .select=0xC00000, .len=4*1024*1024, .offset=0, .flags=RETRO_MEMDESC_CONST }, - * { .addrspace="ExHiROM", .start=0x400000, .select=0xC00000, .len=4*1024*1024, .offset=4*1024*1024, .flags=RETRO_MEMDESC_CONST }, - * { .addrspace="ExHiROM", .start=0x808000, .select=0xC08000, .len=4*1024*1024, .offset=0x8000, .flags=RETRO_MEMDESC_CONST }, - * { .addrspace="ExHiROM", .start=0x008000, .select=0xC08000, .len=4*1024*1024, .offset=4*1024*1024+0x8000, .flags=RETRO_MEMDESC_CONST }, - * - * // Clarifying the full size of the address space - * { .select=0xFFFFFF, .ptr=NULL }, - * }, - * .num_descriptors = 14, - * }; - * @endcode - * - * @see RETRO_ENVIRONMENT_SET_MEMORY_MAPS - */ -struct retro_memory_map -{ - /** - * Pointer to an array of memory descriptors, - * each of which describes part of the emulated console's address space. - */ - const struct retro_memory_descriptor *descriptors; - - /** The number of descriptors in \c descriptors. */ - unsigned num_descriptors; -}; - -/** @} */ - -/** @defgroup SET_CONTROLLER_INFO Controller Info - * @{ - */ - -/** - * Details about a controller (or controller configuration) - * supported by one of a core's emulated input ports. - * - * @see RETRO_ENVIRONMENT_SET_CONTROLLER_INFO - */ -struct retro_controller_description -{ - /** - * A human-readable label for the controller or configuration - * represented by this device type. - * Most likely the device's original brand name. - */ - const char *desc; - - /** - * A unique identifier that will be passed to \c retro_set_controller_port_device()'s \c device parameter. - * May be the ID of one of \ref RETRO_DEVICE "the generic controller types", - * or a subclass ID defined with \c RETRO_DEVICE_SUBCLASS. - * - * @see RETRO_DEVICE_SUBCLASS - */ - unsigned id; -}; - -/** - * Lists the types of controllers supported by - * one of core's emulated input ports. - * - * @see RETRO_ENVIRONMENT_SET_CONTROLLER_INFO - */ -struct retro_controller_info -{ - - /** - * A pointer to an array of device types supported by this controller port. - * - * @note Ports that support the same devices - * may share the same underlying array. - */ - const struct retro_controller_description *types; - - /** The number of elements in \c types. */ - unsigned num_types; -}; - -/** @} */ - -/** @defgroup SET_SUBSYSTEM_INFO Subsystems - * @{ - */ - -/** - * Information about a type of memory associated with a subsystem. - * Usually used for SRAM (save RAM). - * - * @see RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO - * @see retro_get_memory_data - * @see retro_get_memory_size - */ -struct retro_subsystem_memory_info -{ - /** - * The file extension the frontend should use - * to save this memory region to disk, e.g. "srm" or "sav". - */ - const char *extension; - - /** - * A constant that identifies this type of memory. - * Should be at least 0x100 (256) to avoid conflict - * with the standard libretro memory types, - * unless a subsystem uses the main platform's memory region. - * @see RETRO_MEMORY - */ - unsigned type; -}; - -/** - * Information about a type of ROM that a subsystem may use. - * Subsystems may use one or more ROMs at once, - * possibly of different types. - * - * @see RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO - * @see retro_subsystem_info - */ -struct retro_subsystem_rom_info -{ - /** - * Human-readable description of what the content represents, - * e.g. "Game Boy ROM". - */ - const char *desc; - - /** @copydoc retro_system_info::valid_extensions */ - const char *valid_extensions; - - /** @copydoc retro_system_info::need_fullpath */ - bool need_fullpath; - - /** @copydoc retro_system_info::block_extract */ - bool block_extract; - - /** - * Indicates whether this particular subsystem ROM is required. - * If \c true and the user doesn't provide a ROM, - * the frontend should not load the core. - * If \c false and the user doesn't provide a ROM, - * the frontend should pass a zeroed-out \c retro_game_info - * to the corresponding entry in \c retro_load_game_special(). - */ - bool required; - - /** - * Pointer to an array of memory descriptors that this subsystem ROM type uses. - * Useful for secondary cartridges that have their own save data. - * May be \c NULL, in which case this subsystem ROM's memory is not persisted by the frontend - * and \c num_memory should be zero. - */ - const struct retro_subsystem_memory_info *memory; - - /** The number of elements in the array pointed to by \c memory. */ - unsigned num_memory; -}; - -/** - * Information about a secondary platform that a core supports. - * @see RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO - */ -struct retro_subsystem_info -{ - /** - * A human-readable description of the subsystem type, - * usually the brand name of the original platform - * (e.g. "Super Game Boy"). - */ - const char *desc; - - /** - * A short machine-friendly identifier for the subsystem, - * usually an abbreviation of the platform name. - * For example, a Super Game Boy subsystem for a SNES core - * might use an identifier of "sgb". - * This identifier can be used for command-line interfaces, - * configuration, or other purposes. - * Must use lower-case alphabetical characters only (i.e. from a-z). - */ - const char *ident; - - /** - * The list of ROM types that this subsystem may use. - * - * The first entry is considered to be the "most significant" content, - * for the purposes of the frontend's categorization. - * E.g. with Super GameBoy, the first content should be the GameBoy ROM, - * as it is the most "significant" content to a user. - * - * If a frontend creates new files based on the content used (e.g. for savestates), - * it should derive the filenames from the name of the first ROM in this list. - * - * @note \c roms can have a single element, - * but this is usually a sign that the core should broaden its - * primary system info instead. - * - * @see \c retro_system_info - */ - const struct retro_subsystem_rom_info *roms; - - /** The length of the array given in \c roms. */ - unsigned num_roms; - - /** A unique identifier passed to retro_load_game_special(). */ - unsigned id; -}; - -/** @} */ - -/** @defgroup SET_PROC_ADDRESS_CALLBACK Core Function Pointers - * @{ */ - -/** - * The function pointer type that \c retro_get_proc_address_t returns. - * - * Despite the signature shown here, the original function may include any parameters and return type - * that respects the calling convention and C ABI. - * - * The frontend is expected to cast the function pointer to the correct type. - */ -typedef void (RETRO_CALLCONV *retro_proc_address_t)(void); - -/** - * Get a symbol from a libretro core. - * - * Cores should only return symbols that serve as libretro extensions. - * Frontends should not use this to obtain symbols to standard libretro entry points; - * instead, they should link to the core statically or use \c dlsym (or local equivalent). - * - * The symbol name must be equal to the function name. - * e.g. if void retro_foo(void); exists, the symbol in the compiled library must be called \c retro_foo. - * The returned function pointer must be cast to the corresponding type. - * - * @param \c sym The name of the symbol to look up. - * @return Pointer to the exposed function with the name given in \c sym, - * or \c NULL if one couldn't be found. - * @note The frontend is expected to know the returned pointer's type in advance - * so that it can be cast correctly. - * @note The core doesn't need to expose every possible function through this interface. - * It's enough to only expose the ones that it expects the frontend to use. - * @note The functions exposed through this interface - * don't need to be publicly exposed in the compiled library - * (e.g. via \c __declspec(dllexport)). - * @see RETRO_ENVIRONMENT_SET_PROC_ADDRESS_CALLBACK - */ -typedef retro_proc_address_t (RETRO_CALLCONV *retro_get_proc_address_t)(const char *sym); - -/** - * An interface that the frontend can use to get function pointers from the core. - * - * @note The returned function pointer will be invalidated once the core is unloaded. - * How and when that happens is up to the frontend. - * - * @see retro_get_proc_address_t - * @see RETRO_ENVIRONMENT_SET_PROC_ADDRESS_CALLBACK - */ -struct retro_get_proc_address_interface -{ - /** Set by the core. */ - retro_get_proc_address_t get_proc_address; -}; - -/** @} */ - -/** @defgroup GET_LOG_INTERFACE Logging - * @{ - */ - -/** - * The severity of a given message. - * The frontend may log messages differently depending on the level. - * It may also ignore log messages of a certain level. - * @see retro_log_callback - */ -enum retro_log_level -{ - /** The logged message is most likely not interesting to the user. */ - RETRO_LOG_DEBUG = 0, - - /** Information about the core operating normally. */ - RETRO_LOG_INFO, - - /** Indicates a potential problem, possibly one that the core can recover from. */ - RETRO_LOG_WARN, - - /** Indicates a degraded experience, if not failure. */ - RETRO_LOG_ERROR, - - /** Defined to ensure that sizeof(enum retro_log_level) == sizeof(int). Do not use. */ - RETRO_LOG_DUMMY = INT_MAX -}; - -/** - * Logs a message to the frontend. - * - * @param level The log level of the message. - * @param fmt The format string to log. - * Same format as \c printf. - * Behavior is undefined if this is \c NULL. - * @param ... Zero or more arguments used by the format string. - * Behavior is undefined if these don't match the ones expected by \c fmt. - * @see retro_log_level - * @see retro_log_callback - * @see RETRO_ENVIRONMENT_GET_LOG_INTERFACE - * @see printf - */ -typedef void (RETRO_CALLCONV *retro_log_printf_t)(enum retro_log_level level, - const char *fmt, ...); - -/** - * Details about how to make log messages. - * - * @see retro_log_printf_t - * @see RETRO_ENVIRONMENT_GET_LOG_INTERFACE - */ -struct retro_log_callback -{ - /** - * Called when logging a message. - * - * @note Set by the frontend. - */ - retro_log_printf_t log; -}; - -/** @} */ - -/** @defgroup GET_PERF_INTERFACE Performance Interface - * @{ - */ - -/** @defgroup RETRO_SIMD CPU Features - * @{ - */ - -/** - * Indicates CPU support for the SSE instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#ssetechs=SSE - */ -#define RETRO_SIMD_SSE (1 << 0) - -/** - * Indicates CPU support for the SSE2 instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#ssetechs=SSE2 - */ -#define RETRO_SIMD_SSE2 (1 << 1) - -/** Indicates CPU support for the AltiVec (aka VMX or Velocity Engine) instruction set. */ -#define RETRO_SIMD_VMX (1 << 2) - -/** Indicates CPU support for the VMX128 instruction set. Xbox 360 only. */ -#define RETRO_SIMD_VMX128 (1 << 3) - -/** - * Indicates CPU support for the AVX instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#avxnewtechs=AVX - */ -#define RETRO_SIMD_AVX (1 << 4) - -/** - * Indicates CPU support for the NEON instruction set. - * @see https://developer.arm.com/architectures/instruction-sets/intrinsics/#f:@navigationhierarchiessimdisa=[Neon] - */ -#define RETRO_SIMD_NEON (1 << 5) - -/** - * Indicates CPU support for the SSE3 instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#ssetechs=SSE3 - */ -#define RETRO_SIMD_SSE3 (1 << 6) - -/** - * Indicates CPU support for the SSSE3 instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#ssetechs=SSSE3 - */ -#define RETRO_SIMD_SSSE3 (1 << 7) - -/** - * Indicates CPU support for the MMX instruction set. - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#techs=MMX - */ -#define RETRO_SIMD_MMX (1 << 8) - -/** Indicates CPU support for the MMXEXT instruction set. */ -#define RETRO_SIMD_MMXEXT (1 << 9) - -/** - * Indicates CPU support for the SSE4 instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#ssetechs=SSE4_1 - */ -#define RETRO_SIMD_SSE4 (1 << 10) - -/** - * Indicates CPU support for the SSE4.2 instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#ssetechs=SSE4_2 - */ -#define RETRO_SIMD_SSE42 (1 << 11) - -/** - * Indicates CPU support for the AVX2 instruction set. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#avxnewtechs=AVX2 - */ -#define RETRO_SIMD_AVX2 (1 << 12) - -/** Indicates CPU support for the VFPU instruction set. PS2 and PSP only. - * - * @see https://pspdev.github.io/vfpu-docs - */ -#define RETRO_SIMD_VFPU (1 << 13) - -/** - * Indicates CPU support for Gekko SIMD extensions. GameCube only. - */ -#define RETRO_SIMD_PS (1 << 14) - -/** - * Indicates CPU support for AES instructions. - * - * @see https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html#aestechs=AES&othertechs=AES - */ -#define RETRO_SIMD_AES (1 << 15) - -/** - * Indicates CPU support for the VFPv3 instruction set. - */ -#define RETRO_SIMD_VFPV3 (1 << 16) - -/** - * Indicates CPU support for the VFPv4 instruction set. - */ -#define RETRO_SIMD_VFPV4 (1 << 17) - -/** Indicates CPU support for the POPCNT instruction. */ -#define RETRO_SIMD_POPCNT (1 << 18) - -/** Indicates CPU support for the MOVBE instruction. */ -#define RETRO_SIMD_MOVBE (1 << 19) - -/** Indicates CPU support for the CMOV instruction. */ -#define RETRO_SIMD_CMOV (1 << 20) - -/** Indicates CPU support for the ASIMD instruction set. */ -#define RETRO_SIMD_ASIMD (1 << 21) - -/** @} */ - -/** - * An abstract unit of ticks. - * - * Usually nanoseconds or CPU cycles, - * but it depends on the platform and the frontend. - */ -typedef uint64_t retro_perf_tick_t; - -/** Time in microseconds. */ -typedef int64_t retro_time_t; - -/** - * A performance counter. - * - * Use this to measure the execution time of a region of code. - * @see retro_perf_callback - */ -struct retro_perf_counter -{ - /** - * A human-readable identifier for the counter. - * - * May be displayed by the frontend. - * Behavior is undefined if this is \c NULL. - */ - const char *ident; - - /** - * The time of the most recent call to \c retro_perf_callback::perf_start - * on this performance counter. - * - * @see retro_perf_start_t - */ - retro_perf_tick_t start; - - /** - * The total time spent within this performance counter's measured code, - * i.e. between calls to \c retro_perf_callback::perf_start and \c retro_perf_callback::perf_stop. - * - * Updated after each call to \c retro_perf_callback::perf_stop. - * @see retro_perf_stop_t - */ - retro_perf_tick_t total; - - /** - * The number of times this performance counter has been started. - * - * Updated after each call to \c retro_perf_callback::perf_start. - * @see retro_perf_start_t - */ - retro_perf_tick_t call_cnt; - - /** - * \c true if this performance counter has been registered by the frontend. - * Must be initialized to \c false by the core before registering it. - * @see retro_perf_register_t - */ - bool registered; -}; - -/** - * @returns The current system time in microseconds. - * @note Accuracy may vary by platform. - * The frontend should use the most accurate timer possible. - * @see RETRO_ENVIRONMENT_GET_PERF_INTERFACE - */ -typedef retro_time_t (RETRO_CALLCONV *retro_perf_get_time_usec_t)(void); - -/** - * @returns The number of ticks since some unspecified epoch. - * The exact meaning of a "tick" depends on the platform, - * but it usually refers to nanoseconds or CPU cycles. - * @see RETRO_ENVIRONMENT_GET_PERF_INTERFACE - */ -typedef retro_perf_tick_t (RETRO_CALLCONV *retro_perf_get_counter_t)(void); - -/** - * Returns a bitmask of detected CPU features. - * - * Use this for runtime dispatching of CPU-specific code. - * - * @returns A bitmask of detected CPU features. - * @see RETRO_ENVIRONMENT_GET_PERF_INTERFACE - * @see RETRO_SIMD - */ -typedef uint64_t (RETRO_CALLCONV *retro_get_cpu_features_t)(void); - -/** - * Asks the frontend to log or display the state of performance counters. - * How this is done depends on the frontend. - * Performance counters can be reviewed manually as well. - * - * @see RETRO_ENVIRONMENT_GET_PERF_INTERFACE - * @see retro_perf_counter - */ -typedef void (RETRO_CALLCONV *retro_perf_log_t)(void); - -/** - * Registers a new performance counter. - * - * If \c counter has already been registered beforehand, - * this function does nothing. - * - * @param counter The counter to register. - * \c counter::ident must be set to a unique identifier, - * and all other values in \c counter must be set to zero or \c false. - * Behavior is undefined if \c NULL. - * @post If \c counter is successfully registered, - * then \c counter::registered will be set to \c true. - * Otherwise, it will be set to \c false. - * Registration may fail if the frontend's maximum number of counters (if any) has been reached. - * @note The counter is owned by the core and must not be freed by the frontend. - * The frontend must also clean up any references to a core's performance counters - * before unloading it, otherwise undefined behavior may occur. - * @see retro_perf_start_t - * @see retro_perf_stop_t - */ -typedef void (RETRO_CALLCONV *retro_perf_register_t)(struct retro_perf_counter *counter); - -/** - * Starts a registered performance counter. - * - * Call this just before the code you want to measure. - * - * @param counter The counter to start. - * Behavior is undefined if \c NULL. - * @see retro_perf_stop_t - */ -typedef void (RETRO_CALLCONV *retro_perf_start_t)(struct retro_perf_counter *counter); - -/** - * Stops a registered performance counter. - * - * Call this just after the code you want to measure. - * - * @param counter The counter to stop. - * Behavior is undefined if \c NULL. - * @see retro_perf_start_t - * @see retro_perf_stop_t - */ -typedef void (RETRO_CALLCONV *retro_perf_stop_t)(struct retro_perf_counter *counter); - -/** - * An interface that the core can use to get performance information. - * - * Here's a usage example: - * - * @code{.c} - * #ifdef PROFILING - * // Wrapper macros to simplify using performance counters. - * // Optional; tailor these to your project's needs. - * #define RETRO_PERFORMANCE_INIT(perf_cb, name) static struct retro_perf_counter name = {#name}; if (!name.registered) perf_cb.perf_register(&(name)) - * #define RETRO_PERFORMANCE_START(perf_cb, name) perf_cb.perf_start(&(name)) - * #define RETRO_PERFORMANCE_STOP(perf_cb, name) perf_cb.perf_stop(&(name)) - * #else - * // Exclude the performance counters if profiling is disabled. - * #define RETRO_PERFORMANCE_INIT(perf_cb, name) ((void)0) - * #define RETRO_PERFORMANCE_START(perf_cb, name) ((void)0) - * #define RETRO_PERFORMANCE_STOP(perf_cb, name) ((void)0) - * #endif - * - * // Defined somewhere else in the core. - * extern struct retro_perf_callback perf_cb; - * - * void retro_run(void) - * { - * RETRO_PERFORMANCE_INIT(cb, interesting); - * RETRO_PERFORMANCE_START(cb, interesting); - * interesting_work(); - * RETRO_PERFORMANCE_STOP(cb, interesting); - * - * RETRO_PERFORMANCE_INIT(cb, maybe_slow); - * RETRO_PERFORMANCE_START(cb, maybe_slow); - * more_interesting_work(); - * RETRO_PERFORMANCE_STOP(cb, maybe_slow); - * } - * - * void retro_deinit(void) - * { - * // Asks the frontend to log the results of all performance counters. - * perf_cb.perf_log(); - * } - * @endcode - * - * All functions are set by the frontend. - * - * @see RETRO_ENVIRONMENT_GET_PERF_INTERFACE - */ -struct retro_perf_callback -{ - /** @copydoc retro_perf_get_time_usec_t */ - retro_perf_get_time_usec_t get_time_usec; - - /** @copydoc retro_perf_get_counter_t */ - retro_get_cpu_features_t get_cpu_features; - - /** @copydoc retro_perf_get_counter_t */ - retro_perf_get_counter_t get_perf_counter; - - /** @copydoc retro_perf_register_t */ - retro_perf_register_t perf_register; - - /** @copydoc retro_perf_start_t */ - retro_perf_start_t perf_start; - - /** @copydoc retro_perf_stop_t */ - retro_perf_stop_t perf_stop; - - /** @copydoc retro_perf_log_t */ - retro_perf_log_t perf_log; -}; - -/** @} */ - -/** - * @defgroup RETRO_SENSOR Sensor Interface - * @{ - */ - -/** - * Defines actions that can be performed on sensors. - * @note Cores should only enable sensors while they're actively being used; - * depending on the frontend and platform, - * enabling these sensors may impact battery life. - * - * @see RETRO_ENVIRONMENT_GET_SENSOR_INTERFACE - * @see retro_sensor_interface - * @see retro_set_sensor_state_t - */ -enum retro_sensor_action -{ - /** Enables accelerometer input, if one exists. */ - RETRO_SENSOR_ACCELEROMETER_ENABLE = 0, - - /** Disables accelerometer input, if one exists. */ - RETRO_SENSOR_ACCELEROMETER_DISABLE, - - /** Enables gyroscope input, if one exists. */ - RETRO_SENSOR_GYROSCOPE_ENABLE, - - /** Disables gyroscope input, if one exists. */ - RETRO_SENSOR_GYROSCOPE_DISABLE, - - /** Enables ambient light input, if a luminance sensor exists. */ - RETRO_SENSOR_ILLUMINANCE_ENABLE, - - /** Disables ambient light input, if a luminance sensor exists. */ - RETRO_SENSOR_ILLUMINANCE_DISABLE, - - /** @private Defined to ensure sizeof(enum retro_sensor_action) == sizeof(int). Do not use. */ - RETRO_SENSOR_DUMMY = INT_MAX -}; - -/** @defgroup RETRO_SENSOR_ID Sensor Value IDs - * @{ - */ -/* Id values for SENSOR types. */ - -/** - * Returns the device's acceleration along its local X axis minus the effect of gravity, in m/s^2. - * - * Positive values mean that the device is accelerating to the right. - * assuming the user is looking at it head-on. - */ -#define RETRO_SENSOR_ACCELEROMETER_X 0 - -/** - * Returns the device's acceleration along its local Y axis minus the effect of gravity, in m/s^2. - * - * Positive values mean that the device is accelerating upwards, - * assuming the user is looking at it head-on. - */ -#define RETRO_SENSOR_ACCELEROMETER_Y 1 - -/** - * Returns the the device's acceleration along its local Z axis minus the effect of gravity, in m/s^2. - * - * Positive values indicate forward acceleration towards the user, - * assuming the user is looking at the device head-on. - */ -#define RETRO_SENSOR_ACCELEROMETER_Z 2 - -/** - * Returns the angular velocity of the device around its local X axis, in radians per second. - * - * Positive values indicate counter-clockwise rotation. - * - * @note A radian is about 57 degrees, and a full 360-degree rotation is 2*pi radians. - * @see https://developer.android.com/reference/android/hardware/SensorEvent#sensor.type_gyroscope - * for guidance on using this value to derive a device's orientation. - */ -#define RETRO_SENSOR_GYROSCOPE_X 3 - -/** - * Returns the angular velocity of the device around its local Z axis, in radians per second. - * - * Positive values indicate counter-clockwise rotation. - * - * @note A radian is about 57 degrees, and a full 360-degree rotation is 2*pi radians. - * @see https://developer.android.com/reference/android/hardware/SensorEvent#sensor.type_gyroscope - * for guidance on using this value to derive a device's orientation. - */ -#define RETRO_SENSOR_GYROSCOPE_Y 4 - -/** - * Returns the angular velocity of the device around its local Z axis, in radians per second. - * - * Positive values indicate counter-clockwise rotation. - * - * @note A radian is about 57 degrees, and a full 360-degree rotation is 2*pi radians. - * @see https://developer.android.com/reference/android/hardware/SensorEvent#sensor.type_gyroscope - * for guidance on using this value to derive a device's orientation. - */ -#define RETRO_SENSOR_GYROSCOPE_Z 5 - -/** - * Returns the ambient illuminance (light intensity) of the device's environment, in lux. - * - * @see https://en.wikipedia.org/wiki/Lux for a table of common lux values. - */ -#define RETRO_SENSOR_ILLUMINANCE 6 -/** @} */ - -/** - * Adjusts the state of a sensor. - * - * @param port The device port of the controller that owns the sensor given in \c action. - * @param action The action to perform on the sensor. - * Different devices support different sensors. - * @param rate The rate at which the underlying sensor should be updated, in Hz. - * This should be treated as a hint, - * as some device sensors may not support the requested rate - * (if it's configurable at all). - * @returns \c true if the sensor state was successfully adjusted, \c false otherwise. - * @note If one of the \c RETRO_SENSOR_*_ENABLE actions fails, - * this likely means that the given sensor is not available - * on the provided \c port. - * @see retro_sensor_action - */ -typedef bool (RETRO_CALLCONV *retro_set_sensor_state_t)(unsigned port, - enum retro_sensor_action action, unsigned rate); - -/** - * Retrieves the current value reported by sensor. - * @param port The device port of the controller that owns the sensor given in \c id. - * @param id The sensor value to query. - * @returns The current sensor value. - * Exact semantics depend on the value given in \c id, - * but will return 0 for invalid arguments. - * - * @see RETRO_SENSOR_ID - */ -typedef float (RETRO_CALLCONV *retro_sensor_get_input_t)(unsigned port, unsigned id); - -/** - * An interface that cores can use to access device sensors. - * - * All function pointers are set by the frontend. - */ -struct retro_sensor_interface -{ - /** @copydoc retro_set_sensor_state_t */ - retro_set_sensor_state_t set_sensor_state; - - /** @copydoc retro_sensor_get_input_t */ - retro_sensor_get_input_t get_sensor_input; -}; - -/** @} */ - -/** @defgroup GET_CAMERA_INTERFACE Camera Interface - * @{ - */ - -/** - * Denotes the type of buffer in which the camera will store its input. - * - * Different camera drivers may support different buffer types. - * - * @see RETRO_ENVIRONMENT_GET_CAMERA_INTERFACE - * @see retro_camera_callback - */ -enum retro_camera_buffer -{ - /** - * Indicates that camera frames should be delivered to the core as an OpenGL texture. - * - * Requires that the core is using an OpenGL context via \c RETRO_ENVIRONMENT_SET_HW_RENDER. - * - * @see retro_camera_frame_opengl_texture_t - */ - RETRO_CAMERA_BUFFER_OPENGL_TEXTURE = 0, - - /** - * Indicates that camera frames should be delivered to the core as a raw buffer in memory. - * - * @see retro_camera_frame_raw_framebuffer_t - */ - RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER, - - /** - * @private Defined to ensure sizeof(enum retro_camera_buffer) == sizeof(int). - * Do not use. - */ - RETRO_CAMERA_BUFFER_DUMMY = INT_MAX -}; - -/** - * Starts an initialized camera. - * The camera is disabled by default, - * and must be enabled with this function before being used. - * - * Set by the frontend. - * - * @returns \c true if the camera was successfully started, \c false otherwise. - * Failure may occur if no actual camera is available, - * or if the frontend doesn't have permission to access it. - * @note Must be called in \c retro_run(). - * @see retro_camera_callback - */ -typedef bool (RETRO_CALLCONV *retro_camera_start_t)(void); - -/** - * Stops the running camera. - * - * Set by the frontend. - * - * @note Must be called in \c retro_run(). - * @warning The frontend may close the camera on its own when unloading the core, - * but this behavior is not guaranteed. - * Cores should clean up the camera before exiting. - * @see retro_camera_callback - */ -typedef void (RETRO_CALLCONV *retro_camera_stop_t)(void); - -/** - * Called by the frontend to report the state of the camera driver. - * - * @see retro_camera_callback - */ -typedef void (RETRO_CALLCONV *retro_camera_lifetime_status_t)(void); - -/** - * Called by the frontend to report a new camera frame, - * delivered as a raw buffer in memory. - * - * Set by the core. - * - * @param buffer Pointer to the camera's most recent video frame. - * Each pixel is in XRGB8888 format. - * The first pixel represents the top-left corner of the image - * (i.e. the Y axis goes downward). - * @param width The width of the frame given in \c buffer, in pixels. - * @param height The height of the frame given in \c buffer, in pixels. - * @param pitch The width of the frame given in \c buffer, in bytes. - * @warning \c buffer may be invalidated when this function returns, - * so the core should make its own copy of \c buffer if necessary. - * @see RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER - */ -typedef void (RETRO_CALLCONV *retro_camera_frame_raw_framebuffer_t)(const uint32_t *buffer, - unsigned width, unsigned height, size_t pitch); - -/** - * Called by the frontend to report a new camera frame, - * delivered as an OpenGL texture. - * - * @param texture_id The ID of the OpenGL texture that represents the camera's most recent frame. - * Owned by the frontend, and must not be modified by the core. - * @param texture_target The type of the texture given in \c texture_id. - * Usually either \c GL_TEXTURE_2D or \c GL_TEXTURE_RECTANGLE, - * but other types are allowed. - * @param affine A pointer to a 3x3 column-major affine matrix - * that can be used to transform pixel coordinates to texture coordinates. - * After transformation, the bottom-left corner should have coordinates of (0, 0) - * and the top-right corner should have coordinates of (1, 1) - * (or (width, height) for \c GL_TEXTURE_RECTANGLE). - * - * @note GL-specific typedefs (e.g. \c GLfloat and \c GLuint) are avoided here - * so that the API doesn't rely on gl.h. - * @warning \c texture_id and \c affine may be invalidated when this function returns, - * so the core should make its own copy of them if necessary. - */ -typedef void (RETRO_CALLCONV *retro_camera_frame_opengl_texture_t)(unsigned texture_id, - unsigned texture_target, const float *affine); - -/** - * An interface that the core can use to access a device's camera. - * - * @see RETRO_ENVIRONMENT_GET_CAMERA_INTERFACE - */ -struct retro_camera_callback -{ - /** - * Requested camera capabilities, - * given as a bitmask of \c retro_camera_buffer values. - * Set by the core. - * - * Here's a usage example: - * @code - * // Requesting support for camera data delivered as both an OpenGL texture and a pixel buffer: - * struct retro_camera_callback callback; - * callback.caps = (1 << RETRO_CAMERA_BUFFER_OPENGL_TEXTURE) | (1 << RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER); - * @endcode - */ - uint64_t caps; - - /** - * The desired width of the camera frame, in pixels. - * This is only a hint; the frontend may provide a different size. - * Set by the core. - * Use zero to let the frontend decide. - */ - unsigned width; - - /** - * The desired height of the camera frame, in pixels. - * This is only a hint; the frontend may provide a different size. - * Set by the core. - * Use zero to let the frontend decide. - */ - unsigned height; - - /** - * @copydoc retro_camera_start_t - * @see retro_camera_callback - */ - retro_camera_start_t start; - - /** - * @copydoc retro_camera_stop_t - * @see retro_camera_callback - */ - retro_camera_stop_t stop; - - /** - * @copydoc retro_camera_frame_raw_framebuffer_t - * @note If \c NULL, this function will not be called. - */ - retro_camera_frame_raw_framebuffer_t frame_raw_framebuffer; - - /** - * @copydoc retro_camera_frame_opengl_texture_t - * @note If \c NULL, this function will not be called. - */ - retro_camera_frame_opengl_texture_t frame_opengl_texture; - - /** - * Core-defined callback invoked by the frontend right after the camera driver is initialized - * (\em not when calling \c start). - * May be \c NULL, in which case this function is skipped. - */ - retro_camera_lifetime_status_t initialized; - - /** - * Core-defined callback invoked by the frontend - * right before the video camera driver is deinitialized - * (\em not when calling \c stop). - * May be \c NULL, in which case this function is skipped. - */ - retro_camera_lifetime_status_t deinitialized; -}; - -/** @} */ - -/** @defgroup GET_LOCATION_INTERFACE Location Interface - * @{ - */ - -/** @copydoc retro_location_callback::set_interval */ -typedef void (RETRO_CALLCONV *retro_location_set_interval_t)(unsigned interval_ms, - unsigned interval_distance); - -/** @copydoc retro_location_callback::start */ -typedef bool (RETRO_CALLCONV *retro_location_start_t)(void); - -/** @copydoc retro_location_callback::stop */ -typedef void (RETRO_CALLCONV *retro_location_stop_t)(void); - -/** @copydoc retro_location_callback::get_position */ -typedef bool (RETRO_CALLCONV *retro_location_get_position_t)(double *lat, double *lon, - double *horiz_accuracy, double *vert_accuracy); - -/** Function type that reports the status of the location service. */ -typedef void (RETRO_CALLCONV *retro_location_lifetime_status_t)(void); - -/** - * An interface that the core can use to access a device's location. - * - * @note It is the frontend's responsibility to request the necessary permissions - * from the operating system. - * @see RETRO_ENVIRONMENT_GET_LOCATION_INTERFACE - */ -struct retro_location_callback -{ - /** - * Starts listening the device's location service. - * - * The frontend will report changes to the device's location - * at the interval defined by \c set_interval. - * Set by the frontend. - * - * @return true if location services were successfully started, false otherwise. - * Note that this will return \c false if location services are disabled - * or the frontend doesn't have permission to use them. - * @note The device's location service may or may not have been enabled - * before the core calls this function. - */ - retro_location_start_t start; - - /** - * Stop listening to the device's location service. - * - * Set by the frontend. - * - * @note The location service itself may or may not - * be turned off by this function, - * depending on the platform and the frontend. - * @post The core will stop receiving location service updates. - */ - retro_location_stop_t stop; - - /** - * Returns the device's current coordinates. - * - * Set by the frontend. - * - * @param[out] lat Pointer to latitude, in degrees. - * Will be set to 0 if no change has occurred since the last call. - * Behavior is undefined if \c NULL. - * @param[out] lon Pointer to longitude, in degrees. - * Will be set to 0 if no change has occurred since the last call. - * Behavior is undefined if \c NULL. - * @param[out] horiz_accuracy Pointer to horizontal accuracy. - * Will be set to 0 if no change has occurred since the last call. - * Behavior is undefined if \c NULL. - * @param[out] vert_accuracy Pointer to vertical accuracy. - * Will be set to 0 if no change has occurred since the last call. - * Behavior is undefined if \c NULL. - */ - retro_location_get_position_t get_position; - - /** - * Sets the rate at which the location service should report updates. - * - * This is only a hint; the actual rate may differ. - * Sets the interval of time and/or distance at which to update/poll - * location-based data. - * - * Some platforms may only support one of the two parameters; - * cores should provide both to ensure compatibility. - * - * Set by the frontend. - * - * @param interval_ms The desired period of time between location updates, in milliseconds. - * @param interval_distance The desired distance between location updates, in meters. - */ - retro_location_set_interval_t set_interval; - - /** Called when the location service is initialized. Set by the core. Optional. */ - retro_location_lifetime_status_t initialized; - - /** Called when the location service is deinitialized. Set by the core. Optional. */ - retro_location_lifetime_status_t deinitialized; -}; - -/** @} */ - -/** @addtogroup GET_RUMBLE_INTERFACE - * @{ */ - -/** - * The type of rumble motor in a controller. - * - * Both motors can be controlled independently, - * and the strong motor does not override the weak motor. - * @see RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE - */ -enum retro_rumble_effect -{ - RETRO_RUMBLE_STRONG = 0, - RETRO_RUMBLE_WEAK = 1, - - /** @private Defined to ensure sizeof(enum retro_rumble_effect) == sizeof(int). Do not use. */ - RETRO_RUMBLE_DUMMY = INT_MAX -}; - -/** - * Requests a rumble state change for a controller. - * Set by the frontend. - * - * @param port The controller port to set the rumble state for. - * @param effect The rumble motor to set the strength of. - * @param strength The desired intensity of the rumble motor, ranging from \c 0 to \c 0xffff (inclusive). - * @return \c true if the requested rumble state was honored. - * If the controller doesn't support rumble, will return \c false. - * @note Calling this before the first \c retro_run() may return \c false. - * @see RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE - */ -typedef bool (RETRO_CALLCONV *retro_set_rumble_state_t)(unsigned port, - enum retro_rumble_effect effect, uint16_t strength); - -/** - * An interface that the core can use to set the rumble state of a controller. - * @see RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE - */ -struct retro_rumble_interface -{ - /** @copydoc retro_set_rumble_state_t */ - retro_set_rumble_state_t set_rumble_state; -}; - -/** @} */ - -/** - * Called by the frontend to request audio samples. - * The core should render audio within this function - * using the callback provided by \c retro_set_audio_sample or \c retro_set_audio_sample_batch. - * - * @warning This function may be called by any thread, - * therefore it must be thread-safe. - * @see RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK - * @see retro_audio_callback - * @see retro_audio_sample_batch_t - * @see retro_audio_sample_t - */ -typedef void (RETRO_CALLCONV *retro_audio_callback_t)(void); - -/** - * Called by the frontend to notify the core that it should pause or resume audio rendering. - * The initial state of the audio driver after registering this callback is \c false (inactive). - * - * @param enabled \c true if the frontend's audio driver is active. - * If so, the registered audio callback will be called regularly. - * If not, the audio callback will not be invoked until the next time - * the frontend calls this function with \c true. - * @warning This function may be called by any thread, - * therefore it must be thread-safe. - * @note Even if no audio samples are rendered, - * the core should continue to update its emulated platform's audio engine if necessary. - * @see RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK - * @see retro_audio_callback - * @see retro_audio_callback_t - */ -typedef void (RETRO_CALLCONV *retro_audio_set_state_callback_t)(bool enabled); - -/** - * An interface that the frontend uses to request audio samples from the core. - * @note To unregister a callback, pass a \c retro_audio_callback_t - * with both fields set to NULL. - * @see RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK - */ -struct retro_audio_callback -{ - /** @see retro_audio_callback_t */ - retro_audio_callback_t callback; - - /** @see retro_audio_set_state_callback_t */ - retro_audio_set_state_callback_t set_state; -}; - -typedef int64_t retro_usec_t; - -/** - * Called right before each iteration of \c retro_run - * if registered via RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK. - * - * @param usec Time since the last call to retro_run, in microseconds. - * If the frontend is manipulating the frame time - * (e.g. via fast-forward or slow motion), - * this value will be the reference value initially provided to the environment call. - * @see RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK - * @see retro_frame_time_callback - */ -typedef void (RETRO_CALLCONV *retro_frame_time_callback_t)(retro_usec_t usec); - -/** - * @see RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK - */ -struct retro_frame_time_callback -{ - /** - * Called to notify the core of the current frame time. - * If NULL, the frontend will clear its registered callback. - */ - retro_frame_time_callback_t callback; - - /** - * The ideal duration of one frame, in microseconds. - * Compute it as 1000000 / fps. - * The frontend will resolve rounding to ensure that framestepping, etc is exact. - */ - retro_usec_t reference; -}; - -/** @defgroup SET_AUDIO_BUFFER_STATUS_CALLBACK Audio Buffer Occupancy - * @{ - */ - -/** - * Notifies a libretro core of how full the frontend's audio buffer is. - * Set by the core, called by the frontend. - * It will be called right before \c retro_run() every frame. - * - * @param active \c true if the frontend's audio buffer is currently in use, - * \c false if audio is disabled in the frontend. - * @param occupancy A value between 0 and 100 (inclusive), - * corresponding to the frontend's audio buffer occupancy percentage. - * @param underrun_likely \c true if the frontend expects an audio buffer underrun - * during the next frame, which indicates that a core should attempt frame-skipping. - */ -typedef void (RETRO_CALLCONV *retro_audio_buffer_status_callback_t)( - bool active, unsigned occupancy, bool underrun_likely); - -/** - * A callback to register with the frontend to receive audio buffer occupancy information. - */ -struct retro_audio_buffer_status_callback -{ - /** @copydoc retro_audio_buffer_status_callback_t */ - retro_audio_buffer_status_callback_t callback; -}; - -/** @} */ - -/* Pass this to retro_video_refresh_t if rendering to hardware. - * Passing NULL to retro_video_refresh_t is still a frame dupe as normal. - * */ -#define RETRO_HW_FRAME_BUFFER_VALID ((void*)-1) - -/* Invalidates the current HW context. - * Any GL state is lost, and must not be deinitialized explicitly. - * If explicit deinitialization is desired by the libretro core, - * it should implement context_destroy callback. - * If called, all GPU resources must be reinitialized. - * Usually called when frontend reinits video driver. - * Also called first time video driver is initialized, - * allowing libretro core to initialize resources. - */ -typedef void (RETRO_CALLCONV *retro_hw_context_reset_t)(void); - -/* Gets current framebuffer which is to be rendered to. - * Could change every frame potentially. - */ -typedef uintptr_t (RETRO_CALLCONV *retro_hw_get_current_framebuffer_t)(void); - -/* Get a symbol from HW context. */ -typedef retro_proc_address_t (RETRO_CALLCONV *retro_hw_get_proc_address_t)(const char *sym); - -enum retro_hw_context_type -{ - RETRO_HW_CONTEXT_NONE = 0, - /* OpenGL 2.x. Driver can choose to use latest compatibility context. */ - RETRO_HW_CONTEXT_OPENGL = 1, - /* OpenGL ES 2.0. */ - RETRO_HW_CONTEXT_OPENGLES2 = 2, - /* Modern desktop core GL context. Use version_major/ - * version_minor fields to set GL version. */ - RETRO_HW_CONTEXT_OPENGL_CORE = 3, - /* OpenGL ES 3.0 */ - RETRO_HW_CONTEXT_OPENGLES3 = 4, - /* OpenGL ES 3.1+. Set version_major/version_minor. For GLES2 and GLES3, - * use the corresponding enums directly. */ - RETRO_HW_CONTEXT_OPENGLES_VERSION = 5, - - /* Vulkan, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE. */ - RETRO_HW_CONTEXT_VULKAN = 6, - - /* Direct3D11, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ - RETRO_HW_CONTEXT_D3D11 = 7, - - /* Direct3D10, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ - RETRO_HW_CONTEXT_D3D10 = 8, - - /* Direct3D12, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ - RETRO_HW_CONTEXT_D3D12 = 9, - - /* Direct3D9, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ - RETRO_HW_CONTEXT_D3D9 = 10, - - /** Dummy value to ensure sizeof(enum retro_hw_context_type) == sizeof(int). Do not use. */ - RETRO_HW_CONTEXT_DUMMY = INT_MAX -}; - -struct retro_hw_render_callback -{ - /* Which API to use. Set by libretro core. */ - enum retro_hw_context_type context_type; - - /* Called when a context has been created or when it has been reset. - * An OpenGL context is only valid after context_reset() has been called. - * - * When context_reset is called, OpenGL resources in the libretro - * implementation are guaranteed to be invalid. - * - * It is possible that context_reset is called multiple times during an - * application lifecycle. - * If context_reset is called without any notification (context_destroy), - * the OpenGL context was lost and resources should just be recreated - * without any attempt to "free" old resources. - */ - retro_hw_context_reset_t context_reset; - - /* Set by frontend. - * TODO: This is rather obsolete. The frontend should not - * be providing preallocated framebuffers. */ - retro_hw_get_current_framebuffer_t get_current_framebuffer; - - /* Set by frontend. - * Can return all relevant functions, including glClear on Windows. */ - retro_hw_get_proc_address_t get_proc_address; - - /* Set if render buffers should have depth component attached. - * TODO: Obsolete. */ - bool depth; - - /* Set if stencil buffers should be attached. - * TODO: Obsolete. */ - bool stencil; - - /* If depth and stencil are true, a packed 24/8 buffer will be added. - * Only attaching stencil is invalid and will be ignored. */ - - /* Use conventional bottom-left origin convention. If false, - * standard libretro top-left origin semantics are used. - * TODO: Move to GL specific interface. */ - bool bottom_left_origin; - - /* Major version number for core GL context or GLES 3.1+. */ - unsigned version_major; - - /* Minor version number for core GL context or GLES 3.1+. */ - unsigned version_minor; - - /* If this is true, the frontend will go very far to avoid - * resetting context in scenarios like toggling fullscreen, etc. - * TODO: Obsolete? Maybe frontend should just always assume this ... - */ - bool cache_context; - - /* The reset callback might still be called in extreme situations - * such as if the context is lost beyond recovery. - * - * For optimal stability, set this to false, and allow context to be - * reset at any time. - */ - - /* A callback to be called before the context is destroyed in a - * controlled way by the frontend. */ - retro_hw_context_reset_t context_destroy; - - /* OpenGL resources can be deinitialized cleanly at this step. - * context_destroy can be set to NULL, in which resources will - * just be destroyed without any notification. - * - * Even when context_destroy is non-NULL, it is possible that - * context_reset is called without any destroy notification. - * This happens if context is lost by external factors (such as - * notified by GL_ARB_robustness). - * - * In this case, the context is assumed to be already dead, - * and the libretro implementation must not try to free any OpenGL - * resources in the subsequent context_reset. - */ - - /* Creates a debug context. */ - bool debug_context; -}; - -/* Callback type passed in RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK. - * Called by the frontend in response to keyboard events. - * down is set if the key is being pressed, or false if it is being released. - * keycode is the RETROK value of the char. - * character is the text character of the pressed key. (UTF-32). - * key_modifiers is a set of RETROKMOD values or'ed together. - * - * The pressed/keycode state can be independent of the character. - * It is also possible that multiple characters are generated from a - * single keypress. - * Keycode events should be treated separately from character events. - * However, when possible, the frontend should try to synchronize these. - * If only a character is posted, keycode should be RETROK_UNKNOWN. - * - * Similarly if only a keycode event is generated with no corresponding - * character, character should be 0. - */ -typedef void (RETRO_CALLCONV *retro_keyboard_event_t)(bool down, unsigned keycode, - uint32_t character, uint16_t key_modifiers); - -struct retro_keyboard_callback -{ - retro_keyboard_event_t callback; -}; - -/** @defgroup SET_DISK_CONTROL_INTERFACE Disk Control - * - * Callbacks for inserting and removing disks from the emulated console at runtime. - * Should be provided by cores that support doing so. - * Cores should automate this process if possible, - * but some cases require the player's manual input. - * - * The steps for swapping disk images are generally as follows: - * - * \li Eject the emulated console's disk drive with \c set_eject_state(true). - * \li Insert the new disk image with \c set_image_index(index). - * \li Close the virtual disk tray with \c set_eject_state(false). - * - * @{ - */ - -/** - * Called by the frontend to open or close the emulated console's virtual disk tray. - * - * The frontend may only set the disk image index - * while the emulated tray is opened. - * - * If the emulated console's disk tray is already in the state given by \c ejected, - * then this function should return \c true without doing anything. - * The core should return \c false if it couldn't change the disk tray's state; - * this may happen if the console itself limits when the disk tray can be open or closed - * (e.g. to wait for the disc to stop spinning). - * - * @param ejected \c true if the virtual disk tray should be "ejected", - * \c false if it should be "closed". - * @return \c true if the virtual disk tray's state has been set to the given state, - * false if there was an error. - * @see retro_get_eject_state_t - */ -typedef bool (RETRO_CALLCONV *retro_set_eject_state_t)(bool ejected); - -/** - * Gets the current ejected state of the disk drive. - * The initial state is closed, i.e. \c false. - * - * @return \c true if the virtual disk tray is "ejected", - * i.e. it's open and a disk can be inserted. - * @see retro_set_eject_state_t - */ -typedef bool (RETRO_CALLCONV *retro_get_eject_state_t)(void); - -/** - * Gets the index of the current disk image, - * as determined by however the frontend orders disk images - * (such as m3u-formatted playlists or special directories). - * - * @return The index of the current disk image - * (starting with 0 for the first disk), - * or a value greater than or equal to \c get_num_images() if no disk is inserted. - * @see retro_get_num_images_t - */ -typedef unsigned (RETRO_CALLCONV *retro_get_image_index_t)(void); - -/** - * Inserts the disk image at the given index into the emulated console's drive. - * Can only be called while the disk tray is ejected - * (i.e. \c retro_get_eject_state_t returns \c true). - * - * If the emulated disk tray is ejected - * and already contains the disk image named by \c index, - * then this function should do nothing and return \c true. - * - * @param index The index of the disk image to insert, - * starting from 0 for the first disk. - * A value greater than or equal to \c get_num_images() - * represents the frontend removing the disk without inserting a new one. - * @return \c true if the disk image was successfully set. - * \c false if the disk tray isn't ejected or there was another error - * inserting a new disk image. - */ -typedef bool (RETRO_CALLCONV *retro_set_image_index_t)(unsigned index); - -/** - * @return The number of disk images which are available to use. - * These are most likely defined in a playlist file. - */ -typedef unsigned (RETRO_CALLCONV *retro_get_num_images_t)(void); - -struct retro_game_info; - -/** - * Replaces the disk image at the given index with a new disk. - * - * Replaces the disk image associated with index. - * Arguments to pass in info have same requirements as retro_load_game(). - * Virtual disk tray must be ejected when calling this. - * - * Passing \c NULL to this function indicates - * that the frontend has removed this disk image from its internal list. - * As a result, calls to this function can change the number of available disk indexes. - * - * For example, calling replace_image_index(1, NULL) - * will remove the disk image at index 1, - * and the disk image at index 2 (if any) - * will be moved to the newly-available index 1. - * - * @param index The index of the disk image to replace. - * @param info Details about the new disk image, - * or \c NULL if the disk image at the given index should be discarded. - * The semantics of each field are the same as in \c retro_load_game. - * @return \c true if the disk image was successfully replaced - * or removed from the playlist, - * \c false if the tray is not ejected - * or if there was an error. - */ -typedef bool (RETRO_CALLCONV *retro_replace_image_index_t)(unsigned index, - const struct retro_game_info *info); - -/** - * Adds a new index to the core's internal disk list. - * This will increment the return value from \c get_num_images() by 1. - * This image index cannot be used until a disk image has been set - * with \c replace_image_index. - * - * @return \c true if the core has added space for a new disk image - * and is ready to receive one. - */ -typedef bool (RETRO_CALLCONV *retro_add_image_index_t)(void); - -/** - * Sets the disk image that will be inserted into the emulated disk drive - * before \c retro_load_game is called. - * - * \c retro_load_game does not provide a way to ensure - * that a particular disk image in a playlist is inserted into the console; - * this function makes up for that. - * Frontends should call it immediately before \c retro_load_game, - * and the core should use the arguments - * to validate the disk image in \c retro_load_game. - * - * When content is loaded, the core should verify that the - * disk specified by \c index can be found at \c path. - * This is to guard against auto-selecting the wrong image - * if (for example) the user should modify an existing M3U playlist. - * We have to let the core handle this because - * \c set_initial_image() must be called before loading content, - * i.e. the frontend cannot access image paths in advance - * and thus cannot perform the error check itself. - * If \c index is invalid (i.e. index >= get_num_images()) - * or the disk image doesn't match the value given in \c path, - * the core should ignore the arguments - * and insert the disk at index 0 into the virtual disk tray. - * - * @warning If \c RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE is called within \c retro_load_game, - * then this function may not be executed. - * Set the disk control interface in \c retro_init if possible. - * - * @param index The index of the disk image within the playlist to set. - * @param path The path of the disk image to set as the first. - * The core should not load this path immediately; - * instead, it should use it within \c retro_load_game - * to verify that the correct disk image was loaded. - * @return \c true if the initial disk index was set, - * \c false if the arguments are invalid - * or the core doesn't support this function. - */ -typedef bool (RETRO_CALLCONV *retro_set_initial_image_t)(unsigned index, const char *path); - -/** - * Returns the path of the disk image at the given index - * on the host's file system. - * - * @param index The index of the disk image to get the path of. - * @param s A buffer to store the path in. - * @param len The size of \c s, in bytes. - * @return \c true if the disk image's location was successfully - * queried and copied into \c s, - * \c false if the index is invalid - * or the core couldn't locate the disk image. - */ -typedef bool (RETRO_CALLCONV *retro_get_image_path_t)(unsigned index, char *s, size_t len); - -/** - * Returns a friendly label for the given disk image. - * - * In the simplest case, this may be the disk image's file name - * with the extension omitted. - * For cores or games with more complex content requirements, - * the label can be used to provide information to help the player - * select a disk image to insert; - * for example, a core may label different kinds of disks - * (save data, level disk, installation disk, bonus content, etc.). - * with names that correspond to in-game prompts, - * so that the frontend can provide better guidance to the player. - * - * @param index The index of the disk image to return a label for. - * @param s A buffer to store the resulting label in. - * @param len The length of \c s, in bytes. - * @return \c true if the disk image at \c index is valid - * and a label was copied into \c s. - */ -typedef bool (RETRO_CALLCONV *retro_get_image_label_t)(unsigned index, char *s, size_t len); - -/** - * An interface that the frontend can use to exchange disks - * within the emulated console's disk drive. - * - * All function pointers are required. - * - * @deprecated This struct is superseded by \ref retro_disk_control_ext_callback. - * Only use this one to maintain compatibility - * with older cores and frontends. - * - * @see RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE - * @see retro_disk_control_ext_callback - */ -struct retro_disk_control_callback -{ - /** @copydoc retro_set_eject_state_t */ - retro_set_eject_state_t set_eject_state; - - /** @copydoc retro_get_eject_state_t */ - retro_get_eject_state_t get_eject_state; - - /** @copydoc retro_get_image_index_t */ - retro_get_image_index_t get_image_index; - - /** @copydoc retro_set_image_index_t */ - retro_set_image_index_t set_image_index; - - /** @copydoc retro_get_num_images_t */ - retro_get_num_images_t get_num_images; - - /** @copydoc retro_replace_image_index_t */ - retro_replace_image_index_t replace_image_index; - - /** @copydoc retro_add_image_index_t */ - retro_add_image_index_t add_image_index; -}; - -/** - * @copybrief retro_disk_control_callback - * - * All function pointers are required unless otherwise noted. - * - * @see RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE - */ -struct retro_disk_control_ext_callback -{ - /** @copydoc retro_set_eject_state_t */ - retro_set_eject_state_t set_eject_state; - - /** @copydoc retro_get_eject_state_t */ - retro_get_eject_state_t get_eject_state; - - /** @copydoc retro_get_image_index_t */ - retro_get_image_index_t get_image_index; - - /** @copydoc retro_set_image_index_t */ - retro_set_image_index_t set_image_index; - - /** @copydoc retro_get_num_images_t */ - retro_get_num_images_t get_num_images; - - /** @copydoc retro_replace_image_index_t */ - retro_replace_image_index_t replace_image_index; - - /** @copydoc retro_add_image_index_t */ - retro_add_image_index_t add_image_index; - - /** @copydoc retro_set_initial_image_t - * - * Optional; not called if \c NULL. - * - * @note The frontend will only try to record/restore the last-used disk index - * if both \c set_initial_image and \c get_image_path are implemented. - */ - retro_set_initial_image_t set_initial_image; - - /** - * @copydoc retro_get_image_path_t - * - * Optional; not called if \c NULL. - */ - retro_get_image_path_t get_image_path; - - /** - * @copydoc retro_get_image_label_t - * - * Optional; not called if \c NULL. - */ - retro_get_image_label_t get_image_label; -}; - -/** @} */ - -/* Definitions for RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE. - * A core can set it if sending and receiving custom network packets - * during a multiplayer session is desired. - */ - -/* Netpacket flags for retro_netpacket_send_t */ -#define RETRO_NETPACKET_UNRELIABLE 0 /* Packet to be sent unreliable, depending on network quality it might not arrive. */ -#define RETRO_NETPACKET_RELIABLE (1 << 0) /* Reliable packets are guaranteed to arrive at the target in the order they were sent. */ -#define RETRO_NETPACKET_UNSEQUENCED (1 << 1) /* Packet will not be sequenced with other packets and may arrive out of order. Cannot be set on reliable packets. */ -#define RETRO_NETPACKET_FLUSH_HINT (1 << 2) /* Request the packet and any previously buffered ones to be sent immediately */ - -/* Broadcast client_id for retro_netpacket_send_t */ -#define RETRO_NETPACKET_BROADCAST 0xFFFF - -/* Used by the core to send a packet to one or all connected players. - * A single packet sent via this interface can contain up to 64 KB of data. - * - * The client_id RETRO_NETPACKET_BROADCAST sends the packet as a broadcast to - * all connected players. This is supported from the host as well as clients. -* Otherwise, the argument indicates the player to send the packet to. - * - * A frontend must support sending reliable packets (RETRO_NETPACKET_RELIABLE). - * Unreliable packets might not be supported by the frontend, but the flags can - * still be specified. Reliable transmission will be used instead. - * - * Calling this with the flag RETRO_NETPACKET_FLUSH_HINT will send off the - * packet and any previously buffered ones immediately and without blocking. - * To only flush previously queued packets, buf or len can be passed as NULL/0. - * - * This function is not guaranteed to be thread-safe and must be called during - * retro_run or any of the netpacket callbacks passed with this interface. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_send_t)(int flags, const void* buf, size_t len, uint16_t client_id); - -/* Optionally read any incoming packets without waiting for the end of the - * frame. While polling, retro_netpacket_receive_t and retro_netpacket_stop_t - * can be called. The core can perform this in a loop to do a blocking read, - * i.e., wait for incoming data, but needs to handle stop getting called and - * also give up after a short while to avoid freezing on a connection problem. - * It is a good idea to manually flush outgoing packets before calling this. - * - * This function is not guaranteed to be thread-safe and must be called during - * retro_run or any of the netpacket callbacks passed with this interface. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_poll_receive_t)(void); - -/* Called by the frontend to signify that a multiplayer session has started. - * If client_id is 0 the local player is the host of the session and at this - * point no other player has connected yet. - * - * If client_id is > 0 the local player is a client connected to a host and - * at this point is already fully connected to the host. - * - * The core must store the function pointer send_fn and use it whenever it - * wants to send a packet. Optionally poll_receive_fn can be stored and used - * when regular receiving between frames is not enough. These function pointers - * remain valid until the frontend calls retro_netpacket_stop_t. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_start_t)(uint16_t client_id, retro_netpacket_send_t send_fn, retro_netpacket_poll_receive_t poll_receive_fn); - -/* Called by the frontend when a new packet arrives which has been sent from - * another player with retro_netpacket_send_t. The client_id argument indicates - * who has sent the packet. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_receive_t)(const void* buf, size_t len, uint16_t client_id); - -/* Called by the frontend when the multiplayer session has ended. - * Once this gets called the function pointers passed to - * retro_netpacket_start_t will not be valid anymore. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_stop_t)(void); - -/* Called by the frontend every frame (between calls to retro_run while - * updating the state of the multiplayer session. - * This is a good place for the core to call retro_netpacket_send_t from. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_poll_t)(void); - -/* Called by the frontend when a new player connects to the hosted session. - * This is only called on the host side, not for clients connected to the host. - * If this function returns false, the newly connected player gets dropped. - * This can be used for example to limit the number of players. - */ -typedef bool (RETRO_CALLCONV *retro_netpacket_connected_t)(uint16_t client_id); - -/* Called by the frontend when a player leaves or disconnects from the hosted session. - * This is only called on the host side, not for clients connected to the host. - */ -typedef void (RETRO_CALLCONV *retro_netpacket_disconnected_t)(uint16_t client_id); - -/** - * A callback interface for giving a core the ability to send and receive custom - * network packets during a multiplayer session between two or more instances - * of a libretro frontend. - * - * Normally during connection handshake the frontend will compare library_version - * used by both parties and show a warning if there is a difference. When the core - * supplies protocol_version, the frontend will check against this instead. - * - * @see RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE - */ -struct retro_netpacket_callback -{ - retro_netpacket_start_t start; - retro_netpacket_receive_t receive; - retro_netpacket_stop_t stop; /* Optional - may be NULL */ - retro_netpacket_poll_t poll; /* Optional - may be NULL */ - retro_netpacket_connected_t connected; /* Optional - may be NULL */ - retro_netpacket_disconnected_t disconnected; /* Optional - may be NULL */ - const char* protocol_version; /* Optional - if not NULL will be used instead of core version to decide if communication is compatible */ -}; - -/** - * The pixel format used for rendering. - * @see RETRO_ENVIRONMENT_SET_PIXEL_FORMAT - */ -enum retro_pixel_format -{ - /** - * 0RGB1555, native endian. - * Used as the default if \c RETRO_ENVIRONMENT_SET_PIXEL_FORMAT is not called. - * The most significant bit must be set to 0. - * @deprecated This format remains supported to maintain compatibility. - * New code should use RETRO_PIXEL_FORMAT_RGB565 instead. - * @see RETRO_PIXEL_FORMAT_RGB565 - */ - RETRO_PIXEL_FORMAT_0RGB1555 = 0, - - /** - * XRGB8888, native endian. - * The most significant byte (the X) is ignored. - */ - RETRO_PIXEL_FORMAT_XRGB8888 = 1, - - /** - * RGB565, native endian. - * This format is recommended if 16-bit pixels are desired, - * as it is available on a variety of devices and APIs. - */ - RETRO_PIXEL_FORMAT_RGB565 = 2, - - /** Defined to ensure that sizeof(retro_pixel_format) == sizeof(int). Do not use. */ - RETRO_PIXEL_FORMAT_UNKNOWN = INT_MAX -}; - -/** @defgroup GET_SAVESTATE_CONTEXT Savestate Context - * @{ - */ - -/** - * Details about how the frontend will use savestates. - * - * @see RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT - * @see retro_serialize - */ -enum retro_savestate_context -{ - /** - * Standard savestate written to disk. - * May be loaded at any time, - * even in a separate session or on another device. - * - * Should not contain any pointers to code or data. - */ - RETRO_SAVESTATE_CONTEXT_NORMAL = 0, - - /** - * The savestate is guaranteed to be loaded - * within the same session, address space, and binary. - * Will not be written to disk or sent over the network; - * therefore, internal pointers to code or data are acceptable. - * May still be loaded or saved at any time. - * - * @note This context generally implies the use of runahead or rewinding, - * which may work by taking savestates multiple times per second. - * Savestate code that runs in this context should be fast. - */ - RETRO_SAVESTATE_CONTEXT_RUNAHEAD_SAME_INSTANCE = 1, - - /** - * The savestate is guaranteed to be loaded - * in the same session and by the same binary, - * but possibly by a different address space - * (e.g. for "second instance" runahead) - * - * Will not be written to disk or sent over the network, - * but may be loaded in a different address space. - * Therefore, the savestate must not contain pointers. - */ - RETRO_SAVESTATE_CONTEXT_RUNAHEAD_SAME_BINARY = 2, - - /** - * The savestate will not be written to disk, - * but no other guarantees are made. - * The savestate will almost certainly be loaded - * by a separate binary, device, and address space. - * - * This context is intended for use with frontends that support rollback netplay. - * Serialized state should omit any data that would unnecessarily increase bandwidth usage. - * Must not contain pointers, and integers must be saved in big-endian format. - * @see retro_endianness.h - * @see network_stream - */ - RETRO_SAVESTATE_CONTEXT_ROLLBACK_NETPLAY = 3, - - /** - * @private Defined to ensure sizeof(retro_savestate_context) == sizeof(int). - * Do not use. - */ - RETRO_SAVESTATE_CONTEXT_UNKNOWN = INT_MAX -}; - -/** @} */ - -/** @defgroup SET_MESSAGE User-Visible Messages - * - * @{ - */ - -/** - * Defines a message that the frontend will display to the user, - * as determined by RETRO_ENVIRONMENT_SET_MESSAGE. - * - * @deprecated This struct is superseded by \ref retro_message_ext, - * which provides more control over how a message is presented. - * Only use it for compatibility with older cores and frontends. - * - * @see RETRO_ENVIRONMENT_SET_MESSAGE - * @see retro_message_ext - */ -struct retro_message -{ - /** - * Null-terminated message to be displayed. - * If \c NULL or empty, the message will be ignored. - */ - const char *msg; - - /** Duration to display \c msg in frames. */ - unsigned frames; -}; - -/** - * The method that the frontend will use to display a message to the player. - * @see retro_message_ext - */ -enum retro_message_target -{ - /** - * Indicates that the frontend should display the given message - * using all other targets defined by \c retro_message_target at once. - */ - RETRO_MESSAGE_TARGET_ALL = 0, - - /** - * Indicates that the frontend should display the given message - * using the frontend's on-screen display, if available. - * - * @attention If the frontend allows players to customize or disable notifications, - * then they may not see messages sent to this target. - */ - RETRO_MESSAGE_TARGET_OSD, - - /** - * Indicates that the frontend should log the message - * via its usual logging mechanism, if available. - * - * This is not intended to be a substitute for \c RETRO_ENVIRONMENT_SET_LOG_INTERFACE. - * It is intended for the common use case of - * logging a player-facing message. - * - * This target should not be used for messages - * of type \c RETRO_MESSAGE_TYPE_STATUS or \c RETRO_MESSAGE_TYPE_PROGRESS, - * as it may add unnecessary noise to a log file. - * - * @see RETRO_ENVIRONMENT_SET_LOG_INTERFACE - */ - RETRO_MESSAGE_TARGET_LOG -}; - -/** - * A broad category for the type of message that the frontend will display. - * - * Each message type has its own use case, - * therefore the frontend should present each one differently. - * - * @note This is a hint that the frontend may ignore. - * The frontend should fall back to \c RETRO_MESSAGE_TYPE_NOTIFICATION - * for message types that it doesn't support. - */ -enum retro_message_type -{ - /** - * A standard on-screen message. - * - * Suitable for a variety of use cases, - * such as messages about errors - * or other important events. - * - * Frontends that display their own messages - * should display this type of core-generated message the same way. - */ - RETRO_MESSAGE_TYPE_NOTIFICATION = 0, - - /** - * An on-screen message that should be visually distinct - * from \c RETRO_MESSAGE_TYPE_NOTIFICATION messages. - * - * The exact meaning of "visually distinct" is left to the frontend, - * but this usually implies that the frontend shows the message - * in a way that it doesn't typically use for its own notices. - */ - RETRO_MESSAGE_TYPE_NOTIFICATION_ALT, - - /** - * Indicates a frequently-updated status display, - * rather than a standard notification. - * Status messages are intended to be displayed permanently while a core is running - * in a way that doesn't suggest user action is required. - * - * Here are some possible use cases for status messages: - * - * @li An internal framerate counter. - * @li Debugging information. - * Remember to let the player disable it in the core options. - * @li Core-specific state, such as when a microphone is active. - * - * The status message is displayed for the given duration, - * unless another status message of equal or greater priority is shown. - */ - RETRO_MESSAGE_TYPE_STATUS, - - /** - * Denotes a message that reports the progress - * of a long-running asynchronous task, - * such as when a core loads large files from disk or the network. - * - * The frontend should display messages of this type as a progress bar - * (or a progress spinner for indefinite tasks), - * where \c retro_message_ext::msg is the progress bar's title - * and \c retro_message_ext::progress sets the progress bar's length. - * - * This message type shouldn't be used for tasks that are expected to complete quickly. - */ - RETRO_MESSAGE_TYPE_PROGRESS -}; - -/** - * A core-provided message that the frontend will display to the player. - * - * @note The frontend is encouraged store these messages in a queue. - * However, it should not empty the queue of core-submitted messages upon exit; - * if a core exits with an error, it may want to use this API - * to show an error message to the player. - * - * The frontend should maintain its own copy of the submitted message - * and all subobjects, including strings. - * - * @see RETRO_ENVIRONMENT_SET_MESSAGE_EXT - */ -struct retro_message_ext -{ - /** - * The \c NULL-terminated text of a message to show to the player. - * Must not be \c NULL. - * - * @note The frontend must honor newlines in this string - * when rendering text to \c RETRO_MESSAGE_TARGET_OSD. - */ - const char *msg; - - /** - * The duration that \c msg will be displayed on-screen, in milliseconds. - * - * Ignored for \c RETRO_MESSAGE_TARGET_LOG. - */ - unsigned duration; - - /** - * The relative importance of this message - * when targeting \c RETRO_MESSAGE_TARGET_OSD. - * Higher values indicate higher priority. - * - * The frontend should use this to prioritize messages - * when it can't show all active messages at once, - * or to remove messages from its queue if it's full. - * - * The relative display order of messages with the same priority - * is left to the frontend's discretion, - * although we suggest breaking ties - * in favor of the most recently-submitted message. - * - * Frontends may handle deprioritized messages at their discretion; - * such messages may have their \c duration altered, - * be hidden without being delayed, - * or even be discarded entirely. - * - * @note In the reference frontend (RetroArch), - * the same priority values are used for frontend-generated notifications, - * which are typically between 0 and 3 depending upon importance. - * - * Ignored for \c RETRO_MESSAGE_TARGET_LOG. - */ - unsigned priority; - - /** - * The severity level of this message. - * - * The frontend may use this to filter or customize messages - * depending on the player's preferences. - * Here are some ideas: - * - * @li Use this to prioritize errors and warnings - * over higher-ranking info and debug messages. - * @li Render warnings or errors with extra visual feedback, - * e.g. with brighter colors or accompanying sound effects. - * - * @see RETRO_ENVIRONMENT_SET_LOG_INTERFACE - */ - enum retro_log_level level; - - /** - * The intended destination of this message. - * - * @see retro_message_target - */ - enum retro_message_target target; - - /** - * The intended semantics of this message. - * - * Ignored for \c RETRO_MESSAGE_TARGET_LOG. - * - * @see retro_message_type - */ - enum retro_message_type type; - - /** - * The progress of an asynchronous task. - * - * A value between 0 and 100 (inclusive) indicates the task's percentage, - * and a value of -1 indicates a task of unknown completion. - * - * @note Since message type is a hint, a frontend may ignore progress values. - * Where relevant, a core should include progress percentage within the message string, - * such that the message intent remains clear when displayed - * as a standard frontend-generated notification. - * - * Ignored for \c RETRO_MESSAGE_TARGET_LOG and for - * message types other than \c RETRO_MESSAGE_TYPE_PROGRESS. - */ - int8_t progress; -}; - -/** @} */ - -/* Describes how the libretro implementation maps a libretro input bind - * to its internal input system through a human readable string. - * This string can be used to better let a user configure input. */ -struct retro_input_descriptor -{ - /* Associates given parameters with a description. */ - unsigned port; - unsigned device; - unsigned index; - unsigned id; - - /* Human readable description for parameters. - * The pointer must remain valid until - * retro_unload_game() is called. */ - const char *description; -}; - -/** - * Contains basic information about the core. - * - * @see retro_get_system_info - * @warning All pointers are owned by the core - * and must remain valid throughout its lifetime. - */ -struct retro_system_info -{ - /** - * Descriptive name of the library. - * - * @note Should not contain any version numbers, etc. - */ - const char *library_name; - - /** - * Descriptive version of the core. - */ - const char *library_version; - - /** - * A pipe-delimited string list of file extensions that this core can load, e.g. "bin|rom|iso". - * Typically used by a frontend for filtering or core selection. - */ - const char *valid_extensions; - - /* Libretro cores that need to have direct access to their content - * files, including cores which use the path of the content files to - * determine the paths of other files, should set need_fullpath to true. - * - * Cores should strive for setting need_fullpath to false, - * as it allows the frontend to perform patching, etc. - * - * If need_fullpath is true and retro_load_game() is called: - * - retro_game_info::path is guaranteed to have a valid path - * - retro_game_info::data and retro_game_info::size are invalid - * - * If need_fullpath is false and retro_load_game() is called: - * - retro_game_info::path may be NULL - * - retro_game_info::data and retro_game_info::size are guaranteed - * to be valid - * - * See also: - * - RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY - * - RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY - */ - bool need_fullpath; - - /* If true, the frontend is not allowed to extract any archives before - * loading the real content. - * Necessary for certain libretro implementations that load games - * from zipped archives. */ - bool block_extract; -}; - -/* Defines overrides which modify frontend handling of - * specific content file types. - * An array of retro_system_content_info_override is - * passed to RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE - * NOTE: In the following descriptions, references to - * retro_load_game() may be replaced with - * retro_load_game_special() */ -struct retro_system_content_info_override -{ - /* A list of file extensions for which the override - * should apply, delimited by a 'pipe' character - * (e.g. "md|sms|gg") - * Permitted file extensions are limited to those - * included in retro_system_info::valid_extensions - * and/or retro_subsystem_rom_info::valid_extensions */ - const char *extensions; - - /* Overrides the need_fullpath value set in - * retro_system_info and/or retro_subsystem_rom_info. - * To reiterate: - * - * If need_fullpath is true and retro_load_game() is called: - * - retro_game_info::path is guaranteed to contain a valid - * path to an existent file - * - retro_game_info::data and retro_game_info::size are invalid - * - * If need_fullpath is false and retro_load_game() is called: - * - retro_game_info::path may be NULL - * - retro_game_info::data and retro_game_info::size are guaranteed - * to be valid - * - * In addition: - * - * If need_fullpath is true and retro_load_game() is called: - * - retro_game_info_ext::full_path is guaranteed to contain a valid - * path to an existent file - * - retro_game_info_ext::archive_path may be NULL - * - retro_game_info_ext::archive_file may be NULL - * - retro_game_info_ext::dir is guaranteed to contain a valid path - * to the directory in which the content file exists - * - retro_game_info_ext::name is guaranteed to contain the - * basename of the content file, without extension - * - retro_game_info_ext::ext is guaranteed to contain the - * extension of the content file in lower case format - * - retro_game_info_ext::data and retro_game_info_ext::size - * are invalid - * - * If need_fullpath is false and retro_load_game() is called: - * - If retro_game_info_ext::file_in_archive is false: - * - retro_game_info_ext::full_path is guaranteed to contain - * a valid path to an existent file - * - retro_game_info_ext::archive_path may be NULL - * - retro_game_info_ext::archive_file may be NULL - * - retro_game_info_ext::dir is guaranteed to contain a - * valid path to the directory in which the content file exists - * - retro_game_info_ext::name is guaranteed to contain the - * basename of the content file, without extension - * - retro_game_info_ext::ext is guaranteed to contain the - * extension of the content file in lower case format - * - If retro_game_info_ext::file_in_archive is true: - * - retro_game_info_ext::full_path may be NULL - * - retro_game_info_ext::archive_path is guaranteed to - * contain a valid path to an existent compressed file - * inside which the content file is located - * - retro_game_info_ext::archive_file is guaranteed to - * contain a valid path to an existent content file - * inside the compressed file referred to by - * retro_game_info_ext::archive_path - * e.g. for a compressed file '/path/to/foo.zip' - * containing 'bar.sfc' - * > retro_game_info_ext::archive_path will be '/path/to/foo.zip' - * > retro_game_info_ext::archive_file will be 'bar.sfc' - * - retro_game_info_ext::dir is guaranteed to contain a - * valid path to the directory in which the compressed file - * (containing the content file) exists - * - retro_game_info_ext::name is guaranteed to contain - * EITHER - * 1) the basename of the compressed file (containing - * the content file), without extension - * OR - * 2) the basename of the content file inside the - * compressed file, without extension - * In either case, a core should consider 'name' to - * be the canonical name/ID of the the content file - * - retro_game_info_ext::ext is guaranteed to contain the - * extension of the content file inside the compressed file, - * in lower case format - * - retro_game_info_ext::data and retro_game_info_ext::size are - * guaranteed to be valid */ - bool need_fullpath; - - /* If need_fullpath is false, specifies whether the content - * data buffer available in retro_load_game() is 'persistent' - * - * If persistent_data is false and retro_load_game() is called: - * - retro_game_info::data and retro_game_info::size - * are valid only until retro_load_game() returns - * - retro_game_info_ext::data and retro_game_info_ext::size - * are valid only until retro_load_game() returns - * - * If persistent_data is true and retro_load_game() is called: - * - retro_game_info::data and retro_game_info::size - * are valid until retro_deinit() returns - * - retro_game_info_ext::data and retro_game_info_ext::size - * are valid until retro_deinit() returns */ - bool persistent_data; -}; - -/* Similar to retro_game_info, but provides extended - * information about the source content file and - * game memory buffer status. - * And array of retro_game_info_ext is returned by - * RETRO_ENVIRONMENT_GET_GAME_INFO_EXT - * NOTE: In the following descriptions, references to - * retro_load_game() may be replaced with - * retro_load_game_special() */ -struct retro_game_info_ext -{ - /* - If file_in_archive is false, contains a valid - * path to an existent content file (UTF-8 encoded) - * - If file_in_archive is true, may be NULL */ - const char *full_path; - - /* - If file_in_archive is false, may be NULL - * - If file_in_archive is true, contains a valid path - * to an existent compressed file inside which the - * content file is located (UTF-8 encoded) */ - const char *archive_path; - - /* - If file_in_archive is false, may be NULL - * - If file_in_archive is true, contain a valid path - * to an existent content file inside the compressed - * file referred to by archive_path (UTF-8 encoded) - * e.g. for a compressed file '/path/to/foo.zip' - * containing 'bar.sfc' - * > archive_path will be '/path/to/foo.zip' - * > archive_file will be 'bar.sfc' */ - const char *archive_file; - - /* - If file_in_archive is false, contains a valid path - * to the directory in which the content file exists - * (UTF-8 encoded) - * - If file_in_archive is true, contains a valid path - * to the directory in which the compressed file - * (containing the content file) exists (UTF-8 encoded) */ - const char *dir; - - /* Contains the canonical name/ID of the content file - * (UTF-8 encoded). Intended for use when identifying - * 'complementary' content named after the loaded file - - * i.e. companion data of a different format (a CD image - * required by a ROM), texture packs, internally handled - * save files, etc. - * - If file_in_archive is false, contains the basename - * of the content file, without extension - * - If file_in_archive is true, then string is - * implementation specific. A frontend may choose to - * set a name value of: - * EITHER - * 1) the basename of the compressed file (containing - * the content file), without extension - * OR - * 2) the basename of the content file inside the - * compressed file, without extension - * RetroArch sets the 'name' value according to (1). - * A frontend that supports routine loading of - * content from archives containing multiple unrelated - * content files may set the 'name' value according - * to (2). */ - const char *name; - - /* - If file_in_archive is false, contains the extension - * of the content file in lower case format - * - If file_in_archive is true, contains the extension - * of the content file inside the compressed file, - * in lower case format */ - const char *ext; - - /* String of implementation specific meta-data. */ - const char *meta; - - /* Memory buffer of loaded game content. Will be NULL: - * IF - * - retro_system_info::need_fullpath is true and - * retro_system_content_info_override::need_fullpath - * is unset - * OR - * - retro_system_content_info_override::need_fullpath - * is true */ - const void *data; - - /* Size of game content memory buffer, in bytes */ - size_t size; - - /* True if loaded content file is inside a compressed - * archive */ - bool file_in_archive; - - /* - If data is NULL, value is unset/ignored - * - If data is non-NULL: - * - If persistent_data is false, data and size are - * valid only until retro_load_game() returns - * - If persistent_data is true, data and size are - * are valid until retro_deinit() returns */ - bool persistent_data; -}; - -/** - * Parameters describing the size and shape of the video frame. - * @see retro_system_av_info - * @see RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO - * @see RETRO_ENVIRONMENT_SET_GEOMETRY - * @see retro_get_system_av_info - */ -struct retro_game_geometry -{ - /** - * Nominal video width of game, in pixels. - * This will typically be the emulated platform's native video width - * (or its smallest, if the original hardware supports multiple resolutions). - */ - unsigned base_width; - - /** - * Nominal video height of game, in pixels. - * This will typically be the emulated platform's native video height - * (or its smallest, if the original hardware supports multiple resolutions). - */ - unsigned base_height; - - /** - * Maximum possible width of the game screen, in pixels. - * This will typically be the emulated platform's maximum video width. - * For cores that emulate platforms with multiple screens (such as the Nintendo DS), - * this should assume the core's widest possible screen layout (e.g. side-by-side). - * For cores that support upscaling the resolution, - * this should assume the highest supported scale factor is active. - */ - unsigned max_width; - - /** - * Maximum possible height of the game screen, in pixels. - * This will typically be the emulated platform's maximum video height. - * For cores that emulate platforms with multiple screens (such as the Nintendo DS), - * this should assume the core's tallest possible screen layout (e.g. vertical). - * For cores that support upscaling the resolution, - * this should assume the highest supported scale factor is active. - */ - unsigned max_height; /* Maximum possible height of game. */ - - /** - * Nominal aspect ratio of game. - * If zero or less, - * an aspect ratio of base_width / base_height is assumed. - * - * @note A frontend may ignore this setting. - */ - float aspect_ratio; -}; - -/** - * Parameters describing the timing of the video and audio. - * @see retro_system_av_info - * @see RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO - * @see retro_get_system_av_info - */ -struct retro_system_timing -{ - /** Video output refresh rate, in frames per second. */ - double fps; - - /** The audio output sample rate, in Hz. */ - double sample_rate; -}; - -/** - * Configures how the core's audio and video should be updated. - * @see RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO - * @see retro_get_system_av_info - */ -struct retro_system_av_info -{ - /** Parameters describing the size and shape of the video frame. */ - struct retro_game_geometry geometry; - - /** Parameters describing the timing of the video and audio. */ - struct retro_system_timing timing; -}; - -/** @defgroup SET_CORE_OPTIONS Core Options - * @{ - */ - -/** - * Represents \ref RETRO_ENVIRONMENT_GET_VARIABLE "a core option query". - * - * @note In \ref RETRO_ENVIRONMENT_SET_VARIABLES - * (which is a deprecated API), - * this \c struct serves as an option definition. - * - * @see RETRO_ENVIRONMENT_GET_VARIABLE - */ -struct retro_variable -{ - /** - * A unique key identifying this option. - * - * Should be a key for an option that was previously defined - * with \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 or similar. - * - * Should be prefixed with the core's name - * to minimize the risk of collisions with another core's options, - * as frontends are not required to use a namespacing scheme for storing options. - * For example, a core named "foo" might define an option named "foo_option". - * - * @note In \ref RETRO_ENVIRONMENT_SET_VARIABLES - * (which is a deprecated API), - * this field is used to define an option - * named by this key. - */ - const char *key; - - /** - * Value to be obtained. - * - * Set by the frontend to \c NULL if - * the option named by \ref key does not exist. - * - * @note In \ref RETRO_ENVIRONMENT_SET_VARIABLES - * (which is a deprecated API), - * this field is set by the core to define the possible values - * for an option named by \ref key. - * When used this way, it must be formatted as follows: - * @li The text before the first ';' is the option's human-readable title. - * @li A single space follows the ';'. - * @li The rest of the string is a '|'-delimited list of possible values, - * with the first one being the default. - */ - const char *value; -}; - -/** - * An argument that's used to show or hide a core option in the frontend. - * - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY - */ -struct retro_core_option_display -{ - /** - * The key for a core option that was defined with \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2, - * \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL, - * or their legacy equivalents. - */ - const char *key; - - /** - * Whether the option named by \c key - * should be displayed to the player in the frontend's core options menu. - * - * @note This value is a hint, \em not a requirement; - * the frontend is free to ignore this field. - */ - bool visible; -}; - -/** - * The maximum number of choices that can be defined for a given core option. - * - * This limit was chosen as a compromise between - * a core's flexibility and a streamlined user experience. - * - * @note A guiding principle of libretro's API design is that - * all common interactions (gameplay, menu navigation, etc.) - * should be possible without a keyboard. - * - * If you need more than 128 choices for a core option, - * consider simplifying your option structure. - * Here are some ideas: - * - * \li If a core option represents a numeric value, - * consider reducing the option's granularity - * (e.g. define time limits in increments of 5 seconds instead of 1 second). - * Providing a fixed set of values based on experimentation - * is also a good idea. - * \li If a core option represents a dynamically-built list of files, - * consider leaving out files that won't be useful. - * For example, if a core allows the player to choose a specific BIOS file, - * it can omit files of the wrong length or without a valid header. - * - * @see retro_core_option_definition - * @see retro_core_option_v2_definition - */ -#define RETRO_NUM_CORE_OPTION_VALUES_MAX 128 - -/** - * A descriptor for a particular choice within a core option. - * - * @note All option values are represented as strings. - * If you need to represent any other type, - * parse the string in \ref value. - * - * @see retro_core_option_v2_category - */ -struct retro_core_option_value -{ - /** - * The option value that the frontend will serialize. - * - * Must not be \c NULL or empty. - * No other hard limits are placed on this value's contents, - * but here are some suggestions: - * - * \li If the value represents a number, - * don't include any non-digit characters (units, separators, etc.). - * Instead, include that information in \c label. - * This will simplify parsing. - * \li If the value represents a file path, - * store it as a relative path with respect to one of the common libretro directories - * (e.g. \ref RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY "the system directory" - * or \ref RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY "the save directory"), - * and use forward slashes (\c "/") as directory separators. - * This will simplify cloud storage if supported by the frontend, - * as the same file may be used on multiple devices. - */ - const char *value; - - /** - * Human-readable name for \c value that the frontend should show to players. - * - * May be \c NULL, in which case the frontend - * should display \c value itself. - * - * Here are some guidelines for writing a good label: - * - * \li Make the option labels obvious - * so that they don't need to be explained in the description. - * \li Keep labels short, and don't use unnecessary words. - * For example, "OpenGL" is a better label than "OpenGL Mode". - * \li If the option represents a number, - * consider adding units, separators, or other punctuation - * into the label itself. - * For example, "5 seconds" is a better label than "5". - * \li If the option represents a number, use intuitive units - * that don't take a lot of digits to express. - * For example, prefer "1 minute" over "60 seconds" or "60,000 milliseconds". - */ - const char *label; -}; - -/** - * @copybrief retro_core_option_v2_definition - * - * @deprecated Use \ref retro_core_option_v2_definition instead, - * as it supports categorizing options into groups. - * Only use this \c struct to support older frontends or cores. - * - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL - */ -struct retro_core_option_definition -{ - /** @copydoc retro_core_option_v2_definition::key */ - const char *key; - - /** @copydoc retro_core_option_v2_definition::desc */ - const char *desc; - - /** @copydoc retro_core_option_v2_definition::info */ - const char *info; - - /** @copydoc retro_core_option_v2_definition::values */ - struct retro_core_option_value values[RETRO_NUM_CORE_OPTION_VALUES_MAX]; - - /** @copydoc retro_core_option_v2_definition::default_value */ - const char *default_value; -}; - -#ifdef __PS3__ -#undef local -#endif - -/** - * A variant of \ref retro_core_options that supports internationalization. - * - * @deprecated Use \ref retro_core_options_v2_intl instead, - * as it supports categorizing options into groups. - * Only use this \c struct to support older frontends or cores. - * - * @see retro_core_options - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL - * @see RETRO_ENVIRONMENT_GET_LANGUAGE - * @see retro_language - */ -struct retro_core_options_intl -{ - /** @copydoc retro_core_options_v2_intl::us */ - struct retro_core_option_definition *us; - - /** @copydoc retro_core_options_v2_intl::local */ - struct retro_core_option_definition *local; -}; - -/** - * A descriptor for a group of related core options. - * - * Here's an example category: - * - * @code - * { - * "cpu", - * "CPU Emulation", - * "Settings for CPU quirks." - * } - * @endcode - * - * @see retro_core_options_v2 - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL - */ -struct retro_core_option_v2_category -{ - /** - * A string that uniquely identifies this category within the core's options. - * Any \c retro_core_option_v2_definition whose \c category_key matches this - * is considered to be within this category. - * Different cores may use the same category keys, - * so namespacing them is not necessary. - * Valid characters are [a-zA-Z0-9_-]. - * - * Frontends should use this category to organize core options, - * but may customize this category's presentation in other ways. - * For example, a frontend may use common keys like "audio" or "gfx" - * to select an appropriate icon in its UI. - * - * Required; must not be \c NULL. - */ - const char *key; - - /** - * A brief human-readable name for this category, - * intended for the frontend to display to the player. - * This should be a name that's concise and descriptive, such as "Audio" or "Video". - * - * Required; must not be \c NULL. - */ - const char *desc; - - /** - * A human-readable description for this category, - * intended for the frontend to display to the player - * as secondary help text (e.g. a sublabel or a tooltip). - * Optional; may be \c NULL or an empty string. - */ - const char *info; -}; - -/** - * A descriptor for a particular core option and the values it may take. - * - * Supports categorizing options into groups, - * so as not to overwhelm the player. - * - * @see retro_core_option_v2_category - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL - */ -struct retro_core_option_v2_definition -{ - /** - * A unique identifier for this option that cores may use - * \ref RETRO_ENVIRONMENT_GET_VARIABLE "to query its value from the frontend". - * Must be unique within this core. - * - * Should be unique globally; - * the recommended method for doing so - * is to prefix each option with the core's name. - * For example, an option that controls the resolution for a core named "foo" - * should be named \c "foo_resolution". - * - * Valid key characters are in the set [a-zA-Z0-9_-]. - */ - const char *key; - - /** - * A human-readable name for this option, - * intended to be displayed by frontends that don't support - * categorizing core options. - * - * Required; must not be \c NULL or empty. - */ - const char *desc; - - /** - * A human-readable name for this option, - * intended to be displayed by frontends that support - * categorizing core options. - * - * This version may be slightly more concise than \ref desc, - * as it can rely on the structure of the options menu. - * For example, "Interface" is a good \c desc_categorized, - * as it can be displayed as a sublabel for a "Network" category. - * For \c desc, "Network Interface" would be more suitable. - * - * Optional; if this field or \c category_key is empty or \c NULL, - * \c desc will be used instead. - */ - const char *desc_categorized; - - /** - * A human-readable description of this option and its effects, - * intended to be displayed by frontends that don't support - * categorizing core options. - * - * @details Intended to be displayed as secondary help text, - * such as a tooltip or a sublabel. - * - * Here are some suggestions for writing a good description: - * - * \li Avoid technical jargon unless this option is meant for advanced users. - * If unavoidable, suggest one of the default options for those unsure. - * \li Don't repeat the option name in the description; - * instead, describe what the option name means. - * \li If an option requires a core restart or game reset to take effect, - * be sure to say so. - * \li Try to make the option labels obvious - * so that they don't need to be explained in the description. - * - * Optional; may be \c NULL. - */ - const char *info; - - /** - * @brief A human-readable description of this option and its effects, - * intended to be displayed by frontends that support - * categorizing core options. - * - * This version is provided to accommodate descriptions - * that reference other options by name, - * as options may have different user-facing names - * depending on whether the frontend supports categorization. - * - * @copydetails info - * - * If empty or \c NULL, \c info will be used instead. - * Will be ignored if \c category_key is empty or \c NULL. - */ - const char *info_categorized; - - /** - * The key of the category that this option belongs to. - * - * Optional; if equal to \ref retro_core_option_v2_category::key "a defined category", - * then this option shall be displayed by the frontend - * next to other options in this same category, - * assuming it supports doing so. - * Option categories are intended to be displayed in a submenu, - * but this isn't a hard requirement. - * - * If \c NULL, empty, or not equal to a defined category, - * then this option is considered uncategorized - * and the frontend shall display it outside of any category - * (most likely at a top-level menu). - * - * @see retro_core_option_v2_category - */ - const char *category_key; - - /** - * One or more possible values for this option, - * up to the limit of \ref RETRO_NUM_CORE_OPTION_VALUES_MAX. - * - * Terminated by a \c { NULL, NULL } element, - * although frontends should work even if all elements are used. - */ - struct retro_core_option_value values[RETRO_NUM_CORE_OPTION_VALUES_MAX]; - - /** - * The default value for this core option. - * Used if it hasn't been set, e.g. for new cores. - * Must equal one of the \ref value members in the \c values array, - * or else this option will be ignored. - */ - const char *default_value; -}; - -/** - * A set of core option descriptors and the categories that group them, - * suitable for enabling a core to be customized. - * - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 - */ -struct retro_core_options_v2 -{ - /** - * An array of \ref retro_core_option_v2_category "option categories", - * terminated by a zeroed-out category \c struct. - * - * Will be ignored if the frontend doesn't support core option categories. - * - * If \c NULL or ignored, all options will be treated as uncategorized. - * This most likely means that a frontend will display them at a top-level menu - * without any kind of hierarchy or grouping. - */ - struct retro_core_option_v2_category *categories; - - /** - * An array of \ref retro_core_option_v2_definition "core option descriptors", - * terminated by a zeroed-out definition \c struct. - * - * Required; must not be \c NULL. - */ - struct retro_core_option_v2_definition *definitions; -}; - -/** - * A variant of \ref retro_core_options_v2 that supports internationalization. - * - * @see retro_core_options_v2 - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL - * @see RETRO_ENVIRONMENT_GET_LANGUAGE - * @see retro_language - */ -struct retro_core_options_v2_intl -{ - /** - * Pointer to a core options set - * whose text is written in American English. - * - * This may be passed to \c RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 as-is - * if not using \c RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL. - * - * Required; must not be \c NULL. - */ - struct retro_core_options_v2 *us; - - /** - * Pointer to a core options set - * whose text is written in one of libretro's \ref retro_language "supported languages", - * most likely the one returned by \ref RETRO_ENVIRONMENT_GET_LANGUAGE. - * - * Structure is the same, but usage is slightly different: - * - * \li All text (except for keys and option values) - * should be written in whichever language - * is returned by \c RETRO_ENVIRONMENT_GET_LANGUAGE. - * \li All fields besides keys and option values may be \c NULL, - * in which case the corresponding string in \c us - * is used instead. - * \li All \ref retro_core_option_v2_definition::default_value "default option values" - * are taken from \c us. - * The defaults in this field are ignored. - * - * May be \c NULL, in which case \c us is used instead. - */ - struct retro_core_options_v2 *local; -}; - -/** - * Called by the frontend to determine if any core option's visibility has changed. - * - * Each time a frontend sets a core option, - * it should call this function to see if - * any core option should be made visible or invisible. - * - * May also be called after \ref retro_load_game "loading a game", - * to determine what the initial visibility of each option should be. - * - * Within this function, the core must update the visibility - * of any dynamically-hidden options - * using \ref RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY. - * - * @note All core options are visible by default, - * even during this function's first call. - * - * @return \c true if any core option's visibility was adjusted - * since the last call to this function. - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY - * @see retro_core_option_display - */ -typedef bool (RETRO_CALLCONV *retro_core_options_update_display_callback_t)(void); - -/** - * Callback registered by the core for the frontend to use - * when setting the visibility of each core option. - * - * @see RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY - * @see retro_core_option_display - */ -struct retro_core_options_update_display_callback -{ - /** - * @copydoc retro_core_options_update_display_callback_t - * - * Set by the core. - */ - retro_core_options_update_display_callback_t callback; -}; - -/** @} */ - -struct retro_game_info -{ - const char *path; /* Path to game, UTF-8 encoded. - * Sometimes used as a reference for building other paths. - * May be NULL if game was loaded from stdin or similar, - * but in this case some cores will be unable to load `data`. - * So, it is preferable to fabricate something here instead - * of passing NULL, which will help more cores to succeed. - * retro_system_info::need_fullpath requires - * that this path is valid. */ - const void *data; /* Memory buffer of loaded game. Will be NULL - * if need_fullpath was set. */ - size_t size; /* Size of memory buffer. */ - const char *meta; /* String of implementation specific meta-data. */ -}; - -/** @defgroup GET_CURRENT_SOFTWARE_FRAMEBUFFER Frontend-Owned Framebuffers - * @{ - */ - -/** @defgroup RETRO_MEMORY_ACCESS Framebuffer Memory Access Types - * @{ - */ - -/** Indicates that core will write to the framebuffer returned by the frontend. */ -#define RETRO_MEMORY_ACCESS_WRITE (1 << 0) - -/** Indicates that the core will read from the framebuffer returned by the frontend. */ -#define RETRO_MEMORY_ACCESS_READ (1 << 1) - -/** @} */ - -/** @defgroup RETRO_MEMORY_TYPE Framebuffer Memory Types - * @{ - */ - -/** - * Indicates that the returned framebuffer's memory is cached. - * If not set, random access to the buffer may be very slow. - */ -#define RETRO_MEMORY_TYPE_CACHED (1 << 0) - -/** @} */ - -/** - * A frame buffer owned by the frontend that a core may use for rendering. - * - * @see GET_CURRENT_SOFTWARE_FRAMEBUFFER - * @see retro_video_refresh_t - */ -struct retro_framebuffer -{ - /** - * Pointer to the beginning of the framebuffer provided by the frontend. - * The initial contents of this buffer are unspecified, - * as is the means used to map the memory; - * this may be defined in software, - * or it may be GPU memory mapped to RAM. - * - * If the framebuffer is used, - * this pointer must be passed to \c retro_video_refresh_t as-is. - * It is undefined behavior to pass an offset to this pointer. - * - * @warning This pointer is only guaranteed to be valid - * for the duration of the same \c retro_run iteration - * \ref GET_CURRENT_SOFTWARE_FRAMEBUFFER "that requested the framebuffer". - * Reuse of this pointer is undefined. - */ - void *data; - - /** - * The width of the framebuffer given in \c data, in pixels. - * Set by the core. - * - * @warning If the framebuffer is used, - * this value must be passed to \c retro_video_refresh_t as-is. - * It is undefined behavior to try to render \c data with any other width. - */ - unsigned width; - - /** - * The height of the framebuffer given in \c data, in pixels. - * Set by the core. - * - * @warning If the framebuffer is used, - * this value must be passed to \c retro_video_refresh_t as-is. - * It is undefined behavior to try to render \c data with any other height. - */ - unsigned height; - - /** - * The distance between the start of one scanline and the beginning of the next, in bytes. - * In practice this is usually equal to \c width times the pixel size, - * but that's not guaranteed. - * Sometimes called the "stride". - * - * @setby{frontend} - * @warning If the framebuffer is used, - * this value must be passed to \c retro_video_refresh_t as-is. - * It is undefined to try to render \c data with any other pitch. - */ - size_t pitch; - - /** - * The pixel format of the returned framebuffer. - * May be different than the format specified by the core in \c RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, - * e.g. due to conversions. - * Set by the frontend. - * - * @see RETRO_ENVIRONMENT_SET_PIXEL_FORMAT - */ - enum retro_pixel_format format; - - /** - * One or more \ref RETRO_MEMORY_ACCESS "memory access flags" - * that specify how the core will access the memory in \c data. - * - * @setby{core} - */ - unsigned access_flags; - - /** - * Zero or more \ref RETRO_MEMORY_TYPE "memory type flags" - * that describe how the framebuffer's memory has been mapped. - * - * @setby{frontend} - */ - unsigned memory_flags; -}; - -/** @} */ - -/** @defgroup SET_FASTFORWARDING_OVERRIDE Fast-Forward Override - * @{ - */ - -/** - * Parameters that govern when and how the core takes control - * of fast-forwarding mode. - */ -struct retro_fastforwarding_override -{ - /** - * The factor by which the core will be sped up - * when \c fastforward is \c true. - * This value is used as follows: - * - * @li A value greater than 1.0 will run the core at - * the specified multiple of normal speed. - * For example, a value of 5.0 - * combined with a normal target rate of 60 FPS - * will result in a target rate of 300 FPS. - * The actual rate may be lower if the host's hardware can't keep up. - * @li A value of 1.0 will run the core at normal speed. - * @li A value between 0.0 (inclusive) and 1.0 (exclusive) - * will run the core as fast as the host system can manage. - * @li A negative value will let the frontend choose a factor. - * @li An infinite value or \c NaN results in undefined behavior. - * - * @attention Setting this value to less than 1.0 will \em not - * slow down the core. - */ - float ratio; - - /** - * If \c true, the frontend should activate fast-forwarding - * until this field is set to \c false or the core is unloaded. - */ - bool fastforward; - - /** - * If \c true, the frontend should display an on-screen notification or icon - * while \c fastforward is \c true (where supported). - * Otherwise, the frontend should not display any such notification. - */ - bool notification; - - /** - * If \c true, the core has exclusive control - * over enabling and disabling fast-forwarding - * via the \c fastforward field. - * The frontend will not be able to start or stop fast-forwarding - * until this field is set to \c false or the core is unloaded. - */ - bool inhibit_toggle; -}; - -/** @} */ - -/** - * During normal operation. - * - * @note Rate will be equal to the core's internal FPS. - */ -#define RETRO_THROTTLE_NONE 0 - -/** - * While paused or stepping single frames. - * - * @note Rate will be 0. - */ -#define RETRO_THROTTLE_FRAME_STEPPING 1 - -/** - * During fast forwarding. - * - * @note Rate will be 0 if not specifically limited to a maximum speed. - */ -#define RETRO_THROTTLE_FAST_FORWARD 2 - -/** - * During slow motion. - * - * @note Rate will be less than the core's internal FPS. - */ -#define RETRO_THROTTLE_SLOW_MOTION 3 - -/** - * While rewinding recorded save states. - * - * @note Rate can vary depending on the rewind speed or be 0 if the frontend - * is not aiming for a specific rate. - */ -#define RETRO_THROTTLE_REWINDING 4 - -/** - * While vsync is active in the video driver, and the target refresh rate is lower than the core's internal FPS. - * - * @note Rate is the target refresh rate. - */ -#define RETRO_THROTTLE_VSYNC 5 - -/** - * When the frontend does not throttle in any way. - * - * @note Rate will be 0. An example could be if no vsync or audio output is active. - */ -#define RETRO_THROTTLE_UNBLOCKED 6 - -/** - * Details about the actual rate an implementation is calling \c retro_run() at. - * - * @see RETRO_ENVIRONMENT_GET_THROTTLE_STATE - */ -struct retro_throttle_state -{ - /** - * The current throttling mode. - * - * @note Should be one of the \c RETRO_THROTTLE_* values. - * @see RETRO_THROTTLE_NONE - * @see RETRO_THROTTLE_FRAME_STEPPING - * @see RETRO_THROTTLE_FAST_FORWARD - * @see RETRO_THROTTLE_SLOW_MOTION - * @see RETRO_THROTTLE_REWINDING - * @see RETRO_THROTTLE_VSYNC - * @see RETRO_THROTTLE_UNBLOCKED - */ - unsigned mode; - - /** - * How many times per second the frontend aims to call retro_run. - * - * @note Depending on the mode, it can be 0 if there is no known fixed rate. - * This won't be accurate if the total processing time of the core and - * the frontend is longer than what is available for one frame. - */ - float rate; -}; - -/** @defgroup GET_MICROPHONE_INTERFACE Microphone Interface - * @{ - */ - -/** - * Opaque handle to a microphone that's been opened for use. - * The underlying object is accessed or created with \c retro_microphone_interface_t. - */ -typedef struct retro_microphone retro_microphone_t; - -/** - * Parameters for configuring a microphone. - * Some of these might not be honored, - * depending on the available hardware and driver configuration. - */ -typedef struct retro_microphone_params -{ - /** - * The desired sample rate of the microphone's input, in Hz. - * The microphone's input will be resampled, - * so cores can ask for whichever frequency they need. - * - * If zero, some reasonable default will be provided by the frontend - * (usually from its config file). - * - * @see retro_get_mic_rate_t - */ - unsigned rate; -} retro_microphone_params_t; - -/** - * @copydoc retro_microphone_interface::open_mic - */ -typedef retro_microphone_t *(RETRO_CALLCONV *retro_open_mic_t)(const retro_microphone_params_t *params); - -/** - * @copydoc retro_microphone_interface::close_mic - */ -typedef void (RETRO_CALLCONV *retro_close_mic_t)(retro_microphone_t *microphone); - -/** - * @copydoc retro_microphone_interface::get_params - */ -typedef bool (RETRO_CALLCONV *retro_get_mic_params_t)(const retro_microphone_t *microphone, retro_microphone_params_t *params); - -/** - * @copydoc retro_microphone_interface::set_mic_state - */ -typedef bool (RETRO_CALLCONV *retro_set_mic_state_t)(retro_microphone_t *microphone, bool state); - -/** - * @copydoc retro_microphone_interface::get_mic_state - */ -typedef bool (RETRO_CALLCONV *retro_get_mic_state_t)(const retro_microphone_t *microphone); - -/** - * @copydoc retro_microphone_interface::read_mic - */ -typedef int (RETRO_CALLCONV *retro_read_mic_t)(retro_microphone_t *microphone, int16_t* samples, size_t num_samples); - -/** - * The current version of the microphone interface. - * Will be incremented whenever \c retro_microphone_interface or \c retro_microphone_params_t - * receive new fields. - * - * Frontends using cores built against older mic interface versions - * should not access fields introduced in newer versions. - */ -#define RETRO_MICROPHONE_INTERFACE_VERSION 1 - -/** - * An interface for querying the microphone and accessing data read from it. - * - * @see RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE - */ -struct retro_microphone_interface -{ - /** - * The version of this microphone interface. - * Set by the core to request a particular version, - * and set by the frontend to indicate the returned version. - * 0 indicates that the interface is invalid or uninitialized. - */ - unsigned interface_version; - - /** - * Initializes a new microphone. - * Assuming that microphone support is enabled and provided by the frontend, - * cores may call this function whenever necessary. - * A microphone could be opened throughout a core's lifetime, - * or it could wait until a microphone is plugged in to the emulated device. - * - * The returned handle will be valid until it's freed, - * even if the audio driver is reinitialized. - * - * This function is not guaranteed to be thread-safe. - * - * @param[in] args Parameters used to create the microphone. - * May be \c NULL, in which case the default value of each parameter will be used. - * - * @returns Pointer to the newly-opened microphone, - * or \c NULL if one couldn't be opened. - * This likely means that no microphone is plugged in and recognized, - * or the maximum number of supported microphones has been reached. - * - * @note Microphones are \em inactive by default; - * to begin capturing audio, call \c set_mic_state. - * @see retro_microphone_params_t - */ - retro_open_mic_t open_mic; - - /** - * Closes a microphone that was initialized with \c open_mic. - * Calling this function will stop all microphone activity - * and free up the resources that it allocated. - * Afterwards, the handle is invalid and must not be used. - * - * A frontend may close opened microphones when unloading content, - * but this behavior is not guaranteed. - * Cores should close their microphones when exiting, just to be safe. - * - * @param microphone Pointer to the microphone that was allocated by \c open_mic. - * If \c NULL, this function does nothing. - * - * @note The handle might be reused if another microphone is opened later. - */ - retro_close_mic_t close_mic; - - /** - * Returns the configured parameters of this microphone. - * These may differ from what was requested depending on - * the driver and device configuration. - * - * Cores should check these values before they start fetching samples. - * - * Will not change after the mic was opened. - * - * @param[in] microphone Opaque handle to the microphone - * whose parameters will be retrieved. - * @param[out] params The parameters object that the - * microphone's parameters will be copied to. - * - * @return \c true if the parameters were retrieved, - * \c false if there was an error. - */ - retro_get_mic_params_t get_params; - - /** - * Enables or disables the given microphone. - * Microphones are disabled by default - * and must be explicitly enabled before they can be used. - * Disabled microphones will not process incoming audio samples, - * and will therefore have minimal impact on overall performance. - * Cores may enable microphones throughout their lifetime, - * or only for periods where they're needed. - * - * Cores that accept microphone input should be able to operate without it; - * we suggest substituting silence in this case. - * - * @param microphone Opaque handle to the microphone - * whose state will be adjusted. - * This will have been provided by \c open_mic. - * @param state \c true if the microphone should receive audio input, - * \c false if it should be idle. - * @returns \c true if the microphone's state was successfully set, - * \c false if \c microphone is invalid - * or if there was an error. - */ - retro_set_mic_state_t set_mic_state; - - /** - * Queries the active state of a microphone at the given index. - * Will return whether the microphone is enabled, - * even if the driver is paused. - * - * @param microphone Opaque handle to the microphone - * whose state will be queried. - * @return \c true if the provided \c microphone is valid and active, - * \c false if not or if there was an error. - */ - retro_get_mic_state_t get_mic_state; - - /** - * Retrieves the input processed by the microphone since the last call. - * \em Must be called every frame unless \c microphone is disabled, - * similar to how \c retro_audio_sample_batch_t works. - * - * @param[in] microphone Opaque handle to the microphone - * whose recent input will be retrieved. - * @param[out] samples The buffer that will be used to store the microphone's data. - * Microphone input is in mono (i.e. one number per sample). - * Should be large enough to accommodate the expected number of samples per frame; - * for example, a 44.1kHz sample rate at 60 FPS would require space for 735 samples. - * @param[in] num_samples The size of the data buffer in samples (\em not bytes). - * Microphone input is in mono, so a "frame" and a "sample" are equivalent in length here. - * - * @return The number of samples that were copied into \c samples. - * If \c microphone is pending driver initialization, - * this function will copy silence of the requested length into \c samples. - * - * Will return -1 if the microphone is disabled, - * the audio driver is paused, - * or there was an error. - */ - retro_read_mic_t read_mic; -}; - -/** @} */ - -/** @defgroup GET_DEVICE_POWER Device Power - * @{ - */ - -/** - * Describes how a device is being powered. - * @see RETRO_ENVIRONMENT_GET_DEVICE_POWER - */ -enum retro_power_state -{ - /** - * Indicates that the frontend cannot report its power state at this time, - * most likely due to a lack of support. - * - * \c RETRO_ENVIRONMENT_GET_DEVICE_POWER will not return this value; - * instead, the environment callback will return \c false. - */ - RETRO_POWERSTATE_UNKNOWN = 0, - - /** - * Indicates that the device is running on its battery. - * Usually applies to portable devices such as handhelds, laptops, and smartphones. - */ - RETRO_POWERSTATE_DISCHARGING, - - /** - * Indicates that the device's battery is currently charging. - */ - RETRO_POWERSTATE_CHARGING, - - /** - * Indicates that the device is connected to a power source - * and that its battery has finished charging. - */ - RETRO_POWERSTATE_CHARGED, - - /** - * Indicates that the device is connected to a power source - * and that it does not have a battery. - * This usually suggests a desktop computer or a non-portable game console. - */ - RETRO_POWERSTATE_PLUGGED_IN -}; - -/** - * Indicates that an estimate is not available for the battery level or time remaining, - * even if the actual power state is known. - */ -#define RETRO_POWERSTATE_NO_ESTIMATE (-1) - -/** - * Describes the power state of the device running the frontend. - * @see RETRO_ENVIRONMENT_GET_DEVICE_POWER - */ -struct retro_device_power -{ - /** - * The current state of the frontend's power usage. - */ - enum retro_power_state state; - - /** - * A rough estimate of the amount of time remaining (in seconds) - * before the device powers off. - * This value depends on a variety of factors, - * so it is not guaranteed to be accurate. - * - * Will be set to \c RETRO_POWERSTATE_NO_ESTIMATE if \c state does not equal \c RETRO_POWERSTATE_DISCHARGING. - * May still be set to \c RETRO_POWERSTATE_NO_ESTIMATE if the frontend is unable to provide an estimate. - */ - int seconds; - - /** - * The approximate percentage of battery charge, - * ranging from 0 to 100 (inclusive). - * The device may power off before this reaches 0. - * - * The user might have configured their device - * to stop charging before the battery is full, - * so do not assume that this will be 100 in the \c RETRO_POWERSTATE_CHARGED state. - */ - int8_t percent; -}; - -/** @} */ - -/** - * @defgroup Callbacks - * @{ - */ - -/** - * Environment callback to give implementations a way of performing uncommon tasks. - * - * @note Extensible. - * - * @param cmd The command to run. - * @param data A pointer to the data associated with the command. - * - * @return Varies by callback, - * but will always return \c false if the command is not recognized. - * - * @see RETRO_ENVIRONMENT_SET_ROTATION - * @see retro_set_environment() - */ -typedef bool (RETRO_CALLCONV *retro_environment_t)(unsigned cmd, void *data); - -/** - * Render a frame. - * - * @note For performance reasons, it is highly recommended to have a frame - * that is packed in memory, i.e. pitch == width * byte_per_pixel. - * Certain graphic APIs, such as OpenGL ES, do not like textures - * that are not packed in memory. - * - * @param data A pointer to the frame buffer data with a pixel format of 15-bit \c 0RGB1555 native endian, unless changed with \c RETRO_ENVIRONMENT_SET_PIXEL_FORMAT. - * @param width The width of the frame buffer, in pixels. - * @param height The height frame buffer, in pixels. - * @param pitch The width of the frame buffer, in bytes. - * - * @see retro_set_video_refresh() - * @see RETRO_ENVIRONMENT_SET_PIXEL_FORMAT - * @see retro_pixel_format - */ -typedef void (RETRO_CALLCONV *retro_video_refresh_t)(const void *data, unsigned width, - unsigned height, size_t pitch); - -/** - * Renders a single audio frame. Should only be used if implementation generates a single sample at a time. - * - * @param left The left audio sample represented as a signed 16-bit native endian. - * @param right The right audio sample represented as a signed 16-bit native endian. - * - * @see retro_set_audio_sample() - * @see retro_set_audio_sample_batch() - */ -typedef void (RETRO_CALLCONV *retro_audio_sample_t)(int16_t left, int16_t right); - -/** - * Renders multiple audio frames in one go. - * - * @note Only one of the audio callbacks must ever be used. - * - * @param data A pointer to the audio sample data pairs to render. - * @param frames The number of frames that are represented in the data. One frame - * is defined as a sample of left and right channels, interleaved. - * For example: int16_t buf[4] = { l, r, l, r }; would be 2 frames. - * - * @return The number of frames that were processed. - * - * @see retro_set_audio_sample_batch() - * @see retro_set_audio_sample() - */ -typedef size_t (RETRO_CALLCONV *retro_audio_sample_batch_t)(const int16_t *data, - size_t frames); - -/** - * Polls input. - * - * @see retro_set_input_poll() - */ -typedef void (RETRO_CALLCONV *retro_input_poll_t)(void); - -/** - * Queries for input for player 'port'. - * - * @param port Which player 'port' to query. - * @param device Which device to query for. Will be masked with \c RETRO_DEVICE_MASK. - * @param index The input index to retrieve. - * The exact semantics depend on the device type given in \c device. - * @param id The ID of which value to query, like \c RETRO_DEVICE_ID_JOYPAD_B. - * @returns Depends on the provided arguments, - * but will return 0 if their values are unsupported - * by the frontend or the backing physical device. - * @note Specialization of devices such as \c RETRO_DEVICE_JOYPAD_MULTITAP that - * have been set with \c retro_set_controller_port_device() will still use the - * higher level \c RETRO_DEVICE_JOYPAD to request input. - * - * @see retro_set_input_state() - * @see RETRO_DEVICE_NONE - * @see RETRO_DEVICE_JOYPAD - * @see RETRO_DEVICE_MOUSE - * @see RETRO_DEVICE_KEYBOARD - * @see RETRO_DEVICE_LIGHTGUN - * @see RETRO_DEVICE_ANALOG - * @see RETRO_DEVICE_POINTER - */ -typedef int16_t (RETRO_CALLCONV *retro_input_state_t)(unsigned port, unsigned device, - unsigned index, unsigned id); - -/** - * Sets the environment callback. - * - * @param cb The function which is used when making environment calls. - * - * @note Guaranteed to be called before \c retro_init(). - * - * @see RETRO_ENVIRONMENT - */ -RETRO_API void retro_set_environment(retro_environment_t cb); - -/** - * Sets the video refresh callback. - * - * @param cb The function which is used when rendering a frame. - * - * @note Guaranteed to have been called before the first call to \c retro_run() is made. - */ -RETRO_API void retro_set_video_refresh(retro_video_refresh_t cb); - -/** - * Sets the audio sample callback. - * - * @param cb The function which is used when rendering a single audio frame. - * - * @note Guaranteed to have been called before the first call to \c retro_run() is made. - */ -RETRO_API void retro_set_audio_sample(retro_audio_sample_t cb); - -/** - * Sets the audio sample batch callback. - * - * @param cb The function which is used when rendering multiple audio frames in one go. - * - * @note Guaranteed to have been called before the first call to \c retro_run() is made. - */ -RETRO_API void retro_set_audio_sample_batch(retro_audio_sample_batch_t cb); - -/** - * Sets the input poll callback. - * - * @param cb The function which is used to poll the active input. - * - * @note Guaranteed to have been called before the first call to \c retro_run() is made. - */ -RETRO_API void retro_set_input_poll(retro_input_poll_t cb); - -/** - * Sets the input state callback. - * - * @param cb The function which is used to query the input state. - * - *@note Guaranteed to have been called before the first call to \c retro_run() is made. - */ -RETRO_API void retro_set_input_state(retro_input_state_t cb); - -/** - * @} - */ - -/** - * Called by the frontend when initializing a libretro core. - * - * @warning There are many possible "gotchas" with global state in dynamic libraries. - * Here are some to keep in mind: - *
    - *
  • Do not assume that the core was loaded by the operating system - * for the first time within this call. - * It may have been statically linked or retained from a previous session. - * Consequently, cores must not rely on global variables being initialized - * to their default values before this function is called; - * this also goes for object constructors in C++. - *
  • Although C++ requires that constructors be called for global variables, - * it does not require that their destructors be called - * if stored within a dynamic library's global scope. - *
  • If the core is statically linked to the frontend, - * global variables may be initialized when the frontend itself is initially executed. - *
- * @see retro_deinit - */ -RETRO_API void retro_init(void); - -/** - * Called by the frontend when deinitializing a libretro core. - * The core must release all of its allocated resources before this function returns. - * - * @warning There are many possible "gotchas" with global state in dynamic libraries. - * Here are some to keep in mind: - *
    - *
  • Do not assume that the operating system will unload the core after this function returns, - * as the core may be linked statically or retained in memory. - * Cores should use this function to clean up all allocated resources - * and reset all global variables to their default states. - *
  • Do not assume that this core won't be loaded again after this function returns. - * It may be kept in memory by the frontend for later use, - * or it may be statically linked. - * Therefore, all global variables should be reset to their default states within this function. - *
  • C++ does not require that destructors be called - * for variables within a dynamic library's global scope. - * Therefore, global objects that own dynamically-managed resources - * (such as \c std::string or std::vector) - * should be kept behind pointers that are explicitly deallocated within this function. - *
- * @see retro_init - */ -RETRO_API void retro_deinit(void); - -/** - * Retrieves which version of the libretro API is being used. - * - * @note This is used to validate ABI compatibility when the API is revised. - * - * @return Must return \c RETRO_API_VERSION. - * - * @see RETRO_API_VERSION - */ -RETRO_API unsigned retro_api_version(void); - -/** - * Gets statically known system info. - * - * @note Can be called at any time, even before retro_init(). - * - * @param info A pointer to a \c retro_system_info where the info is to be loaded into. This must be statically allocated. - */ -RETRO_API void retro_get_system_info(struct retro_system_info *info); - -/** - * Gets information about system audio/video timings and geometry. - * - * @note Can be called only after \c retro_load_game() has successfully completed. - * - * @note The implementation of this function might not initialize every variable - * if needed. For example, \c geom.aspect_ratio might not be initialized if - * the core doesn't desire a particular aspect ratio. - * - * @param info A pointer to a \c retro_system_av_info where the audio/video information should be loaded into. - * - * @see retro_system_av_info - */ -RETRO_API void retro_get_system_av_info(struct retro_system_av_info *info); - -/** - * Sets device to be used for player 'port'. - * - * By default, \c RETRO_DEVICE_JOYPAD is assumed to be plugged into all - * available ports. - * - * @note Setting a particular device type is not a guarantee that libretro cores - * will only poll input based on that particular device type. It is only a - * hint to the libretro core when a core cannot automatically detect the - * appropriate input device type on its own. It is also relevant when a - * core can change its behavior depending on device type. - * - * @note As part of the core's implementation of retro_set_controller_port_device, - * the core should call \c RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS to notify the - * frontend if the descriptions for any controls have changed as a - * result of changing the device type. - * - * @param port Which port to set the device for, usually indicates the player number. - * @param device Which device the given port is using. By default, \c RETRO_DEVICE_JOYPAD is assumed for all ports. - * - * @see RETRO_DEVICE_NONE - * @see RETRO_DEVICE_JOYPAD - * @see RETRO_DEVICE_MOUSE - * @see RETRO_DEVICE_KEYBOARD - * @see RETRO_DEVICE_LIGHTGUN - * @see RETRO_DEVICE_ANALOG - * @see RETRO_DEVICE_POINTER - * @see RETRO_ENVIRONMENT_SET_CONTROLLER_INFO - */ -RETRO_API void retro_set_controller_port_device(unsigned port, unsigned device); - -/** - * Resets the currently-loaded game. - * Cores should treat this as a soft reset (i.e. an emulated reset button) if possible, - * but hard resets are acceptable. - */ -RETRO_API void retro_reset(void); - -/** - * Runs the game for one video frame. - * - * During \c retro_run(), the \c retro_input_poll_t callback must be called at least once. - * - * @note If a frame is not rendered for reasons where a game "dropped" a frame, - * this still counts as a frame, and \c retro_run() should explicitly dupe - * a frame if \c RETRO_ENVIRONMENT_GET_CAN_DUPE returns true. In this case, - * the video callback can take a NULL argument for data. - * - * @see retro_input_poll_t - */ -RETRO_API void retro_run(void); - -/** - * Returns the amount of data the implementation requires to serialize internal state (save states). - * - * @note Between calls to \c retro_load_game() and \c retro_unload_game(), the - * returned size is never allowed to be larger than a previous returned - * value, to ensure that the frontend can allocate a save state buffer once. - * - * @return The amount of data the implementation requires to serialize the internal state. - * - * @see retro_serialize() - */ -RETRO_API size_t retro_serialize_size(void); - -/** - * Serializes the internal state. - * - * @param data A pointer to where the serialized data should be saved to. - * @param size The size of the memory. - * - * @return If failed, or size is lower than \c retro_serialize_size(), it - * should return false. On success, it will return true. - * - * @see retro_serialize_size() - * @see retro_unserialize() - */ -RETRO_API bool retro_serialize(void *data, size_t len); - -/** - * Unserialize the given state data, and load it into the internal state. - * - * @return Returns true if loading the state was successful, false otherwise. - * - * @see retro_serialize() - */ -RETRO_API bool retro_unserialize(const void *data, size_t len); - -/** - * Reset all the active cheats to their default disabled state. - * - * @see retro_cheat_set() - */ -RETRO_API void retro_cheat_reset(void); - -/** - * Enable or disable a cheat. - * - * @param index The index of the cheat to act upon. - * @param enabled Whether to enable or disable the cheat. - * @param code A string of the code used for the cheat. - * - * @see retro_cheat_reset() - */ -RETRO_API void retro_cheat_set(unsigned index, bool enabled, const char *code); - -/** - * Loads a game. - * - * @param game A pointer to a \c retro_game_info detailing information about the game to load. - * May be \c NULL if the core is loaded without content. - * - * @return Will return true when the game was loaded successfully, or false otherwise. - * - * @see retro_game_info - * @see RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME - */ -RETRO_API bool retro_load_game(const struct retro_game_info *game); - -/** - * Called when the frontend has loaded one or more "special" content files, - * typically through subsystems. - * - * @note Only necessary for cores that support subsystems. - * Others may return \c false or delegate to retro_load_game. - * - * @param game_type The type of game to load, - * as determined by \c retro_subsystem_info. - * @param info A pointer to an array of \c retro_game_info objects - * providing information about the loaded content. - * @param num_info The number of \c retro_game_info objects passed into the info parameter. - * @return \c true if loading is successful, false otherwise. - * If the core returns \c false, - * the frontend should abort the core - * and return to its main menu (if applicable). - * - * @see RETRO_ENVIRONMENT_GET_GAME_INFO_EXT - * @see RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO - * @see retro_load_game() - * @see retro_subsystem_info - */ -RETRO_API bool retro_load_game_special( - unsigned game_type, - const struct retro_game_info *info, size_t num_info -); - -/** - * Unloads the currently loaded game. - * - * @note This is called before \c retro_deinit(void). - * - * @see retro_load_game() - * @see retro_deinit() - */ -RETRO_API void retro_unload_game(void); - -/** - * Gets the region of the actively loaded content as either \c RETRO_REGION_NTSC or \c RETRO_REGION_PAL. - * @note This refers to the region of the content's intended television standard, - * not necessarily the region of the content's origin. - * For emulated consoles that don't use either standard - * (e.g. handhelds or post-HD platforms), - * the core should return \c RETRO_REGION_NTSC. - * @return The region of the actively loaded content. - * - * @see RETRO_REGION_NTSC - * @see RETRO_REGION_PAL - */ -RETRO_API unsigned retro_get_region(void); - -/** - * Get a region of memory. - * - * @param id The ID for the memory block that's desired to retrieve. Can be \c RETRO_MEMORY_SAVE_RAM, \c RETRO_MEMORY_RTC, \c RETRO_MEMORY_SYSTEM_RAM, or \c RETRO_MEMORY_VIDEO_RAM. - * - * @return A pointer to the desired region of memory, or NULL when not available. - * - * @see RETRO_MEMORY_SAVE_RAM - * @see RETRO_MEMORY_RTC - * @see RETRO_MEMORY_SYSTEM_RAM - * @see RETRO_MEMORY_VIDEO_RAM - */ -RETRO_API void *retro_get_memory_data(unsigned id); - -/** - * Gets the size of the given region of memory. - * - * @param id The ID for the memory block to check the size of. Can be RETRO_MEMORY_SAVE_RAM, RETRO_MEMORY_RTC, RETRO_MEMORY_SYSTEM_RAM, or RETRO_MEMORY_VIDEO_RAM. - * - * @return The size of the region in memory, or 0 when not available. - * - * @see RETRO_MEMORY_SAVE_RAM - * @see RETRO_MEMORY_RTC - * @see RETRO_MEMORY_SYSTEM_RAM - * @see RETRO_MEMORY_VIDEO_RAM - */ -RETRO_API size_t retro_get_memory_size(unsigned id); - -#ifdef __cplusplus -} -#endif - -#endif \ No newline at end of file diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c deleted file mode 100644 index 63d3b4d4..00000000 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ /dev/null @@ -1,405 +0,0 @@ -#include "libretro.h" -#include -#include -#include -#include -#include - -#define RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB (3 | 0x800000) - -int initialized = 0; - -typedef struct { - int type; - void* fn; - void* arg1; - void* arg2; - void* result; -} call_def_t; - -call_def_t call; - -enum call_type { - CALL_VOID = -1, - CALL_SERIALIZE = 1, - CALL_UNSERIALIZE = 2, -}; - -void *same_thread_with_args(void *f, int type, ...); - -// Input State Cache - -#define INPUT_MAX_PORTS 4 -#define INPUT_MAX_KEYS 512 - -typedef struct { - uint32_t buttons[INPUT_MAX_PORTS]; - int16_t analog[INPUT_MAX_PORTS][4]; // LX, LY, RX, RY - int16_t triggers[INPUT_MAX_PORTS][2]; // L2, R2 - - uint8_t keyboard[INPUT_MAX_KEYS]; - int16_t mouse_x; - int16_t mouse_y; - uint8_t mouse_buttons; -} input_cache_t; - -static input_cache_t input_cache = {0}; - -// Update entire port state at once -void input_cache_set_port(unsigned port, uint32_t buttons, - int16_t lx, int16_t ly, int16_t rx, int16_t ry, - int16_t l2, int16_t r2) { - if (port < INPUT_MAX_PORTS) { - input_cache.buttons[port] = buttons; - input_cache.analog[port][0] = lx; - input_cache.analog[port][1] = ly; - input_cache.analog[port][2] = rx; - input_cache.analog[port][3] = ry; - input_cache.triggers[port][0] = l2; - input_cache.triggers[port][1] = r2; - } -} - -// Keyboard update -void input_cache_set_keyboard_key(unsigned id, uint8_t pressed) { - if (id < INPUT_MAX_KEYS) { - input_cache.keyboard[id] = pressed; - } -} - -// Mouse update -void input_cache_set_mouse(int16_t dx, int16_t dy, uint8_t buttons) { - input_cache.mouse_x = dx; - input_cache.mouse_y = dy; - input_cache.mouse_buttons = buttons; -} - -void input_cache_clear(void) { - memset(&input_cache, 0, sizeof(input_cache)); -} - -void core_log_cgo(enum retro_log_level level, const char *fmt, ...) { - char msg[2048] = {0}; - va_list va; - va_start(va, fmt); - vsnprintf(msg, sizeof(msg), fmt, va); - va_end(va); - void coreLog(enum retro_log_level level, const char *msg); - coreLog(level, msg); -} - -void bridge_call(void *f) { - ((void (*)(void)) f)(); -} - -void bridge_set_callback(void *f, void *callback) { - ((void (*)(void *))f)(callback); -} - -unsigned bridge_retro_api_version(void *f) { - return ((unsigned (*)(void)) f)(); -} - -void bridge_retro_get_system_info(void *f, struct retro_system_info *si) { - ((void (*)(struct retro_system_info *)) f)(si); -} - -void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si) { - ((void (*)(struct retro_system_av_info *)) f)(si); -} - -bool bridge_retro_set_environment(void *f, void *callback) { - return ((bool (*)(retro_environment_t)) f)((retro_environment_t) callback); -} - -void bridge_retro_set_input_state(void *f, void *callback) { - ((int16_t (*)(retro_input_state_t)) f)((retro_input_state_t) callback); -} - -bool bridge_retro_load_game(void *f, struct retro_game_info *gi) { - return ((bool (*)(struct retro_game_info *)) f)(gi); -} - -size_t bridge_retro_get_memory_size(void *f, unsigned id) { - return ((size_t (*)(unsigned)) f)(id); -} - -void *bridge_retro_get_memory_data(void *f, unsigned id) { - return ((void *(*)(unsigned)) f)(id); -} - -size_t bridge_retro_serialize_size(void *f) { - return ((size_t (*)(void)) f)(); -} - -bool bridge_retro_serialize(void *f, void *data, size_t size) { - return ((bool (*)(void *, size_t)) f)(data, size); -} - -bool bridge_retro_unserialize(void *f, void *data, size_t size) { - return ((bool (*)(void *, size_t)) f)(data, size); -} - -void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device) { - ((void (*)(unsigned, unsigned)) f)(port, device); -} - -static bool clear_all_thread_waits_cb(unsigned v, void *data) { - core_log_cgo(RETRO_LOG_DEBUG, "CLEAR_ALL_THREAD_WAITS_CB (%d)\n", v); - return true; -} - -void bridge_retro_keyboard_callback(void *cb, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers) { - (*(retro_keyboard_event_t *) cb)(down, keycode, character, keyModifiers); -} - -bool core_environment_cgo(unsigned cmd, void *data) { - bool coreEnvironment(unsigned, void *); - - switch (cmd) - { - case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE: - return false; - break; - case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: - return false; - break; - case RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB: - *(retro_environment_t *)data = clear_all_thread_waits_cb; - return true; - break; - case RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS: - *(unsigned *)data = 4; - core_log_cgo(RETRO_LOG_DEBUG, "Set max users: %d\n", 4); - return true; - break; - case RETRO_ENVIRONMENT_GET_INPUT_BITMASKS: - return false; - case RETRO_ENVIRONMENT_SHUTDOWN: - return false; - break; - case RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT: - if (data != NULL) *(int *)data = RETRO_SAVESTATE_CONTEXT_NORMAL; - return true; - break; - } - - return coreEnvironment(cmd, data); -} - -void core_video_refresh_cgo(void *data, unsigned width, unsigned height, size_t pitch) { - void coreVideoRefresh(void *, unsigned, unsigned, size_t); - coreVideoRefresh(data, width, height, pitch); -} - -void core_input_poll_cgo() { -} - -int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, unsigned id) { - if (port >= INPUT_MAX_PORTS) { - return 0; - } - - switch (device) { - case RETRO_DEVICE_JOYPAD: - return (int16_t)((input_cache.buttons[port] >> id) & 1); - - case RETRO_DEVICE_ANALOG: - switch (index) { - case RETRO_DEVICE_INDEX_ANALOG_LEFT: - // id: RETRO_DEVICE_ID_ANALOG_X=0, RETRO_DEVICE_ID_ANALOG_Y=1 - if (id <= RETRO_DEVICE_ID_ANALOG_Y) { - return input_cache.analog[port][id]; - } - break; - case RETRO_DEVICE_INDEX_ANALOG_RIGHT: - // id: RETRO_DEVICE_ID_ANALOG_X=0, RETRO_DEVICE_ID_ANALOG_Y=1 - if (id <= RETRO_DEVICE_ID_ANALOG_Y) { - return input_cache.analog[port][2 + id]; - } - break; - case RETRO_DEVICE_INDEX_ANALOG_BUTTON: - // Any button can be queried as analog - // id = RETRO_DEVICE_ID_JOYPAD_* (0-15) - // For now, only L2/R2 have analog values - switch (id) { - case RETRO_DEVICE_ID_JOYPAD_L2: - return input_cache.triggers[port][0]; - case RETRO_DEVICE_ID_JOYPAD_R2: - return input_cache.triggers[port][1]; - default: - // Other buttons: return digital as 0 or 0x7fff - return ((input_cache.buttons[port] >> id) & 1) ? 0x7FFF : 0; - } - break; - } - break; - - case RETRO_DEVICE_KEYBOARD: - if (id < INPUT_MAX_KEYS) { - return input_cache.keyboard[id] ? 1 : 0; - } - break; - - case RETRO_DEVICE_MOUSE: - switch (id) { - case RETRO_DEVICE_ID_MOUSE_X: { - int16_t x = input_cache.mouse_x; - input_cache.mouse_x = 0; - return x; - } - case RETRO_DEVICE_ID_MOUSE_Y: { - int16_t y = input_cache.mouse_y; - input_cache.mouse_y = 0; - return y; - } - case RETRO_DEVICE_ID_MOUSE_LEFT: - return (input_cache.mouse_buttons & 0x01) ? 1 : 0; - case RETRO_DEVICE_ID_MOUSE_RIGHT: - return (input_cache.mouse_buttons & 0x02) ? 1 : 0; - case RETRO_DEVICE_ID_MOUSE_MIDDLE: - return (input_cache.mouse_buttons & 0x04) ? 1 : 0; - } - break; - } - - return 0; -} - -size_t core_audio_sample_batch_cgo(const int16_t *data, size_t frames) { - size_t coreAudioSampleBatch(const int16_t *, size_t); - return coreAudioSampleBatch(data, frames); -} - -void core_audio_sample_cgo(int16_t left, int16_t right) { - int16_t frame[2] = { left, right }; - core_audio_sample_batch_cgo(frame, 1); -} - -uintptr_t core_get_current_framebuffer_cgo() { - uintptr_t coreGetCurrentFramebuffer(); - return coreGetCurrentFramebuffer(); -} - -retro_proc_address_t core_get_proc_address_cgo(const char *sym) { - retro_proc_address_t coreGetProcAddress(const char *sym); - return coreGetProcAddress(sym); -} - -void bridge_context_reset(retro_hw_context_reset_t f) { - f(); -} - -void init_video_cgo() { - void initVideo(); - initVideo(); -} - -void deinit_video_cgo() { - void deinitVideo(); - deinitVideo(); -} - -typedef struct { - pthread_mutex_t m; - pthread_cond_t cond; -} mutex_t; - -void mutex_init(mutex_t *m) { - pthread_mutex_init(&m->m, NULL); - pthread_cond_init(&m->cond, NULL); -} - -void mutex_destroy(mutex_t *m) { - pthread_mutex_trylock(&m->m); - pthread_mutex_unlock(&m->m); - pthread_mutex_destroy(&m->m); - pthread_cond_signal(&m->cond); - pthread_cond_destroy(&m->cond); -} - -void mutex_lock(mutex_t *m) { pthread_mutex_lock(&m->m); } -void mutex_wait(mutex_t *m) { pthread_cond_wait(&m->cond, &m->m); } -void mutex_unlock(mutex_t *m) { pthread_mutex_unlock(&m->m); } -void mutex_signal(mutex_t *m) { pthread_cond_signal(&m->cond); } - -static pthread_t thread; -mutex_t run_mutex, done_mutex; - -void *run_loop(void *unused) { - core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop start\n"); - mutex_lock(&done_mutex); - mutex_lock(&run_mutex); - mutex_signal(&done_mutex); - mutex_unlock(&done_mutex); - while (initialized) { - mutex_wait(&run_mutex); - switch (call.type) { - case CALL_SERIALIZE: - case CALL_UNSERIALIZE: - *(bool*)call.result = ((bool (*)(void*, size_t))call.fn)(call.arg1, *(size_t*)call.arg2); - break; - default: - ((void (*)(void)) call.fn)(); - } - mutex_lock(&done_mutex); - mutex_signal(&done_mutex); - mutex_unlock(&done_mutex); - } - mutex_destroy(&run_mutex); - mutex_destroy(&done_mutex); - pthread_detach(thread); - core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop stop\n"); - pthread_exit(NULL); -} - -void same_thread_stop() { - initialized = 0; -} - -void *same_thread_with_args(void *f, int type, ...) { - if (!initialized) { - initialized = 1; - mutex_init(&run_mutex); - mutex_init(&done_mutex); - mutex_lock(&done_mutex); - pthread_create(&thread, NULL, run_loop, NULL); - mutex_wait(&done_mutex); - mutex_unlock(&done_mutex); - } - mutex_lock(&run_mutex); - mutex_lock(&done_mutex); - - call.type = type; - call.fn = f; - - if (type != CALL_VOID) { - va_list args; - va_start(args, type); - switch (type) { - case CALL_SERIALIZE: - case CALL_UNSERIALIZE: - call.arg1 = va_arg(args, void*); - size_t size; - size = va_arg(args, size_t); - call.arg2 = &size; - bool result; - call.result = &result; - break; - } - va_end(args); - } - mutex_signal(&run_mutex); - mutex_unlock(&run_mutex); - mutex_wait(&done_mutex); - mutex_unlock(&done_mutex); - return call.result; -} - -void *same_thread_with_args2(void *f, int type, void *arg1, void *arg2) { - return same_thread_with_args(f, type, arg1, arg2); -} - -void same_thread(void *f) { - same_thread_with_args(f, CALL_VOID); -} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go deleted file mode 100644 index 5d34dca3..00000000 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ /dev/null @@ -1,943 +0,0 @@ -package nanoarch - -import ( - "errors" - "fmt" - "maps" - "path/filepath" - "runtime" - "strings" - "sync" - "sync/atomic" - "time" - "unsafe" - - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/os" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics" - "github.com/giongto35/cloud-game/v3/pkg/worker/thread" -) - -/* -#include "libretro.h" -#include "nanoarch.h" -#include -*/ -import "C" - -var ( - RGBA5551 = PixFmt{C: 0, BPP: 2} // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha - RGBA8888Rev = PixFmt{C: 1, BPP: 4} // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha - RGB565 = PixFmt{C: 2, BPP: 2} // BIT_FORMAT_SHORT_5_6_5 has 5 bits R, 6 bits G, 5 bits -) - -type Nanoarch struct { - Handlers - - keyboard KeyboardState - mouse MouseState - retropad InputState - - keyboardCb *C.struct_retro_keyboard_callback - LastFrameTime int64 - LibCo bool - meta Metadata - options map[string]string - options4rom map[string]map[string]string - reserved chan struct{} // limits concurrent use - Rot uint - serializeSize C.size_t - Stopped atomic.Bool - sys struct { - av C.struct_retro_system_av_info - i C.struct_retro_system_info - api C.unsigned - } - tickTime int64 - cSaveDirectory *C.char - cSystemDirectory *C.char - cUserName *C.char - Video struct { - gl struct { - enabled bool - autoCtx bool - } - hw *C.struct_retro_hw_render_callback - PixFmt PixFmt - } - vfr bool - Aspect bool - sdlCtx *graphics.SDL - hackSkipHwContextDestroy bool - hackSkipSameThreadSave bool - limiter func(func()) - log *logger.Logger -} - -type Handlers struct { - OnAudio func(ptr unsafe.Pointer, frames int) - OnVideo func(data []byte, delta int32, fi FrameInfo) - OnDup func() - OnSystemAvInfo func() -} - -type FrameInfo struct { - W uint - H uint - Stride uint -} - -type Metadata struct { - FrameDup bool - LibPath string // the full path to some emulator lib - IsGlAllowed bool - UsesLibCo bool - AutoGlContext bool - HasVFR bool - Options map[string]string - Options4rom map[string]map[string]string - Hacks []string - Hid map[int][]int - CoreAspectRatio bool - KbMouseSupport bool - LibExt string -} - -type PixFmt struct { - C uint32 - BPP uint -} - -func (p PixFmt) String() string { - switch p.C { - case 0: - return "RGBA5551/2" - case 1: - return "RGBA8888Rev/4" - case 2: - return "RGB565/2" - default: - return fmt.Sprintf("Unknown (%v/%v)", p.C, p.BPP) - } -} - -// Nan0 is a global link for C callbacks to Go -var Nan0 = Nanoarch{ - reserved: make(chan struct{}, 1), // this thing forbids concurrent use of the emulator - Stopped: atomic.Bool{}, - limiter: func(fn func()) { fn() }, - Handlers: Handlers{ - OnAudio: func(unsafe.Pointer, int) {}, - OnVideo: func([]byte, int32, FrameInfo) {}, - OnDup: func() {}, - }, -} - -// init provides a global single instance lock -// !to remove when isolated properly -func init() { Nan0.reserved <- struct{}{} } - -func NewNano(localPath string) *Nanoarch { - nano := &Nan0 - nano.cSaveDirectory = C.CString(localPath + "/legacy_save") - nano.cSystemDirectory = C.CString(localPath + "/system") - nano.cUserName = C.CString("retro") - return nano -} - -func (n *Nanoarch) AspectRatio() float32 { return float32(n.sys.av.geometry.aspect_ratio) } -func (n *Nanoarch) AudioSampleRate() int { return int(n.sys.av.timing.sample_rate) } -func (n *Nanoarch) VideoFramerate() int { return int(n.sys.av.timing.fps) } -func (n *Nanoarch) IsPortrait() bool { return 90 == n.Rot%180 } -func (n *Nanoarch) KbMouseSupport() bool { return n.meta.KbMouseSupport } -func (n *Nanoarch) BaseWidth() int { return int(n.sys.av.geometry.base_width) } -func (n *Nanoarch) BaseHeight() int { return int(n.sys.av.geometry.base_height) } -func (n *Nanoarch) WaitReady() { <-n.reserved } -func (n *Nanoarch) Close() { n.Stopped.Store(true); n.reserved <- struct{}{} } -func (n *Nanoarch) SetLogger(log *logger.Logger) { n.log = log } -func (n *Nanoarch) SetVideoDebounce(t time.Duration) { n.limiter = NewLimit(t) } -func (n *Nanoarch) SaveDir() string { return C.GoString(n.cSaveDirectory) } -func (n *Nanoarch) SetSaveDirSuffix(sx string) { - dir := C.GoString(n.cSaveDirectory) + "/" + sx - err := os.CheckCreateDir(dir) - if err != nil { - n.log.Error().Msgf("couldn't create %v, %v", dir, err) - } - if n.cSaveDirectory != nil { - C.free(unsafe.Pointer(n.cSaveDirectory)) - } - n.cSaveDirectory = C.CString(dir) -} -func (n *Nanoarch) DeleteSaveDir() error { - if n.cSaveDirectory == nil { - return nil - } - - dir := C.GoString(n.cSaveDirectory) - return os.RemoveAll(dir) -} - -func (n *Nanoarch) CoreLoad(meta Metadata) { - var err error - n.meta = meta - n.LibCo = meta.UsesLibCo - n.vfr = meta.HasVFR - n.Aspect = meta.CoreAspectRatio - n.Video.gl.autoCtx = meta.AutoGlContext - n.Video.gl.enabled = meta.IsGlAllowed - - thread.SwitchGraphics(n.Video.gl.enabled) - - // hacks - Nan0.hackSkipHwContextDestroy = meta.HasHack("skip_hw_context_destroy") - Nan0.hackSkipSameThreadSave = meta.HasHack("skip_same_thread_save") - - // reset controllers - n.retropad = InputState{} - n.keyboardCb = nil - n.keyboard = KeyboardState{} - n.mouse = MouseState{} - - n.options = maps.Clone(meta.Options) - n.options4rom = meta.Options4rom - - corePath := meta.LibPath + meta.LibExt - coreLib, err = loadLib(corePath) - // fallback to sequential lib loader (first successfully loaded) - if err != nil { - n.log.Error().Err(err).Msgf("load fail: %v", corePath) - coreLib, err = loadLibRollingRollingRolling(corePath) - if err != nil { - n.log.Fatal().Err(err).Msgf("core load: %s", corePath) - } - } - - retroInit = loadFunction(coreLib, "retro_init") - retroDeinit = loadFunction(coreLib, "retro_deinit") - retroAPIVersion = loadFunction(coreLib, "retro_api_version") - retroGetSystemInfo = loadFunction(coreLib, "retro_get_system_info") - retroGetSystemAVInfo = loadFunction(coreLib, "retro_get_system_av_info") - retroSetEnvironment = loadFunction(coreLib, "retro_set_environment") - retroSetVideoRefresh = loadFunction(coreLib, "retro_set_video_refresh") - retroSetInputPoll = loadFunction(coreLib, "retro_set_input_poll") - retroSetInputState = loadFunction(coreLib, "retro_set_input_state") - retroSetAudioSample = loadFunction(coreLib, "retro_set_audio_sample") - retroSetAudioSampleBatch = loadFunction(coreLib, "retro_set_audio_sample_batch") - retroReset = loadFunction(coreLib, "retro_reset") - retroRun = loadFunction(coreLib, "retro_run") - retroLoadGame = loadFunction(coreLib, "retro_load_game") - retroUnloadGame = loadFunction(coreLib, "retro_unload_game") - retroSerializeSize = loadFunction(coreLib, "retro_serialize_size") - retroSerialize = loadFunction(coreLib, "retro_serialize") - retroUnserialize = loadFunction(coreLib, "retro_unserialize") - retroSetControllerPortDevice = loadFunction(coreLib, "retro_set_controller_port_device") - retroGetMemorySize = loadFunction(coreLib, "retro_get_memory_size") - retroGetMemoryData = loadFunction(coreLib, "retro_get_memory_data") - - C.bridge_retro_set_environment(retroSetEnvironment, C.core_environment_cgo) - C.bridge_retro_set_input_state(retroSetInputState, C.core_input_state_cgo) - C.bridge_set_callback(retroSetVideoRefresh, C.core_video_refresh_cgo) - C.bridge_set_callback(retroSetInputPoll, C.core_input_poll_cgo) - C.bridge_set_callback(retroSetAudioSample, C.core_audio_sample_cgo) - C.bridge_set_callback(retroSetAudioSampleBatch, C.core_audio_sample_batch_cgo) - - if n.LibCo { - C.same_thread(retroInit) - } else { - C.bridge_call(retroInit) - } - - n.sys.api = C.bridge_retro_api_version(retroAPIVersion) - C.bridge_retro_get_system_info(retroGetSystemInfo, &n.sys.i) - n.log.Info().Msgf("System >>> %v (%v) [%v] nfp: %v, api: %v", - C.GoString(n.sys.i.library_name), C.GoString(n.sys.i.library_version), - C.GoString(n.sys.i.valid_extensions), bool(n.sys.i.need_fullpath), - uint(n.sys.api)) -} - -func (n *Nanoarch) LoadGame(path string) error { - game := C.struct_retro_game_info{} - - big := bool(n.sys.i.need_fullpath) // big ROMs are loaded by cores later - if big { - size, err := os.StatSize(path) - if err != nil { - return err - } - game.size = C.size_t(size) - } else { - bytes, err := os.ReadFile(path) - if err != nil { - return err - } - // !to pin in 1.21 - ptr := unsafe.Pointer(C.CBytes(bytes)) - game.data = ptr - game.size = C.size_t(len(bytes)) - defer C.free(ptr) - } - fp := C.CString(path) - defer C.free(unsafe.Pointer(fp)) - game.path = fp - - n.log.Debug().Msgf("ROM - big: %v, size: %v", big, byteCountBinary(int64(game.size))) - - // maybe some custom options - if n.options4rom != nil { - romName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - if _, ok := n.options4rom[romName]; ok { - for k, v := range n.options4rom[romName] { - n.options[k] = v - n.log.Debug().Msgf("Replace: %v=%v", k, v) - } - } - } - - if ok := C.bridge_retro_load_game(retroLoadGame, &game); !ok { - return fmt.Errorf("core failed to load ROM: %v", path) - } - - var av C.struct_retro_system_av_info - C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &av) - n.log.Info().Msgf("System A/V >>> %vx%v (%vx%v), [%vfps], AR [%v], audio [%vHz]", - av.geometry.base_width, av.geometry.base_height, - av.geometry.max_width, av.geometry.max_height, - av.timing.fps, av.geometry.aspect_ratio, av.timing.sample_rate, - ) - if isGeometryDifferent(av.geometry) { - geometryChange(av.geometry) - } - n.sys.av = av - - n.serializeSize = C.bridge_retro_serialize_size(retroSerializeSize) - n.log.Info().Msgf("Save file size: %v", byteCountBinary(int64(n.serializeSize))) - - Nan0.tickTime = int64(time.Second / time.Duration(n.sys.av.timing.fps)) - if n.vfr { - n.log.Info().Msgf("variable framerate (VFR) is enabled") - } - - n.Stopped.Store(false) - - if n.Video.gl.enabled { - if n.LibCo { - C.same_thread(C.init_video_cgo) - C.same_thread(unsafe.Pointer(Nan0.Video.hw.context_reset)) - } else { - runtime.LockOSThread() - initVideo() - C.bridge_context_reset(Nan0.Video.hw.context_reset) - runtime.UnlockOSThread() - } - } - - // set default controller types on all ports - // needed for nestopia - for i := range maxPort { - C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD) - } - - // map custom devices to ports - for k, v := range n.meta.Hid { - for _, device := range v { - C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(k), C.unsigned(device)) - n.log.Debug().Msgf("set custom port-device: %v:%v", k, device) - } - } - - n.LastFrameTime = time.Now().UnixNano() - - return nil -} - -func (n *Nanoarch) Shutdown() { - if n.LibCo { - thread.Main(func() { - C.same_thread(retroUnloadGame) - C.same_thread(retroDeinit) - if n.Video.gl.enabled { - C.same_thread(C.deinit_video_cgo) - } - C.same_thread(C.same_thread_stop) - }) - } else { - if n.Video.gl.enabled { - thread.Main(func() { - // running inside a go routine, lock the thread to make sure the OpenGL context stays current - runtime.LockOSThread() - if err := n.sdlCtx.BindContext(); err != nil { - n.log.Error().Err(err).Msg("ctx switch fail") - } - }) - } - C.bridge_call(retroUnloadGame) - C.bridge_call(retroDeinit) - if n.Video.gl.enabled { - thread.Main(func() { - deinitVideo() - runtime.UnlockOSThread() - }) - } - } - - setRotation(0) - Nan0.sys.av = C.struct_retro_system_av_info{} - if err := closeLib(coreLib); err != nil { - n.log.Error().Err(err).Msg("lib close failed") - } - n.options = nil - n.options4rom = nil - C.free(unsafe.Pointer(n.cUserName)) - C.free(unsafe.Pointer(n.cSaveDirectory)) - C.free(unsafe.Pointer(n.cSystemDirectory)) -} - -func (n *Nanoarch) Reset() { - C.bridge_call(retroReset) -} - -func (n *Nanoarch) syncInputToCache() { - n.retropad.SyncToCache() - if n.keyboardCb != nil { - n.keyboard.SyncToCache() - } - n.mouse.SyncToCache() -} - -func (n *Nanoarch) Run() { - n.syncInputToCache() - - if n.LibCo { - C.same_thread(retroRun) - } else { - if n.Video.gl.enabled { - runtime.LockOSThread() - if err := n.sdlCtx.BindContext(); err != nil { - n.log.Error().Err(err).Msg("ctx bind fail") - } - } - C.bridge_call(retroRun) - if n.Video.gl.enabled { - runtime.UnlockOSThread() - } - } -} - -func (n *Nanoarch) IsSupported() error { return graphics.TryInit() } -func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled } -func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() } -func (n *Nanoarch) InputRetropad(port int, data []byte) { n.retropad.SetInput(port, data) } -func (n *Nanoarch) InputKeyboard(_ int, data []byte) { - if n.keyboardCb == nil { - return - } - - // we should preserve the state of pressed buttons for the input poll function (each retro_run) - // and explicitly call the retro_keyboard_callback function when a keyboard event happens - pressed, key, mod := n.keyboard.SetKey(data) - C.bridge_retro_keyboard_callback(unsafe.Pointer(n.keyboardCb), C.bool(pressed), - C.unsigned(key), C.uint32_t(0), C.uint16_t(mod)) -} -func (n *Nanoarch) InputMouse(_ int, data []byte) { - if len(data) == 0 { - return - } - - t := data[0] - state := data[1:] - switch t { - case MouseMove: - n.mouse.ShiftPos(state) - case MouseButton: - n.mouse.SetButtons(state[0]) - } -} - -func videoSetPixelFormat(format uint32) (C.bool, error) { - switch format { - case C.RETRO_PIXEL_FORMAT_0RGB1555: - Nan0.Video.PixFmt = RGBA5551 - if err := graphics.SetPixelFormat(graphics.UnsignedShort5551); err != nil { - return false, fmt.Errorf("unknown pixel format %v", Nan0.Video.PixFmt) - } - case C.RETRO_PIXEL_FORMAT_XRGB8888: - Nan0.Video.PixFmt = RGBA8888Rev - if err := graphics.SetPixelFormat(graphics.UnsignedInt8888Rev); err != nil { - return false, fmt.Errorf("unknown pixel format %v", Nan0.Video.PixFmt) - } - case C.RETRO_PIXEL_FORMAT_RGB565: - Nan0.Video.PixFmt = RGB565 - if err := graphics.SetPixelFormat(graphics.UnsignedShort565); err != nil { - return false, fmt.Errorf("unknown pixel format %v", Nan0.Video.PixFmt) - } - default: - return false, fmt.Errorf("unknown pixel type %v", format) - } - Nan0.log.Info().Msgf("Pixel format: %v", Nan0.Video.PixFmt) - - return true, nil -} - -func setRotation(rot uint) { - Nan0.Rot = rot - Nan0.log.Debug().Msgf("Image rotated %v°", rot) -} - -func printOpenGLDriverInfo() { - var openGLInfo strings.Builder - openGLInfo.Grow(128) - version, vendor, renderrer, glsl := graphics.GLInfo() - openGLInfo.WriteString(fmt.Sprintf("\n[OpenGL] Version: %v\n", version)) - openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Vendor: %v\n", vendor)) - openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Renderer: %v\n", renderrer)) - openGLInfo.WriteString(fmt.Sprintf("[OpenGL] GLSL Version: %v", glsl)) - Nan0.log.Debug().Msg(openGLInfo.String()) -} - -// State defines any memory state of the emulator -type State []byte - -type mem struct { - ptr unsafe.Pointer - size uint -} - -const ( - CallSerialize = 1 - CallUnserialize = 2 -) - -// SaveState returns emulator internal state. -func SaveState() (State, error) { - size := C.bridge_retro_serialize_size(retroSerializeSize) - data := make([]byte, uint(size)) - rez := false - - if Nan0.LibCo && !Nan0.hackSkipSameThreadSave { - rez = *(*bool)(C.same_thread_with_args2(retroSerialize, C.int(CallSerialize), unsafe.Pointer(&data[0]), unsafe.Pointer(&size))) - } else { - rez = bool(C.bridge_retro_serialize(retroSerialize, unsafe.Pointer(&data[0]), size)) - } - - if !rez { - return nil, errors.New("retro_serialize failed") - } - - return data, nil -} - -// RestoreSaveState restores emulator internal state. -func RestoreSaveState(st State) error { - if len(st) <= 0 { - return errors.New("empty load state") - } - - size := C.size_t(len(st)) - rez := false - - if Nan0.LibCo { - rez = *(*bool)(C.same_thread_with_args2(retroUnserialize, C.int(CallUnserialize), unsafe.Pointer(&st[0]), unsafe.Pointer(&size))) - } else { - rez = bool(C.bridge_retro_unserialize(retroUnserialize, unsafe.Pointer(&st[0]), size)) - } - - if !rez { - return errors.New("retro_unserialize failed") - } - - return nil -} - -// SaveRAM returns the game save RAM (cartridge) data or a nil slice. -func SaveRAM() State { - memory := ptSaveRAM() - if memory == nil { - return nil - } - return C.GoBytes(memory.ptr, C.int(memory.size)) -} - -// RestoreSaveRAM restores game save RAM. -func RestoreSaveRAM(st State) { - if len(st) > 0 { - if memory := ptSaveRAM(); memory != nil { - //noinspection GoRedundantConversion - copy(unsafe.Slice((*byte)(memory.ptr), memory.size), st) - } - } -} - -// memorySize returns memory region size. -func memorySize(id C.uint) uint { - return uint(C.bridge_retro_get_memory_size(retroGetMemorySize, id)) -} - -// memoryData returns a pointer to memory data. -func memoryData(id C.uint) unsafe.Pointer { - return C.bridge_retro_get_memory_data(retroGetMemoryData, id) -} - -// ptSaveRam return SRAM memory pointer if core supports it or nil. -func ptSaveRAM() *mem { - ptr, size := memoryData(C.RETRO_MEMORY_SAVE_RAM), memorySize(C.RETRO_MEMORY_SAVE_RAM) - if ptr == nil || size == 0 { - return nil - } - return &mem{ptr: ptr, size: size} -} - -func byteCountBinary(b int64) string { - const unit = 1024 - if b < unit { - return fmt.Sprintf("%d B", b) - } - div, exp := int64(unit), 0 - for n := b / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) -} - -func (m Metadata) HasHack(h string) bool { - for _, n := range m.Hacks { - if h == n { - return true - } - } - return false -} - -var ( - retroAPIVersion unsafe.Pointer - retroDeinit unsafe.Pointer - retroGetSystemAVInfo unsafe.Pointer - retroGetSystemInfo unsafe.Pointer - coreLib unsafe.Pointer - retroInit unsafe.Pointer - retroLoadGame unsafe.Pointer - retroReset unsafe.Pointer - retroRun unsafe.Pointer - retroSetAudioSample unsafe.Pointer - retroSetAudioSampleBatch unsafe.Pointer - retroSetControllerPortDevice unsafe.Pointer - retroSetEnvironment unsafe.Pointer - retroSetInputPoll unsafe.Pointer - retroSetInputState unsafe.Pointer - retroSetVideoRefresh unsafe.Pointer - retroUnloadGame unsafe.Pointer - retroGetMemoryData unsafe.Pointer - retroGetMemorySize unsafe.Pointer - retroSerialize unsafe.Pointer - retroSerializeSize unsafe.Pointer - retroUnserialize unsafe.Pointer -) - -//export coreVideoRefresh -func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { - if Nan0.Stopped.Load() { - return - } - - // some frames can be rendered slower or faster than internal 1/fps core tick - // so track actual frame render time for proper RTP packet timestamps - // (and proper frame display time, for example: 1->1/60=16.6ms, 2->10ms, 3->23ms, 4->16.6ms) - // this is useful only for cores with variable framerate, for the fixed framerate cores this adds stutter - // !to find docs on Libretro refresh sync and frame times - dt := Nan0.tickTime - if Nan0.vfr { - t := time.Now().UnixNano() - dt = t - Nan0.LastFrameTime - Nan0.LastFrameTime = t - } - - // when the core returns a duplicate frame - if data == nil { - Nan0.Handlers.OnDup() - return - } - - // calculate real frame width in pixels from packed data (realWidth >= width) - // some cores or games output zero pitch, i.e. N64 Mupen - bpp := Nan0.Video.PixFmt.BPP - if packed == 0 { - packed = width * bpp - } - // calculate space for the video frame - bytes := packed * height - - var data_ []byte - if data != C.RETRO_HW_FRAME_BUFFER_VALID { - //noinspection GoRedundantConversion - data_ = unsafe.Slice((*byte)(data), bytes) - } else { - // if Libretro renders frame with OpenGL context - data_ = graphics.ReadFramebuffer(bytes, width, height) - } - - // some cores or games have a variable output frame size, i.e. PSX Rearmed - // also we have an option of xN output frame magnification - // so, it may be rescaled - - Nan0.Handlers.OnVideo(data_, int32(dt), FrameInfo{W: width, H: height, Stride: packed}) -} - -//export coreAudioSampleBatch -func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t { - if Nan0.Stopped.Load() { - return frames - } - Nan0.Handlers.OnAudio(data, int(frames)<<1) - return frames -} - -func m(m *C.char) string { return strings.TrimRight(C.GoString(m), "\n") } - -//export coreLog -func coreLog(level C.enum_retro_log_level, msg *C.char) { - switch level { - // with debug level cores have too much logs - case C.RETRO_LOG_DEBUG: - Nan0.log.Debug().MsgFunc(func() string { return m(msg) }) - case C.RETRO_LOG_INFO: - Nan0.log.Info().MsgFunc(func() string { return m(msg) }) - case C.RETRO_LOG_WARN: - Nan0.log.Warn().MsgFunc(func() string { return m(msg) }) - case C.RETRO_LOG_ERROR: - Nan0.log.Error().MsgFunc(func() string { return m(msg) }) - default: - Nan0.log.Log().MsgFunc(func() string { return m(msg) }) - // RETRO_LOG_DUMMY = INT_MAX - } -} - -//export coreGetCurrentFramebuffer -func coreGetCurrentFramebuffer() C.uintptr_t { return (C.uintptr_t)(graphics.GlFbo()) } - -//export coreGetProcAddress -func coreGetProcAddress(sym *C.char) C.retro_proc_address_t { - return (C.retro_proc_address_t)(graphics.GlProcAddress(C.GoString(sym))) -} - -//export coreEnvironment -func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { - - // see core_environment_cgo - - switch cmd { - case C.RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO: - Nan0.log.Debug().Msgf("retro_set_system_av_info") - av := *(*C.struct_retro_system_av_info)(data) - if isGeometryDifferent(av.geometry) { - geometryChange(av.geometry) - } - return true - case C.RETRO_ENVIRONMENT_SET_GEOMETRY: - Nan0.log.Debug().Msgf("retro_set_geometry") - geom := *(*C.struct_retro_game_geometry)(data) - if isGeometryDifferent(geom) { - geometryChange(geom) - } - return true - case C.RETRO_ENVIRONMENT_SET_ROTATION: - setRotation((*(*uint)(data) % 4) * 90) - return true - case C.RETRO_ENVIRONMENT_GET_CAN_DUPE: - dup := C.bool(Nan0.meta.FrameDup) - *(*C.bool)(data) = dup - return dup - case C.RETRO_ENVIRONMENT_GET_USERNAME: - *(**C.char)(data) = Nan0.cUserName - return true - case C.RETRO_ENVIRONMENT_GET_LOG_INTERFACE: - cb := (*C.struct_retro_log_callback)(data) - cb.log = (C.retro_log_printf_t)(C.core_log_cgo) - return true - case C.RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: - res, err := videoSetPixelFormat(*(*C.enum_retro_pixel_format)(data)) - if err != nil { - Nan0.log.Fatal().Err(err).Msg("pix format failed") - } - return res - case C.RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: - *(**C.char)(data) = Nan0.cSystemDirectory - return true - case C.RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: - *(**C.char)(data) = Nan0.cSaveDirectory - return true - case C.RETRO_ENVIRONMENT_SET_MESSAGE: - // only with the Libretro debug mode - if Nan0.log.GetLevel() < logger.InfoLevel { - message := (*C.struct_retro_message)(data) - msg := C.GoString(message.msg) - Nan0.log.Debug().Msgf("message: %v", msg) - return true - } - return false - case C.RETRO_ENVIRONMENT_GET_VARIABLE: - if Nan0.options == nil { - return false - } - rv := (*C.struct_retro_variable)(data) - key := C.GoString(rv.key) - if v, ok := Nan0.options[key]; ok { - // make Go strings null-terminated copies ;_; - Nan0.options[key] = v + "\x00" - ptr := unsafe.Pointer(unsafe.StringData(Nan0.options[key])) - var p runtime.Pinner - p.Pin(ptr) - defer p.Unpin() - // cast to C string and set the value - rv.value = (*C.char)(ptr) - Nan0.log.Debug().Msgf("Set %v=%v", key, v) - return true - } - return false - case C.RETRO_ENVIRONMENT_SET_HW_RENDER: - if Nan0.Video.gl.enabled { - Nan0.Video.hw = (*C.struct_retro_hw_render_callback)(data) - Nan0.Video.hw.get_current_framebuffer = (C.retro_hw_get_current_framebuffer_t)(C.core_get_current_framebuffer_cgo) - Nan0.Video.hw.get_proc_address = (C.retro_hw_get_proc_address_t)(C.core_get_proc_address_cgo) - return true - } - return false - case C.RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: - if Nan0.log.GetLevel() > logger.DebugLevel { - return false - } - - info := (*[64]C.struct_retro_controller_info)(data) - for c, controller := range info { - tp := unsafe.Pointer(controller.types) - if tp == nil { - break - } - cInfo := strings.Builder{} - cInfo.WriteString(fmt.Sprintf("Controller [%v] ", c)) - cd := (*[32]C.struct_retro_controller_description)(tp) - delim := ", " - n := int(controller.num_types) - for i := range n { - if i == n-1 { - delim = "" - } - cInfo.WriteString(fmt.Sprintf("%v: %v%s", cd[i].id, C.GoString(cd[i].desc), delim)) - } - //Nan0.log.Debug().Msgf("%v", cInfo.String()) - } - return true - case C.RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK: - Nan0.log.Debug().Msgf("Keyboard event callback was set") - Nan0.keyboardCb = (*C.struct_retro_keyboard_callback)(data) - return true - } - return false -} - -//export initVideo -func initVideo() { - var context graphics.Context - switch Nan0.Video.hw.context_type { - case C.RETRO_HW_CONTEXT_NONE: - context = graphics.CtxNone - case C.RETRO_HW_CONTEXT_OPENGL: - context = graphics.CtxOpenGl - case C.RETRO_HW_CONTEXT_OPENGLES2: - context = graphics.CtxOpenGlEs2 - case C.RETRO_HW_CONTEXT_OPENGL_CORE: - context = graphics.CtxOpenGlCore - case C.RETRO_HW_CONTEXT_OPENGLES3: - context = graphics.CtxOpenGlEs3 - case C.RETRO_HW_CONTEXT_OPENGLES_VERSION: - context = graphics.CtxOpenGlEsVersion - case C.RETRO_HW_CONTEXT_VULKAN: - context = graphics.CtxVulkan - case C.RETRO_HW_CONTEXT_DUMMY: - context = graphics.CtxDummy - default: - context = graphics.CtxUnknown - } - - thread.Main(func() { - var err error - Nan0.sdlCtx, err = graphics.NewSDLContext(graphics.Config{ - Ctx: context, - W: int(Nan0.sys.av.geometry.max_width), - H: int(Nan0.sys.av.geometry.max_height), - GLAutoContext: Nan0.Video.gl.autoCtx, - GLVersionMajor: uint(Nan0.Video.hw.version_major), - GLVersionMinor: uint(Nan0.Video.hw.version_minor), - GLHasDepth: bool(Nan0.Video.hw.depth), - GLHasStencil: bool(Nan0.Video.hw.stencil), - }) - if err != nil { - panic(err) - } - }) - - if Nan0.log.GetLevel() < logger.InfoLevel { - printOpenGLDriverInfo() - } -} - -//export deinitVideo -func deinitVideo() { - if !Nan0.hackSkipHwContextDestroy { - C.bridge_context_reset(Nan0.Video.hw.context_destroy) - } - thread.Main(func() { - if err := Nan0.sdlCtx.Deinit(); err != nil { - Nan0.log.Error().Err(err).Msg("deinit fail") - } - }) - Nan0.Video.gl.enabled = false - Nan0.Video.gl.autoCtx = false - Nan0.hackSkipHwContextDestroy = false - Nan0.hackSkipSameThreadSave = false - thread.SwitchGraphics(false) -} - -type limit struct { - d time.Duration - t *time.Timer - mu sync.Mutex -} - -func NewLimit(d time.Duration) func(f func()) { - l := &limit{d: d} - return func(f func()) { l.push(f) } -} - -func (d *limit) push(f func()) { - d.mu.Lock() - defer d.mu.Unlock() - if d.t != nil { - d.t.Stop() - } - d.t = time.AfterFunc(d.d, f) -} - -func geometryChange(geom C.struct_retro_game_geometry) { - Nan0.limiter(func() { - old := Nan0.sys.av.geometry - Nan0.sys.av.geometry = geom - - if Nan0.Video.gl.enabled && (old.max_width != geom.max_width || old.max_height != geom.max_height) { - // (for LRPS2) makes the max height bigger increasing SDL2 and OpenGL buffers slightly - Nan0.sys.av.geometry.max_height = C.unsigned(float32(Nan0.sys.av.geometry.max_height) * 1.5) - bufS := uint(geom.max_width*Nan0.sys.av.geometry.max_height) * Nan0.Video.PixFmt.BPP - graphics.SetBuffer(int(bufS)) - Nan0.log.Debug().Msgf("OpenGL frame buffer: %v", bufS) - } - - if Nan0.OnSystemAvInfo != nil { - Nan0.log.Debug().Msgf(">>> geometry change %v -> %v", old, geom) - go Nan0.OnSystemAvInfo() - } - }) -} - -func isGeometryDifferent(geom C.struct_retro_game_geometry) bool { - return Nan0.sys.av.geometry.base_width != geom.base_width || - Nan0.sys.av.geometry.base_height != geom.base_height -} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.h b/pkg/worker/caged/libretro/nanoarch/nanoarch.h deleted file mode 100644 index d8e09265..00000000 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef FRONTEND_H__ -#define FRONTEND_H__ - -void bridge_call(void *f); -void bridge_set_callback(void *f, void *callback); - -bool bridge_retro_load_game(void *f, struct retro_game_info *gi); -bool bridge_retro_serialize(void *f, void *data, size_t size); -size_t bridge_retro_serialize_size(void *f); -bool bridge_retro_unserialize(void *f, void *data, size_t size); -bool bridge_retro_set_environment(void *f, void *callback); -unsigned bridge_retro_api_version(void *f); -size_t bridge_retro_get_memory_size(void *f, unsigned id); -void *bridge_retro_get_memory_data(void *f, unsigned id); -void bridge_context_reset(retro_hw_context_reset_t f); -void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si); -void bridge_retro_get_system_info(void *f, struct retro_system_info *si); -void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device); -void bridge_retro_set_input_state(void *f, void *callback); -void bridge_retro_keyboard_callback(void *f, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers); - -bool core_environment_cgo(unsigned cmd, void *data); -int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, unsigned id); -retro_proc_address_t core_get_proc_address_cgo(const char *sym); -size_t core_audio_sample_batch_cgo(const int16_t *data, size_t frames); -uintptr_t core_get_current_framebuffer_cgo(); -void core_audio_sample_cgo(int16_t left, int16_t right); -void core_input_poll_cgo(); -void core_log_cgo(int level, const char *msg); -void core_video_refresh_cgo(void *data, unsigned width, unsigned height, size_t pitch); -void init_video_cgo(); -void deinit_video_cgo(); - -void same_thread(void *f); -void *same_thread_with_args2(void *f, int type, void *arg1, void *arg2); -void same_thread_stop(); - -#endif diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch_test.go b/pkg/worker/caged/libretro/nanoarch/nanoarch_test.go deleted file mode 100644 index c92c89e8..00000000 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package nanoarch - -import ( - "sync/atomic" - "testing" - "time" -) - -func TestLimit(t *testing.T) { - c := atomic.Int32{} - lim := NewLimit(50 * time.Millisecond) - - for range 10 { - lim(func() { - c.Add(1) - }) - } - - if c.Load() > 1 { - t.Errorf("should be just 1") - } -} diff --git a/pkg/worker/caged/libretro/recording.go b/pkg/worker/caged/libretro/recording.go deleted file mode 100644 index 64734536..00000000 --- a/pkg/worker/caged/libretro/recording.go +++ /dev/null @@ -1,72 +0,0 @@ -package libretro - -import ( - "time" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" - "github.com/giongto35/cloud-game/v3/pkg/worker/recorder" -) - -type RecordingFrontend struct { - Emulator - rec *recorder.Recording -} - -func WithRecording(fe Emulator, rec bool, user string, game string, conf config.Recording, log *logger.Logger) *RecordingFrontend { - rr := &RecordingFrontend{Emulator: fe, rec: recorder.NewRecording( - recorder.Meta{UserName: user}, - log, - recorder.Options{ - Dir: conf.Folder, - Game: game, - Name: conf.Name, - Zip: conf.Zip, - Vsync: true, - Flip: fe.Flipped(), - })} - rr.ToggleRecording(rec, user) - return rr -} - -func (r *RecordingFrontend) SetAudioCb(fn func(app.Audio)) { - r.Emulator.SetAudioCb(func(audio app.Audio) { - if r.IsRecording() { - pcm := audio.Data - // example: 1600 = x / 1000 * 48000 * 2 - l := time.Duration(float64(len(pcm)) / float64(r.AudioSampleRate()<<1) * 1000000000) - r.rec.WriteAudio(recorder.Audio{Samples: pcm, Duration: l}) - } - fn(audio) - }) -} - -func (r *RecordingFrontend) SetVideoCb(fn func(app.Video)) { - r.Emulator.SetVideoCb(func(v app.Video) { - if r.IsRecording() { - r.rec.WriteVideo(recorder.Video{Frame: recorder.Frame(v.Frame), Duration: time.Duration(v.Duration)}) - } - fn(v) - }) -} - -func (r *RecordingFrontend) LoadGame(path string) error { - err := r.Emulator.LoadGame(path) - if err != nil { - return err - } - r.rec.SetFramerate(float64(r.Emulator.FPS())) - r.rec.SetAudioFrequency(r.Emulator.AudioSampleRate()) - r.rec.SetPixFormat(r.Emulator.PixFormat()) - return nil -} - -func (r *RecordingFrontend) ToggleRecording(active bool, user string) { - if r.rec != nil { - r.rec.Set(active, user) - } -} - -func (r *RecordingFrontend) IsRecording() bool { return r.rec != nil && r.rec.Enabled() } -func (r *RecordingFrontend) Close() { r.Emulator.Close(); r.ToggleRecording(false, "") } diff --git a/pkg/worker/cloud/s3.go b/pkg/worker/cloud/s3.go deleted file mode 100644 index bc5227f7..00000000 --- a/pkg/worker/cloud/s3.go +++ /dev/null @@ -1,91 +0,0 @@ -package cloud - -import ( - "bytes" - "context" - "errors" - "io" - - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" - "github.com/rs/zerolog/log" -) - -type S3Client struct { - c *minio.Client - bucket string - log *logger.Logger -} - -func NewS3Client(endpoint, bucket, key, secret string, log *logger.Logger) (*S3Client, error) { - s3Client, err := minio.New(endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(key, secret, ""), - Secure: true, - }) - if err != nil { - return nil, err - } - - exists, err := s3Client.BucketExists(context.Background(), bucket) - if err != nil { - return nil, err - } - if !exists { - return nil, errors.New("bucket doesn't exist") - } - - return &S3Client{bucket: bucket, c: s3Client, log: log}, nil -} - -func (s *S3Client) SetBucket(bucket string) { s.bucket = bucket } - -func (s *S3Client) Save(name string, data []byte, meta map[string]string) error { - if s == nil || s.c == nil { - return errors.New("s3 client was not initialised") - } - r := bytes.NewReader(data) - opts := minio.PutObjectOptions{ - ContentType: "application/octet-stream", - SendContentMd5: true, - } - if meta != nil { - opts.UserMetadata = meta - } - - info, err := s.c.PutObject(context.Background(), s.bucket, name, r, int64(len(data)), opts) - if err != nil { - return err - } - s.log.Debug().Msgf("Uploaded: %v", info) - return nil -} - -func (s *S3Client) Load(name string) (data []byte, err error) { - if s == nil || s.c == nil { - return nil, errors.New("s3 client was not initialised") - } - - r, err := s.c.GetObject(context.Background(), s.bucket, name, minio.GetObjectOptions{}) - if err != nil { - return nil, err - } - defer func() { err = errors.Join(err, r.Close()) }() - - stats, err := r.Stat() - log.Debug().Msgf("Downloaded: %v", stats) - dat, err := io.ReadAll(r) - if err != nil { - return nil, err - } - - return dat, nil -} - -func (s *S3Client) Has(name string) bool { - if s == nil || s.c == nil { - return false - } - _, err := s.c.StatObject(context.Background(), s.bucket, name, minio.GetObjectOptions{}) - return err == nil -} diff --git a/pkg/worker/cloud/s3_test.go b/pkg/worker/cloud/s3_test.go deleted file mode 100644 index 9701cd9c..00000000 --- a/pkg/worker/cloud/s3_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cloud - -import ( - "crypto/rand" - "testing" - - "github.com/giongto35/cloud-game/v3/pkg/logger" -) - -func TestS3(t *testing.T) { - t.Skip() - - name := "test" - s3, err := NewS3Client( - "s3.tebi.io", - "cloudretro-001", - "", - "", - logger.Default(), - ) - if err != nil { - t.Error(err) - } - - buf := make([]byte, 1024*4) - // then we can call rand.Read. - _, err = rand.Read(buf) - if err != nil { - t.Error(err) - } - - err = s3.Save(name, buf, map[string]string{"id": "test"}) - if err != nil { - t.Error(err) - } - - exists := s3.Has(name) - if !exists { - t.Errorf("don't exist, but shuld") - } - - ne := s3.Has(name + "123213") - if ne { - t.Errorf("exists, but shouldn't") - } - - dat, err := s3.Load(name) - if err != nil { - t.Error(err) - } - - if len(dat) == 0 { - t.Errorf("should be something") - } -} diff --git a/pkg/worker/cloud/store.go b/pkg/worker/cloud/store.go deleted file mode 100644 index 538983cf..00000000 --- a/pkg/worker/cloud/store.go +++ /dev/null @@ -1,24 +0,0 @@ -package cloud - -import ( - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" -) - -type Storage interface { - Save(name string, data []byte, tags map[string]string) (err error) - Load(name string) (data []byte, err error) - Has(name string) bool -} - -func Store(conf config.Storage, log *logger.Logger) (Storage, error) { - var st Storage - var err error - switch conf.Provider { - case "s3": - st, err = NewS3Client(conf.S3Endpoint, conf.S3BucketName, conf.S3AccessKeyId, conf.S3SecretAccessKey, log) - case "coordinator": - default: - } - return st, err -} diff --git a/pkg/worker/cloudsave.go b/pkg/worker/cloudsave.go new file mode 100644 index 00000000..a3e0be64 --- /dev/null +++ b/pkg/worker/cloudsave.go @@ -0,0 +1,56 @@ +package worker + +import "os" + +type CloudSaveRoom struct { + GamingRoom + storage CloudStorage // a cloud storage to store room state online +} + +func WithCloudStorage(room GamingRoom, storage CloudStorage) *CloudSaveRoom { + cr := CloudSaveRoom{ + GamingRoom: room, + storage: storage, + } + if err := room.(*CloudSaveRoom).Download(); err != nil { + room.GetLog().Warn().Err(err).Msg("The room is not in the cloud") + } + return &cr +} + +func (c *CloudSaveRoom) Download() error { + // saveOnlineRoomToLocal save online room to local. + // !Supports only one file of main save state. + + data, err := c.storage.Load(c.GetId()) + if err != nil { + return err + } + // Save the data fetched from a cloud provider to the local server + if data != nil { + if err := os.WriteFile(c.GetEmulator().GetHashPath(), data, 0644); err != nil { + return err + } + c.GetLog().Debug().Msg("Successfully downloaded cloud save") + } + return nil +} + +func (c *CloudSaveRoom) HasSave() bool { + _, err := c.storage.Load(c.GetId()) + if err == nil { + return true + } + return c.GamingRoom.HasSave() +} + +func (c *CloudSaveRoom) SaveGame() error { + if err := c.GamingRoom.SaveGame(); err != nil { + return err + } + if err := c.storage.Save(c.GetId(), c.GetEmulator().GetHashPath()); err != nil { + return err + } + c.GetLog().Debug().Msg("Cloud save is successful") + return nil +} diff --git a/pkg/worker/compression/compression.go b/pkg/worker/compression/compression.go index e9e719a1..ea2269e4 100644 --- a/pkg/worker/compression/compression.go +++ b/pkg/worker/compression/compression.go @@ -3,8 +3,8 @@ package compression import ( "path/filepath" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/compression/zip" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/compression/zip" ) type Extractor interface { diff --git a/pkg/worker/compression/zip/compression.go b/pkg/worker/compression/zip/compression.go index ad80acb0..0357cd84 100644 --- a/pkg/worker/compression/zip/compression.go +++ b/pkg/worker/compression/zip/compression.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) const Ext = ".zip" diff --git a/pkg/worker/compression/zip/compression_test.go b/pkg/worker/compression/zip/compression_test.go index f193ca15..d0328d32 100644 --- a/pkg/worker/compression/zip/compression_test.go +++ b/pkg/worker/compression/zip/compression_test.go @@ -1,10 +1,11 @@ package zip import ( - cr "crypto/rand" "fmt" + "math/rand" "reflect" "testing" + "time" ) func TestCompression(t *testing.T) { @@ -61,14 +62,10 @@ func BenchmarkCompressions(b *testing.B) { {name: "compress", size: 1024 * 1024 * 2}, } for _, bm := range benchmarks { + rand.Seed(time.Now().UnixNano()) b.Run(fmt.Sprintf("%v %v", bm.name, bm.size), func(b *testing.B) { - b.StopTimer() dat := make([]byte, bm.size) - _, err := cr.Read(dat) - if err != nil { - b.Fatal(err) - } - b.StartTimer() + rand.Read(dat) for i := 0; i < b.N; i++ { _, _ = Compress(dat, "test") } diff --git a/pkg/worker/coordinator.go b/pkg/worker/coordinator.go index bd5cd3e1..73372555 100644 --- a/pkg/worker/coordinator.go +++ b/pkg/worker/coordinator.go @@ -3,105 +3,126 @@ package worker import ( "net/url" - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/network/webrtc" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/network/webrtc" ) -type Connection interface { - Disconnect() - Id() com.Uid - ProcessPackets(func(api.In[com.Uid]) error) chan struct{} - SetErrorHandler(func(error)) - - Send(api.PT, any) ([]byte, error) - Notify(api.PT, any) - Route(api.In[com.Uid], *api.Out) -} - type coordinator struct { - Connection - log *logger.Logger + com.SocketClient } -var connector com.Client +var connector = com.NewConnector() -func newCoordinatorConnection(host string, conf config.Worker, addr string, log *logger.Logger) (*coordinator, error) { +// connect to a coordinator. +func connect(host string, conf worker.Worker, addr string, log *logger.Logger) (*coordinator, error) { scheme := "ws" if conf.Network.Secure { scheme = "wss" } address := url.URL{Scheme: scheme, Host: host, Path: conf.Network.Endpoint} - log.Debug(). - Str(logger.ClientField, "c"). - Str(logger.DirectionField, logger.MarkOut). - Msgf("Handshake %s", address.String()) + log.Debug().Str("c", "c").Str("d", "→").Msgf("Handshake %s", address.String()) - id := com.NewUid() + id := network.NewUid() req, err := buildConnQuery(id, conf, addr) if req != "" && err == nil { address.RawQuery = "data=" + req - } else { - return nil, err } - - conn, err := connector.Connect(address) + conn, err := connector.NewClient(address, log) if err != nil { return nil, err } - - clog := log.Extend(log.With().Str(logger.ClientField, "c")) - client := com.NewConnection[api.PT, api.In[com.Uid], api.Out, *api.Out](conn, id, clog) - - return &coordinator{ - Connection: client, - log: log.Extend(log.With().Str("cid", client.Id().Short())), - }, nil + return &coordinator{com.New(conn, "c", id, log)}, nil } -func (c *coordinator) HandleRequests(w *Worker) chan struct{} { - ap, err := webrtc.NewApiFactory(w.conf.Webrtc, c.log, nil) +func (c *coordinator) HandleRequests(w *Worker) { + ap, err := webrtc.NewApiFactory(w.conf.Webrtc, c.Log, nil) if err != nil { - c.log.Panic().Err(err).Msg("WebRTC API creation has been failed") + c.Log.Panic().Err(err).Msg("WebRTC API creation has been failed") } + c.ProcessMessages() + skipped := com.Out{} - return c.ProcessPackets(func(x api.In[com.Uid]) (err error) { - var out api.Out - + c.OnPacket(func(x com.In) (err error) { + var out com.Out switch x.T { case api.WebrtcInit: - err = api.Do(x, func(d api.WebrtcInitRequest) { out = c.HandleWebrtcInit(d, w, ap) }) - case api.StartGame: - err = api.Do(x, func(d api.StartGameRequest) { out = c.HandleGameStart(d, w) }) - case api.SaveGame: - err = api.Do(x, func(d api.SaveGameRequest) { out = c.HandleSaveGame(d, w) }) - case api.LoadGame: - err = api.Do(x, func(d api.LoadGameRequest) { out = c.HandleLoadGame(d, w) }) - case api.ChangePlayer: - err = api.Do(x, func(d api.ChangePlayerRequest) { out = c.HandleChangePlayer(d, w) }) - case api.RecordGame: - err = api.Do(x, func(d api.RecordGameRequest) { out = c.HandleRecordGame(d, w) }) + if dat := api.Unwrap[api.WebrtcInitRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleWebrtcInit(*dat, w, ap) + } case api.WebrtcAnswer: - err = api.Do(x, func(d api.WebrtcAnswerRequest) { c.HandleWebrtcAnswer(d, w) }) + dat := api.Unwrap[api.WebrtcAnswerRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleWebrtcAnswer(*dat, w) case api.WebrtcIce: - err = api.Do(x, func(d api.WebrtcIceCandidateRequest) { c.HandleWebrtcIceCandidate(d, w) }) + dat := api.Unwrap[api.WebrtcIceCandidateRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleWebrtcIceCandidate(*dat, w) + case api.StartGame: + if dat := api.Unwrap[api.StartGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleGameStart(*dat, w) + } case api.TerminateSession: - err = api.Do(x, func(d api.TerminateSessionRequest) { c.HandleTerminateSession(d, w) }) + dat := api.Unwrap[api.TerminateSessionRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleTerminateSession(*dat, w) case api.QuitGame: - err = api.Do(x, func(d api.GameQuitRequest) { c.HandleQuitGame(d, w) }) - case api.ResetGame: - err = api.Do(x, func(d api.ResetGameRequest) { c.HandleResetGame(d, w) }) + dat := api.Unwrap[api.GameQuitRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleQuitGame(*dat, w) + case api.SaveGame: + if dat := api.Unwrap[api.SaveGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleSaveGame(*dat, w) + } + case api.LoadGame: + if dat := api.Unwrap[api.LoadGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleLoadGame(*dat, w) + } + case api.ChangePlayer: + if dat := api.Unwrap[api.ChangePlayerRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleChangePlayer(*dat, w) + } + case api.ToggleMultitap: + if dat := api.Unwrap[api.ToggleMultitapRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + c.HandleToggleMultitap(*dat, w) + } + case api.RecordGame: + if dat := api.Unwrap[api.RecordGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleRecordGame(*dat, w) + } default: - c.log.Warn().Msgf("unhandled packet type %v", x.T) + c.Log.Warn().Msgf("unhandled packet type %v", x.T) } - - if out != (api.Out{}) { - w.cord.Route(x, &out) + if out != skipped { + w.cord.Route(x, out) } - return + return err }) } @@ -109,34 +130,6 @@ func (c *coordinator) RegisterRoom(id string) { c.Notify(api.RegisterRoom, id) } // CloseRoom sends a signal to coordinator which will remove that room from its list. func (c *coordinator) CloseRoom(id string) { c.Notify(api.CloseRoom, id) } -func (c *coordinator) IceCandidate(candidate string, sessionId string) { - c.Notify(api.WebrtcIce, api.WebrtcIceCandidateRequest{ - Stateful: api.Stateful{Id: sessionId}, - Candidate: candidate, - }) -} - -func (c *coordinator) SendLibrary(w *Worker) { - g := w.lib.GetAll() - - var gg = make([]api.GameInfo, len(g)) - for i, g := range g { - gg[i] = api.GameInfo(g) - } - - c.Notify(api.LibNewGameList, api.LibGameListInfo{T: 1, List: gg}) -} - -func (c *coordinator) SendPrevSessions(w *Worker) { - sessions := w.lib.Sessions() - - // extract ids from save states, i.e. sessions - var ids []string - - for _, id := range sessions { - x, _ := api.ExplodeDeepLink(id) - ids = append(ids, x) - } - - c.Notify(api.PrevSessions, api.PrevSessionInfo{List: ids}) +func (c *coordinator) IceCandidate(candidate string, sessionId network.Uid) { + c.Notify(api.NewWebrtcIceCandidateRequest(sessionId, candidate)) } diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index d8e30a0e..c341986a 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -1,25 +1,21 @@ package worker import ( - "encoding/base64" + "fmt" - "github.com/giongto35/cloud-game/v3/pkg/api" - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/games" - "github.com/giongto35/cloud-game/v3/pkg/network/webrtc" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged" - "github.com/giongto35/cloud-game/v3/pkg/worker/media" - "github.com/giongto35/cloud-game/v3/pkg/worker/room" - "github.com/goccy/go-json" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/network/webrtc" ) // buildConnQuery builds initial connection data query to a coordinator. -func buildConnQuery(id com.Uid, conf config.Worker, address string) (string, error) { +func buildConnQuery[S fmt.Stringer](id S, conf worker.Worker, address string) (string, error) { addr := conf.GetPingAddr(address) - return toBase64Json(api.ConnectionRequest[com.Uid]{ + return api.ToBase64Json(api.ConnectionRequest{ Addr: addr.Hostname(), - Id: id, + Id: id.String(), IsHTTPS: conf.Server.Https, PingURL: addr.String(), Port: conf.GetPort(address), @@ -28,287 +24,186 @@ func buildConnQuery(id com.Uid, conf config.Worker, address string) (string, err }) } -func (c *coordinator) HandleWebrtcInit(rq api.WebrtcInitRequest, w *Worker, factory *webrtc.ApiFactory) api.Out { - peer := webrtc.New(c.log, factory) - localSDP, err := peer.NewCall(w.conf.Encoder.Video.Codec, "opus", func(data any) { - candidate, err := toBase64Json(data) +func (c *coordinator) HandleWebrtcInit(rq api.WebrtcInitRequest, w *Worker, connApi *webrtc.ApiFactory) com.Out { + peer := webrtc.New(c.Log, connApi) + localSDP, err := peer.NewCall(w.conf.Encoder.Video.Codec, audioCodec, func(data any) { + candidate, err := api.ToBase64Json(data) if err != nil { - c.log.Error().Err(err).Msgf("ICE candidate encode fail for [%v]", data) + c.Log.Error().Err(err).Msgf("ICE candidate encode fail for [%v]", data) return } c.IceCandidate(candidate, rq.Id) }) if err != nil { - c.log.Error().Err(err).Msg("cannot create new webrtc session") - return api.EmptyPacket + c.Log.Error().Err(err).Msg("cannot create new webrtc session") + return com.EmptyPacket } - sdp, err := toBase64Json(localSDP) + sdp, err := api.ToBase64Json(localSDP) if err != nil { - c.log.Error().Err(err).Msgf("SDP encode fail fro [%v]", localSDP) - return api.EmptyPacket + c.Log.Error().Err(err).Msgf("SDP encode fail fro [%v]", localSDP) + return com.EmptyPacket } - user := room.NewGameSession(rq.Id, peer) // use user uid from the coordinator - c.log.Info().Msgf("Peer connection: %s", user.Id()) + // use user uid from the coordinator + user := NewSession(peer, rq.Id) w.router.AddUser(user) + c.Log.Info().Str("id", string(rq.Id)).Msgf("Peer connection (uid:%s)", user.Id()) - return api.Out{Payload: sdp} + return com.Out{Payload: sdp} } func (c *coordinator) HandleWebrtcAnswer(rq api.WebrtcAnswerRequest, w *Worker) { - if user := w.router.FindUser(rq.Id); user != nil { - if err := room.WithWebRTC(user.Session).SetRemoteSDP(rq.Sdp, fromBase64Json); err != nil { - c.log.Error().Err(err).Msgf("cannot set remote SDP of client [%v]", rq.Id) + if user := w.router.GetUser(rq.Id); user != nil { + if err := user.GetPeerConn().SetRemoteSDP(rq.Sdp, api.FromBase64Json); err != nil { + c.Log.Error().Err(err).Msgf("cannot set remote SDP of client [%v]", rq.Id) } } } func (c *coordinator) HandleWebrtcIceCandidate(rs api.WebrtcIceCandidateRequest, w *Worker) { - if user := w.router.FindUser(rs.Id); user != nil { - if err := room.WithWebRTC(user.Session).AddCandidate(rs.Candidate, fromBase64Json); err != nil { - c.log.Error().Err(err).Msgf("cannot add ICE candidate of the client [%v]", rs.Id) + if user := w.router.GetUser(rs.Id); user != nil { + if err := user.GetPeerConn().AddCandidate(rs.Candidate, api.FromBase64Json); err != nil { + c.Log.Error().Err(err).Msgf("cannot add ICE candidate of the client [%v]", rs.Id) } } } -func (c *coordinator) HandleGameStart(rq api.StartGameRequest, w *Worker) api.Out { - user := w.router.FindUser(rq.Id) +func (c *coordinator) HandleGameStart(rq api.StartGameRequest, w *Worker) com.Out { + user := w.router.GetUser(rq.Id) if user == nil { - c.log.Error().Msgf("no user [%v]", rq.Id) - return api.EmptyPacket + c.Log.Error().Msgf("no user [%v]", rq.Id) + return com.EmptyPacket } - user.Index = rq.PlayerIndex + w.log.Info().Msgf("Starting game: %v", rq.Game.Name) - r := w.router.FindRoom(rq.Rid) + room := w.router.GetRoom(rq.Rid) + if room == nil { + room = NewRoom( + rq.Room.Rid, + games.GameMetadata{Name: rq.Game.Name, Base: rq.Game.Base, Type: rq.Game.Type, Path: rq.Game.Path}, + func(room *Room) { + w.router.RemoveRoom() + c.CloseRoom(room.id) + w.log.Debug().Msgf("Room close has been called %v", room.id) + }, + w.conf, + w.log, + ) + user.SetPlayerIndex(rq.PlayerIndex) - // +injects game data into the original game request - // the name of the game either in the `room id` field or - // it's in the initial request - gameName := rq.Game - if rq.Rid != "" { - name := w.launcher.ExtractAppNameFromUrl(rq.Rid) - if name == "" { - c.log.Warn().Msg("couldn't decode game name from the room id") - return api.EmptyPacket + if w.storage != nil { + room = WithCloudStorage(room, w.storage) + } + if w.conf.Recording.Enabled { + room = WithRecording(room.(*Room), rq.Record, rq.RecordUser, rq.Game.Name, w.conf) + } + w.router.SetRoom(room) + + room.StartEmulator() + + if w.conf.Emulator.AutosaveSec > 0 { + // !to can crash if emulator starts earlier + go room.EnableAutosave(w.conf.Emulator.AutosaveSec) } - gameName = name } - gameInfo, err := w.launcher.FindAppByName(gameName) - if err != nil { - c.log.Error().Err(err).Send() - return api.EmptyPacket + if room == nil { + c.Log.Error().Msgf("couldn't create a room [%v]", rq.Id) + return com.EmptyPacket } - if r == nil { // new room - uid := rq.Rid - if uid == "" { - uid = games.GenerateRoomID(gameName) - } - game := games.GameMetadata(gameInfo) - - r = room.NewRoom[*room.GameSession](uid, nil, w.router.Users(), nil) - r.HandleClose = func() { - c.CloseRoom(uid) - c.log.Debug().Msgf("room close request %v sent", uid) - } - - if other := w.router.Room(); other != nil { - c.log.Error().Msgf("concurrent room creation: %v / %v", uid, w.router.Room().Id()) - return api.EmptyPacket - } - - w.router.SetRoom(r) - c.log.Info().Str("room", r.Id()).Str("game", game.Name).Msg("New room") - - // start the emulator - app := room.WithEmulator(w.mana.Get(caged.Libretro)) - app.ReloadFrontend() - app.SetSessionId(uid) - app.SetSaveOnClose(true) - app.EnableCloudStorage(uid, w.storage) - app.EnableRecording(rq.Record, rq.RecordUser, gameName) - - r.SetApp(app) - - m := media.NewWebRtcMediaPipe(w.conf.Encoder.Audio, w.conf.Encoder.Video, w.log) - - // recreate the video encoder - app.VideoChangeCb(func() { - app.ViewportRecalculate() - m.VideoW, m.VideoH = app.ViewportSize() - m.VideoScale = app.Scale() - - if m.IsInitialized() { - if err := m.Reinit(); err != nil { - c.log.Error().Err(err).Msgf("reinit fail") - } - } - - data, err := api.Wrap(api.Out{ - T: uint8(api.AppVideoChange), - Payload: api.AppVideoInfo{ - W: m.VideoW, - H: m.VideoH, - A: app.AspectRatio(), - S: int(app.Scale()), - }}) - if err != nil { - c.log.Error().Err(err).Msgf("wrap") - } - r.Send(data) - }) - - w.log.Info().Msgf("Starting the game: %v", gameName) - if err := app.Load(game, w.conf.Library.BasePath); err != nil { - c.log.Error().Err(err).Msgf("couldn't load the game %v", game) - r.Close() - w.router.SetRoom(nil) - return api.EmptyPacket - } - - m.AudioSrcHz = app.AudioSampleRate() - m.AudioFrames = w.conf.Encoder.Audio.Frames - m.VideoW, m.VideoH = app.ViewportSize() - m.VideoScale = app.Scale() - - r.SetMedia(m) - - if err := m.Init(); err != nil { - c.log.Error().Err(err).Msgf("couldn't init the media") - r.Close() - w.router.SetRoom(nil) - return api.EmptyPacket - } - - if app.Flipped() { - m.SetVideoFlip(true) - } - m.SetPixFmt(app.PixFormat()) - m.SetRot(app.Rotation()) - - r.BindAppMedia() - r.StartApp() + if !room.HasUser(user) { + room.AddUser(user) + room.PollUserInput(user) } + user.SetRoom(room) - c.log.Debug().Msg("Start session input poll") + c.RegisterRoom(room.GetId()) - needsKbMouse := r.App().KbMouseSupport() - - s := room.WithWebRTC(user.Session) - s.OnMessage = func(data []byte) { r.App().Input(user.Index, byte(caged.RetroPad), data) } - if needsKbMouse { - _ = s.AddChannel("keyboard", func(data []byte) { r.App().Input(user.Index, byte(caged.Keyboard), data) }) - _ = s.AddChannel("mouse", func(data []byte) { r.App().Input(user.Index, byte(caged.Mouse), data) }) - } - - c.RegisterRoom(r.Id()) - - response := api.StartGameResponse{ - Room: api.Room{Rid: r.Id()}, - Record: w.conf.Recording.Enabled, - KbMouse: needsKbMouse, - } - if r.App().AspectEnabled() { - ww, hh := r.App().ViewportSize() - response.AV = &api.AppVideoInfo{W: ww, H: hh, A: r.App().AspectRatio(), S: int(r.App().Scale())} - } - - return api.Out{Payload: response} + return com.Out{Payload: api.StartGameResponse{Room: api.Room{Rid: room.GetId()}, Record: w.conf.Recording.Enabled}} } // HandleTerminateSession handles cases when a user has been disconnected from the websocket of coordinator. func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest, w *Worker) { - if user := w.router.FindUser(rq.Id); user != nil { - w.router.Remove(user) - c.log.Debug().Msgf(">>> users: %v", w.router.Users()) - user.Disconnect() + if session := w.router.GetUser(rq.Id); session != nil { + w.router.RemoveUser(session) + if room := session.GetSetRoom(nil); room != nil { + room.CleanupUser(session) + } } } // HandleQuitGame handles cases when a user manually exits the game. func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest, w *Worker) { - if user := w.router.FindUser(rq.Id); user != nil { - w.router.Remove(user) - c.log.Debug().Msgf(">>> users: %v", w.router.Users()) + if user := w.router.GetUser(rq.Id); user != nil { + // we don't strictly need a room id form the request, + // since users hold their room reference + // !to remove rid, maybe + if room := w.router.GetRoom(rq.Rid); room != nil { + room.CleanupUser(user) + } } } -func (c *coordinator) HandleResetGame(rq api.ResetGameRequest, w *Worker) api.Out { - if r := w.router.FindRoom(rq.Rid); r != nil { - room.WithEmulator(r.App()).Reset() - return api.OkPacket +func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest, w *Worker) com.Out { + if room := roomy(rq, w); room != nil { + if err := room.SaveGame(); err != nil { + c.Log.Error().Err(err).Msg("cannot save game state") + return com.ErrPacket + } + return com.OkPacket } - return api.ErrPacket + return com.ErrPacket } -func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest, w *Worker) api.Out { - r := w.router.FindRoom(rq.Rid) - if r == nil { - return api.ErrPacket +func (c *coordinator) HandleLoadGame(rq api.LoadGameRequest, w *Worker) com.Out { + if room := roomy(rq, w); room != nil { + if err := room.LoadGame(); err != nil { + c.Log.Error().Err(err).Msg("cannot load game state") + return com.ErrPacket + } + return com.OkPacket } - if err := room.WithEmulator(r.App()).SaveGameState(); err != nil { - c.log.Error().Err(err).Msg("cannot save game state") - return api.ErrPacket - } - return api.OkPacket + return com.ErrPacket } -func (c *coordinator) HandleLoadGame(rq api.LoadGameRequest, w *Worker) api.Out { - r := w.router.FindRoom(rq.Rid) - if r == nil { - return api.ErrPacket +func (c *coordinator) HandleChangePlayer(rq api.ChangePlayerRequest, w *Worker) com.Out { + user := w.router.GetUser(rq.Id) + if user == nil || w.router.GetRoom(rq.Rid) == nil { + return com.Out{Payload: -1} // semi-predicates } - if err := room.WithEmulator(r.App()).RestoreGameState(); err != nil { - c.log.Error().Err(err).Msg("cannot load game state") - return api.ErrPacket - } - return api.OkPacket -} - -func (c *coordinator) HandleChangePlayer(rq api.ChangePlayerRequest, w *Worker) api.Out { - user := w.router.FindUser(rq.Id) - if user == nil || w.router.FindRoom(rq.Rid) == nil { - return api.Out{Payload: -1} // semi-predicates - } - user.Index = rq.Index + user.SetPlayerIndex(rq.Index) w.log.Info().Msgf("Updated player index to: %d", rq.Index) - return api.Out{Payload: rq.Index} + return com.Out{Payload: rq.Index} } -func (c *coordinator) HandleRecordGame(rq api.RecordGameRequest, w *Worker) api.Out { +func (c *coordinator) HandleToggleMultitap(rq api.ToggleMultitapRequest, w *Worker) com.Out { + if room := roomy(rq, w); room != nil { + room.ToggleMultitap() + return com.OkPacket + } + return com.ErrPacket +} + +func (c *coordinator) HandleRecordGame(rq api.RecordGameRequest, w *Worker) com.Out { if !w.conf.Recording.Enabled { - return api.ErrPacket + return com.ErrPacket } - r := w.router.FindRoom(rq.Rid) - if r == nil { - return api.ErrPacket + if room := roomy(rq, w); room != nil { + room.(*RecordingRoom).ToggleRecording(rq.Active, rq.User) + return com.OkPacket } - room.WithRecorder(r.App()).ToggleRecording(rq.Active, rq.User) - return api.OkPacket + return com.ErrPacket } -// fromBase64Json decodes data from a URL-encoded Base64+JSON string. -func fromBase64Json(data string, obj any) error { - b, err := base64.URLEncoding.DecodeString(data) - if err != nil { - return err +func roomy(rq api.RoomInterface, w *Worker) GamingRoom { + rid := rq.GetRoom() + if rid == "" { + return nil } - err = json.Unmarshal(b, obj) - if err != nil { - return err + room := w.router.GetRoom(rid) + if room == nil { + return nil } - return nil -} - -// toBase64Json encodes data to a URL-encoded Base64+JSON string. -func toBase64Json(data any) (string, error) { - if data == nil { - return "", nil - } - b, err := json.Marshal(data) - if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(b), nil + return room } diff --git a/pkg/worker/emulator/emulator.go b/pkg/worker/emulator/emulator.go new file mode 100644 index 00000000..4cc9a87d --- /dev/null +++ b/pkg/worker/emulator/emulator.go @@ -0,0 +1,71 @@ +package emulator + +import ( + "time" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/image" +) + +type Emulator interface { + // SetAudio sets the audio callback + SetAudio(func(*GameAudio)) + // SetVideo sets the video callback + SetVideo(func(*GameFrame)) + GetAudio() func(*GameAudio) + GetVideo() func(*GameFrame) + LoadMetadata(name string) + LoadGame(path string) error + GetFps() uint + GetSampleRate() uint + GetFrameSize() (w, h int) + HasVerticalFrame() bool + // Start is called after LoadGame + Start() + // SetViewport sets viewport size + SetViewport(width int, height int) + // SetMainSaveName sets distinct name for saves naming + SetMainSaveName(name string) + // SaveGameState save game state + SaveGameState() error + // LoadGameState load game state + LoadGameState() error + // GetHashPath returns the path emulator will save state to + GetHashPath() string + // Close will be called when the game is done + Close() + // ToggleMultitap toggles multitap controller. + ToggleMultitap() + // Input passes input to the emulator + Input(player int, data []byte) +} + +type Metadata struct { + // the full path to some emulator lib + LibPath string + // the full path to the emulator config + ConfigPath string + + AudioSampleRate int + Fps float64 + BaseWidth int + BaseHeight int + Rotation image.Rotate + IsGlAllowed bool + UsesLibCo bool + AutoGlContext bool + HasMultitap bool +} + +type ( + GameFrame struct { + Data *image.Frame + Duration time.Duration + } + GameAudio struct { + Data *[]int16 + Duration time.Duration + } + InputEvent struct { + RawState []byte + } +) diff --git a/pkg/worker/emulator/graphics/context.go b/pkg/worker/emulator/graphics/context.go new file mode 100644 index 00000000..6ac446c7 --- /dev/null +++ b/pkg/worker/emulator/graphics/context.go @@ -0,0 +1,18 @@ +package graphics + +import "math" + +type Context int + +const ( + CtxNone Context = iota + CtxOpenGl + CtxOpenGlEs2 + CtxOpenGlCore + CtxOpenGlEs3 + CtxOpenGlEsVersion + CtxVulkan + + CtxUnknown = math.MaxInt32 - 1 + CtxDummy = math.MaxInt32 +) diff --git a/pkg/worker/caged/libretro/graphics/gl/KHR/khrplatform.h b/pkg/worker/emulator/graphics/gl/KHR/khrplatform.h similarity index 100% rename from pkg/worker/caged/libretro/graphics/gl/KHR/khrplatform.h rename to pkg/worker/emulator/graphics/gl/KHR/khrplatform.h diff --git a/pkg/worker/caged/libretro/graphics/gl/gl.go b/pkg/worker/emulator/graphics/gl/gl.go similarity index 96% rename from pkg/worker/caged/libretro/graphics/gl/gl.go rename to pkg/worker/emulator/graphics/gl/gl.go index 46e3842e..d8f95a62 100644 --- a/pkg/worker/caged/libretro/graphics/gl/gl.go +++ b/pkg/worker/emulator/graphics/gl/gl.go @@ -78,7 +78,6 @@ typedef void (APIENTRYP GPREADPIXELS)(GLint x, GLint y, GLsizei width, GLsizei h typedef void (APIENTRYP GPRENDERBUFFERSTORAGE)(GLenum target, GLenum internalformat, GLsizei width, GLsizei height); typedef void (APIENTRYP GPTEXIMAGE2D)(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void *pixels); typedef void (APIENTRYP GPTEXPARAMETERI)(GLenum target, GLenum pname, GLint param); -typedef void (APIENTRYP GPPIXELSTOREI)(GLenum pname, GLint param); static const GLubyte *getString(GPGETSTRING ptr, GLenum name) { return (*ptr)(name); } static GLenum getError(GPGETERROR ptr) { return (*ptr)(); } @@ -114,7 +113,6 @@ static void deleteTextures(GPDELETETEXTURES ptr, GLsizei n, const GLuint *textur static void readPixels(GPREADPIXELS ptr, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void *pixels) { (*ptr)(x, y, width, height, format, type, pixels); } -static void pixelStorei(GPPIXELSTOREI ptr, GLenum pname, GLint param) { (*ptr)(pname, param); } */ import "C" import ( @@ -146,8 +144,6 @@ const ( UnsignedShort5551 = 0x8034 UnsignedShort565 = 0x8363 UnsignedInt8888Rev = 0x8367 - - PackAlignment = 0x0D05 ) var ( @@ -169,7 +165,6 @@ var ( gpDeleteFramebuffers C.GPDELETEFRAMEBUFFERS gpDeleteTextures C.GPDELETETEXTURES gpReadPixels C.GPREADPIXELS - gpPixelStorei C.GPPIXELSTOREI ) func InitWithProcAddrFunc(getProcAddr func(name string) unsafe.Pointer) error { @@ -210,9 +205,6 @@ func InitWithProcAddrFunc(getProcAddr func(name string) unsafe.Pointer) error { if gpReadPixels == nil { return errors.New("glReadPixels") } - if gpPixelStorei = (C.GPPIXELSTOREI)(getProcAddr("glPixelStorei")); gpPixelStorei == nil { - return errors.New("glPixelStorei") - } return nil } @@ -265,9 +257,6 @@ func DeleteTextures(n int32, textures *uint32) { func ReadPixels(x int32, y int32, width int32, height int32, format uint32, xtype uint32, pixels unsafe.Pointer) { C.readPixels(gpReadPixels, (C.GLint)(x), (C.GLint)(y), (C.GLsizei)(width), (C.GLsizei)(height), (C.GLenum)(format), (C.GLenum)(xtype), pixels) } -func PixelStorei(pname uint32, param int32) { - C.pixelStorei(gpPixelStorei, (C.GLenum)(pname), (C.GLint)(param)) -} func GetError() uint32 { return (uint32)(C.getError(gpGetError)) } diff --git a/pkg/worker/emulator/graphics/opengl.go b/pkg/worker/emulator/graphics/opengl.go new file mode 100644 index 00000000..bc3c56e1 --- /dev/null +++ b/pkg/worker/emulator/graphics/opengl.go @@ -0,0 +1,131 @@ +package graphics + +import ( + "errors" + "fmt" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/graphics/gl" +) + +type ( + offscreenSetup struct { + tex uint32 + fbo uint32 + rbo uint32 + + width int32 + height int32 + + pixType uint32 + pixFormat uint32 + + hasDepth bool + hasStencil bool + } + PixelFormat int +) + +const ( + UnsignedShort5551 PixelFormat = iota + UnsignedShort565 + UnsignedInt8888Rev +) + +var ( + opt = offscreenSetup{} + buf []byte +) + +func initContext(getProcAddr func(name string) unsafe.Pointer) { + if err := gl.InitWithProcAddrFunc(getProcAddr); err != nil { + panic(err) + } +} + +func initFramebuffer(w int, h int, hasDepth bool, hasStencil bool) error { + opt.width = int32(w) + opt.height = int32(h) + opt.hasDepth = hasDepth + opt.hasStencil = hasStencil + + // texture init + gl.GenTextures(1, &opt.tex) + gl.BindTexture(gl.Texture2d, opt.tex) + + gl.TexParameteri(gl.Texture2d, gl.TextureMinFilter, gl.NEAREST) + gl.TexParameteri(gl.Texture2d, gl.TextureMagFilter, gl.NEAREST) + + gl.TexImage2D(gl.Texture2d, 0, gl.RGBA8, opt.width, opt.height, 0, opt.pixType, opt.pixFormat, nil) + gl.BindTexture(gl.Texture2d, 0) + + // framebuffer init + gl.GenFramebuffers(1, &opt.fbo) + gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) + + gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.ColorAttachment0, gl.Texture2d, opt.tex, 0) + + // more buffers init + if opt.hasDepth { + gl.GenRenderbuffers(1, &opt.rbo) + gl.BindRenderbuffer(gl.RENDERBUFFER, opt.rbo) + if opt.hasStencil { + gl.RenderbufferStorage(gl.RENDERBUFFER, gl.Depth24Stencil8, opt.width, opt.height) + gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DepthStencilAttachment, gl.RENDERBUFFER, opt.rbo) + } else { + gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DepthComponent24, opt.width, opt.height) + gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DepthAttachment, gl.RENDERBUFFER, opt.rbo) + } + gl.BindRenderbuffer(gl.RENDERBUFFER, 0) + } + + if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FramebufferComplete { + return fmt.Errorf("invalid framebuffer (0x%X)", status) + } + return nil +} + +func destroyFramebuffer() { + if opt.hasDepth { + gl.DeleteRenderbuffers(1, &opt.rbo) + } + gl.DeleteFramebuffers(1, &opt.fbo) + gl.DeleteTextures(1, &opt.tex) +} + +func ReadFramebuffer(bytes, w, h uint) []byte { + data := buf[:bytes] + gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) + gl.ReadPixels(0, 0, int32(w), int32(h), opt.pixType, opt.pixFormat, unsafe.Pointer(&data[0])) + gl.BindFramebuffer(gl.FRAMEBUFFER, 0) + return data +} + +func getFbo() uint32 { return opt.fbo } + +func SetBuffer(size int) { buf = make([]byte, size) } + +func SetPixelFormat(format PixelFormat) error { + switch format { + case UnsignedShort5551: + opt.pixFormat = gl.UnsignedShort5551 + opt.pixType = gl.BGRA + case UnsignedShort565: + opt.pixFormat = gl.UnsignedShort565 + opt.pixType = gl.RGB + case UnsignedInt8888Rev: + opt.pixFormat = gl.UnsignedInt8888Rev + opt.pixType = gl.BGRA + default: + return errors.New("unknown pixel format") + } + return nil +} + +func GetGLVersionInfo() string { return get(gl.VERSION) } +func GetGLVendorInfo() string { return get(gl.VENDOR) } +func GetGLRendererInfo() string { return get(gl.RENDERER) } +func GetGLSLInfo() string { return get(gl.ShadingLanguageVersion) } +func GetGLError() uint32 { return gl.GetError() } + +func get(name uint32) string { return gl.GoStr(gl.GetString(name)) } diff --git a/pkg/worker/emulator/graphics/sdl.go b/pkg/worker/emulator/graphics/sdl.go new file mode 100644 index 00000000..77b8ceaa --- /dev/null +++ b/pkg/worker/emulator/graphics/sdl.go @@ -0,0 +1,139 @@ +package graphics + +import ( + "fmt" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" + "github.com/veandco/go-sdl2/sdl" +) + +type SDL struct { + glWCtx sdl.GLContext + w *sdl.Window + log *logger.Logger +} + +type Config struct { + Ctx Context + W int + H int + GLAutoContext bool + GLVersionMajor uint + GLVersionMinor uint + GLHasDepth bool + GLHasStencil bool +} + +// NewSDLContext initializes SDL/OpenGL context. +// Uses main thread lock (see thread/mainthread). +func NewSDLContext(cfg Config, log *logger.Logger) (*SDL, error) { + log.Debug().Msg("[SDL/OpenGL] initialization...") + + if err := sdl.Init(sdl.INIT_VIDEO); err != nil { + return nil, fmt.Errorf("SDL initialization fail: %w", err) + } + + display := SDL{log: log} + + if cfg.GLAutoContext { + log.Debug().Msgf("[OpenGL] CONTEXT_AUTO (type: %v v%v.%v)", cfg.Ctx, cfg.GLVersionMajor, cfg.GLVersionMinor) + } else { + switch cfg.Ctx { + case CtxOpenGlCore: + display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE) + log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_CORE") + case CtxOpenGlEs2: + display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_ES) + display.setAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, 3) + display.setAttribute(sdl.GL_CONTEXT_MINOR_VERSION, 0) + log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_ES 3.0") + case CtxOpenGl: + if cfg.GLVersionMajor >= 3 { + display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_COMPATIBILITY) + } + log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_COMPATIBILITY") + default: + log.Error().Msgf("[OpenGL] Unsupported hw context: %v", cfg.Ctx) + } + } + + var err error + // In OSX 10.14+ window creation and context creation must happen in the main thread + thread.Main(func() { display.w, display.glWCtx, err = createWindow() }) + if err != nil { + return nil, fmt.Errorf("window fail: %w", err) + } + + if err := display.BindContext(); err != nil { + return nil, fmt.Errorf("bind context fail: %w", err) + } + initContext(sdl.GLGetProcAddress) + if err := initFramebuffer(cfg.W, cfg.H, cfg.GLHasDepth, cfg.GLHasStencil); err != nil { + return nil, fmt.Errorf("OpenGL initialization fail: %w", err) + } + return &display, nil +} + +// Deinit destroys SDL/OpenGL context. +// Uses main thread lock (see thread/mainthread). +func (s *SDL) Deinit() error { + s.log.Debug().Msg("[SDL/OpenGL] shutdown...") + destroyFramebuffer() + var err error + // In OSX 10.14+ window deletion must happen in the main thread + thread.Main(func() { + err = s.destroyWindow() + }) + if err != nil { + return fmt.Errorf("[SDL/OpenGL] deinit fail: %w", err) + } + sdl.Quit() + s.log.Debug().Msgf("[SDL/OpenGL] shutdown codes:(%v, %v)", sdl.GetError(), GetGLError()) + return nil +} + +// createWindow creates a fake SDL window just for OpenGL initialization purposes. +func createWindow() (*sdl.Window, sdl.GLContext, error) { + w, err := sdl.CreateWindow( + "CloudRetro dummy window", + sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, + 1, 1, + sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN, + ) + if err != nil { + return nil, nil, fmt.Errorf("window creation fail: %w", err) + } + glWCtx, err := w.GLCreateContext() + if err != nil { + return nil, nil, fmt.Errorf("window OpenGL context fail: %w", err) + } + return w, glWCtx, nil +} + +// destroyWindow destroys previously created SDL window. +func (s *SDL) destroyWindow() error { + if err := s.BindContext(); err != nil { + return err + } + sdl.GLDeleteContext(s.glWCtx) + if err := s.w.Destroy(); err != nil { + return fmt.Errorf("window destroy fail: %w", err) + } + return nil +} + +// BindContext explicitly binds context to current thread. +func (s *SDL) BindContext() error { return s.w.GLMakeCurrent(s.glWCtx) } + +// setAttribute tries to set a GL attribute or prints error. +func (s *SDL) setAttribute(attr sdl.GLattr, value int) { + if err := sdl.GLSetAttribute(attr, value); err != nil { + s.log.Error().Err(err).Msg("[SDL] attribute") + } +} + +func GetGlFbo() uint32 { return getFbo() } + +func GetGlProcAddress(proc string) unsafe.Pointer { return sdl.GLGetProcAddress(proc) } diff --git a/pkg/worker/emulator/image/canvas.go b/pkg/worker/emulator/image/canvas.go new file mode 100644 index 00000000..83e2d1a4 --- /dev/null +++ b/pkg/worker/emulator/image/canvas.go @@ -0,0 +1,167 @@ +package image + +import ( + "image" + "math/bits" + "sync" + "unsafe" +) + +// Canvas is a stateful drawing surface, i.e. image.RGBA +type Canvas struct { + w, h int + vertical bool + pool sync.Pool + wg sync.WaitGroup +} + +type Frame struct { + *image.RGBA +} + +func (f *Frame) Opaque() bool { return true } +func (f *Frame) Copy() Frame { + return Frame{&image.RGBA{ + Pix: append([]uint8{}, f.Pix...), + Stride: f.Stride, + Rect: f.Rect, + }} +} + +const ( + BitFormatShort5551 = iota // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha + BitFormatInt8888Rev // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha + BitFormatShort565 // BIT_FORMAT_SHORT_5_6_5 has 5 bits R, 6 bits G, 5 bits +) + +func NewCanvas(w, h, size int) *Canvas { + return &Canvas{ + w: w, + h: h, + vertical: h > w, // input is inverted + pool: sync.Pool{New: func() any { + return &Frame{&image.RGBA{ + Pix: make([]uint8, size<<2), + Rect: image.Rectangle{Max: image.Point{X: w, Y: h}}, + }} + }}, + } +} + +func (c *Canvas) Get(w, h int) *Frame { + i := c.pool.Get().(*Frame) + if c.vertical { + w, h = h, w + } + i.Stride = w << 2 + i.Pix = i.Pix[:i.Stride*h] + i.Rect.Max.X = w + i.Rect.Max.Y = h + return i +} + +func (c *Canvas) Put(i *Frame) { c.pool.Put(i) } +func (c *Canvas) Clear() { c.wg = sync.WaitGroup{} } + +func (c *Canvas) Draw(encoding uint32, rot *Rotate, w, h, packedW, bpp int, data []byte, th int) *Frame { + dst := c.Get(w, h) + if th == 0 { + frame(encoding, dst, data, 0, h, h, w, packedW, bpp, rot) + } else { + hn := h / th + c.wg.Add(th) + for i := 0; i < th; i++ { + xx := hn * i + go func() { + frame(encoding, dst, data, xx, hn, h, w, packedW, bpp, rot) + c.wg.Done() + }() + } + c.wg.Wait() + } + + // rescale + if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h { + out := c.Get(c.w, c.h) + Resize(ScaleNearestNeighbour, dst.RGBA, out.RGBA) + c.Put(dst) + return out + } + + return dst +} + +func frame(encoding uint32, dst *Frame, data []byte, yy int, hn int, h int, w int, pwb int, bpp int, rot *Rotate) { + sPtr := unsafe.Pointer(&data[yy*pwb]) + dPtr := unsafe.Pointer(&dst.Pix[yy*dst.Stride]) + // some cores can zero-right-pad rows to the packed width value + pad := pwb - w*bpp + yn := yy + hn + + if rot == nil { + // LE, BE might not work + switch encoding { + case BitFormatShort565: + for y := yy; y < yn; y++ { + for x := 0; x < w; x++ { + i565((*uint32)(dPtr), uint32(*(*uint16)(sPtr))) + sPtr = unsafe.Add(sPtr, uintptr(bpp)) + dPtr = unsafe.Add(dPtr, uintptr(4)) + } + if pad > 0 { + sPtr = unsafe.Add(sPtr, uintptr(pad)) + } + } + case BitFormatInt8888Rev: + for y := yy; y < yn; y++ { + for x := 0; x < w; x++ { + ix8888((*uint32)(dPtr), *(*uint32)(sPtr)) + sPtr = unsafe.Add(sPtr, uintptr(bpp)) + dPtr = unsafe.Add(dPtr, uintptr(4)) + } + if pad > 0 { + sPtr = unsafe.Add(sPtr, uintptr(pad)) + } + } + } + } else { + switch encoding { + case BitFormatShort565: + for y := yy; y < yn; y++ { + for x, k := 0, 0; x < w; x++ { + dx, dy := rot.Call(x, y, w, h) + k = dx<<2 + dy*dst.Stride + dPtr = unsafe.Pointer(&dst.Pix[k]) + i565((*uint32)(dPtr), uint32(*(*uint16)(sPtr))) + sPtr = unsafe.Add(sPtr, uintptr(bpp)) + } + if pad > 0 { + sPtr = unsafe.Add(sPtr, uintptr(pad)) + } + } + case BitFormatInt8888Rev: + for y := yy; y < yn; y++ { + for x, k := 0, 0; x < w; x++ { + dx, dy := rot.Call(x, y, w, h) + k = dx<<2 + dy*dst.Stride + dPtr = unsafe.Pointer(&dst.Pix[k]) + ix8888((*uint32)(dPtr), *(*uint32)(sPtr)) + sPtr = unsafe.Add(sPtr, uintptr(bpp)) + } + if pad > 0 { + sPtr = unsafe.Add(sPtr, uintptr(pad)) + } + } + } + } +} + +func i565(dst *uint32, px uint32) { + *dst = (px >> 8 & 0xf8) | ((px >> 3 & 0xfc) << 8) | ((px << 3 & 0xfc) << 16) // | 0xff000000 + // setting the last byte to 255 allows saving RGBA images to PNG not as black squares +} + +func ix8888(dst *uint32, px uint32) { + //*dst = ((px >> 16) & 0xff) | (px & 0xff00) | ((px << 16) & 0xff0000) + 0xff000000 + *dst = bits.ReverseBytes32(px << 8) //| 0xff000000 +} diff --git a/pkg/worker/emulator/image/canvas_test.go b/pkg/worker/emulator/image/canvas_test.go new file mode 100644 index 00000000..9b076f7d --- /dev/null +++ b/pkg/worker/emulator/image/canvas_test.go @@ -0,0 +1,104 @@ +package image + +import ( + "fmt" + "testing" +) + +func BenchmarkDraw(b *testing.B) { + type args struct { + encoding uint32 + rot *Rotate + scaleType int + w int + h int + packedW int + bpp int + data []byte + dw int + dh int + th int + } + tests := []struct { + name string + args args + }{ + { + name: "0th", + args: args{ + encoding: BitFormatInt8888Rev, + rot: nil, + scaleType: ScaleNearestNeighbour, + w: 256, + h: 240, + packedW: 256, + bpp: 4, + data: make([]uint8, 256*240*4), + dw: 256, + dh: 240, + th: 0, + }, + }, + { + name: "4th", + args: args{ + encoding: BitFormatInt8888Rev, + rot: nil, + scaleType: ScaleNearestNeighbour, + w: 256, + h: 240, + packedW: 256, + bpp: 4, + data: make([]uint8, 256*240*4), + dw: 256, + dh: 240, + th: 4, + }, + }, + } + + for _, bn := range tests { + c := NewCanvas(bn.args.dw, bn.args.dh, bn.args.dw*bn.args.dh) + img := c.Get(bn.args.dw, bn.args.dh) + c.Put(img) + img2 := c.Get(bn.args.dw, bn.args.dh) + c.Put(img2) + b.ResetTimer() + b.Run(fmt.Sprintf("%v", bn.name), func(b *testing.B) { + for i := 0; i < b.N; i++ { + p := c.Draw(bn.args.encoding, bn.args.rot, bn.args.w, bn.args.h, bn.args.packedW, bn.args.bpp, bn.args.data, bn.args.th) + c.Put(p) + } + b.ReportAllocs() + }) + } +} + +func Test_ix8888(t *testing.T) { + type args struct { + dst *uint32 + px uint32 + expect uint32 + } + tests := []struct { + name string + args args + }{ + { + name: "", + args: args{ + dst: new(uint32), + px: 0x11223344, + expect: 0xff443322, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ix8888(tt.args.dst, tt.args.px) + if *tt.args.dst != tt.args.expect { + t.Errorf("nope, %x %x", *tt.args.dst, tt.args.expect) + } + }) + } +} diff --git a/pkg/worker/emulator/image/rotation.go b/pkg/worker/emulator/image/rotation.go new file mode 100644 index 00000000..950c89a2 --- /dev/null +++ b/pkg/worker/emulator/image/rotation.go @@ -0,0 +1,87 @@ +// Package image contains functions for rotations of points in a 2-dimensional space. +package image + +type Angle uint + +const ( + Angle0 Angle = iota + Angle90 + Angle180 + Angle270 + Flip180 +) + +// Angles is a helper to choose appropriate rotation based on its angle. +var Angles = [5]Rotate{ + Angle0: {Angle: Angle0, Call: Rotate0}, + Angle90: {Angle: Angle90, Call: Rotate90, IsEven: true}, + Angle180: {Angle: Angle180, Call: Rotate180}, + Angle270: {Angle: Angle270, Call: Rotate270, IsEven: true}, + Flip180: {Angle: Flip180, Call: Invert180}, +} + +func GetRotation(angle Angle) Rotate { return Angles[angle] } + +// Rotate is an interface for rotation of a given point. +// +// With the coordinates x, y in the matrix of w x h. +// Returns a pair of new coordinates x, y in the resulting matrix. +// Be aware that w / h values are 0 index-based, +// and it's meant to be used with h corresponded +// to matrix height and y coordinate, and with w to x coordinate. +type Rotate struct { + Angle Angle + Call func(x, y, w, h int) (int, int) + IsEven bool +} + +// Rotate0 is 0° or the original orientation. +// +// 1 2 3 1 2 3 +// 4 5 6 -> 4 5 6 +// 7 8 9 7 8 9 +func Rotate0(x, y, _, _ int) (int, int) { return x, y } + +// Rotate90 is 90° CCW or 270° CW. +// +// 1 2 3 3 6 9 +// 4 5 6 -> 2 5 8 +// 7 8 9 1 4 7 +func Rotate90(x, y, w, _ int) (int, int) { return y, (w - 1) - x } + +// Rotate180 is 180° CCW. +// +// 1 2 3 9 8 7 +// 4 5 6 -> 6 5 4 +// 7 8 9 3 2 1 +func Rotate180(x, y, w, h int) (int, int) { return (w - 1) - x, (h - 1) - y } + +// Rotate270 is 270° CCW or 90° CW. +// +// 1 2 3 7 4 1 +// 4 5 6 -> 8 5 2 +// 7 8 9 9 6 3 +func Rotate270(x, y, _, h int) (int, int) { return (h - 1) - y, x } + +func Invert180(x, y, _, h int) (int, int) { return x, (h - 1) - y } + +// ExampleRotate is an example of rotation usage. +// +// [1 2 3 4 5 6 7 8 9] +// [7 4 1 8 5 2 9 6 3] +func ExampleRotate(data []uint8, w int, h int, angle Angle) []uint8 { + dest := make([]uint8, len(data)) + rotationFn := Angles[angle] + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + nx, ny := rotationFn.Call(x, y, w, h) + stride := w + if rotationFn.IsEven { + stride = h + } + //fmt.Printf("%v:%v (%v) -> %v:%v (%v)\n", x, y, n1, nx, ny, n2) + dest[nx+ny*stride] = data[x+y*w] + } + } + return dest +} diff --git a/pkg/worker/emulator/image/rotation_test.go b/pkg/worker/emulator/image/rotation_test.go new file mode 100644 index 00000000..ec00ea6c --- /dev/null +++ b/pkg/worker/emulator/image/rotation_test.go @@ -0,0 +1,247 @@ +package image + +import ( + "bytes" + "testing" +) + +type dimensions struct { + w int + h int +} + +func TestRotate(t *testing.T) { + tests := []struct { + // packed bytes from a 2D matrix + input []byte + // original matrix's width + w int + // original matrix's height + h int + // rotation algorithm + rotateHow []Angle + expected [][]byte + }{ + { + // a cross + []byte{ + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + }, + 3, 3, []Angle{Angle0, Angle90, Angle180, Angle270}, + [][]byte{ + { + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + }, + { + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + }, + { + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + }, + { + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + }, + }, + }, + { + []byte{ + 1, 2, + 3, 4, + 5, 6, + 7, 8, + }, + 2, 4, []Angle{Angle0, Angle90, Angle180, Angle270}, + [][]byte{ + { + 1, 2, + 3, 4, + 5, 6, + 7, 8, + }, + { + 2, 4, 6, 8, + 1, 3, 5, 7, + }, + { + 8, 7, + 6, 5, + 4, 3, + 2, 1, + }, + { + 7, 5, 3, 1, + 8, 6, 4, 2, + }, + }, + }, + { + // a square + []byte{ + 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 0, 0, 0, 0, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, + }, + 8, 6, []Angle{Angle0, Angle90, Angle180, Angle270}, + [][]byte{ + { + // L // R + 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 0, 0, 0, 0, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, + }, + { + 0, 0, 0, 0, 0, 1, + 0, 1, 1, 1, 1, 0, + 0, 1, 1, 0, 1, 0, + 0, 1, 1, 0, 1, 0, + 0, 1, 1, 0, 1, 0, + 0, 1, 1, 0, 1, 0, + 0, 1, 1, 1, 1, 0, + 1, 0, 0, 0, 0, 0, + }, + + { + 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 0, 0, 0, 0, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, + }, + { + 0, 0, 0, 0, 0, 1, + 0, 1, 1, 1, 1, 0, + 0, 1, 0, 1, 1, 0, + 0, 1, 0, 1, 1, 0, + 0, 1, 0, 1, 1, 0, + 0, 1, 0, 1, 1, 0, + 0, 1, 1, 1, 1, 0, + 1, 0, 0, 0, 0, 0, + }, + }, + }, + } + + for _, test := range tests { + for i, rot := range test.rotateHow { + if output := ExampleRotate(test.input, test.w, test.h, rot); !bytes.Equal(output, test.expected[i]) { + t.Errorf( + "Test fail for angle %v with %v that should be \n%v but it's \n%v", + rot, test.input, test.expected[i], output) + } + } + } +} + +func TestBoundsAfterRotation(t *testing.T) { + tests := []struct { + dim []dimensions + rotateHow []Angle + }{ + { + // a combinatorics lib would be nice instead + []dimensions{ + // square + {w: 100, h: 100}, + // even w/h + {w: 100, h: 50}, + // even h/w + {w: 50, h: 100}, + // odd even w/h + {w: 77, h: 32}, + // even odd h/w + {w: 32, h: 77}, + // just odd + {w: 13, h: 19}, + }, + []Angle{Angle0, Angle90, Angle180, Angle270}, + }, + } + + for _, test := range tests { + for _, rot := range test.rotateHow { + rotationFn := Angles[rot] + for _, dim := range test.dim { + + for y := 0; y < dim.h; y++ { + for x := 0; x < dim.w; x++ { + + xx, yy := rotationFn.Call(x, y, dim.w, dim.h) + + if rotationFn.IsEven { + yy, xx = xx, yy + } + + if xx < 0 || xx > dim.w { + t.Errorf("Rot %v, coordinate x should be in range [0; %v]: %v", rot, dim.w-1, xx) + } + + if yy < 0 || yy > dim.h { + t.Errorf("Rot %v, coordinate y should be in range [0; %v]: %v", rot, dim.h-1, yy) + } + } + } + } + } + } +} + +func BenchmarkDirect(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = Rotate90(1, 1, 2, 2) + } +} + +func BenchmarkLiteral(b *testing.B) { + fn := Rotate90 + for i := 0; i < b.N; i++ { + _, _ = fn(1, 1, 2, 2) + } +} + +func BenchmarkAssign(b *testing.B) { + fn := Angles[Angle90].Call + for i := 0; i < b.N; i++ { + _, _ = fn(1, 1, 2, 2) + } +} + +func BenchmarkMapReassign(b *testing.B) { + fn := Angles[Angle90].Call + for i := 0; i < b.N; i++ { + fn2 := fn + _, _ = fn2(1, 1, 2, 2) + } +} + +func BenchmarkMapDirect(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = Angles[Angle90].Call(1, 1, 2, 2) + } +} + +func BenchmarkNewMapDirect(b *testing.B) { + fns := map[Angle]func(x, y, w, h int) (int, int){ + Angle90: Rotate90, + } + + for i := 0; i < b.N; i++ { + _, _ = fns[Angle90](1, 1, 2, 2) + } +} diff --git a/pkg/worker/emulator/image/scale.go b/pkg/worker/emulator/image/scale.go new file mode 100644 index 00000000..32750d73 --- /dev/null +++ b/pkg/worker/emulator/image/scale.go @@ -0,0 +1,27 @@ +package image + +import ( + "image" + + "golang.org/x/image/draw" +) + +const ( + ScaleNot = iota // skips image interpolation + ScaleNearestNeighbour // nearest neighbour interpolation + ScaleBilinear // bilinear interpolation +) + +func Resize(scaleType int, src *image.RGBA, out *image.RGBA) { + // !to do set it once instead switching on each iteration + switch scaleType { + case ScaleBilinear: + draw.ApproxBiLinear.Scale(out, out.Bounds(), src, src.Bounds(), draw.Src, nil) + case ScaleNot: + fallthrough + case ScaleNearestNeighbour: + fallthrough + default: + draw.NearestNeighbor.Scale(out, out.Bounds(), src, src.Bounds(), draw.Src, nil) + } +} diff --git a/pkg/worker/emulator/libretro/core.go b/pkg/worker/emulator/libretro/core.go new file mode 100644 index 00000000..72393ee6 --- /dev/null +++ b/pkg/worker/emulator/libretro/core.go @@ -0,0 +1,39 @@ +package libretro + +import ( + "errors" + "runtime" +) + +// See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63. +var libretroOsArchMap = map[string]ArchInfo{ + "linux:amd64": {Os: "linux", Arch: "x86_64", LibExt: ".so"}, + "linux:arm": {Os: "linux", Arch: "armv7-neon-hf", LibExt: ".so"}, + "windows:amd64": {Os: "windows", Arch: "x86_64", LibExt: ".dll"}, + "darwin:amd64": {Os: "osx", Arch: "x86_64", Vendor: "apple", LibExt: ".dylib"}, + "darwin:arm64": {Os: "osx", Arch: "arm64", Vendor: "apple", LibExt: ".dylib"}, +} + +// ArchInfo contains Libretro core lib platform info. +// And cores are just C-compiled libraries. +// See: https://buildbot.libretro.com/nightly. +type ArchInfo struct { + // bottom: x86_64, x86, ... + Arch string + // middle: windows, ios, ... + Os string + // top level: apple, nintendo, ... + Vendor string + + // platform dependent library file extension (dot-prefixed) + LibExt string +} + +func GetCoreExt() (ArchInfo, error) { + key := runtime.GOOS + ":" + runtime.GOARCH + if arch, ok := libretroOsArchMap[key]; ok { + return arch, nil + } else { + return ArchInfo{}, errors.New("core mapping not found for " + key) + } +} diff --git a/pkg/worker/emulator/libretro/frontend.go b/pkg/worker/emulator/libretro/frontend.go new file mode 100644 index 00000000..4c550f69 --- /dev/null +++ b/pkg/worker/emulator/libretro/frontend.go @@ -0,0 +1,268 @@ +package libretro + +import ( + "errors" + "fmt" + "path/filepath" + "sync" + "sync/atomic" + "time" + + conf "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/image" +) + +type Frontend struct { + onVideo func(*emulator.GameFrame) + onAudio func(*emulator.GameAudio) + + input InputState + + conf conf.Emulator + storage Storage + + // out frame size + vw, vh int + // draw threads + th int + + stopped atomic.Bool + + canvas *image.Canvas + + done chan struct{} + log *logger.Logger + + mu sync.Mutex +} + +// InputState stores full controller state. +// It consists of: +// - uint16 button values +// - int16 analog stick values +type ( + InputState [maxPort]State + State struct { + keys uint32 + axes [dpadAxes]int32 + } +) + +const ( + maxPort = 4 + dpadAxes = 4 + KeyPressed = 1 + KeyReleased = 0 +) + +var ( + noAudio = func(*emulator.GameAudio) {} + noVideo = func(*emulator.GameFrame) {} +) + +// NewFrontend implements Emulator interface for a Libretro frontend. +func NewFrontend(conf conf.Emulator, log *logger.Logger) (*Frontend, error) { + log = log.Extend(log.With().Str("m", "Libretro")) + ll := log.Extend(log.Level(logger.Level(conf.Libretro.LogLevel)).With()) + SetLibretroLogger(ll) + + // Check if room is on local storage, if not, pull from GCS to local storage + log.Info().Msgf("Local storage path: %v", conf.Storage) + if err := os.CheckCreateDir(conf.Storage); err != nil { + return nil, fmt.Errorf("failed to create local storage path: %v, %w", conf.Storage, err) + } + + path, err := filepath.Abs(conf.LocalPath) + if err != nil { + return nil, fmt.Errorf("failed to use emulator path: %v, %w", conf.LocalPath, err) + } + if err := os.CheckCreateDir(path); err != nil { + return nil, fmt.Errorf("failed to create local path: %v, %w", conf.LocalPath, err) + } + log.Info().Msgf("Emulator save path is %v", path) + Init(path) + + var store Storage = &StateStorage{Path: conf.Storage} + if conf.Libretro.SaveCompression { + store = &ZipStorage{Storage: store} + } + + // set global link to the Libretro + frontend = &Frontend{ + conf: conf, + storage: store, + input: NewGameSessionInput(), + done: make(chan struct{}), + th: conf.Threads, + log: log, + onAudio: noAudio, + onVideo: noVideo, + } + return frontend, nil +} + +func (f *Frontend) LoadMetadata(emu string) { + config := f.conf.GetLibretroCoreConfig(emu) + f.mu.Lock() + coreLoad(emulator.Metadata{ + LibPath: config.Lib, + ConfigPath: config.Config, + IsGlAllowed: config.IsGlAllowed, + UsesLibCo: config.UsesLibCo, + HasMultitap: config.HasMultitap, + AutoGlContext: config.AutoGlContext, + }) + f.mu.Unlock() +} + +func (f *Frontend) Start() { + // start only when it is available + <-nano.reserved + + if err := f.LoadGameState(); err != nil { + f.log.Error().Err(err).Msg("couldn't load a save file") + } + ticker := time.NewTicker(time.Second / time.Duration(nano.sysAvInfo.timing.fps)) + + defer func() { + f.log.Debug().Msgf("run loop cleanup") + ticker.Stop() + f.mu.Lock() + nanoarchShutdown() + frontend.canvas.Clear() + f.SetAudio(noAudio) + f.SetVideo(noVideo) + f.mu.Unlock() + f.log.Debug().Msgf("run loop finished") + }() + + // start time for the first frame + lastFrameTime = time.Now().UnixNano() + for { + // selection from just two channels may freeze on + // ticker, ignoring the close chan for some reason + select { + case <-f.done: + return + default: + select { + case <-ticker.C: + f.mu.Lock() + run() + f.mu.Unlock() + case <-f.done: + return + } + } + } +} + +func (f *Frontend) GetFrameSize() (int, int) { + return int(nano.sysAvInfo.geometry.base_width), int(nano.sysAvInfo.geometry.base_height) +} + +func (f *Frontend) SetAudio(ff func(*emulator.GameAudio)) { f.onAudio = ff } +func (f *Frontend) GetAudio() func(*emulator.GameAudio) { return f.onAudio } +func (f *Frontend) SetVideo(ff func(*emulator.GameFrame)) { f.onVideo = ff } +func (f *Frontend) GetVideo() func(*emulator.GameFrame) { return f.onVideo } +func (f *Frontend) GetFps() uint { return uint(nano.sysAvInfo.timing.fps) } +func (f *Frontend) GetHashPath() string { return f.storage.GetSavePath() } +func (f *Frontend) GetSRAMPath() string { return f.storage.GetSRAMPath() } +func (f *Frontend) GetSampleRate() uint { return uint(nano.sysAvInfo.timing.sample_rate) } +func (f *Frontend) Input(player int, data []byte) { f.input.setInput(player, data) } +func (f *Frontend) LoadGame(path string) error { return LoadGame(path) } +func (f *Frontend) LoadGameState() error { return f.Load() } +func (f *Frontend) HasVerticalFrame() bool { return nano.rot != nil && nano.rot.IsEven } +func (f *Frontend) SaveGameState() error { return f.Save() } +func (f *Frontend) SetMainSaveName(name string) { f.storage.SetMainSaveName(name) } +func (f *Frontend) SetViewport(width int, height int) { + f.mu.Lock() + f.vw, f.vh = width, height + size := int(nano.sysAvInfo.geometry.max_width * nano.sysAvInfo.geometry.max_height) + f.canvas = image.NewCanvas(width, height, size) + f.mu.Unlock() +} +func (f *Frontend) ToggleMultitap() { toggleMultitap() } + +func (f *Frontend) Close() { + f.log.Debug().Msgf("frontend close called") + close(f.done) + frontend.stopped.Store(true) + nano.reserved <- struct{}{} +} + +// Save writes the current state to the filesystem. +func (f *Frontend) Save() error { + f.mu.Lock() + defer f.mu.Unlock() + + if usesLibCo { + return nil + } + + ss, err := getSaveState() + if err != nil { + return err + } + if err := f.storage.Save(f.GetHashPath(), ss); err != nil { + return err + } + + if sram := getSaveRAM(); sram != nil { + if err := f.storage.Save(f.GetSRAMPath(), sram); err != nil { + return err + } + } + return nil +} + +// Load restores the state from the filesystem. +func (f *Frontend) Load() error { + f.mu.Lock() + defer f.mu.Unlock() + + if usesLibCo { + return nil + } + + ss, err := f.storage.Load(f.GetHashPath()) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := restoreSaveState(ss); err != nil { + return err + } + + sram, err := f.storage.Load(f.GetSRAMPath()) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if sram != nil { + restoreSaveRAM(sram) + } + return nil +} + +func NewGameSessionInput() InputState { return [maxPort]State{} } + +// setInput sets input state for some player in a game session. +func (s *InputState) setInput(player int, data []byte) { + atomic.StoreUint32(&s[player].keys, uint32(uint16(data[1])<<8+uint16(data[0]))) + for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ { + axis := i<<1 + 2 + atomic.StoreInt32(&s[player].axes[i], int32(data[axis+1])<<8+int32(data[axis])) + } +} + +// isKeyPressed checks if some button is pressed by any player. +func (s *InputState) isKeyPressed(port uint, key int) int { + return int((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1) +} + +// isDpadTouched checks if D-pad is used by any player. +func (s *InputState) isDpadTouched(port uint, axis uint) (shift int16) { + return int16(atomic.LoadInt32(&s[port].axes[axis])) +} diff --git a/pkg/worker/emulator/libretro/frontend_test.go b/pkg/worker/emulator/libretro/frontend_test.go new file mode 100644 index 00000000..2a77e287 --- /dev/null +++ b/pkg/worker/emulator/libretro/frontend_test.go @@ -0,0 +1,264 @@ +package libretro + +import ( + "fmt" + "math/rand" + "sync" + "testing" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" +) + +// Tests a successful emulator state save. +func TestSave(t *testing.T) { + tests := []testRun{ + { + room: "test_save_ok_00", + system: "gba", + rom: "Sushi The Cat.gba", + emulationTicks: 100, + }, + { + room: "test_save_ok_01", + system: "gba", + rom: "anguna.gba", + emulationTicks: 10, + }, + } + + for _, test := range tests { + t.Logf("Testing [%v] save with [%v]\n", test.system, test.rom) + + mock := GetDefaultEmulatorMock(test.room, test.system, test.rom) + + for test.emulationTicks > 0 { + mock.emulateOneFrame() + test.emulationTicks-- + } + + fmt.Printf("[%-14v] ", "before save") + _, _ = mock.dumpState() + if err := mock.Save(); err != nil { + t.Errorf("Save fail %v", err) + } + fmt.Printf("[%-14v] ", "after save") + snapshot1, snapshot2 := mock.dumpState() + + if snapshot1 != snapshot2 { + t.Errorf("It seems rom state save has failed: %v != %v", snapshot1, snapshot2) + } + + mock.shutdownEmulator() + } +} + +// Tests save and restore function: +// +// Emulate n ticks. +// Call save (a). +// Emulate n ticks again. +// Call load from the save (b). +// Compare states (a) and (b), should be =. +func TestLoad(t *testing.T) { + t.Skip() + tests := []testRun{ + { + room: "test_load_00", + system: "nes", + rom: "Super Mario Bros.nes", + emulationTicks: 100, + }, + { + room: "test_load_01", + system: "gba", + rom: "Sushi The Cat.gba", + emulationTicks: 1000, + }, + { + room: "test_load_02", + system: "gba", + rom: "anguna.gba", + emulationTicks: 100, + }, + } + + for _, test := range tests { + t.Logf("Testing [%v] load with [%v]\n", test.system, test.rom) + + mock := GetDefaultEmulatorMock(test.room, test.system, test.rom) + + fmt.Printf("[%-14v] ", "initial") + mock.dumpState() + + for ticks := test.emulationTicks; ticks > 0; ticks-- { + mock.emulateOneFrame() + } + fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.emulationTicks)) + mock.dumpState() + + if err := mock.Save(); err != nil { + t.Errorf("Save fail %v", err) + } + fmt.Printf("[%-14v] ", "saved") + snapshot1, _ := mock.dumpState() + + for ticks := test.emulationTicks; ticks > 0; ticks-- { + mock.emulateOneFrame() + } + fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.emulationTicks)) + mock.dumpState() + + if err := mock.Load(); err != nil { + t.Errorf("Load fail %v", err) + } + fmt.Printf("[%-14v] ", "restored") + snapshot2, _ := mock.dumpState() + + if snapshot1 != snapshot2 { + t.Errorf("It seems rom state restore has failed: %v != %v", snapshot1, snapshot2) + } + + mock.shutdownEmulator() + } +} + +func TestStateConcurrency(t *testing.T) { + tests := []struct { + run testRun + // determine random + seed int + }{ + { + run: testRun{ + room: "test_concurrency_00", + system: "gba", + rom: "Sushi The Cat.gba", + emulationTicks: 120, + }, + seed: 42, + }, + { + run: testRun{ + room: "test_concurrency_01", + system: "gba", + rom: "anguna.gba", + emulationTicks: 300, + }, + seed: 42 + 42, + }, + } + + for _, test := range tests { + t.Logf("Testing [%v] concurrency with [%v]\n", test.run.system, test.run.rom) + + mock := GetEmulatorMock(test.run.room, test.run.system) + ops := &sync.WaitGroup{} + // quantum lock + qLock := &sync.Mutex{} + op := 0 + <-nano.reserved + + mock.loadRom(test.run.rom) + mock.handleVideo(func(frame *emulator.GameFrame) { + if len(frame.Data.Pix) == 0 { + t.Errorf("It seems that rom video frame was empty, which is strange!") + } + }) + mock.handleAudio(func(_ *emulator.GameAudio) {}) + + rand.Seed(int64(test.seed)) + t.Logf("Random seed is [%v]\n", test.seed) + t.Logf("Save path is [%v]\n", mock.paths.save) + + _ = mock.Save() + + // emulation fps ROM cap + ticker := time.NewTicker(time.Second / time.Duration(mock.GetFps())) + t.Logf("FPS limit is [%v]\n", mock.GetFps()) + + for range ticker.C { + select { + case <-mock.done: + mock.shutdownEmulator() + return + default: + } + + op++ + if op > test.run.emulationTicks { + mock.Close() + } else { + qLock.Lock() + mock.emulateOneFrame() + qLock.Unlock() + + if lucky() && !lucky() { + ops.Add(1) + go func() { + qLock.Lock() + defer qLock.Unlock() + + mock.dumpState() + // remove save to reproduce the bug + _ = mock.Save() + _, snapshot1 := mock.dumpState() + _ = mock.Load() + snapshot2, _ := mock.dumpState() + + // Bug or feature? + // When you load a state from the file + // without immediate preceding save, + // it won't be in the loaded state + // even without calling retro_run. + // But if you pause the threads with a debugger + // and run the code step by step, then it will work as expected. + // Possible background emulation? + + if snapshot1 != snapshot2 { + t.Errorf("States are inconsistent %v != %v on tick %v\n", snapshot1, snapshot2, op) + } + ops.Done() + }() + } + } + } + + ops.Wait() + ticker.Stop() + } +} + +// lucky returns random boolean. +func lucky() bool { return rand.Intn(2) == 1 } + +func TestConcurrentInput(t *testing.T) { + players := NewGameSessionInput() + + events := 1000 + var wg sync.WaitGroup + + wg.Add(events * 2) + + go func() { + for i := 0; i < events; i++ { + player := rand.Intn(maxPort) + go func() { + players.setInput(player, []byte{0, 1}) + wg.Done() + }() + } + }() + + go func() { + for i := 0; i < events; i++ { + player := rand.Intn(maxPort) + go func() { + players.isKeyPressed(uint(player), 100) + wg.Done() + }() + } + }() + + wg.Wait() +} diff --git a/pkg/worker/emulator/libretro/libretro.h b/pkg/worker/emulator/libretro/libretro.h new file mode 100644 index 00000000..79feb2c3 --- /dev/null +++ b/pkg/worker/emulator/libretro/libretro.h @@ -0,0 +1,3937 @@ +/* Copyright (C) 2010-2020 The RetroArch team + * + * --------------------------------------------------------------------------------------- + * The following license statement only applies to this libretro API header (libretro.h). + * --------------------------------------------------------------------------------------- + * + * Permission is hereby granted, free of charge, + * to any person obtaining a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#ifndef LIBRETRO_H__ +#define LIBRETRO_H__ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef __cplusplus +#if defined(_MSC_VER) && _MSC_VER < 1800 && !defined(SN_TARGET_PS3) +/* Hack applied for MSVC when compiling in C89 mode + * as it isn't C99-compliant. */ +#define bool unsigned char +#define true 1 +#define false 0 +#else +#include +#endif +#endif + +#ifndef RETRO_CALLCONV +# if defined(__GNUC__) && defined(__i386__) && !defined(__x86_64__) +# define RETRO_CALLCONV __attribute__((cdecl)) +# elif defined(_MSC_VER) && defined(_M_X86) && !defined(_M_X64) +# define RETRO_CALLCONV __cdecl +# else +# define RETRO_CALLCONV /* all other platforms only have one calling convention each */ +# endif +#endif + +#ifndef RETRO_API +# if defined(_WIN32) || defined(__CYGWIN__) || defined(__MINGW32__) +# ifdef RETRO_IMPORT_SYMBOLS +# ifdef __GNUC__ +# define RETRO_API RETRO_CALLCONV __attribute__((__dllimport__)) +# else +# define RETRO_API RETRO_CALLCONV __declspec(dllimport) +# endif +# else +# ifdef __GNUC__ +# define RETRO_API RETRO_CALLCONV __attribute__((__dllexport__)) +# else +# define RETRO_API RETRO_CALLCONV __declspec(dllexport) +# endif +# endif +# else +# if defined(__GNUC__) && __GNUC__ >= 4 +# define RETRO_API RETRO_CALLCONV __attribute__((__visibility__("default"))) +# else +# define RETRO_API RETRO_CALLCONV +# endif +# endif +#endif + +/* Used for checking API/ABI mismatches that can break libretro + * implementations. + * It is not incremented for compatible changes to the API. + */ +#define RETRO_API_VERSION 1 + +/* + * Libretro's fundamental device abstractions. + * + * Libretro's input system consists of some standardized device types, + * such as a joypad (with/without analog), mouse, keyboard, lightgun + * and a pointer. + * + * The functionality of these devices are fixed, and individual cores + * map their own concept of a controller to libretro's abstractions. + * This makes it possible for frontends to map the abstract types to a + * real input device, and not having to worry about binding input + * correctly to arbitrary controller layouts. + */ + +#define RETRO_DEVICE_TYPE_SHIFT 8 +#define RETRO_DEVICE_MASK ((1 << RETRO_DEVICE_TYPE_SHIFT) - 1) +#define RETRO_DEVICE_SUBCLASS(base, id) (((id + 1) << RETRO_DEVICE_TYPE_SHIFT) | base) + +/* Input disabled. */ +#define RETRO_DEVICE_NONE 0 + +/* The JOYPAD is called RetroPad. It is essentially a Super Nintendo + * controller, but with additional L2/R2/L3/R3 buttons, similar to a + * PS1 DualShock. */ +#define RETRO_DEVICE_JOYPAD 1 + +/* The mouse is a simple mouse, similar to Super Nintendo's mouse. + * X and Y coordinates are reported relatively to last poll (poll callback). + * It is up to the libretro implementation to keep track of where the mouse + * pointer is supposed to be on the screen. + * The frontend must make sure not to interfere with its own hardware + * mouse pointer. + */ +#define RETRO_DEVICE_MOUSE 2 + +/* KEYBOARD device lets one poll for raw key pressed. + * It is poll based, so input callback will return with the current + * pressed state. + * For event/text based keyboard input, see + * RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK. + */ +#define RETRO_DEVICE_KEYBOARD 3 + +/* LIGHTGUN device is similar to Guncon-2 for PlayStation 2. + * It reports X/Y coordinates in screen space (similar to the pointer) + * in the range [-0x8000, 0x7fff] in both axes, with zero being center and + * -0x8000 being out of bounds. + * As well as reporting on/off screen state. It features a trigger, + * start/select buttons, auxiliary action buttons and a + * directional pad. A forced off-screen shot can be requested for + * auto-reloading function in some games. + */ +#define RETRO_DEVICE_LIGHTGUN 4 + +/* The ANALOG device is an extension to JOYPAD (RetroPad). + * Similar to DualShock2 it adds two analog sticks and all buttons can + * be analog. This is treated as a separate device type as it returns + * axis values in the full analog range of [-0x7fff, 0x7fff], + * although some devices may return -0x8000. + * Positive X axis is right. Positive Y axis is down. + * Buttons are returned in the range [0, 0x7fff]. + * Only use ANALOG type when polling for analog values. + */ +#define RETRO_DEVICE_ANALOG 5 + +/* Abstracts the concept of a pointing mechanism, e.g. touch. + * This allows libretro to query in absolute coordinates where on the + * screen a mouse (or something similar) is being placed. + * For a touch centric device, coordinates reported are the coordinates + * of the press. + * + * Coordinates in X and Y are reported as: + * [-0x7fff, 0x7fff]: -0x7fff corresponds to the far left/top of the screen, + * and 0x7fff corresponds to the far right/bottom of the screen. + * The "screen" is here defined as area that is passed to the frontend and + * later displayed on the monitor. + * + * The frontend is free to scale/resize this screen as it sees fit, however, + * (X, Y) = (-0x7fff, -0x7fff) will correspond to the top-left pixel of the + * game image, etc. + * + * To check if the pointer coordinates are valid (e.g. a touch display + * actually being touched), PRESSED returns 1 or 0. + * + * If using a mouse on a desktop, PRESSED will usually correspond to the + * left mouse button, but this is a frontend decision. + * PRESSED will only return 1 if the pointer is inside the game screen. + * + * For multi-touch, the index variable can be used to successively query + * more presses. + * If index = 0 returns true for _PRESSED, coordinates can be extracted + * with _X, _Y for index = 0. One can then query _PRESSED, _X, _Y with + * index = 1, and so on. + * Eventually _PRESSED will return false for an index. No further presses + * are registered at this point. */ +#define RETRO_DEVICE_POINTER 6 + +/* Buttons for the RetroPad (JOYPAD). + * The placement of these is equivalent to placements on the + * Super Nintendo controller. + * L2/R2/L3/R3 buttons correspond to the PS1 DualShock. + * Also used as id values for RETRO_DEVICE_INDEX_ANALOG_BUTTON */ +#define RETRO_DEVICE_ID_JOYPAD_B 0 +#define RETRO_DEVICE_ID_JOYPAD_Y 1 +#define RETRO_DEVICE_ID_JOYPAD_SELECT 2 +#define RETRO_DEVICE_ID_JOYPAD_START 3 +#define RETRO_DEVICE_ID_JOYPAD_UP 4 +#define RETRO_DEVICE_ID_JOYPAD_DOWN 5 +#define RETRO_DEVICE_ID_JOYPAD_LEFT 6 +#define RETRO_DEVICE_ID_JOYPAD_RIGHT 7 +#define RETRO_DEVICE_ID_JOYPAD_A 8 +#define RETRO_DEVICE_ID_JOYPAD_X 9 +#define RETRO_DEVICE_ID_JOYPAD_L 10 +#define RETRO_DEVICE_ID_JOYPAD_R 11 +#define RETRO_DEVICE_ID_JOYPAD_L2 12 +#define RETRO_DEVICE_ID_JOYPAD_R2 13 +#define RETRO_DEVICE_ID_JOYPAD_L3 14 +#define RETRO_DEVICE_ID_JOYPAD_R3 15 + +#define RETRO_DEVICE_ID_JOYPAD_MASK 256 + +/* Index / Id values for ANALOG device. */ +#define RETRO_DEVICE_INDEX_ANALOG_LEFT 0 +#define RETRO_DEVICE_INDEX_ANALOG_RIGHT 1 +#define RETRO_DEVICE_INDEX_ANALOG_BUTTON 2 +#define RETRO_DEVICE_ID_ANALOG_X 0 +#define RETRO_DEVICE_ID_ANALOG_Y 1 + +/* Id values for MOUSE. */ +#define RETRO_DEVICE_ID_MOUSE_X 0 +#define RETRO_DEVICE_ID_MOUSE_Y 1 +#define RETRO_DEVICE_ID_MOUSE_LEFT 2 +#define RETRO_DEVICE_ID_MOUSE_RIGHT 3 +#define RETRO_DEVICE_ID_MOUSE_WHEELUP 4 +#define RETRO_DEVICE_ID_MOUSE_WHEELDOWN 5 +#define RETRO_DEVICE_ID_MOUSE_MIDDLE 6 +#define RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELUP 7 +#define RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELDOWN 8 +#define RETRO_DEVICE_ID_MOUSE_BUTTON_4 9 +#define RETRO_DEVICE_ID_MOUSE_BUTTON_5 10 + +/* Id values for LIGHTGUN. */ +#define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_X 13 /*Absolute Position*/ +#define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_Y 14 /*Absolute*/ +#define RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN 15 /*Status Check*/ +#define RETRO_DEVICE_ID_LIGHTGUN_TRIGGER 2 +#define RETRO_DEVICE_ID_LIGHTGUN_RELOAD 16 /*Forced off-screen shot*/ +#define RETRO_DEVICE_ID_LIGHTGUN_AUX_A 3 +#define RETRO_DEVICE_ID_LIGHTGUN_AUX_B 4 +#define RETRO_DEVICE_ID_LIGHTGUN_START 6 +#define RETRO_DEVICE_ID_LIGHTGUN_SELECT 7 +#define RETRO_DEVICE_ID_LIGHTGUN_AUX_C 8 +#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_UP 9 +#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_DOWN 10 +#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_LEFT 11 +#define RETRO_DEVICE_ID_LIGHTGUN_DPAD_RIGHT 12 +/* deprecated */ +#define RETRO_DEVICE_ID_LIGHTGUN_X 0 /*Relative Position*/ +#define RETRO_DEVICE_ID_LIGHTGUN_Y 1 /*Relative*/ +#define RETRO_DEVICE_ID_LIGHTGUN_CURSOR 3 /*Use Aux:A*/ +#define RETRO_DEVICE_ID_LIGHTGUN_TURBO 4 /*Use Aux:B*/ +#define RETRO_DEVICE_ID_LIGHTGUN_PAUSE 5 /*Use Start*/ + +/* Id values for POINTER. */ +#define RETRO_DEVICE_ID_POINTER_X 0 +#define RETRO_DEVICE_ID_POINTER_Y 1 +#define RETRO_DEVICE_ID_POINTER_PRESSED 2 +#define RETRO_DEVICE_ID_POINTER_COUNT 3 + +/* Returned from retro_get_region(). */ +#define RETRO_REGION_NTSC 0 +#define RETRO_REGION_PAL 1 + +/* Id values for LANGUAGE */ +enum retro_language +{ + RETRO_LANGUAGE_ENGLISH = 0, + RETRO_LANGUAGE_JAPANESE = 1, + RETRO_LANGUAGE_FRENCH = 2, + RETRO_LANGUAGE_SPANISH = 3, + RETRO_LANGUAGE_GERMAN = 4, + RETRO_LANGUAGE_ITALIAN = 5, + RETRO_LANGUAGE_DUTCH = 6, + RETRO_LANGUAGE_PORTUGUESE_BRAZIL = 7, + RETRO_LANGUAGE_PORTUGUESE_PORTUGAL = 8, + RETRO_LANGUAGE_RUSSIAN = 9, + RETRO_LANGUAGE_KOREAN = 10, + RETRO_LANGUAGE_CHINESE_TRADITIONAL = 11, + RETRO_LANGUAGE_CHINESE_SIMPLIFIED = 12, + RETRO_LANGUAGE_ESPERANTO = 13, + RETRO_LANGUAGE_POLISH = 14, + RETRO_LANGUAGE_VIETNAMESE = 15, + RETRO_LANGUAGE_ARABIC = 16, + RETRO_LANGUAGE_GREEK = 17, + RETRO_LANGUAGE_TURKISH = 18, + RETRO_LANGUAGE_SLOVAK = 19, + RETRO_LANGUAGE_PERSIAN = 20, + RETRO_LANGUAGE_HEBREW = 21, + RETRO_LANGUAGE_ASTURIAN = 22, + RETRO_LANGUAGE_FINNISH = 23, + RETRO_LANGUAGE_INDONESIAN = 24, + RETRO_LANGUAGE_SWEDISH = 25, + RETRO_LANGUAGE_UKRAINIAN = 26, + RETRO_LANGUAGE_CZECH = 27, + RETRO_LANGUAGE_CATALAN_VALENCIA = 28, + RETRO_LANGUAGE_CATALAN = 29, + RETRO_LANGUAGE_BRITISH_ENGLISH = 30, + RETRO_LANGUAGE_HUNGARIAN = 31, + RETRO_LANGUAGE_LAST, + + /* Ensure sizeof(enum) == sizeof(int) */ + RETRO_LANGUAGE_DUMMY = INT_MAX +}; + +/* Passed to retro_get_memory_data/size(). + * If the memory type doesn't apply to the + * implementation NULL/0 can be returned. + */ +#define RETRO_MEMORY_MASK 0xff + +/* Regular save RAM. This RAM is usually found on a game cartridge, + * backed up by a battery. + * If save game data is too complex for a single memory buffer, + * the SAVE_DIRECTORY (preferably) or SYSTEM_DIRECTORY environment + * callback can be used. */ +#define RETRO_MEMORY_SAVE_RAM 0 + +/* Some games have a built-in clock to keep track of time. + * This memory is usually just a couple of bytes to keep track of time. + */ +#define RETRO_MEMORY_RTC 1 + +/* System ram lets a frontend peek into a game systems main RAM. */ +#define RETRO_MEMORY_SYSTEM_RAM 2 + +/* Video ram lets a frontend peek into a game systems video RAM (VRAM). */ +#define RETRO_MEMORY_VIDEO_RAM 3 + +/* Keysyms used for ID in input state callback when polling RETRO_KEYBOARD. */ +enum retro_key +{ + RETROK_UNKNOWN = 0, + RETROK_FIRST = 0, + RETROK_BACKSPACE = 8, + RETROK_TAB = 9, + RETROK_CLEAR = 12, + RETROK_RETURN = 13, + RETROK_PAUSE = 19, + RETROK_ESCAPE = 27, + RETROK_SPACE = 32, + RETROK_EXCLAIM = 33, + RETROK_QUOTEDBL = 34, + RETROK_HASH = 35, + RETROK_DOLLAR = 36, + RETROK_AMPERSAND = 38, + RETROK_QUOTE = 39, + RETROK_LEFTPAREN = 40, + RETROK_RIGHTPAREN = 41, + RETROK_ASTERISK = 42, + RETROK_PLUS = 43, + RETROK_COMMA = 44, + RETROK_MINUS = 45, + RETROK_PERIOD = 46, + RETROK_SLASH = 47, + RETROK_0 = 48, + RETROK_1 = 49, + RETROK_2 = 50, + RETROK_3 = 51, + RETROK_4 = 52, + RETROK_5 = 53, + RETROK_6 = 54, + RETROK_7 = 55, + RETROK_8 = 56, + RETROK_9 = 57, + RETROK_COLON = 58, + RETROK_SEMICOLON = 59, + RETROK_LESS = 60, + RETROK_EQUALS = 61, + RETROK_GREATER = 62, + RETROK_QUESTION = 63, + RETROK_AT = 64, + RETROK_LEFTBRACKET = 91, + RETROK_BACKSLASH = 92, + RETROK_RIGHTBRACKET = 93, + RETROK_CARET = 94, + RETROK_UNDERSCORE = 95, + RETROK_BACKQUOTE = 96, + RETROK_a = 97, + RETROK_b = 98, + RETROK_c = 99, + RETROK_d = 100, + RETROK_e = 101, + RETROK_f = 102, + RETROK_g = 103, + RETROK_h = 104, + RETROK_i = 105, + RETROK_j = 106, + RETROK_k = 107, + RETROK_l = 108, + RETROK_m = 109, + RETROK_n = 110, + RETROK_o = 111, + RETROK_p = 112, + RETROK_q = 113, + RETROK_r = 114, + RETROK_s = 115, + RETROK_t = 116, + RETROK_u = 117, + RETROK_v = 118, + RETROK_w = 119, + RETROK_x = 120, + RETROK_y = 121, + RETROK_z = 122, + RETROK_LEFTBRACE = 123, + RETROK_BAR = 124, + RETROK_RIGHTBRACE = 125, + RETROK_TILDE = 126, + RETROK_DELETE = 127, + + RETROK_KP0 = 256, + RETROK_KP1 = 257, + RETROK_KP2 = 258, + RETROK_KP3 = 259, + RETROK_KP4 = 260, + RETROK_KP5 = 261, + RETROK_KP6 = 262, + RETROK_KP7 = 263, + RETROK_KP8 = 264, + RETROK_KP9 = 265, + RETROK_KP_PERIOD = 266, + RETROK_KP_DIVIDE = 267, + RETROK_KP_MULTIPLY = 268, + RETROK_KP_MINUS = 269, + RETROK_KP_PLUS = 270, + RETROK_KP_ENTER = 271, + RETROK_KP_EQUALS = 272, + + RETROK_UP = 273, + RETROK_DOWN = 274, + RETROK_RIGHT = 275, + RETROK_LEFT = 276, + RETROK_INSERT = 277, + RETROK_HOME = 278, + RETROK_END = 279, + RETROK_PAGEUP = 280, + RETROK_PAGEDOWN = 281, + + RETROK_F1 = 282, + RETROK_F2 = 283, + RETROK_F3 = 284, + RETROK_F4 = 285, + RETROK_F5 = 286, + RETROK_F6 = 287, + RETROK_F7 = 288, + RETROK_F8 = 289, + RETROK_F9 = 290, + RETROK_F10 = 291, + RETROK_F11 = 292, + RETROK_F12 = 293, + RETROK_F13 = 294, + RETROK_F14 = 295, + RETROK_F15 = 296, + + RETROK_NUMLOCK = 300, + RETROK_CAPSLOCK = 301, + RETROK_SCROLLOCK = 302, + RETROK_RSHIFT = 303, + RETROK_LSHIFT = 304, + RETROK_RCTRL = 305, + RETROK_LCTRL = 306, + RETROK_RALT = 307, + RETROK_LALT = 308, + RETROK_RMETA = 309, + RETROK_LMETA = 310, + RETROK_LSUPER = 311, + RETROK_RSUPER = 312, + RETROK_MODE = 313, + RETROK_COMPOSE = 314, + + RETROK_HELP = 315, + RETROK_PRINT = 316, + RETROK_SYSREQ = 317, + RETROK_BREAK = 318, + RETROK_MENU = 319, + RETROK_POWER = 320, + RETROK_EURO = 321, + RETROK_UNDO = 322, + RETROK_OEM_102 = 323, + + RETROK_LAST, + + RETROK_DUMMY = INT_MAX /* Ensure sizeof(enum) == sizeof(int) */ +}; + +enum retro_mod +{ + RETROKMOD_NONE = 0x0000, + + RETROKMOD_SHIFT = 0x01, + RETROKMOD_CTRL = 0x02, + RETROKMOD_ALT = 0x04, + RETROKMOD_META = 0x08, + + RETROKMOD_NUMLOCK = 0x10, + RETROKMOD_CAPSLOCK = 0x20, + RETROKMOD_SCROLLOCK = 0x40, + + RETROKMOD_DUMMY = INT_MAX /* Ensure sizeof(enum) == sizeof(int) */ +}; + +/* If set, this call is not part of the public libretro API yet. It can + * change or be removed at any time. */ +#define RETRO_ENVIRONMENT_EXPERIMENTAL 0x10000 +/* Environment callback to be used internally in frontend. */ +#define RETRO_ENVIRONMENT_PRIVATE 0x20000 + +/* Environment commands. */ +#define RETRO_ENVIRONMENT_SET_ROTATION 1 /* const unsigned * -- + * Sets screen rotation of graphics. + * Valid values are 0, 1, 2, 3, which rotates screen by 0, 90, 180, + * 270 degrees counter-clockwise respectively. + */ +#define RETRO_ENVIRONMENT_GET_OVERSCAN 2 /* bool * -- + * NOTE: As of 2019 this callback is considered deprecated in favor of + * using core options to manage overscan in a more nuanced, core-specific way. + * + * Boolean value whether or not the implementation should use overscan, + * or crop away overscan. + */ +#define RETRO_ENVIRONMENT_GET_CAN_DUPE 3 /* bool * -- + * Boolean value whether or not frontend supports frame duping, + * passing NULL to video frame callback. + */ + + /* Environ 4, 5 are no longer supported (GET_VARIABLE / SET_VARIABLES), + * and reserved to avoid possible ABI clash. + */ + +#define RETRO_ENVIRONMENT_SET_MESSAGE 6 /* const struct retro_message * -- + * Sets a message to be displayed in implementation-specific manner + * for a certain amount of 'frames'. + * Should not be used for trivial messages, which should simply be + * logged via RETRO_ENVIRONMENT_GET_LOG_INTERFACE (or as a + * fallback, stderr). + */ +#define RETRO_ENVIRONMENT_SHUTDOWN 7 /* N/A (NULL) -- + * Requests the frontend to shutdown. + * Should only be used if game has a specific + * way to shutdown the game from a menu item or similar. + */ +#define RETRO_ENVIRONMENT_SET_PERFORMANCE_LEVEL 8 + /* const unsigned * -- + * Gives a hint to the frontend how demanding this implementation + * is on a system. E.g. reporting a level of 2 means + * this implementation should run decently on all frontends + * of level 2 and up. + * + * It can be used by the frontend to potentially warn + * about too demanding implementations. + * + * The levels are "floating". + * + * This function can be called on a per-game basis, + * as certain games an implementation can play might be + * particularly demanding. + * If called, it should be called in retro_load_game(). + */ +#define RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY 9 + /* const char ** -- + * Returns the "system" directory of the frontend. + * This directory can be used to store system specific + * content such as BIOSes, configuration data, etc. + * The returned value can be NULL. + * If so, no such directory is defined, + * and it's up to the implementation to find a suitable directory. + * + * NOTE: Some cores used this folder also for "save" data such as + * memory cards, etc, for lack of a better place to put it. + * This is now discouraged, and if possible, cores should try to + * use the new GET_SAVE_DIRECTORY. + */ +#define RETRO_ENVIRONMENT_SET_PIXEL_FORMAT 10 + /* const enum retro_pixel_format * -- + * Sets the internal pixel format used by the implementation. + * The default pixel format is RETRO_PIXEL_FORMAT_0RGB1555. + * This pixel format however, is deprecated (see enum retro_pixel_format). + * If the call returns false, the frontend does not support this pixel + * format. + * + * This function should be called inside retro_load_game() or + * retro_get_system_av_info(). + */ +#define RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS 11 + /* const struct retro_input_descriptor * -- + * Sets an array of retro_input_descriptors. + * It is up to the frontend to present this in a usable way. + * The array is terminated by retro_input_descriptor::description + * being set to NULL. + * This function can be called at any time, but it is recommended + * to call it as early as possible. + */ +#define RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK 12 + /* const struct retro_keyboard_callback * -- + * Sets a callback function used to notify core about keyboard events. + */ +#define RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE 13 + /* const struct retro_disk_control_callback * -- + * Sets an interface which frontend can use to eject and insert + * disk images. + * This is used for games which consist of multiple images and + * must be manually swapped out by the user (e.g. PSX). + */ +#define RETRO_ENVIRONMENT_SET_HW_RENDER 14 + /* struct retro_hw_render_callback * -- + * Sets an interface to let a libretro core render with + * hardware acceleration. + * Should be called in retro_load_game(). + * If successful, libretro cores will be able to render to a + * frontend-provided framebuffer. + * The size of this framebuffer will be at least as large as + * max_width/max_height provided in get_av_info(). + * If HW rendering is used, pass only RETRO_HW_FRAME_BUFFER_VALID or + * NULL to retro_video_refresh_t. + */ +#define RETRO_ENVIRONMENT_GET_VARIABLE 15 + /* struct retro_variable * -- + * Interface to acquire user-defined information from environment + * that cannot feasibly be supported in a multi-system way. + * 'key' should be set to a key which has already been set by + * SET_VARIABLES. + * 'data' will be set to a value or NULL. + */ +#define RETRO_ENVIRONMENT_SET_VARIABLES 16 + /* const struct retro_variable * -- + * Allows an implementation to signal the environment + * which variables it might want to check for later using + * GET_VARIABLE. + * This allows the frontend to present these variables to + * a user dynamically. + * This should be called the first time as early as + * possible (ideally in retro_set_environment). + * Afterward it may be called again for the core to communicate + * updated options to the frontend, but the number of core + * options must not change from the number in the initial call. + * + * 'data' points to an array of retro_variable structs + * terminated by a { NULL, NULL } element. + * retro_variable::key should be namespaced to not collide + * with other implementations' keys. E.g. A core called + * 'foo' should use keys named as 'foo_option'. + * retro_variable::value should contain a human readable + * description of the key as well as a '|' delimited list + * of expected values. + * + * The number of possible options should be very limited, + * i.e. it should be feasible to cycle through options + * without a keyboard. + * + * First entry should be treated as a default. + * + * Example entry: + * { "foo_option", "Speed hack coprocessor X; false|true" } + * + * Text before first ';' is description. This ';' must be + * followed by a space, and followed by a list of possible + * values split up with '|'. + * + * Only strings are operated on. The possible values will + * generally be displayed and stored as-is by the frontend. + */ +#define RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE 17 + /* bool * -- + * Result is set to true if some variables are updated by + * frontend since last call to RETRO_ENVIRONMENT_GET_VARIABLE. + * Variables should be queried with GET_VARIABLE. + */ +#define RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME 18 + /* const bool * -- + * If true, the libretro implementation supports calls to + * retro_load_game() with NULL as argument. + * Used by cores which can run without particular game data. + * This should be called within retro_set_environment() only. + */ +#define RETRO_ENVIRONMENT_GET_LIBRETRO_PATH 19 + /* const char ** -- + * Retrieves the absolute path from where this libretro + * implementation was loaded. + * NULL is returned if the libretro was loaded statically + * (i.e. linked statically to frontend), or if the path cannot be + * determined. + * Mostly useful in cooperation with SET_SUPPORT_NO_GAME as assets can + * be loaded without ugly hacks. + */ + + /* Environment 20 was an obsolete version of SET_AUDIO_CALLBACK. + * It was not used by any known core at the time, + * and was removed from the API. */ +#define RETRO_ENVIRONMENT_SET_FRAME_TIME_CALLBACK 21 + /* const struct retro_frame_time_callback * -- + * Lets the core know how much time has passed since last + * invocation of retro_run(). + * The frontend can tamper with the timing to fake fast-forward, + * slow-motion, frame stepping, etc. + * In this case the delta time will use the reference value + * in frame_time_callback.. + */ +#define RETRO_ENVIRONMENT_SET_AUDIO_CALLBACK 22 + /* const struct retro_audio_callback * -- + * Sets an interface which is used to notify a libretro core about audio + * being available for writing. + * The callback can be called from any thread, so a core using this must + * have a thread safe audio implementation. + * It is intended for games where audio and video are completely + * asynchronous and audio can be generated on the fly. + * This interface is not recommended for use with emulators which have + * highly synchronous audio. + * + * The callback only notifies about writability; the libretro core still + * has to call the normal audio callbacks + * to write audio. The audio callbacks must be called from within the + * notification callback. + * The amount of audio data to write is up to the implementation. + * Generally, the audio callback will be called continously in a loop. + * + * Due to thread safety guarantees and lack of sync between audio and + * video, a frontend can selectively disallow this interface based on + * internal configuration. A core using this interface must also + * implement the "normal" audio interface. + * + * A libretro core using SET_AUDIO_CALLBACK should also make use of + * SET_FRAME_TIME_CALLBACK. + */ +#define RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE 23 + /* struct retro_rumble_interface * -- + * Gets an interface which is used by a libretro core to set + * state of rumble motors in controllers. + * A strong and weak motor is supported, and they can be + * controlled indepedently. + * Should be called from either retro_init() or retro_load_game(). + * Should not be called from retro_set_environment(). + * Returns false if rumble functionality is unavailable. + */ +#define RETRO_ENVIRONMENT_GET_INPUT_DEVICE_CAPABILITIES 24 + /* uint64_t * -- + * Gets a bitmask telling which device type are expected to be + * handled properly in a call to retro_input_state_t. + * Devices which are not handled or recognized always return + * 0 in retro_input_state_t. + * Example bitmask: caps = (1 << RETRO_DEVICE_JOYPAD) | (1 << RETRO_DEVICE_ANALOG). + * Should only be called in retro_run(). + */ +#define RETRO_ENVIRONMENT_GET_SENSOR_INTERFACE (25 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_sensor_interface * -- + * Gets access to the sensor interface. + * The purpose of this interface is to allow + * setting state related to sensors such as polling rate, + * enabling/disable it entirely, etc. + * Reading sensor state is done via the normal + * input_state_callback API. + */ +#define RETRO_ENVIRONMENT_GET_CAMERA_INTERFACE (26 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_camera_callback * -- + * Gets an interface to a video camera driver. + * A libretro core can use this interface to get access to a + * video camera. + * New video frames are delivered in a callback in same + * thread as retro_run(). + * + * GET_CAMERA_INTERFACE should be called in retro_load_game(). + * + * Depending on the camera implementation used, camera frames + * will be delivered as a raw framebuffer, + * or as an OpenGL texture directly. + * + * The core has to tell the frontend here which types of + * buffers can be handled properly. + * An OpenGL texture can only be handled when using a + * libretro GL core (SET_HW_RENDER). + * It is recommended to use a libretro GL core when + * using camera interface. + * + * The camera is not started automatically. The retrieved start/stop + * functions must be used to explicitly + * start and stop the camera driver. + */ +#define RETRO_ENVIRONMENT_GET_LOG_INTERFACE 27 + /* struct retro_log_callback * -- + * Gets an interface for logging. This is useful for + * logging in a cross-platform way + * as certain platforms cannot use stderr for logging. + * It also allows the frontend to + * show logging information in a more suitable way. + * If this interface is not used, libretro cores should + * log to stderr as desired. + */ +#define RETRO_ENVIRONMENT_GET_PERF_INTERFACE 28 + /* struct retro_perf_callback * -- + * Gets an interface for performance counters. This is useful + * for performance logging in a cross-platform way and for detecting + * architecture-specific features, such as SIMD support. + */ +#define RETRO_ENVIRONMENT_GET_LOCATION_INTERFACE 29 + /* struct retro_location_callback * -- + * Gets access to the location interface. + * The purpose of this interface is to be able to retrieve + * location-based information from the host device, + * such as current latitude / longitude. + */ +#define RETRO_ENVIRONMENT_GET_CONTENT_DIRECTORY 30 /* Old name, kept for compatibility. */ +#define RETRO_ENVIRONMENT_GET_CORE_ASSETS_DIRECTORY 30 + /* const char ** -- + * Returns the "core assets" directory of the frontend. + * This directory can be used to store specific assets that the + * core relies upon, such as art assets, + * input data, etc etc. + * The returned value can be NULL. + * If so, no such directory is defined, + * and it's up to the implementation to find a suitable directory. + */ +#define RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY 31 + /* const char ** -- + * Returns the "save" directory of the frontend, unless there is no + * save directory available. The save directory should be used to + * store SRAM, memory cards, high scores, etc, if the libretro core + * cannot use the regular memory interface (retro_get_memory_data()). + * + * If the frontend cannot designate a save directory, it will return + * NULL to indicate that the core should attempt to operate without a + * save directory set. + * + * NOTE: early libretro cores used the system directory for save + * files. Cores that need to be backwards-compatible can still check + * GET_SYSTEM_DIRECTORY. + */ +#define RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO 32 + /* const struct retro_system_av_info * -- + * Sets a new av_info structure. This can only be called from + * within retro_run(). + * This should *only* be used if the core is completely altering the + * internal resolutions, aspect ratios, timings, sampling rate, etc. + * Calling this can require a full reinitialization of video/audio + * drivers in the frontend, + * + * so it is important to call it very sparingly, and usually only with + * the users explicit consent. + * An eventual driver reinitialize will happen so that video and + * audio callbacks + * happening after this call within the same retro_run() call will + * target the newly initialized driver. + * + * This callback makes it possible to support configurable resolutions + * in games, which can be useful to + * avoid setting the "worst case" in max_width/max_height. + * + * ***HIGHLY RECOMMENDED*** Do not call this callback every time + * resolution changes in an emulator core if it's + * expected to be a temporary change, for the reasons of possible + * driver reinitialization. + * This call is not a free pass for not trying to provide + * correct values in retro_get_system_av_info(). If you need to change + * things like aspect ratio or nominal width/height, + * use RETRO_ENVIRONMENT_SET_GEOMETRY, which is a softer variant + * of SET_SYSTEM_AV_INFO. + * + * If this returns false, the frontend does not acknowledge a + * changed av_info struct. + */ +#define RETRO_ENVIRONMENT_SET_PROC_ADDRESS_CALLBACK 33 + /* const struct retro_get_proc_address_interface * -- + * Allows a libretro core to announce support for the + * get_proc_address() interface. + * This interface allows for a standard way to extend libretro where + * use of environment calls are too indirect, + * e.g. for cases where the frontend wants to call directly into the core. + * + * If a core wants to expose this interface, SET_PROC_ADDRESS_CALLBACK + * **MUST** be called from within retro_set_environment(). + */ +#define RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO 34 + /* const struct retro_subsystem_info * -- + * This environment call introduces the concept of libretro "subsystems". + * A subsystem is a variant of a libretro core which supports + * different kinds of games. + * The purpose of this is to support e.g. emulators which might + * have special needs, e.g. Super Nintendo's Super GameBoy, Sufami Turbo. + * It can also be used to pick among subsystems in an explicit way + * if the libretro implementation is a multi-system emulator itself. + * + * Loading a game via a subsystem is done with retro_load_game_special(), + * and this environment call allows a libretro core to expose which + * subsystems are supported for use with retro_load_game_special(). + * A core passes an array of retro_game_special_info which is terminated + * with a zeroed out retro_game_special_info struct. + * + * If a core wants to use this functionality, SET_SUBSYSTEM_INFO + * **MUST** be called from within retro_set_environment(). + */ +#define RETRO_ENVIRONMENT_SET_CONTROLLER_INFO 35 + /* const struct retro_controller_info * -- + * This environment call lets a libretro core tell the frontend + * which controller subclasses are recognized in calls to + * retro_set_controller_port_device(). + * + * Some emulators such as Super Nintendo support multiple lightgun + * types which must be specifically selected from. It is therefore + * sometimes necessary for a frontend to be able to tell the core + * about a special kind of input device which is not specifcally + * provided by the Libretro API. + * + * In order for a frontend to understand the workings of those devices, + * they must be defined as a specialized subclass of the generic device + * types already defined in the libretro API. + * + * The core must pass an array of const struct retro_controller_info which + * is terminated with a blanked out struct. Each element of the + * retro_controller_info struct corresponds to the ascending port index + * that is passed to retro_set_controller_port_device() when that function + * is called to indicate to the core that the frontend has changed the + * active device subclass. SEE ALSO: retro_set_controller_port_device() + * + * The ascending input port indexes provided by the core in the struct + * are generally presented by frontends as ascending User # or Player #, + * such as Player 1, Player 2, Player 3, etc. Which device subclasses are + * supported can vary per input port. + * + * The first inner element of each entry in the retro_controller_info array + * is a retro_controller_description struct that specifies the names and + * codes of all device subclasses that are available for the corresponding + * User or Player, beginning with the generic Libretro device that the + * subclasses are derived from. The second inner element of each entry is the + * total number of subclasses that are listed in the retro_controller_description. + * + * NOTE: Even if special device types are set in the libretro core, + * libretro should only poll input based on the base input device types. + */ +#define RETRO_ENVIRONMENT_SET_MEMORY_MAPS (36 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* const struct retro_memory_map * -- + * This environment call lets a libretro core tell the frontend + * about the memory maps this core emulates. + * This can be used to implement, for example, cheats in a core-agnostic way. + * + * Should only be used by emulators; it doesn't make much sense for + * anything else. + * It is recommended to expose all relevant pointers through + * retro_get_memory_* as well. + * + * Can be called from retro_init and retro_load_game. + */ +#define RETRO_ENVIRONMENT_SET_GEOMETRY 37 + /* const struct retro_game_geometry * -- + * This environment call is similar to SET_SYSTEM_AV_INFO for changing + * video parameters, but provides a guarantee that drivers will not be + * reinitialized. + * This can only be called from within retro_run(). + * + * The purpose of this call is to allow a core to alter nominal + * width/heights as well as aspect ratios on-the-fly, which can be + * useful for some emulators to change in run-time. + * + * max_width/max_height arguments are ignored and cannot be changed + * with this call as this could potentially require a reinitialization or a + * non-constant time operation. + * If max_width/max_height are to be changed, SET_SYSTEM_AV_INFO is required. + * + * A frontend must guarantee that this environment call completes in + * constant time. + */ +#define RETRO_ENVIRONMENT_GET_USERNAME 38 + /* const char ** + * Returns the specified username of the frontend, if specified by the user. + * This username can be used as a nickname for a core that has online facilities + * or any other mode where personalization of the user is desirable. + * The returned value can be NULL. + * If this environ callback is used by a core that requires a valid username, + * a default username should be specified by the core. + */ +#define RETRO_ENVIRONMENT_GET_LANGUAGE 39 + /* unsigned * -- + * Returns the specified language of the frontend, if specified by the user. + * It can be used by the core for localization purposes. + */ +#define RETRO_ENVIRONMENT_GET_CURRENT_SOFTWARE_FRAMEBUFFER (40 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_framebuffer * -- + * Returns a preallocated framebuffer which the core can use for rendering + * the frame into when not using SET_HW_RENDER. + * The framebuffer returned from this call must not be used + * after the current call to retro_run() returns. + * + * The goal of this call is to allow zero-copy behavior where a core + * can render directly into video memory, avoiding extra bandwidth cost by copying + * memory from core to video memory. + * + * If this call succeeds and the core renders into it, + * the framebuffer pointer and pitch can be passed to retro_video_refresh_t. + * If the buffer from GET_CURRENT_SOFTWARE_FRAMEBUFFER is to be used, + * the core must pass the exact + * same pointer as returned by GET_CURRENT_SOFTWARE_FRAMEBUFFER; + * i.e. passing a pointer which is offset from the + * buffer is undefined. The width, height and pitch parameters + * must also match exactly to the values obtained from GET_CURRENT_SOFTWARE_FRAMEBUFFER. + * + * It is possible for a frontend to return a different pixel format + * than the one used in SET_PIXEL_FORMAT. This can happen if the frontend + * needs to perform conversion. + * + * It is still valid for a core to render to a different buffer + * even if GET_CURRENT_SOFTWARE_FRAMEBUFFER succeeds. + * + * A frontend must make sure that the pointer obtained from this function is + * writeable (and readable). + */ +#define RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE (41 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* const struct retro_hw_render_interface ** -- + * Returns an API specific rendering interface for accessing API specific data. + * Not all HW rendering APIs support or need this. + * The contents of the returned pointer is specific to the rendering API + * being used. See the various headers like libretro_vulkan.h, etc. + * + * GET_HW_RENDER_INTERFACE cannot be called before context_reset has been called. + * Similarly, after context_destroyed callback returns, + * the contents of the HW_RENDER_INTERFACE are invalidated. + */ +#define RETRO_ENVIRONMENT_SET_SUPPORT_ACHIEVEMENTS (42 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* const bool * -- + * If true, the libretro implementation supports achievements + * either via memory descriptors set with RETRO_ENVIRONMENT_SET_MEMORY_MAPS + * or via retro_get_memory_data/retro_get_memory_size. + * + * This must be called before the first call to retro_run. + */ +#define RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE (43 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* const struct retro_hw_render_context_negotiation_interface * -- + * Sets an interface which lets the libretro core negotiate with frontend how a context is created. + * The semantics of this interface depends on which API is used in SET_HW_RENDER earlier. + * This interface will be used when the frontend is trying to create a HW rendering context, + * so it will be used after SET_HW_RENDER, but before the context_reset callback. + */ +#define RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS 44 + /* uint64_t * -- + * Sets quirk flags associated with serialization. The frontend will zero any flags it doesn't + * recognize or support. Should be set in either retro_init or retro_load_game, but not both. + */ +#define RETRO_ENVIRONMENT_SET_HW_SHARED_CONTEXT (44 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* N/A (null) * -- + * The frontend will try to use a 'shared' hardware context (mostly applicable + * to OpenGL) when a hardware context is being set up. + * + * Returns true if the frontend supports shared hardware contexts and false + * if the frontend does not support shared hardware contexts. + * + * This will do nothing on its own until SET_HW_RENDER env callbacks are + * being used. + */ +#define RETRO_ENVIRONMENT_GET_VFS_INTERFACE (45 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_vfs_interface_info * -- + * Gets access to the VFS interface. + * VFS presence needs to be queried prior to load_game or any + * get_system/save/other_directory being called to let front end know + * core supports VFS before it starts handing out paths. + * It is recomended to do so in retro_set_environment + */ +#define RETRO_ENVIRONMENT_GET_LED_INTERFACE (46 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_led_interface * -- + * Gets an interface which is used by a libretro core to set + * state of LEDs. + */ +#define RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE (47 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* int * -- + * Tells the core if the frontend wants audio or video. + * If disabled, the frontend will discard the audio or video, + * so the core may decide to skip generating a frame or generating audio. + * This is mainly used for increasing performance. + * Bit 0 (value 1): Enable Video + * Bit 1 (value 2): Enable Audio + * Bit 2 (value 4): Use Fast Savestates. + * Bit 3 (value 8): Hard Disable Audio + * Other bits are reserved for future use and will default to zero. + * If video is disabled: + * * The frontend wants the core to not generate any video, + * including presenting frames via hardware acceleration. + * * The frontend's video frame callback will do nothing. + * * After running the frame, the video output of the next frame should be + * no different than if video was enabled, and saving and loading state + * should have no issues. + * If audio is disabled: + * * The frontend wants the core to not generate any audio. + * * The frontend's audio callbacks will do nothing. + * * After running the frame, the audio output of the next frame should be + * no different than if audio was enabled, and saving and loading state + * should have no issues. + * Fast Savestates: + * * Guaranteed to be created by the same binary that will load them. + * * Will not be written to or read from the disk. + * * Suggest that the core assumes loading state will succeed. + * * Suggest that the core updates its memory buffers in-place if possible. + * * Suggest that the core skips clearing memory. + * * Suggest that the core skips resetting the system. + * * Suggest that the core may skip validation steps. + * Hard Disable Audio: + * * Used for a secondary core when running ahead. + * * Indicates that the frontend will never need audio from the core. + * * Suggests that the core may stop synthesizing audio, but this should not + * compromise emulation accuracy. + * * Audio output for the next frame does not matter, and the frontend will + * never need an accurate audio state in the future. + * * State will never be saved when using Hard Disable Audio. + */ +#define RETRO_ENVIRONMENT_GET_MIDI_INTERFACE (48 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_midi_interface ** -- + * Returns a MIDI interface that can be used for raw data I/O. + */ + +#define RETRO_ENVIRONMENT_GET_FASTFORWARDING (49 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* bool * -- + * Boolean value that indicates whether or not the frontend is in + * fastforwarding mode. + */ + +#define RETRO_ENVIRONMENT_GET_TARGET_REFRESH_RATE (50 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* float * -- + * Float value that lets us know what target refresh rate + * is curently in use by the frontend. + * + * The core can use the returned value to set an ideal + * refresh rate/framerate. + */ + +#define RETRO_ENVIRONMENT_GET_INPUT_BITMASKS (51 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* bool * -- + * Boolean value that indicates whether or not the frontend supports + * input bitmasks being returned by retro_input_state_t. The advantage + * of this is that retro_input_state_t has to be only called once to + * grab all button states instead of multiple times. + * + * If it returns true, you can pass RETRO_DEVICE_ID_JOYPAD_MASK as 'id' + * to retro_input_state_t (make sure 'device' is set to RETRO_DEVICE_JOYPAD). + * It will return a bitmask of all the digital buttons. + */ + +#define RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION 52 + /* unsigned * -- + * Unsigned value is the API version number of the core options + * interface supported by the frontend. If callback return false, + * API version is assumed to be 0. + * + * In legacy code, core options are set by passing an array of + * retro_variable structs to RETRO_ENVIRONMENT_SET_VARIABLES. + * This may be still be done regardless of the core options + * interface version. + * + * If version is >= 1 however, core options may instead be set by + * passing an array of retro_core_option_definition structs to + * RETRO_ENVIRONMENT_SET_CORE_OPTIONS, or a 2D array of + * retro_core_option_definition structs to RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL. + * This allows the core to additionally set option sublabel information + * and/or provide localisation support. + * + * If version is >= 2, core options may instead be set by passing + * a retro_core_options_v2 struct to RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2, + * or an array of retro_core_options_v2 structs to + * RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL. This allows the core + * to additionally set optional core option category information + * for frontends with core option category support. + */ + +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS 53 + /* const struct retro_core_option_definition ** -- + * Allows an implementation to signal the environment + * which variables it might want to check for later using + * GET_VARIABLE. + * This allows the frontend to present these variables to + * a user dynamically. + * This should only be called if RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION + * returns an API version of >= 1. + * This should be called instead of RETRO_ENVIRONMENT_SET_VARIABLES. + * This should be called the first time as early as + * possible (ideally in retro_set_environment). + * Afterwards it may be called again for the core to communicate + * updated options to the frontend, but the number of core + * options must not change from the number in the initial call. + * + * 'data' points to an array of retro_core_option_definition structs + * terminated by a { NULL, NULL, NULL, {{0}}, NULL } element. + * retro_core_option_definition::key should be namespaced to not collide + * with other implementations' keys. e.g. A core called + * 'foo' should use keys named as 'foo_option'. + * retro_core_option_definition::desc should contain a human readable + * description of the key. + * retro_core_option_definition::info should contain any additional human + * readable information text that a typical user may need to + * understand the functionality of the option. + * retro_core_option_definition::values is an array of retro_core_option_value + * structs terminated by a { NULL, NULL } element. + * > retro_core_option_definition::values[index].value is an expected option + * value. + * > retro_core_option_definition::values[index].label is a human readable + * label used when displaying the value on screen. If NULL, + * the value itself is used. + * retro_core_option_definition::default_value is the default core option + * setting. It must match one of the expected option values in the + * retro_core_option_definition::values array. If it does not, or the + * default value is NULL, the first entry in the + * retro_core_option_definition::values array is treated as the default. + * + * The number of possible option values should be very limited, + * and must be less than RETRO_NUM_CORE_OPTION_VALUES_MAX. + * i.e. it should be feasible to cycle through options + * without a keyboard. + * + * Example entry: + * { + * "foo_option", + * "Speed hack coprocessor X", + * "Provides increased performance at the expense of reduced accuracy", + * { + * { "false", NULL }, + * { "true", NULL }, + * { "unstable", "Turbo (Unstable)" }, + * { NULL, NULL }, + * }, + * "false" + * } + * + * Only strings are operated on. The possible values will + * generally be displayed and stored as-is by the frontend. + */ + +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL 54 + /* const struct retro_core_options_intl * -- + * Allows an implementation to signal the environment + * which variables it might want to check for later using + * GET_VARIABLE. + * This allows the frontend to present these variables to + * a user dynamically. + * This should only be called if RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION + * returns an API version of >= 1. + * This should be called instead of RETRO_ENVIRONMENT_SET_VARIABLES. + * This should be called instead of RETRO_ENVIRONMENT_SET_CORE_OPTIONS. + * This should be called the first time as early as + * possible (ideally in retro_set_environment). + * Afterwards it may be called again for the core to communicate + * updated options to the frontend, but the number of core + * options must not change from the number in the initial call. + * + * This is fundamentally the same as RETRO_ENVIRONMENT_SET_CORE_OPTIONS, + * with the addition of localisation support. The description of the + * RETRO_ENVIRONMENT_SET_CORE_OPTIONS callback should be consulted + * for further details. + * + * 'data' points to a retro_core_options_intl struct. + * + * retro_core_options_intl::us is a pointer to an array of + * retro_core_option_definition structs defining the US English + * core options implementation. It must point to a valid array. + * + * retro_core_options_intl::local is a pointer to an array of + * retro_core_option_definition structs defining core options for + * the current frontend language. It may be NULL (in which case + * retro_core_options_intl::us is used by the frontend). Any items + * missing from this array will be read from retro_core_options_intl::us + * instead. + * + * NOTE: Default core option values are always taken from the + * retro_core_options_intl::us array. Any default values in + * retro_core_options_intl::local array will be ignored. + */ + +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY 55 + /* struct retro_core_option_display * -- + * + * Allows an implementation to signal the environment to show + * or hide a variable when displaying core options. This is + * considered a *suggestion*. The frontend is free to ignore + * this callback, and its implementation not considered mandatory. + * + * 'data' points to a retro_core_option_display struct + * + * retro_core_option_display::key is a variable identifier + * which has already been set by SET_VARIABLES/SET_CORE_OPTIONS. + * + * retro_core_option_display::visible is a boolean, specifying + * whether variable should be displayed + * + * Note that all core option variables will be set visible by + * default when calling SET_VARIABLES/SET_CORE_OPTIONS. + */ + +#define RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER 56 + /* unsigned * -- + * + * Allows an implementation to ask frontend preferred hardware + * context to use. Core should use this information to deal + * with what specific context to request with SET_HW_RENDER. + * + * 'data' points to an unsigned variable + */ + +#define RETRO_ENVIRONMENT_GET_DISK_CONTROL_INTERFACE_VERSION 57 + /* unsigned * -- + * Unsigned value is the API version number of the disk control + * interface supported by the frontend. If callback return false, + * API version is assumed to be 0. + * + * In legacy code, the disk control interface is defined by passing + * a struct of type retro_disk_control_callback to + * RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE. + * This may be still be done regardless of the disk control + * interface version. + * + * If version is >= 1 however, the disk control interface may + * instead be defined by passing a struct of type + * retro_disk_control_ext_callback to + * RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE. + * This allows the core to provide additional information about + * disk images to the frontend and/or enables extra + * disk control functionality by the frontend. + */ + +#define RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE 58 + /* const struct retro_disk_control_ext_callback * -- + * Sets an interface which frontend can use to eject and insert + * disk images, and also obtain information about individual + * disk image files registered by the core. + * This is used for games which consist of multiple images and + * must be manually swapped out by the user (e.g. PSX, floppy disk + * based systems). + */ + +#define RETRO_ENVIRONMENT_GET_MESSAGE_INTERFACE_VERSION 59 + /* unsigned * -- + * Unsigned value is the API version number of the message + * interface supported by the frontend. If callback returns + * false, API version is assumed to be 0. + * + * In legacy code, messages may be displayed in an + * implementation-specific manner by passing a struct + * of type retro_message to RETRO_ENVIRONMENT_SET_MESSAGE. + * This may be still be done regardless of the message + * interface version. + * + * If version is >= 1 however, messages may instead be + * displayed by passing a struct of type retro_message_ext + * to RETRO_ENVIRONMENT_SET_MESSAGE_EXT. This allows the + * core to specify message logging level, priority and + * destination (OSD, logging interface or both). + */ + +#define RETRO_ENVIRONMENT_SET_MESSAGE_EXT 60 + /* const struct retro_message_ext * -- + * Sets a message to be displayed in an implementation-specific + * manner for a certain amount of 'frames'. Additionally allows + * the core to specify message logging level, priority and + * destination (OSD, logging interface or both). + * Should not be used for trivial messages, which should simply be + * logged via RETRO_ENVIRONMENT_GET_LOG_INTERFACE (or as a + * fallback, stderr). + */ + +#define RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS 61 + /* unsigned * -- + * Unsigned value is the number of active input devices + * provided by the frontend. This may change between + * frames, but will remain constant for the duration + * of each frame. + * If callback returns true, a core need not poll any + * input device with an index greater than or equal to + * the number of active devices. + * If callback returns false, the number of active input + * devices is unknown. In this case, all input devices + * should be considered active. + */ + +#define RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK 62 + /* const struct retro_audio_buffer_status_callback * -- + * Lets the core know the occupancy level of the frontend + * audio buffer. Can be used by a core to attempt frame + * skipping in order to avoid buffer under-runs. + * A core may pass NULL to disable buffer status reporting + * in the frontend. + */ + +#define RETRO_ENVIRONMENT_SET_MINIMUM_AUDIO_LATENCY 63 + /* const unsigned * -- + * Sets minimum frontend audio latency in milliseconds. + * Resultant audio latency may be larger than set value, + * or smaller if a hardware limit is encountered. A frontend + * is expected to honour requests up to 512 ms. + * + * - If value is less than current frontend + * audio latency, callback has no effect + * - If value is zero, default frontend audio + * latency is set + * + * May be used by a core to increase audio latency and + * therefore decrease the probability of buffer under-runs + * (crackling) when performing 'intensive' operations. + * A core utilising RETRO_ENVIRONMENT_SET_AUDIO_BUFFER_STATUS_CALLBACK + * to implement audio-buffer-based frame skipping may achieve + * optimal results by setting the audio latency to a 'high' + * (typically 6x or 8x) integer multiple of the expected + * frame time. + * + * WARNING: This can only be called from within retro_run(). + * Calling this can require a full reinitialization of audio + * drivers in the frontend, so it is important to call it very + * sparingly, and usually only with the users explicit consent. + * An eventual driver reinitialize will happen so that audio + * callbacks happening after this call within the same retro_run() + * call will target the newly initialized driver. + */ + +#define RETRO_ENVIRONMENT_SET_FASTFORWARDING_OVERRIDE 64 + /* const struct retro_fastforwarding_override * -- + * Used by a libretro core to override the current + * fastforwarding mode of the frontend. + * If NULL is passed to this function, the frontend + * will return true if fastforwarding override + * functionality is supported (no change in + * fastforwarding state will occur in this case). + */ + +#define RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE 65 + /* const struct retro_system_content_info_override * -- + * Allows an implementation to override 'global' content + * info parameters reported by retro_get_system_info(). + * Overrides also affect subsystem content info parameters + * set via RETRO_ENVIRONMENT_SET_SUBSYSTEM_INFO. + * This function must be called inside retro_set_environment(). + * If callback returns false, content info overrides + * are unsupported by the frontend, and will be ignored. + * If callback returns true, extended game info may be + * retrieved by calling RETRO_ENVIRONMENT_GET_GAME_INFO_EXT + * in retro_load_game() or retro_load_game_special(). + * + * 'data' points to an array of retro_system_content_info_override + * structs terminated by a { NULL, false, false } element. + * If 'data' is NULL, no changes will be made to the frontend; + * a core may therefore pass NULL in order to test whether + * the RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE and + * RETRO_ENVIRONMENT_GET_GAME_INFO_EXT callbacks are supported + * by the frontend. + * + * For struct member descriptions, see the definition of + * struct retro_system_content_info_override. + * + * Example: + * + * - struct retro_system_info: + * { + * "My Core", // library_name + * "v1.0", // library_version + * "m3u|md|cue|iso|chd|sms|gg|sg", // valid_extensions + * true, // need_fullpath + * false // block_extract + * } + * + * - Array of struct retro_system_content_info_override: + * { + * { + * "md|sms|gg", // extensions + * false, // need_fullpath + * true // persistent_data + * }, + * { + * "sg", // extensions + * false, // need_fullpath + * false // persistent_data + * }, + * { NULL, false, false } + * } + * + * Result: + * - Files of type m3u, cue, iso, chd will not be + * loaded by the frontend. Frontend will pass a + * valid path to the core, and core will handle + * loading internally + * - Files of type md, sms, gg will be loaded by + * the frontend. A valid memory buffer will be + * passed to the core. This memory buffer will + * remain valid until retro_deinit() returns + * - Files of type sg will be loaded by the frontend. + * A valid memory buffer will be passed to the core. + * This memory buffer will remain valid until + * retro_load_game() (or retro_load_game_special()) + * returns + * + * NOTE: If an extension is listed multiple times in + * an array of retro_system_content_info_override + * structs, only the first instance will be registered + */ + +#define RETRO_ENVIRONMENT_GET_GAME_INFO_EXT 66 + /* const struct retro_game_info_ext ** -- + * Allows an implementation to fetch extended game + * information, providing additional content path + * and memory buffer status details. + * This function may only be called inside + * retro_load_game() or retro_load_game_special(). + * If callback returns false, extended game information + * is unsupported by the frontend. In this case, only + * regular retro_game_info will be available. + * RETRO_ENVIRONMENT_GET_GAME_INFO_EXT is guaranteed + * to return true if RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE + * returns true. + * + * 'data' points to an array of retro_game_info_ext structs. + * + * For struct member descriptions, see the definition of + * struct retro_game_info_ext. + * + * - If function is called inside retro_load_game(), + * the retro_game_info_ext array is guaranteed to + * have a size of 1 - i.e. the returned pointer may + * be used to access directly the members of the + * first retro_game_info_ext struct, for example: + * + * struct retro_game_info_ext *game_info_ext; + * if (environ_cb(RETRO_ENVIRONMENT_GET_GAME_INFO_EXT, &game_info_ext)) + * printf("Content Directory: %s\n", game_info_ext->dir); + * + * - If the function is called inside retro_load_game_special(), + * the retro_game_info_ext array is guaranteed to have a + * size equal to the num_info argument passed to + * retro_load_game_special() + */ + +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 67 + /* const struct retro_core_options_v2 * -- + * Allows an implementation to signal the environment + * which variables it might want to check for later using + * GET_VARIABLE. + * This allows the frontend to present these variables to + * a user dynamically. + * This should only be called if RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION + * returns an API version of >= 2. + * This should be called instead of RETRO_ENVIRONMENT_SET_VARIABLES. + * This should be called instead of RETRO_ENVIRONMENT_SET_CORE_OPTIONS. + * This should be called the first time as early as + * possible (ideally in retro_set_environment). + * Afterwards it may be called again for the core to communicate + * updated options to the frontend, but the number of core + * options must not change from the number in the initial call. + * If RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION returns an API + * version of >= 2, this callback is guaranteed to succeed + * (i.e. callback return value does not indicate success) + * If callback returns true, frontend has core option category + * support. + * If callback returns false, frontend does not have core option + * category support. + * + * 'data' points to a retro_core_options_v2 struct, containing + * of two pointers: + * - retro_core_options_v2::categories is an array of + * retro_core_option_v2_category structs terminated by a + * { NULL, NULL, NULL } element. If retro_core_options_v2::categories + * is NULL, all core options will have no category and will be shown + * at the top level of the frontend core option interface. If frontend + * does not have core option category support, categories array will + * be ignored. + * - retro_core_options_v2::definitions is an array of + * retro_core_option_v2_definition structs terminated by a + * { NULL, NULL, NULL, NULL, NULL, NULL, {{0}}, NULL } + * element. + * + * >> retro_core_option_v2_category notes: + * + * - retro_core_option_v2_category::key should contain string + * that uniquely identifies the core option category. Valid + * key characters are [a-z, A-Z, 0-9, _, -] + * Namespace collisions with other implementations' category + * keys are permitted. + * - retro_core_option_v2_category::desc should contain a human + * readable description of the category key. + * - retro_core_option_v2_category::info should contain any + * additional human readable information text that a typical + * user may need to understand the nature of the core option + * category. + * + * Example entry: + * { + * "advanced_settings", + * "Advanced", + * "Options affecting low-level emulation performance and accuracy." + * } + * + * >> retro_core_option_v2_definition notes: + * + * - retro_core_option_v2_definition::key should be namespaced to not + * collide with other implementations' keys. e.g. A core called + * 'foo' should use keys named as 'foo_option'. Valid key characters + * are [a-z, A-Z, 0-9, _, -]. + * - retro_core_option_v2_definition::desc should contain a human readable + * description of the key. Will be used when the frontend does not + * have core option category support. Examples: "Aspect Ratio" or + * "Video > Aspect Ratio". + * - retro_core_option_v2_definition::desc_categorized should contain a + * human readable description of the key, which will be used when + * frontend has core option category support. Example: "Aspect Ratio", + * where associated retro_core_option_v2_category::desc is "Video". + * If empty or NULL, the string specified by + * retro_core_option_v2_definition::desc will be used instead. + * retro_core_option_v2_definition::desc_categorized will be ignored + * if retro_core_option_v2_definition::category_key is empty or NULL. + * - retro_core_option_v2_definition::info should contain any additional + * human readable information text that a typical user may need to + * understand the functionality of the option. + * - retro_core_option_v2_definition::info_categorized should contain + * any additional human readable information text that a typical user + * may need to understand the functionality of the option, and will be + * used when frontend has core option category support. This is provided + * to accommodate the case where info text references an option by + * name/desc, and the desc/desc_categorized text for that option differ. + * If empty or NULL, the string specified by + * retro_core_option_v2_definition::info will be used instead. + * retro_core_option_v2_definition::info_categorized will be ignored + * if retro_core_option_v2_definition::category_key is empty or NULL. + * - retro_core_option_v2_definition::category_key should contain a + * category identifier (e.g. "video" or "audio") that will be + * assigned to the core option if frontend has core option category + * support. A categorized option will be shown in a subsection/ + * submenu of the frontend core option interface. If key is empty + * or NULL, or if key does not match one of the + * retro_core_option_v2_category::key values in the associated + * retro_core_option_v2_category array, option will have no category + * and will be shown at the top level of the frontend core option + * interface. + * - retro_core_option_v2_definition::values is an array of + * retro_core_option_value structs terminated by a { NULL, NULL } + * element. + * --> retro_core_option_v2_definition::values[index].value is an + * expected option value. + * --> retro_core_option_v2_definition::values[index].label is a + * human readable label used when displaying the value on screen. + * If NULL, the value itself is used. + * - retro_core_option_v2_definition::default_value is the default + * core option setting. It must match one of the expected option + * values in the retro_core_option_v2_definition::values array. If + * it does not, or the default value is NULL, the first entry in the + * retro_core_option_v2_definition::values array is treated as the + * default. + * + * The number of possible option values should be very limited, + * and must be less than RETRO_NUM_CORE_OPTION_VALUES_MAX. + * i.e. it should be feasible to cycle through options + * without a keyboard. + * + * Example entries: + * + * - Uncategorized: + * + * { + * "foo_option", + * "Speed hack coprocessor X", + * NULL, + * "Provides increased performance at the expense of reduced accuracy.", + * NULL, + * NULL, + * { + * { "false", NULL }, + * { "true", NULL }, + * { "unstable", "Turbo (Unstable)" }, + * { NULL, NULL }, + * }, + * "false" + * } + * + * - Categorized: + * + * { + * "foo_option", + * "Advanced > Speed hack coprocessor X", + * "Speed hack coprocessor X", + * "Setting 'Advanced > Speed hack coprocessor X' to 'true' or 'Turbo' provides increased performance at the expense of reduced accuracy", + * "Setting 'Speed hack coprocessor X' to 'true' or 'Turbo' provides increased performance at the expense of reduced accuracy", + * "advanced_settings", + * { + * { "false", NULL }, + * { "true", NULL }, + * { "unstable", "Turbo (Unstable)" }, + * { NULL, NULL }, + * }, + * "false" + * } + * + * Only strings are operated on. The possible values will + * generally be displayed and stored as-is by the frontend. + */ + +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL 68 + /* const struct retro_core_options_v2_intl * -- + * Allows an implementation to signal the environment + * which variables it might want to check for later using + * GET_VARIABLE. + * This allows the frontend to present these variables to + * a user dynamically. + * This should only be called if RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION + * returns an API version of >= 2. + * This should be called instead of RETRO_ENVIRONMENT_SET_VARIABLES. + * This should be called instead of RETRO_ENVIRONMENT_SET_CORE_OPTIONS. + * This should be called instead of RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL. + * This should be called instead of RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2. + * This should be called the first time as early as + * possible (ideally in retro_set_environment). + * Afterwards it may be called again for the core to communicate + * updated options to the frontend, but the number of core + * options must not change from the number in the initial call. + * If RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION returns an API + * version of >= 2, this callback is guaranteed to succeed + * (i.e. callback return value does not indicate success) + * If callback returns true, frontend has core option category + * support. + * If callback returns false, frontend does not have core option + * category support. + * + * This is fundamentally the same as RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2, + * with the addition of localisation support. The description of the + * RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 callback should be consulted + * for further details. + * + * 'data' points to a retro_core_options_v2_intl struct. + * + * - retro_core_options_v2_intl::us is a pointer to a + * retro_core_options_v2 struct defining the US English + * core options implementation. It must point to a valid struct. + * + * - retro_core_options_v2_intl::local is a pointer to a + * retro_core_options_v2 struct defining core options for + * the current frontend language. It may be NULL (in which case + * retro_core_options_v2_intl::us is used by the frontend). Any items + * missing from this struct will be read from + * retro_core_options_v2_intl::us instead. + * + * NOTE: Default core option values are always taken from the + * retro_core_options_v2_intl::us struct. Any default values in + * the retro_core_options_v2_intl::local struct will be ignored. + */ + +#define RETRO_ENVIRONMENT_SET_CORE_OPTIONS_UPDATE_DISPLAY_CALLBACK 69 + /* const struct retro_core_options_update_display_callback * -- + * Allows a frontend to signal that a core must update + * the visibility of any dynamically hidden core options, + * and enables the frontend to detect visibility changes. + * Used by the frontend to update the menu display status + * of core options without requiring a call of retro_run(). + * Must be called in retro_set_environment(). + */ + +#define RETRO_ENVIRONMENT_SET_VARIABLE 70 + /* const struct retro_variable * -- + * Allows an implementation to notify the frontend + * that a core option value has changed. + * + * retro_variable::key and retro_variable::value + * must match strings that have been set previously + * via one of the following: + * + * - RETRO_ENVIRONMENT_SET_VARIABLES + * - RETRO_ENVIRONMENT_SET_CORE_OPTIONS + * - RETRO_ENVIRONMENT_SET_CORE_OPTIONS_INTL + * - RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2 + * - RETRO_ENVIRONMENT_SET_CORE_OPTIONS_V2_INTL + * + * After changing a core option value via this + * callback, RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE + * will return true. + * + * If data is NULL, no changes will be registered + * and the callback will return true; an + * implementation may therefore pass NULL in order + * to test whether the callback is supported. + */ + +#define RETRO_ENVIRONMENT_GET_THROTTLE_STATE (71 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_throttle_state * -- + * Allows an implementation to get details on the actual rate + * the frontend is attempting to call retro_run(). + */ + +#define RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT (72 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* int * -- + * Tells the core about the context the frontend is asking for savestate. + * (see enum retro_savestate_context) + */ + +/* VFS functionality */ + +/* File paths: + * File paths passed as parameters when using this API shall be well formed UNIX-style, + * using "/" (unquoted forward slash) as directory separator regardless of the platform's native separator. + * Paths shall also include at least one forward slash ("game.bin" is an invalid path, use "./game.bin" instead). + * Other than the directory separator, cores shall not make assumptions about path format: + * "C:/path/game.bin", "http://example.com/game.bin", "#game/game.bin", "./game.bin" (without quotes) are all valid paths. + * Cores may replace the basename or remove path components from the end, and/or add new components; + * however, cores shall not append "./", "../" or multiple consecutive forward slashes ("//") to paths they request to front end. + * The frontend is encouraged to make such paths work as well as it can, but is allowed to give up if the core alters paths too much. + * Frontends are encouraged, but not required, to support native file system paths (modulo replacing the directory separator, if applicable). + * Cores are allowed to try using them, but must remain functional if the front rejects such requests. + * Cores are encouraged to use the libretro-common filestream functions for file I/O, + * as they seamlessly integrate with VFS, deal with directory separator replacement as appropriate + * and provide platform-specific fallbacks in cases where front ends do not support VFS. */ + +/* Opaque file handle + * Introduced in VFS API v1 */ +struct retro_vfs_file_handle; + +/* Opaque directory handle + * Introduced in VFS API v3 */ +struct retro_vfs_dir_handle; + +/* File open flags + * Introduced in VFS API v1 */ +#define RETRO_VFS_FILE_ACCESS_READ (1 << 0) /* Read only mode */ +#define RETRO_VFS_FILE_ACCESS_WRITE (1 << 1) /* Write only mode, discard contents and overwrites existing file unless RETRO_VFS_FILE_ACCESS_UPDATE is also specified */ +#define RETRO_VFS_FILE_ACCESS_READ_WRITE (RETRO_VFS_FILE_ACCESS_READ | RETRO_VFS_FILE_ACCESS_WRITE) /* Read-write mode, discard contents and overwrites existing file unless RETRO_VFS_FILE_ACCESS_UPDATE is also specified*/ +#define RETRO_VFS_FILE_ACCESS_UPDATE_EXISTING (1 << 2) /* Prevents discarding content of existing files opened for writing */ + +/* These are only hints. The frontend may choose to ignore them. Other than RAM/CPU/etc use, + and how they react to unlikely external interference (for example someone else writing to that file, + or the file's server going down), behavior will not change. */ +#define RETRO_VFS_FILE_ACCESS_HINT_NONE (0) +/* Indicate that the file will be accessed many times. The frontend should aggressively cache everything. */ +#define RETRO_VFS_FILE_ACCESS_HINT_FREQUENT_ACCESS (1 << 0) + +/* Seek positions */ +#define RETRO_VFS_SEEK_POSITION_START 0 +#define RETRO_VFS_SEEK_POSITION_CURRENT 1 +#define RETRO_VFS_SEEK_POSITION_END 2 + +/* stat() result flags + * Introduced in VFS API v3 */ +#define RETRO_VFS_STAT_IS_VALID (1 << 0) +#define RETRO_VFS_STAT_IS_DIRECTORY (1 << 1) +#define RETRO_VFS_STAT_IS_CHARACTER_SPECIAL (1 << 2) + +/* Get path from opaque handle. Returns the exact same path passed to file_open when getting the handle + * Introduced in VFS API v1 */ +typedef const char *(RETRO_CALLCONV *retro_vfs_get_path_t)(struct retro_vfs_file_handle *stream); + +/* Open a file for reading or writing. If path points to a directory, this will + * fail. Returns the opaque file handle, or NULL for error. + * Introduced in VFS API v1 */ +typedef struct retro_vfs_file_handle *(RETRO_CALLCONV *retro_vfs_open_t)(const char *path, unsigned mode, unsigned hints); + +/* Close the file and release its resources. Must be called if open_file returns non-NULL. Returns 0 on success, -1 on failure. + * Whether the call succeeds ot not, the handle passed as parameter becomes invalid and should no longer be used. + * Introduced in VFS API v1 */ +typedef int (RETRO_CALLCONV *retro_vfs_close_t)(struct retro_vfs_file_handle *stream); + +/* Return the size of the file in bytes, or -1 for error. + * Introduced in VFS API v1 */ +typedef int64_t (RETRO_CALLCONV *retro_vfs_size_t)(struct retro_vfs_file_handle *stream); + +/* Truncate file to specified size. Returns 0 on success or -1 on error + * Introduced in VFS API v2 */ +typedef int64_t (RETRO_CALLCONV *retro_vfs_truncate_t)(struct retro_vfs_file_handle *stream, int64_t length); + +/* Get the current read / write position for the file. Returns -1 for error. + * Introduced in VFS API v1 */ +typedef int64_t (RETRO_CALLCONV *retro_vfs_tell_t)(struct retro_vfs_file_handle *stream); + +/* Set the current read/write position for the file. Returns the new position, -1 for error. + * Introduced in VFS API v1 */ +typedef int64_t (RETRO_CALLCONV *retro_vfs_seek_t)(struct retro_vfs_file_handle *stream, int64_t offset, int seek_position); + +/* Read data from a file. Returns the number of bytes read, or -1 for error. + * Introduced in VFS API v1 */ +typedef int64_t (RETRO_CALLCONV *retro_vfs_read_t)(struct retro_vfs_file_handle *stream, void *s, uint64_t len); + +/* Write data to a file. Returns the number of bytes written, or -1 for error. + * Introduced in VFS API v1 */ +typedef int64_t (RETRO_CALLCONV *retro_vfs_write_t)(struct retro_vfs_file_handle *stream, const void *s, uint64_t len); + +/* Flush pending writes to file, if using buffered IO. Returns 0 on sucess, or -1 on failure. + * Introduced in VFS API v1 */ +typedef int (RETRO_CALLCONV *retro_vfs_flush_t)(struct retro_vfs_file_handle *stream); + +/* Delete the specified file. Returns 0 on success, -1 on failure + * Introduced in VFS API v1 */ +typedef int (RETRO_CALLCONV *retro_vfs_remove_t)(const char *path); + +/* Rename the specified file. Returns 0 on success, -1 on failure + * Introduced in VFS API v1 */ +typedef int (RETRO_CALLCONV *retro_vfs_rename_t)(const char *old_path, const char *new_path); + +/* Stat the specified file. Retruns a bitmask of RETRO_VFS_STAT_* flags, none are set if path was not valid. + * Additionally stores file size in given variable, unless NULL is given. + * Introduced in VFS API v3 */ +typedef int (RETRO_CALLCONV *retro_vfs_stat_t)(const char *path, int32_t *size); + +/* Create the specified directory. Returns 0 on success, -1 on unknown failure, -2 if already exists. + * Introduced in VFS API v3 */ +typedef int (RETRO_CALLCONV *retro_vfs_mkdir_t)(const char *dir); + +/* Open the specified directory for listing. Returns the opaque dir handle, or NULL for error. + * Support for the include_hidden argument may vary depending on the platform. + * Introduced in VFS API v3 */ +typedef struct retro_vfs_dir_handle *(RETRO_CALLCONV *retro_vfs_opendir_t)(const char *dir, bool include_hidden); + +/* Read the directory entry at the current position, and move the read pointer to the next position. + * Returns true on success, false if already on the last entry. + * Introduced in VFS API v3 */ +typedef bool (RETRO_CALLCONV *retro_vfs_readdir_t)(struct retro_vfs_dir_handle *dirstream); + +/* Get the name of the last entry read. Returns a string on success, or NULL for error. + * The returned string pointer is valid until the next call to readdir or closedir. + * Introduced in VFS API v3 */ +typedef const char *(RETRO_CALLCONV *retro_vfs_dirent_get_name_t)(struct retro_vfs_dir_handle *dirstream); + +/* Check if the last entry read was a directory. Returns true if it was, false otherwise (or on error). + * Introduced in VFS API v3 */ +typedef bool (RETRO_CALLCONV *retro_vfs_dirent_is_dir_t)(struct retro_vfs_dir_handle *dirstream); + +/* Close the directory and release its resources. Must be called if opendir returns non-NULL. Returns 0 on success, -1 on failure. + * Whether the call succeeds ot not, the handle passed as parameter becomes invalid and should no longer be used. + * Introduced in VFS API v3 */ +typedef int (RETRO_CALLCONV *retro_vfs_closedir_t)(struct retro_vfs_dir_handle *dirstream); + +struct retro_vfs_interface +{ + /* VFS API v1 */ + retro_vfs_get_path_t get_path; + retro_vfs_open_t open; + retro_vfs_close_t close; + retro_vfs_size_t size; + retro_vfs_tell_t tell; + retro_vfs_seek_t seek; + retro_vfs_read_t read; + retro_vfs_write_t write; + retro_vfs_flush_t flush; + retro_vfs_remove_t remove; + retro_vfs_rename_t rename; + /* VFS API v2 */ + retro_vfs_truncate_t truncate; + /* VFS API v3 */ + retro_vfs_stat_t stat; + retro_vfs_mkdir_t mkdir; + retro_vfs_opendir_t opendir; + retro_vfs_readdir_t readdir; + retro_vfs_dirent_get_name_t dirent_get_name; + retro_vfs_dirent_is_dir_t dirent_is_dir; + retro_vfs_closedir_t closedir; +}; + +struct retro_vfs_interface_info +{ + /* Set by core: should this be higher than the version the front end supports, + * front end will return false in the RETRO_ENVIRONMENT_GET_VFS_INTERFACE call + * Introduced in VFS API v1 */ + uint32_t required_interface_version; + + /* Frontend writes interface pointer here. The frontend also sets the actual + * version, must be at least required_interface_version. + * Introduced in VFS API v1 */ + struct retro_vfs_interface *iface; +}; + +enum retro_hw_render_interface_type +{ + RETRO_HW_RENDER_INTERFACE_VULKAN = 0, + RETRO_HW_RENDER_INTERFACE_D3D9 = 1, + RETRO_HW_RENDER_INTERFACE_D3D10 = 2, + RETRO_HW_RENDER_INTERFACE_D3D11 = 3, + RETRO_HW_RENDER_INTERFACE_D3D12 = 4, + RETRO_HW_RENDER_INTERFACE_GSKIT_PS2 = 5, + RETRO_HW_RENDER_INTERFACE_DUMMY = INT_MAX +}; + +/* Base struct. All retro_hw_render_interface_* types + * contain at least these fields. */ +struct retro_hw_render_interface +{ + enum retro_hw_render_interface_type interface_type; + unsigned interface_version; +}; + +typedef void (RETRO_CALLCONV *retro_set_led_state_t)(int led, int state); +struct retro_led_interface +{ + retro_set_led_state_t set_led_state; +}; + +/* Retrieves the current state of the MIDI input. + * Returns true if it's enabled, false otherwise. */ +typedef bool (RETRO_CALLCONV *retro_midi_input_enabled_t)(void); + +/* Retrieves the current state of the MIDI output. + * Returns true if it's enabled, false otherwise */ +typedef bool (RETRO_CALLCONV *retro_midi_output_enabled_t)(void); + +/* Reads next byte from the input stream. + * Returns true if byte is read, false otherwise. */ +typedef bool (RETRO_CALLCONV *retro_midi_read_t)(uint8_t *byte); + +/* Writes byte to the output stream. + * 'delta_time' is in microseconds and represent time elapsed since previous write. + * Returns true if byte is written, false otherwise. */ +typedef bool (RETRO_CALLCONV *retro_midi_write_t)(uint8_t byte, uint32_t delta_time); + +/* Flushes previously written data. + * Returns true if successful, false otherwise. */ +typedef bool (RETRO_CALLCONV *retro_midi_flush_t)(void); + +struct retro_midi_interface +{ + retro_midi_input_enabled_t input_enabled; + retro_midi_output_enabled_t output_enabled; + retro_midi_read_t read; + retro_midi_write_t write; + retro_midi_flush_t flush; +}; + +enum retro_hw_render_context_negotiation_interface_type +{ + RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_VULKAN = 0, + RETRO_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE_DUMMY = INT_MAX +}; + +/* Base struct. All retro_hw_render_context_negotiation_interface_* types + * contain at least these fields. */ +struct retro_hw_render_context_negotiation_interface +{ + enum retro_hw_render_context_negotiation_interface_type interface_type; + unsigned interface_version; +}; + +/* Serialized state is incomplete in some way. Set if serialization is + * usable in typical end-user cases but should not be relied upon to + * implement frame-sensitive frontend features such as netplay or + * rerecording. */ +#define RETRO_SERIALIZATION_QUIRK_INCOMPLETE (1 << 0) +/* The core must spend some time initializing before serialization is + * supported. retro_serialize() will initially fail; retro_unserialize() + * and retro_serialize_size() may or may not work correctly either. */ +#define RETRO_SERIALIZATION_QUIRK_MUST_INITIALIZE (1 << 1) +/* Serialization size may change within a session. */ +#define RETRO_SERIALIZATION_QUIRK_CORE_VARIABLE_SIZE (1 << 2) +/* Set by the frontend to acknowledge that it supports variable-sized + * states. */ +#define RETRO_SERIALIZATION_QUIRK_FRONT_VARIABLE_SIZE (1 << 3) +/* Serialized state can only be loaded during the same session. */ +#define RETRO_SERIALIZATION_QUIRK_SINGLE_SESSION (1 << 4) +/* Serialized state cannot be loaded on an architecture with a different + * endianness from the one it was saved on. */ +#define RETRO_SERIALIZATION_QUIRK_ENDIAN_DEPENDENT (1 << 5) +/* Serialized state cannot be loaded on a different platform from the one it + * was saved on for reasons other than endianness, such as word size + * dependence */ +#define RETRO_SERIALIZATION_QUIRK_PLATFORM_DEPENDENT (1 << 6) + +#define RETRO_MEMDESC_CONST (1 << 0) /* The frontend will never change this memory area once retro_load_game has returned. */ +#define RETRO_MEMDESC_BIGENDIAN (1 << 1) /* The memory area contains big endian data. Default is little endian. */ +#define RETRO_MEMDESC_SYSTEM_RAM (1 << 2) /* The memory area is system RAM. This is main RAM of the gaming system. */ +#define RETRO_MEMDESC_SAVE_RAM (1 << 3) /* The memory area is save RAM. This RAM is usually found on a game cartridge, backed up by a battery. */ +#define RETRO_MEMDESC_VIDEO_RAM (1 << 4) /* The memory area is video RAM (VRAM) */ +#define RETRO_MEMDESC_ALIGN_2 (1 << 16) /* All memory access in this area is aligned to their own size, or 2, whichever is smaller. */ +#define RETRO_MEMDESC_ALIGN_4 (2 << 16) +#define RETRO_MEMDESC_ALIGN_8 (3 << 16) +#define RETRO_MEMDESC_MINSIZE_2 (1 << 24) /* All memory in this region is accessed at least 2 bytes at the time. */ +#define RETRO_MEMDESC_MINSIZE_4 (2 << 24) +#define RETRO_MEMDESC_MINSIZE_8 (3 << 24) +struct retro_memory_descriptor +{ + uint64_t flags; + + /* Pointer to the start of the relevant ROM or RAM chip. + * It's strongly recommended to use 'offset' if possible, rather than + * doing math on the pointer. + * + * If the same byte is mapped my multiple descriptors, their descriptors + * must have the same pointer. + * If 'start' does not point to the first byte in the pointer, put the + * difference in 'offset' instead. + * + * May be NULL if there's nothing usable here (e.g. hardware registers and + * open bus). No flags should be set if the pointer is NULL. + * It's recommended to minimize the number of descriptors if possible, + * but not mandatory. */ + void *ptr; + size_t offset; + + /* This is the location in the emulated address space + * where the mapping starts. */ + size_t start; + + /* Which bits must be same as in 'start' for this mapping to apply. + * The first memory descriptor to claim a certain byte is the one + * that applies. + * A bit which is set in 'start' must also be set in this. + * Can be zero, in which case each byte is assumed mapped exactly once. + * In this case, 'len' must be a power of two. */ + size_t select; + + /* If this is nonzero, the set bits are assumed not connected to the + * memory chip's address pins. */ + size_t disconnect; + + /* This one tells the size of the current memory area. + * If, after start+disconnect are applied, the address is higher than + * this, the highest bit of the address is cleared. + * + * If the address is still too high, the next highest bit is cleared. + * Can be zero, in which case it's assumed to be infinite (as limited + * by 'select' and 'disconnect'). */ + size_t len; + + /* To go from emulated address to physical address, the following + * order applies: + * Subtract 'start', pick off 'disconnect', apply 'len', add 'offset'. */ + + /* The address space name must consist of only a-zA-Z0-9_-, + * should be as short as feasible (maximum length is 8 plus the NUL), + * and may not be any other address space plus one or more 0-9A-F + * at the end. + * However, multiple memory descriptors for the same address space is + * allowed, and the address space name can be empty. NULL is treated + * as empty. + * + * Address space names are case sensitive, but avoid lowercase if possible. + * The same pointer may exist in multiple address spaces. + * + * Examples: + * blank+blank - valid (multiple things may be mapped in the same namespace) + * 'Sp'+'Sp' - valid (multiple things may be mapped in the same namespace) + * 'A'+'B' - valid (neither is a prefix of each other) + * 'S'+blank - valid ('S' is not in 0-9A-F) + * 'a'+blank - valid ('a' is not in 0-9A-F) + * 'a'+'A' - valid (neither is a prefix of each other) + * 'AR'+blank - valid ('R' is not in 0-9A-F) + * 'ARB'+blank - valid (the B can't be part of the address either, because + * there is no namespace 'AR') + * blank+'B' - not valid, because it's ambigous which address space B1234 + * would refer to. + * The length can't be used for that purpose; the frontend may want + * to append arbitrary data to an address, without a separator. */ + const char *addrspace; + + /* TODO: When finalizing this one, add a description field, which should be + * "WRAM" or something roughly equally long. */ + + /* TODO: When finalizing this one, replace 'select' with 'limit', which tells + * which bits can vary and still refer to the same address (limit = ~select). + * TODO: limit? range? vary? something else? */ + + /* TODO: When finalizing this one, if 'len' is above what 'select' (or + * 'limit') allows, it's bankswitched. Bankswitched data must have both 'len' + * and 'select' != 0, and the mappings don't tell how the system switches the + * banks. */ + + /* TODO: When finalizing this one, fix the 'len' bit removal order. + * For len=0x1800, pointer 0x1C00 should go to 0x1400, not 0x0C00. + * Algorithm: Take bits highest to lowest, but if it goes above len, clear + * the most recent addition and continue on the next bit. + * TODO: Can the above be optimized? Is "remove the lowest bit set in both + * pointer and 'len'" equivalent? */ + + /* TODO: Some emulators (MAME?) emulate big endian systems by only accessing + * the emulated memory in 32-bit chunks, native endian. But that's nothing + * compared to Darek Mihocka + * (section Emulation 103 - Nearly Free Byte Reversal) - he flips the ENTIRE + * RAM backwards! I'll want to represent both of those, via some flags. + * + * I suspect MAME either didn't think of that idea, or don't want the #ifdef. + * Not sure which, nor do I really care. */ + + /* TODO: Some of those flags are unused and/or don't really make sense. Clean + * them up. */ +}; + +/* The frontend may use the largest value of 'start'+'select' in a + * certain namespace to infer the size of the address space. + * + * If the address space is larger than that, a mapping with .ptr=NULL + * should be at the end of the array, with .select set to all ones for + * as long as the address space is big. + * + * Sample descriptors (minus .ptr, and RETRO_MEMFLAG_ on the flags): + * SNES WRAM: + * .start=0x7E0000, .len=0x20000 + * (Note that this must be mapped before the ROM in most cases; some of the + * ROM mappers + * try to claim $7E0000, or at least $7E8000.) + * SNES SPC700 RAM: + * .addrspace="S", .len=0x10000 + * SNES WRAM mirrors: + * .flags=MIRROR, .start=0x000000, .select=0xC0E000, .len=0x2000 + * .flags=MIRROR, .start=0x800000, .select=0xC0E000, .len=0x2000 + * SNES WRAM mirrors, alternate equivalent descriptor: + * .flags=MIRROR, .select=0x40E000, .disconnect=~0x1FFF + * (Various similar constructions can be created by combining parts of + * the above two.) + * SNES LoROM (512KB, mirrored a couple of times): + * .flags=CONST, .start=0x008000, .select=0x408000, .disconnect=0x8000, .len=512*1024 + * .flags=CONST, .start=0x400000, .select=0x400000, .disconnect=0x8000, .len=512*1024 + * SNES HiROM (4MB): + * .flags=CONST, .start=0x400000, .select=0x400000, .len=4*1024*1024 + * .flags=CONST, .offset=0x8000, .start=0x008000, .select=0x408000, .len=4*1024*1024 + * SNES ExHiROM (8MB): + * .flags=CONST, .offset=0, .start=0xC00000, .select=0xC00000, .len=4*1024*1024 + * .flags=CONST, .offset=4*1024*1024, .start=0x400000, .select=0xC00000, .len=4*1024*1024 + * .flags=CONST, .offset=0x8000, .start=0x808000, .select=0xC08000, .len=4*1024*1024 + * .flags=CONST, .offset=4*1024*1024+0x8000, .start=0x008000, .select=0xC08000, .len=4*1024*1024 + * Clarify the size of the address space: + * .ptr=NULL, .select=0xFFFFFF + * .len can be implied by .select in many of them, but was included for clarity. + */ + +struct retro_memory_map +{ + const struct retro_memory_descriptor *descriptors; + unsigned num_descriptors; +}; + +struct retro_controller_description +{ + /* Human-readable description of the controller. Even if using a generic + * input device type, this can be set to the particular device type the + * core uses. */ + const char *desc; + + /* Device type passed to retro_set_controller_port_device(). If the device + * type is a sub-class of a generic input device type, use the + * RETRO_DEVICE_SUBCLASS macro to create an ID. + * + * E.g. RETRO_DEVICE_SUBCLASS(RETRO_DEVICE_JOYPAD, 1). */ + unsigned id; +}; + +struct retro_controller_info +{ + const struct retro_controller_description *types; + unsigned num_types; +}; + +struct retro_subsystem_memory_info +{ + /* The extension associated with a memory type, e.g. "psram". */ + const char *extension; + + /* The memory type for retro_get_memory(). This should be at + * least 0x100 to avoid conflict with standardized + * libretro memory types. */ + unsigned type; +}; + +struct retro_subsystem_rom_info +{ + /* Describes what the content is (SGB BIOS, GB ROM, etc). */ + const char *desc; + + /* Same definition as retro_get_system_info(). */ + const char *valid_extensions; + + /* Same definition as retro_get_system_info(). */ + bool need_fullpath; + + /* Same definition as retro_get_system_info(). */ + bool block_extract; + + /* This is set if the content is required to load a game. + * If this is set to false, a zeroed-out retro_game_info can be passed. */ + bool required; + + /* Content can have multiple associated persistent + * memory types (retro_get_memory()). */ + const struct retro_subsystem_memory_info *memory; + unsigned num_memory; +}; + +struct retro_subsystem_info +{ + /* Human-readable string of the subsystem type, e.g. "Super GameBoy" */ + const char *desc; + + /* A computer friendly short string identifier for the subsystem type. + * This name must be [a-z]. + * E.g. if desc is "Super GameBoy", this can be "sgb". + * This identifier can be used for command-line interfaces, etc. + */ + const char *ident; + + /* Infos for each content file. The first entry is assumed to be the + * "most significant" content for frontend purposes. + * E.g. with Super GameBoy, the first content should be the GameBoy ROM, + * as it is the most "significant" content to a user. + * If a frontend creates new file paths based on the content used + * (e.g. savestates), it should use the path for the first ROM to do so. */ + const struct retro_subsystem_rom_info *roms; + + /* Number of content files associated with a subsystem. */ + unsigned num_roms; + + /* The type passed to retro_load_game_special(). */ + unsigned id; +}; + +typedef void (RETRO_CALLCONV *retro_proc_address_t)(void); + +/* libretro API extension functions: + * (None here so far). + * + * Get a symbol from a libretro core. + * Cores should only return symbols which are actual + * extensions to the libretro API. + * + * Frontends should not use this to obtain symbols to standard + * libretro entry points (static linking or dlsym). + * + * The symbol name must be equal to the function name, + * e.g. if void retro_foo(void); exists, the symbol must be called "retro_foo". + * The returned function pointer must be cast to the corresponding type. + */ +typedef retro_proc_address_t (RETRO_CALLCONV *retro_get_proc_address_t)(const char *sym); + +struct retro_get_proc_address_interface +{ + retro_get_proc_address_t get_proc_address; +}; + +enum retro_log_level +{ + RETRO_LOG_DEBUG = 0, + RETRO_LOG_INFO, + RETRO_LOG_WARN, + RETRO_LOG_ERROR, + + RETRO_LOG_DUMMY = INT_MAX +}; + +/* Logging function. Takes log level argument as well. */ +typedef void (RETRO_CALLCONV *retro_log_printf_t)(enum retro_log_level level, + const char *fmt, ...); + +struct retro_log_callback +{ + retro_log_printf_t log; +}; + +/* Performance related functions */ + +/* ID values for SIMD CPU features */ +#define RETRO_SIMD_SSE (1 << 0) +#define RETRO_SIMD_SSE2 (1 << 1) +#define RETRO_SIMD_VMX (1 << 2) +#define RETRO_SIMD_VMX128 (1 << 3) +#define RETRO_SIMD_AVX (1 << 4) +#define RETRO_SIMD_NEON (1 << 5) +#define RETRO_SIMD_SSE3 (1 << 6) +#define RETRO_SIMD_SSSE3 (1 << 7) +#define RETRO_SIMD_MMX (1 << 8) +#define RETRO_SIMD_MMXEXT (1 << 9) +#define RETRO_SIMD_SSE4 (1 << 10) +#define RETRO_SIMD_SSE42 (1 << 11) +#define RETRO_SIMD_AVX2 (1 << 12) +#define RETRO_SIMD_VFPU (1 << 13) +#define RETRO_SIMD_PS (1 << 14) +#define RETRO_SIMD_AES (1 << 15) +#define RETRO_SIMD_VFPV3 (1 << 16) +#define RETRO_SIMD_VFPV4 (1 << 17) +#define RETRO_SIMD_POPCNT (1 << 18) +#define RETRO_SIMD_MOVBE (1 << 19) +#define RETRO_SIMD_CMOV (1 << 20) +#define RETRO_SIMD_ASIMD (1 << 21) + +typedef uint64_t retro_perf_tick_t; +typedef int64_t retro_time_t; + +struct retro_perf_counter +{ + const char *ident; + retro_perf_tick_t start; + retro_perf_tick_t total; + retro_perf_tick_t call_cnt; + + bool registered; +}; + +/* Returns current time in microseconds. + * Tries to use the most accurate timer available. + */ +typedef retro_time_t (RETRO_CALLCONV *retro_perf_get_time_usec_t)(void); + +/* A simple counter. Usually nanoseconds, but can also be CPU cycles. + * Can be used directly if desired (when creating a more sophisticated + * performance counter system). + * */ +typedef retro_perf_tick_t (RETRO_CALLCONV *retro_perf_get_counter_t)(void); + +/* Returns a bit-mask of detected CPU features (RETRO_SIMD_*). */ +typedef uint64_t (RETRO_CALLCONV *retro_get_cpu_features_t)(void); + +/* Asks frontend to log and/or display the state of performance counters. + * Performance counters can always be poked into manually as well. + */ +typedef void (RETRO_CALLCONV *retro_perf_log_t)(void); + +/* Register a performance counter. + * ident field must be set with a discrete value and other values in + * retro_perf_counter must be 0. + * Registering can be called multiple times. To avoid calling to + * frontend redundantly, you can check registered field first. */ +typedef void (RETRO_CALLCONV *retro_perf_register_t)(struct retro_perf_counter *counter); + +/* Starts a registered counter. */ +typedef void (RETRO_CALLCONV *retro_perf_start_t)(struct retro_perf_counter *counter); + +/* Stops a registered counter. */ +typedef void (RETRO_CALLCONV *retro_perf_stop_t)(struct retro_perf_counter *counter); + +/* For convenience it can be useful to wrap register, start and stop in macros. + * E.g.: + * #ifdef LOG_PERFORMANCE + * #define RETRO_PERFORMANCE_INIT(perf_cb, name) static struct retro_perf_counter name = {#name}; if (!name.registered) perf_cb.perf_register(&(name)) + * #define RETRO_PERFORMANCE_START(perf_cb, name) perf_cb.perf_start(&(name)) + * #define RETRO_PERFORMANCE_STOP(perf_cb, name) perf_cb.perf_stop(&(name)) + * #else + * ... Blank macros ... + * #endif + * + * These can then be used mid-functions around code snippets. + * + * extern struct retro_perf_callback perf_cb; * Somewhere in the core. + * + * void do_some_heavy_work(void) + * { + * RETRO_PERFORMANCE_INIT(cb, work_1; + * RETRO_PERFORMANCE_START(cb, work_1); + * heavy_work_1(); + * RETRO_PERFORMANCE_STOP(cb, work_1); + * + * RETRO_PERFORMANCE_INIT(cb, work_2); + * RETRO_PERFORMANCE_START(cb, work_2); + * heavy_work_2(); + * RETRO_PERFORMANCE_STOP(cb, work_2); + * } + * + * void retro_deinit(void) + * { + * perf_cb.perf_log(); * Log all perf counters here for example. + * } + */ + +struct retro_perf_callback +{ + retro_perf_get_time_usec_t get_time_usec; + retro_get_cpu_features_t get_cpu_features; + + retro_perf_get_counter_t get_perf_counter; + retro_perf_register_t perf_register; + retro_perf_start_t perf_start; + retro_perf_stop_t perf_stop; + retro_perf_log_t perf_log; +}; + +/* FIXME: Document the sensor API and work out behavior. + * It will be marked as experimental until then. + */ +enum retro_sensor_action +{ + RETRO_SENSOR_ACCELEROMETER_ENABLE = 0, + RETRO_SENSOR_ACCELEROMETER_DISABLE, + RETRO_SENSOR_GYROSCOPE_ENABLE, + RETRO_SENSOR_GYROSCOPE_DISABLE, + RETRO_SENSOR_ILLUMINANCE_ENABLE, + RETRO_SENSOR_ILLUMINANCE_DISABLE, + + RETRO_SENSOR_DUMMY = INT_MAX +}; + +/* Id values for SENSOR types. */ +#define RETRO_SENSOR_ACCELEROMETER_X 0 +#define RETRO_SENSOR_ACCELEROMETER_Y 1 +#define RETRO_SENSOR_ACCELEROMETER_Z 2 +#define RETRO_SENSOR_GYROSCOPE_X 3 +#define RETRO_SENSOR_GYROSCOPE_Y 4 +#define RETRO_SENSOR_GYROSCOPE_Z 5 +#define RETRO_SENSOR_ILLUMINANCE 6 + +typedef bool (RETRO_CALLCONV *retro_set_sensor_state_t)(unsigned port, + enum retro_sensor_action action, unsigned rate); + +typedef float (RETRO_CALLCONV *retro_sensor_get_input_t)(unsigned port, unsigned id); + +struct retro_sensor_interface +{ + retro_set_sensor_state_t set_sensor_state; + retro_sensor_get_input_t get_sensor_input; +}; + +enum retro_camera_buffer +{ + RETRO_CAMERA_BUFFER_OPENGL_TEXTURE = 0, + RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER, + + RETRO_CAMERA_BUFFER_DUMMY = INT_MAX +}; + +/* Starts the camera driver. Can only be called in retro_run(). */ +typedef bool (RETRO_CALLCONV *retro_camera_start_t)(void); + +/* Stops the camera driver. Can only be called in retro_run(). */ +typedef void (RETRO_CALLCONV *retro_camera_stop_t)(void); + +/* Callback which signals when the camera driver is initialized + * and/or deinitialized. + * retro_camera_start_t can be called in initialized callback. + */ +typedef void (RETRO_CALLCONV *retro_camera_lifetime_status_t)(void); + +/* A callback for raw framebuffer data. buffer points to an XRGB8888 buffer. + * Width, height and pitch are similar to retro_video_refresh_t. + * First pixel is top-left origin. + */ +typedef void (RETRO_CALLCONV *retro_camera_frame_raw_framebuffer_t)(const uint32_t *buffer, + unsigned width, unsigned height, size_t pitch); + +/* A callback for when OpenGL textures are used. + * + * texture_id is a texture owned by camera driver. + * Its state or content should be considered immutable, except for things like + * texture filtering and clamping. + * + * texture_target is the texture target for the GL texture. + * These can include e.g. GL_TEXTURE_2D, GL_TEXTURE_RECTANGLE, and possibly + * more depending on extensions. + * + * affine points to a packed 3x3 column-major matrix used to apply an affine + * transform to texture coordinates. (affine_matrix * vec3(coord_x, coord_y, 1.0)) + * After transform, normalized texture coord (0, 0) should be bottom-left + * and (1, 1) should be top-right (or (width, height) for RECTANGLE). + * + * GL-specific typedefs are avoided here to avoid relying on gl.h in + * the API definition. + */ +typedef void (RETRO_CALLCONV *retro_camera_frame_opengl_texture_t)(unsigned texture_id, + unsigned texture_target, const float *affine); + +struct retro_camera_callback +{ + /* Set by libretro core. + * Example bitmask: caps = (1 << RETRO_CAMERA_BUFFER_OPENGL_TEXTURE) | (1 << RETRO_CAMERA_BUFFER_RAW_FRAMEBUFFER). + */ + uint64_t caps; + + /* Desired resolution for camera. Is only used as a hint. */ + unsigned width; + unsigned height; + + /* Set by frontend. */ + retro_camera_start_t start; + retro_camera_stop_t stop; + + /* Set by libretro core if raw framebuffer callbacks will be used. */ + retro_camera_frame_raw_framebuffer_t frame_raw_framebuffer; + + /* Set by libretro core if OpenGL texture callbacks will be used. */ + retro_camera_frame_opengl_texture_t frame_opengl_texture; + + /* Set by libretro core. Called after camera driver is initialized and + * ready to be started. + * Can be NULL, in which this callback is not called. + */ + retro_camera_lifetime_status_t initialized; + + /* Set by libretro core. Called right before camera driver is + * deinitialized. + * Can be NULL, in which this callback is not called. + */ + retro_camera_lifetime_status_t deinitialized; +}; + +/* Sets the interval of time and/or distance at which to update/poll + * location-based data. + * + * To ensure compatibility with all location-based implementations, + * values for both interval_ms and interval_distance should be provided. + * + * interval_ms is the interval expressed in milliseconds. + * interval_distance is the distance interval expressed in meters. + */ +typedef void (RETRO_CALLCONV *retro_location_set_interval_t)(unsigned interval_ms, + unsigned interval_distance); + +/* Start location services. The device will start listening for changes to the + * current location at regular intervals (which are defined with + * retro_location_set_interval_t). */ +typedef bool (RETRO_CALLCONV *retro_location_start_t)(void); + +/* Stop location services. The device will stop listening for changes + * to the current location. */ +typedef void (RETRO_CALLCONV *retro_location_stop_t)(void); + +/* Get the position of the current location. Will set parameters to + * 0 if no new location update has happened since the last time. */ +typedef bool (RETRO_CALLCONV *retro_location_get_position_t)(double *lat, double *lon, + double *horiz_accuracy, double *vert_accuracy); + +/* Callback which signals when the location driver is initialized + * and/or deinitialized. + * retro_location_start_t can be called in initialized callback. + */ +typedef void (RETRO_CALLCONV *retro_location_lifetime_status_t)(void); + +struct retro_location_callback +{ + retro_location_start_t start; + retro_location_stop_t stop; + retro_location_get_position_t get_position; + retro_location_set_interval_t set_interval; + + retro_location_lifetime_status_t initialized; + retro_location_lifetime_status_t deinitialized; +}; + +enum retro_rumble_effect +{ + RETRO_RUMBLE_STRONG = 0, + RETRO_RUMBLE_WEAK = 1, + + RETRO_RUMBLE_DUMMY = INT_MAX +}; + +/* Sets rumble state for joypad plugged in port 'port'. + * Rumble effects are controlled independently, + * and setting e.g. strong rumble does not override weak rumble. + * Strength has a range of [0, 0xffff]. + * + * Returns true if rumble state request was honored. + * Calling this before first retro_run() is likely to return false. */ +typedef bool (RETRO_CALLCONV *retro_set_rumble_state_t)(unsigned port, + enum retro_rumble_effect effect, uint16_t strength); + +struct retro_rumble_interface +{ + retro_set_rumble_state_t set_rumble_state; +}; + +/* Notifies libretro that audio data should be written. */ +typedef void (RETRO_CALLCONV *retro_audio_callback_t)(void); + +/* True: Audio driver in frontend is active, and callback is + * expected to be called regularily. + * False: Audio driver in frontend is paused or inactive. + * Audio callback will not be called until set_state has been + * called with true. + * Initial state is false (inactive). + */ +typedef void (RETRO_CALLCONV *retro_audio_set_state_callback_t)(bool enabled); + +struct retro_audio_callback +{ + retro_audio_callback_t callback; + retro_audio_set_state_callback_t set_state; +}; + +/* Notifies a libretro core of time spent since last invocation + * of retro_run() in microseconds. + * + * It will be called right before retro_run() every frame. + * The frontend can tamper with timing to support cases like + * fast-forward, slow-motion and framestepping. + * + * In those scenarios the reference frame time value will be used. */ +typedef int64_t retro_usec_t; +typedef void (RETRO_CALLCONV *retro_frame_time_callback_t)(retro_usec_t usec); +struct retro_frame_time_callback +{ + retro_frame_time_callback_t callback; + /* Represents the time of one frame. It is computed as + * 1000000 / fps, but the implementation will resolve the + * rounding to ensure that framestepping, etc is exact. */ + retro_usec_t reference; +}; + +/* Notifies a libretro core of the current occupancy + * level of the frontend audio buffer. + * + * - active: 'true' if audio buffer is currently + * in use. Will be 'false' if audio is + * disabled in the frontend + * + * - occupancy: Given as a value in the range [0,100], + * corresponding to the occupancy percentage + * of the audio buffer + * + * - underrun_likely: 'true' if the frontend expects an + * audio buffer underrun during the + * next frame (indicates that a core + * should attempt frame skipping) + * + * It will be called right before retro_run() every frame. */ +typedef void (RETRO_CALLCONV *retro_audio_buffer_status_callback_t)( + bool active, unsigned occupancy, bool underrun_likely); +struct retro_audio_buffer_status_callback +{ + retro_audio_buffer_status_callback_t callback; +}; + +/* Pass this to retro_video_refresh_t if rendering to hardware. + * Passing NULL to retro_video_refresh_t is still a frame dupe as normal. + * */ +#define RETRO_HW_FRAME_BUFFER_VALID ((void*)-1) + +/* Invalidates the current HW context. + * Any GL state is lost, and must not be deinitialized explicitly. + * If explicit deinitialization is desired by the libretro core, + * it should implement context_destroy callback. + * If called, all GPU resources must be reinitialized. + * Usually called when frontend reinits video driver. + * Also called first time video driver is initialized, + * allowing libretro core to initialize resources. + */ +typedef void (RETRO_CALLCONV *retro_hw_context_reset_t)(void); + +/* Gets current framebuffer which is to be rendered to. + * Could change every frame potentially. + */ +typedef uintptr_t (RETRO_CALLCONV *retro_hw_get_current_framebuffer_t)(void); + +/* Get a symbol from HW context. */ +typedef retro_proc_address_t (RETRO_CALLCONV *retro_hw_get_proc_address_t)(const char *sym); + +enum retro_hw_context_type +{ + RETRO_HW_CONTEXT_NONE = 0, + /* OpenGL 2.x. Driver can choose to use latest compatibility context. */ + RETRO_HW_CONTEXT_OPENGL = 1, + /* OpenGL ES 2.0. */ + RETRO_HW_CONTEXT_OPENGLES2 = 2, + /* Modern desktop core GL context. Use version_major/ + * version_minor fields to set GL version. */ + RETRO_HW_CONTEXT_OPENGL_CORE = 3, + /* OpenGL ES 3.0 */ + RETRO_HW_CONTEXT_OPENGLES3 = 4, + /* OpenGL ES 3.1+. Set version_major/version_minor. For GLES2 and GLES3, + * use the corresponding enums directly. */ + RETRO_HW_CONTEXT_OPENGLES_VERSION = 5, + + /* Vulkan, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE. */ + RETRO_HW_CONTEXT_VULKAN = 6, + + /* Direct3D, set version_major to select the type of interface + * returned by RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ + RETRO_HW_CONTEXT_DIRECT3D = 7, + + RETRO_HW_CONTEXT_DUMMY = INT_MAX +}; + +struct retro_hw_render_callback +{ + /* Which API to use. Set by libretro core. */ + enum retro_hw_context_type context_type; + + /* Called when a context has been created or when it has been reset. + * An OpenGL context is only valid after context_reset() has been called. + * + * When context_reset is called, OpenGL resources in the libretro + * implementation are guaranteed to be invalid. + * + * It is possible that context_reset is called multiple times during an + * application lifecycle. + * If context_reset is called without any notification (context_destroy), + * the OpenGL context was lost and resources should just be recreated + * without any attempt to "free" old resources. + */ + retro_hw_context_reset_t context_reset; + + /* Set by frontend. + * TODO: This is rather obsolete. The frontend should not + * be providing preallocated framebuffers. */ + retro_hw_get_current_framebuffer_t get_current_framebuffer; + + /* Set by frontend. + * Can return all relevant functions, including glClear on Windows. */ + retro_hw_get_proc_address_t get_proc_address; + + /* Set if render buffers should have depth component attached. + * TODO: Obsolete. */ + bool depth; + + /* Set if stencil buffers should be attached. + * TODO: Obsolete. */ + bool stencil; + + /* If depth and stencil are true, a packed 24/8 buffer will be added. + * Only attaching stencil is invalid and will be ignored. */ + + /* Use conventional bottom-left origin convention. If false, + * standard libretro top-left origin semantics are used. + * TODO: Move to GL specific interface. */ + bool bottom_left_origin; + + /* Major version number for core GL context or GLES 3.1+. */ + unsigned version_major; + + /* Minor version number for core GL context or GLES 3.1+. */ + unsigned version_minor; + + /* If this is true, the frontend will go very far to avoid + * resetting context in scenarios like toggling fullscreen, etc. + * TODO: Obsolete? Maybe frontend should just always assume this ... + */ + bool cache_context; + + /* The reset callback might still be called in extreme situations + * such as if the context is lost beyond recovery. + * + * For optimal stability, set this to false, and allow context to be + * reset at any time. + */ + + /* A callback to be called before the context is destroyed in a + * controlled way by the frontend. */ + retro_hw_context_reset_t context_destroy; + + /* OpenGL resources can be deinitialized cleanly at this step. + * context_destroy can be set to NULL, in which resources will + * just be destroyed without any notification. + * + * Even when context_destroy is non-NULL, it is possible that + * context_reset is called without any destroy notification. + * This happens if context is lost by external factors (such as + * notified by GL_ARB_robustness). + * + * In this case, the context is assumed to be already dead, + * and the libretro implementation must not try to free any OpenGL + * resources in the subsequent context_reset. + */ + + /* Creates a debug context. */ + bool debug_context; +}; + +/* Callback type passed in RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK. + * Called by the frontend in response to keyboard events. + * down is set if the key is being pressed, or false if it is being released. + * keycode is the RETROK value of the char. + * character is the text character of the pressed key. (UTF-32). + * key_modifiers is a set of RETROKMOD values or'ed together. + * + * The pressed/keycode state can be indepedent of the character. + * It is also possible that multiple characters are generated from a + * single keypress. + * Keycode events should be treated separately from character events. + * However, when possible, the frontend should try to synchronize these. + * If only a character is posted, keycode should be RETROK_UNKNOWN. + * + * Similarily if only a keycode event is generated with no corresponding + * character, character should be 0. + */ +typedef void (RETRO_CALLCONV *retro_keyboard_event_t)(bool down, unsigned keycode, + uint32_t character, uint16_t key_modifiers); + +struct retro_keyboard_callback +{ + retro_keyboard_event_t callback; +}; + +/* Callbacks for RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE & + * RETRO_ENVIRONMENT_SET_DISK_CONTROL_EXT_INTERFACE. + * Should be set for implementations which can swap out multiple disk + * images in runtime. + * + * If the implementation can do this automatically, it should strive to do so. + * However, there are cases where the user must manually do so. + * + * Overview: To swap a disk image, eject the disk image with + * set_eject_state(true). + * Set the disk index with set_image_index(index). Insert the disk again + * with set_eject_state(false). + */ + +/* If ejected is true, "ejects" the virtual disk tray. + * When ejected, the disk image index can be set. + */ +typedef bool (RETRO_CALLCONV *retro_set_eject_state_t)(bool ejected); + +/* Gets current eject state. The initial state is 'not ejected'. */ +typedef bool (RETRO_CALLCONV *retro_get_eject_state_t)(void); + +/* Gets current disk index. First disk is index 0. + * If return value is >= get_num_images(), no disk is currently inserted. + */ +typedef unsigned (RETRO_CALLCONV *retro_get_image_index_t)(void); + +/* Sets image index. Can only be called when disk is ejected. + * The implementation supports setting "no disk" by using an + * index >= get_num_images(). + */ +typedef bool (RETRO_CALLCONV *retro_set_image_index_t)(unsigned index); + +/* Gets total number of images which are available to use. */ +typedef unsigned (RETRO_CALLCONV *retro_get_num_images_t)(void); + +struct retro_game_info; + +/* Replaces the disk image associated with index. + * Arguments to pass in info have same requirements as retro_load_game(). + * Virtual disk tray must be ejected when calling this. + * + * Replacing a disk image with info = NULL will remove the disk image + * from the internal list. + * As a result, calls to get_image_index() can change. + * + * E.g. replace_image_index(1, NULL), and previous get_image_index() + * returned 4 before. + * Index 1 will be removed, and the new index is 3. + */ +typedef bool (RETRO_CALLCONV *retro_replace_image_index_t)(unsigned index, + const struct retro_game_info *info); + +/* Adds a new valid index (get_num_images()) to the internal disk list. + * This will increment subsequent return values from get_num_images() by 1. + * This image index cannot be used until a disk image has been set + * with replace_image_index. */ +typedef bool (RETRO_CALLCONV *retro_add_image_index_t)(void); + +/* Sets initial image to insert in drive when calling + * core_load_game(). + * Since we cannot pass the initial index when loading + * content (this would require a major API change), this + * is set by the frontend *before* calling the core's + * retro_load_game()/retro_load_game_special() implementation. + * A core should therefore cache the index/path values and handle + * them inside retro_load_game()/retro_load_game_special(). + * - If 'index' is invalid (index >= get_num_images()), the + * core should ignore the set value and instead use 0 + * - 'path' is used purely for error checking - i.e. when + * content is loaded, the core should verify that the + * disk specified by 'index' has the specified file path. + * This is to guard against auto selecting the wrong image + * if (for example) the user should modify an existing M3U + * playlist. We have to let the core handle this because + * set_initial_image() must be called before loading content, + * i.e. the frontend cannot access image paths in advance + * and thus cannot perform the error check itself. + * If set path and content path do not match, the core should + * ignore the set 'index' value and instead use 0 + * Returns 'false' if index or 'path' are invalid, or core + * does not support this functionality + */ +typedef bool (RETRO_CALLCONV *retro_set_initial_image_t)(unsigned index, const char *path); + +/* Fetches the path of the specified disk image file. + * Returns 'false' if index is invalid (index >= get_num_images()) + * or path is otherwise unavailable. + */ +typedef bool (RETRO_CALLCONV *retro_get_image_path_t)(unsigned index, char *path, size_t len); + +/* Fetches a core-provided 'label' for the specified disk + * image file. In the simplest case this may be a file name + * (without extension), but for cores with more complex + * content requirements information may be provided to + * facilitate user disk swapping - for example, a core + * running floppy-disk-based content may uniquely label + * save disks, data disks, level disks, etc. with names + * corresponding to in-game disk change prompts (so the + * frontend can provide better user guidance than a 'dumb' + * disk index value). + * Returns 'false' if index is invalid (index >= get_num_images()) + * or label is otherwise unavailable. + */ +typedef bool (RETRO_CALLCONV *retro_get_image_label_t)(unsigned index, char *label, size_t len); + +struct retro_disk_control_callback +{ + retro_set_eject_state_t set_eject_state; + retro_get_eject_state_t get_eject_state; + + retro_get_image_index_t get_image_index; + retro_set_image_index_t set_image_index; + retro_get_num_images_t get_num_images; + + retro_replace_image_index_t replace_image_index; + retro_add_image_index_t add_image_index; +}; + +struct retro_disk_control_ext_callback +{ + retro_set_eject_state_t set_eject_state; + retro_get_eject_state_t get_eject_state; + + retro_get_image_index_t get_image_index; + retro_set_image_index_t set_image_index; + retro_get_num_images_t get_num_images; + + retro_replace_image_index_t replace_image_index; + retro_add_image_index_t add_image_index; + + /* NOTE: Frontend will only attempt to record/restore + * last used disk index if both set_initial_image() + * and get_image_path() are implemented */ + retro_set_initial_image_t set_initial_image; /* Optional - may be NULL */ + + retro_get_image_path_t get_image_path; /* Optional - may be NULL */ + retro_get_image_label_t get_image_label; /* Optional - may be NULL */ +}; + +enum retro_pixel_format +{ + /* 0RGB1555, native endian. + * 0 bit must be set to 0. + * This pixel format is default for compatibility concerns only. + * If a 15/16-bit pixel format is desired, consider using RGB565. */ + RETRO_PIXEL_FORMAT_0RGB1555 = 0, + + /* XRGB8888, native endian. + * X bits are ignored. */ + RETRO_PIXEL_FORMAT_XRGB8888 = 1, + + /* RGB565, native endian. + * This pixel format is the recommended format to use if a 15/16-bit + * format is desired as it is the pixel format that is typically + * available on a wide range of low-power devices. + * + * It is also natively supported in APIs like OpenGL ES. */ + RETRO_PIXEL_FORMAT_RGB565 = 2, + + /* Ensure sizeof() == sizeof(int). */ + RETRO_PIXEL_FORMAT_UNKNOWN = INT_MAX +}; + +enum retro_savestate_context +{ + /* Standard savestate written to disk. */ + RETRO_SAVESTATE_CONTEXT_NORMAL = 0, + + /* Savestate where you are guaranteed that the same instance will load the save state. + * You can store internal pointers to code or data. + * It's still a full serialization and deserialization, and could be loaded or saved at any time. + * It won't be written to disk or sent over the network. + */ + RETRO_SAVESTATE_CONTEXT_RUNAHEAD_SAME_INSTANCE = 1, + + /* Savestate where you are guaranteed that the same emulator binary will load that savestate. + * You can skip anything that would slow down saving or loading state but you can not store internal pointers. + * It won't be written to disk or sent over the network. + * Example: "Second Instance" runahead + */ + RETRO_SAVESTATE_CONTEXT_RUNAHEAD_SAME_BINARY = 2, + + /* Savestate used within a rollback netplay feature. + * You should skip anything that would unnecessarily increase bandwidth usage. + * It won't be written to disk but it will be sent over the network. + */ + RETRO_SAVESTATE_CONTEXT_ROLLBACK_NETPLAY = 3, + + /* Ensure sizeof() == sizeof(int). */ + RETRO_SAVESTATE_CONTEXT_UNKNOWN = INT_MAX +}; + +struct retro_message +{ + const char *msg; /* Message to be displayed. */ + unsigned frames; /* Duration in frames of message. */ +}; + +enum retro_message_target +{ + RETRO_MESSAGE_TARGET_ALL = 0, + RETRO_MESSAGE_TARGET_OSD, + RETRO_MESSAGE_TARGET_LOG +}; + +enum retro_message_type +{ + RETRO_MESSAGE_TYPE_NOTIFICATION = 0, + RETRO_MESSAGE_TYPE_NOTIFICATION_ALT, + RETRO_MESSAGE_TYPE_STATUS, + RETRO_MESSAGE_TYPE_PROGRESS +}; + +struct retro_message_ext +{ + /* Message string to be displayed/logged */ + const char *msg; + /* Duration (in ms) of message when targeting the OSD */ + unsigned duration; + /* Message priority when targeting the OSD + * > When multiple concurrent messages are sent to + * the frontend and the frontend does not have the + * capacity to display them all, messages with the + * *highest* priority value should be shown + * > There is no upper limit to a message priority + * value (within the bounds of the unsigned data type) + * > In the reference frontend (RetroArch), the same + * priority values are used for frontend-generated + * notifications, which are typically assigned values + * between 0 and 3 depending upon importance */ + unsigned priority; + /* Message logging level (info, warn, error, etc.) */ + enum retro_log_level level; + /* Message destination: OSD, logging interface or both */ + enum retro_message_target target; + /* Message 'type' when targeting the OSD + * > RETRO_MESSAGE_TYPE_NOTIFICATION: Specifies that a + * message should be handled in identical fashion to + * a standard frontend-generated notification + * > RETRO_MESSAGE_TYPE_NOTIFICATION_ALT: Specifies that + * message is a notification that requires user attention + * or action, but that it should be displayed in a manner + * that differs from standard frontend-generated notifications. + * This would typically correspond to messages that should be + * displayed immediately (independently from any internal + * frontend message queue), and/or which should be visually + * distinguishable from frontend-generated notifications. + * For example, a core may wish to inform the user of + * information related to a disk-change event. It is + * expected that the frontend itself may provide a + * notification in this case; if the core sends a + * message of type RETRO_MESSAGE_TYPE_NOTIFICATION, an + * uncomfortable 'double-notification' may occur. A message + * of RETRO_MESSAGE_TYPE_NOTIFICATION_ALT should therefore + * be presented such that visual conflict with regular + * notifications does not occur + * > RETRO_MESSAGE_TYPE_STATUS: Indicates that message + * is not a standard notification. This typically + * corresponds to 'status' indicators, such as a core's + * internal FPS, which are intended to be displayed + * either permanently while a core is running, or in + * a manner that does not suggest user attention or action + * is required. 'Status' type messages should therefore be + * displayed in a different on-screen location and in a manner + * easily distinguishable from both standard frontend-generated + * notifications and messages of type RETRO_MESSAGE_TYPE_NOTIFICATION_ALT + * > RETRO_MESSAGE_TYPE_PROGRESS: Indicates that message reports + * the progress of an internal core task. For example, in cases + * where a core itself handles the loading of content from a file, + * this may correspond to the percentage of the file that has been + * read. Alternatively, an audio/video playback core may use a + * message of type RETRO_MESSAGE_TYPE_PROGRESS to display the current + * playback position as a percentage of the runtime. 'Progress' type + * messages should therefore be displayed as a literal progress bar, + * where: + * - 'retro_message_ext.msg' is the progress bar title/label + * - 'retro_message_ext.progress' determines the length of + * the progress bar + * NOTE: Message type is a *hint*, and may be ignored + * by the frontend. If a frontend lacks support for + * displaying messages via alternate means than standard + * frontend-generated notifications, it will treat *all* + * messages as having the type RETRO_MESSAGE_TYPE_NOTIFICATION */ + enum retro_message_type type; + /* Task progress when targeting the OSD and message is + * of type RETRO_MESSAGE_TYPE_PROGRESS + * > -1: Unmetered/indeterminate + * > 0-100: Current progress percentage + * NOTE: Since message type is a hint, a frontend may ignore + * progress values. Where relevant, a core should therefore + * include progress percentage within the message string, + * such that the message intent remains clear when displayed + * as a standard frontend-generated notification */ + int8_t progress; +}; + +/* Describes how the libretro implementation maps a libretro input bind + * to its internal input system through a human readable string. + * This string can be used to better let a user configure input. */ +struct retro_input_descriptor +{ + /* Associates given parameters with a description. */ + unsigned port; + unsigned device; + unsigned index; + unsigned id; + + /* Human readable description for parameters. + * The pointer must remain valid until + * retro_unload_game() is called. */ + const char *description; +}; + +struct retro_system_info +{ + /* All pointers are owned by libretro implementation, and pointers must + * remain valid until it is unloaded. */ + + const char *library_name; /* Descriptive name of library. Should not + * contain any version numbers, etc. */ + const char *library_version; /* Descriptive version of core. */ + + const char *valid_extensions; /* A string listing probably content + * extensions the core will be able to + * load, separated with pipe. + * I.e. "bin|rom|iso". + * Typically used for a GUI to filter + * out extensions. */ + + /* Libretro cores that need to have direct access to their content + * files, including cores which use the path of the content files to + * determine the paths of other files, should set need_fullpath to true. + * + * Cores should strive for setting need_fullpath to false, + * as it allows the frontend to perform patching, etc. + * + * If need_fullpath is true and retro_load_game() is called: + * - retro_game_info::path is guaranteed to have a valid path + * - retro_game_info::data and retro_game_info::size are invalid + * + * If need_fullpath is false and retro_load_game() is called: + * - retro_game_info::path may be NULL + * - retro_game_info::data and retro_game_info::size are guaranteed + * to be valid + * + * See also: + * - RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY + * - RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY + */ + bool need_fullpath; + + /* If true, the frontend is not allowed to extract any archives before + * loading the real content. + * Necessary for certain libretro implementations that load games + * from zipped archives. */ + bool block_extract; +}; + +/* Defines overrides which modify frontend handling of + * specific content file types. + * An array of retro_system_content_info_override is + * passed to RETRO_ENVIRONMENT_SET_CONTENT_INFO_OVERRIDE + * NOTE: In the following descriptions, references to + * retro_load_game() may be replaced with + * retro_load_game_special() */ +struct retro_system_content_info_override +{ + /* A list of file extensions for which the override + * should apply, delimited by a 'pipe' character + * (e.g. "md|sms|gg") + * Permitted file extensions are limited to those + * included in retro_system_info::valid_extensions + * and/or retro_subsystem_rom_info::valid_extensions */ + const char *extensions; + + /* Overrides the need_fullpath value set in + * retro_system_info and/or retro_subsystem_rom_info. + * To reiterate: + * + * If need_fullpath is true and retro_load_game() is called: + * - retro_game_info::path is guaranteed to contain a valid + * path to an existent file + * - retro_game_info::data and retro_game_info::size are invalid + * + * If need_fullpath is false and retro_load_game() is called: + * - retro_game_info::path may be NULL + * - retro_game_info::data and retro_game_info::size are guaranteed + * to be valid + * + * In addition: + * + * If need_fullpath is true and retro_load_game() is called: + * - retro_game_info_ext::full_path is guaranteed to contain a valid + * path to an existent file + * - retro_game_info_ext::archive_path may be NULL + * - retro_game_info_ext::archive_file may be NULL + * - retro_game_info_ext::dir is guaranteed to contain a valid path + * to the directory in which the content file exists + * - retro_game_info_ext::name is guaranteed to contain the + * basename of the content file, without extension + * - retro_game_info_ext::ext is guaranteed to contain the + * extension of the content file in lower case format + * - retro_game_info_ext::data and retro_game_info_ext::size + * are invalid + * + * If need_fullpath is false and retro_load_game() is called: + * - If retro_game_info_ext::file_in_archive is false: + * - retro_game_info_ext::full_path is guaranteed to contain + * a valid path to an existent file + * - retro_game_info_ext::archive_path may be NULL + * - retro_game_info_ext::archive_file may be NULL + * - retro_game_info_ext::dir is guaranteed to contain a + * valid path to the directory in which the content file exists + * - retro_game_info_ext::name is guaranteed to contain the + * basename of the content file, without extension + * - retro_game_info_ext::ext is guaranteed to contain the + * extension of the content file in lower case format + * - If retro_game_info_ext::file_in_archive is true: + * - retro_game_info_ext::full_path may be NULL + * - retro_game_info_ext::archive_path is guaranteed to + * contain a valid path to an existent compressed file + * inside which the content file is located + * - retro_game_info_ext::archive_file is guaranteed to + * contain a valid path to an existent content file + * inside the compressed file referred to by + * retro_game_info_ext::archive_path + * e.g. for a compressed file '/path/to/foo.zip' + * containing 'bar.sfc' + * > retro_game_info_ext::archive_path will be '/path/to/foo.zip' + * > retro_game_info_ext::archive_file will be 'bar.sfc' + * - retro_game_info_ext::dir is guaranteed to contain a + * valid path to the directory in which the compressed file + * (containing the content file) exists + * - retro_game_info_ext::name is guaranteed to contain + * EITHER + * 1) the basename of the compressed file (containing + * the content file), without extension + * OR + * 2) the basename of the content file inside the + * compressed file, without extension + * In either case, a core should consider 'name' to + * be the canonical name/ID of the the content file + * - retro_game_info_ext::ext is guaranteed to contain the + * extension of the content file inside the compressed file, + * in lower case format + * - retro_game_info_ext::data and retro_game_info_ext::size are + * guaranteed to be valid */ + bool need_fullpath; + + /* If need_fullpath is false, specifies whether the content + * data buffer available in retro_load_game() is 'persistent' + * + * If persistent_data is false and retro_load_game() is called: + * - retro_game_info::data and retro_game_info::size + * are valid only until retro_load_game() returns + * - retro_game_info_ext::data and retro_game_info_ext::size + * are valid only until retro_load_game() returns + * + * If persistent_data is true and retro_load_game() is called: + * - retro_game_info::data and retro_game_info::size + * are valid until retro_deinit() returns + * - retro_game_info_ext::data and retro_game_info_ext::size + * are valid until retro_deinit() returns */ + bool persistent_data; +}; + +/* Similar to retro_game_info, but provides extended + * information about the source content file and + * game memory buffer status. + * And array of retro_game_info_ext is returned by + * RETRO_ENVIRONMENT_GET_GAME_INFO_EXT + * NOTE: In the following descriptions, references to + * retro_load_game() may be replaced with + * retro_load_game_special() */ +struct retro_game_info_ext +{ + /* - If file_in_archive is false, contains a valid + * path to an existent content file (UTF-8 encoded) + * - If file_in_archive is true, may be NULL */ + const char *full_path; + + /* - If file_in_archive is false, may be NULL + * - If file_in_archive is true, contains a valid path + * to an existent compressed file inside which the + * content file is located (UTF-8 encoded) */ + const char *archive_path; + + /* - If file_in_archive is false, may be NULL + * - If file_in_archive is true, contain a valid path + * to an existent content file inside the compressed + * file referred to by archive_path (UTF-8 encoded) + * e.g. for a compressed file '/path/to/foo.zip' + * containing 'bar.sfc' + * > archive_path will be '/path/to/foo.zip' + * > archive_file will be 'bar.sfc' */ + const char *archive_file; + + /* - If file_in_archive is false, contains a valid path + * to the directory in which the content file exists + * (UTF-8 encoded) + * - If file_in_archive is true, contains a valid path + * to the directory in which the compressed file + * (containing the content file) exists (UTF-8 encoded) */ + const char *dir; + + /* Contains the canonical name/ID of the content file + * (UTF-8 encoded). Intended for use when identifying + * 'complementary' content named after the loaded file - + * i.e. companion data of a different format (a CD image + * required by a ROM), texture packs, internally handled + * save files, etc. + * - If file_in_archive is false, contains the basename + * of the content file, without extension + * - If file_in_archive is true, then string is + * implementation specific. A frontend may choose to + * set a name value of: + * EITHER + * 1) the basename of the compressed file (containing + * the content file), without extension + * OR + * 2) the basename of the content file inside the + * compressed file, without extension + * RetroArch sets the 'name' value according to (1). + * A frontend that supports routine loading of + * content from archives containing multiple unrelated + * content files may set the 'name' value according + * to (2). */ + const char *name; + + /* - If file_in_archive is false, contains the extension + * of the content file in lower case format + * - If file_in_archive is true, contains the extension + * of the content file inside the compressed file, + * in lower case format */ + const char *ext; + + /* String of implementation specific meta-data. */ + const char *meta; + + /* Memory buffer of loaded game content. Will be NULL: + * IF + * - retro_system_info::need_fullpath is true and + * retro_system_content_info_override::need_fullpath + * is unset + * OR + * - retro_system_content_info_override::need_fullpath + * is true */ + const void *data; + + /* Size of game content memory buffer, in bytes */ + size_t size; + + /* True if loaded content file is inside a compressed + * archive */ + bool file_in_archive; + + /* - If data is NULL, value is unset/ignored + * - If data is non-NULL: + * - If persistent_data is false, data and size are + * valid only until retro_load_game() returns + * - If persistent_data is true, data and size are + * are valid until retro_deinit() returns */ + bool persistent_data; +}; + +struct retro_game_geometry +{ + unsigned base_width; /* Nominal video width of game. */ + unsigned base_height; /* Nominal video height of game. */ + unsigned max_width; /* Maximum possible width of game. */ + unsigned max_height; /* Maximum possible height of game. */ + + float aspect_ratio; /* Nominal aspect ratio of game. If + * aspect_ratio is <= 0.0, an aspect ratio + * of base_width / base_height is assumed. + * A frontend could override this setting, + * if desired. */ +}; + +struct retro_system_timing +{ + double fps; /* FPS of video content. */ + double sample_rate; /* Sampling rate of audio. */ +}; + +struct retro_system_av_info +{ + struct retro_game_geometry geometry; + struct retro_system_timing timing; +}; + +struct retro_variable +{ + /* Variable to query in RETRO_ENVIRONMENT_GET_VARIABLE. + * If NULL, obtains the complete environment string if more + * complex parsing is necessary. + * The environment string is formatted as key-value pairs + * delimited by semicolons as so: + * "key1=value1;key2=value2;..." + */ + const char *key; + + /* Value to be obtained. If key does not exist, it is set to NULL. */ + const char *value; +}; + +struct retro_core_option_display +{ + /* Variable to configure in RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY */ + const char *key; + + /* Specifies whether variable should be displayed + * when presenting core options to the user */ + bool visible; +}; + +/* Maximum number of values permitted for a core option + * > Note: We have to set a maximum value due the limitations + * of the C language - i.e. it is not possible to create an + * array of structs each containing a variable sized array, + * so the retro_core_option_definition values array must + * have a fixed size. The size limit of 128 is a balancing + * act - it needs to be large enough to support all 'sane' + * core options, but setting it too large may impact low memory + * platforms. In practise, if a core option has more than + * 128 values then the implementation is likely flawed. + * To quote the above API reference: + * "The number of possible options should be very limited + * i.e. it should be feasible to cycle through options + * without a keyboard." + */ +#define RETRO_NUM_CORE_OPTION_VALUES_MAX 128 + +struct retro_core_option_value +{ + /* Expected option value */ + const char *value; + + /* Human-readable value label. If NULL, value itself + * will be displayed by the frontend */ + const char *label; +}; + +struct retro_core_option_definition +{ + /* Variable to query in RETRO_ENVIRONMENT_GET_VARIABLE. */ + const char *key; + + /* Human-readable core option description (used as menu label) */ + const char *desc; + + /* Human-readable core option information (used as menu sublabel) */ + const char *info; + + /* Array of retro_core_option_value structs, terminated by NULL */ + struct retro_core_option_value values[RETRO_NUM_CORE_OPTION_VALUES_MAX]; + + /* Default core option value. Must match one of the values + * in the retro_core_option_value array, otherwise will be + * ignored */ + const char *default_value; +}; + +#ifdef __PS3__ +#undef local +#endif + +struct retro_core_options_intl +{ + /* Pointer to an array of retro_core_option_definition structs + * - US English implementation + * - Must point to a valid array */ + struct retro_core_option_definition *us; + + /* Pointer to an array of retro_core_option_definition structs + * - Implementation for current frontend language + * - May be NULL */ + struct retro_core_option_definition *local; +}; + +struct retro_core_option_v2_category +{ + /* Variable uniquely identifying the + * option category. Valid key characters + * are [a-z, A-Z, 0-9, _, -] */ + const char *key; + + /* Human-readable category description + * > Used as category menu label when + * frontend has core option category + * support */ + const char *desc; + + /* Human-readable category information + * > Used as category menu sublabel when + * frontend has core option category + * support + * > Optional (may be NULL or an empty + * string) */ + const char *info; +}; + +struct retro_core_option_v2_definition +{ + /* Variable to query in RETRO_ENVIRONMENT_GET_VARIABLE. + * Valid key characters are [a-z, A-Z, 0-9, _, -] */ + const char *key; + + /* Human-readable core option description + * > Used as menu label when frontend does + * not have core option category support + * e.g. "Video > Aspect Ratio" */ + const char *desc; + + /* Human-readable core option description + * > Used as menu label when frontend has + * core option category support + * e.g. "Aspect Ratio", where associated + * retro_core_option_v2_category::desc + * is "Video" + * > If empty or NULL, the string specified by + * desc will be used as the menu label + * > Will be ignored (and may be set to NULL) + * if category_key is empty or NULL */ + const char *desc_categorized; + + /* Human-readable core option information + * > Used as menu sublabel */ + const char *info; + + /* Human-readable core option information + * > Used as menu sublabel when frontend + * has core option category support + * (e.g. may be required when info text + * references an option by name/desc, + * and the desc/desc_categorized text + * for that option differ) + * > If empty or NULL, the string specified by + * info will be used as the menu sublabel + * > Will be ignored (and may be set to NULL) + * if category_key is empty or NULL */ + const char *info_categorized; + + /* Variable specifying category (e.g. "video", + * "audio") that will be assigned to the option + * if frontend has core option category support. + * > Categorized options will be displayed in a + * subsection/submenu of the frontend core + * option interface + * > Specified string must match one of the + * retro_core_option_v2_category::key values + * in the associated retro_core_option_v2_category + * array; If no match is not found, specified + * string will be considered as NULL + * > If specified string is empty or NULL, option will + * have no category and will be shown at the top + * level of the frontend core option interface */ + const char *category_key; + + /* Array of retro_core_option_value structs, terminated by NULL */ + struct retro_core_option_value values[RETRO_NUM_CORE_OPTION_VALUES_MAX]; + + /* Default core option value. Must match one of the values + * in the retro_core_option_value array, otherwise will be + * ignored */ + const char *default_value; +}; + +struct retro_core_options_v2 +{ + /* Array of retro_core_option_v2_category structs, + * terminated by NULL + * > If NULL, all entries in definitions array + * will have no category and will be shown at + * the top level of the frontend core option + * interface + * > Will be ignored if frontend does not have + * core option category support */ + struct retro_core_option_v2_category *categories; + + /* Array of retro_core_option_v2_definition structs, + * terminated by NULL */ + struct retro_core_option_v2_definition *definitions; +}; + +struct retro_core_options_v2_intl +{ + /* Pointer to a retro_core_options_v2 struct + * > US English implementation + * > Must point to a valid struct */ + struct retro_core_options_v2 *us; + + /* Pointer to a retro_core_options_v2 struct + * - Implementation for current frontend language + * - May be NULL */ + struct retro_core_options_v2 *local; +}; + +/* Used by the frontend to monitor changes in core option + * visibility. May be called each time any core option + * value is set via the frontend. + * - On each invocation, the core must update the visibility + * of any dynamically hidden options using the + * RETRO_ENVIRONMENT_SET_CORE_OPTIONS_DISPLAY environment + * callback. + * - On the first invocation, returns 'true' if the visibility + * of any core option has changed since the last call of + * retro_load_game() or retro_load_game_special(). + * - On each subsequent invocation, returns 'true' if the + * visibility of any core option has changed since the last + * time the function was called. */ +typedef bool (RETRO_CALLCONV *retro_core_options_update_display_callback_t)(void); +struct retro_core_options_update_display_callback +{ + retro_core_options_update_display_callback_t callback; +}; + +struct retro_game_info +{ + const char *path; /* Path to game, UTF-8 encoded. + * Sometimes used as a reference for building other paths. + * May be NULL if game was loaded from stdin or similar, + * but in this case some cores will be unable to load `data`. + * So, it is preferable to fabricate something here instead + * of passing NULL, which will help more cores to succeed. + * retro_system_info::need_fullpath requires + * that this path is valid. */ + const void *data; /* Memory buffer of loaded game. Will be NULL + * if need_fullpath was set. */ + size_t size; /* Size of memory buffer. */ + const char *meta; /* String of implementation specific meta-data. */ +}; + +#define RETRO_MEMORY_ACCESS_WRITE (1 << 0) + /* The core will write to the buffer provided by retro_framebuffer::data. */ +#define RETRO_MEMORY_ACCESS_READ (1 << 1) + /* The core will read from retro_framebuffer::data. */ +#define RETRO_MEMORY_TYPE_CACHED (1 << 0) + /* The memory in data is cached. + * If not cached, random writes and/or reading from the buffer is expected to be very slow. */ +struct retro_framebuffer +{ + void *data; /* The framebuffer which the core can render into. + Set by frontend in GET_CURRENT_SOFTWARE_FRAMEBUFFER. + The initial contents of data are unspecified. */ + unsigned width; /* The framebuffer width used by the core. Set by core. */ + unsigned height; /* The framebuffer height used by the core. Set by core. */ + size_t pitch; /* The number of bytes between the beginning of a scanline, + and beginning of the next scanline. + Set by frontend in GET_CURRENT_SOFTWARE_FRAMEBUFFER. */ + enum retro_pixel_format format; /* The pixel format the core must use to render into data. + This format could differ from the format used in + SET_PIXEL_FORMAT. + Set by frontend in GET_CURRENT_SOFTWARE_FRAMEBUFFER. */ + + unsigned access_flags; /* How the core will access the memory in the framebuffer. + RETRO_MEMORY_ACCESS_* flags. + Set by core. */ + unsigned memory_flags; /* Flags telling core how the memory has been mapped. + RETRO_MEMORY_TYPE_* flags. + Set by frontend in GET_CURRENT_SOFTWARE_FRAMEBUFFER. */ +}; + +/* Used by a libretro core to override the current + * fastforwarding mode of the frontend */ +struct retro_fastforwarding_override +{ + /* Specifies the runtime speed multiplier that + * will be applied when 'fastforward' is true. + * For example, a value of 5.0 when running 60 FPS + * content will cap the fast-forward rate at 300 FPS. + * Note that the target multiplier may not be achieved + * if the host hardware has insufficient processing + * power. + * Setting a value of 0.0 (or greater than 0.0 but + * less than 1.0) will result in an uncapped + * fast-forward rate (limited only by hardware + * capacity). + * If the value is negative, it will be ignored + * (i.e. the frontend will use a runtime speed + * multiplier of its own choosing) */ + float ratio; + + /* If true, fastforwarding mode will be enabled. + * If false, fastforwarding mode will be disabled. */ + bool fastforward; + + /* If true, and if supported by the frontend, an + * on-screen notification will be displayed while + * 'fastforward' is true. + * If false, and if supported by the frontend, any + * on-screen fast-forward notifications will be + * suppressed */ + bool notification; + + /* If true, the core will have sole control over + * when fastforwarding mode is enabled/disabled; + * the frontend will not be able to change the + * state set by 'fastforward' until either + * 'inhibit_toggle' is set to false, or the core + * is unloaded */ + bool inhibit_toggle; +}; + +/* During normal operation. Rate will be equal to the core's internal FPS. */ +#define RETRO_THROTTLE_NONE 0 + +/* While paused or stepping single frames. Rate will be 0. */ +#define RETRO_THROTTLE_FRAME_STEPPING 1 + +/* During fast forwarding. + * Rate will be 0 if not specifically limited to a maximum speed. */ +#define RETRO_THROTTLE_FAST_FORWARD 2 + +/* During slow motion. Rate will be less than the core's internal FPS. */ +#define RETRO_THROTTLE_SLOW_MOTION 3 + +/* While rewinding recorded save states. Rate can vary depending on the rewind + * speed or be 0 if the frontend is not aiming for a specific rate. */ +#define RETRO_THROTTLE_REWINDING 4 + +/* While vsync is active in the video driver and the target refresh rate is + * lower than the core's internal FPS. Rate is the target refresh rate. */ +#define RETRO_THROTTLE_VSYNC 5 + +/* When the frontend does not throttle in any way. Rate will be 0. + * An example could be if no vsync or audio output is active. */ +#define RETRO_THROTTLE_UNBLOCKED 6 + +struct retro_throttle_state +{ + /* The current throttling mode. Should be one of the values above. */ + unsigned mode; + + /* How many times per second the frontend aims to call retro_run. + * Depending on the mode, it can be 0 if there is no known fixed rate. + * This won't be accurate if the total processing time of the core and + * the frontend is longer than what is available for one frame. */ + float rate; +}; + +/* Callbacks */ + +/* Environment callback. Gives implementations a way of performing + * uncommon tasks. Extensible. */ +typedef bool (RETRO_CALLCONV *retro_environment_t)(unsigned cmd, void *data); + +/* Render a frame. Pixel format is 15-bit 0RGB1555 native endian + * unless changed (see RETRO_ENVIRONMENT_SET_PIXEL_FORMAT). + * + * Width and height specify dimensions of buffer. + * Pitch specifices length in bytes between two lines in buffer. + * + * For performance reasons, it is highly recommended to have a frame + * that is packed in memory, i.e. pitch == width * byte_per_pixel. + * Certain graphic APIs, such as OpenGL ES, do not like textures + * that are not packed in memory. + */ +typedef void (RETRO_CALLCONV *retro_video_refresh_t)(const void *data, unsigned width, + unsigned height, size_t pitch); + +/* Renders a single audio frame. Should only be used if implementation + * generates a single sample at a time. + * Format is signed 16-bit native endian. + */ +typedef void (RETRO_CALLCONV *retro_audio_sample_t)(int16_t left, int16_t right); + +/* Renders multiple audio frames in one go. + * + * One frame is defined as a sample of left and right channels, interleaved. + * I.e. int16_t buf[4] = { l, r, l, r }; would be 2 frames. + * Only one of the audio callbacks must ever be used. + */ +typedef size_t (RETRO_CALLCONV *retro_audio_sample_batch_t)(const int16_t *data, + size_t frames); + +/* Polls input. */ +typedef void (RETRO_CALLCONV *retro_input_poll_t)(void); + +/* Queries for input for player 'port'. device will be masked with + * RETRO_DEVICE_MASK. + * + * Specialization of devices such as RETRO_DEVICE_JOYPAD_MULTITAP that + * have been set with retro_set_controller_port_device() + * will still use the higher level RETRO_DEVICE_JOYPAD to request input. + */ +typedef int16_t (RETRO_CALLCONV *retro_input_state_t)(unsigned port, unsigned device, + unsigned index, unsigned id); + +/* Sets callbacks. retro_set_environment() is guaranteed to be called + * before retro_init(). + * + * The rest of the set_* functions are guaranteed to have been called + * before the first call to retro_run() is made. */ +RETRO_API void retro_set_environment(retro_environment_t); +RETRO_API void retro_set_video_refresh(retro_video_refresh_t); +RETRO_API void retro_set_audio_sample(retro_audio_sample_t); +RETRO_API void retro_set_audio_sample_batch(retro_audio_sample_batch_t); +RETRO_API void retro_set_input_poll(retro_input_poll_t); +RETRO_API void retro_set_input_state(retro_input_state_t); + +/* Library global initialization/deinitialization. */ +RETRO_API void retro_init(void); +RETRO_API void retro_deinit(void); + +/* Must return RETRO_API_VERSION. Used to validate ABI compatibility + * when the API is revised. */ +RETRO_API unsigned retro_api_version(void); + +/* Gets statically known system info. Pointers provided in *info + * must be statically allocated. + * Can be called at any time, even before retro_init(). */ +RETRO_API void retro_get_system_info(struct retro_system_info *info); + +/* Gets information about system audio/video timings and geometry. + * Can be called only after retro_load_game() has successfully completed. + * NOTE: The implementation of this function might not initialize every + * variable if needed. + * E.g. geom.aspect_ratio might not be initialized if core doesn't + * desire a particular aspect ratio. */ +RETRO_API void retro_get_system_av_info(struct retro_system_av_info *info); + +/* Sets device to be used for player 'port'. + * By default, RETRO_DEVICE_JOYPAD is assumed to be plugged into all + * available ports. + * Setting a particular device type is not a guarantee that libretro cores + * will only poll input based on that particular device type. It is only a + * hint to the libretro core when a core cannot automatically detect the + * appropriate input device type on its own. It is also relevant when a + * core can change its behavior depending on device type. + * + * As part of the core's implementation of retro_set_controller_port_device, + * the core should call RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS to notify the + * frontend if the descriptions for any controls have changed as a + * result of changing the device type. + */ +RETRO_API void retro_set_controller_port_device(unsigned port, unsigned device); + +/* Resets the current game. */ +RETRO_API void retro_reset(void); + +/* Runs the game for one video frame. + * During retro_run(), input_poll callback must be called at least once. + * + * If a frame is not rendered for reasons where a game "dropped" a frame, + * this still counts as a frame, and retro_run() should explicitly dupe + * a frame if GET_CAN_DUPE returns true. + * In this case, the video callback can take a NULL argument for data. + */ +RETRO_API void retro_run(void); + +/* Returns the amount of data the implementation requires to serialize + * internal state (save states). + * Between calls to retro_load_game() and retro_unload_game(), the + * returned size is never allowed to be larger than a previous returned + * value, to ensure that the frontend can allocate a save state buffer once. + */ +RETRO_API size_t retro_serialize_size(void); + +/* Serializes internal state. If failed, or size is lower than + * retro_serialize_size(), it should return false, true otherwise. */ +RETRO_API bool retro_serialize(void *data, size_t size); +RETRO_API bool retro_unserialize(const void *data, size_t size); + +RETRO_API void retro_cheat_reset(void); +RETRO_API void retro_cheat_set(unsigned index, bool enabled, const char *code); + +/* Loads a game. + * Return true to indicate successful loading and false to indicate load failure. + */ +RETRO_API bool retro_load_game(const struct retro_game_info *game); + +/* Loads a "special" kind of game. Should not be used, + * except in extreme cases. */ +RETRO_API bool retro_load_game_special( + unsigned game_type, + const struct retro_game_info *info, size_t num_info +); + +/* Unloads the currently loaded game. Called before retro_deinit(void). */ +RETRO_API void retro_unload_game(void); + +/* Gets region of game. */ +RETRO_API unsigned retro_get_region(void); + +/* Gets region of memory. */ +RETRO_API void *retro_get_memory_data(unsigned id); +RETRO_API size_t retro_get_memory_size(unsigned id); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/pkg/worker/caged/libretro/nanoarch/loader.go b/pkg/worker/emulator/libretro/loader.go similarity index 92% rename from pkg/worker/caged/libretro/nanoarch/loader.go rename to pkg/worker/emulator/libretro/loader.go index d7d0c662..f715bce1 100644 --- a/pkg/worker/caged/libretro/nanoarch/loader.go +++ b/pkg/worker/emulator/libretro/loader.go @@ -1,4 +1,4 @@ -package nanoarch +package libretro import ( "errors" @@ -19,11 +19,7 @@ import "C" func loadFunction(handle unsafe.Pointer, name string) unsafe.Pointer { cs := C.CString(name) defer C.free(unsafe.Pointer(cs)) - ptr := C.dlsym(handle, cs) - if ptr == nil { - panic("lib function not found: " + name) - } - return ptr + return C.dlsym(handle, cs) } func loadLib(filepath string) (handle unsafe.Pointer, err error) { diff --git a/pkg/worker/emulator/libretro/manager/manager.go b/pkg/worker/emulator/libretro/manager/manager.go new file mode 100644 index 00000000..a4e853f6 --- /dev/null +++ b/pkg/worker/emulator/libretro/manager/manager.go @@ -0,0 +1,39 @@ +package manager + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "os" + "path/filepath" + "strings" + + "github.com/giongto35/cloud-game/v2/pkg/config/emulator" +) + +type Manager interface { + Sync() error +} + +type BasicManager struct { + Conf emulator.LibretroConfig +} + +func (m BasicManager) GetInstalled() (installed []emulator.CoreInfo, err error) { + dir := m.Conf.GetCoresStorePath() + arch, err := libretro.GetCoreExt() + if err != nil { + return + } + + files, err := os.ReadDir(dir) + if err != nil { + return + } + + for _, file := range files { + name := file.Name() + if filepath.Ext(name) == arch.LibExt { + installed = append(installed, emulator.CoreInfo{Name: strings.TrimSuffix(name, arch.LibExt)}) + } + } + return +} diff --git a/pkg/worker/caged/libretro/manager/downloader.go b/pkg/worker/emulator/libretro/manager/remotehttp/downloader.go similarity index 91% rename from pkg/worker/caged/libretro/manager/downloader.go rename to pkg/worker/emulator/libretro/manager/remotehttp/downloader.go index d3e97409..96b6212b 100644 --- a/pkg/worker/caged/libretro/manager/downloader.go +++ b/pkg/worker/emulator/libretro/manager/remotehttp/downloader.go @@ -1,10 +1,10 @@ -package manager +package remotehttp import ( "os" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/compression" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/compression" ) type Download struct { diff --git a/pkg/worker/caged/libretro/manager/grab.go b/pkg/worker/emulator/libretro/manager/remotehttp/grab.go similarity index 80% rename from pkg/worker/caged/libretro/manager/grab.go rename to pkg/worker/emulator/libretro/manager/remotehttp/grab.go index f7d40a05..7a2b5704 100644 --- a/pkg/worker/caged/libretro/manager/grab.go +++ b/pkg/worker/emulator/libretro/manager/remotehttp/grab.go @@ -1,11 +1,11 @@ -package manager +package remotehttp import ( "crypto/tls" "net/http" "github.com/cavaliergopher/grab/v3" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) type GrabDownloader struct { @@ -47,15 +47,11 @@ func (d GrabDownloader) Request(dest string, urls ...Download) (ok []string, noo r := resp.Request if err := resp.Err(); err != nil { d.log.Error().Err(err).Msgf("download [%s] %s has failed: %v", r.Label, r.URL(), err) - if resp.HTTPResponse != nil && resp.HTTPResponse.StatusCode == 404 { + if resp.HTTPResponse.StatusCode == 404 { nook = append(nook, resp.Request.Label) } } else { - status := "" - if resp.HTTPResponse != nil { - status = resp.HTTPResponse.Status - } - d.log.Info().Msgf("Downloaded [%v] [%s] -> %s", status, r.Label, resp.Filename) + d.log.Info().Msgf("Downloaded [%v] [%s] -> %s", resp.HTTPResponse.Status, r.Label, resp.Filename) ok = append(ok, resp.Filename) } } diff --git a/pkg/worker/caged/libretro/manager/http.go b/pkg/worker/emulator/libretro/manager/remotehttp/manager.go similarity index 56% rename from pkg/worker/caged/libretro/manager/http.go rename to pkg/worker/emulator/libretro/manager/remotehttp/manager.go index ff57a2de..e05b2eb4 100644 --- a/pkg/worker/caged/libretro/manager/http.go +++ b/pkg/worker/emulator/libretro/manager/remotehttp/manager.go @@ -1,63 +1,69 @@ -package manager +package remotehttp import ( - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "os" + + "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/manager" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo" + "github.com/gofrs/flock" ) type Manager struct { - BasicManager + manager.BasicManager - arch ArchInfo - repo Repository - altRepo Repository + arch libretro.ArchInfo + repo repo.Repository + altRepo repo.Repository client Downloader - fmu *os.Flock + fmu *flock.Flock log *logger.Logger } -func NewRemoteHttpManager(conf config.LibretroConfig, log *logger.Logger) Manager { +func NewRemoteHttpManager(conf emulator.LibretroConfig, log *logger.Logger) Manager { repoConf := conf.Cores.Repo.Main altRepoConf := conf.Cores.Repo.Secondary - // used for synchronization of multiple process - flock, err := os.NewFileLock(conf.Cores.Repo.ExtLock) - if err != nil { - log.Error().Err(err).Msgf("couldn't make file lock") + fileLock := conf.Cores.Repo.ExtLock + if fileLock == "" { + fileLock = os.TempDir() + string(os.PathSeparator) + "cloud_game.lock" } + log.Debug().Msgf("Using .lock file: %v", fileLock) - arch, err := conf.Cores.Repo.Guess() + arch, err := libretro.GetCoreExt() if err != nil { log.Error().Err(err).Msg("couldn't get Libretro core file extension") } m := Manager{ - BasicManager: BasicManager{Conf: conf}, - arch: ArchInfo(arch), + BasicManager: manager.BasicManager{Conf: conf}, + arch: arch, client: NewDefaultDownloader(log), - fmu: flock, + fmu: flock.New(fileLock), log: log, } if repoConf.Type != "" { - m.repo = NewRepo(repoConf.Type, repoConf.Url, repoConf.Compression, "buildbot") + m.repo = repo.New(repoConf.Type, repoConf.Url, repoConf.Compression, "buildbot") } if altRepoConf.Type != "" { - m.altRepo = NewRepo(altRepoConf.Type, altRepoConf.Url, altRepoConf.Compression, "") + m.altRepo = repo.New(altRepoConf.Type, altRepoConf.Url, altRepoConf.Compression, "") } return m } -func CheckCores(conf config.Emulator, log *logger.Logger) error { +func CheckCores(conf emulator.Emulator, log *logger.Logger) error { if !conf.Libretro.Cores.Repo.Sync { return nil } log.Info().Msg("Starting Libretro cores sync...") coreManager := NewRemoteHttpManager(conf.Libretro, log) // make a dir for cores - if err := os.MakeDirAll(coreManager.Conf.GetCoresStorePath()); err != nil { + dir := coreManager.Conf.GetCoresStorePath() + if err := os.MkdirAll(dir, os.ModeDir); err != nil { return err } if err := coreManager.Sync(); err != nil { @@ -68,18 +74,10 @@ func CheckCores(conf config.Emulator, log *logger.Logger) error { func (m *Manager) Sync() error { // IPC lock if multiple worker processes on the same machine - err := m.fmu.Lock() - if err != nil { - m.log.Error().Err(err).Msg("file lock fail") - } - defer func() { - err := m.fmu.Unlock() - if err != nil { - m.log.Error().Err(err).Msg("file unlock fail") - } - }() + m.fmu.Lock() + defer m.fmu.Unlock() - installed, err := m.GetInstalled(m.arch.Ext) + installed, err := m.GetInstalled() if err != nil { return err } @@ -90,35 +88,25 @@ func (m *Manager) Sync() error { return nil } -func (m *Manager) getCoreUrls(names []string, repo Repository) (urls []Download) { +func (m *Manager) getCoreUrls(names []string, repo repo.Repository) (urls []Download) { for _, c := range names { - urls = append(urls, Download{Key: c, Address: repo.CoreUrl(c, m.arch)}) + urls = append(urls, Download{Key: c, Address: repo.GetCoreUrl(c, m.arch)}) } return } -func (m *Manager) download(cores []config.CoreInfo) (failed []string) { +func (m *Manager) download(cores []emulator.CoreInfo) (failed []string) { if len(cores) == 0 || m.repo == nil { return } - var prime, second, fail []string + var prime, second []string for _, n := range cores { - if n.Name == "" { - fail = append(fail, n.Id) - continue - } if !n.AltRepo { prime = append(prime, n.Name) } else { second = append(second, n.Name) } } - - if len(prime) == 0 && len(second) == 0 { - m.log.Warn().Msgf("[core-dl] couldn't find info for %v cores, check the config", fail) - return - } - m.log.Info().Msgf("[core-dl] <<< download | main: %v | alt: %v", prime, second) primeFails := m.down(prime, m.repo) if len(primeFails) > 0 && m.altRepo != nil { @@ -135,7 +123,7 @@ func (m *Manager) download(cores []config.CoreInfo) (failed []string) { return } -func (m *Manager) down(cores []string, repo Repository) (failed []string) { +func (m *Manager) down(cores []string, repo repo.Repository) (failed []string) { if len(cores) == 0 || repo == nil { return } @@ -144,7 +132,7 @@ func (m *Manager) down(cores []string, repo Repository) (failed []string) { } // diff returns a list of not installed cores. -func diff(declared, installed []config.CoreInfo) (diff []config.CoreInfo) { +func diff(declared, installed []emulator.CoreInfo) (diff []emulator.CoreInfo) { if len(declared) == 0 { return } diff --git a/pkg/worker/caged/libretro/manager/http_test.go b/pkg/worker/emulator/libretro/manager/remotehttp/manager_test.go similarity index 84% rename from pkg/worker/caged/libretro/manager/http_test.go rename to pkg/worker/emulator/libretro/manager/remotehttp/manager_test.go index 5d447d9c..a06a47d6 100644 --- a/pkg/worker/caged/libretro/manager/http_test.go +++ b/pkg/worker/emulator/libretro/manager/remotehttp/manager_test.go @@ -1,10 +1,10 @@ -package manager +package remotehttp import ( "reflect" "testing" - "github.com/giongto35/cloud-game/v3/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/config/emulator" ) func TestDiff(t *testing.T) { @@ -39,9 +39,9 @@ func TestDiff(t *testing.T) { }, } - toCoreInfo := func(names []string) (r []config.CoreInfo) { + toCoreInfo := func(names []string) (r []emulator.CoreInfo) { for _, n := range names { - r = append(r, config.CoreInfo{Name: n}) + r = append(r, emulator.CoreInfo{Name: n}) } return } diff --git a/pkg/worker/emulator/libretro/nanoarch.c b/pkg/worker/emulator/libretro/nanoarch.c new file mode 100644 index 00000000..a3d7b5b3 --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch.c @@ -0,0 +1,206 @@ +#include "libretro.h" +#include +#include +#include +#include +//#include +//#include + +void coreLog(enum retro_log_level level, const char *msg); + +void bridge_retro_init(void *f) { + coreLog(RETRO_LOG_INFO, "Initialization...\n"); + return ((void (*)(void)) f)(); +} + +void bridge_retro_deinit(void *f) { + coreLog(RETRO_LOG_INFO, "Deinitialiazation...\n"); + return ((void (*)(void)) f)(); +} + +unsigned bridge_retro_api_version(void *f) { + return ((unsigned (*)(void)) f)(); +} + +void bridge_retro_get_system_info(void *f, struct retro_system_info *si) { + return ((void (*)(struct retro_system_info *)) f)(si); +} + +void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si) { + return ((void (*)(struct retro_system_av_info *)) f)(si); +} + +bool bridge_retro_set_environment(void *f, void *callback) { + return ((bool (*)(retro_environment_t)) f)((retro_environment_t) callback); +} + +void bridge_retro_set_video_refresh(void *f, void *callback) { + ((bool (*)(retro_video_refresh_t)) f)((retro_video_refresh_t) callback); +} + +void bridge_retro_set_input_poll(void *f, void *callback) { + ((bool (*)(retro_input_poll_t)) f)((retro_input_poll_t) callback); +} + +void bridge_retro_set_input_state(void *f, void *callback) { + ((bool (*)(retro_input_state_t)) f)((retro_input_state_t) callback); +} + +void bridge_retro_set_audio_sample(void *f, void *callback) { + ((bool (*)(retro_audio_sample_t)) f)((retro_audio_sample_t) callback); +} + +void bridge_retro_set_audio_sample_batch(void *f, void *callback) { + ((bool (*)(retro_audio_sample_batch_t)) f)((retro_audio_sample_batch_t) callback); +} + +bool bridge_retro_load_game(void *f, struct retro_game_info *gi) { + coreLog(RETRO_LOG_INFO, "Loading the game...\n"); + return ((bool (*)(struct retro_game_info *)) f)(gi); +} + +void bridge_retro_unload_game(void *f) { + coreLog(RETRO_LOG_INFO, "Unloading the game...\n"); + return ((void (*)(void)) f)(); +} + +void bridge_retro_run(void *f) { + return ((void (*)(void)) f)(); +} + +size_t bridge_retro_get_memory_size(void *f, unsigned id) { + return ((size_t (*)(unsigned)) f)(id); +} + +void *bridge_retro_get_memory_data(void *f, unsigned id) { + return ((void *(*)(unsigned)) f)(id); +} + +size_t bridge_retro_serialize_size(void *f) { + return ((size_t (*)(void)) f)(); +} + +bool bridge_retro_serialize(void *f, void *data, size_t size) { + return ((bool (*)(void *, size_t)) f)(data, size); +} + +bool bridge_retro_unserialize(void *f, void *data, size_t size) { + return ((bool (*)(void *, size_t)) f)(data, size); +} + +void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device) { + return ((void (*)(unsigned, unsigned)) f)(port, device); +} + +bool coreEnvironment_cgo(unsigned cmd, void *data) { + bool coreEnvironment(unsigned, void *); + return coreEnvironment(cmd, data); +} + +void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch) { + void coreVideoRefresh(void *, unsigned, unsigned, size_t); + return coreVideoRefresh(data, width, height, pitch); +} + +void coreInputPoll_cgo() { + void coreInputPoll(); + return coreInputPoll(); +} + +int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id) { + int16_t coreInputState(unsigned, unsigned, unsigned, unsigned); + return coreInputState(port, device, index, id); +} + +void coreAudioSample_cgo(int16_t left, int16_t right) { + void coreAudioSample(int16_t, int16_t); + coreAudioSample(left, right); +} + +size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames) { + size_t coreAudioSampleBatch(const int16_t *, size_t); + return coreAudioSampleBatch(data, frames); +} + +void coreLog_cgo(enum retro_log_level level, const char *fmt, ...) { + char msg[4096] = {0}; + va_list va; + va_start(va, fmt); + vsnprintf(msg, sizeof(msg), fmt, va); + va_end(va); + coreLog(level, msg); +} + +uintptr_t coreGetCurrentFramebuffer_cgo() { + uintptr_t coreGetCurrentFramebuffer(); + return coreGetCurrentFramebuffer(); +} + +retro_proc_address_t coreGetProcAddress_cgo(const char *sym) { + retro_proc_address_t coreGetProcAddress(const char *sym); + return coreGetProcAddress(sym); +} + +void bridge_context_reset(retro_hw_context_reset_t f) { + f(); +} + +void initVideo_cgo() { + void initVideo(); + return initVideo(); +} + +void deinitVideo_cgo() { + void deinitVideo(); + return deinitVideo(); +} + +void *function; +pthread_t thread; +int initialized = 0; +pthread_mutex_t run_mutex; +pthread_cond_t run_cv; +pthread_mutex_t done_mutex; +pthread_cond_t done_cv; + +// hack: go hangs with run_loop if SIGINT signal, so we handle it here +//static void sig_handler(int _) { +// exit(0); +//} + +void *run_loop(void *unused) { + pthread_mutex_lock(&done_mutex); + pthread_mutex_lock(&run_mutex); + pthread_cond_signal(&done_cv); + pthread_mutex_unlock(&done_mutex); + while (1) { + pthread_cond_wait(&run_cv, &run_mutex); + ((void (*)(void)) function)(); + pthread_mutex_lock(&done_mutex); + pthread_cond_signal(&done_cv); + pthread_mutex_unlock(&done_mutex); + } + pthread_mutex_unlock(&run_mutex); +} + +void bridge_execute(void *f) { + if (!initialized) { + //signal(SIGINT, sig_handler); + initialized = 1; + pthread_mutex_init(&run_mutex, NULL); + pthread_cond_init(&run_cv, NULL); + pthread_mutex_init(&done_mutex, NULL); + pthread_cond_init(&done_cv, NULL); + pthread_mutex_lock(&done_mutex); + pthread_create(&thread, NULL, run_loop, NULL); + pthread_cond_wait(&done_cv, &done_mutex); + pthread_mutex_unlock(&done_mutex); + } + pthread_mutex_lock(&run_mutex); + pthread_mutex_lock(&done_mutex); + function = f; + pthread_cond_signal(&run_cv); + pthread_mutex_unlock(&run_mutex); + pthread_cond_wait(&done_cv, &done_mutex); + pthread_mutex_unlock(&done_mutex); +} diff --git a/pkg/worker/emulator/libretro/nanoarch.go b/pkg/worker/emulator/libretro/nanoarch.go new file mode 100644 index 00000000..86f22caf --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch.go @@ -0,0 +1,736 @@ +package libretro + +import ( + "errors" + "fmt" + "os" + "os/user" + "runtime" + "strings" + "sync" + "time" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/graphics" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/image" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" +) + +/* +#include "libretro.h" +#include "nanoarch.h" +#include +*/ +import "C" + +const lastKey = int(C.RETRO_DEVICE_ID_JOYPAD_R3) + +type ( + nanoarch struct { + v video + multitap multitap + rot *image.Rotate + sysInfo C.struct_retro_system_info + sysAvInfo C.struct_retro_system_av_info + reserved chan struct{} // limits concurrent use + } + video struct { + pixFmt uint32 + bpp uint + hw *C.struct_retro_hw_render_callback + isGl bool + autoGlContext bool + } + multitap struct { + supported bool + enabled bool + value C.unsigned + } + // defines any memory state of the emulator + state []byte + mem struct { + ptr unsafe.Pointer + size uint + } +) + +// Global link for C callbacks to Go +var nano = nanoarch{ + // this thing forbids concurrent use of the emulator + reserved: make(chan struct{}, 1), +} + +var ( + coreConfig *CoreProperties + frontend *Frontend + lastFrameTime int64 + libretroLogger = logger.Default() + sdlCtx *graphics.SDL + usesLibCo bool + cSaveDirectory *C.char + cSystemDirectory *C.char + cUserName *C.char + + initOnce sync.Once +) + +const rawAudioBuffer = 4096 // 4K +var ( + audioCopyPool sync.Pool + audioPool sync.Pool + videoPool sync.Pool +) + +func init() { + nano.reserved <- struct{}{} + usr, err := user.Current() + if err == nil { + cUserName = C.CString(usr.Name) + } else { + cUserName = C.CString("retro") + } +} + +func Init(localPath string) { + initOnce.Do(func() { + cSaveDirectory = C.CString(localPath + string(os.PathSeparator) + "legacy_save") + cSystemDirectory = C.CString(localPath + string(os.PathSeparator) + "system") + }) +} + +//export coreVideoRefresh +func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { + if frontend.stopped.Load() { + libretroLogger.Warn().Msgf(">>> skip video") + return + } + + // some frames can be rendered slower or faster than internal 1/fps core tick + // so track actual frame render time for proper RTP packet timestamps + // (and proper frame display time, for example: 1->1/60=16.6ms, 2->10ms, 3->23ms, 4->16.6ms) + // this is useful only for cores with variable framerate, for the fixed framerate cores this adds stutter + // !to find docs on Libretro refresh sync and frame times + t := time.Now().UnixNano() + dt := t - lastFrameTime + lastFrameTime = t + + // some cores can return nothing + // !to add duplicate if can dup + if data == nil { + return + } + + // calculate real frame width in pixels from packed data (realWidth >= width) + // some cores or games output zero pitch, i.e. N64 Mupen + if packed == 0 { + packed = width + } + // calculate space for the video frame + bytes := packed * height + + var data_ []byte + if data != C.RETRO_HW_FRAME_BUFFER_VALID { + data_ = unsafe.Slice((*byte)(data), bytes) + } else { + // if Libretro renders frame with OpenGL context + data_ = graphics.ReadFramebuffer(bytes, width, height) + } + + // some cores or games have a variable output frame size, i.e. PSX Rearmed + // also we have an option of xN output frame magnification + // so, it may be rescaled + + fr, _ := videoPool.Get().(*emulator.GameFrame) + if fr == nil { + fr = &emulator.GameFrame{} + } + fr.Data = frontend.canvas. + Draw(nano.v.pixFmt, nano.rot, int(width), int(height), int(packed), int(nano.v.bpp), data_, frontend.th) + fr.Duration = time.Duration(dt) + frontend.onVideo(fr) + frontend.canvas.Put(fr.Data) + videoPool.Put(fr) +} + +//export coreInputPoll +func coreInputPoll() {} + +//export coreInputState +func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t { + if port >= maxPort { + return KeyReleased + } + + if device == C.RETRO_DEVICE_ANALOG { + if index > C.RETRO_DEVICE_INDEX_ANALOG_RIGHT || id > C.RETRO_DEVICE_ID_ANALOG_Y { + return 0 + } + axis := index*2 + id + value := frontend.input.isDpadTouched(uint(port), uint(axis)) + if value != 0 { + return (C.int16_t)(value) + } + } + + key := int(id) + if key > lastKey || index > 0 || device != C.RETRO_DEVICE_JOYPAD { + return KeyReleased + } + if frontend.input.isKeyPressed(uint(port), key) == KeyPressed { + return KeyPressed + } + return KeyReleased +} + +func audioWrite(buf unsafe.Pointer, frames C.size_t) C.size_t { + if frontend.stopped.Load() { + libretroLogger.Warn().Msgf(">>> skip audio") + return 0 + } + + samples := int(frames) << 1 + src := unsafe.Slice((*int16)(buf), samples) + dst, _ := audioCopyPool.Get().(*[]int16) + if dst == nil { + x := make([]int16, rawAudioBuffer) + dst = &x + } + xx := (*dst)[:samples] + copy(xx, src) + + // 1600 = x / 1000 * 48000 * 2 + estimate := float64(samples) / float64(int(nano.sysAvInfo.timing.sample_rate)<<1) * 1000000000 + + fr, _ := audioPool.Get().(*emulator.GameAudio) + if fr == nil { + fr = &emulator.GameAudio{} + } + fr.Data = &xx + fr.Duration = time.Duration(estimate) // used in recordings + frontend.onAudio(fr) + audioPool.Put(fr) + audioCopyPool.Put(dst) + + return frames +} + +//export coreAudioSample +func coreAudioSample(left C.int16_t, right C.int16_t) { + buf := []C.int16_t{left, right} + audioWrite(unsafe.Pointer(&buf), 1) +} + +//export coreAudioSampleBatch +func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t { + return audioWrite(data, frames) +} + +func m(m *C.char) string { return strings.TrimRight(C.GoString(m), "\n") } + +//export coreLog +func coreLog(level C.enum_retro_log_level, msg *C.char) { + switch int(level) { + // with debug level cores have too much logs + case 0: // RETRO_LOG_DEBUG + libretroLogger.Debug().MsgFunc(func() string { return m(msg) }) + case 1: // RETRO_LOG_INFO + libretroLogger.Info().MsgFunc(func() string { return m(msg) }) + case 2: // RETRO_LOG_WARN + libretroLogger.Warn().MsgFunc(func() string { return m(msg) }) + case 3: // RETRO_LOG_ERROR + libretroLogger.Error().MsgFunc(func() string { return m(msg) }) + default: + libretroLogger.Log().MsgFunc(func() string { return m(msg) }) + // RETRO_LOG_DUMMY = INT_MAX + } +} + +//export coreGetCurrentFramebuffer +func coreGetCurrentFramebuffer() C.uintptr_t { return (C.uintptr_t)(graphics.GetGlFbo()) } + +//export coreGetProcAddress +func coreGetProcAddress(sym *C.char) C.retro_proc_address_t { + return (C.retro_proc_address_t)(graphics.GetGlProcAddress(C.GoString(sym))) +} + +//export coreEnvironment +func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { + switch cmd { + case C.RETRO_ENVIRONMENT_GET_USERNAME: + *(**C.char)(data) = cUserName + case C.RETRO_ENVIRONMENT_GET_LOG_INTERFACE: + cb := (*C.struct_retro_log_callback)(data) + cb.log = (C.retro_log_printf_t)(C.coreLog_cgo) + case C.RETRO_ENVIRONMENT_GET_CAN_DUPE: + *(*C.bool)(data) = C.bool(true) + case C.RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: + res, err := videoSetPixelFormat(*(*C.enum_retro_pixel_format)(data)) + if err != nil { + libretroLogger.Fatal().Err(err).Msg("pix format failed") + } + return res + case C.RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: + *(**C.char)(data) = cSystemDirectory + return true + case C.RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: + *(**C.char)(data) = cSaveDirectory + return true + case C.RETRO_ENVIRONMENT_SHUTDOWN: + //window.SetShouldClose(true) + return true + /* + Sets screen rotation of graphics. + Valid values are 0, 1, 2, 3, which rotates screen by 0, 90, 180, 270 degrees + ccw respectively. + */ + case C.RETRO_ENVIRONMENT_SET_ROTATION: + setRotation(*(*uint)(data) % 4) + return true + case C.RETRO_ENVIRONMENT_GET_VARIABLE: + variable := (*C.struct_retro_variable)(data) + key := C.GoString(variable.key) + if val, ok := coreConfig.Get(key); ok { + variable.value = (*C.char)(val) + libretroLogger.Debug().Msgf("Set %s=%v", key, C.GoString(variable.value)) + return true + } + return false + case C.RETRO_ENVIRONMENT_SET_HW_RENDER: + if nano.v.isGl { + nano.v.hw = (*C.struct_retro_hw_render_callback)(data) + nano.v.hw.get_current_framebuffer = (C.retro_hw_get_current_framebuffer_t)(C.coreGetCurrentFramebuffer_cgo) + nano.v.hw.get_proc_address = (C.retro_hw_get_proc_address_t)(C.coreGetProcAddress_cgo) + return true + } + return false + case C.RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: + if !nano.multitap.supported { + return false + } + info := (*[100]C.struct_retro_controller_info)(data) + var i C.unsigned + for i = 0; unsafe.Pointer(info[i].types) != nil; i++ { + var j C.unsigned + types := (*[100]C.struct_retro_controller_description)(unsafe.Pointer(info[i].types)) + for j = 0; j < info[i].num_types; j++ { + if C.GoString(types[j].desc) == "Multitap" { + nano.multitap.value = types[j].id + return true + } + } + } + return false + default: + return false + } + return true +} + +//export initVideo +func initVideo() { + var context graphics.Context + switch nano.v.hw.context_type { + case C.RETRO_HW_CONTEXT_NONE: + context = graphics.CtxNone + case C.RETRO_HW_CONTEXT_OPENGL: + context = graphics.CtxOpenGl + case C.RETRO_HW_CONTEXT_OPENGLES2: + context = graphics.CtxOpenGlEs2 + case C.RETRO_HW_CONTEXT_OPENGL_CORE: + context = graphics.CtxOpenGlCore + case C.RETRO_HW_CONTEXT_OPENGLES3: + context = graphics.CtxOpenGlEs3 + case C.RETRO_HW_CONTEXT_OPENGLES_VERSION: + context = graphics.CtxOpenGlEsVersion + case C.RETRO_HW_CONTEXT_VULKAN: + context = graphics.CtxVulkan + case C.RETRO_HW_CONTEXT_DUMMY: + context = graphics.CtxDummy + default: + context = graphics.CtxUnknown + } + + sdl, err := graphics.NewSDLContext(graphics.Config{ + Ctx: context, + W: int(nano.sysAvInfo.geometry.max_width), + H: int(nano.sysAvInfo.geometry.max_height), + GLAutoContext: nano.v.autoGlContext, + GLVersionMajor: uint(nano.v.hw.version_major), + GLVersionMinor: uint(nano.v.hw.version_minor), + GLHasDepth: bool(nano.v.hw.depth), + GLHasStencil: bool(nano.v.hw.stencil), + }, libretroLogger) + if err != nil { + panic(err) + } + sdlCtx = sdl + + C.bridge_context_reset(nano.v.hw.context_reset) + if libretroLogger.GetLevel() < logger.InfoLevel { + printOpenGLDriverInfo() + } +} + +//export deinitVideo +func deinitVideo() { + C.bridge_context_reset(nano.v.hw.context_destroy) + if err := sdlCtx.Deinit(); err != nil { + libretroLogger.Error().Err(err).Msg("deinit fail") + } + nano.v.isGl = false + nano.v.autoGlContext = false +} + +var ( + //retroAPIVersion unsafe.Pointer + retroDeinit unsafe.Pointer + retroGetSystemAVInfo unsafe.Pointer + retroGetSystemInfo unsafe.Pointer + coreLib unsafe.Pointer + retroInit unsafe.Pointer + retroLoadGame unsafe.Pointer + retroRun unsafe.Pointer + retroSetAudioSample unsafe.Pointer + retroSetAudioSampleBatch unsafe.Pointer + retroSetControllerPortDevice unsafe.Pointer + retroSetEnvironment unsafe.Pointer + retroSetInputPoll unsafe.Pointer + retroSetInputState unsafe.Pointer + retroSetVideoRefresh unsafe.Pointer + retroUnloadGame unsafe.Pointer + retroGetMemoryData unsafe.Pointer + retroGetMemorySize unsafe.Pointer + retroSerialize unsafe.Pointer + retroSerializeSize unsafe.Pointer + retroUnserialize unsafe.Pointer +) + +func SetLibretroLogger(log *logger.Logger) { libretroLogger = log } + +func coreLoad(meta emulator.Metadata) { + var err error + nano.v.isGl = meta.IsGlAllowed + usesLibCo = meta.UsesLibCo + nano.v.autoGlContext = meta.AutoGlContext + coreConfig, err = ReadProperties(meta.ConfigPath) + if err != nil { + libretroLogger.Warn().Err(err).Msg("config scan has been failed") + } + + nano.multitap.supported = meta.HasMultitap + nano.multitap.enabled = false + nano.multitap.value = 0 + + filePath := meta.LibPath + if arch, err := GetCoreExt(); err == nil { + filePath = filePath + arch.LibExt + } else { + libretroLogger.Warn().Err(err).Msg("system arch guesser failed") + } + + coreLib, err = loadLib(filePath) + // fallback to sequential lib loader (first successfully loaded) + if err != nil { + coreLib, err = loadLibRollingRollingRolling(filePath) + if err != nil { + libretroLogger.Fatal().Err(err).Msgf("core load: %s, %v", filePath, err) + } + } + + retroInit = loadFunction(coreLib, "retro_init") + retroDeinit = loadFunction(coreLib, "retro_deinit") + //retroAPIVersion = loadFunction(coreLib, "retro_api_version") + retroGetSystemInfo = loadFunction(coreLib, "retro_get_system_info") + retroGetSystemAVInfo = loadFunction(coreLib, "retro_get_system_av_info") + retroSetEnvironment = loadFunction(coreLib, "retro_set_environment") + retroSetVideoRefresh = loadFunction(coreLib, "retro_set_video_refresh") + retroSetInputPoll = loadFunction(coreLib, "retro_set_input_poll") + retroSetInputState = loadFunction(coreLib, "retro_set_input_state") + retroSetAudioSample = loadFunction(coreLib, "retro_set_audio_sample") + retroSetAudioSampleBatch = loadFunction(coreLib, "retro_set_audio_sample_batch") + retroRun = loadFunction(coreLib, "retro_run") + retroLoadGame = loadFunction(coreLib, "retro_load_game") + retroUnloadGame = loadFunction(coreLib, "retro_unload_game") + retroSerializeSize = loadFunction(coreLib, "retro_serialize_size") + retroSerialize = loadFunction(coreLib, "retro_serialize") + retroUnserialize = loadFunction(coreLib, "retro_unserialize") + retroSetControllerPortDevice = loadFunction(coreLib, "retro_set_controller_port_device") + retroGetMemorySize = loadFunction(coreLib, "retro_get_memory_size") + retroGetMemoryData = loadFunction(coreLib, "retro_get_memory_data") + + C.bridge_retro_set_environment(retroSetEnvironment, C.coreEnvironment_cgo) + C.bridge_retro_set_video_refresh(retroSetVideoRefresh, C.coreVideoRefresh_cgo) + C.bridge_retro_set_input_poll(retroSetInputPoll, C.coreInputPoll_cgo) + C.bridge_retro_set_input_state(retroSetInputState, C.coreInputState_cgo) + C.bridge_retro_set_audio_sample(retroSetAudioSample, C.coreAudioSample_cgo) + C.bridge_retro_set_audio_sample_batch(retroSetAudioSampleBatch, C.coreAudioSampleBatch_cgo) + + C.bridge_retro_init(retroInit) + + C.bridge_retro_get_system_info(retroGetSystemInfo, &nano.sysInfo) + libretroLogger.Debug().Msgf("System >>> %s (%s) [%s] nfp: %v", + C.GoString(nano.sysInfo.library_name), C.GoString(nano.sysInfo.library_version), + C.GoString(nano.sysInfo.valid_extensions), bool(nano.sysInfo.need_fullpath)) +} + +func LoadGame(path string) error { + lastFrameTime = 0 + + fi, err := os.Stat(path) + if err != nil { + return err + } + fileSize := fi.Size() + libretroLogger.Debug().Msgf("ROM size: %v", byteCountBinary(fileSize)) + + fPath := C.CString(path) + defer C.free(unsafe.Pointer(fPath)) + gi := C.struct_retro_game_info{path: fPath, size: C.size_t(fileSize)} + + if !bool(nano.sysInfo.need_fullpath) { + bytes, err := os.ReadFile(path) + if err != nil { + return err + } + dat := C.CString(string(bytes)) + gi.data = unsafe.Pointer(dat) + defer C.free(unsafe.Pointer(dat)) + } + + if ok := C.bridge_retro_load_game(retroLoadGame, &gi); !ok { + return fmt.Errorf("core failed to load ROM: %v", path) + } + + C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &nano.sysAvInfo) + libretroLogger.Info().Msgf("System A/V >>> %vx%v (%vx%v), [%vfps], AR [%v], audio [%vHz]", + nano.sysAvInfo.geometry.base_width, nano.sysAvInfo.geometry.base_height, + nano.sysAvInfo.geometry.max_width, nano.sysAvInfo.geometry.max_height, + nano.sysAvInfo.timing.fps, nano.sysAvInfo.geometry.aspect_ratio, nano.sysAvInfo.timing.sample_rate, + ) + + if nano.v.isGl { + // flip Y coordinates of OpenGL + setRotation(uint(image.Flip180)) + bufS := uint(nano.sysAvInfo.geometry.max_width*nano.sysAvInfo.geometry.max_height) * nano.v.bpp + graphics.SetBuffer(int(bufS)) + libretroLogger.Info().Msgf("Set buffer: %v", byteCountBinary(int64(bufS))) + if usesLibCo { + C.bridge_execute(C.initVideo_cgo) + } else { + runtime.LockOSThread() + initVideo() + runtime.UnlockOSThread() + } + } + + // set default controller types on all ports + for i := 0; i < maxPort; i++ { + C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD) + } + + return nil +} + +func toggleMultitap() { + if nano.multitap.supported && nano.multitap.value != 0 { + // Official SNES games only support a single multitap device + // Most require it to be plugged in player 2 port + // And Snes9X requires it to be "plugged" after the game is loaded + // Control this from the browser since player 2 will stop working in some games if multitap is "plugged" in + if nano.multitap.enabled { + C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, C.RETRO_DEVICE_JOYPAD) + } else { + C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, nano.multitap.value) + } + nano.multitap.enabled = !nano.multitap.enabled + } +} + +func nanoarchShutdown() { + if usesLibCo { + thread.Main(func() { + C.bridge_execute(retroUnloadGame) + C.bridge_execute(retroDeinit) + if nano.v.isGl { + C.bridge_execute(C.deinitVideo_cgo) + } + }) + } else { + if nano.v.isGl { + thread.Main(func() { + // running inside a go routine, lock the thread to make sure the OpenGL context stays current + runtime.LockOSThread() + if err := sdlCtx.BindContext(); err != nil { + libretroLogger.Error().Err(err).Msg("ctx switch fail") + } + }) + } + C.bridge_retro_unload_game(retroUnloadGame) + C.bridge_retro_deinit(retroDeinit) + if nano.v.isGl { + thread.Main(func() { + deinitVideo() + runtime.UnlockOSThread() + }) + } + } + + setRotation(0) + if err := closeLib(coreLib); err != nil { + libretroLogger.Error().Err(err).Msg("lib close failed") + } + coreConfig.Free() +} + +func run() { + if usesLibCo { + C.bridge_execute(retroRun) + } else { + if nano.v.isGl { + // running inside a go routine, lock the thread to make sure the OpenGL context stays current + runtime.LockOSThread() + if err := sdlCtx.BindContext(); err != nil { + libretroLogger.Error().Err(err).Msg("ctx bind fail") + } + } + C.bridge_retro_run(retroRun) + if nano.v.isGl { + runtime.UnlockOSThread() + } + } +} + +func videoSetPixelFormat(format uint32) (C.bool, error) { + switch format { + case C.RETRO_PIXEL_FORMAT_0RGB1555: + nano.v.pixFmt = image.BitFormatShort5551 + if err := graphics.SetPixelFormat(graphics.UnsignedShort5551); err != nil { + return false, fmt.Errorf("unknown pixel format %v", nano.v.pixFmt) + } + nano.v.bpp = 2 + // format is not implemented + return false, fmt.Errorf("unsupported pixel type %v converter", format) + case C.RETRO_PIXEL_FORMAT_XRGB8888: + nano.v.pixFmt = image.BitFormatInt8888Rev + if err := graphics.SetPixelFormat(graphics.UnsignedInt8888Rev); err != nil { + return false, fmt.Errorf("unknown pixel format %v", nano.v.pixFmt) + } + nano.v.bpp = 4 + case C.RETRO_PIXEL_FORMAT_RGB565: + nano.v.pixFmt = image.BitFormatShort565 + if err := graphics.SetPixelFormat(graphics.UnsignedShort565); err != nil { + return false, fmt.Errorf("unknown pixel format %v", nano.v.pixFmt) + } + nano.v.bpp = 2 + default: + return false, fmt.Errorf("unknown pixel type %v", format) + } + return true, nil +} + +func setRotation(rotation uint) { + if nano.rot != nil && rotation == uint(nano.rot.Angle) { + return + } + if rotation > 0 { + r := image.GetRotation(image.Angle(rotation)) + nano.rot = &r + } else { + nano.rot = nil + } + libretroLogger.Debug().Msgf("Image rotated %v°", map[uint]uint{0: 0, 1: 90, 2: 180, 3: 270}[rotation]) +} + +func printOpenGLDriverInfo() { + var openGLInfo strings.Builder + openGLInfo.Grow(128) + openGLInfo.WriteString(fmt.Sprintf("\n[OpenGL] Version: %v\n", graphics.GetGLVersionInfo())) + openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Vendor: %v\n", graphics.GetGLVendorInfo())) + // This string is often the name of the GPU. + // In the case of Mesa3d, it would be i.e "Gallium 0.4 on NVA8". + // It might even say "Direct3D" if the Windows Direct3D wrapper is being used. + openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Renderer: %v\n", graphics.GetGLRendererInfo())) + openGLInfo.WriteString(fmt.Sprintf("[OpenGL] GLSL Version: %v", graphics.GetGLSLInfo())) + libretroLogger.Debug().Msg(openGLInfo.String()) +} + +// saveStateSize returns the amount of data the implementation requires +// to serialize internal state (save states). +func saveStateSize() uint { return uint(C.bridge_retro_serialize_size(retroSerializeSize)) } + +// getSaveState returns emulator internal state. +func getSaveState() (state, error) { + size := saveStateSize() + data := C.malloc(C.size_t(size)) + defer C.free(data) + if !bool(C.bridge_retro_serialize(retroSerialize, data, C.size_t(size))) { + return nil, errors.New("retro_serialize failed") + } + return C.GoBytes(data, C.int(size)), nil +} + +// restoreSaveState restores emulator internal state. +func restoreSaveState(st state) error { + if len(st) == 0 { + return nil + } + size := saveStateSize() + if !bool(C.bridge_retro_unserialize(retroUnserialize, unsafe.Pointer(&st[0]), C.size_t(size))) { + return errors.New("retro_unserialize failed") + } + return nil +} + +// getSaveRAM returns the game save RAM (cartridge) data or a nil slice. +func getSaveRAM() state { + memory := ptSaveRAM() + if memory == nil { + return nil + } + return C.GoBytes(memory.ptr, C.int(memory.size)) +} + +// restoreSaveRAM restores game save RAM. +func restoreSaveRAM(st state) { + if len(st) == 0 { + return + } + if memory := ptSaveRAM(); memory != nil { + sram := (*[1 << 30]byte)(memory.ptr)[:memory.size:memory.size] + copy(sram, st) + } +} + +// getMemorySize returns memory region size. +func getMemorySize(id uint) uint { + return uint(C.bridge_retro_get_memory_size(retroGetMemorySize, C.uint(id))) +} + +// getMemoryData returns a pointer to memory data. +func getMemoryData(id uint) unsafe.Pointer { + return C.bridge_retro_get_memory_data(retroGetMemoryData, C.uint(id)) +} + +// ptSaveRam return SRAM memory pointer if core supports it or nil. +func ptSaveRAM() *mem { + ptr, size := getMemoryData(C.RETRO_MEMORY_SAVE_RAM), getMemorySize(C.RETRO_MEMORY_SAVE_RAM) + if ptr == nil || size == 0 { + return nil + } + return &mem{ptr: ptr, size: size} +} + +func byteCountBinary(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/pkg/worker/emulator/libretro/nanoarch.h b/pkg/worker/emulator/libretro/nanoarch.h new file mode 100644 index 00000000..d049e9e4 --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch.h @@ -0,0 +1,38 @@ +#ifndef FRONTEND_H__ +#define FRONTEND_H__ + +bool bridge_retro_load_game(void *f, struct retro_game_info *gi); +bool bridge_retro_serialize(void *f, void *data, size_t size); +bool bridge_retro_set_environment(void *f, void *callback); +bool bridge_retro_unserialize(void *f, void *data, size_t size); +bool coreEnvironment_cgo(unsigned cmd, void *data); +int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id); +retro_proc_address_t coreGetProcAddress_cgo(const char *sym); +size_t bridge_retro_get_memory_size(void *f, unsigned id); +size_t bridge_retro_serialize_size(void *f); +size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames); +uintptr_t coreGetCurrentFramebuffer_cgo(); +unsigned bridge_retro_api_version(void *f); +void *bridge_retro_get_memory_data(void *f, unsigned id); +void bridge_context_reset(retro_hw_context_reset_t f); +void bridge_execute(void *f); +void bridge_retro_deinit(void *f); +void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si); +void bridge_retro_get_system_info(void *f, struct retro_system_info *si); +void bridge_retro_init(void *f); +void bridge_retro_run(void *f); +void bridge_retro_set_audio_sample(void *f, void *callback); +void bridge_retro_set_audio_sample_batch(void *f, void *callback); +void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device); +void bridge_retro_set_input_poll(void *f, void *callback); +void bridge_retro_set_input_state(void *f, void *callback); +void bridge_retro_set_video_refresh(void *f, void *callback); +void bridge_retro_unload_game(void *f); +void coreAudioSample_cgo(int16_t left, int16_t right); +void coreInputPoll_cgo(); +void coreLog_cgo(int level, const char *msg); +void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch); +void deinitVideo_cgo(); +void initVideo_cgo(); + +#endif diff --git a/pkg/worker/emulator/libretro/nanoarch_test.go b/pkg/worker/emulator/libretro/nanoarch_test.go new file mode 100644 index 00000000..876d3e50 --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch_test.go @@ -0,0 +1,224 @@ +package libretro + +import ( + "crypto/md5" + "fmt" + "io" + "log" + "os" + "path" + "path/filepath" + "testing" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" +) + +type testRun struct { + room string + system string + rom string + emulationTicks int +} + +// EmulatorMock contains Frontend mocking data. +type EmulatorMock struct { + Frontend + + // Libretro compiled lib core name + core string + // shared core paths (can't be changed) + paths EmulatorPaths +} + +// EmulatorPaths defines various emulator file paths. +type EmulatorPaths struct { + assets string + cores string + games string + save string +} + +// GetEmulatorMock returns a properly stubbed emulator instance. +// Due to extensive use of globals -- one mock instance is allowed per a test run. +// Don't forget to init one image channel consumer, it will lock-out otherwise. +// Make sure you call shutdownEmulator(). +func GetEmulatorMock(room string, system string) *EmulatorMock { + rootPath := getRootPath() + configPath := rootPath + "configs/" + + var conf worker.Config + if err := config.LoadConfig(&conf, configPath); err != nil { + panic(err) + } + + meta := conf.Emulator.GetLibretroCoreConfig(system) + + l := logger.Default() + SetLibretroLogger(l.Extend(l.Level(logger.ErrorLevel).With())) + + // an emu + emu := &EmulatorMock{ + Frontend: Frontend{ + conf: conf.Emulator, + storage: &StateStorage{ + Path: os.TempDir(), + MainSave: room, + }, + input: NewGameSessionInput(), + done: make(chan struct{}), + th: conf.Emulator.Threads, + }, + + core: path.Base(meta.Lib), + + paths: EmulatorPaths{ + assets: cleanPath(rootPath), + cores: cleanPath(rootPath + "assets/cores/"), + games: cleanPath(rootPath + "assets/games/"), + }, + } + + emu.paths.save = cleanPath(emu.GetHashPath()) + frontend = &emu.Frontend + + Init(cleanPath(conf.Emulator.LocalPath)) + + return emu +} + +// GetDefaultEmulatorMock returns initialized emulator mock with default params. +// Spawns audio/image channels consumers. +// Don't forget to close emulator mock with shutdownEmulator(). +func GetDefaultEmulatorMock(room string, system string, rom string) *EmulatorMock { + mock := GetEmulatorMock(room, system) + mock.loadRom(rom) + mock.handleVideo(func(_ *emulator.GameFrame) {}) + mock.handleAudio(func(_ *emulator.GameAudio) {}) + + return mock +} + +// loadRom loads a ROM into the emulator. +// The rom will be loaded from emulators' games path. +func (emu *EmulatorMock) loadRom(game string) { + fmt.Printf("%v %v\n", emu.paths.cores, emu.core) + coreLoad(emulator.Metadata{LibPath: emu.paths.cores + emu.core}) + err := LoadGame(emu.paths.games + game) + if err != nil { + log.Fatal(err) + } + emu.SetViewport(emu.GetFrameSize()) +} + +// shutdownEmulator closes the emulator and cleans its resources. +func (emu *EmulatorMock) shutdownEmulator() { + _ = os.Remove(emu.GetHashPath()) + _ = os.Remove(emu.GetSRAMPath()) + + nanoarchShutdown() +} + +// emulateOneFrame emulates one frame with exclusive lock. +func (emu *EmulatorMock) emulateOneFrame() { + emu.mu.Lock() + run() + emu.mu.Unlock() +} + +// Who needs generics anyway? +// handleVideo is a custom message handler for the video channel. +func (emu *EmulatorMock) handleVideo(handler func(image *emulator.GameFrame)) { + emu.Frontend.onVideo = handler +} + +// handleAudio is a custom message handler for the audio channel. +func (emu *EmulatorMock) handleAudio(handler func(sample *emulator.GameAudio)) { + emu.Frontend.onAudio = handler +} + +// dumpState returns the current emulator state and +// the latest saved state for its session. +// Locks the emulator. +func (emu *EmulatorMock) dumpState() (string, string) { + emu.mu.Lock() + bytes, _ := os.ReadFile(emu.paths.save) + persistedStateHash := getHash(bytes) + emu.mu.Unlock() + + stateHash := emu.getStateHash() + fmt.Printf("mem: %v, dat: %v\n", stateHash, persistedStateHash) + return stateHash, persistedStateHash +} + +// getStateHash returns the current emulator state hash. +// Locks the emulator. +func (emu *EmulatorMock) getStateHash() string { + emu.mu.Lock() + state, _ := getSaveState() + emu.mu.Unlock() + + return getHash(state) +} + +func (emu *EmulatorMock) Close() { + emu.Frontend.Close() + <-nano.reserved +} + +// getRootPath returns absolute path to the root directory. +func getRootPath() string { + p, _ := filepath.Abs("../../../../") + return p + string(filepath.Separator) +} + +// getHash returns MD5 hash. +func getHash(bytes []byte) string { + return fmt.Sprintf("%x", md5.Sum(bytes)) +} + +// cleanPath returns a proper file path for current OS. +func cleanPath(path string) string { + return filepath.FromSlash(path) +} + +// benchmarkEmulator is a generic function for +// measuring emulator performance for one emulation frame. +func benchmarkEmulator(system string, rom string, b *testing.B) { + b.StopTimer() + log.SetOutput(io.Discard) + os.Stdout, _ = os.Open(os.DevNull) + libretroLogger = logger.New(false) + + s := GetDefaultEmulatorMock("bench_"+system+"_performance", system, rom) + + b.StartTimer() + for i := 0; i < b.N; i++ { + s.emulateOneFrame() + } + s.shutdownEmulator() +} + +func BenchmarkEmulatorGba(b *testing.B) { + benchmarkEmulator("gba", "Sushi The Cat.gba", b) +} + +func BenchmarkEmulatorNes(b *testing.B) { + benchmarkEmulator("nes", "Super Mario Bros.nes", b) +} + +func TestSwap(t *testing.T) { + data := []byte{1, 254, 255, 32} + pixel := *(*uint32)(unsafe.Pointer(&data[0])) + // 0 1 2 3 + // 2 1 0 3 + ll := ((pixel >> 16) & 0xff) | (pixel & 0xff00) | ((pixel << 16) & 0xff0000) | 0xff000000 + + rez := []byte{0, 0, 0, 0} + *(*uint32)(unsafe.Pointer(&rez[0])) = ll + + log.Printf("%v\n%v", data, rez) +} diff --git a/pkg/worker/emulator/libretro/properties.go b/pkg/worker/emulator/libretro/properties.go new file mode 100644 index 00000000..7d8580cf --- /dev/null +++ b/pkg/worker/emulator/libretro/properties.go @@ -0,0 +1,71 @@ +package libretro + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + "unsafe" +) + +// #include +import "C" + +type CoreProperties struct { + m map[string]*C.char + mu sync.Mutex +} + +func ReadProperties(filename string) (*CoreProperties, error) { + config := CoreProperties{ + m: make(map[string]*C.char), + } + + if len(filename) == 0 { + return &config, nil + } + + file, err := os.Open(filename) + if err != nil { + return &config, fmt.Errorf("couldn't find the %v config file", filename) + } + defer func() { + _ = file.Close() + }() + + scanner := bufio.NewScanner(file) + config.mu.Lock() + defer config.mu.Unlock() + for scanner.Scan() { + line := scanner.Text() + if equal := strings.Index(line, "="); equal >= 0 { + if key := strings.TrimSpace(line[:equal]); len(key) > 0 { + value := "" + if len(line) > equal { + value = strings.TrimSpace(line[equal+1:]) + } + config.m[key] = C.CString(value) + } + } + } + if err := scanner.Err(); err != nil { + panic(err) + } + return &config, nil +} + +func (c *CoreProperties) Get(key string) (*C.char, bool) { + c.mu.Lock() + defer c.mu.Unlock() + v, ok := c.m[key] + return v, ok +} + +func (c *CoreProperties) Free() { + c.mu.Lock() + for _, element := range c.m { + C.free(unsafe.Pointer(element)) + } + c.mu.Unlock() +} diff --git a/pkg/worker/emulator/libretro/repo/buildbot/repository.go b/pkg/worker/emulator/libretro/repo/buildbot/repository.go new file mode 100644 index 00000000..aa2fba6e --- /dev/null +++ b/pkg/worker/emulator/libretro/repo/buildbot/repository.go @@ -0,0 +1,34 @@ +package buildbot + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "strings" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/raw" +) + +type RepoBuildbot struct { + raw.Repo +} + +func NewBuildbotRepo(address string, compression string) RepoBuildbot { + return RepoBuildbot{ + Repo: raw.Repo{ + Address: address, + Compression: compression, + }, + } +} + +func (r RepoBuildbot) GetCoreUrl(file string, info libretro.ArchInfo) string { + var sb strings.Builder + sb.WriteString(r.Address + "/") + if info.Vendor != "" { + sb.WriteString(info.Vendor + "/") + } + sb.WriteString(info.Os + "/" + info.Arch + "/latest/" + file + info.LibExt) + if r.Compression != "" { + sb.WriteString("." + r.Compression) + } + return sb.String() +} diff --git a/pkg/worker/emulator/libretro/repo/buildbot/repository_test.go b/pkg/worker/emulator/libretro/repo/buildbot/repository_test.go new file mode 100644 index 00000000..ff79f9a0 --- /dev/null +++ b/pkg/worker/emulator/libretro/repo/buildbot/repository_test.go @@ -0,0 +1,55 @@ +package buildbot + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "testing" +) + +func TestBuildbotRepo(t *testing.T) { + testAddress := "https://test.me" + tests := []struct { + file string + compression string + arch libretro.ArchInfo + resultUrl string + }{ + { + file: "uber_core", + arch: libretro.ArchInfo{ + Os: "linux", + Arch: "x86_64", + LibExt: ".so", + }, + resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so", + }, + { + file: "uber_core", + compression: "zip", + arch: libretro.ArchInfo{ + Os: "linux", + Arch: "x86_64", + LibExt: ".so", + }, + resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip", + }, + { + file: "uber_core", + arch: libretro.ArchInfo{ + Os: "osx", + Arch: "x86_64", + Vendor: "apple", + LibExt: ".dylib", + }, + resultUrl: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib", + }, + } + + for _, test := range tests { + repo := NewBuildbotRepo(testAddress, test.compression) + url := repo.GetCoreUrl(test.file, test.arch) + if url != test.resultUrl { + t.Errorf("seems that expected link address is incorrect (%v) for file %s %+v", + url, test.file, test.arch) + } + } +} diff --git a/pkg/worker/emulator/libretro/repo/github/repository.go b/pkg/worker/emulator/libretro/repo/github/repository.go new file mode 100644 index 00000000..c27c917d --- /dev/null +++ b/pkg/worker/emulator/libretro/repo/github/repository.go @@ -0,0 +1,18 @@ +package github + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/buildbot" +) + +type RepoGithub struct { + buildbot.RepoBuildbot +} + +func NewGithubRepo(address string, compression string) RepoGithub { + return RepoGithub{RepoBuildbot: buildbot.NewBuildbotRepo(address, compression)} +} + +func (r RepoGithub) GetCoreUrl(file string, info libretro.ArchInfo) string { + return r.RepoBuildbot.GetCoreUrl(file, info) + "?raw=true" +} diff --git a/pkg/worker/emulator/libretro/repo/github/repository_test.go b/pkg/worker/emulator/libretro/repo/github/repository_test.go new file mode 100644 index 00000000..30eb402c --- /dev/null +++ b/pkg/worker/emulator/libretro/repo/github/repository_test.go @@ -0,0 +1,55 @@ +package github + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "testing" +) + +func TestBuildbotRepo(t *testing.T) { + testAddress := "https://test.me" + tests := []struct { + file string + compression string + arch libretro.ArchInfo + resultUrl string + }{ + { + file: "uber_core", + arch: libretro.ArchInfo{ + Os: "linux", + Arch: "x86_64", + LibExt: ".so", + }, + resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so?raw=true", + }, + { + file: "uber_core", + compression: "zip", + arch: libretro.ArchInfo{ + Os: "linux", + Arch: "x86_64", + LibExt: ".so", + }, + resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip?raw=true", + }, + { + file: "uber_core", + arch: libretro.ArchInfo{ + Os: "osx", + Arch: "x86_64", + Vendor: "apple", + LibExt: ".dylib", + }, + resultUrl: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib?raw=true", + }, + } + + for _, test := range tests { + repo := NewGithubRepo(testAddress, test.compression) + url := repo.GetCoreUrl(test.file, test.arch) + if url != test.resultUrl { + t.Errorf("seems that expected link address is incorrect (%v) for file %s %+v", + url, test.file, test.arch) + } + } +} diff --git a/pkg/worker/emulator/libretro/repo/raw/repository.go b/pkg/worker/emulator/libretro/repo/raw/repository.go new file mode 100644 index 00000000..b215a31a --- /dev/null +++ b/pkg/worker/emulator/libretro/repo/raw/repository.go @@ -0,0 +1,20 @@ +package raw + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" +) + +type Repo struct { + Address string + Compression string +} + +// NewRawRepo defines a simple zip file containing +// all the cores that will be extracted as is. +func NewRawRepo(address string) Repo { + return Repo{Address: address, Compression: "zip"} +} + +func (r Repo) GetCoreUrl(_ string, _ libretro.ArchInfo) string { + return r.Address +} diff --git a/pkg/worker/emulator/libretro/repo/repository.go b/pkg/worker/emulator/libretro/repo/repository.go new file mode 100644 index 00000000..a7857742 --- /dev/null +++ b/pkg/worker/emulator/libretro/repo/repository.go @@ -0,0 +1,36 @@ +package repo + +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/buildbot" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/github" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/raw" +) + +type ( + Data struct { + Url string + Compression string + } + + Repository interface { + GetCoreUrl(file string, info libretro.ArchInfo) (url string) + } +) + +func New(kind string, url string, compression string, defaultRepo string) Repository { + var repository Repository + switch kind { + case "raw": + repository = raw.NewRawRepo(url) + case "github": + repository = github.NewGithubRepo(url, compression) + case "buildbot": + repository = buildbot.NewBuildbotRepo(url, compression) + default: + if defaultRepo != "" { + repository = New(defaultRepo, url, compression, "") + } + } + return repository +} diff --git a/pkg/worker/caged/libretro/storage.go b/pkg/worker/emulator/libretro/storage.go similarity index 60% rename from pkg/worker/caged/libretro/storage.go rename to pkg/worker/emulator/libretro/storage.go index fc58faaf..f6760e13 100644 --- a/pkg/worker/caged/libretro/storage.go +++ b/pkg/worker/emulator/libretro/storage.go @@ -1,20 +1,18 @@ package libretro import ( + "os" "path/filepath" "strings" - "github.com/giongto35/cloud-game/v3/pkg/os" - "github.com/giongto35/cloud-game/v3/pkg/worker/compression/zip" + "github.com/giongto35/cloud-game/v2/pkg/worker/compression/zip" ) type ( Storage interface { - MainPath() string GetSavePath() string GetSRAMPath() string SetMainSaveName(name string) - SetNonBlocking(v bool) Load(path string) ([]byte, error) Save(path string, data []byte) error } @@ -26,27 +24,17 @@ type ( // needed for Google Cloud save/restore which // doesn't support multiple files MainSave string - NonBlock bool } ZipStorage struct { Storage } ) -func (s *StateStorage) MainPath() string { return s.MainSave } -func (s *StateStorage) SetMainSaveName(name string) { s.MainSave = name } -func (s *StateStorage) SetNonBlocking(v bool) { s.NonBlock = v } -func (s *StateStorage) GetSavePath() string { return filepath.Join(s.Path, s.MainSave+".dat") } -func (s *StateStorage) GetSRAMPath() string { return filepath.Join(s.Path, s.MainSave+".srm") } -func (s *StateStorage) Load(path string) ([]byte, error) { return os.ReadFile(path) } -func (s *StateStorage) Save(path string, dat []byte) error { - if s.NonBlock { - go func() { _ = os.WriteFile(path, dat, 0644) }() - return nil - } - - return os.WriteFile(path, dat, 0644) -} +func (s *StateStorage) SetMainSaveName(name string) { s.MainSave = name } +func (s *StateStorage) GetSavePath() string { return filepath.Join(s.Path, s.MainSave+".dat") } +func (s *StateStorage) GetSRAMPath() string { return filepath.Join(s.Path, s.MainSave+".srm") } +func (s *StateStorage) Load(path string) ([]byte, error) { return os.ReadFile(path) } +func (s *StateStorage) Save(path string, dat []byte) error { return os.WriteFile(path, dat, 0644) } func (z *ZipStorage) GetSavePath() string { return z.Storage.GetSavePath() + zip.Ext } func (z *ZipStorage) GetSRAMPath() string { return z.Storage.GetSRAMPath() + zip.Ext } diff --git a/pkg/worker/caged/libretro/storage_test.go b/pkg/worker/emulator/libretro/storage_test.go similarity index 100% rename from pkg/worker/caged/libretro/storage_test.go rename to pkg/worker/emulator/libretro/storage_test.go diff --git a/pkg/worker/encoder/encoder.go b/pkg/worker/encoder/encoder.go new file mode 100644 index 00000000..9549dce8 --- /dev/null +++ b/pkg/worker/encoder/encoder.go @@ -0,0 +1,76 @@ +package encoder + +import ( + "image" + "sync" + "sync/atomic" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/yuv" +) + +type ( + InFrame *image.RGBA + OutFrame []byte + Encoder interface { + LoadBuf(input []byte) + Encode() []byte + IntraRefresh() + Shutdown() error + } +) + +type VideoEncoder struct { + encoder Encoder + log *logger.Logger + stopped atomic.Bool + y yuv.ImgProcessor + mu sync.Mutex +} + +type VideoCodec string + +const ( + H264 VideoCodec = "h264" + VP8 VideoCodec = "vp8" +) + +// NewVideoEncoder returns new video encoder. +// By default, it waits for RGBA images on the input channel, +// converts them into YUV I420 format, +// encodes with provided video encoder, and +// puts the result into the output channel. +func NewVideoEncoder(enc Encoder, w, h int, concurrency int, log *logger.Logger) *VideoEncoder { + y := yuv.NewYuvImgProcessor(w, h, &yuv.Options{Threads: concurrency}) + if concurrency > 0 { + log.Info().Msgf("Use concurrent image processor: %v", concurrency) + } + return &VideoEncoder{encoder: enc, y: y, log: log} +} + +func (vp *VideoEncoder) Encode(img InFrame) OutFrame { + vp.mu.Lock() + defer vp.mu.Unlock() + if vp.stopped.Load() { + return nil + } + + yCbCr := vp.y.Process(img) + vp.encoder.LoadBuf(yCbCr) + vp.y.Put(&yCbCr) + + if frame := vp.encoder.Encode(); len(frame) > 0 { + return frame + } + return nil +} + +func (vp *VideoEncoder) Stop() { + vp.stopped.Store(true) + vp.mu.Lock() + defer vp.mu.Unlock() + + if err := vp.encoder.Shutdown(); err != nil { + vp.log.Error().Err(err).Msg("failed to close the encoder") + } +} diff --git a/pkg/worker/encoder/h264/libx264.go b/pkg/worker/encoder/h264/libx264.go new file mode 100644 index 00000000..ac6d944d --- /dev/null +++ b/pkg/worker/encoder/h264/libx264.go @@ -0,0 +1,533 @@ +// Package h264 implements cgo bindings for [x264](https://www.videolan.org/developers/x264.html) library. +package h264 + +/* +#cgo pkg-config: x264 + +#include "stdint.h" +#include "x264.h" +#include +*/ +import "C" +import "unsafe" + +const Build = C.X264_BUILD + +// T is opaque handler for encoder +type T struct{} + +// Nal is The data within the payload is already NAL-encapsulated; the ref_idc and type +// are merely in the struct for easy access by the calling application. +// All data returned in x264_nal_t, including the data in p_payload, is no longer +// valid after the next call to x264_encoder_encode. Thus, it must be used or copied +// before calling x264_encoder_encode or x264_encoder_headers again. +type Nal struct { + IRefIdc int32 /* nal_priority_e */ + IType int32 /* nal_unit_type_e */ + BLongStartcode int32 + IFirstMb int32 /* If this NAL is a slice, the index of the first MB in the slice. */ + ILastMb int32 /* If this NAL is a slice, the index of the last MB in the slice. */ + + /* Size of payload (including any padding) in bytes. */ + IPayload int32 + /* If param->b_annexb is set, Annex-B bytestream with startcode. + * Otherwise, startcode is replaced with a 4-byte size. + * This size is the size used in mp4/similar muxing; it is equal to i_payload-4 */ + /* C.uint8_t */ + PPayload unsafe.Pointer + + /* Size of padding in bytes. */ + IPadding int32 +} + +const RcCrf = 1 + +const ( + CspI420 = 0x0002 // yuv 4:2:0 planar + + // CspMask = 0x00ff /* */ + // CspNone = 0x0000 /* Invalid mode */ + // CspI400 = 0x0001 /* monochrome 4:0:0 */ + + //CspYv12 = 0x0003 /* yvu 4:2:0 planar */ + //CspNv12 = 0x0004 /* yuv 4:2:0, with one y plane and one packed u+v */ + //CspNv21 = 0x0005 /* yuv 4:2:0, with one y plane and one packed v+u */ + //CspI422 = 0x0006 /* yuv 4:2:2 planar */ + //CspYv16 = 0x0007 /* yvu 4:2:2 planar */ + //CspNv16 = 0x0008 /* yuv 4:2:2, with one y plane and one packed u+v */ + //CspYuyv = 0x0009 /* yuyv 4:2:2 packed */ + //CspUyvy = 0x000a /* uyvy 4:2:2 packed */ + //CspV210 = 0x000b /* 10-bit yuv 4:2:2 packed in 32 */ + //CspI444 = 0x000c /* yuv 4:4:4 planar */ + //CspYv24 = 0x000d /* yvu 4:4:4 planar */ + //CspBgr = 0x000e /* packed bgr 24bits */ + //CspBgra = 0x000f /* packed bgr 32bits */ + //CspRgb = 0x0010 /* packed rgb 24bits */ + //CspMax = 0x0011 /* end of list */ + //CspVflip = 0x1000 /* the csp is vertically flipped */ + //CspHighDepth = 0x2000 /* the csp has a depth of 16 bits per pixel component */ +) + +type Zone struct { + IStart, IEnd int32 /* range of frame numbers */ + BForceQp int32 /* whether to use qp vs bitrate factor */ + IQp int32 + FBitrateFactor float32 + Param *Param +} + +type Param struct { + /* CPU flags */ + Cpu uint32 + IThreads int32 /* encode multiple frames in parallel */ + ILookaheadThreads int32 /* multiple threads for lookahead analysis */ + BSlicedThreads int32 /* Whether to use slice-based threading. */ + BDeterministic int32 /* whether to allow non-deterministic optimizations when threaded */ + BCpuIndependent int32 /* force canonical behavior rather than cpu-dependent optimal algorithms */ + ISyncLookahead int32 /* threaded lookahead buffer */ + + /* Video Properties */ + IWidth int32 + IHeight int32 + ICsp int32 /* CSP of encoded bitstream */ + IBitdepth int32 + ILevelIdc int32 + IFrameTotal int32 /* number of frames to encode if known, else 0 */ + + /* NAL HRD + * Uses Buffering and Picture Timing SEIs to signal HRD + * The HRD in H.264 was not designed with VFR in mind. + * It is therefore not recommended to use NAL HRD with VFR. + * Furthermore, reconfiguring the VBV (via x264_encoder_reconfig) + * will currently generate invalid HRD. */ + INalHrd int32 + + Vui struct { + /* they will be reduced to be 0 < x <= 65535 and prime */ + ISarHeight int32 + ISarWidth int32 + + IOverscan int32 /* 0=undef, 1=no overscan, 2=overscan */ + + /* see h264 annex E for the values of the following */ + IVidformat int32 + BFullrange int32 + IColorprim int32 + ITransfer int32 + IColmatrix int32 + IChromaLoc int32 /* both top & bottom */ + } + + /* Bitstream parameters */ + IFrameReference int32 /* Maximum number of reference frames */ + IDpbSize int32 /* Force a DPB size larger than that implied by B-frames and reference frames. + * Useful in combination with interactive error resilience. */ + IKeyintMax int32 /* Force an IDR keyframe at this interval */ + IKeyintMin int32 /* Scenecuts closer together than this are coded as I, not IDR. */ + IScenecutThreshold int32 /* how aggressively to insert extra I frames */ + BIntraRefresh int32 /* Whether or not to use periodic intra refresh instead of IDR frames. */ + + IBframe int32 /* how many b-frame between 2 references pictures */ + IBframeAdaptive int32 + IBframeBias int32 + IBframePyramid int32 /* Keep some B-frames as references: 0=off, 1=strict hierarchical, 2=normal */ + BOpenGop int32 + BBlurayCompat int32 + IAvcintraClass int32 + IAvcintraFlavor int32 + + BDeblockingFilter int32 + IDeblockingFilterAlphac0 int32 /* [-6, 6] -6 light filter, 6 strong */ + IDeblockingFilterBeta int32 /* [-6, 6] idem */ + + BCabac int32 + ICabacInitIdc int32 + + BInterlaced int32 + BConstrainedIntra int32 + + ICqmPreset int32 + PszCqmFile *int8 /* filename (in UTF-8) of CQM file, JM format */ + Cqm4iy [16]byte /* used only if i_cqm_preset == X264_CQM_CUSTOM */ + Cqm4py [16]byte + Cqm4ic [16]byte + Cqm4pc [16]byte + Cqm8iy [64]byte + Cqm8py [64]byte + Cqm8ic [64]byte + Cqm8pc [64]byte + + /* Log */ + PfLog *[0]byte + PLogPrivate unsafe.Pointer + ILogLevel int32 + BFullRecon int32 /* fully reconstruct frames, even when not necessary for encoding. Implied by psz_dump_yuv */ + PszDumpYuv *int8 /* filename (in UTF-8) for reconstructed frames */ + + /* Encoder analyser parameters */ + Analyse struct { + Intra uint32 /* intra partitions */ + Inter uint32 /* inter partitions */ + + BTransform8x8 int32 + IWeightedPred int32 /* weighting for P-frames */ + BWeightedBipred int32 /* implicit weighting for B-frames */ + IDirectMvPred int32 /* spatial vs temporal mv prediction */ + IChromaQpOffset int32 + + IMeMethod int32 /* motion estimation algorithm to use (X264_ME_*) */ + IMeRange int32 /* integer pixel motion estimation search range (from predicted mv) */ + IMvRange int32 /* maximum length of a mv (in pixels). -1 = auto, based on level */ + IMvRangeThread int32 /* minimum space between threads. -1 = auto, based on number of threads. */ + ISubpelRefine int32 /* subpixel motion estimation quality */ + BChromaMe int32 /* chroma ME for subpel and mode decision in P-frames */ + BMixedReferences int32 /* allow each mb partition to have its own reference number */ + ITrellis int32 /* trellis RD quantization */ + BFastPskip int32 /* early SKIP detection on P-frames */ + BDctDecimate int32 /* transform coefficient thresholding on P-frames */ + INoiseReduction int32 /* adaptive pseudo-deadzone */ + FPsyRd float32 /* Psy RD strength */ + FPsyTrellis float32 /* Psy trellis strength */ + BPsy int32 /* Toggle all psy optimizations */ + + BMbInfo int32 /* Use input mb_info data in x264_picture_t */ + BMbInfoUpdate int32 /* Update the values in mb_info according to the results of encoding. */ + + /* the deadzone size that will be used in luma quantization */ + ILumaDeadzone [2]int32 + + BPsnr int32 /* compute and print PSNR stats */ + BSsim int32 /* compute and print SSIM stats */ + } + + /* Rate control parameters */ + Rc struct { + IRcMethod int32 /* X264_RC_* */ + + IQpConstant int32 /* 0=lossless */ + IQpMin int32 /* min allowed QP value */ + IQpMax int32 /* max allowed QP value */ + IQpStep int32 /* max QP step between frames */ + + IBitrate int32 + FRfConstant float32 /* 1pass VBR, nominal QP */ + FRfConstantMax float32 /* In CRF mode, maximum CRF as caused by VBV */ + FRateTolerance float32 + IVbvMaxBitrate int32 + IVbvBufferSize int32 + FVbvBufferInit float32 /* <=1: fraction of buffer_size. >1: kbit */ + FIpFactor float32 + FPbFactor float32 + + /* VBV filler: force CBR VBV and use filler bytes to ensure hard-CBR. + * Implied by NAL-HRD CBR. */ + BFiller int32 + + IAqMode int32 /* psy adaptive QP. (X264_AQ_*) */ + FAqStrength float32 + BMbTree int32 /* Macroblock-tree ratecontrol. */ + ILookahead int32 + + /* 2pass */ + BStatWrite int32 /* Enable stat writing in psz_stat_out */ + PszStatOut *int8 /* output filename (in UTF-8) of the 2pass stats file */ + BStatRead int32 /* Read stat from psz_stat_in and use it */ + PszStatIn *int8 /* input filename (in UTF-8) of the 2pass stats file */ + + /* 2pass params (same as ffmpeg ones) */ + FQcompress float32 /* 0.0 => cbr, 1.0 => constant qp */ + FQblur float32 /* temporally blur quants */ + FComplexityBlur float32 /* temporally blur complexity */ + Zones *Zone /* ratecontrol overrides */ + IZones int32 /* number of zone_t's */ + PszZones *int8 /* alternate method of specifying zones */ + } + + /* Cropping Rectangle parameters: added to those implicitly defined by + non-mod16 video resolutions. */ + CropRect struct { + ILeft int32 + ITop int32 + IRight int32 + IBottom int32 + } + + /* frame packing arrangement flag */ + IFramePacking int32 + + /* alternative transfer SEI */ + IAlternativeTransfer int32 + + /* Muxing parameters */ + BAud int32 /* generate access unit delimiters */ + BRepeatHeaders int32 /* put SPS/PPS before each keyframe */ + BAnnexb int32 /* if set, place start codes (4 bytes) before NAL units, + * otherwise place size (4 bytes) before NAL units. */ + ISpsId int32 /* SPS and PPS id number */ + BVfrInput int32 /* VFR input. If 1, use timebase and timestamps for ratecontrol purposes. + * If 0, use fps only. */ + BPulldown int32 /* use explicity set timebase for CFR */ + IFpsNum uint32 + IFpsDen uint32 + ITimebaseNum uint32 /* Timebase numerator */ + ITimebaseDen uint32 /* Timebase denominator */ + + BTff int32 + + /* Pulldown: + * The correct pic_struct must be passed with each input frame. + * The input timebase should be the timebase corresponding to the output framerate. This should be constant. + * e.g. for 3:2 pulldown timebase should be 1001/30000 + * The PTS passed with each frame must be the PTS of the frame after pulldown is applied. + * Frame doubling and tripling require b_vfr_input set to zero (see H.264 Table D-1) + * + * Pulldown changes are not clearly defined in H.264. Therefore, it is the calling app's responsibility to manage this. + */ + + BPicStruct int32 + + /* Fake Interlaced. + * + * Used only when b_interlaced=0. Setting this flag makes it possible to flag the stream as PAFF interlaced yet + * encode all frames progressively. It is useful for encoding 25p and 30p Blu-Ray streams. + */ + BFakeInterlaced int32 + + /* Don't optimize header parameters based on video content, e.g. ensure that splitting an input video, compressing + * each part, and stitching them back together will result in identical SPS/PPS. This is necessary for stitching + * with container formats that don't allow multiple SPS/PPS. */ + BStitchable int32 + + BOpencl int32 /* use OpenCL when available */ + IOpenclDevice int32 /* specify count of GPU devices to skip, for CLI users */ + OpenclDeviceId unsafe.Pointer /* pass explicit cl_device_id as void*, for API users */ + PszClbinFile *int8 /* filename (in UTF-8) of the compiled OpenCL kernel cache file */ + + /* Slicing parameters */ + iSliceMaxSize int32 /* Max size per slice in bytes; includes estimated NAL overhead. */ + iSliceMaxMbs int32 /* Max number of MBs per slice; overrides iSliceCount. */ + iSliceMinMbs int32 /* Min number of MBs per slice */ + iSliceCount int32 /* Number of slices per frame: forces rectangular slices. */ + iSliceCountMax int32 /* Absolute cap on slices per frame; stops applying slice-max-size + * and slice-max-mbs if this is reached. */ + + ParamFree *func(arg unsafe.Pointer) + NaluProcess *func(H []T, Nal []Nal, Opaque unsafe.Pointer) + + Opaque unsafe.Pointer +} + +/**************************************************************************** + * H.264 level restriction information + ****************************************************************************/ + +type Level struct { + LevelIdc byte + Mbps int32 /* max macroblock processing rate (macroblocks/sec) */ + FrameSize int32 /* max frame size (macroblocks) */ + Dpb int32 /* max decoded picture buffer (mbs) */ + Bitrate int32 /* max bitrate (kbit/sec) */ + Cpb int32 /* max vbv buffer (kbit) */ + MvRange uint16 /* max vertical mv component range (pixels) */ + MvsPer2mb byte /* max mvs per 2 consecutive mbs. */ + SliceRate byte /* ?? */ + Mincr byte /* min compression ratio */ + Bipred8x8 byte /* limit bipred to >=8x8 */ + Direct8x8 byte /* limit b_direct to >=8x8 */ + FrameOnly byte /* forbid interlacing */ +} + +type PicStruct int32 + +type Hrd struct { + CpbInitialArrivalTime float64 + CpbFinalArrivalTime float64 + CpbRemovalTime float64 + + DpbOutputTime float64 +} + +type SeiPayload struct { + PayloadSize int32 + PayloadType int32 + Payload *byte +} + +type Sei struct { + NumPayloads int32 + Payloads *SeiPayload + /* In: optional callback to free each payload AND x264_sei_payload_t when used. */ + SeiFree *func(arg0 unsafe.Pointer) +} + +type Image struct { + ICsp int32 /* Colorspace */ + IPlane int32 /* Number of image planes */ + IStride [4]int32 /* Strides for each plane */ + Plane [4]unsafe.Pointer /* Pointers to each plane */ +} + +type ImageProperties struct { + /* In: an array of quantizer offsets to be applied to this image during encoding. + * These are added on top of the decisions made by x264. + * Offsets can be fractional; they are added before QPs are rounded to integer. + * Adaptive quantization must be enabled to use this feature. Behavior if quant + * offsets differ between encoding passes is undefined. */ + QuantOffsets *float32 + /* In: optional callback to free quant_offsets when used. + * Useful if one wants to use a different quant_offset array for each frame. */ + QuantOffsetsFree *func(arg0 unsafe.Pointer) + + /* In: optional array of flags for each macroblock. + * Allows specifying additional information for the encoder such as which macroblocks + * remain unchanged. Usable flags are listed below. + * x264_param_t.analyse.b_mb_info must be set to use this, since x264 needs to track + * extra data internally to make full use of this information. + * + * Out: if b_mb_info_update is set, x264 will update this array as a result of encoding. + * + * For "MBINFO_CONSTANT", it will remove this flag on any macroblock whose decoded + * pixels have changed. This can be useful for e.g. noting which areas of the + * frame need to actually be blitted. Note: this intentionally ignores the effects + * of deblocking for the current frame, which should be fine unless one needs exact + * pixel-perfect accuracy. + * + * Results for MBINFO_CONSTANT are currently only set for P-frames, and are not + * guaranteed to enumerate all blocks which haven't changed. (There may be false + * negatives, but no false positives.) + */ + MbInfo *byte + /* In: optional callback to free mb_info when used. */ + MbInfoFree *func(arg0 unsafe.Pointer) + + /* Out: SSIM of the the frame luma (if x264_param_t.b_ssim is set) */ + FSsim float64 + /* Out: Average PSNR of the frame (if x264_param_t.b_psnr is set) */ + FPsnrAvg float64 + /* Out: PSNR of Y, U, and V (if x264_param_t.b_psnr is set) */ + FPsnr [3]float64 + + /* Out: Average effective CRF of the encoded frame */ + FCrfAvg float64 +} + +type Picture struct { + /* In: force picture type (if not auto) + * If x264 encoding parameters are violated in the forcing of picture types, + * x264 will correct the input picture type and log a warning. + * Out: type of the picture encoded */ + IType int32 + /* In: force quantizer for != X264_QP_AUTO */ + IQpplus1 int32 + /* In: pic_struct, for pulldown/doubling/etc...used only if b_pic_struct=1. + * use pic_struct_e for pic_struct inputs + * Out: pic_struct element associated with frame */ + IPicStruct int32 + /* Out: whether this frame is a keyframe. Important when using modes that result in + * SEI recovery points being used instead of IDR frames. */ + BKeyframe int32 + /* In: user pts, Out: pts of encoded picture (user)*/ + IPts int64 + /* Out: frame dts. When the pts of the first frame is close to zero, + * initial frames may have a negative dts which must be dealt with by any muxer */ + IDts int64 + /* In: custom encoding parameters to be set from this frame forwards + (in coded order, not display order). If NULL, continue using + parameters from the previous frame. Some parameters, such as + aspect ratio, can only be changed per-GOP due to the limitations + of H.264 itself; in this case, the caller must force an IDR frame + if it needs the changed parameter to apply immediately. */ + Param *Param + /* In: raw image data */ + /* Out: reconstructed image data. x264 may skip part of the reconstruction process, + e.g. deblocking, in frames where it isn't necessary. To force complete + reconstruction, at a small speed cost, set b_full_recon. */ + Img Image + /* In: optional information to modify encoder decisions for this frame + * Out: information about the encoded frame */ + Prop ImageProperties + /* Out: HRD timing information. Output only when i_nal_hrd is set. */ + Hrdiming Hrd + /* In: arbitrary user SEI (e.g subtitles, AFDs) */ + ExtraSei Sei + /* private user data. copied from input to output frames. */ + Opaque unsafe.Pointer +} + +func (p *Picture) freePlanes() { + for _, ptr := range p.Img.Plane { + C.free(ptr) + } +} + +func (t *T) cptr() *C.x264_t { return (*C.x264_t)(unsafe.Pointer(t)) } + +func (n *Nal) cptr() *C.x264_nal_t { return (*C.x264_nal_t)(unsafe.Pointer(n)) } + +func (p *Param) cptr() *C.x264_param_t { return (*C.x264_param_t)(unsafe.Pointer(p)) } + +func (p *Picture) cptr() *C.x264_picture_t { return (*C.x264_picture_t)(unsafe.Pointer(p)) } + +// ParamDefault - fill Param with default values and do CPU detection. +func ParamDefault(param *Param) { C.x264_param_default(param.cptr()) } + +// ParamDefaultPreset - the same as ParamDefault, but also use the passed preset and tune to modify the default settings +// (either can be nil, which implies no preset or no tune, respectively). +// +// Currently available presets are, ordered from fastest to slowest: +// "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo". +// +// Currently available tunings are: +// "film", "animation", "grain", "stillimage", "psnr", "ssim", "fastdecode", "zerolatency". +// +// Returns 0 on success, negative on failure (e.g. invalid preset/tune name). +func ParamDefaultPreset(param *Param, preset string, tune string) int32 { + cpreset := C.CString(preset) + defer C.free(unsafe.Pointer(cpreset)) + ctune := C.CString(tune) + defer C.free(unsafe.Pointer(ctune)) + return (int32)(C.x264_param_default_preset(param.cptr(), cpreset, ctune)) +} + +// ParamApplyProfile - applies the restrictions of the given profile. +// +// Currently available profiles are, from most to least restrictive: +// "baseline", "main", "high", "high10", "high422", "high444". +// (can be nil, in which case the function will do nothing). +// +// Returns 0 on success, negative on failure (e.g. invalid profile name). +func ParamApplyProfile(param *Param, profile string) int32 { + cprofile := C.CString(profile) + defer C.free(unsafe.Pointer(cprofile)) + return (int32)(C.x264_param_apply_profile(param.cptr(), cprofile)) +} + +// EncoderOpen - create a new encoder handler, all parameters from Param are copied. +func EncoderOpen(param *Param) *T { + ret := C.x264_encoder_open(param.cptr()) + return *(**T)(unsafe.Pointer(&ret)) +} + +// EncoderEncode - encode one picture. +// Returns the number of bytes in the returned NALs, negative on error and zero if no NAL units returned. +func EncoderEncode(enc *T, ppNal []*Nal, piNal *int32, picIn *Picture, picOut *Picture) int32 { + cenc := enc.cptr() + + cppNal := (**C.x264_nal_t)(unsafe.Pointer(&ppNal[0])) + cpiNal := (*C.int)(unsafe.Pointer(piNal)) + + cpicIn := picIn.cptr() + cpicOut := picOut.cptr() + + return (int32)(C.x264_encoder_encode(cenc, cppNal, cpiNal, cpicIn, cpicOut)) +} + +// EncoderClose closes an encoder handler. +func EncoderClose(enc *T) { C.x264_encoder_close(enc.cptr()) } + +// EncoderIntraRefresh - If an intra refresh is not in progress, begin one with the next P-frame. +// If an intra refresh is in progress, begin one as soon as the current one finishes. +// Requires that BIntraRefresh be set. +// +// Should not be called during an x264_encoder_encode. +//func EncoderIntraRefresh(enc *T) { C.x264_encoder_intra_refresh(enc.cptr()) } diff --git a/pkg/worker/encoder/h264/x264.go b/pkg/worker/encoder/h264/x264.go new file mode 100644 index 00000000..a33948bd --- /dev/null +++ b/pkg/worker/encoder/h264/x264.go @@ -0,0 +1,151 @@ +package h264 + +/* +#include +*/ +import "C" +import ( + "fmt" + "unsafe" +) + +type H264 struct { + ref *T + + width int32 + lumaSize int32 + chromaSize int32 + csp int32 + nnals int32 + nals []*Nal + + in, out *Picture + y, u, v []byte +} + +type Options struct { + // Constant Rate Factor (CRF) + // This method allows the encoder to attempt to achieve a certain output quality for the whole file + // when output file size is of less importance. + // The range of the CRF scale is 0–51, where 0 is lossless, 23 is the default, and 51 is the worst quality possible. + Crf uint8 + // film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency. + Tune string + // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo. + Preset string + // baseline, main, high, high10, high422, high444. + Profile string + LogLevel int32 +} + +func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) { + libVersion := LibVersion() + + if libVersion < 150 { + return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", libVersion) + } + + if opts == nil { + opts = &Options{ + Crf: 23, + Tune: "zerolatency", + Preset: "superfast", + Profile: "baseline", + } + } + + param := Param{} + if opts.Preset != "" && opts.Tune != "" { + if ParamDefaultPreset(¶m, opts.Preset, opts.Tune) < 0 { + return nil, fmt.Errorf("x264: invalid preset/tune name") + } + } else { + ParamDefault(¶m) + } + + if opts.Profile != "" { + if ParamApplyProfile(¶m, opts.Profile) < 0 { + return nil, fmt.Errorf("x264: invalid profile name") + } + } + + // legacy encoder lacks of this param + param.IBitdepth = 8 + + if libVersion > 155 { + param.ICsp = CspI420 + } else { + param.ICsp = 1 + } + param.IWidth = int32(w) + param.IHeight = int32(h) + param.ILogLevel = opts.LogLevel + param.ISyncLookahead = 0 + param.IThreads = 1 + + param.Rc.IRcMethod = RcCrf + param.Rc.FRfConstant = float32(opts.Crf) + + encoder = &H264{ + csp: param.ICsp, + lumaSize: int32(w * h), + chromaSize: int32(w*h) / 4, + nals: make([]*Nal, 1), + width: int32(w), + out: new(Picture), + } + + // pool + var picIn Picture + + picIn.Img.ICsp = encoder.csp + picIn.Img.IPlane = 3 + picIn.Img.IStride[0] = encoder.width + picIn.Img.IStride[1] = encoder.width >> 1 + picIn.Img.IStride[2] = encoder.width >> 1 + + picIn.Img.Plane[0] = C.malloc(C.size_t(encoder.lumaSize)) + picIn.Img.Plane[1] = C.malloc(C.size_t(encoder.chromaSize)) + picIn.Img.Plane[2] = C.malloc(C.size_t(encoder.chromaSize)) + + encoder.y = unsafe.Slice((*byte)(picIn.Img.Plane[0]), encoder.lumaSize) + encoder.u = unsafe.Slice((*byte)(picIn.Img.Plane[1]), encoder.chromaSize) + encoder.v = unsafe.Slice((*byte)(picIn.Img.Plane[2]), encoder.chromaSize) + + encoder.in = &picIn + + if encoder.ref = EncoderOpen(¶m); encoder.ref == nil { + err = fmt.Errorf("x264: cannot open the encoder") + return + } + return +} + +func LibVersion() int { return int(Build) } + +func (e *H264) LoadBuf(yuv []byte) { + copy(e.y, yuv[:e.lumaSize]) + copy(e.u, yuv[e.lumaSize:e.lumaSize+e.chromaSize]) + copy(e.v, yuv[e.lumaSize+e.chromaSize:]) +} + +func (e *H264) Encode() []byte { + e.in.IPts += 1 + if ret := EncoderEncode(e.ref, e.nals, &e.nnals, e.in, e.out); ret > 0 { + return C.GoBytes(e.nals[0].PPayload, C.int(ret)) + } + return []byte{} +} + +func (e *H264) IntraRefresh() { + // !to implement +} + +func (e *H264) Shutdown() error { + e.y = nil + e.u = nil + e.v = nil + e.in.freePlanes() + EncoderClose(e.ref) + return nil +} diff --git a/pkg/encoder/opus/opus.go b/pkg/worker/encoder/opus/opus.go similarity index 99% rename from pkg/encoder/opus/opus.go rename to pkg/worker/encoder/opus/opus.go index 95b111f1..7d42b6c8 100644 --- a/pkg/encoder/opus/opus.go +++ b/pkg/worker/encoder/opus/opus.go @@ -2,7 +2,6 @@ package opus /* #cgo pkg-config: opus -#cgo st LDFLAGS: -l:libopus.a #include diff --git a/pkg/encoder/vpx/libvpx.go b/pkg/worker/encoder/vpx/libvpx.go similarity index 79% rename from pkg/encoder/vpx/libvpx.go rename to pkg/worker/encoder/vpx/libvpx.go index c175c14f..7db5a2e6 100644 --- a/pkg/encoder/vpx/libvpx.go +++ b/pkg/worker/encoder/vpx/libvpx.go @@ -2,7 +2,6 @@ package vpx /* #cgo pkg-config: vpx -#cgo st LDFLAGS: -l:libvpx.a #include "vpx/vpx_encoder.h" #include "vpx/vpx_image.h" @@ -12,7 +11,6 @@ package vpx #include #define VP8_FOURCC 0x30385056 -#define VP9_FOURCC 0x30395056 typedef struct VpxInterface { const char *const name; @@ -43,10 +41,7 @@ FrameBuffer get_frame_buffer(vpx_codec_ctx_t *codec, vpx_codec_iter_t *iter) { return fb; } -const VpxInterface vpx_encoders[] = { - { "vp8", VP8_FOURCC, &vpx_codec_vp8_cx }, - { "vp9", VP9_FOURCC, &vpx_codec_vp9_cx }, -}; +const VpxInterface vpx_encoders[] = {{ "vp8", VP8_FOURCC, &vpx_codec_vp8_cx }}; int vpx_img_plane_width(const vpx_image_t *img, int plane) { if (plane > 0 && img->x_chroma_shift > 0) @@ -88,40 +83,31 @@ type Vpx struct { image C.vpx_image_t codecCtx C.vpx_codec_ctx_t kfi C.int - flipped bool - v int } -func (vpx *Vpx) SetFlip(b bool) { vpx.flipped = b } - type Options struct { // Target bandwidth to use for this stream, in kilobits per second. Bitrate uint // Force keyframe interval. - KeyframeInterval uint + KeyframeInt uint } -func NewEncoder(w, h int, th int, version int, opts *Options) (*Vpx, error) { - idx := 0 - if version == 9 { - idx = 1 - } - encoder := &C.vpx_encoders[idx] +func NewEncoder(w, h int, opts *Options) (*Vpx, error) { + encoder := &C.vpx_encoders[0] if encoder == nil { return nil, fmt.Errorf("couldn't get the encoder") } if opts == nil { opts = &Options{ - Bitrate: 1200, - KeyframeInterval: 5, + Bitrate: 1200, + KeyframeInt: 5, } } vpx := Vpx{ frameCount: C.int(0), - kfi: C.int(opts.KeyframeInterval), - v: version, + kfi: C.int(opts.KeyframeInt), } if C.vpx_img_alloc(&vpx.image, C.VPX_IMG_FMT_I420, C.uint(w), C.uint(h), 1) == nil { @@ -135,12 +121,8 @@ func NewEncoder(w, h int, th int, version int, opts *Options) (*Vpx, error) { cfg.g_w = C.uint(w) cfg.g_h = C.uint(h) - if th != 0 { - cfg.g_threads = C.uint(th) - } - cfg.g_lag_in_frames = 0 cfg.rc_target_bitrate = C.uint(opts.Bitrate) - cfg.g_error_resilient = C.VPX_ERROR_RESILIENT_DEFAULT + cfg.g_error_resilient = 1 if C.call_vpx_codec_enc_init(&vpx.codecCtx, encoder, &cfg) != 0 { return nil, fmt.Errorf("failed to initialize encoder") @@ -149,13 +131,14 @@ func NewEncoder(w, h int, th int, version int, opts *Options) (*Vpx, error) { return &vpx, nil } +func (vpx *Vpx) LoadBuf(yuv []byte) { + C.vpx_img_read(&vpx.image, unsafe.Pointer(&yuv[0])) +} + // Encode encodes yuv image with the VPX8 encoder. // see: https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c -func (vpx *Vpx) Encode(yuv []byte) []byte { - C.vpx_img_read(&vpx.image, unsafe.Pointer(&yuv[0])) - if vpx.flipped { - C.vpx_img_flip(&vpx.image) - } +func (vpx *Vpx) Encode() []byte { + var iter C.vpx_codec_iter_t var flags C.int if vpx.kfi > 0 && vpx.frameCount%vpx.kfi == 0 { @@ -166,7 +149,6 @@ func (vpx *Vpx) Encode(yuv []byte) []byte { } vpx.frameCount++ - var iter C.vpx_codec_iter_t fb := C.get_frame_buffer(&vpx.codecCtx, &iter) if fb.ptr == nil { return []byte{} @@ -174,19 +156,14 @@ func (vpx *Vpx) Encode(yuv []byte) []byte { return C.GoBytes(fb.ptr, fb.size) } -func (vpx *Vpx) Info() string { - return fmt.Sprintf("vpx (%v): %v", vpx.v, C.GoString(C.vpx_codec_version_str())) -} - func (vpx *Vpx) IntraRefresh() { // !to implement } func (vpx *Vpx) Shutdown() error { - //if &vpx.image != nil { - C.vpx_img_free(&vpx.image) - //} + if &vpx.image != nil { + C.vpx_img_free(&vpx.image) + } C.vpx_codec_destroy(&vpx.codecCtx) - vpx.flipped = false return nil } diff --git a/pkg/worker/encoder/yuv/yuv.c b/pkg/worker/encoder/yuv/yuv.c new file mode 100644 index 00000000..c4d918dc --- /dev/null +++ b/pkg/worker/encoder/yuv/yuv.c @@ -0,0 +1,130 @@ +#include "yuv.h" + +#define Y601_STUDIO 1 + +// BT.601 STUDIO + +#ifdef Y601_STUDIO +// 66*R+129*G+25*B +static __inline int Y(uint8_t *__restrict rgb) { + int R = *rgb; + int G = *(rgb+1); + int B = *(rgb+2); + return (66*R+129*G+25*B+128)>>8; +} + +// 112*B-38*R-74G +static __inline int U(uint8_t *__restrict rgb) { + int R = *rgb; + int G = *(rgb+1); + int B = *(rgb+2); + return (-38*R-74*G+112*B+128) >> 8; +} + +// 112*R-94*G-18*B +static __inline int V(uint8_t *__restrict rgb) { + int R = 56**(rgb); + int G = 47**(rgb+1); + int B = *(rgb+2); + return (R-G-(B+(B<<3))+64) >> 7; +} + +static const int Y_MIN = 16; + +#else + +// BT.601 FULL + +// 77*R+150*G+29*B +static __inline int Y(uint8_t *rgb) { + int R = 77**(rgb); + int G = 150**(rgb+1); + int B = 29**(rgb+2); + return (R+G+B+128) >> 8; +} + +// 127*B-43*R-84*G +static __inline int U(uint8_t *rgb) { + int R = 43**(rgb); + int G = 84**(rgb+1); + int B = 127**(rgb+2); + return (-R-G+B+128) >> 8; +} + +// 127*R-106*G-21*B +static __inline int V(uint8_t *rgb) { + int R = 127**rgb; + int G = -106**(rgb+1); + int B = -21**(rgb+2); + return (G+B+R+128) >> 8; +} + +static const int Y_MIN = 0; +#endif + +static __inline void _y(uint8_t *__restrict p, uint8_t *__restrict y, int size) { + do { + *y++ = Y(p) + Y_MIN; + p += 4; + } while (--size); +} + +// It will take an average color from the 2x2 pixel group for chroma values. +// X X X X +// O O +// X X X X +static __inline void _4uv(uint8_t * __restrict p, uint8_t * __restrict u, uint8_t * __restrict v, const int w, const int h) { + uint8_t *p2, *p3, *p4; + const int row = w << 2; + const int next = 4; + + int x = w, y = h, sumU = 0, sumV = 0; + while (y > 0) { + while (x > 0) { + // xx.. + // .... + p2 = p+next; + sumU = U(p) + U(p2); + sumV = V(p) + V(p2); + // .... + // xx.. + p3 = p+row; + p4 = p3+next; + sumU += U(p3) + U(p4); + sumV += V(p3) + V(p4); + *u++ = 128 + (sumU >> 2); + *v++ = 128 + (sumV >> 2); + // ..x. + p += 8; + x -= 2; + } + p += row; + y -= 2; + x = w; + } +} + +// Converts RGBA image to YUV (I420) with BT.601 studio color range. +void rgbaToYuv(void *__restrict destination, void *__restrict source, const int w, const int h) { + const int image_size = w * h; + uint8_t *src = source; + uint8_t *dst_y = destination; + uint8_t *dst_u = destination + image_size; + uint8_t *dst_v = destination + image_size + image_size / 4; + _y(src, dst_y, image_size); + src = source; + _4uv(source, dst_u, dst_v, w, h); +} + +void luma(void *__restrict destination, void *__restrict source, const int pos, const int w, const int h) { + uint8_t *rgba = source + 4 * pos; + uint8_t *dst = destination + pos; + _y(rgba, dst, w*h); +} + +void chroma(void *__restrict dst, void *__restrict source, const int pos, const int deu, const int dev, const int w, const int h) { + uint8_t *src = source + 4 * pos; + uint8_t *dst_u = dst + deu + pos / 4; + uint8_t *dst_v = dst + dev + pos / 4; + _4uv(src, dst_u, dst_v, w, h); +} diff --git a/pkg/worker/encoder/yuv/yuv.go b/pkg/worker/encoder/yuv/yuv.go new file mode 100644 index 00000000..19a33318 --- /dev/null +++ b/pkg/worker/encoder/yuv/yuv.go @@ -0,0 +1,125 @@ +package yuv + +import ( + "image" + "sync" + "unsafe" +) + +/* +#cgo CFLAGS: -Wall +#include "yuv.h" +*/ +import "C" + +type ImgProcessor interface { + Process(rgba *image.RGBA) []byte + Put(*[]byte) +} + +type Options struct { + Threads int +} + +type processor struct { + w, h int + + // cache + ww C.int + pool sync.Pool +} + +type threadedProcessor struct { + *processor + + // threading + threads int + chunk int + + // cache + chromaU C.int + chromaV C.int + wg sync.WaitGroup +} + +// NewYuvImgProcessor creates new YUV image converter from RGBA. +func NewYuvImgProcessor(w, h int, opts *Options) ImgProcessor { + bufSize := int(float32(w*h) * 1.5) + + processor := processor{ + w: w, + h: h, + ww: C.int(w), + pool: sync.Pool{New: func() any { + b := make([]byte, bufSize) + return &b + }}, + } + + if opts != nil && opts.Threads > 0 { + // chunks the image evenly + chunk := h / opts.Threads + if chunk%2 != 0 { + chunk-- + } + + return &threadedProcessor{ + chromaU: C.int(w * h), + chromaV: C.int(w*h + w*h/4), + chunk: chunk, + processor: &processor, + threads: opts.Threads, + wg: sync.WaitGroup{}, + } + } + return &processor +} + +// Process converts RGBA colorspace into YUV I420 format inside the internal buffer. +// Non-threaded version. +func (yuv *processor) Process(rgba *image.RGBA) []byte { + buf := *yuv.pool.Get().(*[]byte) + C.rgbaToYuv(unsafe.Pointer(&buf[0]), unsafe.Pointer(&rgba.Pix[0]), yuv.ww, C.int(yuv.h)) + return buf +} + +func (yuv *processor) Put(x *[]byte) { yuv.pool.Put(x) } + +// Process converts RGBA colorspace into YUV I420 format inside the internal buffer. +// Threaded version. +// +// We divide the input image into chunks by the number of available CPUs. +// Each chunk should contain 2, 4, 6, etc. rows of the image. +// +// 8x4 CPU (2) +// x x x x x x x x | Coroutine 1 +// x x x x x x x x | Coroutine 1 +// x x x x x x x x | Coroutine 2 +// x x x x x x x x | Coroutine 2 +func (yuv *threadedProcessor) Process(rgba *image.RGBA) []byte { + src := unsafe.Pointer(&rgba.Pix[0]) + buf := *yuv.pool.Get().(*[]byte) + dst := unsafe.Pointer(&buf[0]) + yuv.wg.Add(yuv.threads << 1) + chunk := yuv.w * yuv.chunk + for i := 0; i < yuv.threads; i++ { + pos, hh := C.int(i*chunk), C.int(yuv.chunk) + if i == yuv.threads-1 { + hh = C.int(yuv.h - i*yuv.chunk) + } + go yuv.chroma_(src, dst, pos, hh) + go yuv.luma_(src, dst, pos, hh) + } + yuv.wg.Wait() + return buf +} + +func (yuv *threadedProcessor) luma_(src unsafe.Pointer, dst unsafe.Pointer, pos C.int, hh C.int) { + C.luma(dst, src, pos, yuv.ww, hh) + yuv.wg.Done() +} + +func (yuv *threadedProcessor) chroma_(src unsafe.Pointer, dst unsafe.Pointer, pos C.int, hh C.int) { + C.chroma(dst, src, pos, yuv.chromaU, yuv.chromaV, yuv.ww, hh) + yuv.wg.Done() +} diff --git a/pkg/worker/encoder/yuv/yuv.h b/pkg/worker/encoder/yuv/yuv.h new file mode 100644 index 00000000..6b39ec52 --- /dev/null +++ b/pkg/worker/encoder/yuv/yuv.h @@ -0,0 +1,18 @@ +#ifndef YUV_H__ +#define YUV_H__ + +#include + +// Converts RGBA image to YUV (I420) with BT.601 studio color range. +void rgbaToYuv(void *destination, void *source, int width, int height); + +// Converts RGBA image chunk to YUV (I420) chroma with BT.601 studio color range. +// pos contains a shift value for chunks. +// deu, dev contains constant shifts for U, V planes in the resulting array. +// chroma (0, 1) selects chroma estimation algorithm. +void chroma(void *destination, void *source, int pos, int deu, int dev, int width, int height); + +// Converts RGBA image chunk to YUV (I420) luma with BT.601 studio color range. +void luma(void *destination, void *source, int pos, int width, int height); + +#endif diff --git a/pkg/encoder/yuv/yuv_test.go b/pkg/worker/encoder/yuv/yuv_test.go similarity index 74% rename from pkg/encoder/yuv/yuv_test.go rename to pkg/worker/encoder/yuv/yuv_test.go index 4e0ebbf7..1bbe47b7 100644 --- a/pkg/encoder/yuv/yuv_test.go +++ b/pkg/worker/encoder/yuv/yuv_test.go @@ -1,205 +1,221 @@ package yuv import ( - "archive/zip" "fmt" "image" "image/color" "image/png" - "io" "math" - "math/rand/v2" + "math/rand" "os" - "path/filepath" + "reflect" + "runtime" "testing" - - "github.com/giongto35/cloud-game/v3/pkg/encoder/yuv/libyuv" - _ "github.com/giongto35/cloud-game/v3/test" + "time" ) -func TestYuvPredefined(t *testing.T) { - t.Skip("Skipped because on Windows some colors are different") - im := []uint8{101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255} - should := []byte{ - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, - 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, - 110, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, - 110, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, - 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 110, 94, 94, 94, - 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, - 94, 94, 94, 94, 110, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, - 94, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, - 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, - 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, - 110, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, - 110, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, - 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 110, 94, 94, 94, - 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, - 94, 94, 94, 94, 110, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 76, 47, 47, 47, - 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 76, - 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, - 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, - 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, - 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, - 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, - 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, - 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, - 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, - 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, - 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, - 47, 76, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, - } +func TestYuv(t *testing.T) { + size1, size2 := 32, 32 + for i := 1; i < 100; i++ { + img := generateImage(size1, size2, randomColor()) + pc := NewYuvImgProcessor(size1, size2, new(Options)) + pct := NewYuvImgProcessor(size1, size2, &Options{Threads: runtime.NumCPU()}) - pc := NewYuvConv(32, 32, 1) - frame := RawFrame{Data: im, Stride: 32, W: 32, H: 32} - a := pc.Process(frame, 0, PixFmt(libyuv.FourccAbgr)) + a := pc.Process(img) + b := pct.Process(img) - v := libyuv.Version() - t.Logf("%v", v) - - if len(a) != len(should) { - t.Fatalf("different size a: %v, o: %v", len(a), len(should)) - } - - for i := range a { - if a[i] != should[i] { - t.Fatalf("diff in %vth, %v != %v \n%v\n%v", i, a[i], should[i], im, should) + if !reflect.DeepEqual(a, b) { + t.Fatalf("couldn't convert %v, \n %v \n %v", img.Pix, a, b) } } } -func TestYuvScale(t *testing.T) { - name := "001_alsa_ABGR_256_240_1024.raw" - path := filepath.Join("./test/testdata/raw/", name) - - data, err := ReadZip(path + ".zip") - if err != nil { - t.Error(err) +func TestYuvPredefined(t *testing.T) { + im := []uint8{101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255} + should := []byte{ + 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, + 142, 142, 142, 142, 126, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 126, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 126, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 126, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 126, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 126, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 126, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 126, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 110, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, } - pf, w, h, stride := PixFmt(libyuv.FourccArgb), 256, 240, 1024 - scale := 2 + pc := NewYuvImgProcessor(32, 32, new(Options)) + pct := NewYuvImgProcessor(32, 32, &Options{Threads: runtime.NumCPU()}) - conv := NewYuvConv(w, h, float64(scale)) - frame := RawFrame{Data: data, Stride: stride, W: w, H: h} - out := conv.Process(frame, 0, pf) + img := image.NewRGBA(image.Rect(0, 0, 32, 32)) + img.Pix = im - d := float64(len(out)) / float64(len(data)) - if d != 1.5 { - t.Errorf("Scaled not by factor %v, %v", scale, d) + a := pc.Process(img) + b := pct.Process(img) + + if len(a) != len(b) || len(a) != len(should) || len(b) != len(should) { + t.Fatalf("diffrent size a: %v, b: %v, o: %v", len(a), len(b), len(should)) } - // save as RGBA - //sw, sh := w*scale, h*scale - //yuv := ToYCbCr(out, sw, sh) - //if f, err := os.Create(filepath.Join("./", name+".png")); err == nil { - // if err = png.Encode(f, yuv); err != nil { - // t.Logf("Couldn't encode the image, %v", err) - // } - // _ = f.Close() - //} + for i := 0; i < len(a); i++ { + if a[i] != b[i] || a[i] != should[i] || b[i] != should[i] { + t.Fatalf("diff in %vth, %v != %v != %v \n%v\n%v", i, a[i], b[i], should[i], im, should) + } + } } -func BenchmarkYuv(b *testing.B) { - tests := []struct { - w int - h int - }{ - {w: 1920, h: 1080}, - {w: 320, h: 240}, +func generateImage(w, h int, color color.RGBA) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + img.Set(x, y, color) + } } - r1 := rand.Float32() + return img +} - for _, test := range tests { - w, h := test.w, test.h - frame := genFrame(w, h, r1) - b.Run(fmt.Sprintf("%vx%v YUV", w, h), func(b *testing.B) { - pc := NewYuvConv(w, h, 1) - for i := 0; i < b.N; i++ { - pc.Process(frame, 0, PixFmt(libyuv.FourccAbgr)) - b.SetBytes(int64(len(frame.Data))) - } - b.ReportAllocs() +func randomColor() color.RGBA { + rnd := rand.New(rand.NewSource(time.Now().Unix())) + return color.RGBA{ + R: uint8(rnd.Intn(255)), + G: uint8(rnd.Intn(255)), + B: uint8(rnd.Intn(255)), + A: 255, + } +} + +func BenchmarkYUV(b *testing.B) { + cpu := runtime.NumCPU() + tests := []struct { + cpu int + w int + h int + }{ + {cpu: cpu * 0, w: 1920, h: 1080}, + {cpu: cpu * 2, w: 1920, h: 1080}, + {cpu: cpu * 4, w: 1920, h: 1080}, + {cpu: cpu * 0, w: 320, h: 240}, + {cpu: cpu * 2, w: 320, h: 240}, + {cpu: cpu * 4, w: 320, h: 240}, + } + for _, bn := range tests { + b.Run(fmt.Sprintf("%d-%vx%v", bn.cpu, bn.w, bn.h), func(b *testing.B) { + _processYUV(bn.w, bn.h, bn.cpu, b) }) } } -func genFrame(w, h int, seed float32) RawFrame { +func BenchmarkYUVReference(b *testing.B) { _processYUV(1920, 1080, 0, b) } + +func _processYUV(w, h, cpu int, b *testing.B) { + b.StopTimer() + + r1 := rand.New(rand.NewSource(int64(1))).Float32() + r2 := rand.New(rand.NewSource(int64(2))).Float32() + + pc := NewYuvImgProcessor(w, h, &Options{Threads: cpu}) + + image1 := genTestImage(w, h, r1) + image2 := genTestImage(w, h, r2) + + for i := 0; i < b.N; i++ { + im := image1 + if i%2 == 0 { + im = image2 + } + b.StartTimer() + pc.Process(im) + b.StopTimer() + b.SetBytes(int64(len(im.Pix))) + } + b.ReportAllocs() +} + +func genTestImage(w, h int, seed float32) *image.RGBA { img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) - for x := range w { - for y := range h { + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { col := color.RGBA{R: uint8(seed * 255), G: uint8(seed * 255), B: uint8(seed * 255), A: 0xff} img.Set(x, y, col) } } - return RawFrame{ - Data: img.Pix, - Stride: img.Stride, - W: img.Bounds().Dx(), - H: img.Bounds().Dy(), - } + return img } func TestGen24bitFull(t *testing.T) { @@ -217,9 +233,9 @@ func TestGen24bitFull(t *testing.T) { // radius = centerY //} - for y := range wh { + for y := 0; y < wh; y++ { dy := float64(y - centerY) - for x := range wh { + for x := 0; x < wh; x++ { dx := float64(x - centerX) dist := math.Sqrt(dx*dx + dy*dy) if dist <= float64(radius) { @@ -230,12 +246,18 @@ func TestGen24bitFull(t *testing.T) { } } - f, _ := os.Create("out_image.png") + f, err := os.Create("outimage.png") + if err != nil { + // Handle error + } defer func() { _ = f.Close() }() // Encode to `PNG` with `DefaultCompression` level // then save to file - _ = png.Encode(f, img) + err = png.Encode(f, img) + if err != nil { + // Handle error + } } func linear(a, b, x float64) float64 { return (x - a) / (b - a) } @@ -266,19 +288,3 @@ func hsb2rgb(hue, s, bri float64) (r, g, b int) { } return } - -func ReadZip(path string) ([]byte, error) { - zf, err := zip.OpenReader(path) - if err != nil { - return nil, err - } - defer func() { _ = zf.Close() }() - - f, err := zf.File[0].Open() - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - - return io.ReadAll(f) -} diff --git a/pkg/worker/media.go b/pkg/worker/media.go new file mode 100644 index 00000000..946dc3d3 --- /dev/null +++ b/pkg/worker/media.go @@ -0,0 +1,178 @@ +package worker + +import ( + "sync" + "time" + + conf "github.com/giongto35/cloud-game/v2/pkg/config/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/h264" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/opus" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/vpx" + webrtc "github.com/pion/webrtc/v3/pkg/media" +) + +var ( + encoderOnce = sync.Once{} + opusCoder *opus.Encoder + samplePool sync.Pool + audioPool = sync.Pool{New: func() any { b := make([]int16, 3000); return &b }} +) + +const ( + audioChannels = 2 + audioCodec = "opus" + audioFrequency = 48000 +) + +// Buffer is a simple non-thread safe ring buffer for audio samples. +// It should be used for 16bit PCM (LE interleaved) data. +type ( + Buffer struct { + s Samples + wi int + } + OnFull func(s Samples) + Samples []int16 +) + +func NewBuffer(numSamples int) Buffer { return Buffer{s: make(Samples, numSamples)} } + +// Write fills the buffer with data calling a callback function when +// the internal buffer fills out. +// +// Consider two cases: +// +// 1. Underflow, when the length of written data is less than the buffer's available space. +// 2. Overflow, when the length exceeds the current available buffer space. +// In the both cases we overwrite any previous values in the buffer and move the internal +// write pointer on the length of written data. +// In the first case we won't call the callback, but it will be called every time +// when the internal buffer overflows until all samples are read. +func (b *Buffer) Write(s Samples, onFull OnFull) (r int) { + for r < len(s) { + w := copy(b.s[b.wi:], s[r:]) + r += w + b.wi += w + if b.wi == len(b.s) { + b.wi = 0 + if onFull != nil { + onFull(b.s) + } + } + } + return +} + +// GetFrameSizeFor calculates audio frame size, i.e. 48k*frame/1000*2 +func GetFrameSizeFor(hz int, frame int) int { return hz * frame / 1000 * audioChannels } + +func (r *Room) initAudio(frequency int, conf conf.Audio) { + buf := NewBuffer(GetFrameSizeFor(frequency, conf.Frame)) + resample, frameLen := frequency != audioFrequency, 0 + if resample { + frameLen = GetFrameSizeFor(audioFrequency, conf.Frame) + } + + encoderOnce.Do(func() { + enc, err := opus.NewEncoder(audioFrequency) + if err != nil { + r.log.Fatal().Err(err).Msg("couldn't create audio encoder") + } + opusCoder = enc + }) + if err := opusCoder.Reset(); err != nil { + r.log.Error().Err(err).Msgf("opus state reset fail") + } + r.log.Debug().Msgf("Opus: %v", opusCoder.GetInfo()) + + dur := time.Duration(conf.Frame) * time.Millisecond + + fn := func(s Samples) { + if resample { + s = ResampleStretchNew(s, frameLen) + } + f, err := opusCoder.Encode(s) + audioPool.Put((*[]int16)(&s)) + if err == nil { + r.handleSample(f, dur, func(u *Session, s *webrtc.Sample) { + if err := u.SendAudio(s); err != nil { + r.log.Error().Err(err).Send() + } + }) + } + } + r.emulator.SetAudio(func(samples *emulator.GameAudio) { buf.Write(*samples.Data, fn) }) +} + +// initVideo processes videoFrames images with an encoder (codec) then pushes the result to WebRTC. +func (r *Room) initVideo(width, height int, conf conf.Video) { + var enc encoder.Encoder + var err error + + r.log.Info().Msgf("Video codec: %v", conf.Codec) + if conf.Codec == string(encoder.H264) { + r.log.Debug().Msgf("x264: build v%v", h264.LibVersion()) + enc, err = h264.NewEncoder(width, height, &h264.Options{ + Crf: conf.H264.Crf, + Tune: conf.H264.Tune, + Preset: conf.H264.Preset, + Profile: conf.H264.Profile, + LogLevel: int32(conf.H264.LogLevel), + }) + } else { + enc, err = vpx.NewEncoder(width, height, &vpx.Options{ + Bitrate: conf.Vpx.Bitrate, + KeyframeInt: conf.Vpx.KeyframeInterval, + }) + } + + if err != nil { + r.log.Error().Err(err).Msg("couldn't create a video encoder") + return + } + + r.vEncoder = encoder.NewVideoEncoder(enc, width, height, conf.Concurrency, r.log) + + r.emulator.SetVideo(func(frame *emulator.GameFrame) { + if fr := r.vEncoder.Encode(frame.Data.RGBA); fr != nil { + r.handleSample(fr, frame.Duration, func(u *Session, s *webrtc.Sample) { + if err := u.SendVideo(s); err != nil { + r.log.Error().Err(err).Send() + } + }) + } + }) +} + +func (r *Room) handleSample(b []byte, d time.Duration, fn func(*Session, *webrtc.Sample)) { + sample, _ := samplePool.Get().(*webrtc.Sample) + if sample == nil { + sample = new(webrtc.Sample) + } + sample.Data = b + sample.Duration = d + r.users.ForEach(func(u *Session) { + if u.IsConnected() { + fn(u, sample) + } + }) + samplePool.Put(sample) +} + +// ResampleStretchNew does a simple stretching of audio samples. +// something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] +func ResampleStretchNew(pcm []int16, size int) []int16 { + out := (*audioPool.Get().(*[]int16))[:size] + n := len(pcm) + ratio := float32(size) / float32(n) + for i, l, r := 0, 0, 0; i < n; i += 2 { + l, r = r, int(float32((i+2)>>1)*ratio)<<1 + for j := l; j < r-1; j += 2 { + out[j] = pcm[i] + out[j+1] = pcm[i+1] + } + } + return out +} diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go deleted file mode 100644 index e13bb1f0..00000000 --- a/pkg/worker/media/buffer.go +++ /dev/null @@ -1,143 +0,0 @@ -package media - -import ( - "errors" - - "github.com/giongto35/cloud-game/v3/pkg/resampler" -) - -type ResampleAlgo uint8 - -const ( - ResampleNearest ResampleAlgo = iota - ResampleLinear - ResampleSpeex -) - -type buffer struct { - raw samples - scratch samples - buckets []bucket - srcHz int - dstHz int - bi int - algo ResampleAlgo - - resampler *resampler.Resampler -} - -type bucket struct { - mem samples - ms float32 - p int - dst int -} - -func newBuffer(frames []float32, hz int) (*buffer, error) { - if hz < 2000 || len(frames) == 0 { - return nil, errors.New("invalid params") - } - - buckets := make([]bucket, len(frames)) - var total int - for i, ms := range frames { - n := stereoSamples(hz, ms) - buckets[i] = bucket{ms: ms, dst: n} - total += n - } - if total == 0 { - return nil, errors.New("zero buffer size") - } - - raw := make(samples, total) - for i, off := 0, 0; i < len(buckets); i++ { - buckets[i].mem = raw[off : off+buckets[i].dst] - off += buckets[i].dst - } - - return &buffer{ - raw: raw, - scratch: make(samples, 5760), - buckets: buckets, - srcHz: hz, - dstHz: hz, - bi: len(buckets) - 1, - }, nil -} - -func (b *buffer) close() { - if b.resampler != nil { - b.resampler.Destroy() - b.resampler = nil - } -} - -func (b *buffer) resample(hz int, algo ResampleAlgo) error { - b.algo, b.dstHz = algo, hz - for i := range b.buckets { - b.buckets[i].dst = stereoSamples(hz, b.buckets[i].ms) - } - if algo == ResampleSpeex { - var err error - b.resampler, err = resampler.Init(2, b.srcHz, hz, resampler.QualityMax) - return err - } - return nil -} - -func (b *buffer) write(s samples, onFull func(samples, float32)) int { - n := len(s) - for i := 0; i < n; { - cur := &b.buckets[b.bi] - c := copy(cur.mem[cur.p:], s[i:]) - i += c - cur.p += c - if cur.p == len(cur.mem) { - onFull(b.stretch(cur.mem, cur.dst), cur.ms) - b.choose(n - i) - b.buckets[b.bi].p = 0 - } - } - return n -} - -func (b *buffer) choose(rem int) { - for i := len(b.buckets) - 1; i >= 0; i-- { - if rem >= len(b.buckets[i].mem) { - b.bi = i - return - } - } - b.bi = 0 -} - -func (b *buffer) stretch(src samples, size int) samples { - if len(src) == size { - return src - } - - if cap(b.scratch) < size { - b.scratch = make(samples, size) - } - out := b.scratch[:size] - - if b.algo == ResampleSpeex && b.resampler != nil { - if n, _ := b.resampler.Process(out, src); n > 0 { - for i := n; i < size; i += 2 { - out[i], out[i+1] = out[n-2], out[n-1] - } - return out - } - } - - if b.algo == ResampleNearest { - resampler.Nearest(out, src) - } else { - resampler.Linear(out, src) - } - return out -} - -func stereoSamples(hz int, ms float32) int { - return int(float32(hz)*ms/1000+0.5) * 2 -} diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go deleted file mode 100644 index 6c8d300a..00000000 --- a/pkg/worker/media/buffer_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package media - -import ( - "reflect" - "testing" - - "github.com/giongto35/cloud-game/v3/pkg/resampler" -) - -func mustBuffer(t *testing.T, frames []float32, hz int) *buffer { - t.Helper() - buf, err := newBuffer(frames, hz) - if err != nil { - t.Fatalf("failed to create buffer: %v", err) - } - return buf -} - -func samplesOf(v int16, n int) samples { - s := make(samples, n) - for i := range s { - s[i] = v - } - return s -} - -func ramp(pairs int) samples { - s := make(samples, pairs*2) - for i := range pairs { - s[i*2], s[i*2+1] = int16(i), int16(i) - } - return s -} - -func TestNewBuffer(t *testing.T) { - tests := []struct { - name string - frames []float32 - hz int - wantErr bool - }{ - {"valid single", []float32{10}, 48000, false}, - {"valid multi", []float32{10, 20}, 48000, false}, - {"hz too low", []float32{10}, 1999, true}, - {"empty frames", []float32{}, 48000, true}, - {"nil frames", nil, 48000, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf, err := newBuffer(tt.frames, tt.hz) - if (err != nil) != tt.wantErr { - t.Errorf("err = %v, wantErr %v", err, tt.wantErr) - } - if buf != nil { - buf.close() - } - }) - } -} - -func TestBufferBucketSizes(t *testing.T) { - buf := mustBuffer(t, []float32{10, 20}, 48000) - defer buf.close() - - if len(buf.buckets) != 2 { - t.Fatalf("got %d buckets, want 2", len(buf.buckets)) - } - if n := len(buf.buckets[0].mem); n != 960 { - t.Errorf("bucket[0] = %d, want 960", n) - } - if n := len(buf.buckets[1].mem); n != 1920 { - t.Errorf("bucket[1] = %d, want 1920", n) - } -} - -func TestBufferClose(t *testing.T) { - buf := mustBuffer(t, []float32{10}, 48000) - buf.close() - buf.close() // idempotent - if buf.resampler != nil { - t.Error("resampler should be nil after close") - } -} - -func TestBufferWrite(t *testing.T) { - tests := []struct { - name string - writes []struct { - v int16 - n int - } - want samples - }{ - { - name: "overflow triggers callback", - writes: []struct { - v int16 - n int - }{{1, 10}, {2, 20}, {3, 30}}, - want: samples{ - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - }, - }, - { - name: "partial fill", - writes: []struct { - v int16 - n int - }{{1, 3}, {2, 18}, {3, 2}}, - want: samples{1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := mustBuffer(t, []float32{10, 5}, 2000) - defer buf.close() - - var got samples - for _, w := range tt.writes { - buf.write(samplesOf(w.v, w.n), func(s samples, _ float32) { got = s }) - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("\ngot: %v\nwant: %v", got, tt.want) - } - }) - } -} - -func TestBufferWriteExact(t *testing.T) { - buf := mustBuffer(t, []float32{10}, 2000) // 40 samples - defer buf.close() - - calls := 0 - buf.write(samplesOf(1, 40), func(_ samples, ms float32) { - calls++ - if ms != 10 { - t.Errorf("ms = %v, want 10", ms) - } - }) - if calls != 1 { - t.Errorf("calls = %d, want 1", calls) - } -} - -func TestBufferWriteReturn(t *testing.T) { - buf := mustBuffer(t, []float32{10}, 2000) - defer buf.close() - - if n := buf.write(samplesOf(1, 100), func(samples, float32) {}); n != 100 { - t.Errorf("return = %d, want 100", n) - } -} - -func TestBufferChoose(t *testing.T) { - buf := mustBuffer(t, []float32{20, 10, 5}, 48000) // 1920, 960, 480 - defer buf.close() - - tests := []struct{ rem, want int }{ - {10000, 2}, {500, 2}, {479, 0}, {0, 0}, - } - for _, tt := range tests { - buf.choose(tt.rem) - if buf.bi != tt.want { - t.Errorf("choose(%d) = %d, want %d", tt.rem, buf.bi, tt.want) - } - } -} - -func TestStereoSamples(t *testing.T) { - tests := []struct { - hz int - ms float32 - want int - }{ - {16000, 5, 160}, - {32768, 10, 656}, - {32768, 2.5, 164}, - {32768, 5, 328}, - {44100, 10, 882}, - {48000, 10, 960}, - {48000, 2.5, 240}, - } - for _, tt := range tests { - if got := stereoSamples(tt.hz, tt.ms); got != tt.want { - t.Errorf("stereoSamples(%d, %.0f) = %d, want %d", tt.hz, tt.ms, got, tt.want) - } - } -} - -func TestStretchPassthrough(t *testing.T) { - buf := mustBuffer(t, []float32{10}, 48000) - defer buf.close() - - src := samples{1, 2, 3, 4} - if res := buf.stretch(src, 4); &res[0] != &src[0] { - t.Error("expected zero-copy when sizes match") - } -} - -func TestLinear(t *testing.T) { - t.Run("interpolation", func(t *testing.T) { - out := make(samples, 8) - resampler.Linear(out, samples{0, 0, 100, 100}) - if out[2] <= 0 || out[2] >= 100 { - t.Errorf("middle value %d not interpolated", out[2]) - } - }) - - t.Run("sizes", func(t *testing.T) { - cases := []struct{ srcPairs, dstSize int }{ - {4, 16}, {8, 8}, {4, 8}, - } - for _, tc := range cases { - out := make(samples, tc.dstSize) - resampler.Linear(out, ramp(tc.srcPairs)) - if len(out) != tc.dstSize { - t.Errorf("len = %d, want %d", len(out), tc.dstSize) - } - } - }) -} - -func TestNearest(t *testing.T) { - tests := []struct { - src samples - want samples - }{ - {samples{10, 20, 30, 40}, samples{10, 20, 10, 20, 30, 40, 30, 40}}, - {samples{10, 20, 30, 40, 50, 60, 70, 80}, samples{10, 20, 50, 60}}, - } - for _, tt := range tests { - out := make(samples, len(tt.want)) - resampler.Nearest(out, tt.src) - if !reflect.DeepEqual(out, tt.want) { - t.Errorf("nearest(%v) = %v, want %v", tt.src, out, tt.want) - } - } -} - -func TestSpeex(t *testing.T) { - buf := mustBuffer(t, []float32{10}, 48000) - defer buf.close() - - if err := buf.resample(24000, ResampleSpeex); err != nil { - t.Fatal(err) - } - - t.Run("stretch", func(t *testing.T) { - res := buf.stretch(samplesOf(1000, 960), 480) - if len(res) != 480 { - t.Errorf("len = %d, want 480", len(res)) - } - for _, s := range res { - if s != 0 { - return - } - } - t.Error("output is silent") - }) - - t.Run("write", func(t *testing.T) { - calls := 0 - buf.write(samplesOf(5000, 960), func(s samples, ms float32) { - calls++ - if len(s) != 480 { - t.Errorf("len = %d, want 480", len(s)) - } - if ms != 10 { - t.Errorf("ms = %v, want 10", ms) - } - }) - if calls != 1 { - t.Errorf("calls = %d, want 1", calls) - } - }) -} - -func BenchmarkStretch(b *testing.B) { - src := samplesOf(1000, 1920) // 20ms @ 48kHz - - b.Run("speex", func(b *testing.B) { - buf, _ := newBuffer([]float32{20}, 48000) - defer buf.close() - _ = buf.resample(24000, ResampleSpeex) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - buf.stretch(src, 960) - } - }) - - b.Run("linear", func(b *testing.B) { - buf, _ := newBuffer([]float32{20}, 48000) - defer buf.close() - _ = buf.resample(24000, ResampleLinear) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - buf.stretch(src, 960) - } - }) - - b.Run("nearest", func(b *testing.B) { - buf, _ := newBuffer([]float32{20}, 48000) - defer buf.close() - _ = buf.resample(24000, ResampleNearest) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - buf.stretch(src, 960) - } - }) -} diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go deleted file mode 100644 index 0d1407d6..00000000 --- a/pkg/worker/media/media.go +++ /dev/null @@ -1,194 +0,0 @@ -package media - -import ( - "fmt" - "sync" - "time" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/encoder" - "github.com/giongto35/cloud-game/v3/pkg/encoder/opus" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" -) - -const audioHz = 48000 - -type samples []int16 - -var ( - encoderOnce = sync.Once{} - opusCoder *opus.Encoder -) - -func DefaultOpus() (*opus.Encoder, error) { - var err error - encoderOnce.Do(func() { opusCoder, err = opus.NewEncoder(audioHz) }) - if err != nil { - return nil, err - } - if err = opusCoder.Reset(); err != nil { - return nil, err - } - return opusCoder, nil -} - -type WebrtcMediaPipe struct { - a *opus.Encoder - v *encoder.Video - onAudio func([]byte, float32) - audioBuf *buffer - log *logger.Logger - - mua sync.RWMutex - muv sync.RWMutex - - aConf config.Audio - vConf config.Video - - AudioSrcHz int - AudioFrames []float32 - VideoW, VideoH int - VideoScale float64 - - initialized bool - - // keep the old settings for reinit - oldPf uint32 - oldRot uint - oldFlip bool -} - -func NewWebRtcMediaPipe(ac config.Audio, vc config.Video, log *logger.Logger) *WebrtcMediaPipe { - return &WebrtcMediaPipe{log: log, aConf: ac, vConf: vc} -} - -func (wmp *WebrtcMediaPipe) SetAudioCb(cb func([]byte, int32)) { - wmp.onAudio = func(bytes []byte, ms float32) { - cb(bytes, int32(time.Duration(ms)*time.Millisecond)) - } -} -func (wmp *WebrtcMediaPipe) Destroy() { - v := wmp.Video() - if v != nil { - v.Stop() - } -} -func (wmp *WebrtcMediaPipe) PushAudio(audio []int16) { - wmp.audioBuf.write(audio, wmp.encodeAudio) -} - -func (wmp *WebrtcMediaPipe) Init() error { - if err := wmp.initAudio(wmp.AudioSrcHz, wmp.AudioFrames); err != nil { - return err - } - if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil { - return err - } - - a := wmp.Audio() - v := wmp.Video() - - if v == nil || a == nil { - return fmt.Errorf("could intit the encoders, v=%v a=%v", v != nil, a != nil) - } - - wmp.log.Debug().Msgf("%v", v.Info()) - wmp.initialized = true - return nil -} - -func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSizes []float32) error { - au, err := DefaultOpus() - if err != nil { - return fmt.Errorf("opus fail: %w", err) - } - wmp.log.Debug().Msgf("Opus: %v", au.GetInfo()) - wmp.SetAudio(au) - buf, err := newBuffer(frameSizes, srcHz) - if err != nil { - return err - } - wmp.log.Debug().Msgf("Opus frames (ms): %v", frameSizes) - dstHz, _ := au.SampleRate() - if srcHz != dstHz { - buf.resample(dstHz, ResampleAlgo(wmp.aConf.Resampler)) - wmp.log.Debug().Msgf("Resample %vHz -> %vHz", srcHz, dstHz) - } - wmp.audioBuf = buf - return nil -} - -func (wmp *WebrtcMediaPipe) encodeAudio(pcm samples, ms float32) { - data, err := wmp.Audio().Encode(pcm) - if err != nil { - wmp.log.Error().Err(err).Msgf("opus encode fail") - return - } - wmp.onAudio(data, ms) -} - -func (wmp *WebrtcMediaPipe) initVideo(w, h int, scale float64, conf config.Video) (err error) { - sw, sh := round(w, scale), round(h, scale) - enc, err := encoder.NewVideoEncoder(w, h, sw, sh, scale, conf, wmp.log) - if err != nil { - return err - } - if enc == nil { - return fmt.Errorf("broken video encoder init") - } - wmp.SetVideo(enc) - wmp.log.Debug().Msgf("media scale: %vx%v -> %vx%v", w, h, sw, sh) - return err -} - -func round(x int, scale float64) int { return (int(float64(x)*scale) + 1) & ^1 } - -func (wmp *WebrtcMediaPipe) ProcessVideo(v app.Video) []byte { - return wmp.Video().Encode(encoder.InFrame(v.Frame)) -} - -func (wmp *WebrtcMediaPipe) Reinit() error { - if !wmp.initialized { - return nil - } - - wmp.Video().Stop() - if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil { - return err - } - // restore old - wmp.SetPixFmt(wmp.oldPf) - wmp.SetRot(wmp.oldRot) - wmp.SetVideoFlip(wmp.oldFlip) - return nil -} - -func (wmp *WebrtcMediaPipe) IsInitialized() bool { return wmp.initialized } -func (wmp *WebrtcMediaPipe) SetPixFmt(f uint32) { wmp.oldPf = f; wmp.v.SetPixFormat(f) } -func (wmp *WebrtcMediaPipe) SetVideoFlip(b bool) { wmp.oldFlip = b; wmp.v.SetFlip(b) } -func (wmp *WebrtcMediaPipe) SetRot(r uint) { wmp.oldRot = r; wmp.v.SetRot(r) } - -func (wmp *WebrtcMediaPipe) Video() *encoder.Video { - wmp.muv.RLock() - defer wmp.muv.RUnlock() - return wmp.v -} - -func (wmp *WebrtcMediaPipe) SetVideo(e *encoder.Video) { - wmp.muv.Lock() - wmp.v = e - wmp.muv.Unlock() -} - -func (wmp *WebrtcMediaPipe) Audio() *opus.Encoder { - wmp.mua.RLock() - defer wmp.mua.RUnlock() - return wmp.a -} - -func (wmp *WebrtcMediaPipe) SetAudio(e *opus.Encoder) { - wmp.mua.Lock() - wmp.a = e - wmp.mua.Unlock() -} diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go deleted file mode 100644 index a0fd9399..00000000 --- a/pkg/worker/media/media_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package media - -import ( - "image" - "math/rand/v2" - "testing" - - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/encoder" - "github.com/giongto35/cloud-game/v3/pkg/logger" -) - -var l = logger.New(false) - -func TestEncoders(t *testing.T) { - tests := []struct { - n int - w, h int - codec encoder.VideoCodec - frames int - }{ - {n: 3, w: 1920, h: 1080, codec: encoder.H264, frames: 60}, - {n: 3, w: 1920, h: 1080, codec: encoder.VP8, frames: 60}, - } - - for _, test := range tests { - a := genTestImage(test.w, test.h, rand.Float32()) - b := genTestImage(test.w, test.h, rand.Float32()) - for i := 0; i < test.n; i++ { - run(test.w, test.h, test.codec, test.frames, a, b, t) - } - } -} - -func BenchmarkH264(b *testing.B) { run(640, 480, encoder.H264, b.N, nil, nil, b) } -func BenchmarkVP8(b *testing.B) { run(1920, 1080, encoder.VP8, b.N, nil, nil, b) } - -func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RGBA, backend testing.TB) { - conf := config.Video{ - Codec: string(cod), - Threads: 0, - H264: struct { - Mode string - Crf uint8 - MaxRate int - BufSize int - LogLevel int32 - Preset string - Profile string - Tune string - }{ - Crf: 30, - LogLevel: 0, - Preset: "ultrafast", - Profile: "baseline", - Tune: "zerolatency", - }, - Vpx: struct { - Bitrate uint - KeyframeInterval uint - }{ - Bitrate: 1000, - KeyframeInterval: 5, - }, - } - - logger.SetGlobalLevel(logger.Disabled) - ve, err := encoder.NewVideoEncoder(w, h, w, h, 1, conf, l) - if err != nil { - backend.Error(err) - return - } - defer ve.Stop() - - if a == nil { - a = genTestImage(w, h, rand.Float32()) - } - if b == nil { - b = genTestImage(w, h, rand.Float32()) - } - - for i := range count { - im := a - if i%2 == 0 { - im = b - } - out := ve.Encode(encoder.InFrame{ - Data: im.Pix, - Stride: im.Stride, - W: im.Bounds().Dx(), - H: im.Bounds().Dy(), - }) - if out == nil { - backend.Fatalf("encoder closed abnormally") - } - } -} - -func genTestImage(w, h int, seed float32) *image.RGBA { - img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) - for x := range w { - for y := range h { - i := img.PixOffset(x, y) - s := img.Pix[i : i+4 : i+4] - s[0] = uint8(seed * 255) - s[1] = uint8(seed * 255) - s[2] = uint8(seed * 255) - s[3] = 0xff - } - } - return img -} diff --git a/pkg/worker/media_test.go b/pkg/worker/media_test.go new file mode 100644 index 00000000..c4e772c0 --- /dev/null +++ b/pkg/worker/media_test.go @@ -0,0 +1,215 @@ +package worker + +import ( + "fmt" + "image" + "math/rand" + "reflect" + "testing" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/h264" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/vpx" +) + +var l = logger.New(false) + +func TestEncoders(t *testing.T) { + tests := []struct { + n int + w, h int + codec encoder.VideoCodec + frames int + }{ + {n: 3, w: 1920, h: 1080, codec: encoder.H264, frames: 60}, + {n: 3, w: 1920, h: 1080, codec: encoder.VP8, frames: 60}, + } + + for _, test := range tests { + a := genTestImage(test.w, test.h, rand.New(rand.NewSource(int64(1))).Float32()) + b := genTestImage(test.w, test.h, rand.New(rand.NewSource(int64(2))).Float32()) + for i := 0; i < test.n; i++ { + run(test.w, test.h, test.codec, test.frames, a, b, t) + } + } +} + +func BenchmarkH264(b *testing.B) { run(1920, 1080, encoder.H264, b.N, nil, nil, b) } +func BenchmarkVP8(b *testing.B) { run(1920, 1080, encoder.VP8, b.N, nil, nil, b) } + +func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RGBA, backend testing.TB) { + var enc encoder.Encoder + if cod == encoder.H264 { + enc, _ = h264.NewEncoder(w, h, nil) + } else { + enc, _ = vpx.NewEncoder(w, h, nil) + } + + logger.SetGlobalLevel(logger.Disabled) + ve := encoder.NewVideoEncoder(enc, w, h, 8, l) + defer ve.Stop() + + if a == nil { + a = genTestImage(w, h, rand.New(rand.NewSource(int64(1))).Float32()) + } + if b == nil { + b = genTestImage(w, h, rand.New(rand.NewSource(int64(2))).Float32()) + } + + for i := 0; i < count; i++ { + im := a + if i%2 == 0 { + im = b + } + out := ve.Encode(im) + if out == nil { + backend.Fatalf("encoder closed abnormally") + } + } +} + +func genTestImage(w, h int, seed float32) *image.RGBA { + img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + i := img.PixOffset(x, y) + s := img.Pix[i : i+4 : i+4] + s[0] = uint8(seed * 255) + s[1] = uint8(seed * 255) + s[2] = uint8(seed * 255) + s[3] = 0xff + } + } + return img +} + +func TestResampleStretch(t *testing.T) { + type args struct { + pcm []int16 + size int + } + tests := []struct { + name string + args args + want []int16 + }{ + //1764:1920 + { + name: "", + args: args{ + pcm: gen(1764), + size: 1920, + }, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rez2 := ResampleStretchNew(tt.args.pcm, tt.args.size) + + if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || + rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || + rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { + t.Logf("%v\n%v", tt.args.pcm, rez2) + t.Errorf("2nd is wrong (2)") + } + }) + } +} + +func BenchmarkResampler(b *testing.B) { + tests := []struct { + name string + fn func(pcm []int16, size int) []int16 + }{ + {name: "new", fn: ResampleStretchNew}, + } + pcm := gen(1764) + size := 1920 + for _, bn := range tests { + b.Run(fmt.Sprintf("%v", bn.name), func(b *testing.B) { + for i := 0; i < b.N; i++ { + bn.fn(pcm, size) + } + }) + } +} + +func gen(l int) []int16 { + rand.Seed(time.Now().Unix()) + + nums := make([]int16, l) + for i := range nums { + nums[i] = int16(rand.Intn(10)) + } + //for i := len(nums) / 2; i < len(nums)/2+42; i++ { + // nums[i] = 0 + //} + + return nums +} + +type bufWrite struct { + sample int16 + len int +} + +func TestBufferWrite(t *testing.T) { + tests := []struct { + bufLen int + writes []bufWrite + expect Samples + }{ + { + bufLen: 20, + writes: []bufWrite{ + {sample: 1, len: 10}, + {sample: 2, len: 20}, + {sample: 3, len: 30}, + }, + expect: Samples{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, + }, + { + bufLen: 11, + writes: []bufWrite{ + {sample: 1, len: 3}, + {sample: 2, len: 18}, + {sample: 3, len: 2}, + }, + expect: Samples{3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3}, + }, + } + + for _, test := range tests { + var lastResult Samples + buf := NewBuffer(test.bufLen) + for _, w := range test.writes { + buf.Write(samplesOf(w.sample, w.len), func(s Samples) { lastResult = s }) + } + if !reflect.DeepEqual(test.expect, lastResult) { + t.Errorf("not expted buffer, %v != %v", lastResult, test.expect) + } + } +} + +func BenchmarkBufferWrite(b *testing.B) { + fn := func(_ Samples) {} + l := 1920 + buf := NewBuffer(l) + samples1 := samplesOf(1, l/2) + samples2 := samplesOf(2, l*2) + for i := 0; i < b.N; i++ { + buf.Write(samples1, fn) + buf.Write(samples2, fn) + } +} + +func samplesOf(v int16, len int) (s Samples) { + s = make(Samples, len) + for i := range s { + s[i] = v + } + return +} diff --git a/pkg/worker/recorder/draw.go b/pkg/worker/recorder/draw.go new file mode 100644 index 00000000..e3f6d4f8 --- /dev/null +++ b/pkg/worker/recorder/draw.go @@ -0,0 +1,39 @@ +package recorder + +import ( + "fmt" + "image" + "image/color" + "image/draw" + "time" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +func AddLabel(img *image.RGBA, x, y int, label string) { + draw.Draw(img, image.Rect(x, y, x+len(label)*7+3, y+12), &image.Uniform{C: color.RGBA{}}, image.Point{}, draw.Src) + (&font.Drawer{ + Dst: img, + Src: image.NewUniform(color.RGBA{R: 255, G: 255, B: 255, A: 255}), + Face: basicfont.Face7x13, + Dot: fixed.Point26_6{X: fixed.Int26_6((x + 2) * 64), Y: fixed.Int26_6((y + 10) * 64)}, + }).DrawString(label) +} + +func clone(src image.Image) *image.RGBA { + b := src.Bounds() + dst := image.NewRGBA(b) + draw.Draw(dst, b, src, b.Min, draw.Src) + return dst +} + +func TimeFormat(d time.Duration) string { + mms := int(d.Milliseconds()) + ms := mms % 1000 + s := (mms / 1000) % 60 + m := (mms / (1000 * 60)) % 60 + h := (mms / (1000 * 60 * 60)) % 24 + return fmt.Sprintf("%02d:%02d:%02d.%03d", h, m, s, ms) +} diff --git a/pkg/worker/recorder/ffmpegmux.go b/pkg/worker/recorder/ffmpegmux.go index ba543551..4869ef71 100644 --- a/pkg/worker/recorder/ffmpegmux.go +++ b/pkg/worker/recorder/ffmpegmux.go @@ -15,23 +15,6 @@ const demuxFile = "input.txt" // ffmpeg concat demuxer, see: https://ffmpeg.org/ffmpeg-formats.html#concat // example: // -// !to change -// -// - can't read pix_fmt from ffconcat -// - maybe change raw output to yuv420? -// - frame durations and size can change dynamically -// - or maybe merge encoded streams -// -// new: -// -// ffmpeg -f image2 -framerate 59 -video_size 384x224 -pixel_format rgb565le \ -// -i "./f%07d__384x224__768.raw" \ -// -ac 2 -channel_layout stereo -i audio.wav -b:a 192K \ -// -c:v libx264 -pix_fmt yuv420p -crf 20 \ -// output.mp4 -// -// old: -// // ffmpeg -f concat -i input.txt \ // -ac 2 -channel_layout stereo -i audio.wav \ // -b:a 192K -crf 23 -vf fps=30 -pix_fmt yuv420p \ @@ -42,17 +25,9 @@ func createFfmpegMuxFile(dir string, fPattern string, frameTimes []time.Duration return err } defer func() { er = demux.Close() }() - - b := strings.Builder{} - - b.WriteString("ffconcat version 1.0\n") - b.WriteString(meta("v", "1")) - b.WriteString(meta("date", time.Now().Format("20060102"))) - b.WriteString(meta("game", opts.Game)) - b.WriteString(meta("fps", opts.Fps)) - b.WriteString(meta("freq", opts.Frequency)) - b.WriteString(meta("pix", opts.Pix)) - _, err = demux.WriteString(fmt.Sprintf("%s\n", b.String())) + _, err = demux.WriteString( + fmt.Sprintf("ffconcat version 1.0\n# v: 1\n# date: %v\n# game: %v\n# fps: %v\n# freq (hz): %v\n\n", + time.Now().Format("20060102"), opts.Game, opts.Fps, opts.Frequency)) if err != nil { return err } @@ -76,9 +51,7 @@ func createFfmpegMuxFile(dir string, fPattern string, frameTimes []time.Duration } i++ } - w, h, s := ExtractFileInfo(file.Name()) - inf := fmt.Sprintf("file %v\nduration %f\n%s%s%s", name, dur, - metaf("width", w), metaf("height", h), metaf("stride", s)) + inf := fmt.Sprintf("file %v\nduration %f\n", name, dur) if _, err := demux.WriteString(inf); err != nil { er = err } @@ -88,11 +61,3 @@ func createFfmpegMuxFile(dir string, fPattern string, frameTimes []time.Duration } return er } - -// meta adds stream_meta key value line. -func meta(key string, value any) string { return fmt.Sprintf("stream_meta %s '%v'\n", key, value) } - -// metaf adds file_packet_meta key value line. -func metaf(key string, value any) string { - return fmt.Sprintf("file_packet_meta %s '%v'\n", key, value) -} diff --git a/pkg/worker/recorder/options.go b/pkg/worker/recorder/options.go index fe4ca7ce..9707e171 100644 --- a/pkg/worker/recorder/options.go +++ b/pkg/worker/recorder/options.go @@ -1,18 +1,14 @@ package recorder type Options struct { - Dir string - Fps float64 - W int - H int - Stride int - Flip bool - Frequency int - Pix string - Game string - Name string - Zip bool - Vsync bool + Dir string + Fps float64 + Frequency int + Game string + ImageCompressionLevel int + Name string + Zip bool + Vsync bool } type Meta struct { diff --git a/pkg/worker/recorder/pngstream.go b/pkg/worker/recorder/pngstream.go new file mode 100644 index 00000000..39fee7bb --- /dev/null +++ b/pkg/worker/recorder/pngstream.go @@ -0,0 +1,74 @@ +package recorder + +import ( + "bytes" + "fmt" + "image" + "image/png" + "log" + "os" + "path/filepath" + "sync" + "sync/atomic" +) + +type pngStream struct { + videoStream + + dir string + e *png.Encoder + id uint32 + wg sync.WaitGroup +} + +const videoFile = "f%07d.png" + +type pool struct{ sync.Pool } + +func pngBuf() *pool { return &pool{sync.Pool{New: func() any { return &png.EncoderBuffer{} }}} } +func (p *pool) Get() *png.EncoderBuffer { return p.Pool.Get().(*png.EncoderBuffer) } +func (p *pool) Put(b *png.EncoderBuffer) { p.Pool.Put(b) } + +func newPngStream(dir string, opts Options) (*pngStream, error) { + return &pngStream{ + dir: dir, + e: &png.Encoder{ + CompressionLevel: png.CompressionLevel(opts.ImageCompressionLevel), + BufferPool: pngBuf(), + }, + }, nil +} + +func (p *pngStream) Close() error { + atomic.StoreUint32(&p.id, 0) + p.wg.Wait() + return nil +} + +func (p *pngStream) Write(data Video) { + fileName := fmt.Sprintf(videoFile, atomic.AddUint32(&p.id, 1)) + p.wg.Add(1) + go p.saveImage(fileName, data.Image) +} + +func (p *pngStream) saveImage(fileName string, img image.Image) { + var buf bytes.Buffer + x, y := (img).Bounds().Dx(), (img).Bounds().Dy() + buf.Grow(x * y * 4) + + if err := p.e.Encode(&buf, img); err != nil { + log.Printf("p err: %v", err) + } else { + file, err := os.Create(filepath.Join(p.dir, fileName)) + if err != nil { + log.Printf("c err: %v", err) + } + if _, err = file.Write(buf.Bytes()); err != nil { + log.Printf("f err: %v", err) + } + if err = file.Close(); err != nil { + log.Printf("fc err: %v", err) + } + } + p.wg.Done() +} diff --git a/pkg/worker/recorder/rawstream.go b/pkg/worker/recorder/rawstream.go deleted file mode 100644 index 26b8875c..00000000 --- a/pkg/worker/recorder/rawstream.go +++ /dev/null @@ -1,66 +0,0 @@ -package recorder - -import ( - "fmt" - "log" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" -) - -type rawStream struct { - dir string - id uint32 - wg sync.WaitGroup -} - -const videoFile = "f%07d__%dx%d__%d.raw" - -func newRawStream(dir string) (*rawStream, error) { - return &rawStream{dir: dir}, nil -} - -func (p *rawStream) Close() error { - atomic.StoreUint32(&p.id, 0) - p.wg.Wait() - return nil -} - -func (p *rawStream) Write(data Video) { - i := atomic.AddUint32(&p.id, 1) - fileName := fmt.Sprintf(videoFile, i, data.Frame.W, data.Frame.H, data.Frame.Stride) - p.wg.Add(1) - go p.saveFrame(fileName, data.Frame) -} - -func (p *rawStream) saveFrame(fileName string, frame Frame) { - file, err := os.Create(filepath.Join(p.dir, fileName)) - if err != nil { - log.Printf("c err: %v", err) - } - if _, err = file.Write(frame.Data); err != nil { - log.Printf("f err: %v", err) - } - - if err = file.Close(); err != nil { - log.Printf("fc err: %v", err) - } - p.wg.Done() -} - -func ExtractFileInfo(name string) (w, h, st string) { - s1 := strings.Split(name, "__") - if len(s1) > 1 { - s12 := strings.Split(s1[1], "x") - if len(s12) > 1 { - w, h = s12[0], s12[1] - } - s21 := strings.TrimSuffix(s1[2], filepath.Ext(s1[2])) - if s21 != "" { - st = s21 - } - } - return -} diff --git a/pkg/worker/recorder/recorder.go b/pkg/worker/recorder/recorder.go index 8082ab50..7527d16b 100644 --- a/pkg/worker/recorder/recorder.go +++ b/pkg/worker/recorder/recorder.go @@ -1,8 +1,9 @@ package recorder import ( + "image" "io" - "math/rand/v2" + "math/rand" "os" "path/filepath" "regexp" @@ -10,8 +11,8 @@ import ( "sync" "time" - "github.com/giongto35/cloud-game/v3/pkg/logger" - oss "github.com/giongto35/cloud-game/v3/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/logger" + oss "github.com/giongto35/cloud-game/v2/pkg/os" ) type Recording struct { @@ -55,20 +56,17 @@ type videoStream interface { type ( Audio struct { - Samples []int16 + Samples *[]int16 Duration time.Duration } Video struct { - Frame Frame + Image image.Image Duration time.Duration } - Frame struct { - Data []byte - Stride int - W, H int - } ) +func init() { rand.Seed(time.Now().UnixNano()) } + // NewRecording creates new media recorder for the emulator. func NewRecording(meta Meta, log *logger.Logger, opts Options) *Recording { savePath, err := filepath.Abs(opts.Dir) @@ -98,13 +96,11 @@ func (r *Recording) Start() { audio, err := newWavStream(path, r.opts) if err != nil { r.log.Fatal().Err(err) - return } r.audio = audio - video, err := newRawStream(path) + video, err := newPngStream(path, r.opts) if err != nil { r.log.Fatal().Err(err) - return } r.video = video } @@ -113,12 +109,8 @@ func (r *Recording) Stop() (err error) { r.Lock() defer r.Unlock() r.enabled = false - if r.audio != nil { - err = r.audio.Close() - } - if r.video != nil { - err = r.video.Close() - } + err = r.audio.Close() + err = r.video.Close() path := filepath.Join(r.dir, r.saveDir) // FFMPEG @@ -143,11 +135,9 @@ func (r *Recording) Stop() (err error) { func (r *Recording) Set(enable bool, user string) { r.Lock() - r.meta.UserName = user if !r.enabled && enable { r.Unlock() r.Start() - r.log.Debug().Msgf("[REC] set: +, user: %v", user) r.Lock() } else { if r.enabled && !enable { @@ -160,24 +150,10 @@ func (r *Recording) Set(enable bool, user string) { } } r.enabled = enable + r.meta.UserName = user r.Unlock() } -func (r *Recording) SetFramerate(fps float64) { r.opts.Fps = fps } -func (r *Recording) SetAudioFrequency(fq int) { r.opts.Frequency = fq } -func (r *Recording) SetPixFormat(fmt uint32) { - pix := "" - switch fmt { - case 0: - pix = "rgb1555" - case 1: - pix = "brga" - case 2: - pix = "rgb565le" - } - r.opts.Pix = pix -} - func (r *Recording) Enabled() bool { r.Lock() defer r.Unlock() @@ -216,7 +192,7 @@ func random(num string) string { } b := make([]byte, n) for i := range b { - b[i] = letterBytes[rand.Int64()%int64(len(letterBytes))] + b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))] } return string(b) } diff --git a/pkg/worker/recorder/recorder_test.go b/pkg/worker/recorder/recorder_test.go index d968cc34..81f51320 100644 --- a/pkg/worker/recorder/recorder_test.go +++ b/pkg/worker/recorder/recorder_test.go @@ -5,14 +5,14 @@ import ( "image" "image/color" "log" - "math/rand/v2" + "math/rand" "os" "sync" "sync/atomic" "testing" "time" - "github.com/giongto35/cloud-game/v3/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) func TestName(t *testing.T) { @@ -30,12 +30,13 @@ func TestName(t *testing.T) { Meta{UserName: "test"}, logger.Default(), Options{ - Dir: dir, - Fps: 60, - Frequency: 10, - Game: fmt.Sprintf("test_game_%v", rand.Int()), - Name: "test", - Zip: false, + Dir: dir, + Fps: 60, + Frequency: 10, + Game: fmt.Sprintf("test_game_%v", rand.Int()), + ImageCompressionLevel: 0, + Name: "test", + Zip: true, }) recorder.Set(true, "test_user") @@ -44,15 +45,15 @@ func TestName(t *testing.T) { var imgWg, audioWg sync.WaitGroup imgWg.Add(iterations) audioWg.Add(iterations) - frame := genFrame(100, 100) + img := generateImage(100, 100) - for range 222 { + for i := 0; i < 222; i++ { go func() { - recorder.WriteVideo(Video{Frame: frame, Duration: 16 * time.Millisecond}) + recorder.WriteVideo(Video{Image: img, Duration: 16 * time.Millisecond}) imgWg.Done() }() go func() { - recorder.WriteAudio(Audio{[]int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1}, 1}) + recorder.WriteAudio(Audio{&[]int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1}, 1}) audioWg.Done() }() } @@ -65,14 +66,17 @@ func TestName(t *testing.T) { } func BenchmarkNewRecording100x100(b *testing.B) { - benchmarkRecorder(100, 100, b) + benchmarkRecorder(100, 100, 0, b) } -func BenchmarkNewRecording320x240(b *testing.B) { - benchmarkRecorder(320, 240, b) +func BenchmarkNewRecording320x240_compressed(b *testing.B) { + benchmarkRecorder(320, 240, 0, b) +} +func BenchmarkNewRecording320x240_nocompress(b *testing.B) { + benchmarkRecorder(320, 240, -1, b) } -func benchmarkRecorder(w, h int, b *testing.B) { +func benchmarkRecorder(w, h int, comp int, b *testing.B) { b.StopTimer() dir, err := os.MkdirTemp("", "rec_bench_") @@ -85,8 +89,8 @@ func benchmarkRecorder(w, h int, b *testing.B) { } }() - frame1 := genFrame(w, h) - frame2 := genFrame(w, h) + image1 := generateImage(w, h) + image2 := generateImage(w, h) var bytes int64 = 0 @@ -99,28 +103,29 @@ func benchmarkRecorder(w, h int, b *testing.B) { Meta{UserName: "test"}, logger.Default(), Options{ - Dir: dir, - Fps: 60, - Frequency: 10, - Game: fmt.Sprintf("test_game_%v", rand.Int()), - Name: "", - Zip: false, + Dir: dir, + Fps: 60, + Frequency: 10, + Game: fmt.Sprintf("test_game_%v", rand.Int()), + ImageCompressionLevel: comp, + Name: "", + Zip: false, }) recorder.Set(true, "test_user") samples := []int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1} for i := 0; i < b.N; i++ { - f := frame1 + im := image1 if i%2 == 0 { - f = frame2 + im = image2 } go func() { - recorder.WriteVideo(Video{Frame: f, Duration: 16 * time.Millisecond}) - atomic.AddInt64(&bytes, int64(len(f.Data))) + recorder.WriteVideo(Video{Image: im, Duration: 16 * time.Millisecond}) + atomic.AddInt64(&bytes, int64(len(im.(*image.RGBA).Pix))) ticks.Done() }() go func() { - recorder.WriteAudio(Audio{samples, 1}) + recorder.WriteAudio(Audio{&samples, 1}) atomic.AddInt64(&bytes, int64(len(samples)*2)) ticks.Done() }() @@ -132,26 +137,23 @@ func benchmarkRecorder(w, h int, b *testing.B) { } } -func genFrame(w, h int) Frame { +func generateImage(w, h int) image.Image { img := image.NewRGBA(image.Rect(0, 0, w, h)) - for x := range w { - for y := range h { + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { img.Set(x, y, randomColor()) } } - return Frame{ - Data: img.Pix, - Stride: img.Stride, - W: img.Bounds().Dx(), - H: img.Bounds().Dy(), - } + return img } +var rnd = rand.New(rand.NewSource(time.Now().Unix())) + func randomColor() color.RGBA { return color.RGBA{ - R: uint8(rand.IntN(256)), - G: uint8(rand.IntN(256)), - B: uint8(rand.IntN(256)), + R: uint8(rnd.Intn(256)), + G: uint8(rnd.Intn(256)), + B: uint8(rnd.Intn(256)), A: 255, } } diff --git a/pkg/worker/recorder/wavstream.go b/pkg/worker/recorder/wavstream.go index 7b4e0b09..c445a6d5 100644 --- a/pkg/worker/recorder/wavstream.go +++ b/pkg/worker/recorder/wavstream.go @@ -1,11 +1,10 @@ package recorder -import ( - "encoding/binary" - "errors" -) +import "encoding/binary" type wavStream struct { + audioStream + frequency int wav *file } @@ -34,25 +33,19 @@ func (w *wavStream) Close() (err error) { err = w.wav.Flush() size, er := w.wav.Size() if er != nil { - err = errors.Join(err, er) + err = er } if size > 0 { // write an actual RIFF header - if er = w.wav.WriteAtStart(rIFFWavHeader(uint32(size), w.frequency)); er != nil { - err = errors.Join(err, er) - } - if er = w.wav.Flush(); er != nil { - err = errors.Join(err, er) - } - } - if er = w.wav.Close(); er != nil { - err = errors.Join(err, er) + err = w.wav.WriteAtStart(rIFFWavHeader(uint32(size), w.frequency)) + err = w.wav.Flush() } + err = w.wav.Close() return } func (w *wavStream) Write(data Audio) { - pcm := data.Samples + pcm := *data.Samples bs := make([]byte, len(pcm)*2) // int & 0xFF + (int >> 8) & 0xFF for i, ln := 0, len(pcm); i < ln; i++ { diff --git a/pkg/worker/recording.go b/pkg/worker/recording.go new file mode 100644 index 00000000..c3b890af --- /dev/null +++ b/pkg/worker/recording.go @@ -0,0 +1,69 @@ +package worker + +import ( + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/recorder" +) + +type RecordingRoom struct { + GamingRoom + rec *recorder.Recording +} + +func WithRecording(room GamingRoom, rec bool, recUser string, game string, conf worker.Config) *RecordingRoom { + rr := &RecordingRoom{GamingRoom: room, rec: recorder.NewRecording( + recorder.Meta{UserName: recUser}, + room.GetLog(), + recorder.Options{ + Dir: conf.Recording.Folder, + Fps: float64(room.GetEmulator().GetFps()), + Frequency: int(room.GetEmulator().GetSampleRate()), + Game: game, + ImageCompressionLevel: conf.Recording.CompressLevel, + Name: conf.Recording.Name, + Zip: conf.Recording.Zip, + Vsync: true, + })} + rr.ToggleRecording(rec, recUser) + rr.captureAudio() + rr.captureVideo() + return rr +} + +func (r *RecordingRoom) captureAudio() { + handler := r.GetEmulator().GetAudio() + r.GetEmulator().SetAudio(func(samples *emulator.GameAudio) { + if r.IsRecording() { + r.rec.WriteAudio(recorder.Audio{Samples: samples.Data, Duration: samples.Duration}) + } + handler(samples) + }) +} + +func (r *RecordingRoom) captureVideo() { + handler := r.GetEmulator().GetVideo() + r.GetEmulator().SetVideo(func(frame *emulator.GameFrame) { + if r.IsRecording() { + r.rec.WriteVideo(recorder.Video{Image: frame.Data, Duration: frame.Duration}) + } + handler(frame) + }) +} + +func (r *RecordingRoom) ToggleRecording(active bool, user string) { + if r.rec == nil { + return + } + r.GetLog().Debug().Msgf("[REC] set: %v, user: %v", active, user) + r.rec.Set(active, user) +} + +func (r *RecordingRoom) IsRecording() bool { return r.rec != nil && r.rec.Enabled() } + +func (r *RecordingRoom) Close() { + r.GamingRoom.Close() + if r.rec != nil { + r.rec.Set(false, "") + } +} diff --git a/pkg/worker/room.go b/pkg/worker/room.go new file mode 100644 index 00000000..f64ef440 --- /dev/null +++ b/pkg/worker/room.go @@ -0,0 +1,188 @@ +package worker + +import ( + "time" + + "github.com/giongto35/cloud-game/v2/pkg/com" + conf "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" +) + +type GamingRoom interface { + GetId() string + Close() + CleanupUser(*Session) + HasSave() bool + StartEmulator() + SaveGame() error + LoadGame() error + ToggleMultitap() + HasUser(*Session) bool + AddUser(*Session) + PollUserInput(*Session) + EnableAutosave(periodS int) + GetEmulator() emulator.Emulator + GetLog() *logger.Logger +} + +type Room struct { + id string + done chan struct{} + vEncoder *encoder.VideoEncoder + users com.NetMap[*Session] // a list of users in the room + emulator emulator.Emulator + onClose func(self *Room) + closed bool + log *logger.Logger +} + +func NewRoom(id string, game games.GameMetadata, onClose func(*Room), conf worker.Config, log *logger.Logger) *Room { + if id == "" { + id = games.GenerateRoomID(game.Name) + } + log = log.Extend(log.With().Str("room", id[:5])) + log.Info().Str("game", game.Name).Send() + room := &Room{id: id, users: com.NewNetMap[*Session](), done: make(chan struct{}), onClose: onClose, log: log} + + nano, err := libretro.NewFrontend(conf.Emulator, log) + if err != nil { + log.Fatal().Err(err).Send() + } + room.emulator = nano + room.emulator.SetMainSaveName(id) + room.emulator.LoadMetadata(conf.Emulator.GetEmulator(game.Type, game.Path)) + err = room.emulator.LoadGame(game.FullPath()) + if err != nil { + log.Fatal().Err(err).Msgf("couldn't load the game %v", game) + } + // calc output frame size and rotation + w, h := room.whatsFrame(conf.Emulator) + if room.emulator.HasVerticalFrame() { + w, h = h, w + } + room.emulator.SetViewport(w, h) + + room.initVideo(w, h, conf.Encoder.Video) + room.initAudio(int(room.emulator.GetSampleRate()), conf.Encoder.Audio) + + log.Info().Str("room", room.GetId()). + Str("game", game.Name). + Msg("New room") + return room +} + +func (r *Room) GetEmulator() emulator.Emulator { return r.emulator } +func (r *Room) GetId() string { return r.id } +func (r *Room) GetLog() *logger.Logger { return r.log } +func (r *Room) HasSave() bool { return os.Exists(r.emulator.GetHashPath()) } +func (r *Room) HasUser(u *Session) bool { return r != nil && r.users.Has(u.id) } +func (r *Room) IsEmpty() bool { return r.users.IsEmpty() } +func (r *Room) LoadGame() error { return r.emulator.LoadGameState() } +func (r *Room) SaveGame() error { return r.emulator.SaveGameState() } +func (r *Room) StartEmulator() { go r.emulator.Start() } +func (r *Room) ToggleMultitap() { r.emulator.ToggleMultitap() } + +func (r *Room) EnableAutosave(periodSec int) { + r.log.Info().Msgf("Autosave every [%vs]", periodSec) + ticker := time.NewTicker(time.Duration(periodSec) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if r.closed { + continue + } + if err := r.emulator.SaveGameState(); err != nil { + r.log.Error().Msgf("Autosave failed: %v", err) + } else { + r.log.Debug().Msgf("Autosave done") + } + case <-r.done: + return + } + } +} + +func (r *Room) whatsFrame(conf conf.Emulator) (ww int, hh int) { + w, h := r.emulator.GetFrameSize() + // nwidth, nheight are the WebRTC output size + var nwidth, nheight int + emu, ar := conf, conf.AspectRatio + + if ar.Keep { + baseAspectRatio := float64(w) / float64(ar.Height) + nwidth, nheight = ar.ResizeToAspect(baseAspectRatio, ar.Width, ar.Height) + r.log.Info().Msgf("Viewport size will be changed from %dx%d (%f) -> %dx%d", ar.Width, ar.Height, + baseAspectRatio, nwidth, nheight) + } else { + nwidth, nheight = w, h + r.log.Info().Msgf("Viewport resolution: %dx%d", nwidth, nheight) + } + + if emu.Scale > 1 { + nwidth, nheight = nwidth*emu.Scale, nheight*emu.Scale + r.log.Info().Msgf("Viewport size has scaled to %dx%d", nwidth, nheight) + } + + // set game frame size considering its orientation + ww, hh = nwidth, nheight + return +} + +func (r *Room) PollUserInput(session *Session) { + r.log.Debug().Msg("Start session input poll") + session.GetPeerConn().OnMessage = func(data []byte) { r.emulator.Input(session.GetPlayerIndex(), data) } +} + +func (r *Room) AddUser(user *Session) { + r.users.Add(user) + user.SetRoom(r) + r.log.Debug().Str("user", string(user.Id())).Msg("User has joined the room") +} + +func (r *Room) CleanupUser(user *Session) { + user.SetRoom(nil) + if r.HasUser(user) { + r.users.Remove(user) + r.log.Debug().Str("user", string(user.Id())).Msg("User has left the room") + } + if r.IsEmpty() { + r.log.Debug().Msg("The room is empty") + r.Close() + } +} + +func (r *Room) Close() { + r.log.Debug().Msg("Closing the room") + if r.closed { + r.log.Debug().Msg("Close room skip") + return + } + + r.closed = true + + // Save game before quit. Only save for game which was previous saved to avoid flooding database + if r.HasSave() { + r.log.Debug().Msg("Save game before closing room") + if err := r.SaveGame(); err != nil { + r.log.Error().Err(err).Msg("couldn't save the game during close") + } + } + r.emulator.Close() + close(r.done) + + if r.vEncoder != nil { + r.vEncoder.Stop() + } + + if r.onClose != nil { + r.onClose(r) + } +} diff --git a/pkg/worker/room/cast.go b/pkg/worker/room/cast.go deleted file mode 100644 index 81a6c57d..00000000 --- a/pkg/worker/room/cast.go +++ /dev/null @@ -1,22 +0,0 @@ -package room - -import ( - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/network/webrtc" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro" -) - -type GameRouter struct { - Router[*GameSession] -} - -func NewGameRouter() *GameRouter { - u := com.NewNetMap[SessionKey, *GameSession]() - return &GameRouter{Router: Router[*GameSession]{users: &u}} -} - -func WithEmulator(wtf any) *libretro.Caged { return wtf.(*libretro.Caged) } -func WithRecorder(wtf any) *libretro.RecordingFrontend { - return (WithEmulator(wtf).Emulator).(*libretro.RecordingFrontend) -} -func WithWebRTC(wtf Session) *webrtc.Peer { return wtf.(*webrtc.Peer) } diff --git a/pkg/worker/room/cast_test.go b/pkg/worker/room/cast_test.go deleted file mode 100644 index cf50dc69..00000000 --- a/pkg/worker/room/cast_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package room - -import ( - "testing" - - "github.com/giongto35/cloud-game/v3/pkg/network/webrtc" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro" -) - -func TestGoodWithRecorder(t *testing.T) { - WithRecorder(&libretro.Caged{Emulator: &libretro.RecordingFrontend{}}) -} - -func TestBadWithRecorder(t *testing.T) { - defer func() { _ = recover() }() - WithEmulator(libretro.Caged{}) - t.Errorf("no panic") -} - -func TestGoodWithEmulator(t *testing.T) { WithEmulator(&libretro.Caged{}) } - -func TestBadWithEmulator(t *testing.T) { - defer func() { _ = recover() }() - WithEmulator(libretro.Caged{}) // not a pointer - t.Errorf("no panic") -} - -func TestGoodWithWebRTCCast(t *testing.T) { - WithWebRTC(GameSession{AppSession: AppSession{Session: &webrtc.Peer{}}}.Session) -} - -func TestBadWithWebRTCCast(t *testing.T) { - defer func() { _ = recover() }() - WithWebRTC(GameSession{}) // not a Session due to deep nesting - t.Errorf("no panic") -} diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go deleted file mode 100644 index 88380683..00000000 --- a/pkg/worker/room/room.go +++ /dev/null @@ -1,173 +0,0 @@ -package room - -import ( - "iter" - "sync" - - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" -) - -type MediaPipe interface { - // Destroy frees all allocated resources. - Destroy() - // Init initializes the pipe: allocates needed resources. - Init() error - // Reinit initializes video and audio pipes with the new settings. - Reinit() error - // PushAudio pushes the 16bit PCM audio frames into an encoder. - // Because we need to fill the buffer, the SetAudioCb should be - // used in order to get the result. - PushAudio([]int16) - // ProcessVideo returns encoded video frame. - ProcessVideo(app.Video) []byte - // SetAudioCb sets a callback for encoded audio data with its frame duration (ns). - SetAudioCb(func(data []byte, duration int32)) -} - -type SessionManager[T Session] interface { - Add(T) bool - Empty() bool - Find(string) T - RemoveL(T) int - // Reset used for proper cleanup of the resources if needed. - Reset() - Values() iter.Seq[T] -} - -type Session interface { - Disconnect() - SendAudio([]byte, int32) - SendVideo([]byte, int32) - SendData([]byte) -} - -type SessionKey string - -func (s SessionKey) String() string { return string(s) } -func (s SessionKey) Id() string { return s.String() } - -type Room[T Session] struct { - app app.App - id string - media MediaPipe - users SessionManager[T] - - closed bool - HandleClose func() -} - -func NewRoom[T Session](id string, app app.App, um SessionManager[T], media MediaPipe) *Room[T] { - room := &Room[T]{id: id, app: app, users: um, media: media} - if app != nil && media != nil { - room.InitVideo() - room.InitAudio() - } - return room -} - -func (r *Room[T]) InitAudio() { - r.app.SetAudioCb(func(a app.Audio) { r.media.PushAudio(a.Data) }) - r.media.SetAudioCb(func(d []byte, l int32) { - for u := range r.users.Values() { - u.SendAudio(d, l) - } - }) -} - -func (r *Room[T]) InitVideo() { - r.app.SetVideoCb(func(v app.Video) { - data := r.media.ProcessVideo(v) - for u := range r.users.Values() { - u.SendVideo(data, v.Duration) - } - }) -} - -func (r *Room[T]) App() app.App { return r.app } -func (r *Room[T]) BindAppMedia() { r.InitAudio(); r.InitVideo() } -func (r *Room[T]) Id() string { return r.id } -func (r *Room[T]) SetApp(app app.App) { r.app = app } -func (r *Room[T]) SetMedia(m MediaPipe) { r.media = m } -func (r *Room[T]) StartApp() { r.app.Start() } -func (r *Room[T]) Send(data []byte) { - for u := range r.users.Values() { - u.SendData(data) - } -} - -func (r *Room[T]) Close() { - if r == nil || r.closed { - return - } - r.closed = true - - if r.app != nil { - r.app.Close() - } - if r.media != nil { - r.media.Destroy() - } - if r.HandleClose != nil { - r.HandleClose() - } -} - -// Router tracks and routes freshly connected users to an app room. -// Rooms and users has 1-to-n relationship. -type Router[T Session] struct { - room *Room[T] - users SessionManager[T] - mu sync.Mutex -} - -func (r *Router[T]) FindRoom(id string) *Room[T] { - r.mu.Lock() - defer r.mu.Unlock() - if r.room != nil && r.room.Id() == id { - return r.room - } - return nil -} - -func (r *Router[T]) Remove(user T) { - if left := r.users.RemoveL(user); left == 0 { - r.Close() - r.SetRoom(nil) // !to remove - } -} - -func (r *Router[T]) AddUser(user T) { r.users.Add(user) } -func (r *Router[T]) Close() { r.mu.Lock(); r.room.Close(); r.room = nil; r.mu.Unlock() } -func (r *Router[T]) FindUser(uid string) T { return r.users.Find(uid) } -func (r *Router[T]) Room() *Room[T] { r.mu.Lock(); defer r.mu.Unlock(); return r.room } -func (r *Router[T]) SetRoom(room *Room[T]) { r.mu.Lock(); r.room = room; r.mu.Unlock() } -func (r *Router[T]) HasRoom() bool { r.mu.Lock(); defer r.mu.Unlock(); return r.room != nil } -func (r *Router[T]) Users() SessionManager[T] { return r.users } -func (r *Router[T]) Reset() { - r.mu.Lock() - if r.room != nil { - r.room.Close() - r.room = nil - } - for u := range r.users.Values() { - u.Disconnect() - } - r.users.Reset() - r.mu.Unlock() -} - -type AppSession struct { - Session - uid SessionKey -} - -func (p AppSession) Id() SessionKey { return p.uid } - -type GameSession struct { - AppSession - Index int // track user Index (i.e. player 1,2,3,4 select) -} - -func NewGameSession(id string, s Session) *GameSession { - return &GameSession{AppSession: AppSession{uid: SessionKey(id), Session: s}} -} diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go deleted file mode 100644 index 7a537d69..00000000 --- a/pkg/worker/room/room_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package room - -import ( - "flag" - "fmt" - "hash/crc32" - "image" - "image/color" - "image/draw" - "image/png" - "log" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/giongto35/cloud-game/v3/pkg/com" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/encoder" - "github.com/giongto35/cloud-game/v3/pkg/encoder/color/bgra" - "github.com/giongto35/cloud-game/v3/pkg/encoder/color/rgb565" - "github.com/giongto35/cloud-game/v3/pkg/encoder/color/rgba" - "github.com/giongto35/cloud-game/v3/pkg/games" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" - "github.com/giongto35/cloud-game/v3/pkg/worker/media" - "github.com/giongto35/cloud-game/v3/pkg/worker/thread" - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" - - _ "github.com/giongto35/cloud-game/v3/test" -) - -var ( - renderFrames bool - outputPath string - autoGlContext bool -) - -type testRoom struct { - *Room[*GameSession] - started bool -} - -type codec = encoder.VideoCodec - -type conf struct { - roomName string - game games.GameMetadata - codec codec - autoGlContext bool - autoAppStart bool - noLog bool -} - -func (r testRoom) Close() { - r.Room.Close() - time.Sleep(2 * time.Second) // hack: wait room destruction (atm impossible to tell) -} - -func (r testRoom) WaitFrame(n int) app.RawFrame { - var wg sync.WaitGroup - wg.Add(1) - target := app.RawFrame{} - WithEmulator(r.app).SetVideoCb(func(v app.Video) { - if n == 1 { - target = v.Frame - target.Data = make([]byte, len(v.Frame.Data)) - copy(target.Data, v.Frame.Data) - wg.Done() - } - n-- - }) - if !r.started { - r.StartApp() - } - wg.Wait() - return target -} - -type testParams struct { - system string - game games.GameMetadata - codecs []codec - frames int - color int -} - -// Store absolute path to test games -var testTempDir = filepath.Join(os.TempDir(), "cloud-game-core-tests") - -// games -var ( - alwas = games.GameMetadata{Name: "Alwa's Awakening (Demo)", Type: "nes", Path: "nes/Alwa's Awakening (Demo).nes", System: "nes"} - sushi = games.GameMetadata{Name: "Sushi The Cat", Type: "gba", Path: "gba/Sushi The Cat.gba", System: "gba"} - fd = games.GameMetadata{Name: "Florian Demo", Type: "n64", Path: "n64/Sample Demo by Florian (PD).z64", System: "n64"} - rogue = games.GameMetadata{Name: "Rogue", Type: "dos", Path: "dos/rogue.zip", System: "dos"} -) - -func TestMain(m *testing.M) { - flag.BoolVar(&renderFrames, "renderFrames", false, "Render frames for eye testing purposes") - flag.StringVar(&outputPath, "outputPath", "./", "Output path for generated files") - flag.BoolVar(&autoGlContext, "autoGlContext", false, "Set auto GL context choose for headless machines") - - thread.Wrap(func() { os.Exit(m.Run()) }) -} - -func TestRoom(t *testing.T) { - tests := []testParams{ - {game: alwas, codecs: []codec{encoder.H264, encoder.VP8, encoder.VP9}, frames: 300}, - } - - for _, test := range tests { - for _, codec := range test.codecs { - room := room(conf{codec: codec, game: test.game}) - room.WaitFrame(test.frames) - room.Close() - } - } -} - -func TestAll(t *testing.T) { - tests := []testParams{ - {game: sushi, frames: 150, color: 2}, - {game: alwas, frames: 50, color: 1}, - {game: fd, frames: 50, system: "gl", color: 1}, - {game: rogue, frames: 33, color: 1}, - } - - crc32q := crc32.MakeTable(0xD5828281) - - for _, test := range tests { - var frame app.RawFrame - room := room(conf{game: test.game, codec: encoder.VP8, autoGlContext: autoGlContext, autoAppStart: false}) - flip := test.system == "gl" - thread.Main(func() { frame = room.WaitFrame(test.frames) }) - room.Close() - - if renderFrames { - rect := image.Rect(0, 0, frame.W, frame.H) - var src image.Image - src1 := bgra.NewBGRA(rect) - src1.Pix = frame.Data - src1.Stride = frame.Stride - src = src1 - if test.color == 2 { - src2 := rgb565.NewRGB565(rect) - src2.Pix = frame.Data - src2.Stride = frame.Stride - src = src2 - } - dst := rgba.ToRGBA(src, flip) - tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(frame.Data, crc32q)) - dumpCanvas(dst, tag, fmt.Sprintf("%v [%v]", tag, test.frames), outputPath) - } - } -} - -func dumpCanvas(frame *image.RGBA, name string, caption string, path string) { - // slap 'em caption - if caption != "" { - draw.Draw(frame, image.Rect(8, 8, 8+len(caption)*7+3, 24), &image.Uniform{C: color.RGBA{}}, image.Point{}, draw.Src) - (&font.Drawer{ - Dst: frame, - Src: image.NewUniform(color.RGBA{R: 255, G: 255, B: 255, A: 255}), - Face: basicfont.Face7x13, - Dot: fixed.Point26_6{X: fixed.Int26_6(10 * 64), Y: fixed.Int26_6(20 * 64)}, - }).DrawString(caption) - } - - outPath := testTempDir - if path != "" { - outPath = path - } - - if err := os.MkdirAll(outPath, 0770); err != nil { - log.Printf("Couldn't create target dir for the output images, %v", err) - return - } - - if f, err := os.Create(filepath.Join(outPath, name+".png")); err == nil { - if err = png.Encode(f, frame); err != nil { - log.Printf("Couldn't encode the image, %v", err) - } - _ = f.Close() - } else { - log.Printf("Couldn't create the image, %v", err) - } -} - -// room returns mocked Room struct. -func room(cfg conf) testRoom { - var conf config.WorkerConfig - if _, err := config.LoadConfig(&conf, ""); err != nil { - panic(err) - } - - conf.Emulator.Libretro.Cores.Repo.ExtLock = expand("tests", ".cr", "cloud-game.lock") - conf.Emulator.LocalPath = expand("tests", conf.Emulator.LocalPath) - conf.Emulator.Storage = expand("tests", "storage") - - conf.Encoder.Video.Codec = string(cfg.codec) - - l := logger.NewConsole(conf.Worker.Debug, "w", false) - if cfg.noLog { - logger.SetGlobalLevel(logger.Disabled) - } - - id := cfg.roomName - if id == "" { - id = games.GenerateRoomID(cfg.game.Name) - } - - manager := caged.NewManager(l) - if err := manager.Load(caged.Libretro, conf); err != nil { - l.Fatal().Msgf("couldn't cage libretro: %v", err) - } - - emu := WithEmulator(manager.Get(caged.Libretro)) - emu.ReloadFrontend() - emu.SetSessionId(id) - if err := emu.Load(cfg.game, conf.Library.BasePath); err != nil { - l.Fatal().Err(err).Msgf("couldn't load the game %v", cfg.game) - } - - m := media.NewWebRtcMediaPipe(conf.Encoder.Audio, conf.Encoder.Video, l) - m.AudioSrcHz = emu.AudioSampleRate() - m.AudioFrames = conf.Encoder.Audio.Frames - m.VideoW, m.VideoH = emu.ViewportSize() - m.VideoScale = emu.Scale() - if err := m.Init(); err != nil { - l.Fatal().Err(err).Msgf("no init") - } - - room := NewRoom[*GameSession](id, emu, &com.NetMap[SessionKey, *GameSession]{}, m) - if cfg.autoAppStart { - room.StartApp() - } - - return testRoom{Room: room, started: cfg.autoAppStart} -} - -// Measures emulation performance of various -// emulators and encoding options. -func BenchmarkRoom(b *testing.B) { - benches := []testParams{ - // warm up - {system: "gba", game: sushi, codecs: []codec{encoder.VP8, encoder.VP9}, frames: 50}, - {system: "gba", game: sushi, codecs: []codec{encoder.VP8, encoder.H264}, frames: 100}, - {system: "nes", game: alwas, codecs: []codec{encoder.VP8, encoder.H264}, frames: 100}, - } - - for _, bench := range benches { - for _, cod := range bench.codecs { - b.Run(fmt.Sprintf("%s-%v-%d", bench.system, cod, bench.frames), func(b *testing.B) { - for i := 0; i < b.N; i++ { - b.StopTimer() - room := room(conf{game: bench.game, codec: cod, noLog: true}) - b.StartTimer() - room.WaitFrame(bench.frames) - b.StopTimer() - room.Room.Close() - } - }) - } - } -} - -// expand joins a list of file path elements. -func expand(p ...string) string { - ph, _ := filepath.Abs(filepath.FromSlash(filepath.Join(p...))) - return ph -} diff --git a/pkg/worker/room/router_test.go b/pkg/worker/room/router_test.go deleted file mode 100644 index d4f2e621..00000000 --- a/pkg/worker/room/router_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package room - -import ( - "testing" - - "github.com/giongto35/cloud-game/v3/pkg/com" -) - -type sKey string - -func (s sKey) String() string { return string(s) } - -type tSession struct { - id sKey - connected bool -} - -func (t *tSession) SendAudio([]byte, int32) {} -func (t *tSession) SendVideo([]byte, int32) {} -func (t *tSession) SendData([]byte) {} -func (t *tSession) Connect() { t.connected = true } -func (t *tSession) Disconnect() { t.connected = false } -func (t *tSession) Id() sKey { return t.id } - -type lookMap struct { - com.NetMap[sKey, *tSession] - prev com.NetMap[sKey, *tSession] // we could use pointers in the original :3 -} - -func (l *lookMap) Reset() { - l.prev = com.NewNetMap[sKey, *tSession]() - for s := range l.Map.Values() { - l.prev.Add(s) - } - l.NetMap.Reset() -} - -func TestRouter(t *testing.T) { - router := newTestRouter() - - var r *Room[*tSession] - - router.SetRoom(&Room[*tSession]{id: "test001"}) - room := router.FindRoom("test001") - if room == nil { - t.Errorf("no room, but should be") - } - router.SetRoom(r) - room = router.FindRoom("x") - if room != nil { - t.Errorf("a room, but should not be") - } - router.SetRoom(nil) - router.Close() -} - -func TestRouterReset(t *testing.T) { - u := lookMap{NetMap: com.NewNetMap[sKey, *tSession]()} - router := Router[*tSession]{users: &u} - - router.AddUser(&tSession{id: "1", connected: true}) - router.AddUser(&tSession{id: "2", connected: false}) - router.AddUser(&tSession{id: "3", connected: true}) - - router.Reset() - - disconnected := true - for u := range u.prev.Values() { - disconnected = disconnected && !u.connected - } - if !disconnected { - t.Errorf("not all users were disconnected, but should") - } - if !router.Users().Empty() { - t.Errorf("has users after reset, but should not") - } -} - -func newTestRouter() *Router[*tSession] { - u := com.NewNetMap[sKey, *tSession]() - return &Router[*tSession]{users: &u} -} diff --git a/pkg/worker/room_test.go b/pkg/worker/room_test.go new file mode 100644 index 00000000..ea345c1f --- /dev/null +++ b/pkg/worker/room_test.go @@ -0,0 +1,381 @@ +package worker + +import ( + "flag" + "fmt" + "hash/crc32" + "image" + "image/color" + "image/draw" + "image/png" + "log" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + image2 "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/image" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/manager/remotehttp" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +var ( + renderFrames bool + outputPath string + autoGlContext bool +) + +type roomMock struct { + *Room + startEmulator bool +} + +type roomMockConfig struct { + roomName string + gamesPath string + game games.GameMetadata + vCodec encoder.VideoCodec + autoGlContext bool + dontStartEmulator bool + noLog bool +} + +// Store absolute path to test games +var whereIsGames = getRootPath() + "assets/games/" +var whereIsConfigs = getRootPath() + "configs/" +var testTempDir = filepath.Join(os.TempDir(), "cloud-game-core-tests") + +func init() { + runtime.LockOSThread() +} + +func TestMain(m *testing.M) { + flag.BoolVar(&renderFrames, "renderFrames", false, "Render frames for eye testing purposes") + flag.StringVar(&outputPath, "outputPath", "./", "Output path for generated files") + flag.BoolVar(&autoGlContext, "autoGlContext", false, "Set auto GL context choose for headless machines") + + thread.Wrap(func() { os.Exit(m.Run()) }) +} + +func TestRoom(t *testing.T) { + tests := []struct { + roomName string + game games.GameMetadata + vCodec encoder.VideoCodec + frames int + }{ + { + game: games.GameMetadata{ + Name: "Super Mario Bros", + Type: "nes", + Path: "Super Mario Bros.nes", + }, + vCodec: encoder.H264, + frames: 300, + }, + } + + for _, test := range tests { + room := getRoomMock(roomMockConfig{ + roomName: test.roomName, + gamesPath: whereIsGames, + game: test.game, + vCodec: test.vCodec, + }) + + t.Logf("The game [%v] has been loaded", test.game.Name) + waitNFrames(test.frames, room) + room.Close() + } + // hack: wait room destruction + time.Sleep(2 * time.Second) +} + +func TestRoomWithGL(t *testing.T) { + tests := []struct { + game games.GameMetadata + vCodec encoder.VideoCodec + frames int + }{ + { + game: games.GameMetadata{ + Name: "Sample Demo by Florian (PD)", + Type: "n64", + Path: "Sample Demo by Florian (PD).z64", + }, + vCodec: encoder.VP8, + frames: 50, + }, + } + + run := func() { + for _, test := range tests { + room := getRoomMock(roomMockConfig{ + gamesPath: whereIsGames, + game: test.game, + vCodec: test.vCodec, + }) + t.Logf("The game [%v] has been loaded", test.game.Name) + waitNFrames(test.frames, room) + room.Close() + } + // hack: wait room destruction + time.Sleep(2 * time.Second) + } + + thread.Main(run) +} + +func TestAllEmulatorRooms(t *testing.T) { + tests := []struct { + game games.GameMetadata + frames int + }{ + { + game: games.GameMetadata{Name: "Sushi", Type: "gba", Path: "Sushi The Cat.gba"}, + frames: 150, + }, + { + game: games.GameMetadata{Name: "Mario", Type: "nes", Path: "Super Mario Bros.nes"}, + frames: 50, + }, + { + game: games.GameMetadata{Name: "Florian Demo", Type: "n64", Path: "Sample Demo by Florian (PD).z64"}, + frames: 50, + }, + } + + crc32q := crc32.MakeTable(0xD5828281) + + for _, test := range tests { + room := getRoomMock(roomMockConfig{ + gamesPath: whereIsGames, + game: test.game, + vCodec: encoder.VP8, + autoGlContext: autoGlContext, + dontStartEmulator: true, + }) + t.Logf("The game [%v] has been loaded", test.game.Name) + frame := waitNFrames(test.frames, room) + + if renderFrames { + tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(frame.Data.Pix, crc32q)) + dumpCanvas(frame.Data, tag, fmt.Sprintf("%v [%v]", tag, test.frames), outputPath) + } + + room.Close() + // hack: wait room destruction + time.Sleep(1 * time.Second) + } +} + +func dumpCanvas(frame *image2.Frame, name string, caption string, path string) { + // slap 'em caption + if len(caption) > 0 { + draw.Draw(frame, image.Rect(8, 8, 8+len(caption)*7+3, 24), &image.Uniform{C: color.RGBA{}}, image.Point{}, draw.Src) + (&font.Drawer{ + Dst: frame, + Src: image.NewUniform(color.RGBA{R: 255, G: 255, B: 255, A: 255}), + Face: basicfont.Face7x13, + Dot: fixed.Point26_6{X: fixed.Int26_6(10 * 64), Y: fixed.Int26_6(20 * 64)}, + }).DrawString(caption) + } + + var outPath string + if len(path) > 0 { + outPath = path + } else { + outPath = testTempDir + } + + // really like Go's error handling + if err := os.MkdirAll(outPath, 0770); err != nil { + log.Printf("Couldn't create target dir for the output images, %v", err) + return + } + + if f, err := os.Create(filepath.Join(outPath, name+".png")); err == nil { + if err = png.Encode(f, frame); err != nil { + log.Printf("Couldn't encode the image, %v", err) + } + _ = f.Close() + } else { + log.Printf("Couldn't create the image, %v", err) + } +} + +// getRoomMock returns mocked Room struct. +func getRoomMock(cfg roomMockConfig) roomMock { + cfg.game.Path = cfg.gamesPath + cfg.game.Path + + var conf worker.Config + if err := config.LoadConfig(&conf, whereIsConfigs); err != nil { + panic(err) + } + fixEmulators(&conf, cfg.autoGlContext) + l := logger.NewConsole(conf.Worker.Debug, "w", true) + if cfg.noLog { + logger.SetGlobalLevel(logger.Disabled) + } + + // sync cores + coreManager := remotehttp.NewRemoteHttpManager(conf.Emulator.Libretro, l) + if err := coreManager.Sync(); err != nil { + log.Printf("error: cores sync has failed, %v", err) + } + conf.Encoder.Video.Codec = string(cfg.vCodec) + + room := NewRoom(cfg.roomName, cfg.game, nil, conf, l) + + if !cfg.dontStartEmulator { + room.StartEmulator() + } + + // loop-wait the room initialization + var init sync.WaitGroup + init.Add(1) + wasted := 0 + go func() { + sleepDeltaMs := 10 + for room.emulator == nil { + time.Sleep(time.Duration(sleepDeltaMs) * time.Millisecond) + wasted++ + if wasted > 1000 { + break + } + } + init.Done() + }() + init.Wait() + return roomMock{Room: room, startEmulator: !cfg.dontStartEmulator} +} + +// fixEmulators makes absolute game paths in global GameList and passes GL context config. +// hack: emulator paths should be absolute and visible to the tests. +func fixEmulators(config *worker.Config, autoGlContext bool) { + rootPath := getRootPath() + + config.Emulator.Libretro.Cores.Paths.Libs = + filepath.FromSlash(rootPath + config.Emulator.Libretro.Cores.Paths.Libs) + config.Emulator.Libretro.Cores.Paths.Configs = + filepath.FromSlash(rootPath + config.Emulator.Libretro.Cores.Paths.Configs) + config.Emulator.LocalPath = filepath.FromSlash(filepath.Join(rootPath, "tests", config.Emulator.LocalPath)) + config.Emulator.Storage = filepath.FromSlash(filepath.Join(rootPath, "tests", "storage")) + + for k, conf := range config.Emulator.Libretro.Cores.List { + if conf.IsGlAllowed && autoGlContext { + conf.AutoGlContext = true + } + config.Emulator.Libretro.Cores.List[k] = conf + } +} + +// getRootPath returns absolute path to the assets. +func getRootPath() string { + p, _ := filepath.Abs("../../") + return p + string(filepath.Separator) +} + +func waitNFrames(n int, room roomMock) *emulator.GameFrame { + var i = int32(n) + wg := sync.WaitGroup{} + wg.Add(n) + var frame emulator.GameFrame + handler := room.emulator.GetVideo() + room.emulator.SetVideo(func(video *emulator.GameFrame) { + handler(video) + if atomic.AddInt32(&i, -1) >= 0 { + v := video.Data.Copy() + frame = emulator.GameFrame{ + Duration: video.Duration, + Data: &v, + } + wg.Done() + } + }) + if !room.startEmulator { + room.StartEmulator() + } + wg.Wait() + return &frame +} + +// benchmarkRoom measures app performance for n emulation frames. +// Measure period: the room initialization, n emulated and encoded frames, the room shutdown. +func benchmarkRoom(rom games.GameMetadata, codec encoder.VideoCodec, frames int, suppressOutput bool, b *testing.B) { + for i := 0; i < b.N; i++ { + room := getRoomMock(roomMockConfig{ + gamesPath: whereIsGames, + game: rom, + vCodec: codec, + noLog: suppressOutput, + }) + waitNFrames(frames, room) + room.Close() + } +} + +// Measures emulation performance of various +// emulators and encoding options. +func BenchmarkRoom(b *testing.B) { + benches := []struct { + system string + game games.GameMetadata + codecs []encoder.VideoCodec + frames int + }{ + // warm up + { + system: "gba", + game: games.GameMetadata{ + Name: "Sushi The Cat", + Type: "gba", + Path: "Sushi The Cat.gba", + }, + codecs: []encoder.VideoCodec{encoder.VP8}, + frames: 50, + }, + { + system: "gba", + game: games.GameMetadata{ + Name: "Sushi The Cat", + Type: "gba", + Path: "Sushi The Cat.gba", + }, + codecs: []encoder.VideoCodec{encoder.VP8, encoder.H264}, + frames: 100, + }, + { + system: "nes", + game: games.GameMetadata{ + Name: "Super Mario Bros", + Type: "nes", + Path: "Super Mario Bros.nes", + }, + codecs: []encoder.VideoCodec{encoder.VP8, encoder.H264}, + frames: 100, + }, + } + + for _, bench := range benches { + for _, cod := range bench.codecs { + b.Run(fmt.Sprintf("%s-%v-%d", bench.system, cod, bench.frames), func(b *testing.B) { + benchmarkRoom(bench.game, cod, bench.frames, true, b) + }) + // hack: wait room destruction + time.Sleep(5 * time.Second) + } + } +} diff --git a/pkg/worker/router.go b/pkg/worker/router.go new file mode 100644 index 00000000..878fb918 --- /dev/null +++ b/pkg/worker/router.go @@ -0,0 +1,57 @@ +package worker + +import ( + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/network/webrtc" + "github.com/pion/webrtc/v3/pkg/media" +) + +// Router tracks and routes freshly connected users to a game room. +// Basically, it holds user connection data until some user makes (connects to) +// a new room (game), then it manages all the cross-references between room and users. +// Rooms and users has 1-to-n relationship. +type Router struct { + room GamingRoom + users com.NetMap[*Session] +} + +// Session represents WebRTC connection of the user. +type Session struct { + id network.Uid + conn *webrtc.Peer + pi int + room GamingRoom // back reference +} + +func NewRouter() Router { return Router{users: com.NewNetMap[*Session]()} } + +func (r *Router) SetRoom(room GamingRoom) { r.room = room } +func (r *Router) AddUser(user *Session) { r.users.Add(user) } +func (r *Router) Close() { + if r.room != nil { + r.room.Close() + } +} +func (r *Router) GetRoom(id string) GamingRoom { + if r.room != nil && r.room.GetId() == id { + return r.room + } + return nil +} +func (r *Router) GetUser(uid network.Uid) *Session { sess, _ := r.users.Find(string(uid)); return sess } +func (r *Router) RemoveRoom() { r.room = nil } +func (r *Router) RemoveUser(user *Session) { r.users.Remove(user); user.Close() } + +func NewSession(rtc *webrtc.Peer, id network.Uid) *Session { return &Session{id: id, conn: rtc} } + +func (s *Session) Id() network.Uid { return s.id } +func (s *Session) GetSetRoom(v GamingRoom) GamingRoom { vv := s.room; s.room = v; return vv } +func (s *Session) GetPeerConn() *webrtc.Peer { return s.conn } +func (s *Session) GetPlayerIndex() int { return s.pi } +func (s *Session) IsConnected() bool { return s.conn.IsConnected() } +func (s *Session) SendVideo(sample *media.Sample) error { return s.conn.WriteVideo(sample) } +func (s *Session) SendAudio(sample *media.Sample) error { return s.conn.WriteAudio(sample) } +func (s *Session) SetRoom(room GamingRoom) { s.room = room } +func (s *Session) SetPlayerIndex(index int) { s.pi = index } +func (s *Session) Close() { s.conn.Disconnect() } diff --git a/pkg/worker/storage.go b/pkg/worker/storage.go new file mode 100644 index 00000000..183f1441 --- /dev/null +++ b/pkg/worker/storage.go @@ -0,0 +1,125 @@ +package worker + +import ( + "bytes" + "crypto/md5" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "time" +) + +type CloudStorage interface { + Save(name string, localPath string) (err error) + Load(name string) (data []byte, err error) +} + +type OracleDataStorageClient struct { + accessURL string + client *http.Client +} + +func GetCloudStorage(provider, key string) (CloudStorage, error) { + var st CloudStorage + var err error + switch provider { + case "oracle": + st, err = NewOracleDataStorageClient(key) + case "coordinator": + default: + } + return st, err +} + +// NewOracleDataStorageClient returns either a new Oracle Data Storage +// client or some error in case of failure. +// Oracle infrastructure access is based on pre-authenticated requests, +// see: https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/usingpreauthenticatedrequests.htm +// +// It follows broken Google Cloud Storage client design. +func NewOracleDataStorageClient(accessURL string) (*OracleDataStorageClient, error) { + if accessURL == "" { + return nil, errors.New("pre-authenticated request was not specified") + } + return &OracleDataStorageClient{ + accessURL: accessURL, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + }, nil +} + +func (s *OracleDataStorageClient) Save(name string, localPath string) (err error) { + if s == nil { + return nil + } + + dat, err := os.ReadFile(localPath) + if err != nil { + return err + } + + req, err := http.NewRequest("PUT", s.accessURL+name, bytes.NewBuffer(dat)) + if err != nil { + return err + } + + resp, err := s.client.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != 200 { + return errors.New(resp.Status) + } + + dstMD5 := resp.Header.Get("Opc-Content-Md5") + srcMD5 := base64.StdEncoding.EncodeToString(md5Hash(dat)) + if dstMD5 != srcMD5 { + return fmt.Errorf("MD5 mismatch %v != %v", srcMD5, dstMD5) + } + + return nil +} + +func (s *OracleDataStorageClient) Load(name string) (data []byte, err error) { + if s == nil { + return nil, errors.New("cloud storage was not initialized") + } + + res, err := s.client.Get(s.accessURL + name) + if err != nil { + return nil, err + } + defer func() { + _ = res.Body.Close() + }() + + if res.StatusCode != 200 { + return nil, errors.New(res.Status) + } + + dat, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + dstMD5 := res.Header.Get("Content-Md5") + srcMD5 := base64.StdEncoding.EncodeToString(md5Hash(dat)) + if dstMD5 != srcMD5 { + return nil, fmt.Errorf("MD5 mismatch %v != %v", srcMD5, dstMD5) + } + + return dat, nil +} + +func md5Hash(data []byte) []byte { + hash := md5.New() + hash.Write(data) + return hash.Sum(nil) +} diff --git a/pkg/worker/storage_test.go b/pkg/worker/storage_test.go new file mode 100644 index 00000000..cb80830a --- /dev/null +++ b/pkg/worker/storage_test.go @@ -0,0 +1,54 @@ +package worker + +import ( + "io" + "net/http" + "os" + "strings" + "testing" +) + +type rtFunc func(req *http.Request) *http.Response + +func (f rtFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } + +func newTestClient(fn rtFunc) *http.Client { + return &http.Client{ + Transport: fn, + } +} + +func TestOracleSave(t *testing.T) { + client, _ := NewOracleDataStorageClient("test-url/") + client.client = newTestClient(func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("")), + Header: map[string][]string{ + "Opc-Content-Md5": {"CY9rzUYh03PK3k6DJie09g=="}, + }, + } + }) + + tempFile, err := os.CreateTemp("", "oracle_test.file") + if err != nil { + t.Errorf("%v", err) + } + defer func() { + _ = tempFile.Close() + err := os.Remove(tempFile.Name()) + if err != nil { + t.Errorf("%v", err) + } + }() + + _, err = tempFile.WriteString("test") + if err != nil { + return + } + + err = client.Save("oracle_test.file", tempFile.Name()) + if err != nil { + t.Errorf("can't save, err: %v", err) + } +} diff --git a/pkg/worker/thread/mainthread_darwin.go b/pkg/worker/thread/mainthread_darwin.go index 53ac7585..730a3f27 100644 --- a/pkg/worker/thread/mainthread_darwin.go +++ b/pkg/worker/thread/mainthread_darwin.go @@ -13,8 +13,6 @@ type fun struct { var dPool = sync.Pool{New: func() any { return make(chan struct{}) }} var fq = make(chan fun, runtime.GOMAXPROCS(0)) -var isGraphics = false - func init() { runtime.LockOSThread() } @@ -40,17 +38,8 @@ func Run(run func()) { // Call queues function f on the main thread and blocks until the function f finishes. func Call(f func()) { - if !isGraphics { - f() - return - } - done := dPool.Get().(chan struct{}) defer dPool.Put(done) fq <- fun{fn: f, done: done} <-done } - -func Switch(s bool) { - isGraphics = s -} diff --git a/pkg/worker/thread/mainthread_darwin_test.go b/pkg/worker/thread/mainthread_darwin_test.go index 15ce9328..bab4a92c 100644 --- a/pkg/worker/thread/mainthread_darwin_test.go +++ b/pkg/worker/thread/mainthread_darwin_test.go @@ -1,14 +1,16 @@ package thread -import ( - "os" - "testing" -) +import "testing" -func TestMain(m *testing.M) { - Wrap(func() { os.Exit(m.Run()) }) +func init() { + runtime.LockOSThread() } func TestMainThread(t *testing.T) { - _ = 10 + value := 0 + fn := func() { value = 1 } + Main(fn) + if value != 1 { + t.Errorf("wrong value %v", value) + } } diff --git a/pkg/worker/thread/thread.go b/pkg/worker/thread/thread.go index 3cd824ab..20582a85 100644 --- a/pkg/worker/thread/thread.go +++ b/pkg/worker/thread/thread.go @@ -2,6 +2,5 @@ package thread -func Wrap(f func()) { f() } -func Main(f func()) { f() } -func SwitchGraphics(s bool) {} +func Wrap(f func()) { f() } +func Main(f func()) { f() } diff --git a/pkg/worker/thread/thread_darwin.go b/pkg/worker/thread/thread_darwin.go index 120c7af1..bee4f73e 100644 --- a/pkg/worker/thread/thread_darwin.go +++ b/pkg/worker/thread/thread_darwin.go @@ -8,5 +8,3 @@ func Wrap(f func()) { Run(f) } // Main calls a function on the main thread. func Main(f func()) { Call(f) } - -func SwitchGraphics(s bool) { Switch(s) } diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 0da257b2..51614455 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -1,54 +1,32 @@ package worker import ( - "errors" - "fmt" + "time" - "github.com/giongto35/cloud-game/v3/pkg/config" - "github.com/giongto35/cloud-game/v3/pkg/games" - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/monitoring" - "github.com/giongto35/cloud-game/v3/pkg/network" - "github.com/giongto35/cloud-game/v3/pkg/network/httpx" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged" - "github.com/giongto35/cloud-game/v3/pkg/worker/cloud" - "github.com/giongto35/cloud-game/v3/pkg/worker/room" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/network/httpx" + "github.com/giongto35/cloud-game/v2/pkg/service" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/manager/remotehttp" ) type Worker struct { - address string - conf config.WorkerConfig - cord *coordinator - lib games.GameLibrary - launcher games.Launcher - log *logger.Logger - mana *caged.Manager - router *room.GameRouter - services [2]interface { - Run() - Stop() error - } - storage cloud.Storage + address string + conf worker.Config + cord *coordinator + log *logger.Logger + router Router + storage CloudStorage + done chan struct{} } -func New(conf config.WorkerConfig, log *logger.Logger) (*Worker, error) { - manager := caged.NewManager(log) - if err := manager.Load(caged.Libretro, conf); err != nil { - return nil, fmt.Errorf("couldn't cage libretro: %v", err) +const retry = 10 * time.Second + +func New(conf worker.Config, log *logger.Logger, done chan struct{}) (services service.Group) { + if err := remotehttp.CheckCores(conf.Emulator, log); err != nil { + log.Error().Err(err).Msg("cores sync error") } - - library := games.NewLib(conf.Library, conf.Emulator, log) - library.Scan() - - worker := &Worker{ - conf: conf, - lib: library, - launcher: games.NewGameLauncher(library), - log: log, - mana: manager, - router: room.NewGameRouter(), - } - h, err := httpx.NewServer( conf.Worker.GetAddr(), func(s *httpx.Server) httpx.Handler { @@ -58,85 +36,58 @@ func New(conf config.WorkerConfig, log *logger.Logger) (*Worker, error) { }) }, httpx.WithServerConfig(conf.Worker.Server), + // no need just for one route httpx.HttpsRedirect(false), httpx.WithPortRoll(true), httpx.WithZone(conf.Worker.Network.Zone), httpx.WithLogger(log), ) if err != nil { - return nil, fmt.Errorf("http init fail: %w", err) + log.Error().Err(err).Msg("http init fail") + return } - worker.address = h.Addr - worker.services[0] = h + services.Add(h) if conf.Worker.Monitoring.IsEnabled() { - worker.services[1] = monitoring.New(conf.Worker.Monitoring, h.GetHost(), log) + services.Add(monitoring.New(conf.Worker.Monitoring, h.GetHost(), log)) } - st, err := cloud.Store(conf.Storage, log) + st, err := GetCloudStorage(conf.Storage.Provider, conf.Storage.Key) if err != nil { - log.Warn().Err(err).Msgf("cloud storage fail, using no storage") + log.Error().Err(err).Msgf("cloud storage fail, using dummy cloud storage instead") } - worker.storage = st + services.Add(&Worker{address: h.Addr, conf: conf, done: done, log: log, storage: st, router: NewRouter()}) - return worker, nil + return } -func (w *Worker) Reset() { w.router.Reset() } - -func (w *Worker) Start(done chan struct{}) { - for _, s := range w.services { - if s != nil { - s.Run() - } - } - - // !to restore alive worker info when coordinator connection was lost - retry := network.NewRetry() - - onRetryFail := func(err error) { - w.log.Warn().Err(err).Msgf("socket fail. Retrying in %v", retry.Time()) - retry.Fail().Multiply(2) - } - +func (w *Worker) Run() { go func() { remoteAddr := w.conf.Worker.Network.CoordinatorAddress defer func() { if w.cord != nil { - w.cord.Disconnect() + w.cord.Close() } - w.Reset() + w.router.Close() + w.log.Debug().Msgf("Service loop end") }() for { select { - case <-done: + case <-w.done: return default: - w.Reset() - cord, err := newCoordinatorConnection(remoteAddr, w.conf.Worker, w.address, w.log) + conn, err := connect(remoteAddr, w.conf.Worker, w.address, w.log) if err != nil { - onRetryFail(err) + w.log.Error().Err(err).Msgf("no connection: %v. Retrying in %v", remoteAddr, retry) + time.Sleep(retry) continue } - cord.SetErrorHandler(onRetryFail) - w.cord = cord - w.cord.log.Info().Msgf("Connected to the coordinator %v", remoteAddr) - wait := w.cord.HandleRequests(w) - w.cord.SendLibrary(w) - w.cord.SendPrevSessions(w) - <-wait - retry.Success() + w.cord = conn + w.cord.Log.Info().Msgf("Connected to the coordinator %v", remoteAddr) + w.cord.HandleRequests(w) + <-w.cord.Done() + w.router.Close() } } }() } - -func (w *Worker) Stop() error { - var err error - for _, s := range w.services { - if s != nil { - err0 := s.Stop() - err = errors.Join(err, err0) - } - } - return err -} +func (w *Worker) Stop() error { return nil } diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..da52858c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +echo This script should install application dependencies for Debian-based systems +if [ $(id -u) -ne 0 ] +then + echo "error: run with sudo or root" + exit 1 +fi + +apt-get -qq update +apt-get -qq install -y \ + ca-certificates \ + libvpx6 \ + libx264-160 \ + libopus0 \ + libgl1-mesa-glx \ + xvfb \ + xauth diff --git a/scripts/mkdirs.sh b/scripts/mkdirs.sh deleted file mode 100755 index 2ddfb767..00000000 --- a/scripts/mkdirs.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env sh - -app="$1" - -echo Making application runtime directories -mkdir -p ./assets/cache -mkdir -p ./assets/games -mkdir -p ./.cr -if [ "$app" = "worker" ]; then - mkdir -p ./assets/cores - mkdir -p ./libretro -fi - - diff --git a/scripts/version.sh b/scripts/version.sh index 3e273791..8a33daa6 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env sh +#!/bin/sh file="$1" version="$2" diff --git a/test/test.go b/test/test.go deleted file mode 100644 index b80b425a..00000000 --- a/test/test.go +++ /dev/null @@ -1,17 +0,0 @@ -package test - -import ( - "os" - "path" - "runtime" -) - -// runs tests from the root dir when imported - -func init() { - _, filename, _, _ := runtime.Caller(0) - dir := path.Join(path.Dir(filename), "..") - if err := os.Chdir(dir); err != nil { - panic(err) - } -} diff --git a/test/testdata/raw/000_name_fourcc_width_height_stride b/test/testdata/raw/000_name_fourcc_width_height_stride deleted file mode 100644 index e69de29b..00000000 diff --git a/test/testdata/raw/001_alsa_ABGR_256_240_1024.raw.zip b/test/testdata/raw/001_alsa_ABGR_256_240_1024.raw.zip deleted file mode 100644 index a85e7d7b..00000000 Binary files a/test/testdata/raw/001_alsa_ABGR_256_240_1024.raw.zip and /dev/null differ diff --git a/web/css/main.css b/web/css/main.css index 4b95690d..8ffbc897 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -1,13 +1,3 @@ -@font-face { - font-family: '6809'; - src: url('/fonts/6809-Chargen.woff2'); -} - -/*noinspection CssInvalidPseudoSelector*/ -.no-media-controls::-webkit-media-controls { - display: none !important; -} - html { /* force full size for Firefox */ width: 100%; @@ -15,29 +5,45 @@ html { } body { - background-image: url('/img/background.jpg'); + background-image: url('/static/img/background.jpg'); background-repeat: repeat; + + align-items: center; + display: flex; + justify-content: center; } #gamebody { display: flex; overflow: hidden; - width: 640px; + width: 556px; height: 286px; + /*-webkit-box-shadow: inset 0px 0px 2px 2px rgba(219, 222, 222, 1);*/ + /*-moz-box-shadow: inset 0px 0px 2px 2px rgba(219, 222, 222, 1);*/ + /*box-shadow: inset 0px 0px 2px 2px rgba(219, 222, 222, 1);*/ + position: absolute; top: 50%; left: 50%; margin-right: -50%; transform: translate(-50%, -50%); - background-image: url('/img/ui/bg.jpg'); + background-image: url('/static/img/ui/bg.png'); background-repeat: no-repeat; background-size: 100% 100%; - border-radius: 24px; +} - user-select: none; +#ui-emulator-bg { + width: 100%; + height: 100%; + display: block; + position: absolute; + + background-image: url('/static/img/ui/bg.png'); + background-repeat: no-repeat; + background-size: 100% 100%; } #help-overlay { @@ -61,16 +67,11 @@ body { height: 100%; position: absolute; - background-image: url('/img/help_overlay.png'); + background-image: url('/static/img/help_overlay.png'); background-repeat: no-repeat; background-size: 100% 100%; } -#controls-right { - position: absolute; - left: 70px; -} - #circle-pad-holder { display: block; @@ -80,19 +81,19 @@ body { top: 155px; left: 22px; background-size: contain; - background-image: url('/img/ui/bt MOVE.png'); - z-index: 1; + background-image: url('/static/img/ui/bt MOVE.png'); } .dpad-empty { - background-image: url('/img/ui/bt MOVE EMPTY.png') !important; + background-image: url('/static/img/ui/bt MOVE EMPTY.png') !important; } #guide-txt { - color: #979797; + color: #bababa; font-size: 8px; top: 269px; - left: 101px; + left: 30px; + width: 1000px; position: absolute; user-select: none; @@ -100,7 +101,7 @@ body { #circle-pad { display: block; - width: 69px; + width: 70px; height: 70px; position: absolute; background-size: contain; @@ -110,13 +111,13 @@ body { pointer-events: none; opacity: 0.5; - background-image: url('/img/ui/bong.png'); + background-image: url('/static/img/ui/bong.png'); } .bong-full { opacity: 1.0 !important; - background-image: url('/img/ui/bong full.png') !important; + background-image: url('/static/img/ui/bong full.png') !important; } .dpad { @@ -129,40 +130,71 @@ body { #player-index { background-repeat: no-repeat; background-size: contain; - background-image: url('/img/ui/bt PlayerIndex.png'); + background-image: url('/static/img/ui/bt PlayerIndex.png'); } #btn-up { top: 18px; left: 50%; - border-bottom: 0; + border-bottom: 0px; border-radius: 3px; transform: translateX(-50%); + -webkit-transform: translateX(-50%); + -moz-transform: translateX(-50%); } #btn-down { bottom: 18px; left: 50%; - border-top: 0; + border-top: 0px; border-radius: 3px; transform: translateX(-50%); + -webkit-transform: translateX(-50%); + -moz-transform: translateX(-50%); } #btn-left { left: 18px; top: 50%; - border-right: 0; + border-right: 0px; border-radius: 3px; transform: translateY(-50%); + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); } #btn-right { right: 18px; top: 50%; - border-left: 0; + border-left: 0px; border-radius: 3px; transform: translateY(-50%); + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); +} + + +#bottom-screen { + display: flex; + align-items: center; + justify-content: center; + + width: 256px; + height: 240px; + position: absolute; + top: 23px; + left: 150px; + overflow: hidden; + background-color: #333; + + -moz-border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + border-radius: 5px 5px 5px 5px; + + -webkit-box-shadow: 0px 0px 2px 2px rgba(219, 222, 222, 1); + -moz-box-shadow: 0px 0px 2px 2px rgba(219, 222, 222, 1); + box-shadow: 0px 0px 2px 2px rgba(25, 25, 25, 1); } #color-button-holder { @@ -196,28 +228,28 @@ body { } #btn-help { - padding-top: 0; + padding-top: 0px; width: 20px; height: 28px; top: 16px; left: 23px; - background-image: url('/img/ui/Help.png'); + background-image: url('/static/img/ui/Help.png'); } #btn-load { top: 20px; left: 435px; - background-image: url('/img/ui/bt LOAD.png'); + background-image: url('/static/img/ui/bt LOAD.png'); } #btn-save { top: 60px; left: 435px; - background-image: url('/img/ui/bt SAVE.png'); + background-image: url('/static/img/ui/bt SAVE.png'); } #btn-join { @@ -226,7 +258,7 @@ body { left: 22px; height: 25px; - background-image: url('/img/ui/bt SHARE.png'); + background-image: url('/static/img/ui/bt SHARE.png'); } #btn-quit { @@ -235,7 +267,7 @@ body { left: 75px; height: 25px; - background-image: url('/img/ui/bt QUIT.png'); + background-image: url('/static/img/ui/bt QUIT.png'); } #btn-rec { @@ -244,7 +276,10 @@ body { left: 373px; height: 9px; - background-image: url('/img/ui/bt REC.png'); + background-image: url('/static/img/ui/bt REC.png'); +} + +.record { } .record-user { @@ -255,13 +290,15 @@ body { top: 4px; left: 262px; color: #888888; + -moz-border-radius: 6px; + -webkit-border-radius: 6px; border-radius: 6px; outline: none; border-top-style: hidden; border-right-style: hidden; border-left-style: hidden; border-bottom-style: hidden; - background-image: url('/img/ui/FrameTEXT.png'); + background-image: url(/static/img/ui/FrameTEXT.png); background-size: cover; } @@ -277,6 +314,7 @@ body { top: 7px; } + .record.blink:before { animation: blinker 1s linear infinite; } @@ -294,7 +332,7 @@ body { left: 435px; height: 25px; - background-image: url('/img/ui/bt SELECT.png'); + background-image: url('/static/img/ui/bt SELECT.png'); } #btn-start { @@ -302,7 +340,7 @@ body { top: 100px; left: 489px; - background-image: url('/img/ui/bt START.png'); + background-image: url('/static/img/ui/bt START.png'); } #btn-a { @@ -310,8 +348,10 @@ body { right: 0; transform: translateY(-50%); + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); - background-image: url('/img/ui/bt A.png'); + background-image: url('/static/img/ui/bt A.png'); } @@ -320,16 +360,20 @@ body { left: 50%; transform: translateX(-50%); + -webkit-transform: translateX(-50%); + -moz-transform: translateX(-50%); - background-image: url('/img/ui/bt B.png'); + background-image: url('/static/img/ui/bt B.png'); } #btn-x { top: 0; left: 50%; transform: translateX(-50%); + -webkit-transform: translateX(-50%); + -moz-transform: translateX(-50%); - background-image: url('/img/ui/bt X.png'); + background-image: url('/static/img/ui/bt X.png'); } @@ -337,24 +381,25 @@ body { top: 50%; left: 0; transform: translateY(-50%); + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); - background-image: url('/img/ui/bt Y.png'); + background-image: url('/static/img/ui/bt Y.png'); } #btn-settings { width: 65px; height: 21px; - top: 26px; + top: 9%; left: 55px; padding: 0; transform: translateY(-50%); - background-image: url('/img/ui/bt OPTIONS.png'); + background-image: url('/static/img/ui/bt OPTIONS.png'); opacity: .7; - z-index: 0; } #lights-holder { @@ -366,13 +411,54 @@ body { left: 460px; } +@-webkit-keyframes blink { + 0% { + background: #7a7e7d; + } + + 100% { + background: yellow; + } +} + +@-moz-keyframes blink { + 0% { + background: #7a7e7d; + } + + 100% { + background: yellow; + } +} + +@keyframes blink { + 0% { + background: #7a7e7d; + } + + 100% { + background: yellow; + } +} + + +*.unselectable { + -moz-user-select: -moz-none; + -khtml-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; +} + #room-txt { position: absolute; width: 59px; top: 48px; left: 23px; color: #bababa; - padding-left: 0; + padding-left: 0px; + -moz-border-radius: 6px; + -webkit-border-radius: 6px; border-radius: 6px; outline: none; @@ -381,7 +467,7 @@ body { border-left-style: hidden; border-bottom-style: hidden; - background-image: url('/img/ui/FrameTEXT.png'); + background-image: url('/static/img/ui/FrameTEXT.png'); background-size: cover; } @@ -395,12 +481,18 @@ body { opacity: 0.75; } +#bottom-screen { + position: absolute; + /* popups under the screen fix */ + z-index: -1; +} + .game-screen { - position: relative; - object-fit: contain; - width: inherit; - height: inherit; - background-color: #101010; + width: 100%; + height: 100%; + background-color: #222222; + position: absolute; + display: flex; } #menu-screen { @@ -408,12 +500,11 @@ body { display: block; overflow: hidden; - width: 320px; + width: 256px; height: 240px; - background-image: url('/img/screen_background5.png'); + background-image: url('/static/img/screen_background5.png'); background-size: cover; - z-index: 1; } #menu-item-choice { @@ -424,7 +515,7 @@ body { height: 36px; background-color: #FFCF9E; opacity: 0.75; - mix-blend-mode: lighten; + font-family: 'Roboto'; top: 50%; left: 0; @@ -440,10 +531,19 @@ body { top: 102px; /* 240px - 36 / 2 */ left: 0; - /*z-index: 1;*/ } +@font-face { + font-family: 'Roboto'; /*a name to be used later*/ + src: url('/static/fonts/Roboto-Regular.ttf'); /*URL to font*/ +} + +@font-face { + font-family: '6809'; /*a name to be used later*/ + src: url('/static/fonts/6809 chargen.ttf'); /*URL to font*/ +} + .menu-item { display: block; position: relative; @@ -451,19 +551,22 @@ body { width: 100%; height: 36px; /* 35 + 1 border = 36px */ - font-family: '6809', monospace; + font-family: '6809'; font-size: 19px; + /* border-top: 1px dashed blue; */ } -.menu-item div:first-child { +.menu-item div { overflow: hidden; display: block; position: absolute; left: 15px; - top: 7px; - width: 288px; + top: 5px; + width: 226px; height: 25px; + + /* background-color: yellow; */ } .menu-item div span { @@ -479,14 +582,64 @@ body { .menu-item div .pick { overflow: unset; + -moz-animation: horizontally 4s linear infinite alternate; + -webkit-animation: horizontally 4s linear infinite alternate; + animation: horizontally 4s linear infinite alternate; } -.menu-item__info { - color: white; - opacity: .55; - font-size: 30%; - text-align: center; - padding-top: 3px; + +@-moz-keyframes horizontally { + 0% { + transform: translateX(0%); + } + 25% { + transform: translateX(-20%); + } + 50% { + transform: translateX(0%); + } + 75% { + transform: translateX(20%); + } + 100% { + transform: translateX(0%); + } +} + +@-webkit-keyframes horizontally { + 0% { + transform: translateX(0%); + } + 25% { + transform: translateX(-20%); + } + 50% { + transform: translateX(0%); + } + 75% { + transform: translateX(20%); + } + 100% { + transform: translateX(0%); + } +} + +@keyframes horizontally { + 0% { + transform: translateX(0%); + } + 25% { + transform: translateX(-20%); + } + 50% { + transform: translateX(0%); + } + 75% { + transform: translateX(20%); + } + 100% { + transform: translateX(0%); + } } #noti-box { @@ -510,7 +663,7 @@ body { display: block; margin-top: 10px; text-align: center; - font-family: 'Arial', sans-serif; + font-family: 'Roboto'; font-size: 15px; color: #bababa; @@ -530,12 +683,13 @@ body { height: 25px; /* Specified height */ outline: none; /* Remove outline */ /*opacity: 0.7; [> Set transparency (for mouse-over effects on hover) <]*/ + -webkit-transition: .2s; /* 0.2 seconds transition on hover */ transition: opacity .2s; background-color: transparent; background-repeat: no-repeat; background-size: contain; - background-image: url('/img/ui/FramePlayerIndex.png'); + background-image: url('/static/img/ui/FramePlayerIndex.png'); } /* Mouse-over effects */ @@ -554,7 +708,7 @@ body { background-color: transparent; background-repeat: no-repeat; background-size: contain; - background-image: url('/img/ui/bt PlayerIndex.png'); + background-image: url('/static/img/ui/bt PlayerIndex.png'); } .slider::-moz-range-thumb { @@ -563,17 +717,69 @@ body { height: 25px; /* Slider handle height */ cursor: pointer; /* Cursor on hover */ - border: 0; + border: 0px; background-color: transparent; background-repeat: no-repeat; background-size: contain; - background-image: url('/img/ui/bt PlayerIndex.png'); + background-image: url('/static/img/ui/bt PlayerIndex.png'); } * { touch-action: manipulation; } +#stats-overlay { + position: absolute; + z-index: 200; + backface-visibility: hidden; + + display: flex; + flex-direction: column; + justify-content: space-around; + + top: 1.1em; + right: 1.1em; + color: #fff; + background: #000; + opacity: .765; + padding: .5em 1em .1em 1em; + + font-family: monospace; + font-size: 40%; + + width: 70px; + + visibility: hidden; +} + +#stats-overlay > div { + display: flex; + flex-flow: wrap; + justify-content: space-between; + + margin-bottom: .7em; +} + +#stats-overlay > div > div { + display: inline-block; + font-weight: 500; +} + +#stats-overlay .graph { + width: 100%; + /* artifacts with pixelated option */ + /*image-rendering: pixelated;*/ + image-rendering: optimizeSpeed; +} + +.no-select { + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + .dpad-toggle-label { position: absolute; display: inline-block; @@ -598,6 +804,7 @@ body { right: 0; bottom: 0; background-color: #515151; + -webkit-transition: .4s; transition: .4s; border-radius: 20px; } @@ -610,6 +817,7 @@ body { left: 3px; bottom: 3px; background-color: #5f5f5f; + -webkit-transition: .4s; transition: .4s; border-radius: 50%; } @@ -619,6 +827,8 @@ input:checked + .dpad-toggle-slider { } input:checked + .dpad-toggle-slider:before { + -webkit-transform: translateX(15px); + -ms-transform: translateX(15px); transform: translateX(15px); } @@ -630,6 +840,10 @@ input:checked + .dpad-toggle-slider:before { text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; } +.source #v { + cursor: default; +} + .source a { color: #dddddd; } diff --git a/web/css/ui.css b/web/css/ui.css index 1b5d1e79..227a6ec2 100644 --- a/web/css/ui.css +++ b/web/css/ui.css @@ -3,6 +3,67 @@ display: none !important; } +.modal-window { + position: fixed; + + top: 0; + right: 0; + bottom: 0; + left: 0; + + background-color: rgba(0, 0, 0, 0.7); + + z-index: 9999; + visibility: hidden; + opacity: 0; + pointer-events: none; + + -webkit-transition: all 0.2s; + transition: all 0.2s; +} + +.modal-visible { + visibility: visible; + opacity: 1; + pointer-events: auto; +} + + +.modal-window > div { + width: 42vw; + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + padding: 2em; + background: #ffffff; +} + +.modal-window header { + font-weight: bold; +} + +.modal-window h1 { + font-size: 150%; + margin: 0 0 15px; +} + +.semi-button { + cursor: pointer; +} + + +#app-settings { + font-family: monospace; +} + +#settings-data { + overflow-y: auto; + height: 50vh; + padding: 1em 0; +} + .container { display: grid; -webkit-box-pack: center; @@ -12,6 +73,10 @@ height: 100vh; } +.modal-window div:not(:last-of-type) { + margin-bottom: 15px; +} + .btn2 { font-size: 80%; padding: .2em .4em; @@ -23,10 +88,6 @@ height: 1rem; } -.settings { - padding: 0 1em 1em 1em; -} - .settings__controls { color: #aaa; font-size: 80%; @@ -43,23 +104,9 @@ grid-template-rows: auto; } -.settings__option-title { - background-color: beige; - margin-top: .5em; - padding: .5em; -} - .settings__option-name { -} - -.settings__option-desc { - font-size: 61%; - color: #444; - font-family: monospace; -} - -.settings__option-value { - padding: .5em; + background-color: beige; + padding: 1em; } .restart-needed-asterisk:after { @@ -67,37 +114,13 @@ color: red; } -.restart-needed-asterisk-b:before { - content: '*'; - color: red; -} +.settings__option-value { -.settings__option-value select, .settings__option-value input { - font-family: '6809', monospace; - font-size: 90%; -} - -.settings__option-value input:not([type='checkbox']) { - width: 6em; -} - -.settings__option-value option { - font-size: 150%; -} - -.settings__info { - font-size: 80%; } .keyboard-bindings .settings__option-value { display: grid; - grid-template-columns: 20% 20% 20% 20% auto; - row-gap: 5px; -} - -.settings__option-checkbox label { - display: inline-flex; - align-items: center; + grid-template-columns: 25% 25% auto auto; } .binding-element { @@ -106,16 +129,20 @@ align-items: center; } -.binding-element button { - font-family: '6809', monospace; - min-width: 6em; -} - -.binding-element div { - font-size: 80%; -} - /* Server list styling */ +#servers { + background-color: white; + font-size: 12px; + + font-family: '6809', monospace; + + z-index: 1; + position: relative; + + opacity: .95; + + cursor: default; +} .server-list div { display: grid; @@ -144,16 +171,6 @@ display: flex; flex-grow: 1; flex-direction: column; - - background-color: white; - font-size: 12px; - - font-family: '6809', monospace; - - z-index: 1; - position: relative; - - cursor: default; } .panel__header { @@ -194,7 +211,7 @@ background-color: #ededed; padding: 2px 4px; - min-width: 0.7rem; + width: 0.7rem; text-align: center; } @@ -209,10 +226,6 @@ font-weight: bold; } -.panel__button_separator { - width: .5em; -} - .app-button { position: absolute; @@ -227,140 +240,3 @@ .app-button:hover { color: #7e7e7e; } - -.app-button.fs { - position: relative; - top: 0; - left: 0; -} - -#mirror-stream { - image-rendering: pixelated; - width: 100%; - height: 100%; -} - -#screen { - display: flex; - /*align-items: center;*/ - justify-content: center; - - min-width: 0 !important; - min-height: 0 !important; - - position: absolute; - /* popups under the screen fix */ - z-index: -1; - - width: 320px; - height: 240px; - top: 23px; - left: 150px; - overflow: hidden; - background-color: #000000; - - border-radius: 5px 5px 5px 5px; - box-shadow: 0 0 2px 2px rgba(25, 25, 25, 1); -} - -.screen__footer { - position: absolute; - bottom: 0; - display: flex; - flex-direction: row; - border-top: 1px solid #1b1b1b; - width: calc(100% - .6rem); - justify-content: space-between; - - background-color: #00000022; - - height: 13px; - font-size: .6rem; - color: #ffffff; - - opacity: .3; - - padding: 0 .2em; - - cursor: default; -} - -.hover:hover { - opacity: .567; -} - -.with-footer { - height: calc(100% - 14px); -} - -.kbm-button { - top: 265px; - left: 542px; - - text-align: center; - font-size: 70%; - - opacity: .5; - filter: contrast(.3); -} - -.kbm-button-fs { - width: 1em; - text-align: center; - font-size: 110%; - /*color: #ffffff;*/ - /*opacity: .5;*/ - filter: contrast(.3); -} - -.no-pointer { - cursor: none; -} - -#stats-overlay { - cursor: default; - - display: flex; - flex-direction: row; - justify-content: end; - - color: #fff; - background: #000; - /*opacity: .3;*/ - - font-size: 10px; - font-family: monospace; - min-width: 18em; - - gap: 5px; - visibility: hidden; -} - -#stats-overlay > div { - display: flex; - flex-flow: wrap; - justify-content: space-between; - align-items: center; -} - -#stats-overlay > div > div { - display: inline-block; - font-weight: 500; -} - -#stats-overlay .graph { - width: 100%; - /* artifacts with pixelated option */ - /*image-rendering: pixelated;*/ - image-rendering: optimizeSpeed; -} - -.stats-bitrate { - min-width: 3.3rem; -} - -#play-stream { - color: brown; - align-content: center; - font-size: 200%; -} diff --git a/web/fonts/6809 chargen.ttf b/web/fonts/6809 chargen.ttf new file mode 100644 index 00000000..bb816087 Binary files /dev/null and b/web/fonts/6809 chargen.ttf differ diff --git a/web/fonts/6809-Chargen.woff2 b/web/fonts/6809-Chargen.woff2 deleted file mode 100644 index e3e3b406..00000000 Binary files a/web/fonts/6809-Chargen.woff2 and /dev/null differ diff --git a/web/fonts/8-Bit-Madness.ttf b/web/fonts/8-Bit-Madness.ttf new file mode 100644 index 00000000..ea2e0b97 Binary files /dev/null and b/web/fonts/8-Bit-Madness.ttf differ diff --git a/web/fonts/Roboto-Regular.ttf b/web/fonts/Roboto-Regular.ttf new file mode 100644 index 00000000..2b6392ff Binary files /dev/null and b/web/fonts/Roboto-Regular.ttf differ diff --git a/web/fonts/Roboto-Thin.ttf b/web/fonts/Roboto-Thin.ttf new file mode 100644 index 00000000..4e797cf7 Binary files /dev/null and b/web/fonts/Roboto-Thin.ttf differ diff --git a/web/img/background.jpg b/web/img/background.jpg index 1f726014..68df6eec 100644 Binary files a/web/img/background.jpg and b/web/img/background.jpg differ diff --git a/web/img/help_overlay.png b/web/img/help_overlay.png index 256d3237..20c006d2 100644 Binary files a/web/img/help_overlay.png and b/web/img/help_overlay.png differ diff --git a/web/img/screen_background5.png b/web/img/screen_background5.png index f1017218..6222bea8 100644 Binary files a/web/img/screen_background5.png and b/web/img/screen_background5.png differ diff --git a/web/img/ui/FramePlayerIndex.png b/web/img/ui/FramePlayerIndex.png index e94dac7b..c8a78a86 100644 Binary files a/web/img/ui/FramePlayerIndex.png and b/web/img/ui/FramePlayerIndex.png differ diff --git a/web/img/ui/FrameTEXT.png b/web/img/ui/FrameTEXT.png index a08997da..185fe2e2 100644 Binary files a/web/img/ui/FrameTEXT.png and b/web/img/ui/FrameTEXT.png differ diff --git a/web/img/ui/Help.png b/web/img/ui/Help.png index d999f7c6..bb9d072a 100644 Binary files a/web/img/ui/Help.png and b/web/img/ui/Help.png differ diff --git a/web/img/ui/bg.jpg b/web/img/ui/bg.jpg deleted file mode 100644 index d396a3ff..00000000 Binary files a/web/img/ui/bg.jpg and /dev/null differ diff --git a/web/img/ui/bg.png b/web/img/ui/bg.png new file mode 100644 index 00000000..c87100fe Binary files /dev/null and b/web/img/ui/bg.png differ diff --git a/web/img/ui/bong full.png b/web/img/ui/bong full.png index d31d7afa..8304178e 100644 Binary files a/web/img/ui/bong full.png and b/web/img/ui/bong full.png differ diff --git a/web/img/ui/bong.png b/web/img/ui/bong.png index 34894842..b9e2d473 100644 Binary files a/web/img/ui/bong.png and b/web/img/ui/bong.png differ diff --git a/web/img/ui/bt A.png b/web/img/ui/bt A.png index 9b01b8d9..63c644c2 100644 Binary files a/web/img/ui/bt A.png and b/web/img/ui/bt A.png differ diff --git a/web/img/ui/bt B.png b/web/img/ui/bt B.png index 4ec8a616..7458ebd1 100644 Binary files a/web/img/ui/bt B.png and b/web/img/ui/bt B.png differ diff --git a/web/img/ui/bt LOAD.png b/web/img/ui/bt LOAD.png index 074cdb5d..0e999820 100644 Binary files a/web/img/ui/bt LOAD.png and b/web/img/ui/bt LOAD.png differ diff --git a/web/img/ui/bt MOVE EMPTY.png b/web/img/ui/bt MOVE EMPTY.png index 533de364..2a40d66f 100644 Binary files a/web/img/ui/bt MOVE EMPTY.png and b/web/img/ui/bt MOVE EMPTY.png differ diff --git a/web/img/ui/bt MOVE.png b/web/img/ui/bt MOVE.png index bf51d4df..d05cd3b8 100644 Binary files a/web/img/ui/bt MOVE.png and b/web/img/ui/bt MOVE.png differ diff --git a/web/img/ui/bt OPTIONS.png b/web/img/ui/bt OPTIONS.png index 24b4618d..15390f4b 100644 Binary files a/web/img/ui/bt OPTIONS.png and b/web/img/ui/bt OPTIONS.png differ diff --git a/web/img/ui/bt PlayerIndex.png b/web/img/ui/bt PlayerIndex.png index f3973dc8..c4453680 100644 Binary files a/web/img/ui/bt PlayerIndex.png and b/web/img/ui/bt PlayerIndex.png differ diff --git a/web/img/ui/bt QUIT.png b/web/img/ui/bt QUIT.png index 62878bc3..86f2d0e0 100644 Binary files a/web/img/ui/bt QUIT.png and b/web/img/ui/bt QUIT.png differ diff --git a/web/img/ui/bt REC.png b/web/img/ui/bt REC.png index c4f0c69b..8d66855c 100644 Binary files a/web/img/ui/bt REC.png and b/web/img/ui/bt REC.png differ diff --git a/web/img/ui/bt SAVE.png b/web/img/ui/bt SAVE.png index 9db4f68a..0e8bb08e 100644 Binary files a/web/img/ui/bt SAVE.png and b/web/img/ui/bt SAVE.png differ diff --git a/web/img/ui/bt SELECT.png b/web/img/ui/bt SELECT.png index d9006eac..12ced4c4 100644 Binary files a/web/img/ui/bt SELECT.png and b/web/img/ui/bt SELECT.png differ diff --git a/web/img/ui/bt SHARE.png b/web/img/ui/bt SHARE.png index 89db69de..60691834 100644 Binary files a/web/img/ui/bt SHARE.png and b/web/img/ui/bt SHARE.png differ diff --git a/web/img/ui/bt START.png b/web/img/ui/bt START.png index 896ab8b5..88e2f850 100644 Binary files a/web/img/ui/bt START.png and b/web/img/ui/bt START.png differ diff --git a/web/img/ui/bt X.png b/web/img/ui/bt X.png index d3fbf8f0..f1631c12 100644 Binary files a/web/img/ui/bt X.png and b/web/img/ui/bt X.png differ diff --git a/web/img/ui/bt Y.png b/web/img/ui/bt Y.png index e0e7335d..6da62c85 100644 Binary files a/web/img/ui/bt Y.png and b/web/img/ui/bt Y.png differ diff --git a/web/img/ui/frame.png b/web/img/ui/frame.png index 3cec1c88..f48c5129 100644 Binary files a/web/img/ui/frame.png and b/web/img/ui/frame.png differ diff --git a/web/index.html b/web/index.html index 4bd8c591..d6f83333 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,3 @@ - @@ -9,14 +8,15 @@ + - - + + Cloud Retro @@ -24,109 +24,123 @@
W
-
-
-
-
+
+
+
+
-
+
+
+ - + -
-
-
- Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), - Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link) +
Arrows(move),ZXCVAS(game ABXYLR),1/2(1st/2nd player),Shift/Enter/K/L(select/start/save/load),F(fullscreen),share(copy + sharelink to clipboard)
-
+
+
+
- player choice - -
-
- - - -
- - -
-
- -
-
-
-
-
-
+ player choice +
-
+
+
+
- +
+
+
+
+
+
+ +
+ + + -
Oh my god
+
Oh my god
-
+
{{if .Recording.Enabled}} -
+ class="record-user unselectable"> +
{{end}}
-
Cloudretro (ɔ) 2025 + + - + + + + + + + + + + + + + + + + + + + + + + + + - + {{if .Analytics.Inject}} diff --git a/web/js/api.js b/web/js/api.js deleted file mode 100644 index 906342b0..00000000 --- a/web/js/api.js +++ /dev/null @@ -1,337 +0,0 @@ -import {log} from 'log'; - -const endpoints = { - LATENCY_CHECK: 3, - INIT: 4, - INIT_WEBRTC: 100, - OFFER: 101, - ANSWER: 102, - ICE_CANDIDATE: 103, - GAME_START: 104, - GAME_QUIT: 105, - GAME_SAVE: 106, - GAME_LOAD: 107, - GAME_SET_PLAYER_INDEX: 108, - GAME_RECORDING: 110, - GET_WORKER_LIST: 111, - GAME_ERROR_NO_FREE_SLOTS: 112, - GAME_RESET: 113, - - APP_VIDEO_CHANGE: 150, -} - -let transport = { - send: (packet) => { - log.warn('Default transport is used! Change it with the api.transport variable.', packet) - }, - keyboard: (packet) => { - log.warn('Default transport is used! Change it with the api.transport variable.', packet) - }, - mouse: (packet) => { - log.warn('Default transport is used! Change it with the api.transport variable.', packet) - } -} - -const packet = (type, payload, id) => { - const packet = {t: type} - if (id !== undefined) packet.id = id - if (payload !== undefined) packet.p = payload - transport.send(packet) -} - -const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b)) - -const keyboardPress = (() => { - // 0 1 2 3 4 5 6 - // [CODE ] P MOD - const buffer = new ArrayBuffer(7) - const dv = new DataView(buffer) - - return (pressed = false, e) => { - if (e.repeat) return // skip pressed key events - - const key = libretro.mod - let code = libretro.map('', e.code) - let shift = e.shiftKey - - // a special Esc for &$&!& Firefox - if (shift && code === 96) { - code = 27 - shift = false - } - - const mod = 0 - | (e.altKey && key.ALT) - | (e.ctrlKey && key.CTRL) - | (e.metaKey && key.META) - | (shift && key.SHIFT) - | (e.getModifierState('NumLock') && key.NUMLOCK) - | (e.getModifierState('CapsLock') && key.CAPSLOCK) - | (e.getModifierState('ScrollLock') && key.SCROLLOCK) - dv.setUint32(0, code) - dv.setUint8(4, +pressed) - dv.setUint16(5, mod) - transport.keyboard(buffer) - } -})() - -const mouse = { - MOVEMENT: 0, - BUTTONS: 1 -} - -const mouseMove = (() => { - // 0 1 2 3 4 - // T DX DY - const buffer = new ArrayBuffer(5) - const dv = new DataView(buffer) - - return (dx = 0, dy = 0) => { - dv.setUint8(0, mouse.MOVEMENT) - dv.setInt16(1, dx) - dv.setInt16(3, dy) - transport.mouse(buffer) - } -})() - -const mousePress = (() => { - // 0 1 - // T B - const buffer = new ArrayBuffer(2) - const dv = new DataView(buffer) - - // 0: Main button pressed, usually the left button or the un-initialized state - // 1: Auxiliary button pressed, usually the wheel button or the middle button (if present) - // 2: Secondary button pressed, usually the right button - // 3: Fourth button, typically the Browser Back button - // 4: Fifth button, typically the Browser Forward button - - const b2r = [1, 4, 2, 0, 0] // browser mouse button to retro button - // assumed that only one button pressed / released - - return (button = 0, pressed = false) => { - dv.setUint8(0, mouse.BUTTONS) - dv.setUint8(1, pressed ? b2r[button] : 0) - transport.mouse(buffer) - } -})() - - -const libretro = function () {// RETRO_KEYBOARD - const retro = { - '': 0, - 'Unidentified': 0, - 'Unknown': 0, // ??? - 'First': 0, // ??? - 'Backspace': 8, - 'Tab': 9, - 'Clear': 12, - 'Enter': 13, 'Return': 13, - 'Pause': 19, - 'Escape': 27, - 'Space': 32, - 'Exclaim': 33, - 'Quotedbl': 34, - 'Hash': 35, - 'Dollar': 36, - 'Ampersand': 38, - 'Quote': 39, - 'Leftparen': 40, '(': 40, - 'Rightparen': 41, ')': 41, - 'Asterisk': 42, - 'Plus': 43, - 'Comma': 44, - 'Minus': 45, - 'Period': 46, - 'Slash': 47, - 'Digit0': 48, - 'Digit1': 49, - 'Digit2': 50, - 'Digit3': 51, - 'Digit4': 52, - 'Digit5': 53, - 'Digit6': 54, - 'Digit7': 55, - 'Digit8': 56, - 'Digit9': 57, - 'Colon': 58, ':': 58, - 'Semicolon': 59, ';': 59, - 'Less': 60, '<': 60, - 'Equal': 61, '=': 61, - 'Greater': 62, '>': 62, - 'Question': 63, '?': 63, - // RETROK_AT = 64, - 'BracketLeft': 91, '[': 91, - 'Backslash': 92, '\\': 92, - 'BracketRight': 93, ']': 93, - // RETROK_CARET = 94, - // RETROK_UNDERSCORE = 95, - 'Backquote': 96, '`': 96, - 'KeyA': 97, - 'KeyB': 98, - 'KeyC': 99, - 'KeyD': 100, - 'KeyE': 101, - 'KeyF': 102, - 'KeyG': 103, - 'KeyH': 104, - 'KeyI': 105, - 'KeyJ': 106, - 'KeyK': 107, - 'KeyL': 108, - 'KeyM': 109, - 'KeyN': 110, - 'KeyO': 111, - 'KeyP': 112, - 'KeyQ': 113, - 'KeyR': 114, - 'KeyS': 115, - 'KeyT': 116, - 'KeyU': 117, - 'KeyV': 118, - 'KeyW': 119, - 'KeyX': 120, - 'KeyY': 121, - 'KeyZ': 122, - '{': 123, - '|': 124, - '}': 125, - 'Tilde': 126, '~': 126, - 'Delete': 127, - - 'Numpad0': 256, - 'Numpad1': 257, - 'Numpad2': 258, - 'Numpad3': 259, - 'Numpad4': 260, - 'Numpad5': 261, - 'Numpad6': 262, - 'Numpad7': 263, - 'Numpad8': 264, - 'Numpad9': 265, - 'NumpadDecimal': 266, - 'NumpadDivide': 267, - 'NumpadMultiply': 268, - 'NumpadSubtract': 269, - 'NumpadAdd': 270, - 'NumpadEnter': 271, - 'NumpadEqual': 272, - - 'ArrowUp': 273, - 'ArrowDown': 274, - 'ArrowRight': 275, - 'ArrowLeft': 276, - 'Insert': 277, - 'Home': 278, - 'End': 279, - 'PageUp': 280, - 'PageDown': 281, - - 'F1': 282, - 'F2': 283, - 'F3': 284, - 'F4': 285, - 'F5': 286, - 'F6': 287, - 'F7': 288, - 'F8': 289, - 'F9': 290, - 'F10': 291, - 'F11': 292, - 'F12': 293, - 'F13': 294, - 'F14': 295, - 'F15': 296, - - 'NumLock': 300, - 'CapsLock': 301, - 'ScrollLock': 302, - 'ShiftRight': 303, - 'ShiftLeft': 304, - 'ControlRight': 305, - 'ControlLeft': 306, - 'AltRight': 307, - 'AltLeft': 308, - 'MetaRight': 309, - 'MetaLeft': 310, - // RETROK_LSUPER = 311, - // RETROK_RSUPER = 312, - // RETROK_MODE = 313, - // RETROK_COMPOSE = 314, - - // RETROK_HELP = 315, - // RETROK_PRINT = 316, - // RETROK_SYSREQ = 317, - // RETROK_BREAK = 318, - // RETROK_MENU = 319, - 'Power': 320, - // RETROK_EURO = 321, - // RETROK_UNDO = 322, - // RETROK_OEM_102 = 323, - } - - const retroMod = { - NONE: 0x0000, - SHIFT: 0x01, - CTRL: 0x02, - ALT: 0x04, - META: 0x08, - NUMLOCK: 0x10, - CAPSLOCK: 0x20, - SCROLLOCK: 0x40, - } - - const _map = (key = '', code = '') => { - return retro[code] || retro[key] || 0 - } - - return { - map: _map, - mod: retroMod, - } -}() - -/** - * Server API. - * - * Requires the actual api.transport implementation. - */ -export const api = { - set transport(t) { - transport = t; - }, - endpoint: endpoints, - decode: (b) => JSON.parse(decodeBytes(b)), - server: { - initWebrtc: () => packet(endpoints.INIT_WEBRTC), - sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))), - sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))), - latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id), - getWorkerList: () => packet(endpoints.GET_WORKER_LIST), - }, - game: { - input: { - keyboard: { - press: keyboardPress, - }, - mouse: { - move: mouseMove, - press: mousePress, - } - }, - load: () => packet(endpoints.GAME_LOAD), - reset: (roomId) => packet(endpoints.GAME_RESET, {room_id: roomId}), - save: () => packet(endpoints.GAME_SAVE), - setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), - start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, { - game_name: game, - room_id: roomId, - player_index: player, - record: record, - record_user: recordUser, - }), - toggleRecording: (active = false, userName = '') => - packet(endpoints.GAME_RECORDING, {active: active, user: userName}), - quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), - } -} diff --git a/web/js/api/api.js b/web/js/api/api.js new file mode 100644 index 00000000..7fafe225 --- /dev/null +++ b/web/js/api/api.js @@ -0,0 +1,64 @@ +/** + * Server API. + * + * @version 1 + * + */ +const api = (() => { + const endpoints = Object.freeze({ + LATENCY_CHECK: 3, + INIT: 4, + INIT_WEBRTC: 100, + OFFER: 101, + ANSWER: 102, + ICE_CANDIDATE: 103, + GAME_START: 104, + GAME_QUIT: 105, + GAME_SAVE: 106, + GAME_LOAD: 107, + GAME_SET_PLAYER_INDEX: 108, + GAME_TOGGLE_MULTITAP: 109, + GAME_RECORDING: 110, + GET_WORKER_LIST: 111, + }); + + const packet = (type, payload, id) => { + const packet = {t: type}; + if (id !== undefined) packet.id = id; + if (payload !== undefined) packet.p = payload; + + socket.send(packet); + }; + + return Object.freeze({ + endpoint: endpoints, + server: + Object.freeze({ + initWebrtc: () => packet(endpoints.INIT_WEBRTC), + sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))), + sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))), + latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id), + getWorkerList: () => packet(endpoints.GET_WORKER_LIST), + }), + game: + Object.freeze({ + load: () => packet(endpoints.GAME_LOAD), + save: () => packet(endpoints.GAME_SAVE), + setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), + start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, { + game_name: game, + room_id: roomId, + player_index: player, + record: record, + record_user: recordUser, + }), + toggleMultitap: () => packet(endpoints.GAME_TOGGLE_MULTITAP), + toggleRecording: (active = false, userName = '') => + packet(endpoints.GAME_RECORDING, { + active: active, + user: userName, + }), + quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), + }) + }) +})(socket); diff --git a/web/js/app.js b/web/js/app.js deleted file mode 100644 index 3d58dc89..00000000 --- a/web/js/app.js +++ /dev/null @@ -1,627 +0,0 @@ -import {log} from 'log'; -import {opts, settings} from 'settings'; -import {api} from 'api'; -import { - APP_VIDEO_CHANGED, - AXIS_CHANGED, - CONTROLLER_UPDATED, - DPAD_TOGGLE, - FULLSCREEN_CHANGE, - GAME_ERROR_NO_FREE_SLOTS, - GAME_PLAYER_IDX, - GAME_PLAYER_IDX_SET, - GAME_ROOM_AVAILABLE, - GAME_SAVED, - GAMEPAD_CONNECTED, - GAMEPAD_DISCONNECTED, - HELP_OVERLAY_TOGGLED, - KB_MOUSE_FLAG, - KEY_PRESSED, - KEY_RELEASED, - KEYBOARD_KEY_DOWN, - KEYBOARD_KEY_UP, - LATENCY_CHECK_REQUESTED, - MESSAGE, - MOUSE_MOVED, - MOUSE_PRESSED, - POINTER_LOCK_CHANGE, - RECORDING_STATUS_CHANGED, - RECORDING_TOGGLED, - REFRESH_INPUT, - SETTINGS_CHANGED, - WEBRTC_CONNECTION_CLOSED, - WEBRTC_CONNECTION_READY, - WEBRTC_ICE_CANDIDATE_FOUND, - WEBRTC_ICE_CANDIDATE_RECEIVED, - WEBRTC_ICE_CANDIDATES_FLUSH, - WEBRTC_NEW_CONNECTION, - WEBRTC_SDP_ANSWER, - WEBRTC_SDP_OFFER, - WORKER_LIST_FETCHED, - pub, - sub, -} from 'event'; -import {gui} from 'gui'; -import {input, KEY} from 'input'; -import {socket, webrtc} from 'network'; -import {debounce} from 'utils'; - -import {gameList} from './gameList.js?v=3'; -import {menu} from './menu.js?v=3'; -import {message} from './message.js?v=3'; -import {recording} from './recording.js?v=3'; -import {room} from './room.js?v=3'; -import {screen} from './screen.js?v=3'; -import {stats} from './stats.js?v=3'; -import {stream} from './stream.js?v=3'; -import {workerManager} from "./workerManager.js?v=3"; - -settings.init(); -log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); - -// application display state -let state; -let lastState; - -// first user interaction -let interacted = false; - -const helpOverlay = document.getElementById('help-overlay'); -const playerIndex = document.getElementById('playeridx'); - -// screen init -screen.add(menu, stream); - -// keymap -const keyButtons = {}; -Object.keys(KEY).forEach(button => { - keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`); -}); - -/** - * State machine transition. - * @param newState A new state strictly from app.state.* - * @example - * setState(app.state.eden) - */ -const setState = (newState = app.state.eden) => { - if (newState === state) return; - - const prevState = state; - - // keep the current state intact for one of the "uber" states - if (state && state._uber) { - // if we are done with the uber state - if (lastState === newState) state = newState; - lastState = newState; - } else { - lastState = state - state = newState; - } - - if (log.level === log.DEBUG) { - const previous = prevState ? prevState.name : '???'; - const current = state ? state.name : '???'; - const kept = lastState ? lastState.name : '???'; - - log.debug(`[state] ${previous} -> ${current} [${kept}]`); - } -}; - -const onConnectionReady = () => room.id ? startGame() : state.menuReady() - -const onLatencyCheck = async (data) => { - message.show('Connecting to fastest server...'); - const servers = await workerManager.checkLatencies(data); - const latencies = Object.assign({}, ...servers); - log.info('[ping] <->', latencies); - api.server.latencyCheck(data.packetId, latencies); -}; - -const helpScreen = { - shown: false, - show: function (show, event) { - if (this.shown === show) return; - - const isGameScreen = state === app.state.game - screen.toggle(undefined, !show); - - gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen); - gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen); - - gui.toggle(helpOverlay, show) - - this.shown = show; - - if (event) pub(HELP_OVERLAY_TOGGLED, {shown: show}); - } -}; - -const showMenuScreen = () => { - log.debug('[control] loading menu screen'); - - gui.hide(keyButtons[KEY.SAVE]); - gui.hide(keyButtons[KEY.LOAD]); - - gameList.show(); - screen.toggle(menu); - - setState(app.state.menu); -}; - -const startGame = () => { - if (!webrtc.isConnected()) { - message.show('Game cannot load. Please refresh'); - return; - } - - if (!webrtc.isInputReady()) { - message.show('Game is not ready yet. Please wait'); - return; - } - - log.info('[control] game start'); - - setState(app.state.game); - - screen.toggle(stream) - - api.game.start( - gameList.selected, - room.id, - recording.isActive(), - recording.getUser(), - +playerIndex.value - 1, - ) - - gameList.disable() - input.retropad.toggle(false) - gui.show(keyButtons[KEY.SAVE]); - gui.show(keyButtons[KEY.LOAD]); - input.retropad.toggle(true) -}; - -const saveGame = debounce(() => api.game.save(), 1000); -const loadGame = debounce(() => api.game.load(), 1000); - -const onMessage = (m) => { - const {id, t, p: payload} = m; - switch (t) { - case api.endpoint.INIT: - pub(WEBRTC_NEW_CONNECTION, payload); - break; - case api.endpoint.OFFER: - pub(WEBRTC_SDP_OFFER, {sdp: payload}); - break; - case api.endpoint.ICE_CANDIDATE: - pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); - break; - case api.endpoint.GAME_START: - payload.av && pub(APP_VIDEO_CHANGED, payload.av) - payload.kb_mouse && pub(KB_MOUSE_FLAG) - pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId}); - break; - case api.endpoint.GAME_SAVE: - pub(GAME_SAVED); - break; - case api.endpoint.GAME_LOAD: - break; - case api.endpoint.GAME_SET_PLAYER_INDEX: - pub(GAME_PLAYER_IDX_SET, payload); - break; - case api.endpoint.GET_WORKER_LIST: - pub(WORKER_LIST_FETCHED, payload); - break; - case api.endpoint.LATENCY_CHECK: - pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload}); - break; - case api.endpoint.GAME_RECORDING: - pub(RECORDING_STATUS_CHANGED, payload); - break; - case api.endpoint.GAME_ERROR_NO_FREE_SLOTS: - pub(GAME_ERROR_NO_FREE_SLOTS); - break; - case api.endpoint.APP_VIDEO_CHANGE: - pub(APP_VIDEO_CHANGED, {...payload}) - break; - } -} - -const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; - -// pre-state key press handler -const onKeyPress = (data) => { - const button = keyButtons[data.key]; - - if (_dpadArrowKeys.includes(data.key)) { - button.classList.add('dpad-pressed'); - } else { - if (button) button.classList.add('pressed'); - } - - if (state !== app.state.settings) { - if (KEY.HELP === data.key) helpScreen.show(true, event); - } - - state.keyPress(data.key, data.code) -}; - -// pre-state key release handler -const onKeyRelease = data => { - const button = keyButtons[data.key]; - - if (_dpadArrowKeys.includes(data.key)) { - button.classList.remove('dpad-pressed'); - } else { - if (button) button.classList.remove('pressed'); - } - - if (state !== app.state.settings) { - if (KEY.HELP === data.key) helpScreen.show(false, event); - } - - // maybe move it somewhere - if (!interacted) { - // unmute when there is user interaction - stream.audio.mute(false); - interacted = true; - } - - // change app state if settings - if (KEY.SETTINGS === data.key) setState(app.state.settings); - - state.keyRelease(data.key, data.code); -}; - -const updatePlayerIndex = (idx, not_game = false) => { - playerIndex.value = idx + 1; - !not_game && api.game.setPlayerIndex(idx); -}; - -// noop function for the state -const _nil = () => ({/*_*/}) - -const onAxisChanged = (data) => { - // maybe move it somewhere - if (!interacted) { - // unmute when there is user interaction - stream.audio.mute(false); - interacted = true; - } - - state.axisChanged(data.id, data.value); -}; - -const handleToggle = (force = false) => { - const toggle = document.getElementById('dpad-toggle'); - - force && toggle.setAttribute('checked', '') - toggle.checked = !toggle.checked; - pub(DPAD_TOGGLE, {checked: toggle.checked}); -}; - -const handleRecording = (data) => { - const {recording, userName} = data; - api.game.toggleRecording(recording, userName); -} - -const handleRecordingStatus = (data) => { - if (data === 'ok') { - message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`) - if (recording.isActive()) { - recording.setIndicator(true) - } - } else { - message.show(`Recording failed ):`) - recording.setIndicator(false) - } - log.debug("recording is ", recording.isActive()) -} - -const _default = { - name: 'default', - axisChanged: _nil, - keyPress: _nil, - keyRelease: _nil, - menuReady: _nil, -} -const app = { - state: { - eden: { - ..._default, - name: 'eden', - menuReady: showMenuScreen - }, - - settings: { - ..._default, - _uber: true, - name: 'settings', - keyRelease: (() => { - settings.ui.onToggle = (o) => !o && setState(lastState); - return (key) => key === KEY.SETTINGS && settings.ui.toggle() - })(), - menuReady: showMenuScreen - }, - - menu: { - ..._default, - name: 'menu', - axisChanged: (id, val) => id === 1 && gameList.scroll(val < -.5 ? -1 : val > .5 ? 1 : 0), - keyPress: (key) => { - switch (key) { - case KEY.UP: - case KEY.DOWN: - gameList.scroll(key === KEY.UP ? -1 : 1) - break; - } - }, - keyRelease: (key) => { - switch (key) { - case KEY.UP: - case KEY.DOWN: - gameList.scroll(0); - break; - case KEY.JOIN: - case KEY.A: - case KEY.B: - case KEY.X: - case KEY.Y: - case KEY.START: - case KEY.SELECT: - startGame(); - break; - case KEY.QUIT: - message.show('You are already in menu screen!'); - break; - case KEY.LOAD: - message.show('Loading the game.'); - break; - case KEY.SAVE: - message.show('Saving the game.'); - break; - case KEY.STATS: - stats.toggle(); - break; - case KEY.SETTINGS: - break; - case KEY.DTOGGLE: - handleToggle(); - break; - } - }, - }, - - game: { - ..._default, - name: 'game', - axisChanged: (id, value) => input.retropad.setAxisChanged(id, value), - keyboardInput: (pressed, e) => api.game.input.keyboard.press(pressed, e), - mouseMove: (e) => api.game.input.mouse.move(e.dx, e.dy), - mousePress: (e) => api.game.input.mouse.press(e.b, e.p), - keyPress: (key) => input.retropad.setKeyState(key, true), - keyRelease: function (key) { - input.retropad.setKeyState(key, false); - - switch (key) { - case KEY.JOIN: // or SHARE - // save when click share - saveGame(); - room.copyToClipboard(); - message.show('Shared link copied to the clipboard!'); - break; - case KEY.SAVE: - saveGame(); - break; - case KEY.LOAD: - loadGame(); - break; - case KEY.FULL: - screen.fullscreen(); - break; - case KEY.PAD1: - updatePlayerIndex(0); - break; - case KEY.PAD2: - updatePlayerIndex(1); - break; - case KEY.PAD3: - updatePlayerIndex(2); - break; - case KEY.PAD4: - updatePlayerIndex(3); - break; - case KEY.QUIT: - input.retropad.toggle(false) - api.game.quit(room.id) - room.reset(); - window.location = window.location.pathname; - break; - case KEY.RESET: - api.game.reset(room.id) - break; - case KEY.STATS: - stats.toggle(); - break; - case KEY.DTOGGLE: - handleToggle(); - break; - } - }, - } - } -}; - -// switch keyboard+mouse / retropad -const kbmEl = document.getElementById('kbm') -const kbmEl2 = document.getElementById('kbm2') -let kbmSkip = false -const kbmCb = () => { - input.kbm = kbmSkip - kbmSkip = !kbmSkip - pub(REFRESH_INPUT) -} -gui.multiToggle([kbmEl, kbmEl2], { - list: [ - {caption: '⌨️+🖱️', cb: kbmCb}, - {caption: ' 🎮 ', cb: kbmCb} - ] -}) -sub(KB_MOUSE_FLAG, () => { - gui.show(kbmEl, kbmEl2) - handleToggle(true) - message.show('Keyboard and mouse work in fullscreen') -}) - -// Browser lock API -document.onpointerlockchange = () => pub(POINTER_LOCK_CHANGE, document.pointerLockElement) -document.onfullscreenchange = () => pub(FULLSCREEN_CHANGE, document.fullscreenElement) - -// subscriptions -sub(MESSAGE, onMessage); - -sub(GAME_ROOM_AVAILABLE, async () => { - stream.play() -}, 2) -sub(GAME_SAVED, () => message.show('Saved')); -sub(GAME_PLAYER_IDX, data => { - updatePlayerIndex(+data.index, state !== app.state.game); -}); -sub(GAME_PLAYER_IDX_SET, idx => { - if (!isNaN(+idx)) message.show(+idx + 1); -}); -sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500)); -sub(WEBRTC_NEW_CONNECTION, (data) => { - workerManager.whoami(data.wid); - webrtc.onData = (x) => onMessage(api.decode(x.data)) - webrtc.start(data.ice); - api.server.initWebrtc() - gameList.set(data.games); -}); -sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate)); -sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp)); -sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el)); -sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)); -sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); -sub(WEBRTC_CONNECTION_READY, onConnectionReady); -sub(WEBRTC_CONNECTION_CLOSED, () => { - input.retropad.toggle(false) - webrtc.stop(); -}); -sub(LATENCY_CHECK_REQUESTED, onLatencyCheck); -sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected')); -sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected')); - -// keyboard handler in the Screen Lock mode -sub(KEYBOARD_KEY_DOWN, (v) => state.keyboardInput?.(true, v)) -sub(KEYBOARD_KEY_UP, (v) => state.keyboardInput?.(false, v)) - -// mouse handler in the Screen Lock mode -sub(MOUSE_MOVED, (e) => state.mouseMove?.(e)) -sub(MOUSE_PRESSED, (e) => state.mousePress?.(e)) - -// general keyboard handler -sub(KEY_PRESSED, onKeyPress); -sub(KEY_RELEASED, onKeyRelease); - -sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); -sub(AXIS_CHANGED, onAxisChanged); -sub(CONTROLLER_UPDATED, data => webrtc.input(data)); -sub(RECORDING_TOGGLED, handleRecording); -sub(RECORDING_STATUS_CHANGED, handleRecordingStatus); - -sub(SETTINGS_CHANGED, () => { - const s = settings.get(); - log.level = s[opts.LOG_LEVEL]; -}); - -// initial app state -setState(app.state.eden); - -input.init() - -stream.init(); -screen.init(); - -let [roomId, zone] = room.loadMaybe(); -// find worker id if present -const wid = new URLSearchParams(document.location.search).get('wid'); -// if from URL -> start game immediately! -socket.init(roomId, wid, zone); -api.transport = { - send: socket.send, - keyboard: webrtc.keyboard, - mouse: webrtc.mouse, -} - -// stats -let WEBRTC_STATS_RTT; -let VIDEO_BITRATE; -let GET_V_CODEC, SET_CODEC; - -const bitrate = (() => { - let bytesPrev, timestampPrev - const w = [0, 0, 0, 0, 0, 0] - const n = w.length - let i = 0 - return (now, bytes) => { - w[i++ % n] = timestampPrev ? Math.floor(8 * (bytes - bytesPrev) / (now - timestampPrev)) : 0 - bytesPrev = bytes - timestampPrev = now - return Math.floor(w.reduce((a, b) => a + b) / n) - } -})() - -stats.modules = [ - { - mui: stats.mui('', '<1'), - init() { - WEBRTC_STATS_RTT = (v) => (this.val = v) - }, - }, - { - mui: stats.mui('', '', false, () => ''), - init() { - GET_V_CODEC = (v) => (this.val = v + ' @ ') - } - }, - { - mui: stats.mui('', '', false, () => ''), - init() { - sub(APP_VIDEO_CHANGED, ({s = 1, w, h}) => (this.val = `${w * s}x${h * s}`)) - }, - }, - { - mui: stats.mui('', '', false, () => ' kb/s', 'stats-bitrate'), - init() { - VIDEO_BITRATE = (v) => (this.val = v) - } - }, - { - async stats() { - const stats = await webrtc.stats(); - if (!stats) return; - - stats.forEach(report => { - if (!SET_CODEC && report.mimeType?.startsWith('video/')) { - GET_V_CODEC(report.mimeType.replace('video/', '').toLowerCase()) - SET_CODEC = 1 - } - const {nominated, currentRoundTripTime, type, kind} = report; - if (nominated && currentRoundTripTime !== undefined) { - WEBRTC_STATS_RTT(currentRoundTripTime * 1000); - } - if (type === 'inbound-rtp' && kind === 'video') { - VIDEO_BITRATE(bitrate(report.timestamp, report.bytesReceived)) - } - }); - }, - enable() { - this.interval = window.setInterval(this.stats, 999); - }, - disable() { - window.clearInterval(this.interval); - }, - }] - -stats.toggle() diff --git a/web/js/controller.js b/web/js/controller.js new file mode 100644 index 00000000..ab0ad129 --- /dev/null +++ b/web/js/controller.js @@ -0,0 +1,537 @@ +/** + * App controller module. + * @version 1 + */ +(() => { + // application state + let state; + let lastState; + + // first user interaction + let interacted = false; + + // ping-pong + // let pingPong = 0; + + const DIR = (() => { + return { + IDLE: 'idle', + UP: 'up', + DOWN: 'down', + } + })(); + let prevDir = DIR.IDLE; + + const menuScreen = document.getElementById('menu-screen'); + const helpOverlay = document.getElementById('help-overlay'); + const playerIndex = document.getElementById('playeridx'); + + // keymap + const keyButtons = {}; + Object.keys(KEY).forEach(button => { + keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`); + }); + + /** + * State machine transition. + * @param newState A new state strictly from app.state.* + * @example + * setState(app.state.eden) + */ + const setState = (newState = app.state.eden) => { + if (newState === state) return; + + const prevState = state; + + // keep the current state intact for one of the "uber" states + if (state && state._uber) { + // if we are done with the uber state + if (lastState === newState) state = newState; + lastState = newState; + } else { + lastState = state + state = newState; + } + + if (log.level === log.DEBUG) { + const previous = prevState ? prevState.name : '???'; + const current = state ? state.name : '???'; + const kept = lastState ? lastState.name : '???'; + + log.debug(`[state] ${previous} -> ${current} [${kept}]`); + } + }; + + const onGameRoomAvailable = () => { + message.show('Now you can share you game!'); + }; + + // const onWebrtcMessage = () => { + // event.pub(PING_RESPONSE); + // }; + + const onConnectionReady = () => { + // ping / pong + // if (pingPong === 0) { + // pingPong = setInterval(() => { + // if (!webrtc.message('x')) { + // clearInterval(pingPong); + // pingPong = 0; + // log.info("ping-pong was disabled due to remote channel error"); + // } + // event.pub(PING_REQUEST, {time: Date.now()}) + // }, 10000); + // } + + // start a game right away or show the menu + if (room.getId()) { + startGame(); + } else { + state.menuReady(); + } + }; + + const onLatencyCheck = async (data) => { + message.show('Connecting to fastest server...'); + const servers = await workerManager.checkLatencies(data); + const latencies = Object.assign({}, ...servers); + log.info('[ping] <->', latencies); + api.server.latencyCheck(data.packetId, latencies); + }; + + const helpScreen = { + // don't call $ if holding the button + shown: false, + // use function () if you need "this" + show: function (show, event) { + if (this.shown === show) return; + + const isGameScreen = state === app.state.game + if (isGameScreen) { + stream.toggle(!show); + } else { + gui.toggle(menuScreen, !show); + } + + gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen); + gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen); + + gui.toggle(helpOverlay, show) + + this.shown = show; + + if (event) event.pub(HELP_OVERLAY_TOGGLED, {shown: show}); + } + }; + + const showMenuScreen = () => { + log.debug('[control] loading menu screen'); + + stream.toggle(false); + gui.hide(keyButtons[KEY.SAVE]); + gui.hide(keyButtons[KEY.LOAD]); + + gameList.show(); + gui.show(menuScreen); + + setState(app.state.menu); + }; + + const startGame = () => { + if (!webrtc.isConnected()) { + message.show('Game cannot load. Please refresh'); + return; + } + + if (!webrtc.isInputReady()) { + message.show('Game is not ready yet. Please wait'); + return; + } + + log.info('[control] game start'); + + setState(app.state.game); + + stream.play() + + // TODO get current game from the URL and not from the list? + // if we are opening a share link it will send the default game name to the server + // currently it's a game with the index 1 + // on the server this game is ignored and the actual game will be extracted from the share link + // so there's no point in doing this and this' really confusing + + api.game.start( + gameList.getCurrentGame(), + room.getId(), + recording.isActive(), + recording.getUser(), + +playerIndex.value - 1, + ); + + // clear menu screen + input.poll.disable(); + gui.hide(menuScreen); + stream.toggle(true); + gui.show(keyButtons[KEY.SAVE]); + gui.show(keyButtons[KEY.LOAD]); + // end clear + input.poll.enable(); + }; + + const saveGame = utils.debounce(() => api.game.save(), 1000); + const loadGame = utils.debounce(() => api.game.load(), 1000); + + const onMessage = (message) => { + const {id, t, p: payload} = message; + switch (t) { + case api.endpoint.INIT: + event.pub(WEBRTC_NEW_CONNECTION, payload); + break; + case api.endpoint.OFFER: + event.pub(WEBRTC_SDP_OFFER, {sdp: payload}); + break; + case api.endpoint.ICE_CANDIDATE: + event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); + break; + case api.endpoint.GAME_START: + event.pub(GAME_ROOM_AVAILABLE, {roomId: payload}); + break; + case api.endpoint.GAME_SAVE: + event.pub(GAME_SAVED); + break; + case api.endpoint.GAME_LOAD: + event.pub(GAME_LOADED); + break; + case api.endpoint.GAME_SET_PLAYER_INDEX: + event.pub(GAME_PLAYER_IDX_SET, payload); + break; + case api.endpoint.GET_WORKER_LIST: + event.pub(WORKER_LIST_FETCHED, payload); + break; + case api.endpoint.LATENCY_CHECK: + event.pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload}); + break; + case api.endpoint.GAME_RECORDING: + event.pub(RECORDING_STATUS_CHANGED, payload); + break; + } + } + + const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; + + // pre-state key press handler + const onKeyPress = (data) => { + const button = keyButtons[data.key]; + + if (_dpadArrowKeys.includes(data.key)) { + button.classList.add('dpad-pressed'); + } else { + if (button) button.classList.add('pressed'); + } + + if (state !== app.state.settings) { + if (KEY.HELP === data.key) helpScreen.show(true, event); + } + + state.keyPress(data.key); + }; + + // pre-state key release handler + const onKeyRelease = data => { + const button = keyButtons[data.key]; + + if (_dpadArrowKeys.includes(data.key)) { + button.classList.remove('dpad-pressed'); + } else { + if (button) button.classList.remove('pressed'); + } + + if (state !== app.state.settings) { + if (KEY.HELP === data.key) helpScreen.show(false, event); + } + + // maybe move it somewhere + if (!interacted) { + // unmute when there is user interaction + stream.audio.mute(false); + interacted = true; + } + + // change app state if settings + if (KEY.SETTINGS === data.key) setState(app.state.settings); + + state.keyRelease(data.key); + }; + + const updatePlayerIndex = idx => { + playerIndex.value = idx + 1; + api.game.setPlayerIndex(idx); + }; + + // noop function for the state + const _nil = () => ({/*_*/}) + + const onAxisChanged = (data) => { + // maybe move it somewhere + if (!interacted) { + // unmute when there is user interaction + stream.audio.mute(false); + interacted = true; + } + + state.axisChanged(data.id, data.value); + }; + + const handleToggle = () => { + const toggle = document.getElementById('dpad-toggle'); + toggle.checked = !toggle.checked; + event.pub(DPAD_TOGGLE, {checked: toggle.checked}); + }; + + const handleRecording = (data) => { + const {recording, userName} = data; + api.game.toggleRecording(recording, userName); + } + + const handleRecordingStatus = (data) => { + if (data === 'ok') { + message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`, true) + if (recording.isActive()) { + recording.setIndicator(true) + } + } else { + message.show(`Recording failed ):`) + recording.setIndicator(false) + } + console.log("recording is ", recording.isActive()) + } + + const _default = { + name: 'default', + axisChanged: _nil, + keyPress: _nil, + keyRelease: _nil, + menuReady: _nil, + } + const app = { + state: { + eden: { + ..._default, + name: 'eden', + menuReady: showMenuScreen + }, + + settings: { + ..._default, + _uber: true, + name: 'settings', + keyRelease: key => { + if (key === KEY.SETTINGS) { + const isSettingsOpened = settings.ui.toggle(); + if (!isSettingsOpened) setState(lastState); + } + }, + menuReady: showMenuScreen + }, + + menu: { + ..._default, + name: 'menu', + axisChanged: (id, value) => { + if (id === 1) { // Left Stick, Y Axis + let dir = DIR.IDLE; + if (value < -0.5) dir = DIR.UP; + if (value > 0.5) dir = DIR.DOWN; + if (dir !== prevDir) { + prevDir = dir; + switch (dir) { + case DIR.IDLE: + gameList.stopGamePickerTimer(); + break; + case DIR.UP: + gameList.startGamePickerTimer(true); + break; + case DIR.DOWN: + gameList.startGamePickerTimer(false); + break; + } + } + } + }, + keyPress: (key) => { + switch (key) { + case KEY.UP: + case KEY.DOWN: + gameList.startGamePickerTimer(key === KEY.UP); + break; + } + }, + keyRelease: (key) => { + switch (key) { + case KEY.UP: + case KEY.DOWN: + gameList.stopGamePickerTimer(); + break; + case KEY.JOIN: + case KEY.A: + case KEY.B: + case KEY.X: + case KEY.Y: + case KEY.START: + case KEY.SELECT: + startGame(); + break; + case KEY.QUIT: + message.show('You are already in menu screen!'); + break; + case KEY.LOAD: + message.show('Loading the game.'); + break; + case KEY.SAVE: + message.show('Saving the game.'); + break; + case KEY.STATS: + event.pub(STATS_TOGGLE); + break; + case KEY.SETTINGS: + break; + case KEY.DTOGGLE: + handleToggle(); + break; + } + }, + }, + + game: { + ..._default, + name: 'game', + axisChanged: (id, value) => input.setAxisChanged(id, value), + keyPress: key => input.setKeyState(key, true), + keyRelease: function (key) { + input.setKeyState(key, false); + + switch (key) { + case KEY.JOIN: // or SHARE + // save when click share + saveGame(); + room.copyToClipboard(); + message.show('Shared link copied to the clipboard!'); + break; + case KEY.SAVE: + saveGame(); + break; + case KEY.LOAD: + loadGame(); + break; + case KEY.FULL: + stream.video.toggleFullscreen(); + break; + + // update player index + case KEY.PAD1: + updatePlayerIndex(0); + break; + case KEY.PAD2: + updatePlayerIndex(1); + break; + case KEY.PAD3: + updatePlayerIndex(2); + break; + case KEY.PAD4: + updatePlayerIndex(3); + break; + + // toggle multitap + case KEY.MULTITAP: + api.game.toggleMultitap(); + break; + + // quit + case KEY.QUIT: + input.poll.disable(); + + api.game.quit(room.getId()); + room.reset(); + + message.show('Quit!'); + + window.location = window.location.pathname; + break; + + case KEY.STATS: + event.pub(STATS_TOGGLE); + break; + case KEY.DTOGGLE: + handleToggle(); + break; + } + }, + } + } + }; + + // subscriptions + event.sub(MESSAGE, onMessage); + + event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2); + event.sub(GAME_SAVED, () => message.show('Saved')); + event.sub(GAME_LOADED, () => message.show('Loaded')); + event.sub(GAME_PLAYER_IDX, data => { + updatePlayerIndex(+data.index); + }); + event.sub(GAME_PLAYER_IDX_SET, idx => { + if (!isNaN(+idx)) message.show(+idx + 1); + }); + event.sub(WEBRTC_NEW_CONNECTION, (data) => { + // if (pingPong) { + // webrtc.setMessageHandler(onWebrtcMessage); + // } + workerManager.whoami(data.wid); + webrtc.start(data.ice); + api.server.initWebrtc() + gameList.set(data.games); + }); + event.sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate)); + event.sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp)); + event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el())); + event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)); + event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); + // event.sub(MEDIA_STREAM_READY, () => rtcp.start()); + event.sub(WEBRTC_CONNECTION_READY, onConnectionReady); + event.sub(WEBRTC_CONNECTION_CLOSED, () => { + input.poll.disable(); + // if (pingPong > 0) { + // clearInterval(pingPong); + // pingPong = 0; + // } + webrtc.stop(); + }); + event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheck); + event.sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected')); + event.sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected')); + // touch stuff + event.sub(MENU_HANDLER_ATTACHED, (data) => { + menuScreen.addEventListener(data.event, data.handler, {passive: true}); + }); + event.sub(KEY_PRESSED, onKeyPress); + event.sub(KEY_RELEASED, onKeyRelease); + event.sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); + event.sub(SETTINGS_CLOSED, () => { + state.keyRelease(KEY.SETTINGS); + }); + event.sub(AXIS_CHANGED, onAxisChanged); + event.sub(CONTROLLER_UPDATED, data => webrtc.input(data)); + // recording + event.sub(RECORDING_TOGGLED, handleRecording); + event.sub(RECORDING_STATUS_CHANGED, handleRecordingStatus); + + event.sub(SETTINGS_CHANGED, () => { + const newValue = settings.get()[opts.LOG_LEVEL]; + if (newValue !== log.level) { + log.level = newValue; + } + }); + + // initial app state + setState(app.state.eden); +})(api, document, event, env, gameList, input, KEY, log, message, recording, room, settings, socket, stats, stream, utils, webrtc, workerManager); diff --git a/web/js/env.js b/web/js/env.js index a725c87d..0a6ddb80 100644 --- a/web/js/env.js +++ b/web/js/env.js @@ -1,113 +1,120 @@ -import { - pub, - TRANSFORM_CHANGE -} from 'event'; +const env = (() => { + // UI + const page = document.getElementsByTagName('html')[0]; + const gameBoy = document.getElementById('gamebody'); + const sourceLink = document.getElementsByClassName('source')[0]; -// UI -const page = document.getElementsByTagName('html')[0]; -const gameBoy = document.getElementById('gamebody'); -const sourceLink = document.getElementsByClassName('source')[0]; + let isLayoutSwitched = false; -export const browser = {unknown: 0, firefox: 1, chrome: 2, edge: 3, safari: 4} -export const platform = {unknown: 0, windows: 1, linux: 2, macos: 3, android: 4,} + // Window rerender / rotate screen if needed + const fixScreenLayout = () => { + let pw = getWidth(page), + ph = getHeight(page), + targetWidth = Math.round(pw * 0.9 / 2) * 2, + targetHeight = Math.round(ph * 0.9 / 2) * 2; -let isLayoutSwitched = false; + // save page rotation + isLayoutSwitched = isPortrait(); -// Window rerender / rotate screen if needed -const fixScreenLayout = () => { - let pw = getWidth(page), - ph = getHeight(page), - targetWidth = Math.round(pw * 0.9 / 2) * 2, - targetHeight = Math.round(ph * 0.9 / 2) * 2; + rescaleGameBoy(targetWidth, targetHeight); - // save page rotation - isLayoutSwitched = isPortrait(); + sourceLink.style['bottom'] = isLayoutSwitched ? 0 : ''; + if (isLayoutSwitched) { + sourceLink.style.removeProperty('right'); + sourceLink.style['left'] = 5; + } else { + sourceLink.style.removeProperty('left'); + sourceLink.style['right'] = 5; + } + sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : ''; + sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : ''; + }; - rescaleGameBoy(targetWidth, targetHeight); + const rescaleGameBoy = (targetWidth, targetHeight) => { + const transformations = ['translate(-50%, -50%)']; - sourceLink.style['bottom'] = isLayoutSwitched ? 0 : ''; - if (isLayoutSwitched) { - sourceLink.style.removeProperty('right'); - sourceLink.style['left'] = 5; - } else { - sourceLink.style.removeProperty('left'); - sourceLink.style['right'] = 5; - } - sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : ''; - sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : ''; -}; + if (isLayoutSwitched) { + transformations.push('rotate(90deg)'); + [targetWidth, targetHeight] = [targetHeight, targetWidth] + } -const rescaleGameBoy = (targetWidth, targetHeight) => { - const transformations = ['translate(-50%, -50%)']; + // scale, fit to target size + const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy)); + transformations.push(`scale(${scale})`); - if (isLayoutSwitched) { - transformations.push('rotate(90deg)'); - [targetWidth, targetHeight] = [targetHeight, targetWidth] + gameBoy.style['transform'] = transformations.join(' '); } - // scale, fit to target size - const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy)); - transformations.push(`scale(${scale})`); + const getOS = () => { + // linux? ios? + let OSName = 'unknown'; + if (navigator.appVersion.indexOf('Win') !== -1) OSName = 'win'; + else if (navigator.appVersion.indexOf('Mac') !== -1) OSName = 'mac'; + else if (navigator.userAgent.indexOf('Linux') !== -1) OSName = 'linux'; + else if (navigator.userAgent.indexOf('Android') !== -1) OSName = 'android'; + return OSName; + }; - gameBoy.style['transform'] = transformations.join(' '); -} + const getBrowser = () => { + let browserName = 'unknown'; + if (navigator.userAgent.indexOf('Firefox') !== -1) browserName = 'firefox'; + if (navigator.userAgent.indexOf('Chrome') !== -1) browserName = 'chrome'; + if (navigator.userAgent.indexOf('Edge') !== -1) browserName = 'edge'; + if (navigator.userAgent.indexOf('Version/') !== -1) browserName = 'safari'; + if (navigator.userAgent.indexOf('UCBrowser') !== -1) browserName = 'uc'; + return browserName; + }; -new MutationObserver(() => pub(TRANSFORM_CHANGE)).observe(gameBoy, {attributeFilter: ['style']}) + const isPortrait = () => getWidth(page) < getHeight(page); -const os = () => { - const ua = window.navigator.userAgent; - // noinspection JSUnresolvedReference,JSDeprecatedSymbols - const plt = window.navigator?.userAgentData?.platform || window.navigator.platform; - const macs = ["Macintosh", "MacIntel"]; - const wins = ["Win32", "Win64", "Windows"]; - if (wins.indexOf(plt) !== -1) return platform.windows; - if (macs.indexOf(plt) !== -1) return platform.macos; - if (/Linux/.test(plt)) return platform.linux; - if (/Android/.test(ua)) return platform.android; - return platform.unknown -} + const toggleFullscreen = (enable, element) => { + const el = enable ? element : document; -const _browser = () => { - if (navigator.userAgent.indexOf('Firefox') !== -1) return browser.firefox; - if (navigator.userAgent.indexOf('Chrome') !== -1) return browser.chrome; - if (navigator.userAgent.indexOf('Edge') !== -1) return browser.edge; - if (navigator.userAgent.indexOf('Version/') !== -1) return browser.safari; - return browser.unknown; -} + if (enable) { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.mozRequestFullScreen) { /* Firefox */ + el.mozRequestFullScreen(); + } else if (el.webkitRequestFullscreen) { /* Chrome, Safari and Opera */ + el.webkitRequestFullscreen(); + } else if (el.msRequestFullscreen) { /* IE/Edge */ + el.msRequestFullscreen(); + } + } else { + if (el.exitFullscreen) { + el.exitFullscreen(); + } else if (el.mozCancelFullScreen) { /* Firefox */ + el.mozCancelFullScreen(); + } else if (el.webkitExitFullscreen) { /* Chrome, Safari and Opera */ + el.webkitExitFullscreen(); + } else if (el.msExitFullscreen) { /* IE/Edge */ + el.msExitFullscreen(); + } + } + }; -const isMobile = () => /Mobi|Android|iPhone/i.test(navigator.userAgent); - -const isPortrait = () => getWidth(page) < getHeight(page); - -const toggleFullscreen = (enable, element) => { - const el = enable ? element : document; - if (enable) { - el.requestFullscreen?.().then().catch(); - return + function getHeight(el) { + return parseFloat(getComputedStyle(el, null).height.replace("px", "")); } - el.exitFullscreen?.().then().catch(); -} -function getHeight(el) { - return parseFloat(getComputedStyle(el, null).height.replace("px", "")); -} + function getWidth(el) { + return parseFloat(getComputedStyle(el, null).width.replace("px", "")); + } -function getWidth(el) { - return parseFloat(getComputedStyle(el, null).width.replace("px", "")); -} + window.addEventListener('resize', fixScreenLayout); + window.addEventListener('orientationchange', fixScreenLayout); + document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false); -window.addEventListener('resize', fixScreenLayout); -window.addEventListener('orientationchange', fixScreenLayout); -document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false); - -export const env = { - getOs: os(), - getBrowser: _browser(), - isMobileDevice: isMobile(), - display: () => ({ - isPortrait, - toggleFullscreen, - fixScreenLayout, - isLayoutSwitched: isLayoutSwitched - }) -} + return { + getOs: getOS, + getBrowser: getBrowser, + // Check mobile type because different mobile can accept different video encoder. + isMobileDevice: () => (typeof window.orientation !== 'undefined') || (navigator.userAgent.indexOf('IEMobile') !== -1), + display: () => ({ + isPortrait: isPortrait, + toggleFullscreen: toggleFullscreen, + fixScreenLayout: fixScreenLayout, + isLayoutSwitched: isLayoutSwitched + }) + } +})(document, log, navigator, screen, window); diff --git a/web/js/event.js b/web/js/event.js deleted file mode 100644 index 8ade9024..00000000 --- a/web/js/event.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Event publishing / subscribe module. - * Just a simple observer pattern. - */ - -const topics = {}; - -// internal listener index -let _index = 0; - -/** - * Subscribes onto some event. - * - * @param topic The name of the event. - * @param listener A callback function to call during the event. - * @param order A number in a queue of event handlers to run callback in ordered manner. - * @returns {{unsub: unsub}} The function to remove this subscription. - * @example - * const sub01 = event.sub('rapture', () => {a}, 1) - * ... - * sub01.unsub() - */ -export const sub = (topic, listener, order = undefined) => { - if (!topics[topic]) topics[topic] = {}; - // order index * big pad + next internal index (e.g. 1*100+1=101) - // use some arbitrary big number to not overlap with non-ordered - let i = (order !== undefined ? order * 1000000 : 0) + _index++; - topics[topic][i] = listener; - return { - unsub: () => { - delete topics[topic][i] - } - } -} - -/** - * Publishes some event for handling. - * - * @param topic The name of the event. - * @param data Additional data for the event handling. - * Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data). - * @example - * event.pub('rapture', {time: now()}) - */ -export const pub = (topic, data) => { - if (!topics[topic]) return; - Object.keys(topics[topic]).forEach((ls) => { - topics[topic][ls](data !== undefined ? data : {}) - }); -} - -// events -export const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; - -export const WORKER_LIST_FETCHED = 'workerListFetched'; - -export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; -export const GAME_SAVED = 'gameSaved'; -export const GAME_PLAYER_IDX = 'gamePlayerIndex'; -export const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet' -export const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots' - -export const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed'; -export const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady'; -export const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound' -export const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived'; -export const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush'; -export const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection'; -export const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer' -export const WEBRTC_SDP_OFFER = 'webrtcSdpOffer'; - -export const MESSAGE = 'message' - -export const GAMEPAD_CONNECTED = 'gamepadConnected'; -export const GAMEPAD_DISCONNECTED = 'gamepadDisconnected'; - -export const MENU_HANDLER_ATTACHED = 'menuHandlerAttached'; -export const MENU_PRESSED = 'menuPressed'; -export const MENU_RELEASED = 'menuReleased'; - -export const KEY_PRESSED = 'keyPressed'; -export const KEY_RELEASED = 'keyReleased'; -export const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode'; -export const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed'; -export const KEYBOARD_KEY_DOWN = 'keyboardKeyDown'; -export const KEYBOARD_KEY_UP = 'keyboardKeyUp'; - -export const AXIS_CHANGED = 'axisChanged'; -export const CONTROLLER_UPDATED = 'controllerUpdated'; - -export const MOUSE_MOVED = 'mouseMoved' -export const MOUSE_PRESSED = 'mousePressed' - -export const FULLSCREEN_CHANGE = 'fsc' -export const POINTER_LOCK_CHANGE = 'plc' -export const TRANSFORM_CHANGE = 'tc' - -export const DPAD_TOGGLE = 'dpadToggle'; -export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; - -export const SETTINGS_CHANGED = 'settingsChanged'; - -export const RECORDING_TOGGLED = 'recordingToggle' -export const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' - -export const APP_VIDEO_CHANGED = 'appVideoChanged' -export const KB_MOUSE_FLAG = 'kbMouseFlag' - -export const REFRESH_INPUT = 'refreshInput' diff --git a/web/js/event/event.js b/web/js/event/event.js new file mode 100644 index 00000000..922d468f --- /dev/null +++ b/web/js/event/event.js @@ -0,0 +1,102 @@ +/** + * Event publishing / subscribe module. + * Just a simple observer pattern. + * @version 1 + */ +const event = (() => { + const topics = {}; + + // internal listener index + let _index = 0; + + return { + /** + * Subscribes onto some event. + * + * @param topic The name of the event. + * @param listener A callback function to call during the event. + * @param order A number in a queue of event handlers to run callback in ordered manner. + * @returns {{unsub: unsub}} The function to remove this subscription. + * @example + * const sub01 = event.sub('rapture', () => {a}, 1) + * ... + * sub01.unsub() + */ + sub: (topic, listener, order = undefined) => { + if (!topics[topic]) topics[topic] = {}; + // order index * big pad + next internal index (e.g. 1*100+1=101) + // use some arbitrary big number to not overlap with non-ordered + let i = (order !== undefined ? order * 1000000 : 0) + _index++; + topics[topic][i] = listener; + return Object.freeze({ + unsub: () => { + delete topics[topic][i] + } + }); + }, + + /** + * Publishes some event for handling. + * + * @param topic The name of the event. + * @param data Additional data for the event handling. + * Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data). + * @example + * event.pub('rapture', {time: now()}) + */ + pub: (topic, data) => { + if (!topics[topic]) return; + Object.keys(topics[topic]).forEach((ls) => { + topics[topic][ls](data !== undefined ? data : {}) + }); + } + } +})(); + +// events +const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; +const PING_REQUEST = 'pingRequest'; +const PING_RESPONSE = 'pingResponse'; + +const WORKER_LIST_FETCHED = 'workerListFetched'; + +const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; +const GAME_SAVED = 'gameSaved'; +const GAME_LOADED = 'gameLoaded'; +const GAME_PLAYER_IDX = 'gamePlayerIndex'; +const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet' + +const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed'; +const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady'; +const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound' +const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived'; +const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush'; +const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection'; +const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer' +const WEBRTC_SDP_OFFER = 'webrtcSdpOffer'; + +const MESSAGE = 'message' + +const GAMEPAD_CONNECTED = 'gamepadConnected'; +const GAMEPAD_DISCONNECTED = 'gamepadDisconnected'; + +const MENU_HANDLER_ATTACHED = 'menuHandlerAttached'; +const MENU_PRESSED = 'menuPressed'; +const MENU_RELEASED = 'menuReleased'; + +const KEY_PRESSED = 'keyPressed'; +const KEY_RELEASED = 'keyReleased'; +const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode'; +const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed'; +const AXIS_CHANGED = 'axisChanged'; +const CONTROLLER_UPDATED = 'controllerUpdated'; + +const DPAD_TOGGLE = 'dpadToggle'; +const STATS_TOGGLE = 'statsToggle'; +const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; + +const SETTINGS_CHANGED = 'settingsChanged'; +const SETTINGS_CLOSED = 'settingsClosed'; + +const RECORDING_TOGGLED = 'recordingToggle' +const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' diff --git a/web/js/gameList.js b/web/js/gameList.js index cae64220..f309295d 100644 --- a/web/js/gameList.js +++ b/web/js/gameList.js @@ -1,255 +1,100 @@ -import {MENU_PRESSED, MENU_RELEASED, sub} from 'event'; -import {gui} from 'gui'; - -const TOP_POSITION = 102 -const SELECT_THRESHOLD_MS = 160 - -const games = (() => { - let list = [], index = 0 - return { - get index() { - return index - }, - get list() { - return list - }, - get selected() { - return list[index].title // selected by the game title, oof - }, - set index(i) { - index = i < -1 ? i = 0 : - i > list.length ? i = list.length - 1 : - (i % list.length + list.length) % list.length - }, - set: (data = []) => list = data.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1), - empty: () => list.length === 0 - } -})() - -const scroll = ((DEFAULT_INTERVAL) => { - const state = { - IDLE: 0, UP: -1, DOWN: 1, DRAG: 3 - } - let last = state.IDLE - let _si - let onShift, onStop - - const shift = (delta) => { - if (scroll.scrolling) return - onShift(delta) - // velocity? - // keep rolling the game list if the button is pressed - _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL) - } - - const stop = () => { - onStop() - _si && (clearInterval(_si) && (_si = null)) - } - - const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null} - - return { - scroll: (move = state.IDLE) => { - handle[move] && handle[move](move) - last = move - }, - get scrolling() { - return last !== state.IDLE - }, - set onShift(fn) { - onShift = fn - }, - set onStop(fn) { - onStop = fn - }, - state, - last: () => last - } -})(SELECT_THRESHOLD_MS) - -const ui = (() => { - const rootEl = document.getElementById('menu-container') - const choiceMarkerEl = document.getElementById('menu-item-choice') - - const TRANSITION_DEFAULT = `top ${SELECT_THRESHOLD_MS}ms` - let listTopPos = TOP_POSITION - - rootEl.style.transition = TRANSITION_DEFAULT - - let onTransitionEnd = () => ({}) - - let items = [] - - const marque = (() => { - const speed = 1 - const sep = ' '.repeat(10) - - let el = null - let raf = 0 - let txt = null - let w = 0 - - const move = () => { - const shift = parseFloat(getComputedStyle(el).left) - speed - el.style.left = w + shift < 1 ? `0px` : `${shift}px` - raf = requestAnimationFrame(move) - } - - return { - reset() { - cancelAnimationFrame(raf) - el && (el.style.left = `0px`) - }, - enable(cap) { - txt && (el.textContent = txt) // restore the text - el = cap - txt = el.textContent - el.textContent += sep - w = el.scrollWidth // keep the text width - el.textContent += txt - cancelAnimationFrame(raf) - raf = requestAnimationFrame(move) - } - } - })() - - const item = (parent) => { - const title = parent.firstChild.firstChild - const desc = parent.children[1] - - const _desc = { - hide: () => gui.hide(desc), - show: async () => { - gui.show(desc) - await gui.anim.fadeIn(desc, .054321) - }, - } - - const isOverflown = () => title.scrollWidth > title.clientWidth - - const _title = { - pick: () => { - title.classList.add('pick') - isOverflown() && marque.enable(title) - }, - reset: () => { - title.classList.remove('pick') - isOverflown() && marque.reset() - } - } - - const clear = () => _title.reset() - - return { - get description() { - return _desc - }, - get title() { - return _title - }, - clear, - } - } - - const render = () => { - rootEl.innerHTML = games.list.map(game => - ``) - .join('') - items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x)) - } - - return { - get items() { - return items - }, - get selected() { - return items[games.index] - }, - get roundIndex() { - const closest = Math.round((listTopPos - TOP_POSITION) / -36) - return closest < 0 ? 0 : - closest > games.list.length - 1 ? games.list.length - 1 : - closest // don't wrap the list on drag - }, - set onTransitionEnd(x) { - onTransitionEnd = x - }, - set pos(idx) { - listTopPos = TOP_POSITION - idx * 36 - rootEl.style.top = `${listTopPos}px` - }, - drag: { - startPos: (pos) => { - rootEl.style.top = `${listTopPos - pos}px` - rootEl.style.transition = '' - }, - stopPos: (pos) => { - listTopPos -= pos - rootEl.style.transition = TRANSITION_DEFAULT - }, - }, - render, - marker: { - show: () => gui.show(choiceMarkerEl) - }, - NO_TRANSITION: onTransitionEnd(), - } -})(TOP_POSITION, SELECT_THRESHOLD_MS, games) - -const show = () => { - ui.render() - ui.marker.show() // we show square pseudo-selection marker only after rendering - scroll.scroll(scroll.state.DOWN) // interactively moves games select down - scroll.scroll(scroll.state.IDLE) -} - -const select = (index) => { - ui.items.forEach(i => i.clear()) // !to rewrite - games.index = index - ui.pos = games.index -} - -scroll.onShift = (delta) => select(games.index + delta) - -scroll.onStop = () => { - const item = ui.selected - item && item.title.pick() -} - -sub(MENU_PRESSED, (position) => { - if (games.empty()) return - ui.onTransitionEnd = ui.NO_TRANSITION - scroll.scroll(scroll.state.DRAG) - ui.selected && ui.selected.clear() - ui.drag.startPos(position) -}) - -sub(MENU_RELEASED, (position) => { - if (games.empty()) return - ui.drag.stopPos(position) - select(ui.roundIndex) - scroll.scroll(scroll.state.IDLE) -}) - /** * Game list module. + * @version 1 */ -export const gameList = { - disable: () => ui.selected?.clear(), - scroll: (x) => { - if (games.empty()) return - scroll.scroll(x) - }, - get selected() { - return games.selected - }, - set: games.set, - show: () => { - if (games.empty()) return - show() - }, -} +const gameList = (() => { + // state + let games = []; + let gameIndex = 1; + let gamePickTimer = null; + + // UI + const listBox = document.getElementById('menu-container'); + const menuItemChoice = document.getElementById('menu-item-choice'); + + const MENU_TOP_POSITION = 102; + let menuTop = MENU_TOP_POSITION; + + const setGames = (gameList) => { + games = gameList.sort((a, b) => a > b ? 1 : -1); + }; + + const render = () => { + log.debug('[games] load game menu'); + + listBox.innerHTML = games + .map(game => ``) + .join(''); + }; + + const show = () => { + render(); + menuItemChoice.style.display = "block"; + pickGame(); + }; + + const pickGame = (index) => { + let idx = undefined !== index ? index : gameIndex; + + // check boundaries + // cycle + if (idx < 0) idx = games.length - 1; + if (idx >= games.length) idx = 0; + + // transition menu box + listBox.style['transition'] = 'top 0.2s'; + + menuTop = MENU_TOP_POSITION - idx * 36; + listBox.style['top'] = `${menuTop}px`; + + // overflow marquee + let pick = document.querySelectorAll('.menu-item .pick')[0]; + if (pick) { + pick.classList.remove('pick'); + } + document.querySelectorAll(`.menu-item span`)[idx].classList.add('pick'); + + gameIndex = idx; + }; + + const startGamePickerTimer = (upDirection) => { + if (gamePickTimer !== null) return; + const shift = upDirection ? -1 : 1; + pickGame(gameIndex + shift); + + // velocity? + // keep rolling the game list if the button is pressed + gamePickTimer = setInterval(() => { + pickGame(gameIndex + shift); + }, 200); + }; + + const stopGamePickerTimer = () => { + if (gamePickTimer === null) return; + clearInterval(gamePickTimer); + gamePickTimer = null; + }; + + const onMenuPressed = (newPosition) => { + listBox.style['transition'] = ''; + listBox.style['top'] = `${menuTop - newPosition}px`; + }; + + const onMenuReleased = (position) => { + menuTop -= position; + const index = Math.round((menuTop - MENU_TOP_POSITION) / -36); + pickGame(index); + }; + + event.sub(MENU_PRESSED, onMenuPressed); + event.sub(MENU_RELEASED, onMenuReleased); + + return { + startGamePickerTimer: startGamePickerTimer, + stopGamePickerTimer: stopGamePickerTimer, + pickGame: pickGame, + show: show, + set: setGames, + getCurrentGame: () => games[gameIndex] + } +})(document, event, log); diff --git a/web/js/gui.js b/web/js/gui.js deleted file mode 100644 index b6eb9d94..00000000 --- a/web/js/gui.js +++ /dev/null @@ -1,277 +0,0 @@ -/** - * App UI elements module. - */ - -const _create = (name = 'div', modFn) => { - const el = document.createElement(name); - if (modFn) { - modFn(el); - } - return el; -} - -const _option = (text = '', selected = false, label) => { - const el = _create('option'); - if (label) { - el.textContent = label; - el.value = text; - } else { - el.textContent = text; - } - if (selected) el.selected = true; - - return el; -} - -const select = (key = '', callback = () => ({}), values = {values: [], labels: []}, current = '') => { - const el = _create(); - const select = _create('select'); - select.onchange = event => { - callback(key, event.target.value); - }; - el.append(select); - - select.append(_option(0, current === '', 'none')); - values.values.forEach((value, index) => { - select.append(_option(value, current == value, values.labels?.[index])); - }); - - return el; -} - -const checkbox = (id, cb = () => ({}), checked = false, label = '', cc = '') => { - const el = _create(); - cc !== '' && el.classList.add(cc); - - let parent = el; - - if (label) { - const _label = _create('label', (el) => { - el.setAttribute('htmlFor', id); - }) - _label.innerText = label; - el.append(_label) - parent = _label; - } - - const input = _create('input', (el) => { - el.setAttribute('id', id); - el.setAttribute('name', id); - el.setAttribute('type', 'checkbox'); - el.onclick = ((e) => { - checked = e.target.checked - cb(id, checked) - }) - checked && el.setAttribute('checked', ''); - }); - parent.prepend(input); - - return el; -} - -const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => { - const state = { - shown: false, - loading: false, - title: title, - } - - const tHandlers = []; - onToggle && tHandlers.push(onToggle); - - const _root = root || _create('div'); - _root.classList.add('panel'); - gui.hide(_root); - - const header = _create('div', (el) => el.classList.add('panel__header')); - const _content = _create('div', (el) => { - if (cc) { - el.classList.add(cc); - } - el.classList.add('panel__content') - }); - - const _title = _create('span', (el) => { - el.classList.add('panel__header__title'); - el.innerText = title; - }); - header.append(_title); - - header.append(_create('div', (el) => { - el.classList.add('panel__header__controls'); - - buttons.forEach((b => el.append(_create('span', (el) => { - if (Object.keys(b).length === 0) { - el.classList.add('panel__button_separator'); - return - } - el.classList.add('panel__button'); - if (b.cl) b.cl.forEach(class_ => el.classList.add(class_)); - if (b.title) el.title = b.title; - el.innerText = b.caption; - el.addEventListener('click', b.handler) - })))) - - el.append(_create('span', (el) => { - el.classList.add('panel__button'); - el.innerText = 'X'; - el.title = 'Close'; - el.addEventListener('click', () => toggle(false)) - })) - })) - - root.append(header, _content); - if (content) { - _content.append(content); - } - - const setContent = (content) => _content.replaceChildren(content) - - const setLoad = (load = true) => { - state.loading = load; - _title.innerText = state.loading ? `${state.title}...` : state.title; - } - - const toggle = (() => { - let br = window.getComputedStyle(_root.parentElement).borderRadius; - return (force) => { - state.shown = force !== undefined ? force : !state.shown; - // hack for not transparent jpeg corners :_; - _root.parentElement.style.borderRadius = state.shown ? '0px' : br; - tHandlers.forEach(h => h?.(state.shown, _root)); - state.shown ? gui.show(_root) : gui.hide(_root) - } - })() - - return { - contentEl: _content, - isHidden: () => !state.shown, - onToggle: (fn) => tHandlers.push(fn), - setContent, - setLoad, - toggle, - } -} - -const _bind = (cb = () => ({}), name = '', oldValue) => { - const el = _create('button'); - el.onclick = () => cb(name, oldValue); - el.textContent = name; - return el; -} - -const binding = (key = '', value = '', cb = () => ({})) => { - const el = _create(); - el.setAttribute('class', 'binding-element'); - - const k = _bind(cb, key, value); - - el.append(k); - - const v = _create(); - v.textContent = value; - el.append(v); - - return el; -} - -const show = (...els) => { - els.forEach(el => el.classList.remove('hidden')) -} - -const inputN = (key = '', cb = () => ({}), current = 0) => { - const el = _create(); - const input = _create('input'); - input.type = 'number'; - input.value = current; - input.onchange = event => cb(key, event.target.value); - el.append(input); - return el; -} - -const hide = (el) => { - el.classList.add('hidden'); -} - -const toggle = (el, what) => { - if (what === undefined) { - el.classList.toggle('hidden') - return - } - what ? show(el) : hide(el) -} - -const multiToggle = (elements = [], options = {list: []}) => { - if (!options.list.length || !elements.length) return - - let i = 0 - - const setText = () => elements.forEach(el => el.innerText = options.list[i].caption) - - const handleClick = () => { - options.list[i].cb() - i = (i + 1) % options.list.length - setText() - } - - setText() - elements.forEach(el => el.addEventListener('click', handleClick)) -} - -const fadeIn = async (el, speed = .1) => { - el.style.opacity = '0'; - el.style.display = 'block'; - return new Promise((done) => (function fade() { - let val = parseFloat(el.style.opacity); - const proceed = ((val += speed) <= 1); - if (proceed) { - el.style.opacity = '' + val; - requestAnimationFrame(fade); - } else { - done(); - } - })() - ); -} - -const fadeOut = async (el, speed = .1) => { - el.style.opacity = '1'; - return new Promise((done) => (function fade() { - if ((el.style.opacity -= speed) < 0) { - el.style.display = "none"; - done(); - } else { - requestAnimationFrame(fade); - } - })() - ) -} - -const fragment = () => document.createDocumentFragment(); - -const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); - -const fadeInOut = async (el, wait = 1000, speed = .1) => { - await fadeIn(el, speed) - await sleep(wait); - await fadeOut(el, speed) -} - -export const gui = { - anim: { - fadeIn, - fadeOut, - fadeInOut, - }, - binding, - checkbox, - create: _create, - fragment, - hide, - inputN, - multiToggle, - panel, - select, - show, - toggle, -} diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js new file mode 100644 index 00000000..361fe72b --- /dev/null +++ b/web/js/gui/gui.js @@ -0,0 +1,213 @@ +/** + * App UI elements module. + * + * @version 1 + */ +const gui = (() => { + + const _create = (name = 'div', modFn) => { + const el = document.createElement(name); + if (modFn) { + modFn(el); + } + return el; + } + + const _option = (text = '', selected = false, label) => { + const el = _create('option'); + if (label) { + el.textContent = label; + el.value = text; + } else { + el.textContent = text; + } + if (selected) el.selected = true; + + return el; + } + + const select = (key = '', callback = function () { + }, values = {values: [], labels: []}, current = '') => { + const el = _create(); + const select = _create('select'); + select.onchange = event => { + callback(key, event.target.value); + }; + el.append(select); + + select.append(_option('none', current === '')); + values.values.forEach((value, index) => { + select.append(_option(value, current === value, values.labels?.[index])); + }); + + return el; + } + + const panel = (root, title = '', cc = '', content, buttons = []) => { + const state = { + shown: false, + loading: false, + title: title, + } + + const _root = root || _create('div'); + _root.classList.add('panel'); + const header = _create('div', (el) => el.classList.add('panel__header')); + const _content = _create('div', (el) => { + if (cc) { + el.classList.add(cc); + } + el.classList.add('panel__content') + }); + + const _title = _create('span', (el) => { + el.classList.add('panel__header__title'); + el.innerText = title; + }); + header.append(_title); + + header.append(_create('div', (el) => { + el.classList.add('panel__header__controls'); + + buttons.forEach((b => el.append(_create('span', (el) => { + el.classList.add('panel__button'); + if (b.cl) b.cl.forEach(class_ => el.classList.add(class_)); + if (b.title) el.title = b.title; + el.innerText = b.caption; + el.addEventListener('click', b.handler) + })))) + + el.append(_create('span', (el) => { + el.classList.add('panel__button'); + el.innerText = 'X'; + el.title = 'Close'; + el.addEventListener('click', () => toggle(false)) + })) + })) + + root.append(header, _content); + if (content) { + _content.append(content); + } + + const setContent = (content) => _content.replaceChildren(content) + + const setLoad = (load = true) => { + state.loading = load; + _title.innerText = state.loading ? `${state.title}...` : state.title; + } + + function toggle(show) { + state.shown = show; + if (state.shown) { + gui.show(_root); + } else { + gui.hide(_root); + } + } + + return { + isHidden: () => !state.shown, + setContent, + setLoad, + toggle, + } + } + + const _bind = (callback = function () { + }, name = '', oldValue) => { + const el = _create('button'); + el.onclick = () => callback(name, oldValue); + + el.textContent = name; + + return el; + } + + const binding = (key = '', value = '', callback = function () { + }) => { + const el = _create(); + el.setAttribute('class', 'binding-element'); + + const k = _bind(callback, key, value); + + el.append(k); + + const v = _create(); + v.textContent = value; + el.append(v); + + return el; + } + + const show = (el) => { + el.classList.remove('hidden'); + } + + const hide = (el) => { + el.classList.add('hidden'); + } + + const toggle = (el, what) => { + if (what) { + show(el) + } else { + hide(el) + } + } + + const fadeIn = async (el, speed = .1) => { + el.style.opacity = '0'; + el.style.display = 'block'; + return new Promise((done) => (function fade() { + let val = parseFloat(el.style.opacity); + const proceed = ((val += speed) <= 1); + if (proceed) { + el.style.opacity = '' + val; + requestAnimationFrame(fade); + } else { + done(); + } + })() + ); + } + + const fadeOut = async (el, speed = .1) => { + el.style.opacity = '1'; + return new Promise((done) => (function fade() { + if ((el.style.opacity -= speed) < 0) { + el.style.display = "none"; + done(); + } else { + requestAnimationFrame(fade); + } + })() + ) + } + + const fragment = () => document.createDocumentFragment(); + + const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); + + const fadeInOut = async (el, wait = 1000, speed = .1) => { + await fadeIn(el, speed) + await sleep(wait); + await fadeOut(el, speed) + } + + return { + anim: { + fadeIn, + fadeOut, + fadeInOut, + }, + binding, + create: _create, + fragment, + hide, + panel, + select, + show, + toggle, + } +})(document); diff --git a/web/js/gui/message.js b/web/js/gui/message.js new file mode 100644 index 00000000..d4bf668c --- /dev/null +++ b/web/js/gui/message.js @@ -0,0 +1,48 @@ +/** + * App UI message module. + * + * @version 1 + */ +const message = (() => { + const popupBox = document.getElementById('noti-box'); + + // fifo queue + let queue = []; + const queueMaxSize = 5; + + let isScreenFree = true; + + const _popup = () => { + // recursion edge case: + // no messages in the queue or one on the screen + if (!(queue.length > 0 && isScreenFree)) { + return; + } + + isScreenFree = false; + popupBox.innerText = queue.shift(); + gui.anim.fadeInOut(popupBox, 1000, .05).finally(() => { + isScreenFree = true; + _popup(); + }) + } + + const _storeMessage = (text) => { + if (queue.length <= queueMaxSize) { + queue.push(text); + } + } + + const _proceed = (text) => { + _storeMessage(text); + _popup(); + } + + const show = (text) => { + _proceed(text) + } + + return Object.freeze({ + show: show + }) +})(document, gui, utils); diff --git a/web/js/init.js b/web/js/init.js new file mode 100644 index 00000000..d421bddb --- /dev/null +++ b/web/js/init.js @@ -0,0 +1,26 @@ +settings.init(); + +(() => { + let lvl = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); + // migrate old log level options + // !to remove at some point + if (isNaN(lvl)) { + console.warn( + `The log value [${lvl}] is not supported! ` + + `The default value [debug] will be used instead.`); + settings.set(opts.LOG_LEVEL, `${log.DEFAULT}`) + lvl = log.DEFAULT + } + log.level = lvl +})(); + +keyboard.init(); +joystick.init(); +touch.init(); +stream.init(); + +[roomId, zone] = room.loadMaybe(); +// find worker id if present +const wid = new URLSearchParams(document.location.search).get('wid'); +// if from URL -> start game immediately! +socket.init(roomId, wid, zone); diff --git a/web/js/input/input.js b/web/js/input/input.js index 2c3fffa4..0ccf5b48 100644 --- a/web/js/input/input.js +++ b/web/js/input/input.js @@ -1,56 +1,114 @@ -import { - REFRESH_INPUT, - KB_MOUSE_FLAG, - pub, - sub -} from 'event'; +const input = (() => { + const pollingIntervalMs = 4; + let controllerChangedIndex = -1; -export {KEY, JOYPAD_KEYS} from './keys.js?v=3'; + // Libretro config + let controllerState = { + [KEY.B]: false, + [KEY.Y]: false, + [KEY.SELECT]: false, + [KEY.START]: false, + [KEY.UP]: false, + [KEY.DOWN]: false, + [KEY.LEFT]: false, + [KEY.RIGHT]: false, + [KEY.A]: false, + [KEY.X]: false, + // extra + [KEY.L]: false, + [KEY.R]: false, + [KEY.L2]: false, + [KEY.R2]: false, + [KEY.L3]: false, + [KEY.R3]: false + }; -import {joystick} from './joystick.js?v=3'; -import {keyboard} from './keyboard.js?v=3' -import {pointer} from './pointer.js?v=3'; -import {retropad} from './retropad.js?v=3'; -import {touch} from './touch.js?v=3'; - -export {joystick, keyboard, pointer, retropad, touch}; - -const input_state = { - joystick: true, - keyboard: false, - pointer: true, // aka mouse - retropad: true, - touch: true, - - kbm: false, -} - -const init = () => { - keyboard.init() - joystick.init() - touch.init() -} - -sub(KB_MOUSE_FLAG, () => { - input_state.kbm = true - pub(REFRESH_INPUT) -}) - -export const input = { - state: input_state, - init, - retropad: { - ...retropad, - toggle(on = true) { - if (on === input_state.retropad) return - input_state.retropad = on - retropad.toggle(on) + const poll = (intervalMs, callback) => { + let _ticker = 0; + return { + enable: () => { + if (_ticker > 0) return; + log.debug(`[input] poll set to ${intervalMs}ms`); + _ticker = setInterval(callback, intervalMs) + }, + disable: () => { + if (_ticker < 1) return; + log.debug('[input] poll has been disabled'); + clearInterval(_ticker); + _ticker = 0; + } } - }, - set kbm(v) { - input_state.kbm = v - }, - get kbm() { - return input_state.kbm + }; + + const controllerEncoded = [0, 0, 0, 0, 0]; + const keys = Object.keys(controllerState); + + const compare = (a, b) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + }; + + + // let lastState = controllerEncoded; + + const sendControllerState = () => { + if (controllerChangedIndex >= 0) { + const state = _getState(); + + // log.debug(state) + + // if (compare(lastState, state)) { + // log.debug('!skip') + // } else { + event.pub(CONTROLLER_UPDATED, _encodeState(state)); + // } + // lastState = state; + controllerChangedIndex = -1; + } + }; + + const setKeyState = (name, state) => { + if (controllerState[name] !== undefined) { + controllerState[name] = state; + controllerChangedIndex = Math.max(controllerChangedIndex, 0); + } + }; + + const setAxisChanged = (index, value) => { + if (controllerEncoded[index + 1] !== undefined) { + controllerEncoded[index + 1] = Math.floor(32767 * value); + controllerChangedIndex = Math.max(controllerChangedIndex, index + 1); + } + }; + + /** + * Converts key state into a bitmap and prepends it to the axes state. + * + * @returns {Uint16Array} The controller state. + * First uint16 is the controller state bitmap. + * The other uint16 are the axes values. + * Truncated to the last value changed. + * + * @private + */ + const _encodeState = (state) => new Uint16Array(state) + + const _getState = () => { + controllerEncoded[0] = 0; + for (let i = 0, len = keys.length; i < len; i++) { + controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0; + } + return controllerEncoded.slice(0, controllerChangedIndex + 1); } -} + + return { + poll: poll(pollingIntervalMs, sendControllerState), + setKeyState, + setAxisChanged, + } +})(event, KEY, log); diff --git a/web/js/input/joystick.js b/web/js/input/joystick.js index 51e22e2a..c5ee2fdb 100644 --- a/web/js/input/joystick.js +++ b/web/js/input/joystick.js @@ -1,260 +1,3 @@ -import { - pub, - sub, - AXIS_CHANGED, - DPAD_TOGGLE, - GAMEPAD_CONNECTED, - GAMEPAD_DISCONNECTED, - KEY_PRESSED, - KEY_RELEASED -} from 'event'; -import {env, browser as br, platform} from 'env'; -import {KEY} from 'input'; -import {log} from 'log'; - -const deadZone = 0.1; -let joystickMap; -let joystickState = {}; -let joystickAxes = []; -let joystickIdx; -let joystickTimer = null; -let dpadMode = true; - -function onDpadToggle(checked) { - if (dpadMode === checked) { - return //error? - } - if (dpadMode) { - dpadMode = false; - // reset dpad keys pressed before moving to analog stick mode - checkJoystickAxisState(KEY.LEFT, false); - checkJoystickAxisState(KEY.RIGHT, false); - checkJoystickAxisState(KEY.UP, false); - checkJoystickAxisState(KEY.DOWN, false); - } else { - dpadMode = true; - // reset analog stick axes before moving to dpad mode - joystickAxes.forEach(function (value, index) { - checkJoystickAxis(index, 0); - }); - } -} - -// check state for each axis -> dpad -function checkJoystickAxisState(name, state) { - if (joystickState[name] !== state) { - joystickState[name] = state; - pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name}); - } -} - -function checkJoystickAxis(axis, value) { - if (-deadZone < value && value < deadZone) value = 0; - if (joystickAxes[axis] !== value) { - joystickAxes[axis] = value; - pub(AXIS_CHANGED, {id: axis, value: value}); - } -} - -// loop timer for checking joystick state -function checkJoystickState() { - let gamepad = navigator.getGamepads()[joystickIdx]; - if (gamepad) { - if (dpadMode) { - // axis -> dpad - let corX = gamepad.axes[0]; // -1 -> 1, left -> right - let corY = gamepad.axes[1]; // -1 -> 1, up -> down - checkJoystickAxisState(KEY.LEFT, corX <= -0.5); - checkJoystickAxisState(KEY.RIGHT, corX >= 0.5); - checkJoystickAxisState(KEY.UP, corY <= -0.5); - checkJoystickAxisState(KEY.DOWN, corY >= 0.5); - } else { - gamepad.axes.forEach(function (value, index) { - checkJoystickAxis(index, value); - }); - } - - // normal button map - Object.keys(joystickMap).forEach(function (btnIdx) { - const buttonState = gamepad.buttons[btnIdx]; - - const isPressed = navigator.webkitGetGamepads ? buttonState === 1 : - buttonState.value > 0 || buttonState.pressed === true; - - if (joystickState[btnIdx] !== isPressed) { - joystickState[btnIdx] = isPressed; - pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]}); - } - }); - } -} - -// we only capture the last plugged joystick -const onGamepadConnected = (e) => { - let gamepad = e.gamepad; - log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`); - - joystickIdx = gamepad.index; - - // Ref: https://github.com/giongto35/cloud-game/issues/14 - // get mapping first (default KeyMap2) - const os = env.getOs; - const browser = env.getBrowser; - - if (os === platform.android) { - // default of android is KeyMap1 - joystickMap = { - 2: KEY.A, - 0: KEY.B, - 3: KEY.START, - 4: KEY.SELECT, - 10: KEY.LOAD, - 11: KEY.SAVE, - 8: KEY.HELP, - 9: KEY.QUIT, - 12: KEY.UP, - 13: KEY.DOWN, - 14: KEY.LEFT, - 15: KEY.RIGHT - }; - } else { - // default of other OS is KeyMap2 - joystickMap = { - 0: KEY.A, - 1: KEY.B, - 2: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 12: KEY.UP, - 13: KEY.DOWN, - 14: KEY.LEFT, - 15: KEY.RIGHT - }; - } - - if (os === platform.android && browser === br.firefox) { //KeyMap2 - joystickMap = { - 0: KEY.A, - 1: KEY.B, - 2: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 12: KEY.UP, - 13: KEY.DOWN, - 14: KEY.LEFT, - 15: KEY.RIGHT - }; - } - - if (os === platform.windows && browser === br.firefox) { //KeyMap3 - joystickMap = { - 1: KEY.A, - 2: KEY.B, - 0: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT - }; - } - - if (os === platform.macos && browser === br.safari) { //KeyMap4 - joystickMap = { - 1: KEY.A, - 2: KEY.B, - 0: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 14: KEY.UP, - 15: KEY.DOWN, - 16: KEY.LEFT, - 17: KEY.RIGHT - }; - } - - if (os === platform.macos && browser === br.firefox) { //KeyMap5 - joystickMap = { - 1: KEY.A, - 2: KEY.B, - 0: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 14: KEY.UP, - 15: KEY.DOWN, - 16: KEY.LEFT, - 17: KEY.RIGHT - }; - } - - // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272 - if (gamepad.id.includes('PLAYSTATION(R)3')) { - if (browser === br.chrome) { - joystickMap = { - 1: KEY.A, - 0: KEY.B, - 2: KEY.Y, - 3: KEY.X, - 4: KEY.L, - 5: KEY.R, - 8: KEY.SELECT, - 9: KEY.START, - 10: KEY.DTOGGLE, - 11: KEY.R3, - }; - } else { - joystickMap = { - 13: KEY.A, - 14: KEY.B, - 12: KEY.X, - 15: KEY.Y, - 3: KEY.START, - 0: KEY.SELECT, - 4: KEY.UP, - 6: KEY.DOWN, - 7: KEY.LEFT, - 5: KEY.RIGHT, - 10: KEY.L, - 11: KEY.R, - 8: KEY.L2, - 9: KEY.R2, - 1: KEY.DTOGGLE, - 2: KEY.R3, - }; - } - } - - // reset state - joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; - Object.keys(joystickMap).forEach(function (btnIdx) { - joystickState[btnIdx] = false; - }); - - joystickAxes = new Array(gamepad.axes.length).fill(0); - - // looper, too intense? - if (joystickTimer !== null) { - clearInterval(joystickTimer); - } - - joystickTimer = setInterval(checkJoystickState, 10); // milliseconds per hit - pub(GAMEPAD_CONNECTED); -}; - -sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - /** * Joystick controls. * @@ -273,18 +16,263 @@ sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); * * @version 1 */ -export const joystick = { - init: () => { - // we only capture the last plugged joystick - window.addEventListener('gamepadconnected', onGamepadConnected); +const joystick = (() => { + const deadZone = 0.1; + let joystickMap; + let joystickState = {}; + let joystickAxes = []; + let joystickIdx; + let joystickTimer = null; + let dpadMode = true; - // disconnected event is triggered - window.addEventListener('gamepaddisconnected', (event) => { - clearInterval(joystickTimer); - log.info(`Gamepad disconnected at index ${event.gamepad.index}`); - pub(GAMEPAD_DISCONNECTED); + function onDpadToggle(checked) { + if (dpadMode === checked) { + return //error? + } + if (dpadMode) { + dpadMode = false; + // reset dpad keys pressed before moving to analog stick mode + checkJoystickAxisState(KEY.LEFT, false); + checkJoystickAxisState(KEY.RIGHT, false); + checkJoystickAxisState(KEY.UP, false); + checkJoystickAxisState(KEY.DOWN, false); + } else { + dpadMode = true; + // reset analog stick axes before moving to dpad mode + joystickAxes.forEach(function (value, index) { + checkJoystickAxis(index, 0); + }); + } + } + + // check state for each axis -> dpad + function checkJoystickAxisState(name, state) { + if (joystickState[name] !== state) { + joystickState[name] = state; + event.pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name}); + } + } + + function checkJoystickAxis(axis, value) { + if (-deadZone < value && value < deadZone) value = 0; + if (joystickAxes[axis] !== value) { + joystickAxes[axis] = value; + event.pub(AXIS_CHANGED, {id: axis, value: value}); + } + } + + // loop timer for checking joystick state + function checkJoystickState() { + let gamepad = navigator.getGamepads()[joystickIdx]; + if (gamepad) { + if (dpadMode) { + // axis -> dpad + let corX = gamepad.axes[0]; // -1 -> 1, left -> right + let corY = gamepad.axes[1]; // -1 -> 1, up -> down + checkJoystickAxisState(KEY.LEFT, corX <= -0.5); + checkJoystickAxisState(KEY.RIGHT, corX >= 0.5); + checkJoystickAxisState(KEY.UP, corY <= -0.5); + checkJoystickAxisState(KEY.DOWN, corY >= 0.5); + } else { + gamepad.axes.forEach(function (value, index) { + checkJoystickAxis(index, value); + }); + } + + // normal button map + Object.keys(joystickMap).forEach(function (btnIdx) { + const buttonState = gamepad.buttons[btnIdx]; + + const isPressed = navigator.webkitGetGamepads ? buttonState === 1 : + buttonState.value > 0 || buttonState.pressed === true; + + if (joystickState[btnIdx] !== isPressed) { + joystickState[btnIdx] = isPressed; + event.pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]}); + } + }); + } + } + + // we only capture the last plugged joystick + const onGamepadConnected = (e) => { + let gamepad = e.gamepad; + log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`); + + joystickIdx = gamepad.index; + + // Ref: https://github.com/giongto35/cloud-game/issues/14 + // get mapping first (default KeyMap2) + let os = env.getOs(); + let browser = env.getBrowser(); + + if (os === 'android') { + // default of android is KeyMap1 + joystickMap = { + 2: KEY.A, + 0: KEY.B, + 3: KEY.START, + 4: KEY.SELECT, + 10: KEY.LOAD, + 11: KEY.SAVE, + 8: KEY.HELP, + 9: KEY.QUIT, + 12: KEY.UP, + 13: KEY.DOWN, + 14: KEY.LEFT, + 15: KEY.RIGHT + }; + } else { + // default of other OS is KeyMap2 + joystickMap = { + 0: KEY.A, + 1: KEY.B, + 2: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 12: KEY.UP, + 13: KEY.DOWN, + 14: KEY.LEFT, + 15: KEY.RIGHT + }; + } + + if (os === 'android' && (browser === 'firefox' || browser === 'uc')) { //KeyMap2 + joystickMap = { + 0: KEY.A, + 1: KEY.B, + 2: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 12: KEY.UP, + 13: KEY.DOWN, + 14: KEY.LEFT, + 15: KEY.RIGHT + }; + } + + if (os === 'win' && browser === 'firefox') { //KeyMap3 + joystickMap = { + 1: KEY.A, + 2: KEY.B, + 0: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT + }; + } + + if (os === 'mac' && browser === 'safari') { //KeyMap4 + joystickMap = { + 1: KEY.A, + 2: KEY.B, + 0: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 14: KEY.UP, + 15: KEY.DOWN, + 16: KEY.LEFT, + 17: KEY.RIGHT + }; + } + + if (os === 'mac' && browser === 'firefox') { //KeyMap5 + joystickMap = { + 1: KEY.A, + 2: KEY.B, + 0: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 14: KEY.UP, + 15: KEY.DOWN, + 16: KEY.LEFT, + 17: KEY.RIGHT + }; + } + + // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272 + if (gamepad.id.includes('PLAYSTATION(R)3')) { + if (browser === 'chrome') { + joystickMap = { + 1: KEY.A, + 0: KEY.B, + 2: KEY.Y, + 3: KEY.X, + 4: KEY.L, + 5: KEY.R, + 8: KEY.SELECT, + 9: KEY.START, + 10: KEY.DTOGGLE, + 11: KEY.R3, + }; + } else { + joystickMap = { + 13: KEY.A, + 14: KEY.B, + 12: KEY.X, + 15: KEY.Y, + 3: KEY.START, + 0: KEY.SELECT, + 4: KEY.UP, + 6: KEY.DOWN, + 7: KEY.LEFT, + 5: KEY.RIGHT, + 10: KEY.L, + 11: KEY.R, + 8: KEY.L2, + 9: KEY.R2, + 1: KEY.DTOGGLE, + 2: KEY.R3, + }; + } + } + + // reset state + joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; + Object.keys(joystickMap).forEach(function (btnIdx) { + joystickState[btnIdx] = false; }); - log.info('[input] joystick has been initialized'); + joystickAxes = new Array(gamepad.axes.length).fill(0); + + // looper, too intense? + if (joystickTimer !== null) { + clearInterval(joystickTimer); + } + + joystickTimer = setInterval(checkJoystickState, 10); // miliseconds per hit + event.pub(GAMEPAD_CONNECTED); + }; + + event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + + return { + init: () => { + // we only capture the last plugged joystick + window.addEventListener('gamepadconnected', onGamepadConnected); + + // disconnected event is triggered + window.addEventListener('gamepaddisconnected', (event) => { + clearInterval(joystickTimer); + log.info(`Gamepad disconnected at index ${event.gamepad.index}`); + event.pub(GAMEPAD_DISCONNECTED); + }); + + log.info('[input] joystick has been initialized'); + } } -} +})(event, env, KEY, navigator, window); diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index 4c26e9db..91b9f548 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -1,164 +1,128 @@ -import { - pub, - sub, - AXIS_CHANGED, - DPAD_TOGGLE, - KEY_PRESSED, - KEY_RELEASED, - KEYBOARD_KEY_PRESSED, - KEYBOARD_KEY_DOWN, - KEYBOARD_KEY_UP, - KEYBOARD_TOGGLE_FILTER_MODE, -} from 'event'; -import {KEY} from 'input'; -import {log} from 'log' -import {opts, settings} from 'settings'; - -// default keyboard bindings -const defaultMap = Object.freeze({ - ArrowLeft: KEY.LEFT, - ArrowUp: KEY.UP, - ArrowRight: KEY.RIGHT, - ArrowDown: KEY.DOWN, - KeyZ: KEY.A, - KeyX: KEY.B, - KeyC: KEY.X, - KeyV: KEY.Y, - KeyA: KEY.L, - KeyS: KEY.R, - Semicolon: KEY.L2, - Quote: KEY.R2, - Period: KEY.L3, - Slash: KEY.R3, - Enter: KEY.START, - ShiftLeft: KEY.SELECT, - // non-game - KeyQ: KEY.QUIT, - KeyW: KEY.JOIN, - KeyK: KEY.SAVE, - KeyL: KEY.LOAD, - Digit1: KEY.PAD1, - Digit2: KEY.PAD2, - Digit3: KEY.PAD3, - Digit4: KEY.PAD4, - KeyF: KEY.FULL, - KeyH: KEY.HELP, - Backslash: KEY.STATS, - Digit9: KEY.SETTINGS, - KeyT: KEY.DTOGGLE, - Digit0: KEY.RESET, -}); - -let keyMap = {}; -// special mode for changing button bindings in the options -let isKeysFilteredMode = true; -// if the browser supports Keyboard Lock API (Firefox does not) -let hasKeyboardLock = ('keyboard' in navigator) && ('lock' in navigator.keyboard) - -let locked = false - -const remap = (map = {}) => { - settings.set(opts.INPUT_KEYBOARD_MAP, map); - log.debug('Keyboard keys have been remapped') -} - -sub(KEYBOARD_TOGGLE_FILTER_MODE, data => { - isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode; - log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`); -}); - -let dpadMode = true; -let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; - -function onDpadToggle(checked) { - if (dpadMode === checked) { - return //error? - } - - dpadMode = !dpadMode - if (dpadMode) { - // reset dpad keys pressed before moving to analog stick mode - for (const key in dpadState) { - if (dpadState[key]) { - dpadState[key] = false; - pub(KEY_RELEASED, {key: key}); - } - } - } else { - // reset analog stick axes before moving to dpad mode - if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) { - pub(AXIS_CHANGED, {id: 0, value: 0}); - } - if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) { - pub(AXIS_CHANGED, {id: 1, value: 0}); - } - dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; - } -} - -const lock = async (lock) => { - locked = lock - if (hasKeyboardLock) { - lock ? await navigator.keyboard.lock() : navigator.keyboard.unlock() - } - // if the browser doesn't support keyboard lock, it will be emulated -} - -const onKey = (code, evt, state) => { - const key = keyMap[code] - - if (dpadState[key] !== undefined) { - dpadState[key] = state - if (!dpadMode) { - const LR = key === KEY.LEFT || key === KEY.RIGHT - pub(AXIS_CHANGED, { - id: !LR, - value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP] - }) - return - } - } - pub(evt, {key: key, code: code}) -} - -sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - /** * Keyboard controls. + * + * @version 1 */ -export const keyboard = { - init: () => { - keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap); - const body = document.body; +const keyboard = (() => { + // default keyboard bindings + const defaultMap = Object.freeze({ + ArrowLeft: KEY.LEFT, + ArrowUp: KEY.UP, + ArrowRight: KEY.RIGHT, + ArrowDown: KEY.DOWN, + KeyZ: KEY.A, + KeyX: KEY.B, + KeyC: KEY.X, + KeyV: KEY.Y, + KeyA: KEY.L, + KeyS: KEY.R, + Enter: KEY.START, + ShiftLeft: KEY.SELECT, + // non-game + KeyQ: KEY.QUIT, + KeyW: KEY.JOIN, + KeyK: KEY.SAVE, + KeyL: KEY.LOAD, + Digit1: KEY.PAD1, + Digit2: KEY.PAD2, + Digit3: KEY.PAD3, + Digit4: KEY.PAD4, + KeyF: KEY.FULL, + KeyH: KEY.HELP, + Backslash: KEY.STATS, + Digit9: KEY.SETTINGS, + KeyM: KEY.MULTITAP, + KeyT: KEY.DTOGGLE + }); - body.addEventListener('keyup', e => { - e.stopPropagation() - !hasKeyboardLock && locked && e.preventDefault() + let keyMap = {}; + let isKeysFilteredMode = true; - let lock = locked - // hack with Esc up when outside of lock - if (e.code === 'Escape') { - lock = true + const remap = (map = {}) => { + settings.set(opts.INPUT_KEYBOARD_MAP, map); + log.info('Keyboard keys have been remapped') + } + + event.sub(KEYBOARD_TOGGLE_FILTER_MODE, data => { + isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode; + log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`); + }); + + let dpadMode = true; + let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; + + function onDpadToggle(checked) { + if (dpadMode === checked) { + return //error? + } + + dpadMode = !dpadMode + if (dpadMode) { + // reset dpad keys pressed before moving to analog stick mode + for (const key in dpadState) { + if (dpadState[key]) { + dpadState[key] = false; + event.pub(KEY_RELEASED, {key: key}); + } } + } else { + // reset analog stick axes before moving to dpad mode + if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) { + event.pub(AXIS_CHANGED, {id: 0, value: 0}); + } + if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) { + event.pub(AXIS_CHANGED, {id: 1, value: 0}); + } + dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; + } + } - isKeysFilteredMode ? - (lock ? pub(KEYBOARD_KEY_UP, e) : onKey(e.code, KEY_RELEASED, false)) - : pub(KEYBOARD_KEY_PRESSED, {key: e.code}) - }, false) + const onKey = (code, evt, state) => { + const key = keyMap[code] + if (key === undefined) return - body.addEventListener('keydown', e => { - e.stopPropagation() - !hasKeyboardLock && locked && e.preventDefault() + if (dpadState[key] !== undefined) { + dpadState[key] = state + if (!dpadMode) { + const LR = key === KEY.LEFT || key === KEY.RIGHT + event.pub(AXIS_CHANGED, { + id: !LR, + value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP] + }) + return + } + } + event.pub(evt, {key: key}) + } - isKeysFilteredMode ? - (locked ? pub(KEYBOARD_KEY_DOWN, e) : onKey(e.code, KEY_PRESSED, true)) : - pub(KEYBOARD_KEY_PRESSED, {key: e.code}) - }) + event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - log.info('[input] keyboard has been initialized') - }, - settings: { - remap - }, - lock, -} + return { + init: () => { + keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap); + const body = document.body; + // !to use prevent default as everyone + body.addEventListener('keyup', e => { + e.stopPropagation(); + if (isKeysFilteredMode) { + onKey(e.code, KEY_RELEASED, false) + } else { + event.pub(KEYBOARD_KEY_PRESSED, {key: e.code}); + } + }, false); + + body.addEventListener('keydown', e => { + e.stopPropagation(); + if (isKeysFilteredMode) { + onKey(e.code, KEY_PRESSED, true) + } else { + event.pub(KEYBOARD_KEY_PRESSED, {key: e.code}); + } + }); + + log.info('[input] keyboard has been initialized'); + }, settings: { + remap + } + } +})(event, document, KEY, log, opts, settings); diff --git a/web/js/input/keys.js b/web/js/input/keys.js index 60e45e3e..1f798b95 100644 --- a/web/js/input/keys.js +++ b/web/js/input/keys.js @@ -1,41 +1,35 @@ -export const KEY = { - A: 'a', - B: 'b', - X: 'x', - Y: 'y', - L: 'l', - R: 'r', - START: 'start', - SELECT: 'select', - LOAD: 'load', - SAVE: 'save', - HELP: 'help', - JOIN: 'join', - FULL: 'full', - QUIT: 'quit', - UP: 'up', - DOWN: 'down', - LEFT: 'left', - RIGHT: 'right', - PAD1: 'pad1', - PAD2: 'pad2', - PAD3: 'pad3', - PAD4: 'pad4', - STATS: 'stats', - SETTINGS: 'settings', - DTOGGLE: 'dtoggle', - L2: 'l2', - R2: 'r2', - L3: 'l3', - R3: 'r3', - REC: 'rec', - RESET: 'reset', -}; - -// Keys match libretro RETRO_DEVICE_ID_JOYPAD_* -export const JOYPAD_KEYS = [ - KEY.B, KEY.Y, KEY.SELECT, KEY.START, - KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT, - KEY.A, KEY.X, KEY.L, KEY.R, - KEY.L2, KEY.R2, KEY.L3, KEY.R3 -] +const KEY = (() => { + return { + A: 'a', + B: 'b', + X: 'x', + Y: 'y', + L: 'l', + R: 'r', + START: 'start', + SELECT: 'select', + LOAD: 'load', + SAVE: 'save', + HELP: 'help', + JOIN: 'join', + FULL: 'full', + QUIT: 'quit', + UP: 'up', + DOWN: 'down', + LEFT: 'left', + RIGHT: 'right', + PAD1: 'pad1', + PAD2: 'pad2', + PAD3: 'pad3', + PAD4: 'pad4', + STATS: 'stats', + SETTINGS: 'settings', + DTOGGLE: 'dtoggle', + L2: 'l2', + R2: 'r2', + L3: 'l3', + R3: 'r3', + MULTITAP: 'multitap', + REC: 'rec', + } +})(); diff --git a/web/js/input/pointer.js b/web/js/input/pointer.js deleted file mode 100644 index e0fab075..00000000 --- a/web/js/input/pointer.js +++ /dev/null @@ -1,153 +0,0 @@ -// Pointer (aka mouse) stuff -import { - MOUSE_PRESSED, - MOUSE_MOVED, - pub -} from 'event'; -import {browser, env} from 'env'; - -const hasRawPointer = 'onpointerrawupdate' in window - -const p = {dx: 0, dy: 0} - -const move = (e, cb, single = false) => { - // !to fix ff https://github.com/w3c/pointerlock/issues/42 - if (single) { - p.dx = e.movementX - p.dy = e.movementY - cb(p) - } else { - const _events = e.getCoalescedEvents?.() - if (_events && (hasRawPointer || _events.length > 1)) { - for (let i = 0; i < _events.length; i++) { - p.dx = _events[i].movementX - p.dy = _events[i].movementY - cb(p) - } - } - } -} - -const _track = (el, cb, single) => { - const _move = (e) => { - move(e, cb, single) - } - el.addEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move) - return () => { - el.removeEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move) - } -} - -const dpiScaler = () => { - let ex = 0 - let ey = 0 - let scaled = {dx: 0, dy: 0} - return { - scale(x, y, src_w, src_h, dst_w, dst_h) { - scaled.dx = x / (src_w / dst_w) + ex - scaled.dy = y / (src_h / dst_h) + ey - - ex = scaled.dx % 1 - ey = scaled.dy % 1 - - scaled.dx -= ex - scaled.dy -= ey - - return scaled - } - } -} - -const dpi = dpiScaler() - -const handlePointerMove = (el, cb) => { - let w, h = 0 - let s = false - const dw = 640, dh = 480 - return (p) => { - ({w, h, s} = cb()) - pub(MOUSE_MOVED, s ? dpi.scale(p.dx, p.dy, w, h, dw, dh) : p) - } -} - -const trackPointer = (el, cb) => { - let mpu, mpd - let noTrack - - // disable coalesced mouse move events - const single = true - - // coalesced event are broken since FF 120 - const isFF = env.getBrowser === browser.firefox - - const pm = handlePointerMove(el, cb) - - return (enabled) => { - if (enabled) { - !noTrack && (noTrack = _track(el, pm, isFF || single)) - mpu = pointer.handle.up(el) - mpd = pointer.handle.down(el) - return - } - - mpu?.() - mpd?.() - noTrack?.() - noTrack = null - } -} - -const handleDown = ((b = {b: null, p: true}) => (e) => { - b.b = e.button - pub(MOUSE_PRESSED, b) -})() - -const handleUp = ((b = {b: null, p: false}) => (e) => { - b.b = e.button - pub(MOUSE_PRESSED, b) -})() - -const autoHide = (el, time = 3000) => { - let tm - let move - const cl = el.classList - - const hide = (force = false) => { - cl.add('no-pointer') - !force && el.addEventListener('pointermove', move) - } - - move = () => { - cl.remove('no-pointer') - clearTimeout(tm) - tm = setTimeout(hide, time) - } - - const show = () => { - clearTimeout(tm) - el.removeEventListener('pointermove', move) - cl.remove('no-pointer') - } - - return { - autoHide: (on) => on ? show() : hide() - } -} - -export const pointer = { - autoHide, - lock: async (el) => { - await el.requestPointerLock(/*{ unadjustedMovement: true}*/) - }, - track: trackPointer, - handle: { - down: (el) => { - el.onpointerdown = handleDown - return () => (el.onpointerdown = null) - }, - up: (el) => { - el.onpointerup = handleUp - return () => (el.onpointerup = null) - } - } -} diff --git a/web/js/input/retropad.js b/web/js/input/retropad.js deleted file mode 100644 index 0e7026ee..00000000 --- a/web/js/input/retropad.js +++ /dev/null @@ -1,64 +0,0 @@ -import {pub, CONTROLLER_UPDATED} from 'event'; -import {JOYPAD_KEYS} from 'input'; - -/* - * [BUTTONS, LEFT_X, LEFT_Y, RIGHT_X, RIGHT_Y] - * - * Buttons are packed into a 16-bit bitmask where each bit is one button. - * Axes are signed 16-bit values ranging from -32768 to 32767. - * The whole thing is 10 bytes when sent over the wire. - */ -const state = new Int16Array(5); -let buttons = 0; -let dirty = false; -let rafId = 0; - -/* - * Polls controller state using requestAnimationFrame which gives us - * ~60Hz update rate that syncs with the display. As a bonus, - * it automatically pauses when the tab goes to background. - * We only send data when something actually changed. - */ -const poll = () => { - if (dirty) { - state[0] = buttons; - pub(CONTROLLER_UPDATED, new Uint16Array(state.buffer)); - dirty = false; - } - rafId = requestAnimationFrame(poll); -}; - -/* - * Toggles a button on or off in the bitmask. The button's position - * in JOYPAD_KEYS determines which bit gets flipped. For example, - * if A is at index 8, pressing it sets bit 8. - */ -const setKeyState = (key, pressed) => { - const idx = JOYPAD_KEYS.indexOf(key); - if (idx < 0) return; - - const prev = buttons; - buttons = pressed ? buttons | (1 << idx) : buttons & ~(1 << idx); - dirty ||= buttons !== prev; -}; - -/* - * Updates an analog stick axis. Axes 0-1 are the left stick (X and Y), - * axes 2-3 are the right stick. Input should be a float from -1 to 1 - * which gets converted to a signed 16-bit integer for transmission. - */ -const setAxisChanged = (axis, value) => { - if (axis < 0 || axis > 3) return; - - const v = Math.trunc(Math.max(-1, Math.min(1, value)) * 32767); - dirty ||= state[++axis] !== v; - state[axis] = v; -}; - -// Starts or stops the polling loop -const toggle = (on) => { - if (on === !!rafId) return; - rafId = on ? requestAnimationFrame(poll) : (cancelAnimationFrame(rafId), 0); -}; - -export const retropad = {toggle, setKeyState, setAxisChanged}; \ No newline at end of file diff --git a/web/js/input/touch.js b/web/js/input/touch.js index f98359fc..97b45a9a 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -1,301 +1,3 @@ -import {env} from 'env'; -import { - pub, - sub, - AXIS_CHANGED, - KEY_PRESSED, - KEY_RELEASED, - GAME_PLAYER_IDX, - DPAD_TOGGLE, - MENU_HANDLER_ATTACHED, - MENU_PRESSED, - MENU_RELEASED -} from 'event'; -import {KEY} from 'input'; -import {log} from 'log'; - -const MAX_DIFF = 20; // radius of circle boundary - -// vpad state, use for mouse button down -let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false}; -let analogState = [0, 0]; - -let vpadTouchIdx = null; -let vpadTouchDrag = null; -let vpadHolder = document.getElementById('circle-pad-holder'); -let vpadCircle = document.getElementById('circle-pad'); - -const buttons = Array.from(document.getElementsByClassName('btn')); -const playerSlider = document.getElementById('playeridx'); -const dpad = Array.from(document.getElementsByClassName('dpad')); - -const dpadToggle = document.getElementById('dpad-toggle') -dpadToggle.addEventListener('change', (e) => { - pub(DPAD_TOGGLE, {checked: e.target.checked}); -}); - -const getKey = (el) => el.dataset.key - -let dpadMode = true; -const deadZone = 0.1; - -let enabled = false - -function onDpadToggle(checked) { - if (dpadMode === checked) { - return //error? - } - if (dpadMode) { - dpadMode = false; - vpadHolder.classList.add('dpad-empty'); - vpadCircle.classList.add('bong-full'); - // reset dpad keys pressed before moving to analog stick mode - resetVpadState() - } else { - dpadMode = true; - vpadHolder.classList.remove('dpad-empty'); - vpadCircle.classList.remove('bong-full'); - } -} - -function resetVpadState() { - if (dpadMode) { - // trigger up event? - checkVpadState(KEY.UP, false); - checkVpadState(KEY.DOWN, false); - checkVpadState(KEY.LEFT, false); - checkVpadState(KEY.RIGHT, false); - } else { - checkAnalogState(0, 0); - checkAnalogState(1, 0); - } - - vpadTouchDrag = null; - vpadTouchIdx = null; - - dpad.forEach(arrow => arrow.classList.remove('pressed')); -} - -function checkVpadState(axis, state) { - if (state !== vpadState[axis]) { - vpadState[axis] = state; - pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis}); - } -} - -function checkAnalogState(axis, value) { - if (-deadZone < value && value < deadZone) value = 0; - if (analogState[axis] !== value) { - analogState[axis] = value; - pub(AXIS_CHANGED, {id: axis, value: value}); - } -} - -function handleVpadJoystickDown(event) { - vpadCircle.style['transition'] = '0s'; - - if (event.changedTouches) { - resetVpadState(); - vpadTouchIdx = event.changedTouches[0].identifier; - event.clientX = event.changedTouches[0].clientX; - event.clientY = event.changedTouches[0].clientY; - } - - vpadTouchDrag = {x: event.clientX, y: event.clientY}; -} - -function handleVpadJoystickUp() { - if (vpadTouchDrag === null) return; - - vpadCircle.style['transition'] = '.2s'; - vpadCircle.style['transform'] = 'translate3d(0px, 0px, 0px)'; - - resetVpadState(); -} - -function handleVpadJoystickMove(event) { - if (vpadTouchDrag === null) return; - - if (event.changedTouches) { - // check if moving source is from other touch? - for (let i = 0; i < event.changedTouches.length; i++) { - if (event.changedTouches[i].identifier === vpadTouchIdx) { - event.clientX = event.changedTouches[i].clientX; - event.clientY = event.changedTouches[i].clientY; - } - } - if (event.clientX === undefined || event.clientY === undefined) - return; - } - - let xDiff = event.clientX - vpadTouchDrag.x; - let yDiff = event.clientY - vpadTouchDrag.y; - let angle = Math.atan2(yDiff, xDiff); - let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff)); - let xNew = distance * Math.cos(angle); - let yNew = distance * Math.sin(angle); - - if (env.display().isLayoutSwitched) { - let tmp = xNew; - xNew = yNew; - yNew = -tmp; - } - - vpadCircle.style['transform'] = `translate(${xNew}px, ${yNew}px)`; - - let xRatio = xNew / MAX_DIFF; - let yRatio = yNew / MAX_DIFF; - - if (dpadMode) { - checkVpadState(KEY.LEFT, xRatio <= -0.5); - checkVpadState(KEY.RIGHT, xRatio >= 0.5); - checkVpadState(KEY.UP, yRatio <= -0.5); - checkVpadState(KEY.DOWN, yRatio >= 0.5); - } else { - checkAnalogState(0, xRatio); - checkAnalogState(1, yRatio); - } -} - -// right side - control buttons -const _handleButton = (key, state) => checkVpadState(key, state) - -function handleButtonDown() { - _handleButton(getKey(this), true); -} - -function handleButtonUp() { - _handleButton(getKey(this), false); -} - -function handleButtonClick() { - _handleButton(getKey(this), true); - setTimeout(() => { - _handleButton(getKey(this), false); - }, 30); -} - -function handlePlayerSlider() { - pub(GAME_PLAYER_IDX, {index: this.value - 1}); -} - -// Touch menu -let menuTouchIdx = null; -let menuTouchDrag = null; -let menuTouchTime = null; - -function handleMenuDown(event) { - // Identify of touch point - if (event.changedTouches) { - menuTouchIdx = event.changedTouches[0].identifier; - event.clientX = event.changedTouches[0].clientX; - event.clientY = event.changedTouches[0].clientY; - } - - menuTouchDrag = {x: event.clientX, y: event.clientY,}; - menuTouchTime = Date.now(); -} - -function handleMenuMove(evt) { - if (menuTouchDrag === null) return; - - if (evt.changedTouches) { - // check if moving source is from other touch? - for (let i = 0; i < evt.changedTouches.length; i++) { - if (evt.changedTouches[i].identifier === menuTouchIdx) { - evt.clientX = evt.changedTouches[i].clientX; - evt.clientY = evt.changedTouches[i].clientY; - } - } - if (evt.clientX === undefined || evt.clientY === undefined) - return; - } - - const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY; - pub(MENU_PRESSED, pos); -} - -function handleMenuUp(evt) { - if (menuTouchDrag === null) return; - if (evt.changedTouches) { - if (evt.changedTouches[0].identifier !== menuTouchIdx) - return; - evt.clientX = evt.changedTouches[0].clientX; - evt.clientY = evt.changedTouches[0].clientY; - } - - let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY; - - let interval = Date.now() - menuTouchTime; // 100ms? - if (interval < 200) { - // calc velocity - newY = newY / interval * 250; - } - - // current item? - pub(MENU_RELEASED, newY); - menuTouchDrag = null; -} - -// Common events -function handleWindowMove(event) { - if (!enabled) return - - event.preventDefault(); - handleVpadJoystickMove(event); - handleMenuMove(event); - - // moving touch - if (event.changedTouches) { - for (let i = 0; i < event.changedTouches.length; i++) { - if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) { - // check class - - let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY); - - if (elem.classList.contains('btn')) { - elem.dispatchEvent(new Event('touchstart')); - } else { - elem.dispatchEvent(new Event('touchend')); - } - } - } - } -} - -function handleWindowUp(ev) { - handleVpadJoystickUp(ev); - handleMenuUp(ev); - buttons.forEach((btn) => { - btn.dispatchEvent(new Event('touchend')); - }); -} - -// touch/mouse events for control buttons. mouseup events is bound to window. -buttons.forEach((btn) => { - btn.addEventListener('mousedown', handleButtonDown); - btn.addEventListener('touchstart', handleButtonDown, {passive: true}); - btn.addEventListener('touchend', handleButtonUp); -}); - -// touch/mouse events for dpad. mouseup events is bound to window. -vpadHolder.addEventListener('mousedown', handleVpadJoystickDown); -vpadHolder.addEventListener('touchstart', handleVpadJoystickDown, {passive: true}); -vpadHolder.addEventListener('touchend', handleVpadJoystickUp); - -dpad.forEach((arrow) => { - arrow.addEventListener('click', handleButtonClick); -}); - -// touch/mouse events for player slider. -playerSlider.addEventListener('oninput', handlePlayerSlider); -playerSlider.addEventListener('onchange', handlePlayerSlider); -playerSlider.addEventListener('click', handlePlayerSlider); -playerSlider.addEventListener('touchend', handlePlayerSlider); -playerSlider.onkeydown = (e) => { - e.preventDefault(); -} - /** * Touch controls. * @@ -305,27 +7,300 @@ playerSlider.onkeydown = (e) => { * @link https://jsfiddle.net/aa0et7tr/5/ * @version 1 */ -export const touch = { - init: () => { - enabled = true - // Bind events for menu - // TODO change this flow - pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); - pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown}); - pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp}); +const touch = (() => { + const MAX_DIFF = 20; // radius of circle boundary - sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + // vpad state, use for mouse button down + let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false}; + let analogState = [0, 0]; - // add buttons into the state 🤦 - Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => { - vpadState[getKey(el)] = false; + let vpadTouchIdx = null; + let vpadTouchDrag = null; + let vpadHolder = document.getElementById('circle-pad-holder'); + let vpadCircle = document.getElementById('circle-pad'); + + const buttons = Array.from(document.getElementsByClassName('btn')); + const playerSlider = document.getElementById('playeridx'); + const dpad = Array.from(document.getElementsByClassName('dpad')); + + const dpadToggle = document.getElementById('dpad-toggle') + dpadToggle.addEventListener('change', (e) => { + event.pub(DPAD_TOGGLE, {checked: e.target.checked}); + }); + + let dpadMode = true; + const deadZone = 0.1; + + function onDpadToggle(checked) { + if (dpadMode === checked) { + return //error? + } + if (dpadMode) { + dpadMode = false; + vpadHolder.classList.add('dpad-empty'); + vpadCircle.classList.add('bong-full'); + // reset dpad keys pressed before moving to analog stick mode + resetVpadState() + } else { + dpadMode = true; + vpadHolder.classList.remove('dpad-empty'); + vpadCircle.classList.remove('bong-full'); + } + } + + function resetVpadState() { + if (dpadMode) { + // trigger up event? + checkVpadState(KEY.UP, false); + checkVpadState(KEY.DOWN, false); + checkVpadState(KEY.LEFT, false); + checkVpadState(KEY.RIGHT, false); + } else { + checkAnalogState(0, 0); + checkAnalogState(1, 0); + } + + vpadTouchDrag = null; + vpadTouchIdx = null; + + dpad.forEach(arrow => arrow.classList.remove('pressed')); + } + + function checkVpadState(axis, state) { + if (state !== vpadState[axis]) { + vpadState[axis] = state; + event.pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis}); + } + } + + function checkAnalogState(axis, value) { + if (-deadZone < value && value < deadZone) value = 0; + if (analogState[axis] !== value) { + analogState[axis] = value; + event.pub(AXIS_CHANGED, {id: axis, value: value}); + } + } + + function handleVpadJoystickDown(event) { + vpadCircle.style['transition'] = '0s'; + + if (event.changedTouches) { + resetVpadState(); + vpadTouchIdx = event.changedTouches[0].identifier; + event.clientX = event.changedTouches[0].clientX; + event.clientY = event.changedTouches[0].clientY; + } + + vpadTouchDrag = {x: event.clientX, y: event.clientY}; + } + + function handleVpadJoystickUp() { + if (vpadTouchDrag === null) return; + + vpadCircle.style['transition'] = '.2s'; + vpadCircle.style['transform'] = 'translate3d(0px, 0px, 0px)'; + + resetVpadState(); + } + + function handleVpadJoystickMove(event) { + if (vpadTouchDrag === null) return; + + if (event.changedTouches) { + // check if moving source is from other touch? + for (let i = 0; i < event.changedTouches.length; i++) { + if (event.changedTouches[i].identifier === vpadTouchIdx) { + event.clientX = event.changedTouches[i].clientX; + event.clientY = event.changedTouches[i].clientY; + } + } + if (event.clientX === undefined || event.clientY === undefined) + return; + } + + let xDiff = event.clientX - vpadTouchDrag.x; + let yDiff = event.clientY - vpadTouchDrag.y; + let angle = Math.atan2(yDiff, xDiff); + let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff)); + let xNew = distance * Math.cos(angle); + let yNew = distance * Math.sin(angle); + + if (env.display().isLayoutSwitched) { + let tmp = xNew; + xNew = yNew; + yNew = -tmp; + } + + vpadCircle.style['transform'] = `translate(${xNew}px, ${yNew}px)`; + + let xRatio = xNew / MAX_DIFF; + let yRatio = yNew / MAX_DIFF; + + if (dpadMode) { + checkVpadState(KEY.LEFT, xRatio <= -0.5); + checkVpadState(KEY.RIGHT, xRatio >= 0.5); + checkVpadState(KEY.UP, yRatio <= -0.5); + checkVpadState(KEY.DOWN, yRatio >= 0.5); + } else { + checkAnalogState(0, xRatio); + checkAnalogState(1, yRatio); + } + } + + // right side - control buttons + const _handleButton = (key, state) => checkVpadState(key, state) + + function handleButtonDown() { + _handleButton(this.getAttribute('value'), true); + } + + function handleButtonUp() { + _handleButton(this.getAttribute('value'), false); + } + + function handleButtonClick() { + _handleButton(this.getAttribute('value'), true); + setTimeout(() => { + _handleButton(this.getAttribute('value'), false); + }, 30); + } + + function handlePlayerSlider() { + event.pub(GAME_PLAYER_IDX, {index: this.value - 1}); + } + + // Touch menu + let menuTouchIdx = null; + let menuTouchDrag = null; + let menuTouchTime = null; + + function handleMenuDown(event) { + // Identify of touch point + if (event.changedTouches) { + menuTouchIdx = event.changedTouches[0].identifier; + event.clientX = event.changedTouches[0].clientX; + event.clientY = event.changedTouches[0].clientY; + } + + menuTouchDrag = {x: event.clientX, y: event.clientY,}; + menuTouchTime = Date.now(); + } + + function handleMenuMove(evt) { + if (menuTouchDrag === null) return; + + if (evt.changedTouches) { + // check if moving source is from other touch? + for (let i = 0; i < evt.changedTouches.length; i++) { + if (evt.changedTouches[i].identifier === menuTouchIdx) { + evt.clientX = evt.changedTouches[i].clientX; + evt.clientY = evt.changedTouches[i].clientY; + } + } + if (evt.clientX === undefined || evt.clientY === undefined) + return; + } + + const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY; + event.pub(MENU_PRESSED, pos); + } + + function handleMenuUp(evt) { + if (menuTouchDrag === null) return; + if (evt.changedTouches) { + if (evt.changedTouches[0].identifier !== menuTouchIdx) + return; + evt.clientX = evt.changedTouches[0].clientX; + evt.clientY = evt.changedTouches[0].clientY; + } + + let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY; + + let interval = Date.now() - menuTouchTime; // 100ms? + if (interval < 200) { + // calc velo + newY = newY / interval * 250; + } + + // current item? + event.pub(MENU_RELEASED, newY); + menuTouchDrag = null; + } + + // Common events + function handleWindowMove(event) { + event.preventDefault(); + handleVpadJoystickMove(event); + handleMenuMove(event); + + // moving touch + if (event.changedTouches) { + for (let i = 0; i < event.changedTouches.length; i++) { + if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) { + // check class + + let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY); + + if (elem.classList.contains('btn')) { + elem.dispatchEvent(new Event('touchstart')); + } else { + elem.dispatchEvent(new Event('touchend')); + } + } + } + } + } + + function handleWindowUp(ev) { + handleVpadJoystickUp(ev); + handleMenuUp(ev); + buttons.forEach((btn) => { + btn.dispatchEvent(new Event('touchend')); }); + } - window.addEventListener('pointermove', handleWindowMove); - window.addEventListener('touchmove', handleWindowMove, {passive: false}); - window.addEventListener('mouseup', handleWindowUp); + // touch/mouse events for control buttons. mouseup events is binded to window. + buttons.forEach((btn) => { + btn.addEventListener('mousedown', handleButtonDown); + btn.addEventListener('touchstart', handleButtonDown, {passive: true}); + btn.addEventListener('touchend', handleButtonUp); + }); - log.info('[input] touch input has been initialized'); - }, - toggle: (v) => v === undefined ? (enabled = !enabled) : (enabled = v) -} + // touch/mouse events for dpad. mouseup events is binded to window. + vpadHolder.addEventListener('mousedown', handleVpadJoystickDown); + vpadHolder.addEventListener('touchstart', handleVpadJoystickDown, {passive: true}); + vpadHolder.addEventListener('touchend', handleVpadJoystickUp); + + dpad.forEach((arrow) => { + arrow.addEventListener('click', handleButtonClick); + }); + + // touch/mouse events for player slider. + playerSlider.addEventListener('oninput', handlePlayerSlider); + playerSlider.addEventListener('onchange', handlePlayerSlider); + playerSlider.addEventListener('mouseup', handlePlayerSlider); + playerSlider.addEventListener('touchend', handlePlayerSlider); + + // Bind events for menu + // TODO change this flow + event.pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); + event.pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown}); + event.pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp}); + + event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + + return { + init: () => { + // add buttons into the state 🤦 + Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => { + vpadState[el.getAttribute('value')] = false; + }); + + window.addEventListener('mousemove', handleWindowMove); + window.addEventListener('touchmove', handleWindowMove, {passive: false}); + window.addEventListener('mouseup', handleWindowUp); + + log.info('[input] touch input has been initialized'); + } + } +})(document, event, KEY, window); diff --git a/web/js/log.js b/web/js/log.js index 2c316225..af138188 100644 --- a/web/js/log.js +++ b/web/js/log.js @@ -1,31 +1,35 @@ -const noop = () => ({}) - -const _log = { - ASSERT: 1, - ERROR: 2, - WARN: 3, - INFO: 4, - DEBUG: 5, - TRACE: 6, - - DEFAULT: 5, - - set level(level) { - this.assert = level >= this.ASSERT ? console.assert.bind(window.console) : noop; - this.error = level >= this.ERROR ? console.error.bind(window.console) : noop; - this.warn = level >= this.WARN ? console.warn.bind(window.console) : noop; - this.info = level >= this.INFO ? console.info.bind(window.console) : noop; - this.debug = level >= this.DEBUG ? console.debug.bind(window.console) : noop; - this.trace = level >= this.TRACE ? console.log.bind(window.console) : noop; - this._level = level; - }, - get level() { - return this._level; - } -} -_log.level = _log.DEFAULT; - /** * Logging module. + * + * @version 2 */ -export const log = _log +const log = (() => { + const noop = () => ({}) + + const _log = { + ASSERT: 1, + ERROR: 2, + WARN: 3, + INFO: 4, + DEBUG: 5, + TRACE: 6, + + DEFAULT: 5, + + set level(level) { + this.assert = level >= this.ASSERT ? console.assert.bind(window.console) : noop; + this.error = level >= this.ERROR ? console.error.bind(window.console) : noop; + this.warn = level >= this.WARN ? console.warn.bind(window.console) : noop; + this.info = level >= this.INFO ? console.info.bind(window.console) : noop; + this.debug = level >= this.DEBUG ? console.debug.bind(window.console) : noop; + this.trace = level >= this.TRACE ? console.log.bind(window.console) : noop; + this._level = level; + }, + get level() { + return this._level; + } + } + _log.level = _log.DEFAULT; + + return _log +})(console, window); diff --git a/web/js/menu.js b/web/js/menu.js deleted file mode 100644 index 721b87b1..00000000 --- a/web/js/menu.js +++ /dev/null @@ -1,17 +0,0 @@ -import {gui} from 'gui'; -import { - sub, - MENU_HANDLER_ATTACHED, -} from 'event'; - -const rootEl = document.getElementById('menu-screen'); - -// touch stuff -sub(MENU_HANDLER_ATTACHED, (data) => { - rootEl.addEventListener(data.event, data.handler, {passive: true}); -}); - -export const menu = { - toggle: (show) => show === undefined ? gui.toggle(rootEl) : gui.toggle(rootEl, show), - noFullscreen: true, -} diff --git a/web/js/message.js b/web/js/message.js deleted file mode 100644 index 41e8e66e..00000000 --- a/web/js/message.js +++ /dev/null @@ -1,44 +0,0 @@ -import {gui} from 'gui'; - -const popupBox = document.getElementById('noti-box'); - -// fifo queue -let queue = []; -const queueMaxSize = 5; - -let isScreenFree = true; - -const _popup = (time = 1000) => { - // recursion edge case: - // no messages in the queue or one on the screen - if (!(queue.length > 0 && isScreenFree)) { - return; - } - - isScreenFree = false; - popupBox.innerText = queue.shift(); - gui.anim.fadeInOut(popupBox, time, .05).finally(() => { - isScreenFree = true; - _popup(); - }) -} - -const _storeMessage = (text) => { - if (queue.length <= queueMaxSize) { - queue.push(text); - } -} - -const _proceed = (text, time) => { - _storeMessage(text); - _popup(time); -} - -const show = (text, time = 1000) => _proceed(text, time) - -/** - * App UI message module. - */ -export const message = { - show, -} diff --git a/web/js/network/ajax.js b/web/js/network/ajax.js index c2f09ccd..6f8c69c2 100644 --- a/web/js/network/ajax.js +++ b/web/js/network/ajax.js @@ -1,26 +1,29 @@ -const defaultTimeout = 10000; /** * AJAX request module. * @version 1 */ -export const ajax = { - fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => { - const controller = new AbortController(); - const signal = controller.signal; - const allOptions = Object.assign({}, options, signal); +const ajax = (() => { + const defaultTimeout = 10000; - // fetch(url, {...options, signal}) - fetch(url, allOptions) - .then(resolve, () => { + return { + fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => { + const controller = new AbortController(); + const signal = controller.signal; + const allOptions = Object.assign({}, options, signal); + + // fetch(url, {...options, signal}) + fetch(url, allOptions) + .then(resolve, () => { + controller.abort(); + return reject + }); + + // auto abort when a timeout reached + setTimeout(() => { controller.abort(); - return reject - }); - - // auto abort when a timeout reached - setTimeout(() => { - controller.abort(); - reject(); - }, timeout); - }), - defaultTimeoutMs: () => defaultTimeout -} + reject(); + }, timeout); + }), + defaultTimeoutMs: () => defaultTimeout + } +})(); \ No newline at end of file diff --git a/web/js/network/network.js b/web/js/network/network.js deleted file mode 100644 index ca21be6a..00000000 --- a/web/js/network/network.js +++ /dev/null @@ -1,3 +0,0 @@ -export {ajax} from './ajax.js?v=3'; -export {socket} from './socket.js?v=3'; -export {webrtc} from './webrtc.js?v=3'; diff --git a/web/js/network/socket.js b/web/js/network/socket.js index e153f441..06351246 100644 --- a/web/js/network/socket.js +++ b/web/js/network/socket.js @@ -1,51 +1,53 @@ -import { - pub, - MESSAGE -} from 'event'; -import {log} from 'log'; - -let conn; - -const buildUrl = (params = {}) => { - const url = new URL(window.location); - url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss'; - url.pathname = "/ws"; - Object.keys(params).forEach(k => { - if (!!params[k]) url.searchParams.set(k, params[k]) - }) - return url -} - -const init = (roomId, wid, zone) => { - let objParams = {room_id: roomId, zone: zone}; - if (wid) objParams.wid = wid; - const url = buildUrl(objParams) - log.info(`[ws] connecting to ${url}`); - conn = new WebSocket(url.toString()); - conn.onopen = () => { - log.info('[ws] <- open connection'); - }; - conn.onerror = () => log.error('[ws] some error!'); - conn.onclose = (event) => log.info(`[ws] closed (${event.code})`); - conn.onmessage = response => { - const data = JSON.parse(response.data); - log.debug('[ws] <- ', data); - pub(MESSAGE, data); - }; -}; - -const send = (data) => { - if (conn.readyState === 1) { - conn.send(JSON.stringify(data)); - } -} - /** * WebSocket connection module. * * Needs init() call. + * + * @version 1 + * + * Events: + * @link MESSAGE + * */ -export const socket = { - init, - send -} +const socket = (() => { + let conn; + + const buildUrl = (params = {}) => { + const url = new URL(window.location); + url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss'; + url.pathname = "/ws"; + Object.keys(params).forEach(k => { + if (!!params[k]) url.searchParams.set(k, params[k]) + }) + return url + } + + const init = (roomId, wid, zone) => { + let objParams = {room_id: roomId, zone: zone}; + if (wid) objParams.wid = wid; + const url = buildUrl(objParams) + console.info(`[ws] connecting to ${url}`); + conn = new WebSocket(url.toString()); + conn.onopen = () => { + log.info('[ws] <- open connection'); + }; + conn.onerror = () => log.error('[ws] some error!'); + conn.onclose = (event) => log.info(`[ws] closed (${event.code})`); + conn.onmessage = response => { + const data = JSON.parse(response.data); + log.debug('[ws] <- ', data); + event.pub(MESSAGE, data); + }; + }; + + const send = (data) => { + if (conn.readyState === 1) { + conn.send(JSON.stringify(data)); + } + } + + return { + init: init, + send: send, + } +})(event, log); diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js index 3bc5ff76..218e9e75 100644 --- a/web/js/network/webrtc.js +++ b/web/js/network/webrtc.js @@ -1,201 +1,180 @@ -import { - pub, - WEBRTC_CONNECTION_CLOSED, - WEBRTC_CONNECTION_READY, - WEBRTC_ICE_CANDIDATE_FOUND, - WEBRTC_ICE_CANDIDATES_FLUSH, - WEBRTC_SDP_ANSWER -} from 'event'; -import {log} from 'log'; - -let connection; -let dataChannel -let keyboardChannel -let mouseChannel -let mediaStream; -let candidates = []; -let isAnswered = false; -let isFlushing = false; - -let connected = false; -let inputReady = false; - -let onData; - -const start = (iceservers) => { - log.info('[rtc] <- ICE servers', iceservers); - const servers = iceservers || []; - connection = new RTCPeerConnection({iceServers: servers}); - mediaStream = new MediaStream(); - - connection.ondatachannel = e => { - log.debug('[rtc] ondatachannel', e.channel.label) - e.channel.binaryType = "arraybuffer"; - - if (e.channel.label === 'keyboard') { - keyboardChannel = e.channel - return - } - - if (e.channel.label === 'mouse') { - mouseChannel = e.channel - return - } - - dataChannel = e.channel; - dataChannel.onopen = () => { - log.info('[rtc] the input channel has been opened'); - inputReady = true; - pub(WEBRTC_CONNECTION_READY) - }; - if (onData) { - dataChannel.onmessage = onData; - } - dataChannel.onclose = () => { - inputReady = false - log.info('[rtc] the input channel has been closed') - } - } - connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; - connection.onicegatheringstatechange = ice.onIceStateChange; - connection.onicecandidate = ice.onIcecandidate; - connection.ontrack = event => { - mediaStream.addTrack(event.track); - } -}; - -const stop = () => { - if (mediaStream) { - mediaStream.getTracks().forEach(t => { - t.stop(); - mediaStream.removeTrack(t); - }); - mediaStream = null; - } - if (connection) { - connection.close(); - connection = null; - } - if (dataChannel) { - dataChannel.close() - dataChannel = null - } - if (keyboardChannel) { - keyboardChannel?.close() - keyboardChannel = null - } - if (mouseChannel) { - mouseChannel?.close() - mouseChannel = null - } - candidates = []; - log.info('[rtc] WebRTC has been closed'); -} - -const ice = (() => { - const ICE_TIMEOUT = 2000; - let timeForIceGathering; - - return { - onIcecandidate: data => { - if (!data.candidate) return; - log.info('[rtc] user candidate', data.candidate); - pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate}) - }, - onIceStateChange: event => { - switch (event.target.iceGatheringState) { - case 'gathering': - log.info('[rtc] ice gathering'); - timeForIceGathering = setTimeout(() => { - log.warn(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`); - // sendCandidates(); - }, ICE_TIMEOUT); - break; - case 'complete': - log.info('[rtc] ice gathering has been completed'); - if (timeForIceGathering) { - clearTimeout(timeForIceGathering); - } - } - }, - onIceConnectionStateChange: () => { - log.info('[rtc] <- iceConnectionState', connection.iceConnectionState); - switch (connection.iceConnectionState) { - case 'connected': - log.info('[rtc] connected...'); - connected = true; - break; - case 'disconnected': - log.info(`[rtc] disconnected... ` + - `connection: ${connection.connectionState}, ice: ${connection.iceConnectionState}, ` + - `gathering: ${connection.iceGatheringState}, signalling: ${connection.signalingState}`) - connected = false; - pub(WEBRTC_CONNECTION_CLOSED); - break; - case 'failed': - log.error('[rtc] failed establish connection, retry...'); - connected = false; - connection.createOffer({iceRestart: true}) - .then(description => connection.setLocalDescription(description).catch(log.error)) - .catch(log.error); - break; - } - } - } -})(); - /** * WebRTC connection module. + * @version 1 + * + * Events: + * @link WEBRTC_CONNECTION_CLOSED + * @link WEBRTC_CONNECTION_READY + * @link WEBRTC_ICE_CANDIDATE_FOUND + * @link WEBRTC_ICE_CANDIDATES_FLUSH + * @link WEBRTC_SDP_ANSWER + * */ -export const webrtc = { - start, - setRemoteDescription: async (data, media) => { - log.debug('[rtc] remote SDP', data) - const offer = new RTCSessionDescription(JSON.parse(atob(data))); - await connection.setRemoteDescription(offer); +const webrtc = (() => { + let connection; + let inputChannel; + let mediaStream; + let candidates = Array(); + let isAnswered = false; + let isFlushing = false; - const answer = await connection.createAnswer(); - // Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround - // force stereo params for Opus tracks (a=fmtp:111 ...) - answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1'); - await connection.setLocalDescription(answer); - log.debug("[rtc] local SDP", answer) + let connected = false; + let inputReady = false; - isAnswered = true; - pub(WEBRTC_ICE_CANDIDATES_FLUSH); - pub(WEBRTC_SDP_ANSWER, {sdp: answer}); - media.srcObject = mediaStream; - }, - addCandidate: (data) => { - if (data === '') { - pub(WEBRTC_ICE_CANDIDATES_FLUSH); - } else { - candidates.push(data); + let onMessage; + + const start = (iceservers) => { + log.info('[rtc] <- ICE servers', iceservers); + const servers = iceservers || []; + connection = new RTCPeerConnection({iceServers: servers}); + mediaStream = new MediaStream(); + + connection.ondatachannel = e => { + log.debug('[rtc] ondatachannel', e.channel.label) + inputChannel = e.channel; + inputChannel.onopen = () => { + log.info('[rtc] the input channel has been opened'); + inputReady = true; + event.pub(WEBRTC_CONNECTION_READY) + }; + if (onMessage) { + inputChannel.onmessage = onMessage; + } + inputChannel.onclose = () => log.info('[rtc] the input channel has been closed'); } - }, - flushCandidates: () => { - if (isFlushing || !isAnswered) return; - isFlushing = true; - log.debug('[rtc] flushing candidates', candidates); - candidates.forEach(data => { - const candidate = new RTCIceCandidate(JSON.parse(atob(data))) - connection.addIceCandidate(candidate).catch(e => { - log.error('[rtc] candidate add failed', e.name); + connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; + connection.onicegatheringstatechange = ice.onIceStateChange; + connection.onicecandidate = ice.onIcecandidate; + connection.ontrack = event => { + mediaStream.addTrack(event.track); + } + }; + + const stop = () => { + if (mediaStream) { + mediaStream.getTracks().forEach(t => { + t.stop(); + mediaStream.removeTrack(t); }); - }); - isFlushing = false; - }, - keyboard: (data) => keyboardChannel?.send(data), - mouse: (data) => mouseChannel?.send(data), - input: (data) => inputReady && dataChannel.send(data), - isConnected: () => connected, - isInputReady: () => inputReady, - stats: async () => { - if (!connected) return Promise.resolve(); - return await connection.getStats() - }, - stop, - set onData(fn) { - onData = fn + mediaStream = null; + } + if (connection) { + connection.close(); + connection = null; + } + if (inputChannel) { + inputChannel.close(); + inputChannel = null; + } + candidates = Array(); + log.info('[rtc] WebRTC has been closed'); } -} + + const ice = (() => { + const ICE_TIMEOUT = 2000; + let timeForIceGathering; + + return { + onIcecandidate: data => { + if (!data.candidate) return; + log.info('[rtc] user candidate', data.candidate); + event.pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate}) + }, + onIceStateChange: event => { + switch (event.target.iceGatheringState) { + case 'gathering': + log.info('[rtc] ice gathering'); + timeForIceGathering = setTimeout(() => { + log.warning(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`); + // sendCandidates(); + }, ICE_TIMEOUT); + break; + case 'complete': + log.info('[rtc] ice gathering has been completed'); + if (timeForIceGathering) { + clearTimeout(timeForIceGathering); + } + } + }, + onIceConnectionStateChange: () => { + log.info('[rtc] <- iceConnectionState', connection.iceConnectionState); + switch (connection.iceConnectionState) { + case 'connected': { + log.info('[rtc] connected...'); + connected = true; + break; + } + case 'disconnected': { + log.info('[rtc] disconnected...'); + connected = false; + event.pub(WEBRTC_CONNECTION_CLOSED); + break; + } + case 'failed': { + log.error('[rtc] failed establish connection, retry...'); + connected = false; + connection.createOffer({iceRestart: true}) + .then(description => connection.setLocalDescription(description).catch(log.error)) + .catch(log.error); + break; + } + } + } + } + })(); + + return { + start: start, + setRemoteDescription: async (data, media) => { + log.debug('[rtc] remote SDP', data) + const offer = new RTCSessionDescription(JSON.parse(atob(data))); + await connection.setRemoteDescription(offer); + + const answer = await connection.createAnswer(); + // Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround + // force stereo params for Opus tracks (a=fmtp:111 ...) + answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1'); + await connection.setLocalDescription(answer); + log.debug("[rtc] local SDP", answer) + + isAnswered = true; + event.pub(WEBRTC_ICE_CANDIDATES_FLUSH); + event.pub(WEBRTC_SDP_ANSWER, {sdp: answer}); + media.srcObject = mediaStream; + }, + // setMessageHandler: (handler) => onMessage = handler, + addCandidate: (data) => { + if (data === '') { + event.pub(WEBRTC_ICE_CANDIDATES_FLUSH); + } else { + candidates.push(data); + } + }, + flushCandidates: () => { + if (isFlushing || !isAnswered) return; + isFlushing = true; + log.debug('[rtc] flushing candidates', candidates); + candidates.forEach(data => { + const candidate = new RTCIceCandidate(JSON.parse(atob(data))) + connection.addIceCandidate(candidate).catch(e => { + log.error('[rtc] candidate add failed', e.name); + }); + }); + isFlushing = false; + }, + // message: (mess = '') => { + // try { + // inputChannel.send(mess) + // return true + // } catch (error) { + // log.error('[rtc] input channel broken ' + error) + // return false + // } + // }, + input: (data) => inputChannel.send(data), + isConnected: () => connected, + isInputReady: () => inputReady, + getConnection: () => connection, + stop, + } +})(event, log); diff --git a/web/js/recording.js b/web/js/recording.js index 70f18ad0..b78cc01e 100644 --- a/web/js/recording.js +++ b/web/js/recording.js @@ -1,66 +1,64 @@ -import { - pub, - KEYBOARD_TOGGLE_FILTER_MODE, - RECORDING_TOGGLED -} from 'event'; -import {throttle} from 'utils'; +const RECORDING_ON = 1; +const RECORDING_OFF = 0; +const RECORDING_REC = 2; -export const RECORDING_ON = 1; -export const RECORDING_OFF = 0; -export const RECORDING_REC = 2; +/** + * Recording module. + * @version 1 + */ +const recording = (() => { + const userName = document.getElementById('user-name'), + recButton = document.getElementById('btn-rec'); -const userName = document.getElementById('user-name'), - recButton = document.getElementById('btn-rec'); - -let state = { - userName: '', - state: RECORDING_OFF, -}; - -const restoreLastState = () => { - const lastState = localStorage.getItem('recording'); - if (lastState) { - const _last = JSON.parse(lastState); - if (_last) { - state = _last; + if (!userName || !recButton) { + return { + isActive: () => false, + getUser: () => '', } } - userName.value = state.userName -} -const setRec = (val) => { - recButton.classList.toggle('record', val); -} -const setIndicator = (val) => { - recButton.classList.toggle('blink', val); -}; + let state = { + userName: '', + state: RECORDING_OFF, + }; -// persistence -const saveLastState = () => { - const _state = Object.keys(state) - .filter(key => !key.startsWith('_')) - .reduce((obj, key) => ({...obj, [key]: state[key]}), {}); - localStorage.setItem('recording', JSON.stringify(_state)); -} -const saveUserName = throttle(() => { - state.userName = userName.value; - saveLastState(); -}, 500) + const restoreLastState = () => { + const lastState = localStorage.getItem('recording'); + if (lastState) { + const _last = JSON.parse(lastState); + if (_last) { + state = _last; + } + } + userName.value = state.userName + } -let _recording = { - isActive: () => false, - getUser: () => '', - setIndicator: () => ({}), -} + const setRec = (val) => { + recButton.classList.toggle('record', val); + } + const setIndicator = (val) => { + recButton.classList.toggle('blink', val); + }; + + // persistence + const saveLastState = () => { + const _state = Object.keys(state) + .filter(key => !key.startsWith('_')) + .reduce((obj, key) => ({...obj, [key]: state[key]}), {}); + localStorage.setItem('recording', JSON.stringify(_state)); + } + const saveUserName = utils.throttle(() => { + state.userName = userName.value; + saveLastState(); + }, 500) -if (userName && recButton) { restoreLastState(); setIndicator(false); setRec(state.state === RECORDING_ON) // text - userName.addEventListener('focus', () => pub(KEYBOARD_TOGGLE_FILTER_MODE)) - userName.addEventListener('blur', () => pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true})) + userName.addEventListener('focus', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE)) + userName.addEventListener('blur', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true})) userName.addEventListener('keyup', ev => { ev.stopPropagation(); saveUserName() @@ -72,17 +70,11 @@ if (userName && recButton) { const active = state.state === RECORDING_ON setRec(active) saveLastState() - pub(RECORDING_TOGGLED, {userName: state.userName, recording: active}) + event.pub(RECORDING_TOGGLED, {userName: state.userName, recording: active}) }) - - _recording = { + return { isActive: () => state.state > 0, getUser: () => state.userName, - setIndicator, + setIndicator: setIndicator, } -} - -/** - * Recording module. - */ -export const recording = _recording +})(document, event, localStorage, utils); diff --git a/web/js/room.js b/web/js/room.js index 1321fc10..20a53a73 100644 --- a/web/js/room.js +++ b/web/js/room.js @@ -1,81 +1,76 @@ -import { - sub, - GAME_ROOM_AVAILABLE -} from 'event'; - -let id = ''; - -// UI -const roomLabel = document.getElementById('room-txt'); - -// !to rewrite -const parseURLForRoom = () => { - let queryDict = {}; - let regex = /^\/?([A-Za-z]*)\/?/g; - const zone = regex.exec(location.pathname)[1]; - let room = null; - - // get room from URL - location.search.substr(1) - .split('&') - .forEach((item) => { - queryDict[item.split('=')[0]] = item.split('=')[1] - }); - - if (typeof queryDict.id === 'string') { - room = decodeURIComponent(queryDict.id); - } - - return [room, zone]; -}; - -sub(GAME_ROOM_AVAILABLE, data => { - room.id = data.roomId - room.save(data.roomId); -}, 1); - /** * Game room module. + * @version 1 */ -export const room = { - get id() { - return id - }, - set id(id_) { - id = id_; - roomLabel.value = id; - }, - reset: () => { - id = ''; - roomLabel.value = id; - }, - save: (roomIndex) => { - localStorage.setItem('roomID', roomIndex); - }, - load: () => localStorage.getItem('roomID'), - getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.id)}`, - loadMaybe: () => { - // localStorage first - //roomID = loadRoomID(); - let zone = ''; +const room = (() => { + let id = ''; - // Shared URL second - const [parsedId, czone] = parseURLForRoom(); - if (parsedId !== null) { - id = parsedId; - } - if (czone !== null) { - zone = czone; + // UI + const roomLabel = document.getElementById('room-txt'); + + // !to rewrite + const parseURLForRoom = () => { + let queryDict = {}; + let regex = /^\/?([A-Za-z]*)\/?/g; + const zone = regex.exec(location.pathname)[1]; + let room = null; + + // get room from URL + location.search.substr(1) + .split('&') + .forEach((item) => { + queryDict[item.split('=')[0]] = item.split('=')[1] + }); + + if (typeof queryDict.id === 'string') { + room = decodeURIComponent(queryDict.id); } - return [id, zone]; - }, - copyToClipboard: () => { - const el = document.createElement('textarea'); - el.value = room.getLink(); - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); + return [room, zone]; + }; + + event.sub(GAME_ROOM_AVAILABLE, data => { + room.setId(data.roomId); + room.save(data.roomId); + }, 1); + + return { + getId: () => id, + setId: (id_) => { + id = id_; + roomLabel.value = id; + }, + reset: () => { + id = ''; + roomLabel.value = id; + }, + save: (roomIndex) => { + localStorage.setItem('roomID', roomIndex); + }, + load: () => localStorage.getItem('roomID'), + getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`, + loadMaybe: () => { + // localStorage first + //roomID = loadRoomID(); + + // Shared URL second + const [parsedId, czone] = parseURLForRoom(); + if (parsedId !== null) { + id = parsedId; + } + if (czone !== null) { + zone = czone; + } + + return [id, zone]; + }, + copyToClipboard: () => { + const el = document.createElement('textarea'); + el.value = room.getLink(); + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + } } -} +})(document, event, location, localStorage, window); diff --git a/web/js/screen.js b/web/js/screen.js deleted file mode 100644 index b4342e3c..00000000 --- a/web/js/screen.js +++ /dev/null @@ -1,88 +0,0 @@ -import { - sub, - SETTINGS_CHANGED, - REFRESH_INPUT, -} from 'event'; -import {env} from 'env'; -import {input, pointer, keyboard} from 'input'; -import {opts, settings} from 'settings'; -import {gui} from 'gui'; - -const rootEl = document.getElementById('screen') -const footerEl = document.getElementsByClassName('screen__footer')[0] - -const state = { - components: [], - current: undefined, - forceFullscreen: false, -} - -const toggle = async (component, force) => { - component && (state.current = component) // keep the last component - state.components.forEach(c => c.toggle(false)) - state.current?.toggle(force) - state.forceFullscreen && fullscreen(true) -} - -const init = () => { - state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false) - sub(SETTINGS_CHANGED, () => { - state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN] - }) -} - -const cursor = pointer.autoHide(rootEl, 2000) - -const trackPointer = pointer.track(rootEl, () => { - const display = state.current; - return {...display.video.size, s: !!display?.hasDisplay} -}) - -const fullscreen = () => { - if (state.current?.noFullscreen) return - - let h = parseFloat(getComputedStyle(rootEl, null).height.replace('px', '')) - env.display().toggleFullscreen(h !== window.innerHeight, rootEl) -} - -const controls = async (locked = false) => { - if (!state.current?.hasDisplay) return - if (env.isMobileDevice) return - if (!input.kbm) return - - if (locked) { - await pointer.lock(rootEl) - } - - // oof, remove hover:hover when the pointer is forcibly locked, - // leaving the element in the hovered state - locked ? footerEl.classList.remove('hover') : footerEl.classList.add('hover') - - trackPointer(locked) - await keyboard.lock(locked) - input.retropad.toggle(!locked) -} - -rootEl.addEventListener('fullscreenchange', async () => { - const fs = document.fullscreenElement !== null - - cursor.autoHide(!fs) - gui.toggle(footerEl, fs) - await controls(fs) - state.current?.onFullscreen?.(fs) -}) - -sub(REFRESH_INPUT, async () => { - await controls(document.fullscreenElement !== null) -}) - -export const screen = { - fullscreen, - toggle, - /** - * Adds a component. It should have toggle(bool) method and - * an optional noFullscreen (bool) property. - */ - add: (...o) => state.components.push(...o), - init, -} diff --git a/web/js/settings.js b/web/js/settings.js deleted file mode 100644 index 7dc30b06..00000000 --- a/web/js/settings.js +++ /dev/null @@ -1,547 +0,0 @@ -import { - pub, - sub, - SETTINGS_CHANGED, - KEYBOARD_KEY_PRESSED, - KEYBOARD_TOGGLE_FILTER_MODE -} from 'event'; -import {gui} from 'gui'; -import {log} from 'log'; - -/** - * Stores app wide option names. - * - * Use the following format: - * UPPERCASE_NAME: 'uppercase.name' - * - * @version 1 - */ -export const opts = { - _VERSION: '_version', - LOG_LEVEL: 'log.level', - INPUT_KEYBOARD_MAP: 'input.keyboard.map', - MIRROR_SCREEN: 'mirror.screen', - VOLUME: 'volume', - FORCE_FULLSCREEN: 'force.fullscreen', -} - - -// internal structure version -const revision = 1.6; - -// default settings -// keep them for revert to defaults option -const _defaults = Object.create(null); -_defaults[opts._VERSION] = revision; - -/** - * The main store with settings passed around by reference - * (because of that we need a wrapper object) - * don't do this at work (it's faster to write than immutable code). - * - * @type {{settings: {_version: number}}} - */ -let store = { - settings: { - ..._defaults - } -}; -let provider; - -/** - * Enum for settings types (the explicit type of a key-value pair). - * - * @readonly - * @enum {number} - */ -const option = Object.freeze({undefined: 0, string: 1, number: 2, object: 3, list: 4}); - -const exportFileName = `cloud-game.settings.v${revision}.txt`; - -const getStore = () => store.settings; - -/** - * The NullObject provider if everything else fails. - */ -const voidProvider = (store_ = {settings: {}}) => { - const nil = () => ({}) - - return { - get: key => store_.settings[key], - set: nil, - remove: nil, - save: nil, - loadSettings: nil, - reset: nil, - } -} - -/** - * The LocalStorage backend for our settings (store). - * - * For simplicity it will rewrite all the settings on every store change. - * If you want to roll your own, then use its "interface". - */ -const localStorageProvider = ((store_ = {settings: {}}) => { - if (!_isSupported()) return; - - const root = 'settings'; - - const _serialize = data => JSON.stringify(data, null, 2); - - const save = () => localStorage.setItem(root, _serialize(store_.settings)); - - function _isSupported() { - const testKey = '_test_42'; - try { - // check if it's writable and isn't full - localStorage.setItem(testKey, testKey); - localStorage.removeItem(testKey); - return true; - } catch (e) { - log.error(e); - return false; - } - } - - const get = key => JSON.parse(localStorage.getItem(key)); - - const set = () => save(); - - const remove = () => save(); - - const loadSettings = () => { - if (!localStorage.getItem(root)) save(); - store_.settings = JSON.parse(localStorage.getItem(root)); - } - - const reset = () => { - localStorage.removeItem(root); - localStorage.setItem(root, _serialize(store_.settings)); - } - - return { - get, - clear: () => localStorage.removeItem(root), - set, - remove, - save, - loadSettings, - reset, - } -}); - -/** - * Nuke existing settings with provided data. - * @param text The text to extract data from. - * @private - */ -const _import = text => { - try { - for (const property of Object.getOwnPropertyNames(store.settings)) delete store.settings[property]; - Object.assign(store.settings, JSON.parse(text).settings); - provider.save(); - pub(SETTINGS_CHANGED); - } catch (e) { - log.error(`Your import file is broken!`); - } - - _render(); -} - -const _export = () => { - let el = document.createElement('a'); - el.setAttribute( - 'href', - `data:text/plain;charset=utf-8,${encodeURIComponent(JSON.stringify(store, null, 2))}` - ); - el.setAttribute('download', exportFileName); - el.style.display = 'none'; - document.body.appendChild(el); - el.click(); - document.body.removeChild(el); -} - -const init = () => { - // try to load settings from the localStorage with fallback to null-object - provider = localStorageProvider(store) || voidProvider(store); - provider.loadSettings(); - - const lastRev = (store.settings || {_version: 0})._version - - if (revision > lastRev) { - log.warn(`Your settings are in older format (v${lastRev}) and will be reset to (v${revision})!`); - _reset(); - } -} - -const get = () => store.settings; - -const _isLoaded = key => store.settings.hasOwnProperty(key); - -/** - * Tries to load settings by some key. - * - * @param key A key to find values with. - * @param default_ The default values to set if none exist. - * @returns A slice of the settings with the given key or a copy of the value. - */ -const loadOr = (key, default_) => { - // preserve defaults - _defaults[key] = default_; - - if (!_isLoaded(key)) { - store.settings[key] = {}; - set(key, default_); - } else { - // !to check if settings do have new properties from default & update - // or it have ones that defaults doesn't - } - - return store.settings[key]; -} - -const set = (key, value, updateProvider = true) => { - const type = _getType(value); - - // mutate settings w/o changing the reference - switch (type) { - case option.list: - store.settings[key].splice(0, Infinity, ...value); - break; - case option.object: - for (let option of Object.keys(value)) { - log.debug(`Change key [${option}] from ${store.settings[key][option]} to ${value[option]}`); - store.settings[key][option] = value[option]; - } - break; - case option.string: - case option.number: - case option.undefined: - default: - store.settings[key] = value; - } - - if (updateProvider) { - provider.set(key, value); - pub(SETTINGS_CHANGED); - } -} - -const changed = (key, obj, key2) => { - if (!store.settings.hasOwnProperty(key)) return - const newValue = store.settings[key] - const changed = newValue !== obj[key2] - changed && (obj[key2] = newValue) - return changed -} - -const _reset = () => { - for (let _option of Object.keys(_defaults)) { - const value = _defaults[_option]; - - // delete all sub-options not in defaults - if (_getType(value) === option.object) { - for (let opt of Object.keys(store.settings[_option])) { - const prev = store.settings[_option][opt]; - const isDeleted = delete store.settings[_option][opt]; - log.debug(`User option [${opt}=${prev}] has been deleted (${isDeleted}) from the [${_option}]`); - } - } - - set(_option, value, false); - } - - provider.reset(); - pub(SETTINGS_CHANGED); -} - -const remove = (key, subKey) => { - const isRemoved = subKey !== undefined ? delete store.settings[key][subKey] : delete store.settings[key]; - if (!isRemoved) log.warn(`The key: ${key + (subKey ? '.' + subKey : '')} wasn't deleted!`); - provider.remove(key, subKey); -} - -const _render = () => { - renderer.data = panel.contentEl; - renderer.render() -} - -const panel = gui.panel(document.getElementById('settings'), '> OPTIONS', 'settings', null, [ - {caption: 'Export', handler: () => _export(), title: 'Save',}, - {caption: 'Import', handler: () => _fileReader.read(onFileLoad), title: 'Load',}, - { - caption: 'Reset', - handler: () => { - if (window.confirm("Are you sure want to reset your settings?")) { - _reset(); - pub(SETTINGS_CHANGED); - } - }, - title: 'Reset', - }, - {} - ], - (show) => { - if (show) { - _render(); - return; - } - - // to make sure it's disabled, but it's a tad verbose - pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); - }) - -function _getType(value) { - if (value === undefined) return option.undefined - else if (Array.isArray(value)) return option.list - else if (typeof value === 'object' && value !== null) return option.object - else if (typeof value === 'string') return option.string - else if (typeof value === 'number') return option.number - else return option.undefined; -} - -const _fileReader = (() => { - let callback_ = () => ({}) - - const el = document.createElement('input'); - const reader = new FileReader(); - - el.type = 'file'; - el.accept = '.txt'; - el.onchange = event => event.target.files.length && reader.readAsBinaryString(event.target.files[0]); - reader.onload = event => callback_(event.target.result); - - return { - read: callback => { - callback_ = callback; - el.click(); - }, - } -})(); - -const onFileLoad = text => { - try { - _import(text); - } catch (e) { - log.error(`Couldn't read your settings!`, e); - } -} - -sub(SETTINGS_CHANGED, _render); - -/** - * App settings module. - * - * So the basic idea is to let app modules request their settings - * from an abstract store first, and if the store doesn't contain such settings yet, - * then let the store to take default values from the module to save them before that. - * The return value with the settings is gonna be a slice of in-memory structure - * backed by a data provider (localStorage). - * Doing it this way allows us to considerably simplify the code and make sure that - * exposed settings will have the latest values without additional update/get calls. - */ -export const settings = { - init, - loadOr, - getStore, - get, - set, - changed, - remove, - import: _import, - export: _export, - ui: { - set onToggle(fn) { - panel.onToggle(fn); - }, - toggle: () => panel.toggle(), - }, -} - -// don't show these options (i.e. ignored = {'_version': 1}) -const ignored = {'_version': 1}; - -// the main display data holder element -let data = null; - -const scrollState = ((sx = 0, sy = 0, el) => ({ - track(_el) { - el = _el - el.addEventListener("scroll", () => ({scrollTop: sx, scrollLeft: sy} = el), {passive: true}) - }, - restore() { - el.scrollTop = sx - el.scrollLeft = sy - } -}))() - -// a fast way to clear data holder. -const clearData = () => { - while (data.firstChild) data.removeChild(data.firstChild) -}; - -const _option = (holderEl) => { - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add('settings__option'); - - const titleEl = document.createElement('div'); - titleEl.classList.add('settings__option-title'); - wrapperEl.append(titleEl); - - const nameEl = document.createElement('div'); - - const valueEl = document.createElement('div'); - valueEl.classList.add('settings__option-value'); - wrapperEl.append(valueEl); - - return { - withName: function (name = '') { - if (name === '') return this; - nameEl.classList.add('settings__option-name'); - nameEl.textContent = name; - titleEl.append(nameEl); - return this; - }, - withClass: function (name = '') { - wrapperEl.classList.add(name); - return this; - }, - withDescription(text = '') { - if (text === '') return this; - const descEl = document.createElement('div'); - descEl.classList.add('settings__option-desc'); - descEl.textContent = text; - titleEl.append(descEl); - return this; - }, - restartNeeded: function () { - nameEl.classList.add('restart-needed-asterisk'); - return this; - }, - add: function (...elements) { - if (elements.length) for (let _el of elements.flat()) valueEl.append(_el); - return this; - }, - build: () => holderEl.append(wrapperEl), - }; -} - -const onKeyChange = (key, oldValue, newValue, handler) => { - - if (newValue !== 'Escape') { - const _settings = settings.get()[opts.INPUT_KEYBOARD_MAP]; - - if (_settings[newValue] !== undefined) { - log.warn(`There are old settings for key: ${_settings[newValue]}, won't change!`); - } else { - settings.remove(opts.INPUT_KEYBOARD_MAP, oldValue); - settings.set(opts.INPUT_KEYBOARD_MAP, {[newValue]: key}); - } - } - - handler?.unsub(); - - pub(KEYBOARD_TOGGLE_FILTER_MODE); - pub(SETTINGS_CHANGED); -} - -const _keyChangeOverlay = (keyName, oldValue) => { - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add('settings__key-wait'); - wrapperEl.textContent = `Let's choose a ${keyName} key...`; - - let handler = sub(KEYBOARD_KEY_PRESSED, button => onKeyChange(keyName, oldValue, button.key, handler)); - - return wrapperEl; -} - -/** - * Handles a normal option change. - * - * @param key The name (id) of an option. - * @param newValue A new value to set. - */ -const onChange = (key, newValue) => { - settings.set(key, newValue); - scrollState.restore(data); -} - -const onKeyBindingChange = (key, oldValue) => { - clearData(); - data.append(_keyChangeOverlay(key, oldValue)); - pub(KEYBOARD_TOGGLE_FILTER_MODE); -} - -const render = function () { - const _settings = settings.getStore(); - - clearData(); - for (let k of Object.keys(_settings).sort()) { - if (ignored[k]) continue; - - const value = _settings[k]; - switch (k) { - case opts._VERSION: - _option(data).withName('Options format version').add(value).build(); - break; - case opts.LOG_LEVEL: - _option(data).withName('Log level') - .add(gui.select(k, onChange, { - labels: ['trace', 'debug', 'warning', 'info'], - values: [log.TRACE, log.DEBUG, log.WARN, log.INFO].map(String) - }, value)) - .build(); - break; - case opts.INPUT_KEYBOARD_MAP: - _option(data).withName('Keyboard bindings') - .withClass('keyboard-bindings') - .withDescription( - 'Bindings for RetroPad. There is an alternate ESC key [Shift+`] (tilde) for cores with keyboard+mouse controls (DosBox)') - .add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange))) - .build(); - break; - case opts.MIRROR_SCREEN: - _option(data).withName('Video mirroring') - .add(gui.select(k, onChange, {values: ['mirror'], labels: []}, value)) - .withDescription('Disables video image smoothing by rendering the video on a canvas (much more demanding for browser)') - .build(); - break; - case opts.VOLUME: - _option(data).withName('Volume (%)') - .add(gui.inputN(k, onChange, value)) - .build() - break; - case opts.FORCE_FULLSCREEN: - _option(data).withName('Force fullscreen') - .withDescription( - 'Whether games should open in full-screen mode after starting up (excluding mobile devices)' - ) - .add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox')) - .build() - break; - default: - _option(data).withName(k).add(value).build(); - } - } - - data.append( - gui.create('br'), - gui.create('div', (el) => { - el.classList.add('settings__info', 'restart-needed-asterisk-b'); - el.innerText = ' -- applied after page reload' - }), - gui.create('div', (el) => { - el.classList.add('settings__info'); - el.innerText = `Options format version: ${_settings?._version}`; - }) - ); -} - -const renderer = { - render, - set data(el) { - data = el; - scrollState.track(el) - } -} diff --git a/web/js/settings/opts.js b/web/js/settings/opts.js new file mode 100644 index 00000000..bff7a098 --- /dev/null +++ b/web/js/settings/opts.js @@ -0,0 +1,14 @@ +/** + * Stores app wide option names. + * + * Use the following format: + * UPPERCASE_NAME: 'uppercase.name' + * + * @version 1 + */ +const opts = Object.freeze({ + _VERSION: '_version', + LOG_LEVEL: 'log.level', + INPUT_KEYBOARD_MAP: 'input.keyboard.map', + MIRROR_SCREEN: 'mirror.screen' +}); diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js new file mode 100644 index 00000000..e3be8e81 --- /dev/null +++ b/web/js/settings/settings.js @@ -0,0 +1,475 @@ +/** + * App settings module. + * + * So the basic idea is to let app modules request their settings + * from an abstract store first, and if the store doesn't contain such settings yet, + * then let the store to take default values from the module to save them before that. + * The return value with the settings is gonna be a slice of in-memory structure + * backed by a data provider (localStorage). + * Doing it this way allows us to considerably simplify the code and make sure that + * exposed settings will have the latest values without additional update/get calls. + * + * Uses ES8. + * + * @version 1 + */ +const settings = (() => { + // internal structure version + const revision = 1.1; + + // default settings + // keep them for revert to defaults option + const _defaults = Object.create(null); + _defaults[opts._VERSION] = revision; + + /** + * The main store with settings passed around by reference + * (because of that we need a wrapper object) + * don't do this at work (it's faster to write than immutable code). + * + * @type {{settings: {_version: number}}} + */ + let store = { + settings: { + ..._defaults + } + }; + let provider; + + /** + * Enum for settings types (the explicit type of a key-value pair). + * + * @readonly + * @enum {number} + */ + const option = Object.freeze({undefined: 0, string: 1, number: 2, object: 3, list: 4}); + + const exportFileName = `cloud-game.settings.v${revision}.txt`; + + // ui references + const ui = document.getElementById('app-settings'), + closeEl = document.getElementById('settings__controls__close'), + loadEl = document.getElementById('settings__controls__load'), + saveEl = document.getElementById('settings__controls__save'), + resetEl = document.getElementById('settings__controls__reset'); + + this._renderrer = this._renderrer || { + render: () => { + } + }; + + const getStore = () => store.settings; + + /** + * The NullObject provider if everything else fails. + */ + const voidProvider = (store_ = {settings: {}}) => { + const nil = () => { + } + + return { + get: key => store_.settings[key], + set: nil, + remove: nil, + save: nil, + loadSettings: nil, + reset: nil, + } + } + + /** + * The LocalStorage backend for our settings (store). + * + * For simplicity it will rewrite all the settings on every store change. + * If you want to roll your own, then use its "interface". + */ + const localStorageProvider = ((store_ = {settings: {}}) => { + if (!_isSupported()) return; + + const root = 'settings'; + + const _serialize = data => JSON.stringify(data, null, 2); + + const save = () => localStorage.setItem(root, _serialize(store_.settings)); + + function _isSupported() { + const testKey = '_test_42'; + try { + // check if it's writable and isn't full + localStorage.setItem(testKey, testKey); + localStorage.removeItem(testKey); + return true; + } catch (e) { + log.error(e); + return false; + } + } + + const get = key => JSON.parse(localStorage.getItem(key)); + + const set = (key, value) => save(); + + const remove = () => save(); + + const loadSettings = () => { + if (!localStorage.getItem(root)) save(); + store_.settings = JSON.parse(localStorage.getItem(root)); + } + + const reset = () => { + localStorage.removeItem(root); + localStorage.setItem(root, _serialize(store_.settings)); + } + + return { + get, + set, + remove, + save, + loadSettings, + reset, + } + }); + + /** + * Nuke existing settings with provided data. + * @param text The text to extract data from. + * @private + */ + const _import = text => { + try { + for (const property of Object.getOwnPropertyNames(store.settings)) delete store.settings[property]; + Object.assign(store.settings, JSON.parse(text).settings); + provider.save(); + event.pub(SETTINGS_CHANGED); + } catch (e) { + log.error(`Your import file is broken!`); + } + + _render(); + } + + const _export = () => { + let el = document.createElement('a'); + el.setAttribute( + 'href', + `data:text/plain;charset=utf-8,${encodeURIComponent(JSON.stringify(store, null, 2))}` + ); + el.setAttribute('download', exportFileName); + el.style.display = 'none'; + document.body.appendChild(el); + el.click(); + document.body.removeChild(el); + el = undefined; + } + + const init = () => { + provider = localStorageProvider(store) || voidProvider(store); + provider.loadSettings(); + + if (revision > store.settings._version) { + // !to handle this with migrations + log.warn(`Your settings are in older format (v${store.settings._version})`); + } + } + + const get = () => store.settings; + + const _isLoaded = key => store.settings.hasOwnProperty(key); + + /** + * Tries to load settings by some key. + * + * @param key A key to find values with. + * @param default_ The default values to set if none exist. + * @returns A slice of the settings with the given key or a copy of the value. + */ + const loadOr = (key, default_) => { + // preserve defaults + _defaults[key] = default_; + + if (!_isLoaded(key)) { + store.settings[key] = {}; + set(key, default_); + } else { + // !to check if settings do have new properties from default & update + // or it have ones that defaults doesn't + } + + return store.settings[key]; + } + + const set = (key, value, updateProvider = true) => { + const type = _getType(value); + + // mutate settings w/o changing the reference + switch (type) { + case option.list: + store.settings[key].splice(0, Infinity, ...value); + break; + case option.object: + for (let option of Object.keys(value)) { + log.debug(`Change key [${option}] from ${store.settings[key][option]} to ${value[option]}`); + store.settings[key][option] = value[option]; + } + break; + case option.string: + case option.number: + case option.undefined: + default: + store.settings[key] = value; + } + + if (updateProvider) { + provider.set(key, value); + event.pub(SETTINGS_CHANGED); + } + } + + const _reset = () => { + for (let _option of Object.keys(_defaults)) { + const value = _defaults[_option]; + + // delete all sub-options not in defaults + if (_getType(value) === option.object) { + for (let opt of Object.keys(store.settings[_option])) { + const prev = store.settings[_option][opt]; + const isDeleted = delete store.settings[_option][opt]; + log.debug(`User option [${opt}=${prev}] has been deleted (${isDeleted}) from the [${_option}]`); + } + } + + set(_option, value, false); + } + + provider.reset(); + event.pub(SETTINGS_CHANGED); + } + + const remove = (key, subKey) => { + const isRemoved = subKey !== undefined ? delete store.settings[key][subKey] : delete store.settings[key]; + if (!isRemoved) log.warn(`The key: ${key + (subKey ? '.' + subKey : '')} wasn't deleted!`); + provider.remove(key, subKey); + } + + const _render = () => settings._renderrer.render() + + /** + * Settings modal window toggle handler. + * @returns {boolean} True in case if it's opened. + */ + const toggle = () => ui.classList.toggle('modal-visible') && !_render(); + + function _getType(value) { + if (value === undefined) return option.undefined + else if (Array.isArray(value)) return option.list + else if (typeof value === 'object' && value !== null) return option.object + else if (typeof value === 'string') return option.string + else if (typeof value === 'number') return option.number + else return option.undefined; + } + + /** + * File reader submodule (FileReader API). + * + * @type {{read: read}} Tries to read a file. + * @private + */ + const _fileReader = (() => { + let callback_ = () => { + } + + const el = document.createElement('input'); + const reader = new FileReader(); + + el.type = 'file'; + el.accept = '.txt'; + el.onchange = event => event.target.files.length && reader.readAsBinaryString(event.target.files[0]); + reader.onload = event => callback_(event.target.result); + + return { + read: callback => { + callback_ = callback; + el.click(); + }, + } + })(); + + const onFileLoad = text => { + try { + _import(text); + } catch (e) { + log.error(`Couldn't read your settings!`, e); + } + } + + event.sub(SETTINGS_CHANGED, _render); + + // internal init section + closeEl.addEventListener('click', () => { + event.pub(SETTINGS_CLOSED); + // to make sure it's disabled, but it's a tad verbose + event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); + }); + saveEl.addEventListener('click', () => _export()); + loadEl.addEventListener('click', () => _fileReader.read(onFileLoad)); + resetEl.addEventListener('click', () => { + if (window.confirm("Are you sure want to reset your settings?")) { + _reset(); + event.pub(SETTINGS_CHANGED); + } + }); + + return { + init, + loadOr, + getStore, + get, + set, + remove, + import: _import, + export: _export, + ui: { + toggle, + } + } +})(document, event, JSON, localStorage, log, window); + +// hardcoded ui stuff +settings._renderrer = (() => { + // options to ignore (i.e. ignored = {'_version': 1}) + const ignored = {}; + + // the main display data holder element + const data = document.getElementById('settings-data'); + + // a fast way to clear data holder. + const clearData = () => { + while (data.firstChild) data.removeChild(data.firstChild) + }; + + const _option = (holderEl) => { + const wrapperEl = document.createElement('div'); + wrapperEl.classList.add('settings__option'); + + const nameEl = document.createElement('div'); + nameEl.classList.add('settings__option-name'); + wrapperEl.append(nameEl); + + const valueEl = document.createElement('div'); + valueEl.classList.add('settings__option-value'); + wrapperEl.append(valueEl); + + return { + withName: function (name = '') { + nameEl.textContent = name; + return this; + }, + withClass: function (name = '') { + wrapperEl.classList.add(name); + return this; + }, + readOnly: function () { + // reserved + }, + restartNeeded: function () { + nameEl.classList.add('restart-needed-asterisk'); + return this; + }, + add: function (...elements) { + if (elements.length) for (let _el of elements.flat()) valueEl.append(_el); + return this; + }, + build: () => holderEl.append(wrapperEl), + }; + } + + const onKeyChange = (key, oldValue, newValue, handler) => { + + if (newValue !== 'Escape') { + const _settings = settings.get()[opts.INPUT_KEYBOARD_MAP]; + + if (_settings[newValue] !== undefined) { + log.warn(`There are old settings for key: ${_settings[newValue]}, won't change!`); + } else { + settings.remove(opts.INPUT_KEYBOARD_MAP, oldValue); + settings.set(opts.INPUT_KEYBOARD_MAP, {[newValue]: key}); + } + } + + // !to check leaks + if (handler) { + handler.unsub(); + handler = undefined; + } + + event.pub(KEYBOARD_TOGGLE_FILTER_MODE); + event.pub(SETTINGS_CHANGED); + } + + const _keyChangeOverlay = (keyName, oldValue) => { + const wrapperEl = document.createElement('div'); + wrapperEl.classList.add('settings__key-wait'); + wrapperEl.textContent = `Let's choose a ${keyName} key...`; + + let handler = event.sub(KEYBOARD_KEY_PRESSED, button => onKeyChange(keyName, oldValue, button.key, handler)); + + return wrapperEl; + } + + /** + * Handles a normal option change. + * + * @param key The name (id) of an option. + * @param newValue A new value to set. + * @param oldValue An old value to use somehow if needed. + */ + const onChange = (key, newValue, oldValue) => settings.set(key, newValue); + + const onKeyBindingChange = (key, oldValue) => { + clearData(); + data.append(_keyChangeOverlay(key, oldValue)); + event.pub(KEYBOARD_TOGGLE_FILTER_MODE); + } + + const render = function () { + const _settings = settings.getStore(); + + clearData(); + for (let k of Object.keys(_settings).sort()) { + if (ignored[k]) continue; + + const value = _settings[k]; + switch (k) { + case opts._VERSION: + _option(data).withName('Format version').add(value).build(); + break; + case opts.LOG_LEVEL: + _option(data).withName('Log level') + .add(gui.select(k, onChange, { + labels: ['trace', 'debug', 'warning', 'info'], + values: [log.TRACE, log.DEBUG, log.WARN, log.INFO].map(String) + }, value)) + .build(); + break; + case opts.INPUT_KEYBOARD_MAP: + _option(data).withName('Keyboard bindings') + .withClass('keyboard-bindings') + .add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange))) + .build(); + break; + case opts.MIRROR_SCREEN: + _option(data).withName('Video mirroring without smooth') + .add(gui.select(k, onChange, {values: ['mirror']}, value)) + .build(); + break; + default: + _option(data).withName(k).add(value).build(); + } + } + } + + return { + render, + } +})(document, log, opts, settings); diff --git a/web/js/stats.js b/web/js/stats.js deleted file mode 100644 index d8d28974..00000000 --- a/web/js/stats.js +++ /dev/null @@ -1,242 +0,0 @@ -import { - sub, - HELP_OVERLAY_TOGGLED -} from 'event'; - -const _modules = []; -let tempHide = false; - -// internal rendering stuff -const fps = 30; -let time = 0; -let active = false; - -// !to add connection drop notice - -const statsOverlayEl = document.getElementById('stats-overlay'); - -/** - * The graph element. - */ -const graph = (parent, opts = { - historySize: 60, - width: 60 * 2 + 2, - height: 20, - pad: 4, - scale: 1, - style: { - barColor: '#9bd914', - barFallColor: '#c12604' - } -}) => { - const _canvas = document.createElement('canvas'); - const _context = _canvas.getContext('2d'); - - let data = []; - - _canvas.setAttribute('class', 'graph'); - - _canvas.width = opts.width * opts.scale; - _canvas.height = opts.height * opts.scale; - - _context.scale(opts.scale, opts.scale); - _context.imageSmoothingEnabled = false; - _context.fillStyle = opts.fillStyle; - - if (parent) parent.append(_canvas); - - // bar size - const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize); - const barHeight = Math.round(_canvas.height / opts.scale); - - let maxN = 0, - minN = 0; - - const max = () => maxN - - const get = () => _canvas - - const add = (value) => { - if (data.length > opts.historySize) data.shift(); - data.push(value); - render(); - } - - /** - * Draws a bar graph on the canvas. - * - * @example - * +-------+ +-------+ +---------+ - * | | |+---+ | |+---+ | - * | | |||||| | ||||||+---+ - * | | |||||| | ||||||||||| - * +-------+ +----+--+ +---------+ - * [] [3] [3, 2] - */ - const render = () => { - _context.clearRect(0, 0, _canvas.width, _canvas.height); - - maxN = data[0] || 1; - minN = 0; - for (let k = 1; k < data.length; k++) { - if (data[k] > maxN) maxN = data[k]; - if (data[k] < minN) minN = data[k]; - } - - for (let j = 0; j < data.length; j++) { - let x = j * barWidth, - y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad; - - const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor; - - drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color); - } - } - - const drawRect = (x, y, w, h, color = opts.style.barColor) => { - _context.fillStyle = color; - _context.fillRect(x, y, w, h); - } - - const clear = () => { - data = []; - render(); - } - - return {add, get, max, render, clear} -} - -/** - * Get cached module UI. - * - * HTML: - * `
LABEL
VALUE[]
` - * - * @param label The name of the stat to show. - * @param nan A value to show when zero. - * @param withGraph True if to draw a graph. - * @param postfix Supposed to be the name of the stat passed as a function. - * @param cl Class of the UI div element. - * @returns {{el: HTMLDivElement, update: function}} - */ -const moduleUi = (label = '', nan = '', withGraph = false, postfix = () => 'ms', cl = '') => { - const ui = document.createElement('div'), - _label = document.createElement('div'), - _value = document.createElement('span'); - ui.append(_label, _value); - - cl && ui.classList.add(cl) - - let postfix_ = postfix; - - let _graph; - if (withGraph) { - const _container = document.createElement('span'); - ui.append(_container); - _graph = graph(_container); - } - - _label.innerHTML = label; - - const withPostfix = (value) => (postfix_ = value); - - const update = (value) => { - if (_graph) _graph.add(value); - // 203 (333) ms - _value.textContent = `${value < 1 ? nan : value}${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; - } - - const clear = () => { - _graph && _graph.clear(); - } - - return {el: ui, update, withPostfix, clear} -} - -const modules = (fn, force = true) => _modules.forEach(m => (force || m.get) && fn(m)) - -const module = (mod) => { - mod = { - val: 0, - enable: () => ({}), - ...mod, - _disable: function () { - // mod.val = 0; - mod.disable && mod.disable(); - mod.mui && mod.mui.clear(); - }, - ...(mod.mui && { - get: () => mod.mui.el, - render: () => mod.mui.update(mod.val) - }) - } - mod.init?.(); - _modules.push(mod); - modules(m => m.get && statsOverlayEl.append(m.get()), false); -} - -const enable = () => { - active = true; - modules(m => m.enable()) - render(); - draw(); - _show(); -}; - -function draw(timestamp) { - if (!active) return; - - const time_ = time + 1000 / fps; - - if (timestamp > time_) { - time = timestamp; - render(); - } - - requestAnimationFrame(draw); -} - -const disable = () => { - active = false; - modules(m => m._disable()); - _hide(); -} - -const _show = () => (statsOverlayEl.style.visibility = 'visible'); -const _hide = () => (statsOverlayEl.style.visibility = 'hidden'); - -/** - * Handles help overlay toggle event. - * Workaround for a not normal app layout layering. - * - * !to remove when app layering is fixed - * - * @param {Object} overlay Overlay data. - * @param {boolean} overlay.shown A flag if the overlay is being currently showed. - */ -const onHelpOverlayToggle = (overlay) => { - if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) { - _hide(); - tempHide = true; - } else { - if (tempHide) { - _show(); - tempHide = false; - } - } -} - -const render = () => modules(m => m.render(), false); - -sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle) - -/** - * App statistics module. - */ -export const stats = { - toggle: () => active ? disable() : enable(), - set modules(m) { - m && m.forEach(mod => module(mod)) - }, - mui: moduleUi, -} diff --git a/web/js/stats/stats.js b/web/js/stats/stats.js new file mode 100644 index 00000000..c71b96e7 --- /dev/null +++ b/web/js/stats/stats.js @@ -0,0 +1,433 @@ +/** + * App statistics module. + * + * Events: + * <- STATS_TOGGLE + * <- HELP_OVERLAY_TOGGLED + * + * @version 1 + */ +const stats = (() => { + const _modules = []; + let tempHide = false; + + // internal rendering stuff + const fps = 30; + let time = 0; + let active = false; + + // !to add connection drop notice + + const statsOverlayEl = document.getElementById('stats-overlay'); + + /** + * The graph element. + */ + const graph = (parent, opts = { + historySize: 60, + width: 60 * 2 + 2, + height: 20, + pad: 4, + scale: 1, + style: { + barColor: '#9bd914', + barFallColor: '#c12604' + } + }) => { + const _canvas = document.createElement('canvas'); + const _context = _canvas.getContext('2d'); + + let data = []; + + _canvas.setAttribute('class', 'graph'); + + _canvas.width = opts.width * opts.scale; + _canvas.height = opts.height * opts.scale; + + _context.scale(opts.scale, opts.scale); + _context.imageSmoothingEnabled = false; + _context.fillStyle = opts.fillStyle; + + if (parent) parent.append(_canvas); + + // bar size + const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize); + const barHeight = Math.round(_canvas.height / opts.scale); + + let maxN = 0, + minN = 0; + + const max = () => maxN + + const get = () => _canvas + + const add = (value) => { + if (data.length > opts.historySize) data.shift(); + data.push(value); + render(); + } + + /** + * Draws a bar graph on the canvas. + */ + const render = () => { + // 0,0 w,0 0,0 w,0 0,0 w,0 + // +-------+ +-------+ +---------+ + // | | |+---+ | |+---+ | + // | | |||||| | ||||||+---+ + // | | |||||| | ||||||||||| + // +-------+ +----+--+ +---------+ + // 0,h w,h 0,h w,h 0,h w,h + // [] [3] [3, 2] + // + + _context.clearRect(0, 0, _canvas.width, _canvas.height); + + maxN = data[0] || 1; + minN = 0; + for (let k = 1; k < data.length; k++) { + if (data[k] > maxN) maxN = data[k]; + if (data[k] < minN) minN = data[k]; + } + + for (let j = 0; j < data.length; j++) { + let x = j * barWidth, + y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad; + + const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor; + + drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color); + } + } + + const drawRect = (x, y, w, h, color = opts.style.barColor) => { + _context.fillStyle = color; + _context.fillRect(x, y, w, h); + } + + return {add, get, max, render} + } + + /** + * Get cached module UI. + * + * HTML: + *
LABEL
VALUE[]
+ * + * @param label The name of the stat to show. + * @param withGraph True if to draw a graph. + * @param postfix Supposed to be the name of the stat passed as a function. + * @returns {{el: HTMLDivElement, update: function}} + */ + const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { + const ui = document.createElement('div'), + _label = document.createElement('div'), + _value = document.createElement('span'); + ui.append(_label, _value); + + let postfix_ = postfix; + + let _graph; + if (withGraph) { + const _container = document.createElement('span'); + ui.append(_container); + _graph = graph(_container); + } + + _label.innerHTML = label; + + const withPostfix = (value) => postfix_ = value; + + const update = (value) => { + if (_graph) _graph.add(value); + // 203 (333) ms + _value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; + } + + return {el: ui, update, withPostfix} + } + + /** + * Latency stats submodule. + * + * Accumulates the simple rolling mean value + * between the next server request and following server response values. + * + * window + * _____________ + * | | + * [1, 1, 3, 4, 1, 4, 3, 1, 2, 1, 1, 1, 2, ... n] + * | + * stats_snapshot_period + * mean = round(next - mean / length % window) + * + * Events: + * <- PING_RESPONSE + * <- PING_REQUEST + * + * ?Interface: + * HTMLElement get() + * void enable() + * void disable() + * void render() + * + * @version 1 + */ + const latency = (() => { + let listeners = []; + + let mean = 0; + let length = 0; + let previous = 0; + const window = 5; + + const ui = moduleUi('Ping(c)', true); + + const onPingRequest = (data) => previous = data.time; + + const onPingResponse = () => { + length++; + const delta = Date.now() - previous; + mean += Math.round((delta - mean) / length); + + if (length % window === 0) { + length = 1; + mean = delta; + } + } + + const enable = () => { + listeners.push( + event.sub(PING_RESPONSE, onPingResponse), + event.sub(PING_REQUEST, onPingRequest) + ); + } + + const disable = () => { + while (listeners.length) listeners.shift().unsub(); + } + + const render = () => ui.update(mean); + + const get = () => ui.el; + + return {get, enable, disable, render} + })(event, moduleUi); + + /** + * User agent memory stats. + * + * ?Interface: + * HTMLElement get() + * void enable() + * void disable() + * void render() + * + * @version 1 + */ + const clientMemory = (() => { + let active = false; + + const measures = ['B', 'KB', 'MB', 'GB']; + const precision = 1; + let mLog = 0; + + const ui = moduleUi('Memory', false, (x) => (x > 0) ? measures[mLog] : ''); + + const get = () => ui.el; + + const enable = () => { + active = true; + render(); + } + + const disable = () => active = false; + + const render = () => { + if (!active) return; + + const m = performance.memory.usedJSHeapSize; + let newValue = 'N/A'; + + if (m > 0) { + mLog = Math.floor(Math.log(m) / Math.log(1000)); + newValue = Math.round(m * precision / Math.pow(1000, mLog)) / precision; + } + + ui.update(newValue); + } + + if (window.performance && !performance.memory) performance.memory = {usedJSHeapSize: 0, totalJSHeapSize: 0}; + + return {get, enable, disable, render} + })(moduleUi, performance, window); + + + const webRTCStats_ = (() => { + let interval = null + + function getStats() { + if (!webrtc.isConnected()) return; + webrtc.getConnection().getStats(null).then(stats => { + let frameStatValue = '?'; + stats.forEach(report => { + if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) { + frameStatValue = report["framesReceived"] - report["framesDecoded"] - report["framesDropped"]; + event.pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) + } else if (report["framerateMean"] !== undefined) { + frameStatValue = Math.round(report["framerateMean"] * 100) / 100; + event.pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) + } + + if (report["nominated"] && report["currentRoundTripTime"] !== undefined) { + event.pub('STATS_WEBRTC_ICE_RTT', report["currentRoundTripTime"] * 1000); + } + }); + }); + } + + const enable = () => { + interval = window.setInterval(getStats, 1000); + } + + const disable = () => window.clearInterval(interval); + + return {enable, disable, internal: true} + })(event, webrtc, window); + + /** + * User agent frame stats. + * + * ?Interface: + * HTMLElement get() + * void enable() + * void disable() + * void render() + * + * @version 1 + */ + const webRTCFrameStats = (() => { + let value = 0; + let listener; + + const label = env.getBrowser() === 'firefox' ? 'FramerateMean' : 'FrameDelay'; + const ui = moduleUi(label, false, () => ''); + + const get = () => ui.el; + + const enable = () => { + listener = event.sub('STATS_WEBRTC_FRAME_STATS', onStats); + } + + const disable = () => { + value = 0; + if (listener) listener.unsub(); + } + + const render = () => ui.update(value); + + function onStats(val) { + value = val; + } + + return {get, enable, disable, render} + })(env, event, moduleUi); + + const webRTCRttStats = (() => { + let value = 0; + let listener; + + const ui = moduleUi('RTT', true, () => 'ms'); + + const get = () => ui.el; + + const enable = () => { + listener = event.sub('STATS_WEBRTC_ICE_RTT', onStats); + } + + const disable = () => { + value = 0; + if (listener) listener.unsub(); + } + + const render = () => ui.update(value); + + function onStats(val) { + value = val; + } + + return {get, enable, disable, render} + })(event, moduleUi); + + const modules = (fn, force = true) => _modules.forEach(m => (force || !m.internal) && fn(m)) + + const enable = () => { + active = true; + modules(m => m.enable()) + render(); + draw(); + _show(); + }; + + function draw(timestamp) { + if (!active) return; + + const time_ = time + 1000 / fps; + + if (timestamp > time_) { + time = timestamp; + render(); + } + + requestAnimationFrame(draw); + } + + const disable = () => { + active = false; + modules(m => m.disable()); + _hide(); + } + + const _show = () => statsOverlayEl.style.visibility = 'visible'; + const _hide = () => statsOverlayEl.style.visibility = 'hidden'; + + const onToggle = () => active ? disable() : enable(); + + /** + * Handles help overlay toggle event. + * Workaround for a not normal app layout layering. + * + * !to remove when app layering is fixed + * + * @param {Object} overlay Overlay data. + * @param {boolean} overlay.shown A flag if the overlay is being currently showed. + */ + const onHelpOverlayToggle = (overlay) => { + if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) { + _hide(); + tempHide = true; + } else { + if (tempHide) { + _show(); + tempHide = false; + } + } + } + + const render = () => modules(m => m.render(), false); + + // add submodules + _modules.push( + webRTCRttStats, + // latency, + clientMemory, + webRTCStats_, + webRTCFrameStats + ); + modules(m => statsOverlayEl.append(m.get()), false); + + event.sub(STATS_TOGGLE, onToggle); + event.sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle) + + return {enable, disable} +})(document, env, event, log, webrtc, window); diff --git a/web/js/stream.js b/web/js/stream.js deleted file mode 100644 index 01718e2a..00000000 --- a/web/js/stream.js +++ /dev/null @@ -1,227 +0,0 @@ -import { - sub, - APP_VIDEO_CHANGED, - SETTINGS_CHANGED, - TRANSFORM_CHANGE -} from 'event'; -import {log} from 'log'; -import {opts, settings} from 'settings'; - -const videoEl = document.getElementById('stream') -const mirrorEl = document.getElementById('mirror-stream') -const playEl = document.getElementById('play-stream') - -const options = { - volume: 0.5, - poster: '/img/screen_loading.gif', - mirrorMode: null, - mirrorUpdateRate: 1 / 60, -} - -const state = { - screen: videoEl, - timerId: null, - w: 0, - h: 0, - aspect: 4 / 3, - fit: 'contain', - ready: false, - autoplayWait: false -} - -const mute = (mute) => (videoEl.muted = mute) - -const onPlay = () => { - state.ready = true - videoEl.poster = '' - resize(state.w, state.h, state.aspect, state.fit) - useCustomScreen(options.mirrorMode === 'mirror') -} - -const play = () => { - const promise = videoEl.play() - - if (promise === undefined) { - log.error('oh no, the video is not a promise!') - return - } - - promise - .then(onPlay) - .catch(error => { - if (error.name === 'NotAllowedError') { - showPlayButton() - } else { - log.error('Playback fail', error) - } - }) -} - -const toggle = (show) => state.screen.toggleAttribute('hidden', show === undefined ? show : !show) - -const resize = (w, h, aspect, fit) => { - if (!state.ready) return; - - state.screen.setAttribute('width', '' + w) - state.screen.setAttribute('height', '' + h) - aspect !== undefined && (state.screen.style.aspectRatio = '' + aspect) - fit !== undefined && (state.screen.style['object-fit'] = fit) -} - -const showPlayButton = () => { - state.autoplayWait = true - toggle() - playEl.removeAttribute('hidden') -} - -playEl.addEventListener('click', () => { - playEl.setAttribute('hidden', "") - state.autoplayWait = false - play() - toggle() -}) - -// Track resize even when the underlying media stream changes its video size -videoEl.addEventListener('resize', () => { - recalculateSize() - if (state.screen === videoEl) return - resize(videoEl.videoWidth, videoEl.videoHeight) -}) - -videoEl.addEventListener('loadstart', () => { - videoEl.volume = options.volume / 100 - videoEl.poster = options.poster -}) - -videoEl.onfocus = () => videoEl.blur() -videoEl.onerror = (e) => log.error('Playback error', e) - -const onFullscreen = (fullscreen) => { - const el = document.fullscreenElement - - if (fullscreen) { - // timeout is due to a chrome bug - setTimeout(() => { - // aspect ratio calc - const w = window.screen.width ?? window.innerWidth - const hh = el.innerHeight || el.clientHeight || 0 - const dw = (w - hh * state.aspect) / 2 - state.screen.style.padding = `0 ${dw}px` - state.screen.classList.toggle('with-footer') - }, 1) - } else { - state.screen.style.padding = '0' - state.screen.classList.toggle('with-footer') - } - - if (el === videoEl) { - videoEl.classList.toggle('no-media-controls', !fullscreen) - videoEl.blur() - } -} - -const vs = {w: 1, h: 1} - -const recalculateSize = () => { - const fullscreen = document.fullscreenElement !== null - const {aspect, screen} = state - - let width, height - if (fullscreen) { - // we can't get the real