Compare commits

..

No commits in common. "master" and "v3.0.5" have entirely different histories.

267 changed files with 15554 additions and 22583 deletions

View file

@ -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/

View file

@ -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@v3
- uses: actions/setup-go@v5
- uses: actions/setup-go@v4
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@v3
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@v3
- run: docker build --build-arg VERSION=$(./scripts/version.sh) .

View file

@ -4,10 +4,9 @@ coordinator:
debug: true
server:
address:
frameOptions: SAMEORIGIN
https: true
tls:
domain: cloudretro.io
domain: usw.cloudretro.io
analytics:
inject: true
gtag: UA-145078282-1
@ -15,26 +14,11 @@ coordinator:
worker:
debug: true
network:
coordinatorAddress: cloudretro.io
publicAddress: cloudretro.io
coordinatorAddress: usw.cloudretro.io
publicAddress: usw.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
domain: usw.cloudretro.io

View file

@ -1,5 +1,5 @@
COORDINATORS="138.68.48.200"
DOCKER_IMAGE_TAG=master
DOCKER_IMAGE_TAG=dev
#DO_ADDRESS_LIST="cloud-gaming cloud-gaming-eu cloud-gaming-usw"
#SPLIT_HOSTS=1
USER=root

View file

@ -72,6 +72,7 @@ WORKERS=${WORKERS:-4}
USER=${USER:-root}
compose_src=$(cat $LOCAL_WORK_DIR/docker-compose.yml)
config_src=$(cat $LOCAL_WORK_DIR/configs/config.yaml)
function remote_run_commands() {
ret=""

View file

@ -1,93 +1,38 @@
x-params: &default-params
image: ghcr.io/giongto35/cloud-game/cloud-game:${IMAGE_TAG:-master}
version: "3.9"
x-params:
&default-params
image: ghcr.io/giongto35/cloud-game/cloud-game:${IMAGE_TAG:-dev}
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
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
depends_on:
- coordinator
deploy:
mode: replicated
replicas: 4
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
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}/libretro:/usr/local/share/cloud-game/libretro
- ${APP_DIR:-/cloud-game}/home:/root/.cr

View file

@ -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) .

View file

@ -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

View file

@ -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@v3
- 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

View file

@ -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@v3
- 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

View file

@ -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.

View file

@ -1,101 +1,65 @@
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 ubuntu:lunar 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.2.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 ubuntu:lunar
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
RUN mkdir -p ./assets/cache && \
mkdir -p ./assets/cores && \
mkdir -p ./assets/games && \
mkdir -p ./libretro && \
mkdir -p /root/.cr
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

View file

@ -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.

View file

@ -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.
<p align="center">
<img width="420" height="300" src="docs/img/multiplatform.png"> <br>
Synchronize a game session on multiple devices
</p>
## 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))**

Binary file not shown.

View file

@ -1,2 +0,0 @@
[autoexec]
ROGUE.EXE

Binary file not shown.

View file

@ -1,7 +1,7 @@
package main
import (
"github.com/giongto35/cloud-game/v3/pkg/config"
config "github.com/giongto35/cloud-game/v3/pkg/config/coordinator"
"github.com/giongto35/cloud-game/v3/pkg/coordinator"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/os"
@ -10,23 +10,20 @@ import (
var Version = "?"
func main() {
conf, paths := config.NewCoordinatorConfig()
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")
}
}

Binary file not shown.

View file

@ -3,7 +3,7 @@ package main
import (
"time"
"github.com/giongto35/cloud-game/v3/pkg/config"
config "github.com/giongto35/cloud-game/v3/pkg/config/worker"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/os"
"github.com/giongto35/cloud-game/v3/pkg/worker"
@ -13,28 +13,26 @@ import (
var Version = "?"
func run() {
conf, paths := config.NewWorkerConfig()
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)
}

View file

@ -1,44 +1,10 @@
# 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.
# Application configuration file
#
# 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
@ -48,6 +14,22 @@ coordinator:
# - empty value (default, any free)
# - ping (with the lowest ping)
selector:
# 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
@ -61,13 +43,9 @@ coordinator:
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:
@ -85,9 +63,6 @@ coordinator:
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
@ -123,9 +98,20 @@ worker:
tag:
emulator:
# set output viewport scale factor
scale: 1
# set the total number of threads for the image processing
# (removed)
threads: 0
# (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
@ -137,23 +123,9 @@ emulator:
# 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:
@ -169,32 +141,6 @@ emulator:
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
@ -203,7 +149,7 @@ emulator:
# 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
url: https://github.com/sergystepanov/libretro-spiegel/blob/main
compression: zip
# Libretro core configuration
#
@ -216,7 +162,6 @@ emulator:
# - 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,
@ -226,15 +171,7 @@ emulator:
# - 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
# - hasMultitap (bool)
# - vfr (bool)
# (experimental)
# Enable variable frame rate only for cores that can't produce a constant frame rate.
@ -242,26 +179,11 @@ emulator:
# 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.
# - options ([]string) a list of Libretro core options for tweaking
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" ]
@ -269,28 +191,20 @@ emulator:
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
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
hasMultitap: true
n64:
lib: mupen64plus_next_libretro
roms: [ "n64", "v64", "z64" ]
@ -299,67 +213,37 @@ emulator:
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"
mupen64plus-169screensize: 640x360
mupen64plus-43screensize: 320x240
mupen64plus-EnableCopyColorToRDRAM: Off
mupen64plus-EnableCopyDepthToRDRAM: Off
mupen64plus-EnableEnhancedTextureStorage: True
mupen64plus-EnableFBEmulation: True
mupen64plus-EnableLegacyBlending: True
mupen64plus-FrameDuping: False
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
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
# h264, vpx (VP8)
codec: h264
# Threaded encoder if supported, 0 - auto, 1 - nope, >1 - multi-threaded
threads: 0
# concurrent execution units (0 - disabled)
concurrency: 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
@ -383,6 +267,12 @@ encoder:
# 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
@ -396,24 +286,19 @@ recording:
# 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)
# - oracle [Oracle Object Storage](https://www.oracle.com/cloud/storage/object-storage.html)
provider:
s3Endpoint:
s3BucketName:
s3AccessKeyId:
s3SecretAccessKey:
# 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: false
disableDefaultInterceptors: true
# indicates the role of the DTLS transport (see: https://github.com/pion/webrtc/blob/master/dtlsrole.go)
# (debug)
# - (default)

View file

@ -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:

25
docs/DESIGNv2.md Normal file
View file

@ -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.

BIN
docs/img/coordinator.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/img/multiplatform.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

BIN
docs/img/overview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

87
go.mod
View file

@ -1,62 +1,49 @@
module github.com/giongto35/cloud-game/v3
go 1.25
go 1.20
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.2
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.59
github.com/rs/xid v1.4.0
github.com/rs/zerolog v1.29.0
github.com/veandco/go-sdl2 v0.4.33
golang.org/x/crypto v0.7.0
golang.org/x/image v0.6.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.18 // 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.6 // indirect
github.com/pion/ice/v2 v2.3.2 // 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.12 // indirect
github.com/pion/stun v0.4.0 // indirect
github.com/pion/transport/v2 v2.0.2 // indirect
github.com/pion/turn/v2 v2.1.0 // indirect
github.com/pion/udp/v2 v2.0.1 // 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.8.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.8.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

317
go.sum
View file

@ -1,133 +1,226 @@
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.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/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.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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.2.6 h1:yXMxKr0Skd+Ub6A8UqXTRLSywskx93ooMRHsQUtd+Z4=
github.com/pion/dtls/v2 v2.2.6/go.mod h1:t8fWJCIquY5rlQZwA2yWxUS1+OCrAdXrhVKXB5oD/wY=
github.com/pion/ice/v2 v2.3.2 h1:vh+fi4RkZ8H5fB4brZ/jm3j4BqFgMmNs+aB3X52Hu7M=
github.com/pion/ice/v2 v2.3.2/go.mod h1:AMIpuJqcpe+UwloocNebmTSWhCZM1TUCo9v7nW50jX0=
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.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.12 h1:WrmiVCubGMOAObBU1vwWjG0H3VSyQHawKeer2PVA5rY=
github.com/pion/srtp/v2 v2.0.12/go.mod h1:C3Ep44hlOo2qEYaq4ddsmK5dL63eLehXFbHaZ9F5V9Y=
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.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/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
github.com/pion/transport/v2 v2.0.2 h1:St+8o+1PEzPT51O9bv+tH/KYYLMNR5Vwm5Z3Qkjsywg=
github.com/pion/transport/v2 v2.0.2/go.mod h1:vrz6bUbFr/cjdwbnxq8OdDDzHf7JJfGsIRkxfpZoTA0=
github.com/pion/turn/v2 v2.1.0 h1:5wGHSgGhJhP/RpabkUb/T9PdsAjkGLS6toYz5HNzoSI=
github.com/pion/turn/v2 v2.1.0/go.mod h1:yrT5XbXSGX1VFSF31A3c1kCNB5bBZgk/uu5LET162qs=
github.com/pion/udp/v2 v2.0.1 h1:xP0z6WNux1zWEjhC7onRA3EwwSliXqu1ElUZAQhUP54=
github.com/pion/udp/v2 v2.0.1/go.mod h1:B7uvTMP00lzWdyMr/1PVZXtV3wpPIxBRd4Wl6AksXn8=
github.com/pion/webrtc/v3 v3.1.59 h1:B3YFo8q6dwBYKA2LUjWRChP59Qtt+xvv1Ul7UPDp6Zc=
github.com/pion/webrtc/v3 v3.1.59/go.mod h1:rJGgStRoFyFOWJULHLayaimsG+jIEoenhJ5MB5gIFqw=
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.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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/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.33 h1:cxQ0OdUBEByHxvCyrGxy9F8WpL38Ya6hzV4n27QL84M=
github.com/veandco/go-sdl2 v0.4.33/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.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4=
golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0=
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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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-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-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/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
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/sync v0.1.0/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-20220520151302-bc2c85ada10a/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.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/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.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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=

View file

@ -19,22 +19,21 @@ package api
import (
"encoding/json"
"fmt"
"strings"
)
type (
Id interface {
String() string
}
Stateful struct {
Id string `json:"id"`
Stateful[T Id] struct {
Id T `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"`
StatefulRoom[T Id] struct {
Stateful[T]
Room
}
PT uint8
)
@ -63,9 +62,8 @@ func (o *Out) GetPayload() any { return o.Payload }
// 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 +72,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 +109,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"
}
@ -157,21 +143,6 @@ var (
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 {
@ -186,17 +157,3 @@ 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], ""
}
return p[0], p[1]
}

View file

@ -36,7 +36,6 @@ type Server struct {
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"`
}

View file

@ -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,9 @@ 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
)

View file

@ -1,70 +1,61 @@
package api
type (
ChangePlayerRequest struct {
StatefulRoom
ChangePlayerRequest[T Id] struct {
StatefulRoom[T]
Index int `json:"index"`
}
ChangePlayerResponse int
GameQuitRequest StatefulRoom
LoadGameRequest StatefulRoom
LoadGameResponse string
ResetGameRequest StatefulRoom
ResetGameResponse string
SaveGameRequest StatefulRoom
SaveGameResponse string
StartGameRequest struct {
StatefulRoom
ChangePlayerResponse int
GameQuitRequest[T Id] struct {
StatefulRoom[T]
}
LoadGameRequest[T Id] struct {
StatefulRoom[T]
}
LoadGameResponse string
SaveGameRequest[T Id] struct {
StatefulRoom[T]
}
SaveGameResponse string
StartGameRequest[T Id] struct {
StatefulRoom[T]
Record bool
RecordUser string
Game string `json:"game"`
PlayerIndex int `json:"player_index"`
Game GameInfo `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"`
Name string `json:"name"`
Base string `json:"base"`
Path string `json:"path"`
Type string `json:"type"`
}
StartGameResponse struct {
Room
AV *AppVideoInfo `json:"av"`
Record bool `json:"record"`
KbMouse bool `json:"kb_mouse"`
Record bool
}
RecordGameRequest struct {
StatefulRoom
RecordGameRequest[T Id] struct {
StatefulRoom[T]
Active bool `json:"active"`
User string `json:"user"`
}
RecordGameResponse string
TerminateSessionRequest Stateful
WebrtcAnswerRequest struct {
Stateful
RecordGameResponse string
TerminateSessionRequest[T Id] struct {
Stateful[T]
}
ToggleMultitapRequest[T Id] struct {
StatefulRoom[T]
}
WebrtcAnswerRequest[T Id] struct {
Stateful[T]
Sdp string `json:"sdp"`
}
WebrtcIceCandidateRequest struct {
Stateful
WebrtcIceCandidateRequest[T Id] struct {
Stateful[T]
Candidate string `json:"candidate"` // Base64-encoded ICE candidate
}
WebrtcInitRequest Stateful
WebrtcInitRequest[T Id] struct {
Stateful[T]
}
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
}
)

View file

@ -2,34 +2,18 @@ package com
import "github.com/giongto35/cloud-game/v3/pkg/logger"
type stringer interface {
comparable
String() string
}
type NetClient[K stringer] interface {
type NetClient interface {
Disconnect()
Id() K
Id() Uid
}
type NetMap[K stringer, T NetClient[K]] struct{ Map[K, T] }
type NetMap[T NetClient] struct{ Map[Uid, 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 NewNetMap[T NetClient]() NetMap[T] { return NetMap[T]{Map: Map[Uid, T]{m: make(map[Uid]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
}
func (m *NetMap[T]) Add(client T) { m.Put(client.Id(), client) }
func (m *NetMap[T]) Remove(client T) { m.Map.Remove(client.Id()) }
func (m *NetMap[T]) RemoveDisconnect(client T) { client.Disconnect(); m.Remove(client) }
type SocketClient[T ~uint8, P Packet[T], X any, P2 Packet2[X]] struct {
id Uid
@ -66,10 +50,6 @@ func (c *SocketClient[T, P, _, _]) ProcessPackets(fn func(in P) error) chan stru
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()

View file

@ -1,127 +1,53 @@
package com
import (
"fmt"
"iter"
"sync"
)
import "sync"
// 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
mu sync.Mutex
}
func (m *Map[K, _]) Len() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.m)
}
func (m *Map[K, _]) Has(key K) bool {
m.mu.RLock()
_, ok := m.m[key]
m.mu.RUnlock()
return ok
}
// 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
}
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 {
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
}
func (m *Map[K, V]) Remove(key K) {
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, _]) Has(key K) bool { _, ok := m.Find(key); return ok }
func (m *Map[_, _]) Len() int { m.mu.Lock(); defer m.mu.Unlock(); return len(m.m) }
func (m *Map[K, V]) Pop(key K) V {
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()
v := m.m[key]
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()
return v
}
func (m *Map[K, V]) Put(key K, v V) { m.mu.Lock(); m.m[key] = v; m.mu.Unlock() }
func (m *Map[K, _]) Remove(key K) { m.mu.Lock(); delete(m.m, key); m.mu.Unlock() }
// Find returns the first value found and a boolean flag if its found or not.
func (m *Map[K, V]) Find(key K) (v V, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
if vv, ok := m.m[key]; ok {
return vv, true
}
return v, false
}
// 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
}
// FindBy searches the first key-value with the provided predicate function.
func (m *Map[K, V]) FindBy(fn func(v V) bool) (v V, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, vv := range m.m {
if fn(vv) {
return vv, true
}
}
return v, false
}
// ForEach processes every element with the provided callback function.
func (m *Map[K, V]) ForEach(fn func(v V)) {
m.mu.Lock()
defer m.mu.Unlock()
for _, v := range m.m {
fn(v)
}
}

View file

@ -17,11 +17,11 @@ func TestMap_Base(t *testing.T) {
if !m.Has(k) {
t.Errorf("should have the key %v, %v", k, m.m)
}
v, ok := m.Get(k)
v, ok := m.Find(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)
v, ok = m.Find(k + 1)
if ok {
t.Errorf("should not find anything, %v %v", ok, m.m)
}
@ -31,9 +31,7 @@ func TestMap_Base(t *testing.T) {
t.Errorf("should have the key %v and ok, %v %v", 1, ok, m.m)
}
sum := 0
for v := range m.Values() {
sum += v
}
m.ForEach(func(v int) { sum += v })
if sum != 1 {
t.Errorf("shoud have exact sum of 1, but have %v", sum)
}
@ -55,7 +53,8 @@ func TestMap_Base(t *testing.T) {
func TestMap_Concurrency(t *testing.T) {
m := Map[int, int]{m: make(map[int]int)}
for i := range 100 {
for i := 0; i < 100; i++ {
i := i
go m.Put(i, i)
go m.Has(i)
go m.Pop(i)

View file

@ -71,7 +71,7 @@ type request struct {
response []byte
}
const DefaultCallTimeout = 10 * time.Second
const DefaultCallTimeout = 7 * time.Second
var errCanceled = errors.New("canceled")
var errTimeout = errors.New("timeout")
@ -96,9 +96,7 @@ func (s *Server) Connect(w http.ResponseWriter, r *http.Request) (*Connection, e
return connect(s.Server.Connect(w, r, nil))
}
func (c *Connection) IsServer() bool { return c.conn.IsServer() }
func (c *Connection) SetMaxReadSize(s int64) { c.conn.SetMaxMessageSize(s) }
func (c Connection) IsServer() bool { return c.conn.IsServer() }
func connect(conn *websocket.Connection, err error) (*Connection, error) {
if err != nil {
@ -169,10 +167,10 @@ func (t *RPC[_, _]) callTimeout() time.Duration {
func (t *RPC[_, _]) Cleanup() {
// drain cancels all what's left in the task queue.
for task := range t.calls.Values() {
t.calls.ForEach(func(task *request) {
if task.err == nil {
task.err = errCanceled
}
close(task.done)
}
})
}

View file

@ -3,8 +3,7 @@ package com
import (
"encoding/json"
"fmt"
"math/rand/v2"
"net"
"math/rand"
"net/http"
"net/url"
"sync"
@ -50,15 +49,8 @@ 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)
server := newServer(addr, t)
client := newClient(t, url.URL{Scheme: "ws", Host: "localhost" + addr, Path: "/ws"})
server := newServer(t)
client := newClient(t, url.URL{Scheme: "ws", Host: "localhost:8080", Path: "/ws"})
clDone := client.ProcessPackets(func(in TestIn) error { return nil })
if server.conn == nil {
@ -88,12 +80,14 @@ func testWebsocket(t *testing.T) {
// test
for _, call := range calls {
call := call
if call.concurrent {
for range n {
rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < n; i++ {
packet := call.packet
go func() {
defer wait.Done()
time.Sleep(time.Duration(rand.IntN(200-100)+100) * time.Millisecond)
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 {
@ -103,7 +97,7 @@ func testWebsocket(t *testing.T) {
}()
}
} else {
for range n {
for i := 0; i < n; i++ {
packet := call.packet
vv, err := client.rpc.Call(client.sock.conn, &packet)
err = checkCall(vv, err, call.value)
@ -196,30 +190,18 @@ func (s *serverHandler) serve(t *testing.T) func(w http.ResponseWriter, r *http.
}
}
func newServer(addr string, t *testing.T) *serverHandler {
func newServer(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)
if err := http.ListenAndServe(":8080", nil); err != nil {
t.Errorf("no server")
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
}

View file

@ -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()
}

View file

@ -0,0 +1,60 @@
package coordinator
import (
"flag"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/config/emulator"
"github.com/giongto35/cloud-game/v3/pkg/config/monitoring"
"github.com/giongto35/cloud-game/v3/pkg/config/shared"
"github.com/giongto35/cloud-game/v3/pkg/config/webrtc"
"github.com/giongto35/cloud-game/v3/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 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()
}

View file

@ -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
}

View file

@ -0,0 +1,129 @@
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
}
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 {
AltRepo bool
AutoGlContext bool // hack: keep it here to pass it down the emulator
Folder string
HasMultitap bool
Height int
IsGlAllowed bool
Lib string
Options map[string]string
Roms []string
UsesLibCo bool
VFR bool
Width int
}
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)
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
}

View file

@ -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 {

View file

@ -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
}
}

View file

@ -1,124 +1,21 @@
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...)
// 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")
}
homeDir := ""
@ -127,37 +24,18 @@ func LoadConfig(config any, path string) (loaded []string, err error) {
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)
}
if err := fig.Load(config, fig.Dirs(dirs...), fig.UseEnv(EnvPrefix)); err != nil {
return err
}
env := Env(EnvPrefix)
if err := k.Load(&env, nil); err != nil {
return loaded, err
// override from /home
if homeDir != "" {
_ = fig.Load(config, fig.Dirs(homeDir))
}
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))
}

View file

@ -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))
}
})
}
}

View file

@ -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 }

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,6 @@
package storage
type Storage struct {
Provider string
Key string
}

View file

@ -1,4 +1,11 @@
package config
package webrtc
import (
"log"
"strings"
"github.com/giongto35/cloud-game/v3/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
}
}
}

View file

@ -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/config"
"github.com/giongto35/cloud-game/v3/pkg/config/emulator"
"github.com/giongto35/cloud-game/v3/pkg/config/encoder"
"github.com/giongto35/cloud-game/v3/pkg/config/monitoring"
"github.com/giongto35/cloud-game/v3/pkg/config/shared"
"github.com/giongto35/cloud-game/v3/pkg/config/storage"
"github.com/giongto35/cloud-game/v3/pkg/config/webrtc"
"github.com/giongto35/cloud-game/v3/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{}
}
}

View file

@ -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/config/coordinator"
"github.com/giongto35/cloud-game/v3/pkg/config/shared"
"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/httpx"
"github.com/giongto35/cloud-game/v3/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)
})
}

View file

@ -9,7 +9,8 @@ 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/config/coordinator"
"github.com/giongto35/cloud-game/v3/pkg/games"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
@ -23,18 +24,20 @@ type Connection interface {
}
type Hub struct {
conf config.CoordinatorConfig
log *logger.Logger
users com.NetMap[com.Uid, *User]
workers com.NetMap[com.Uid, *Worker]
conf coordinator.Config
launcher games.Launcher
log *logger.Logger
users com.NetMap[*User]
workers com.NetMap[*Worker]
}
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,
}
}
@ -59,30 +62,16 @@ func (h *Hub) handleUserConnection() http.HandlerFunc {
user := NewUser(conn, log)
defer h.users.RemoveDisconnect(user)
done := user.HandleRequests(h, h.conf)
done := user.HandleRequests(h, h.launcher, 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
user.Bind(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)
user.InitSession(worker.Id().String(), h.conf.Webrtc.IceServers, h.launcher.GetAppNames())
log.Info().Str(logger.DirectionField, logger.MarkPlus).Msgf("user %s", user.Id())
<-done
}
@ -94,7 +83,7 @@ func RequestToHandshake(data string) (*api.ConnectionRequest[com.Uid], error) {
}
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)
return nil, fmt.Errorf("%v (%v)", err, handshake)
}
return handshake, nil
}
@ -109,8 +98,6 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc {
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)
@ -138,7 +125,6 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc {
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)
@ -152,9 +138,8 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc {
}
func (h *Hub) GetServerList() (r []api.Server) {
debug := h.conf.Coordinator.Debug
for w := range h.workers.Values() {
server := api.Server{
h.workers.ForEach(func(w *Worker) {
r = append(r, api.Server{
Addr: w.Addr,
Id: w.Id(),
IsBusy: !w.HasSlot(),
@ -163,12 +148,8 @@ func (h *Hub) GetServerList() (r []api.Server) {
Port: w.Port,
Tag: w.Tag,
Zone: w.Zone,
}
if debug {
server.Room = w.RoomId
}
r = append(r, server)
}
})
})
return
}
@ -176,33 +157,18 @@ func (h *Hub) GetServerList() (r []api.Server) {
// 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
}
roomId := q.Get(api.RoomIdQueryParam)
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 {
if worker = h.findWorkerByRoom(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 if worker = h.findWorkerById(wid, h.conf.Coordinator.Debug); worker != nil {
log.Debug().Msgf("Worker with id: %v has been found", wid)
} else {
switch h.conf.Coordinator.Selector {
case config.SelectByPing:
case coordinator.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 {
@ -218,40 +184,23 @@ func (h *Hub) findWorkerFor(usr *User, q url.Values, log *logger.Logger) *Worker
return worker
}
func (h *Hub) findWorkerByPreviousRoom(id string) *Worker {
func (h *Hub) findWorkerByRoom(id string, region 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)
})
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
for w := range h.workers.Values() {
h.workers.ForEach(func(w *Worker) {
if w.HasSlot() && w.In(region) {
workers = append(workers, w)
}
}
})
return workers
}

View file

@ -3,7 +3,8 @@ 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/config/coordinator"
"github.com/giongto35/cloud-game/v3/pkg/games"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
@ -18,7 +19,7 @@ type HasServerInfo interface {
}
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)
conn := com.NewConnection[api.PT, api.In[com.Uid], api.Out](sock, com.NewUid(), log)
return &User{
Connection: conn,
log: log.Extend(log.With().
@ -28,54 +29,77 @@ func NewUser(sock *com.Connection, log *logger.Logger) *User {
}
}
func (u *User) Bind(w *Worker) bool {
func (u *User) Bind(w *Worker) {
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
u.w.Reserve()
}
func (u *User) Disconnect() {
u.Connection.Disconnect()
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) {
switch x.T {
func (u *User) HandleRequests(info HasServerInfo, launcher games.Launcher, conf coordinator.Config) chan struct{} {
return u.ProcessPackets(func(x api.In[com.Uid]) error {
payload := x.GetPayload()
switch x.GetType() {
case api.WebrtcInit:
if u.w != nil {
u.HandleWebrtcInit()
}
case api.WebrtcAnswer:
err = api.Do(x, u.HandleWebrtcAnswer)
rq := api.Unwrap[api.WebrtcAnswerUserRequest](payload)
if rq == nil {
return api.ErrMalformed
}
u.HandleWebrtcAnswer(*rq)
case api.WebrtcIce:
err = api.Do(x, u.HandleWebrtcIceCandidate)
rq := api.Unwrap[api.WebrtcUserIceCandidate](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](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[com.Uid]](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](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[com.Uid]](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)
}
return
return nil
})
}

View file

@ -4,23 +4,28 @@ import (
"unsafe"
"github.com/giongto35/cloud-game/v3/pkg/api"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/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) {
func (u *User) InitSession(wid string, ice []webrtc.IceServer, games []string) {
u.Notify(api.InitSession, api.InitSessionUserResponse{
Ice: *(*[]api.IceServer)(unsafe.Pointer(&ice)), // don't do this at home
// don't do this at home
Ice: *(*[]api.IceServer)(unsafe.Pointer(&ice)),
Games: games,
Wid: wid,
})
@ -33,6 +38,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) }

View file

@ -2,15 +2,15 @@ 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/v3/pkg/com"
"github.com/giongto35/cloud-game/v3/pkg/config/coordinator"
"github.com/giongto35/cloud-game/v3/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")
return
@ -19,64 +19,34 @@ func (u *User) HandleWebrtcInit() {
}
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).Send()
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")
return
@ -86,7 +56,7 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, conf config.Coordina
return
}
u.log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker")
u.StartGame(startGameResp.AV, startGameResp.KbMouse)
u.StartGame()
// send back recording status
if conf.Recording.Enabled && rq.Record {
@ -94,37 +64,23 @@ 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())
func (u *User) HandleQuitGame(rq api.GameQuitRequest[com.Uid]) {
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,16 +89,18 @@ 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) HandleRecordGame(rq api.RecordGameRequest) {
func (u *User) HandleToggleMultitap() { u.w.ToggleMultitap(u.Id()) }
func (u *User) HandleRecordGame(rq api.RecordGameRequest[com.Uid]) {
if u.w == nil {
return
}
@ -154,7 +112,7 @@ func (u *User) HandleRecordGame(rq api.RecordGameRequest) {
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")
return
@ -169,16 +127,14 @@ func (u *User) handleGetWorkerList(debug bool, info HasServerInfo) {
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
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)

View file

@ -1,7 +1,6 @@
package coordinator
import (
"errors"
"fmt"
"sync/atomic"
@ -11,10 +10,8 @@ import (
)
type Worker struct {
AppLibrary
Connection
RegionalClient
Session
slotted
Addr string
@ -24,9 +21,6 @@ type Worker struct {
Tag string
Zone string
Lib []api.GameInfo
Sessions map[string]struct{}
log *logger.Logger
}
@ -35,32 +29,11 @@ type RegionalClient interface {
}
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
Find(com.Uid) (*User, bool)
}
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)
conn := com.NewConnection[api.PT, api.In[com.Uid], api.Out](sock, handshake.Id, log)
return &Worker{
Connection: conn,
Addr: handshake.Addr,
@ -76,58 +49,39 @@ func NewWorker(sock *com.Connection, handshake api.ConnectionRequest[com.Uid], l
}
func (w *Worker) HandleRequests(users HasUserRegistry) chan struct{} {
return w.ProcessPackets(func(p api.In[com.Uid]) (err error) {
switch p.T {
return w.ProcessPackets(func(p api.In[com.Uid]) error {
payload := p.GetPayload()
switch p.GetType() {
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](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](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[com.Uid]](payload)
if rq == nil {
return api.ErrMalformed
}
err := w.HandleIceCandidate(*rq, users)
if err != nil {
w.log.Error().Err(err).Send()
return api.ErrMalformed
}
default:
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,40 +94,13 @@ 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)
}
}

View file

@ -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")
}
}

View file

@ -1,68 +1,67 @@
package coordinator
import "github.com/giongto35/cloud-game/v3/pkg/api"
import (
"github.com/giongto35/cloud-game/v3/pkg/api"
"github.com/giongto35/cloud-game/v3/pkg/com"
"github.com/giongto35/cloud-game/v3/pkg/games"
)
func (w *Worker) WebrtcInit(id string) (*api.WebrtcInitResponse, error) {
func (w *Worker) WebrtcInit(id com.Uid) (*api.WebrtcInitResponse, error) {
return api.UnwrapChecked[api.WebrtcInitResponse](
w.Send(api.WebrtcInit, api.WebrtcInitRequest{Id: id}))
w.Send(api.WebrtcInit, api.WebrtcInitRequest[com.Uid]{Stateful: api.Stateful[com.Uid]{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 com.Uid, sdp string) {
w.Notify(api.WebrtcAnswer, api.WebrtcAnswerRequest[com.Uid]{Stateful: api.Stateful[com.Uid]{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 com.Uid, can string) {
w.Notify(api.WebrtcIce, api.WebrtcIceCandidateRequest[com.Uid]{Stateful: api.Stateful[com.Uid]{Id: id}, Candidate: can})
}
func (w *Worker) StartGame(id string, req api.GameStartUserRequest) (*api.StartGameResponse, error) {
func (w *Worker) StartGame(id com.Uid, app games.AppMeta, 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,
w.Send(api.StartGame, api.StartGameRequest[com.Uid]{
StatefulRoom: 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 com.Uid) {
w.Notify(api.QuitGame, api.GameQuitRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)})
}
func (w *Worker) SaveGame(id string) (*api.SaveGameResponse, error) {
func (w *Worker) SaveGame(id com.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[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)}))
}
func (w *Worker) LoadGame(id string) (*api.LoadGameResponse, error) {
func (w *Worker) LoadGame(id com.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[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)}))
}
func (w *Worker) ChangePlayer(id string, index int) (*api.ChangePlayerResponse, error) {
func (w *Worker) ChangePlayer(id com.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[com.Uid]{StatefulRoom: 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 com.Uid) {
_, _ = w.Send(api.ToggleMultitap, api.ToggleMultitapRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)})
}
func (w *Worker) RecordGame(id string, rec bool, recUser string) (*api.RecordGameResponse, error) {
func (w *Worker) RecordGame(id com.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[com.Uid]{StatefulRoom: 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 com.Uid) {
_, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest[com.Uid]{Stateful: api.Stateful[com.Uid]{Id: id}})
}
func StateRoom[T api.Id](id T, rid string) api.StatefulRoom[T] {
return api.StatefulRoom[T]{Stateful: api.Stateful[T]{Id: id}, Room: api.Room{Rid: rid}}
}

View file

@ -1,39 +1,23 @@
package coordinator
import "github.com/giongto35/cloud-game/v3/pkg/api"
import (
"github.com/giongto35/cloud-game/v3/pkg/api"
"github.com/giongto35/cloud-game/v3/pkg/com"
)
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[com.Uid], users HasUserRegistry) error {
if usr, ok := users.Find(rq.Id); ok {
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
}

View file

@ -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
}

View file

@ -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)))
}

View file

@ -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
}

View file

@ -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")
}
}
}

View file

@ -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 <stdlib.h>
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 051, 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(&param, preset, tune) < 0 {
return nil, fmt.Errorf("x264: invalid preset/tune name")
}
} else {
C.x264_param_default(&param)
}
if opts.Profile != "" {
profile := C.CString(opts.Profile)
defer C.free(unsafe.Pointer(profile))
if C.x264_param_apply_profile(&param, 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(&param)
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) }

View file

@ -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)
}
}

View file

@ -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 <stdint.h> // for uintptr_t and C99 types
#include <stdlib.h>
#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)) }

View file

@ -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
}

View file

@ -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
}

View file

@ -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"
)
// 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
}

View file

@ -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"
)
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)
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
}

26
pkg/games/session.go Normal file
View file

@ -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
}

View file

@ -7,7 +7,7 @@ import (
"strconv"
"github.com/VictoriaMetrics/metrics"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/config/monitoring"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/network/httpx"
)
@ -16,14 +16,14 @@ 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 {

View file

@ -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)
}

View file

@ -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()
}

View file

@ -3,7 +3,7 @@ package httpx
import (
"time"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/config/shared"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
@ -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

View file

@ -1,7 +1,6 @@
package httpx
import (
"errors"
"fmt"
"net/http"
"net/url"
@ -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)) }

View file

@ -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 }

View file

@ -4,13 +4,12 @@ import (
"fmt"
"net"
"github.com/giongto35/cloud-game/v3/pkg/config"
conf "github.com/giongto35/cloud-game/v3/pkg/config/webrtc"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/network/socket"
"github.com/pion/ice/v4"
"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)
}

View file

@ -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/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) }

View file

@ -27,15 +27,13 @@ type Server struct {
}
type Connection struct {
alive bool
callback MessageHandler
conn deadlineConn
done chan struct{}
errorHandler ErrorHandler
once sync.Once
pingPong bool
send chan []byte
messSize int64
alive bool
callback MessageHandler
conn deadlineConn
done chan struct{}
once sync.Once
pingPong bool
send chan []byte
}
type deadlineConn struct {
@ -45,7 +43,6 @@ type deadlineConn struct {
}
type MessageHandler func([]byte, error)
type ErrorHandler func(err error)
type Upgrader struct {
websocket.Upgrader
@ -128,12 +125,7 @@ func (c *Connection) reader() {
c.close()
}()
var s int64 = maxMessageSize
if c.messSize > 0 {
s = c.messSize
}
c.conn.SetReadLimit(s)
c.conn.SetReadLimit(maxMessageSize)
_ = 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 })
@ -153,10 +145,6 @@ func (c *Connection) reader() {
_, message, err := c.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)
}
break
@ -231,10 +219,6 @@ 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

View file

@ -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() }

View file

@ -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)
}

View file

@ -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]
}
}

View file

@ -1,106 +0,0 @@
package resampler
/*
#cgo pkg-config: speexdsp
#cgo st LDFLAGS: -l:libspeexdsp.a
#include <stdint.h>
#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))
}

View file

@ -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

46
pkg/service/service.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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() }

View file

@ -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",
})
}

View file

@ -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)
}
}
}

View file

@ -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 }

Some files were not shown because too many files have changed in this diff Show more