diff --git a/.github/workflows/cd/docker-compose.yml b/.github/workflows/cd/docker-compose.yml index ec68b8ac..2a4e31b1 100644 --- a/.github/workflows/cd/docker-compose.yml +++ b/.github/workflows/cd/docker-compose.yml @@ -18,7 +18,7 @@ services: coordinator: <<: *default-params - command: coordinator --v=5 + 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 @@ -28,7 +28,7 @@ services: environment: - MESA_GL_VERSION_OVERRIDE=3.3 entrypoint: [ "/bin/sh", "-c", "xvfb-run -a $$@", "" ] - command: worker --v=5 --zone=${ZONE:-} + command: worker --zone=${ZONE:-} 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml_ similarity index 99% rename from .github/workflows/release.yml rename to .github/workflows/release.yml_ index e76f6050..9df1f646 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml_ @@ -41,7 +41,7 @@ jobs: - uses: actions/setup-go@v2 with: - go-version: ^1.18 + go-version: ^1.19 - name: Get Linux dev libraries and tools if: matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index c5d83e3e..c2ecdfe2 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ _output/ ./build release/ vendor/ +tests/ +!tests/e2e/ +*.exe .dockerignore @@ -75,3 +78,4 @@ fbneo/ hi/ nvram/ *.mcd + diff --git a/Dockerfile b/Dockerfile index 349de259..23d50dc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN apt-get -qq update && apt-get -qq install --no-install-recommends -y \ && rm -rf /var/lib/apt/lists/* # go setup layer -ARG GO=go1.18.linux-amd64.tar.gz +ARG GO=go1.19.linux-amd64.tar.gz RUN wget -q https://golang.org/dl/$GO \ && rm -rf /usr/local/go \ && tar -C /usr/local -xzf $GO \ diff --git a/Makefile b/Makefile index 3960ba0b..af45bf55 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,10 @@ -# Makefile includes some useful commands to build or format incentives -# More commands could be added - -# Variables PROJECT = cloud-game REPO_ROOT = github.com/giongto35 ROOT = ${REPO_ROOT}/${PROJECT} +CGO_CFLAGS='-g -O3 -funroll-loops' +CGO_LDFLAGS='-g -O3' + fmt: @goimports -w cmd pkg tests @gofmt -s -w cmd pkg tests @@ -13,47 +12,6 @@ fmt: compile: fmt @go install ./cmd/... -check: fmt - @golangci-lint run cmd/... pkg/... -# @staticcheck -checks="all,-S1*" ./cmd/... ./pkg/... ./tests/... - -dep: - go mod download -# go mod tidy - -# NOTE: there is problem with go mod vendor when it delete github.com/gen2brain/x264-go/x264c causing unable to build. https://github.com/golang/go/issues/26366 -#build.cross: build -# CGO_ENABLED=1 GOOS=darwin GOARC=amd64 go build --ldflags '-linkmode external -extldflags "-static"' -o bin/coordinator-darwin ./cmd/coordinator -# CGO_ENABLED=1 GOOS=darwin GOARC=amd64 go build --ldflags '-linkmode external -extldflags "-static"' -o bin/worker-darwin ./cmd/worker -# CC=arm-linux-musleabihf-gcc GOOS=linux GOARC=amd64 CGO_ENABLED=1 go build --ldflags '-linkmode external -extldflags "-static"' -o bin/coordinator-linu ./cmd/coordinator -# CC=arm-linux-musleabihf-gcc GOOS=linux GOARC=amd64 CGO_ENABLED=1 go build --ldflags '-linkmode external -extldflags "-static"' -o bin/worker-linux ./cmd/worker - -# A user can invoke tests in different ways: -# - make test runs all tests; -# - make test TEST_TIMEOUT=10 runs all tests with a timeout of 10 seconds; -# - make test TEST_PKG=./model/... only runs tests for the model package; -# - make test TEST_ARGS="-v -short" runs tests with the specified arguments; -# - make test-race runs tests with race detector enabled. -TEST_TIMEOUT = 60 -TEST_PKGS ?= ./cmd/... ./pkg/... -TEST_TARGETS := test-short test-verbose test-race test-cover -.PHONY: $(TEST_TARGETS) test tests -test-short: TEST_ARGS=-short -test-verbose: TEST_ARGS=-v -test-race: TEST_ARGS=-race -test-cover: TEST_ARGS=-cover -$(TEST_TARGETS): test - -test: compile - @go test -timeout $(TEST_TIMEOUT)s $(TEST_ARGS) $(TEST_PKGS) - -test-e2e: compile - @go test ./tests/e2e/... - -cover: - @go test -v -covermode=count -coverprofile=coverage.out $(TEST_PKGS) -# @$(GOPATH)/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $(COVERALLS_TOKEN) - clean: @rm -rf bin @rm -rf build @@ -62,25 +20,33 @@ clean: build: mkdir -p bin/ go build -ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" -o bin/ ./cmd/coordinator - go build -buildmode=exe -tags static -ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" $(EXT_WFLAGS) -o bin/ ./cmd/worker + CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} \ + go build -buildmode=exe -tags static \ + -ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" $(EXT_WFLAGS) \ + -o bin/ ./cmd/worker verify-cores: - go test -run TestAllEmulatorRooms ./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 - go build -o bin/ ./cmd/worker + CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} go build -o bin/ ./cmd/worker dev.run: dev.build-local - ./bin/coordinator --v=5 & - ./bin/worker --v=5 + ./bin/coordinator & ./bin/worker + +dev.run.debug: + go build -race -o bin/ ./cmd/coordinator + CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} \ + go build -race -gcflags=all=-d=checkptr -o bin/ ./cmd/worker + ./bin/coordinator & ./bin/worker dev.run-docker: docker rm cloud-game-local -f || true - CLOUD_GAME_GAMES_PATH=$(PWD)/assets/games docker-compose up --build + docker-compose up --build # RELEASE # Builds the app for new release. @@ -97,8 +63,8 @@ dev.run-docker: # Config params: # - RELEASE_DIR: the name of the output folder (default: release). # - CONFIG_DIR: search dir for core config files. -# - DLIB_TOOL: the name of a dynamic lib copy tool (with params) (e.g., ldd -x -y; defalut: ldd). -# - DLIB_SEARCH_PATTERN: a grep filter of the output of the DLIB_TOOL (e.g., mylib.so; default: .*so). +# - DLIB_TOOL: the name of a dynamic lib copy tool (with params) (e.g., ldd -x -y; default: ldd). +# - DLIB_SEARCH_PATTERN: a grep filter of the output of the DLIB_TOOL (e.g., my_lib.so; default: .*so). # Be aware that this search pattern will return only matched regular expression part and not the whole line. # de. -> abc def ghj -> def # Makefile special symbols should be escaped with \. diff --git a/README.md b/README.md index ec092fd6..58734c9b 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,6 @@ Because I only hosted the platform on limited servers in US East, US West, Eu, S latency issues + connection problem. You can try hosting the service following the instruction the next section to have a better sense of performance. -| Screenshot | Screenshot | -| :--------------------------------------------: | :--------------------------------------------: | -| ![screenshot](docs/img/landing-page-ps-hm.png) | ![screenshot](docs/img/landing-page-ps-x4.png) | -| ![screenshot](docs/img/landing-page-gb.png) | ![screenshot](docs/img/landing-page-front.png) | - ## Feature 1. **Cloud gaming**: Game logic and storage is hosted on cloud service. It reduces the cumbersome of game diff --git a/assets/cores/nestopia_libretro.cfg b/assets/cores/nestopia_libretro.cfg new file mode 100644 index 00000000..fd06bde7 --- /dev/null +++ b/assets/cores/nestopia_libretro.cfg @@ -0,0 +1 @@ +nestopia_audio_type=stereo diff --git a/cmd/coordinator/main.go b/cmd/coordinator/main.go index 40dc25d5..816390e6 100644 --- a/cmd/coordinator/main.go +++ b/cmd/coordinator/main.go @@ -1,40 +1,33 @@ package main import ( - "context" - goflag "flag" "math/rand" "time" config "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" "github.com/giongto35/cloud-game/v2/pkg/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/giongto35/cloud-game/v2/pkg/os" - "github.com/giongto35/cloud-game/v2/pkg/util/logging" - "github.com/golang/glog" - flag "github.com/spf13/pflag" ) -var Version = "" - -func init() { - rand.Seed(time.Now().UTC().UnixNano()) -} +var Version = "?" func main() { + rand.Seed(time.Now().UTC().UnixNano()) conf := config.NewConfig() - flag.CommandLine.AddGoFlagSet(goflag.CommandLine) conf.ParseFlags() - logging.Init() - defer logging.Flush() + log := logger.NewConsole(conf.Coordinator.Debug, "c", true) - glog.Infof("[coordinator] version: %v", Version) - glog.V(4).Infof("[coordinator] Local configuration %+v", conf) - c := coordinator.New(conf) + log.Info().Msgf("version %s", Version) + log.Info().Msgf("conf version: %v", conf.Version) + if log.GetLevel() < logger.InfoLevel { + log.Debug().Msgf("config: %+v", conf) + } + c := coordinator.New(conf, log) c.Start() - - ctx, cancelCtx := context.WithCancel(context.Background()) - defer c.Shutdown(ctx) <-os.ExpectTermination() - cancelCtx() + if err := c.Stop(); err != nil { + log.Error().Err(err).Msg("service shutdown errors") + } } diff --git a/cmd/worker/main.go b/cmd/worker/main.go index e625fd4f..b4fb1352 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -1,43 +1,38 @@ package main import ( - "context" - goflag "flag" "math/rand" "time" config "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/giongto35/cloud-game/v2/pkg/os" - "github.com/giongto35/cloud-game/v2/pkg/thread" - "github.com/giongto35/cloud-game/v2/pkg/util/logging" "github.com/giongto35/cloud-game/v2/pkg/worker" - "github.com/golang/glog" - flag "github.com/spf13/pflag" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" ) -var Version = "" - -func init() { - rand.Seed(time.Now().UTC().UnixNano()) -} +var Version = "?" func run() { + rand.Seed(time.Now().UTC().UnixNano()) conf := config.NewConfig() - flag.CommandLine.AddGoFlagSet(goflag.CommandLine) conf.ParseFlags() - logging.Init() - defer logging.Flush() + log := logger.NewConsole(conf.Worker.Debug, "w", true) + log.Info().Msgf("version %s", Version) + log.Info().Msgf("conf version: %v", conf.Version) + if log.GetLevel() < logger.InfoLevel { + log.Debug().Msgf("config: %+v", conf) + } - glog.Infof("[worker] version: %v", Version) - glog.V(4).Infof("[worker] Local configuration %+v", conf) - wrk := worker.New(conf) + done := os.ExpectTermination() + wrk := worker.New(conf, log, done) wrk.Start() - - ctx, cancelCtx := context.WithCancel(context.Background()) - defer wrk.Shutdown(ctx) - <-os.ExpectTermination() - cancelCtx() + <-done + time.Sleep(100 * time.Millisecond) + if err := wrk.Stop(); err != nil { + log.Error().Err(err).Msg("service shutdown errors") + } } func main() { diff --git a/configs/config.yaml b/configs/config.yaml index 85217353..8ef789bf 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -2,14 +2,21 @@ # Application configuration file # +# for the compatibility purposes +version: 3 + coordinator: # debugging switch + # - shows debug logs # - allows selecting worker instances debug: false + # selects free workers: + # - any (default, any free) + # - ping (with the lowest ping) + selector: any # games library library: - # some directory which is gonna be the root folder for the library - # where games are stored + # 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 @@ -54,6 +61,8 @@ coordinator: gtag: worker: + # show more logs + debug: false network: # a coordinator address to connect to coordinatorAddress: localhost:8000 @@ -111,9 +120,14 @@ emulator: # special tag {user} will be replaced with current user's home dir storage: "{user}/.cr/save" + # path for storing emulator generated files + localPath: "./libretro" + libretro: # use zip compression for emulator save states - saveCompression: false + saveCompression: true + # Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX + logLevel: 1 cores: paths: libs: assets/cores @@ -176,6 +190,7 @@ emulator: roms: [ "zip" ] nes: lib: nestopia_libretro + config: nestopia_libretro.cfg roms: [ "nes" ] snes: lib: snes9x_libretro @@ -191,21 +206,23 @@ emulator: encoder: audio: - channels: 2 # audio frame duration needed for WebRTC (Opus) - frame: 20 - frequency: 48000 + # most of the emulators have ~1400 samples per a video frame, + # so we keep the frame buffer roughly half of that size or 2 RTC packets per frame + frame: 10 video: # h264, vpx (VP8) codec: h264 + # concurrent execution units (0 - disabled) + concurrency: 0 # see: https://trac.ffmpeg.org/wiki/Encode/H.264 h264: # Constant Rate Factor (CRF) 0-51 (default: 23) - crf: 17 + crf: 23 # ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo - preset: veryfast + preset: superfast # baseline, main, high, high10, high422, high444 - profile: main + profile: baseline # film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency tune: zerolatency # 0-3 @@ -216,9 +233,6 @@ encoder: bitrate: 1200 # force keyframe interval keyframeInterval: 5 - # run without a game - # (experimental) - withoutGame: false # game recording # (experimental) @@ -269,7 +283,7 @@ webrtc: dtlsRole: # a list of STUN/TURN servers to use iceServers: - - url: stun:stun.l.google.com:19302 + - urls: stun:stun.l.google.com:19302 # configures whether the ice agent should be a lite agent (true/false) # (performance) # don't use iceServers when enabled @@ -287,3 +301,6 @@ webrtc: # override ICE candidate IP, see: https://github.com/pion/webrtc/issues/835, # can be used for Docker bridged network internal IP override iceIpMap: + # set additional log level for WebRTC separately + # -1 - trace, 6 - nothing, ..., debug - 0 + logLevel: 6 diff --git a/docker-compose.yml b/docker-compose.yml index 7dcef1a1..5fb3725c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: - 9000:9000 - 8443:8443/udp command: > - bash -c "Xvfb :99 & coordinator --v=5 & worker --v=5" + bash -c "Xvfb :99 & coordinator & worker" volumes: # keep cores persistent in the cloud-game_cores volume - cores:/usr/local/share/cloud-game/assets/cores diff --git a/docs/designdoc/README.md b/docs/DESIGNv2.md similarity index 96% rename from docs/designdoc/README.md rename to docs/DESIGNv2.md index b15cd503..f6ade821 100644 --- a/docs/designdoc/README.md +++ b/docs/DESIGNv2.md @@ -5,7 +5,7 @@ Web-based Cloud Gaming Service contains multiple workers for gaming stream and a ## Worker Worker is responsible for streaming game to frontend -![worker](../img/worker.png) +![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. @@ -16,7 +16,7 @@ Worker is responsible for streaming game to frontend 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) +![Architecture](img/coordinator.png) 1. A user connected to Coordinator . 2. Coordinator will find the most suitable worker to serve the user. diff --git a/docs/STREAMING.md b/docs/STREAMING.md deleted file mode 100644 index 5eeb54ad..00000000 --- a/docs/STREAMING.md +++ /dev/null @@ -1,47 +0,0 @@ -## Streaming process description - -This document describes the step-by-step process of media streaming in all parts of the application. - -``` -┌──────────────┐ ┌───────────────┐ ┌──────────────┐ -│ USER AGENT │ │ COORDINATOR │ │ WORKER...n │ -├──────────────┤ ├───────────────┤ ├──────────────┤ -│ TCP/WS ├──1──►│ WS ──────► WS │◄───┤ TCP/WS │ -│ │ │ ▲ 2 │ │ │ │ -│ │ │ └───────────┘ │ │ │ -│ │ └───────────────┘ │ │ -│ UDP/RTP │◄─────────────3────────────┤ UDP/RTP │ -│ AUDIO < │ OPUS │ AUDIO │ -│ VIDEO < │ VP8/H264 │ VIDEO │ -│ DATA > │ 010101 │ DATA │ -└──────────────┘ └──────────────┘ -``` - -The app is based on WebRTC technology which allows the server to stream media and exchange data with ultra-low latencies. An essential part of these types of P2P connections is the signaling process. It's implemented as a custom text-based messaging protocol on top of WebSocket (quite similarly to [WAMP](https://wamp-proto.org)). The app supports both STUN and TURN protocols for NAT traversal or ICE. In terms of supported codecs, it can stream h264, VP8, and OPUS media. - -The streaming process begins when a user opens the main application page (index.html) served by the coordinator. -- The user's browser tries to open a new WebSocket connection to the coordinator — socket.init(roomId, zone) [web/js/network/socket.js:32](https://github.com/giongto35/cloud-game/blob/ae5260fb4726fd34cc0b0b05100dcc8457f52883/web/js/network/socket.js#L32) -> In the initial WebSocket Upgrade request query it may send two params: roomId — an identification number for existing game rooms stored in the URL query of the application page (i.e. app.com/?id=xxxxxx), zone — or, more precisely, region — serves the purpose of CDN and geographical segmentation of the streaming. -- On the coordinator side this request goes into a dedicated handler (/ws) — func (o *Server) WS(w http.ResponseWriter, r *http.Request) [pkg/coordinator/handlers.go:150](https://github.com/giongto35/cloud-game/blob/ae5260fb4726fd34cc0b0b05100dcc8457f52883/pkg/coordinator/handlers.go#L150) -- There, it unconditionally accepts the WebSocket connection and tags it with some ID, so it will be listening to messages from the user's side. Here a new client connection should be considered as established. -- Next, given provided query params, the coordinator tries to find a suitable worker whose job — directly stream games to a user. -> This process of choosing the right worker is following: if there is no roomId param, then the coordinator gathers the full list of available workers, filters them by a zone value (if provided), returns the user a list of public URLs, which he can ping and send results back to the coordinator. After that, the coordinator links the fastest one with the user. Alternatively, if the user did provide some roomId, then the coordinator directly assigns a worker with that room (workers have 1:1 mapping to rooms or games). -> All the information exchange initiated from the worker side is handled in a separate endpoint (/wso) [pkg/coordinator/handlers.go#L81](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/pkg/coordinator/handlers.go#L81). -- Coordinator sends to the user ICE servers and the list of games available for playing. That's handled in [web/js/network/socket.js:57](https://github.com/giongto35/cloud-game/blob/ae5260fb4726fd34cc0b0b05100dcc8457f52883/web/js/network/socket.js#L57). -- From this point, the user's browser begins to initialize WebRTC connection to the worker — web/js/controller.js:413 → [web/js/network/rtcp.js:16](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/web/js/network/rtcp.js#L16). -- First, it sends init request through the WebSocket connection to the coordinator handler in [pkg/coordinator/useragenthandlers.go:17](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/pkg/coordinator/useragenthandlers.go#L17). -> Following a standard WebRTC call [negotiation procedure](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling), the coordinator acts as a mediator between users and workers. The signaling protocol here is a text messaging through WebSocket transport. -- Coordinator notifies the user's worker that it wants to establish a new PeerConnection (call). That part is being handled in [pkg/worker/internalhandlers.go:42](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/pkg/worker/internalhandlers.go#L42). It is worth noting that it is a worker who makes SDP offer and waits for an SDP answer. -- Worker initializes new WebRTC connection handler in func (w *WebRTC) StartClient(isMobile bool, iceCB OnIceCallback) (string, error) [pkg/webrtc/webrtc.go:103](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/pkg/webrtc/webrtc.go#L103). -- Then through the coordinator it makes simultaneously an SDP offer as well as sends ICE candidates that are handled on the coordinator side (from the user) in [pkg/coordinator/useragenthandlers.go](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/pkg/coordinator/useragenthandlers.go), -(from the worker) in [pkg/coordinator/internalhandlers.go](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/pkg/coordinator/internalhandlers.go), and on the user side both in [web/js/network/socket.js:56](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/web/js/network/socket.js#L56) and inside [web/js/network/rtcp.js](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/web/js/network/rtcp.js). - - Browser on the user's side after SDP offer links remote streams to the HTML Video element in [web/js/controller.js:417](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/web/js/controller.js#L417), makes SDP answer and gathers remote ICE candidates until it's done (if receive an empty ICE candidate). - - For the user's side a successful WebRTC connection should be considered established when WebRTC datachannel is opened here [web/js/network/rtcp.js:31](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/web/js/network/rtcp.js#L31). - *And that should be it for the streaming part.* - > At this point all the connections should be successfully established and the user's ready for a game to start. The coordinator should notify the worker about that fact and the worker starts pushing media frames, listen to the input through the direct to the user WebRTC data channel. - - Then the user may send the game start request to the coordinator in [web/js/controller.js:153](https://github.com/giongto35/cloud-game/blob/a7d8e53dac2bbcf8306e0dafe3878644c760d368/web/js/controller.js#L153). - -#### Streaming requirements -- Workers should not have any closed UDP ports to be able to provide suitable ICE candidates. -- Coordinator should have at least one non-blocked TCP port (default: 8000) for HTTP/WebSocket signaling connections from users and workers. -- Browser should not block WebRTC and support it (check [here](https://test.webrtc.org/)). diff --git a/docs/designdoc/implementation/README.md b/docs/designdoc/implementation/README.md deleted file mode 100644 index 17626a35..00000000 --- a/docs/designdoc/implementation/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Web-based Cloud Gaming Service Implementation Document - -## Code structure -``` -. -├── cmd: service entrypoint -│ ├── main.go: Spawn coordinator or worker based on flag -│ └── main_test.go -├── static: static file for front end -│ ├── js -│ │ └── ws.js: client logic -│ ├── game.html: frontend with gameboy ui -│ └── index_ws.html: raw frontend without ui -├── coordinator: coordinator -│ ├── handlers.go: coordinator entrypoint -│ ├── browser.go: router listening to browser -│ └── worker.go: router listening to worker -├── games: roms list, no code logic -├── worker: integration between emulator + webrtc (communication) -│ ├── room: -│ │ ├── room.go: room logic -│ │ └── media.go: video + audio encoding -│ ├── handlers.go: worker entrypoint -│ └── coordinator.go: router listening to coordinator -├── emulator: emulator internal -│ ├── nes: NES device internal -│ ├── director.go: coordinator of views -│ └── gameview.go: in game logic -├── cws -│ └── cws.go: socket multiplexer library, used for signaling -└── webrtc: webrtc streaming logic -``` - -## Room -Room is a fundamental part of the system. Each user session will spawn a room with a game running inside. There is a pipeline to encode images and audio and stream them out from emulator to user. The pipeline also listens to all input and streams to the emulator. - -## Worker -Worker is an instance that can be provisioned to scale up the traffic. There are multiple rooms inside a worker. Worker will listen to coordinator events in `coordinator.go`. - -## Coordinator -Coordinator is the coordinator, which handles all communication with workers and frontend. -Coordinator will pair up a worker and a user for peer streaming. In WebRTC handshaking, two peers need to exchange their signature (Session Description Protocol) to initiate a peerconnection. -Events come from frontend will be handled in `coordinator/browser.go`. Events come from worker will be handled in `coordinator/worker.go`. Coordinator stays in the middle and relays handshake packages between workers and user. diff --git a/docs/img/crowdplay.gif b/docs/img/crowdplay.gif deleted file mode 100644 index 66514a5e..00000000 Binary files a/docs/img/crowdplay.gif and /dev/null differ diff --git a/docs/img/landing-page-dark.png b/docs/img/landing-page-dark.png deleted file mode 100644 index 21abc792..00000000 Binary files a/docs/img/landing-page-dark.png and /dev/null differ diff --git a/docs/img/landing-page-front.png b/docs/img/landing-page-front.png deleted file mode 100644 index 700ea116..00000000 Binary files a/docs/img/landing-page-front.png and /dev/null differ diff --git a/docs/img/landing-page-gb.png b/docs/img/landing-page-gb.png deleted file mode 100644 index 250f2a4d..00000000 Binary files a/docs/img/landing-page-gb.png and /dev/null differ diff --git a/docs/img/landing-page-ps-hm.png b/docs/img/landing-page-ps-hm.png deleted file mode 100644 index d06a9295..00000000 Binary files a/docs/img/landing-page-ps-hm.png and /dev/null differ diff --git a/docs/img/landing-page-ps-x4.png b/docs/img/landing-page-ps-x4.png deleted file mode 100644 index 4d173dbf..00000000 Binary files a/docs/img/landing-page-ps-x4.png and /dev/null differ diff --git a/docs/img/landing-page.gif b/docs/img/landing-page.gif deleted file mode 100644 index 691f4d9e..00000000 Binary files a/docs/img/landing-page.gif and /dev/null differ diff --git a/docs/img/landing-page2.png b/docs/img/landing-page2.png deleted file mode 100644 index 1dfabd5c..00000000 Binary files a/docs/img/landing-page2.png and /dev/null differ diff --git a/docs/userguide/instruction/README.md b/docs/userguide/instruction/README.md deleted file mode 100644 index f3245c2e..00000000 --- a/docs/userguide/instruction/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Web-based Cloud Gaming Service Game Instruction - -The game can be played on Desktop, Mobile (Android only). You can plug joystick to play with the game. - -Click question mark on the top left to see game instruction. - -## Key map on Desktop -Game keymap follows -Arrow keys to move -H -> Show help -C -> Start -V -> Select -Z -> A -X -> B -S -> Save (Save state) -A -> Load (Load previous saved state) -W -> Share your running game to other or you can keep it to continue playing the next time. Multiple people can access the same game for multiplayer or observation. -F -> Full screen -Q -> Quit the current game and go to menu screen.**NOTE**: we are facing some issue with quit, so it's better to refresh the page. - -## Mobile play -You can play the game on Android device. Make sure your Android has the version that support WebRTC. IOS doesn't support WebRTC streaming now. - -The keys map are equivalent to Desktop. Press the button to fire input. - -## Joystick -The game also accepts joystick, so you can try plug in one and experience. It will be very fun! - diff --git a/go.mod b/go.mod index 34c4bf26..8476f070 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,49 @@ module github.com/giongto35/cloud-game/v2 -go 1.13 +go 1.18 require ( - github.com/cavaliercoder/grab v1.0.1-0.20201108051000-98a5bfe305ec - github.com/fsnotify/fsnotify v1.5.1 + github.com/VictoriaMetrics/metrics v1.23.0 + github.com/cavaliergopher/grab/v3 v3.0.1 + github.com/fsnotify/fsnotify v1.6.0 + github.com/goccy/go-json v0.10.0 github.com/gofrs/flock v0.8.1 - github.com/gofrs/uuid v4.2.0+incompatible - github.com/golang/glog v1.0.0 - github.com/google/go-cmp v0.5.6 // indirect github.com/gorilla/websocket v1.5.0 - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 github.com/kkyr/fig v0.3.1-0.20220103220255-711af35e3ee2 - github.com/mitchellh/mapstructure v1.4.3 // indirect - github.com/pelletier/go-toml v1.9.4 // indirect - github.com/pion/ice/v2 v2.2.3 // indirect - github.com/pion/interceptor v0.1.10 - github.com/pion/rtp v1.7.11 // indirect - github.com/pion/webrtc/v3 v3.1.27 - github.com/prometheus/client_golang v1.12.1 - github.com/prometheus/common v0.33.0 // indirect + github.com/pion/dtls/v2 v2.1.5 + github.com/pion/interceptor v0.1.12 + github.com/pion/logging v0.2.2 + github.com/pion/webrtc/v3 v3.1.50 github.com/rs/xid v1.4.0 - github.com/spf13/pflag v1.0.5 - github.com/veandco/go-sdl2 v0.4.20 - golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 - golang.org/x/image v0.0.0-20220321031419-a8550c1d254a - golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 // indirect - golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect - google.golang.org/protobuf v1.28.0 // indirect + github.com/rs/zerolog v1.28.0 + github.com/veandco/go-sdl2 v0.4.28 + golang.org/x/crypto v0.5.0 + golang.org/x/image v0.3.0 +) + +require ( + github.com/google/uuid v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pion/datachannel v1.5.5 // indirect + github.com/pion/ice/v2 v2.2.13 // indirect + github.com/pion/mdns v0.0.5 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.10 // indirect + github.com/pion/rtp v1.7.13 // indirect + github.com/pion/sctp v1.8.5 // indirect + github.com/pion/sdp/v3 v3.0.6 // indirect + github.com/pion/srtp/v2 v2.0.11 // indirect + github.com/pion/stun v0.3.5 // indirect + github.com/pion/transport v0.14.1 // indirect + github.com/pion/turn/v2 v2.0.9 // indirect + github.com/pion/udp v0.1.1 // indirect + github.com/valyala/fastrand v1.1.0 // indirect + github.com/valyala/histogram v1.2.0 // indirect + golang.org/x/net v0.5.0 // indirect + golang.org/x/sys v0.4.0 // indirect + golang.org/x/text v0.6.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 3fdc6d7c..6bf97308 100644 --- a/go.sum +++ b/go.sum @@ -1,188 +1,57 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cavaliercoder/grab v1.0.1-0.20201108051000-98a5bfe305ec h1:4XvMn0XuV7qxCH22gbnR79r+xTUaLOSA0GW/egpO3SQ= -github.com/cavaliercoder/grab v1.0.1-0.20201108051000-98a5bfe305ec/go.mod h1:NbXoa59CCAGqtRm7kRrcZIk2dTCJMRVF8QI3BOD7isY= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/VictoriaMetrics/metrics v1.23.0 h1:WzfqyzCaxUZip+OBbg1+lV33WChDSu4ssYII3nxtpeA= +github.com/VictoriaMetrics/metrics v1.23.0/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.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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 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.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= +github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= -github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= -github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 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.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/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/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= 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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkyr/fig v0.3.1-0.20220103220255-711af35e3ee2 h1:ZSDGtCWL8CSbDE/ZHdTivgDl8CwuHb8TpMeSKRGAhfk= github.com/kkyr/fig v0.3.1-0.20220103220255-711af35e3ee2/go.mod h1:fEnrLjwg/iwSr8ksJF4DxrDmCUir5CaVMLORGYMcz30= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 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/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +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-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= -github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +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= @@ -193,415 +62,186 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J 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.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= -github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/datachannel v1.5.2 h1:piB93s8LGmbECrpO84DnkIVWasRMk3IimbcXkTQLE6E= -github.com/pion/datachannel v1.5.2/go.mod h1:FTGQWaHrdCwIJ1rw6xBIfZVkslikjShim5yr05XFuCQ= -github.com/pion/dtls/v2 v2.1.3 h1:3UF7udADqous+M2R5Uo2q/YaP4EzUoWKdfX2oscCUio= -github.com/pion/dtls/v2 v2.1.3/go.mod h1:o6+WvyLDAlXF7YiPB/RlskRoeK+/JtuaZa5emwQcWus= -github.com/pion/ice/v2 v2.2.2/go.mod h1:vLI7dFqxw8zMSb9J+ca74XU7JjLhddgfQB9+BbTydCo= -github.com/pion/ice/v2 v2.2.3 h1:kBVhmtMcI1L3bWDepilO9kKpCGpLQeppCuVxVS8obhE= -github.com/pion/ice/v2 v2.2.3/go.mod h1:SWuHiOGP17lGromHTFadUe1EuPgFh/oCU6FCMZHooVE= -github.com/pion/interceptor v0.1.10 h1:DJ2GjMGm4XGIQgMJxuEpdaExdY/6RdngT7Uh4oVmquU= -github.com/pion/interceptor v0.1.10/go.mod h1:Lh3JSl/cbJ2wP8I3ccrjh1K/deRGRn3UlSPuOTiHb6U= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8= +github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0= +github.com/pion/dtls/v2 v2.1.5 h1:jlh2vtIyUBShchoTDqpCCqiYCyRFJ/lvf/gQ8TALs+c= +github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY= +github.com/pion/ice/v2 v2.2.12 h1:n3M3lUMKQM5IoofhJo73D3qVla+mJN2nVvbSPq32Nig= +github.com/pion/ice/v2 v2.2.12/go.mod h1:z2KXVFyRkmjetRlaVRgjO9U3ShKwzhlUylvxKfHfd5A= +github.com/pion/ice/v2 v2.2.13 h1:NvLtzwcyob6wXgFqLmVQbGB3s9zzWmOegNMKYig5l9M= +github.com/pion/ice/v2 v2.2.13/go.mod h1:eFO4/1zCI+a3OFVt7l7kP+5jWCuZo8FwU2UwEa3+164= +github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8= +github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8= +github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw= github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g= 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.6/go.mod h1:52rMNPWFsjr39z9B9MhnkqhPLoeHTv1aN63o/42bWE0= -github.com/pion/rtcp v1.2.9 h1:1ujStwg++IOLIEoOiIQ2s+qBuJ1VN81KW+9pMPsif+U= github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo= -github.com/pion/rtp v1.7.0/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/rtp v1.7.4/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/rtp v1.7.9/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/rtp v1.7.11 h1:WosqH088pRIAnAoAGZjagA1H3uFtzjyD5yagQXqZ3uo= -github.com/pion/rtp v1.7.11/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/sctp v1.8.0/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= -github.com/pion/sctp v1.8.2 h1:yBBCIrUMJ4yFICL3RIvR4eh/H2BTTvlligmSTy+3kiA= -github.com/pion/sctp v1.8.2/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= -github.com/pion/sdp/v3 v3.0.4 h1:2Kf+dgrzJflNCSw3TV5v2VLeI0s/qkzy2r5jlR0wzf8= -github.com/pion/sdp/v3 v3.0.4/go.mod h1:bNiSknmJE0HYBprTHXKPQ3+JjacTv5uap92ueJZKsRk= -github.com/pion/srtp/v2 v2.0.5 h1:ks3wcTvIUE/GHndO3FAvROQ9opy0uLELpwHJaQ1yqhQ= -github.com/pion/srtp/v2 v2.0.5/go.mod h1:8k6AJlal740mrZ6WYxc4Dg6qDqqhxoRG2GSjlUhDF0A= +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 h1:JCc25nghnXWOlSn3OVtEnA9PjQ2JsxQbG+CXZ1UkJKQ= +github.com/pion/sctp v1.8.5/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.10 h1:b8ZvEuI+mrL8hbr/f1YiJFB34UMrOac3R3N1yq2UN0w= +github.com/pion/srtp/v2 v2.0.10/go.mod h1:XEeSWaK9PfuMs7zxXyiN252AHPbH12NX5q/CFDWtUuA= +github.com/pion/srtp/v2 v2.0.11 h1:6cEEgT1oCLWgE+BynbfaSMAxtsqU0M096x9dNH6olY0= +github.com/pion/srtp/v2 v2.0.11/go.mod h1:vzHprzbuVoYJ9NfaRMycnFrkHcLSaLVuBZDOtFQNZjY= github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg= github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA= github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q= -github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A= -github.com/pion/transport v0.13.0 h1:KWTA5ZrQogizzYwPEciGtHPLwpAjE91FgXnyu+Hv2uY= github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g= -github.com/pion/turn/v2 v2.0.8 h1:KEstL92OUN3k5k8qxsXHpr7WWfrdp7iJZHx99ud8muw= +github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg= +github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40= +github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI= github.com/pion/turn/v2 v2.0.8/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw= +github.com/pion/turn/v2 v2.0.9 h1:jcDPw0Vfd5I4iTc7s0Upfc2aMnyu2lgJ9vV0SUrNC1o= +github.com/pion/turn/v2 v2.0.9/go.mod h1:DQlwUwx7hL8Xya6TTAabbd9DdKXTNR96Xf5g5Qqso/M= github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o= github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M= -github.com/pion/webrtc/v3 v3.1.27 h1:yQ6TuHKJR/vro3nLZMfPv+WGf7T1/4ItaQeuzIZLXs4= -github.com/pion/webrtc/v3 v3.1.27/go.mod h1:hdduI+Rx0cpGvva18j0gKy/Iak611WPyhUIXs5W/FuI= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pion/webrtc/v3 v3.1.50 h1:wLMo1+re4WMZ9Kun9qcGcY+XoHkE3i0CXrrc0sjhVCk= +github.com/pion/webrtc/v3 v3.1.50/go.mod h1:y9n09weIXB+sjb9mi0GBBewNxo4TKUQm5qdtT5v3/X4= 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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= -github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.33.0 h1:rHgav/0a6+uYgGdNt3jwz8FNSesO/Hsang3O0T9A5SE= -github.com/prometheus/common v0.33.0/go.mod h1:gB3sOl7P0TvJabZpLY5uQMpUqRCPPCyRLCZYc7JZTNE= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 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.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/veandco/go-sdl2 v0.4.20 h1:/xEP4SBAcGCo++wKv90mxDDRlVPjZ9HpES82FTd6qkg= -github.com/veandco/go-sdl2 v0.4.20/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= +github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= +github.com/veandco/go-sdl2 v0.4.28 h1:kLXyC0MNbQp6aQcow27Nozaos6XT9j1db7hMm2PPPas= +github.com/veandco/go-sdl2 v0.4.28/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220408190544-5352b0902921 h1:iU7T1X1J6yxDr0rda54sWGkHgOp5XJrqm79gcNlC2VM= -golang.org/x/crypto v0.0.0-20220408190544-5352b0902921/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20220321031419-a8550c1d254a h1:LnH9RNcpPv5Kzi15lXg42lYMPUf0x8CuPv1YnvBWZAg= -golang.org/x/image v0.0.0-20220321031419-a8550c1d254a/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20221010152910-d6f0a8c073c2/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8= +golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80= +golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/image v0.2.0 h1:/DcQ0w3VHKCC5p0/P2B0JpAZ9Z++V2KOo2fyU89CXBQ= +golang.org/x/image v0.2.0/go.mod h1:la7oBXb9w3YFjBqaAwtynVioc1ZvOnNteUNrifGNmAI= +golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg= +golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +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-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c= -golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/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-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw= -golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/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.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 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.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 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= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 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.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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.2.5/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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 00000000..19d3bffd --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,151 @@ +package api + +import ( + "encoding/base64" + "fmt" + + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/goccy/go-json" +) + +type ( + Stateful struct { + Id network.Uid `json:"id"` + } + Room struct { + Rid string `json:"room_id"` // room id + } + StatefulRoom struct { + Stateful + Room + } + PT uint8 +) + +type ( + RoomInterface interface { + GetRoom() string + } +) + +func StateRoom(id network.Uid, rid string) StatefulRoom { + return StatefulRoom{Stateful: Stateful{id}, Room: Room{rid}} +} +func (sr StatefulRoom) GetRoom() string { return sr.Rid } + +// Packet codes: +// +// x, 1xx - user codes +// 2xx - worker codes +const ( + CheckLatency PT = 3 + InitSession PT = 4 + WebrtcInit PT = 100 + WebrtcOffer PT = 101 + WebrtcAnswer PT = 102 + WebrtcIce PT = 103 + StartGame PT = 104 + ChangePlayer PT = 108 + QuitGame PT = 105 + SaveGame PT = 106 + LoadGame PT = 107 + ToggleMultitap PT = 109 + RecordGame PT = 110 + GetWorkerList PT = 111 + RegisterRoom PT = 201 + CloseRoom PT = 202 + IceCandidate = WebrtcIce + TerminateSession PT = 204 +) + +func (p PT) String() string { + switch p { + case CheckLatency: + return "CheckLatency" + case InitSession: + return "InitSession" + case WebrtcInit: + return "WebrtcInit" + case WebrtcOffer: + return "WebrtcOffer" + case WebrtcAnswer: + return "WebrtcAnswer" + case WebrtcIce: + return "WebrtcIce" + case StartGame: + return "StartGame" + case ChangePlayer: + return "ChangePlayer" + case QuitGame: + return "QuitGame" + case SaveGame: + return "SaveGame" + case LoadGame: + return "LoadGame" + case ToggleMultitap: + return "ToggleMultitap" + case RecordGame: + return "RecordGame" + case GetWorkerList: + return "GetWorkerList" + case RegisterRoom: + return "RegisterRoom" + case CloseRoom: + return "CloseRoom" + case TerminateSession: + return "TerminateSession" + default: + return "Unknown" + } +} + +// Various codes +const ( + EMPTY = "" + OK = "ok" +) + +var ( + ErrForbidden = fmt.Errorf("forbidden") + ErrMalformed = fmt.Errorf("malformed") +) + +func Unwrap[T any](data []byte) *T { + out := new(T) + if err := json.Unmarshal(data, out); err != nil { + return nil + } + return out +} + +func UnwrapChecked[T any](bytes []byte, err error) (*T, error) { + if err != nil { + return nil, err + } + return Unwrap[T](bytes), nil +} + +// ToBase64Json encodes data to a URL-encoded Base64+JSON string. +func ToBase64Json(data any) (string, error) { + if data == nil { + return "", nil + } + b, err := json.Marshal(data) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// FromBase64Json decodes data from a URL-encoded Base64+JSON string. +func FromBase64Json(data string, obj any) error { + b, err := base64.URLEncoding.DecodeString(data) + if err != nil { + return err + } + err = json.Unmarshal(b, obj) + if err != nil { + return err + } + return nil +} diff --git a/pkg/api/coordinator.go b/pkg/api/coordinator.go new file mode 100644 index 00000000..1d0c308b --- /dev/null +++ b/pkg/api/coordinator.go @@ -0,0 +1,47 @@ +package api + +import "github.com/giongto35/cloud-game/v2/pkg/network" + +type ( + CloseRoomRequest string + ConnectionRequest struct { + Addr string `json:"addr,omitempty"` + Id string `json:"id,omitempty"` + IsHTTPS bool `json:"is_https,omitempty"` + PingURL string `json:"ping_url,omitempty"` + Port string `json:"port,omitempty"` + Tag string `json:"tag,omitempty"` + Zone string `json:"zone,omitempty"` + } + GetWorkerListRequest struct{} + GetWorkerListResponse struct { + Servers []Server `json:"servers"` + } + RegisterRoomRequest string +) + +// Server contains a list of server groups. +// Server is a separate machine that may contain +// multiple sub-processes. +type Server struct { + Addr string `json:"addr,omitempty"` + Id network.Uid `json:"id,omitempty"` + IsBusy bool `json:"is_busy,omitempty"` + InGroup bool `json:"in_group,omitempty"` + PingURL string `json:"ping_url"` + Port string `json:"port,omitempty"` + Replicas uint32 `json:"replicas,omitempty"` + Tag string `json:"tag,omitempty"` + Zone string `json:"zone,omitempty"` +} + +type HasServerInfo interface { + GetServerList() []Server +} + +const ( + DataQueryParam = "data" + RoomIdQueryParam = "room_id" + ZoneQueryParam = "zone" + WorkerIdParam = "wid" +) diff --git a/pkg/api/user.go b/pkg/api/user.go new file mode 100644 index 00000000..998b3320 --- /dev/null +++ b/pkg/api/user.go @@ -0,0 +1,30 @@ +package api + +type ( + ChangePlayerUserRequest int + CheckLatencyUserResponse []string + CheckLatencyUserRequest map[string]int64 + GameStartUserRequest struct { + GameName string `json:"game_name"` + RoomId string `json:"room_id"` + Record bool `json:"record,omitempty"` + RecordUser string `json:"record_user,omitempty"` + PlayerIndex int `json:"player_index"` + } + IceServer struct { + Urls string `json:"urls,omitempty"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` + } + InitSessionUserResponse struct { + Ice []IceServer `json:"ice"` + Games []string `json:"games"` + Wid string `json:"wid"` + } + WebrtcAnswerUserRequest string + WebrtcUserIceCandidate string +) + +func InitSessionResult(ice []IceServer, games []string, wid string) (PT, InitSessionUserResponse) { + return InitSession, InitSessionUserResponse{Ice: ice, Games: games, Wid: wid} +} diff --git a/pkg/api/worker.go b/pkg/api/worker.go new file mode 100644 index 00000000..eb05f277 --- /dev/null +++ b/pkg/api/worker.go @@ -0,0 +1,68 @@ +package api + +import "github.com/giongto35/cloud-game/v2/pkg/network" + +type GameInfo struct { + Name string `json:"name"` + Base string `json:"base"` + Path string `json:"path"` + Type string `json:"type"` +} + +type ( + ChangePlayerRequest = struct { + StatefulRoom + Index int `json:"index"` + } + ChangePlayerResponse int + GameQuitRequest struct { + StatefulRoom + } + LoadGameRequest struct { + StatefulRoom + } + LoadGameResponse string + SaveGameRequest struct { + StatefulRoom + } + SaveGameResponse string + StartGameRequest struct { + StatefulRoom + Record bool + RecordUser string + Game GameInfo `json:"game"` + PlayerIndex int `json:"player_index"` + } + StartGameResponse struct { + Room + Record bool + } + RecordGameRequest struct { + StatefulRoom + Active bool `json:"active"` + User string `json:"user"` + } + RecordGameResponse string + TerminateSessionRequest struct { + Stateful + } + ToggleMultitapRequest struct { + StatefulRoom + } + WebrtcAnswerRequest struct { + Stateful + Sdp string `json:"sdp"` + } + WebrtcIceCandidateRequest struct { + Stateful + Candidate string `json:"candidate"` + } + WebrtcInitRequest struct { + Stateful + } + WebrtcInitResponse string +) + +func NewWebrtcIceCandidateRequest(id network.Uid, can string) (PT, any) { + return WebrtcIce, WebrtcIceCandidateRequest{Stateful: Stateful{id}, Candidate: can} +} diff --git a/pkg/codec/codec.go b/pkg/codec/codec.go deleted file mode 100644 index 1cc33559..00000000 --- a/pkg/codec/codec.go +++ /dev/null @@ -1,8 +0,0 @@ -package codec - -type VideoCodec string - -const ( - H264 VideoCodec = "h264" - VPX VideoCodec = "vpx" -) diff --git a/pkg/com/com.go b/pkg/com/com.go new file mode 100644 index 00000000..347675cd --- /dev/null +++ b/pkg/com/com.go @@ -0,0 +1,93 @@ +package com + +import ( + "encoding/json" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" +) + +type ( + In struct { + Id network.Uid `json:"id,omitempty"` + T api.PT `json:"t"` + Payload json.RawMessage `json:"p,omitempty"` + } + Out struct { + Id network.Uid `json:"id,omitempty"` + T api.PT `json:"t"` + Payload any `json:"p,omitempty"` + } +) + +var ( + EmptyPacket = Out{Payload: ""} + ErrPacket = Out{Payload: "err"} + OkPacket = Out{Payload: "ok"} +) + +type ( + NetClient interface { + Close() + Id() network.Uid + } + RegionalClient interface { + In(region string) bool + } +) + +type SocketClient struct { + NetClient + + id network.Uid + wire *Client + Tag string + Log *logger.Logger +} + +func New(conn *Client, tag string, id network.Uid, log *logger.Logger) SocketClient { + l := log.Extend(log.With().Str("cid", id.Short())) + dir := "→" + if conn.IsServer() { + dir = "←" + } + l.Info().Str("c", tag).Str("d", dir).Msg("Connect") + return SocketClient{id: id, wire: conn, Tag: tag, Log: l} +} + +func (c *SocketClient) SetId(id network.Uid) { c.id = id } + +func (c *SocketClient) OnPacket(fn func(p In) error) { + logFn := func(p In) { + c.Log.Info().Str("c", c.Tag).Str("d", "←").Msgf("%s", p.T) + if err := fn(p); err != nil { + c.Log.Error().Err(err).Send() + } + } + c.wire.OnPacket(logFn) +} + +// Send makes a blocking call. +func (c *SocketClient) Send(t api.PT, data any) ([]byte, error) { + c.Log.Info().Str("c", c.Tag).Str("d", "→").Msgf("ᵇ%s", t) + return c.wire.Call(t, data) +} + +// Notify just sends a message and goes further. +func (c *SocketClient) Notify(t api.PT, data any) { + c.Log.Info().Str("c", c.Tag).Str("d", "→").Msgf("%s", t) + _ = c.wire.Send(t, data) +} + +func (c *SocketClient) Close() { + c.wire.Close() + c.Log.Info().Str("c", c.Tag).Str("d", "x").Msg("Close") +} + +func (c *SocketClient) Id() network.Uid { return c.id } +func (c *SocketClient) Listen() { c.ProcessMessages(); <-c.Done() } +func (c *SocketClient) ProcessMessages() { c.wire.Listen() } +func (c *SocketClient) Route(in In, out Out) { _ = c.wire.Route(in, out) } +func (c *SocketClient) String() string { return c.Tag + ":" + string(c.Id()) } +func (c *SocketClient) Done() chan struct{} { return c.wire.Wait() } diff --git a/pkg/com/map.go b/pkg/com/map.go new file mode 100644 index 00000000..ae45a21c --- /dev/null +++ b/pkg/com/map.go @@ -0,0 +1,98 @@ +package com + +import ( + "errors" + "sync" + + "github.com/giongto35/cloud-game/v2/pkg/network" +) + +// NetMap defines a thread-safe NetClient list. +type NetMap[T NetClient] struct { + m map[string]T + mu sync.Mutex +} + +// ErrNotFound is returned by NetMap when some value is not present. +var ErrNotFound = errors.New("not found") + +func NewNetMap[T NetClient]() NetMap[T] { return NetMap[T]{m: make(map[string]T, 10)} } + +// Add adds a new NetClient value with its id value as the key. +func (m *NetMap[T]) Add(client T) { m.Put(string(client.Id()), client) } + +// Put adds a new NetClient value with a custom key value. +func (m *NetMap[T]) Put(key string, client T) { + m.mu.Lock() + m.m[key] = client + m.mu.Unlock() +} + +// Remove removes NetClient from the map if present. +func (m *NetMap[T]) Remove(client T) { m.RemoveByKey(string(client.Id())) } + +// RemoveByKey removes NetClient from the map by a specified key value. +func (m *NetMap[T]) RemoveByKey(key string) { + m.mu.Lock() + delete(m.m, key) + m.mu.Unlock() +} + +// RemoveAll removes all occurrences of specified NetClient. +func (m *NetMap[T]) RemoveAll(client T) { + m.mu.Lock() + defer m.mu.Unlock() + for k, c := range m.m { + if c.Id() == client.Id() { + delete(m.m, k) + } + } +} + +func (m *NetMap[T]) IsEmpty() bool { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.m) == 0 +} + +// List returns the current NetClient map. +func (m *NetMap[T]) List() map[string]T { return m.m } + +func (m *NetMap[T]) Has(id network.Uid) bool { + _, err := m.Find(string(id)) + return err == nil +} + +// Find searches the first NetClient by a specified key value. +func (m *NetMap[T]) Find(key string) (client T, err error) { + if key == "" { + return client, ErrNotFound + } + m.mu.Lock() + defer m.mu.Unlock() + if c, ok := m.m[key]; ok { + return c, nil + } + return client, ErrNotFound +} + +// FindBy searches the first NetClient with the provided predicate function. +func (m *NetMap[T]) FindBy(fn func(c T) bool) (client T, err error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, w := range m.m { + if fn(w) { + return w, nil + } + } + return client, ErrNotFound +} + +// ForEach processes every NetClient with the provided callback function. +func (m *NetMap[T]) ForEach(fn func(c T)) { + m.mu.Lock() + defer m.mu.Unlock() + for _, w := range m.m { + fn(w) + } +} diff --git a/pkg/com/map_test.go b/pkg/com/map_test.go new file mode 100644 index 00000000..4c1aea94 --- /dev/null +++ b/pkg/com/map_test.go @@ -0,0 +1,32 @@ +package com + +import ( + "fmt" + "sync/atomic" + "testing" + + "github.com/giongto35/cloud-game/v2/pkg/network" +) + +type testClient struct { + NetClient + id int + c int32 +} + +func (t *testClient) Id() network.Uid { return network.Uid(fmt.Sprintf("%v", t.id)) } +func (t *testClient) change(n int) { atomic.AddInt32(&t.c, int32(n)) } + +func TestPointerValue(t *testing.T) { + m := NewNetMap[*testClient]() + c := testClient{id: 1} + m.Add(&c) + fc, _ := m.FindBy(func(c *testClient) bool { return c.id == 1 }) + c.change(100) + fc2, _ := m.Find(fc.Id().String()) + + expected := c.c == fc.c && c.c == fc2.c + if !expected { + t.Errorf("not expected change, o: %v != %v != %v", c.c, fc.c, fc2.c) + } +} diff --git a/pkg/com/net.go b/pkg/com/net.go new file mode 100644 index 00000000..5fee5938 --- /dev/null +++ b/pkg/com/net.go @@ -0,0 +1,187 @@ +package com + +import ( + "errors" + "net/http" + "net/url" + "sync" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/network/websocket" + "github.com/goccy/go-json" +) + +type ( + Connector struct { + tag string + wu *websocket.Upgrader + } + Client struct { + conn *websocket.WS + queue map[network.Uid]*call + onPacket func(packet In) + mu sync.Mutex + } + call struct { + done chan struct{} + err error + Response In + } + Option = func(c *Connector) +) + +var ( + errConnClosed = errors.New("connection closed") + errTimeout = errors.New("timeout") +) +var outPool = sync.Pool{New: func() any { o := Out{}; return &o }} + +func WithOrigin(url string) Option { return func(c *Connector) { c.wu = websocket.NewUpgrader(url) } } +func WithTag(tag string) Option { return func(c *Connector) { c.tag = tag } } + +const callTimeout = 5 * time.Second + +func NewConnector(opts ...Option) *Connector { + c := &Connector{} + for _, opt := range opts { + opt(c) + } + if c.wu == nil { + c.wu = &websocket.DefaultUpgrader + } + return c +} + +func (co *Connector) NewClientServer(w http.ResponseWriter, r *http.Request, log *logger.Logger) (*SocketClient, error) { + ws, err := co.wu.Upgrade(w, r, nil) + if err != nil { + return nil, err + } + conn, err := connect(websocket.NewServerWithConn(ws, log)) + if err != nil { + return nil, err + } + c := New(conn, co.tag, network.NewUid(), log) + return &c, nil +} + +func (co *Connector) NewClient(address url.URL, log *logger.Logger) (*Client, error) { + return connect(websocket.NewClient(address, log)) +} + +func connect(conn *websocket.WS, err error) (*Client, error) { + if err != nil { + return nil, err + } + client := &Client{conn: conn, queue: make(map[network.Uid]*call, 1)} + client.conn.OnMessage = client.handleMessage + return client, nil +} + +func (c *Client) IsServer() bool { return c.conn.IsServer() } + +func (c *Client) OnPacket(fn func(packet In)) { c.mu.Lock(); c.onPacket = fn; c.mu.Unlock() } + +func (c *Client) Listen() { c.mu.Lock(); c.conn.Listen(); c.mu.Unlock() } + +func (c *Client) Close() { + // !to handle error + c.conn.Close() + c.drain(errConnClosed) +} + +func (c *Client) Call(type_ api.PT, payload any) ([]byte, error) { + // !to expose channel instead of results + rq := outPool.Get().(*Out) + rq.Id, rq.T, rq.Payload = network.NewUid(), type_, payload + r, err := json.Marshal(rq) + outPool.Put(rq) + if err != nil { + //delete(c.queue, id) + return nil, err + } + + task := &call{done: make(chan struct{})} + c.mu.Lock() + c.queue[rq.Id] = task + c.conn.Write(r) + c.mu.Unlock() + select { + case <-task.done: + case <-time.After(callTimeout): + task.err = errTimeout + } + return task.Response.Payload, task.err +} + +func (c *Client) Send(type_ api.PT, pl any) error { + rq := outPool.Get().(*Out) + rq.Id, rq.T, rq.Payload = "", type_, pl + defer outPool.Put(rq) + return c.SendPacket(rq) +} + +func (c *Client) Route(p In, pl Out) error { + rq := outPool.Get().(*Out) + rq.Id, rq.T, rq.Payload = p.Id, p.T, pl.Payload + defer outPool.Put(rq) + return c.SendPacket(rq) +} + +func (c *Client) SendPacket(packet *Out) error { + r, err := json.Marshal(packet) + if err != nil { + return err + } + c.mu.Lock() + c.conn.Write(r) + c.mu.Unlock() + return nil +} + +func (c *Client) Wait() chan struct{} { return c.conn.Done } + +func (c *Client) handleMessage(message []byte, err error) { + if err != nil { + return + } + + var res In + if err = json.Unmarshal(message, &res); err != nil { + return + } + + // empty id implies that we won't track (wait) the response + if !res.Id.Empty() { + if task := c.pop(res.Id); task != nil { + task.Response = res + close(task.done) + return + } + } + c.onPacket(res) +} + +// pop extracts and removes a task from the queue by its id. +func (c *Client) pop(id network.Uid) *call { + c.mu.Lock() + task := c.queue[id] + delete(c.queue, id) + c.mu.Unlock() + return task +} + +// drain cancels all what's left in the task queue. +func (c *Client) drain(err error) { + c.mu.Lock() + for _, task := range c.queue { + if task.err == nil { + task.err = err + } + close(task.done) + } + c.mu.Unlock() +} diff --git a/pkg/com/net_test.go b/pkg/com/net_test.go new file mode 100644 index 00000000..ca547bca --- /dev/null +++ b/pkg/com/net_test.go @@ -0,0 +1,171 @@ +package com + +import ( + "encoding/json" + "math/rand" + "net/http" + "net/url" + "sync" + "testing" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network/websocket" +) + +var log = logger.Default() + +func TestPackets(t *testing.T) { + r, err := json.Marshal(Out{Payload: "asd"}) + if err != nil { + t.Fatalf("can't marshal packet") + } + + t.Logf("PACKET: %v", string(r)) +} + +func TestWebsocket(t *testing.T) { + testCases := []struct { + name string + test func(t *testing.T) + }{ + {"If WebSocket implementation is OK in general", testWebsocket}, + } + for _, tc := range testCases { + t.Run(tc.name, tc.test) + } +} + +func testWebsocket(t *testing.T) { + // setup + // socket handler + var socket *websocket.WS + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.DefaultUpgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("no socket, %v", err) + } + sock, err := websocket.NewServerWithConn(conn, log) + if err != nil { + t.Fatalf("couldn't init socket server") + } + socket = sock + socket.OnMessage = func(message []byte, err error) { + // echo response + socket.Write(message) + } + socket.Listen() + }) + // http handler + var wg sync.WaitGroup + wg.Add(1) + go func() { + wg.Done() + if err := http.ListenAndServe(":8080", nil); err != nil { + t.Errorf("no server") + return + } + }() + wg.Wait() + + client := newClient(t, url.URL{Scheme: "ws", Host: "localhost:8080", Path: "/ws"}) + client.Listen() + + calls := []struct { + typ api.PT + payload any + concurrent bool + value any + }{ + {typ: 10, payload: "test", value: "test", concurrent: true}, + {typ: 10, payload: "test2", value: "test2"}, + {typ: 11, payload: "test3", value: "test3"}, + {typ: 99, payload: "", value: ""}, + {typ: 0}, + {typ: 12, payload: 123, value: 123}, + {typ: 10, payload: false, value: false}, + {typ: 10, payload: true, value: true}, + {typ: 11, payload: []string{"test", "test", "test"}, value: []string{"test", "test", "test"}}, + {typ: 22, payload: []string{}, value: []string{}}, + } + + rand.Seed(time.Now().UnixNano()) + + n := 42 * 2 * 2 + var wait sync.WaitGroup + wait.Add(n * len(calls)) + + // test + for _, call := range calls { + for i := 0; i < n; i++ { + if call.concurrent { + call := call + go func() { + w := rand.Intn(600-100) + 100 + time.Sleep(time.Duration(w) * time.Millisecond) + vv, err := client.Call(call.typ, call.payload) + checkCall(t, vv, err, call.value) + wait.Done() + }() + } else { + vv, err := client.Call(call.typ, call.payload) + checkCall(t, vv, err, call.value) + wait.Done() + } + } + } + wait.Wait() + + client.Close() + + <-socket.Done + <-client.conn.Done +} + +func newClient(t *testing.T, addr url.URL) *Client { + conn, err := NewConnector().NewClient(addr, log) + if err != nil { + t.Fatalf("error: couldn't connect to %v because of %v", addr.String(), err) + } + return conn +} + +func checkCall(t *testing.T, v []byte, err error, need any) { + if err != nil { + t.Fatalf("should be no error but %v", err) + return + } + var value any + if v != nil { + if err = json.Unmarshal(v, &value); err != nil { + t.Fatalf("can't unmarshal %v", v) + } + } + + nice := true + // cast values after default unmarshal + switch value.(type) { + default: + nice = value == need + case bool: + nice = value == need.(bool) + case float64: + nice = value == float64(need.(int)) + case []any: + // let's assume that's strings + vv := value.([]any) + for i := 0; i < len(need.([]string)); i++ { + if vv[i].(string) != need.([]string)[i] { + nice = false + break + } + } + case map[string]any: + // ??? + } + + if !nice { + t.Fatalf("expected %v is not expected %v", need, v) + } +} diff --git a/pkg/config/coordinator/config.go b/pkg/config/coordinator/config.go index 63cd288c..892cf4cd 100644 --- a/pkg/config/coordinator/config.go +++ b/pkg/config/coordinator/config.go @@ -1,30 +1,35 @@ package coordinator import ( + "flag" + "github.com/giongto35/cloud-game/v2/pkg/config" "github.com/giongto35/cloud-game/v2/pkg/config/emulator" "github.com/giongto35/cloud-game/v2/pkg/config/monitoring" "github.com/giongto35/cloud-game/v2/pkg/config/shared" "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" "github.com/giongto35/cloud-game/v2/pkg/games" - flag "github.com/spf13/pflag" ) type Config struct { - Coordinator struct { - Debug bool - Library games.Config - Monitoring monitoring.Config - Origin struct { - UserWs string - WorkerWs string - } - Server shared.Server - Analytics Analytics + 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 } - Emulator emulator.Emulator - Recording shared.Recording - Webrtc webrtc.Webrtc + Selector string + Server shared.Server } // Analytics is optional Google Analytics @@ -33,6 +38,11 @@ type Analytics struct { Gtag string } +const ( + SelectAny = "any" + SelectByPing = "ping" +) + // allows custom config path var configPath string @@ -48,6 +58,6 @@ func NewConfig() (conf Config) { func (c *Config) ParseFlags() { c.Coordinator.Server.WithFlags() flag.IntVar(&c.Coordinator.Monitoring.Port, "monitoring.port", c.Coordinator.Monitoring.Port, "Monitoring server port") - flag.StringVarP(&configPath, "conf", "c", configPath, "Set custom configuration file path") + flag.StringVar(&configPath, "c-conf", configPath, "Set custom configuration file path") flag.Parse() } diff --git a/pkg/config/emulator/config.go b/pkg/config/emulator/config.go index 22f35549..34aad237 100644 --- a/pkg/config/emulator/config.go +++ b/pkg/config/emulator/config.go @@ -1,6 +1,7 @@ package emulator import ( + "math" "path" "path/filepath" "strings" @@ -9,16 +10,30 @@ import ( type Emulator struct { Scale int Threads int - AspectRatio struct { - Keep bool - Width int - Height 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 { @@ -34,6 +49,7 @@ type LibretroConfig struct { List map[string]LibretroCoreConfig } SaveCompression bool + LogLevel int } type LibretroRepoConfig struct { @@ -49,7 +65,6 @@ type LibretroCoreConfig struct { Folder string Width int Height int - Ratio float64 IsGlAllowed bool UsesLibCo bool HasMultitap bool diff --git a/pkg/config/encoder/config.go b/pkg/config/encoder/config.go index 00af8dfb..f0b9426c 100644 --- a/pkg/config/encoder/config.go +++ b/pkg/config/encoder/config.go @@ -1,20 +1,18 @@ package encoder type Encoder struct { - Audio Audio - Video Video - WithoutGame bool + Audio Audio + Video Video } type Audio struct { - Channels int - Frame int - Frequency int + Frame int } type Video struct { - Codec string - H264 struct { + Codec string + Concurrency int + H264 struct { Crf uint8 Preset string Profile string @@ -26,6 +24,3 @@ type Video struct { KeyframeInterval uint } } - -func (a *Audio) GetFrameSize() int { return a.GetFrameSizeFor(a.Frequency) } -func (a *Audio) GetFrameSizeFor(hz int) int { return hz * a.Frame / 1000 * a.Channels } diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 5c78d12a..96869c79 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -12,7 +12,7 @@ const EnvPrefix = "CLOUD_GAME" // The path param specifies a custom path to the configuration file. // Reads and puts environment variables with the prefix CLOUD_GAME_. // Params from the config should be in uppercase separated with _. -func LoadConfig(config interface{}, path string) error { +func LoadConfig(config any, path string) error { dirs := []string{path} if path == "" { dirs = append(dirs, ".", "configs", "../../../configs") @@ -26,6 +26,6 @@ func LoadConfig(config interface{}, path string) error { return nil } -func LoadConfigEnv(config interface{}) error { +func LoadConfigEnv(config any) error { return fig.Load(config, fig.IgnoreFile(), fig.UseEnv(EnvPrefix)) } diff --git a/pkg/config/shared/config.go b/pkg/config/shared/config.go index c5731fb3..3a92cd5e 100644 --- a/pkg/config/shared/config.go +++ b/pkg/config/shared/config.go @@ -1,6 +1,8 @@ package shared -import flag "github.com/spf13/pflag" +import "flag" + +type Version int type Server struct { Address string diff --git a/pkg/config/webrtc/config.go b/pkg/config/webrtc/config.go index a72beceb..08da0795 100644 --- a/pkg/config/webrtc/config.go +++ b/pkg/config/webrtc/config.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/giongto35/cloud-game/v2/pkg/config" - "github.com/giongto35/cloud-game/v2/pkg/config/encoder" ) type Webrtc struct { @@ -19,27 +18,28 @@ type Webrtc struct { IceIpMap string IceLite bool SinglePort int + LogLevel int } type IceServer struct { - Url string - Username string - Credential string + Urls string `json:"urls,omitempty"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` } -type Config struct { - Encoder encoder.Encoder - Webrtc Webrtc -} +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 := Config{Webrtc: Webrtc{IceServers: []IceServer{{}, {}, {}, {}, {}}}} + cfg := Webrtc{IceServers: []IceServer{{}, {}, {}, {}, {}}} _ = config.LoadConfigEnv(&cfg) - for i, ice := range cfg.Webrtc.IceServers { - if ice.Url == "" { + for i, ice := range cfg.IceServers { + if ice.Urls == "" { continue } - if strings.HasPrefix(ice.Url, "turn:") || strings.HasPrefix(ice.Url, "turns:") { + 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) } diff --git a/pkg/config/worker/config.go b/pkg/config/worker/config.go index 7b2cbb54..6ecd75da 100644 --- a/pkg/config/worker/config.go +++ b/pkg/config/worker/config.go @@ -1,9 +1,11 @@ package worker import ( - "log" + "flag" + "fmt" "net" "net/url" + "path/filepath" "strings" "github.com/giongto35/cloud-game/v2/pkg/config" @@ -14,7 +16,6 @@ import ( "github.com/giongto35/cloud-game/v2/pkg/config/storage" "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" "github.com/giongto35/cloud-game/v2/pkg/os" - flag "github.com/spf13/pflag" ) type Config struct { @@ -24,9 +25,11 @@ type Config struct { Storage storage.Storage Worker Worker Webrtc webrtc.Webrtc + Version shared.Version } type Worker struct { + Debug bool Monitoring monitoring.Config Network struct { CoordinatorAddress string @@ -44,14 +47,12 @@ type Worker struct { var configPath string func NewConfig() (conf Config) { - _ = config.LoadConfig(&conf, configPath) - conf.expandSpecialTags() - // with ICE lite we clear ICE servers - if !conf.Webrtc.IceLite { - conf.Webrtc.AddIceServersEnv() - } else { - conf.Webrtc.IceServers = []webrtc.IceServer{} + err := config.LoadConfig(&conf, configPath) + if err != nil { + panic(err) } + conf.expandSpecialTags() + conf.fixValues() return } @@ -63,7 +64,7 @@ func (c *Config) ParseFlags() { 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.StringVarP(&configPath, "conf", "c", configPath, "Set custom configuration file path") + flag.StringVar(&configPath, "w-conf", configPath, "Set custom configuration file path") flag.Parse() } @@ -76,9 +77,20 @@ func (c *Config) expandSpecialTags() { } userHomeDir, err := os.GetUserHome() if err != nil { - log.Fatalln("couldn't read user home directory", err) + panic(fmt.Sprintf("couldn't read user home directory, %v", err)) } *dir = strings.Replace(*dir, tag, userHomeDir, -1) + *dir = filepath.FromSlash(*dir) + } +} + +// fixValues tries to fix some values otherwise hard to set externally. +func (c *Config) fixValues() { + // with ICE lite we clear ICE servers + if !c.Webrtc.IceLite { + c.Webrtc.AddIceServersEnv() + } else { + c.Webrtc.IceServers = []webrtc.IceServer{} } } diff --git a/pkg/coordinator/balancer.go b/pkg/coordinator/balancer.go new file mode 100644 index 00000000..1ee8cd4e --- /dev/null +++ b/pkg/coordinator/balancer.go @@ -0,0 +1,105 @@ +package coordinator + +import ( + "bytes" + + "github.com/rs/xid" +) + +func (h *Hub) findWorkerByRoom(id string, region string) *Worker { + if id == "" { + return nil + } + // if there is zone param, we need to ensure the worker in that zone, + // if not we consider the room is missing + w, _ := h.workers.FindBy(func(w *Worker) bool { return w.RoomId == id && w.In(region) }) + return w +} + +func (h *Hub) getAvailableWorkers(region string) []*Worker { + var workers []*Worker + h.workers.ForEach(func(w *Worker) { + if w.HasSlot() && w.In(region) { + workers = append(workers, w) + } + }) + return workers +} + +func (h *Hub) find1stFreeWorker(region string) *Worker { + workers := h.getAvailableWorkers(region) + if len(workers) > 0 { + return workers[0] + } + return nil +} + +// findFastestWorker returns the best server for a session. +// All workers addresses are sent to user and user will ping to get latency. +// !to rewrite +func (h *Hub) findFastestWorker(region string, fn func(addresses []string) (map[string]int64, error)) *Worker { + workers := h.getAvailableWorkers(region) + if len(workers) == 0 { + return nil + } + + var addresses []string + group := map[string][]struct{}{} + for _, w := range workers { + if _, ok := group[w.PingServer]; !ok { + addresses = append(addresses, w.PingServer) + } + group[w.PingServer] = append(group[w.PingServer], struct{}{}) + } + + latencies, err := fn(addresses) + if len(latencies) == 0 || err != nil { + return nil + } + + workers = h.getAvailableWorkers(region) + if len(workers) == 0 { + return nil + } + + var bestWorker *Worker + var minLatency int64 = 1<<31 - 1 + // get a worker with the lowest latency + for addr, ping := range latencies { + if ping < minLatency { + for _, w := range workers { + if w.PingServer == addr { + bestWorker = w + } + } + minLatency = ping + } + } + return bestWorker +} + +func (h *Hub) findWorkerById(workerId string, useAllWorkers bool) *Worker { + // when we select one particular worker + if workerId != "" { + if xid_, err := xid.FromString(workerId); err == nil { + if useAllWorkers { + for _, w := range h.getAvailableWorkers("") { + if xid_.String() == w.Id().String() { + return w + } + } + } else { + for _, w := range h.getAvailableWorkers("") { + xid__, err := xid.FromString(workerId) + if err != nil { + continue + } + if bytes.Equal(xid_.Machine(), xid__.Machine()) { + return w + } + } + } + } + } + return nil +} diff --git a/pkg/coordinator/browser.go b/pkg/coordinator/browser.go deleted file mode 100644 index 41ad610f..00000000 --- a/pkg/coordinator/browser.go +++ /dev/null @@ -1,34 +0,0 @@ -package coordinator - -import ( - "fmt" - "log" - - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/gorilla/websocket" -) - -type BrowserClient struct { - *cws.Client - SessionID string - RoomID string - WorkerID string // TODO: how about pointer to workerClient? -} - -// NewCoordinatorClient returns a client connecting to browser. -// This connection exchanges information between browser and coordinator. -func NewBrowserClient(c *websocket.Conn, browserID string) *BrowserClient { - return &BrowserClient{ - Client: cws.NewClient(c), - SessionID: browserID, - } -} - -// Register new log -func (bc *BrowserClient) Printf(format string, args ...interface{}) { - log.Printf(fmt.Sprintf("Browser %s] %s", bc.SessionID, format), args...) -} - -func (bc *BrowserClient) Println(args ...interface{}) { - log.Println(fmt.Sprintf("Browser %s] %s", bc.SessionID, fmt.Sprint(args...))) -} diff --git a/pkg/coordinator/coordinator.go b/pkg/coordinator/coordinator.go index 05ccd265..88a649f8 100644 --- a/pkg/coordinator/coordinator.go +++ b/pkg/coordinator/coordinator.go @@ -1,27 +1,83 @@ package coordinator import ( - "log" + "html/template" "net/http" "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/config/shared" "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/giongto35/cloud-game/v2/pkg/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/network/httpx" "github.com/giongto35/cloud-game/v2/pkg/service" ) -func New(conf coordinator.Config) (services service.Group) { - srv := NewServer(conf, games.NewLibWhitelisted(conf.Coordinator.Library, conf.Emulator)) - httpSrv, err := NewHTTPServer(conf, func(mux *http.ServeMux) { - mux.HandleFunc("/ws", srv.WS) - mux.HandleFunc("/wso", srv.WSO) +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", hub.handleUserConnection) + mux.HandleFunc("/wso", hub.handleWorkerConnection) + return mux }) if err != nil { - log.Fatalf("http init fail: %v", err) + log.Error().Err(err).Msg("http server init fail") + return } - services.Add(srv, httpSrv) + services.Add(hub, h) if conf.Coordinator.Monitoring.IsEnabled() { - services.Add(monitoring.New(conf.Coordinator.Monitoring, httpSrv.GetHost(), "cord")) + services.Add(monitoring.New(conf.Coordinator.Monitoring, h.GetHost(), log)) } return } + +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)). + Static("/static/", "./web")) + }, + httpx.WithServerConfig(conf.Coordinator.Server), + httpx.WithLogger(log), + ) +} + +func index(conf coordinator.Config, log *logger.Logger) httpx.Handler { + const indexHTML = "./web/index.html" + + 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.Fatal().Err(err).Msg("error with the analytics template file") + } + } + + if conf.Coordinator.Debug { + log.Info().Msgf("Using auto-reloading index.html") + return httpx.HandlerFunc(func(w httpx.ResponseWriter, r *httpx.Request) { + tpl, _ := template.ParseFiles(indexHTML) + handler(tpl, w, r) + }) + } + + indexTpl, err := template.ParseFiles(indexHTML) + if err != nil { + log.Fatal().Err(err).Msg("error with the HTML index page") + } + + return httpx.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + handler(indexTpl, writer, request) + }) +} diff --git a/pkg/coordinator/handlers.go b/pkg/coordinator/handlers.go deleted file mode 100644 index 911d4fa0..00000000 --- a/pkg/coordinator/handlers.go +++ /dev/null @@ -1,389 +0,0 @@ -package coordinator - -import ( - "bytes" - "encoding/json" - "errors" - "github.com/rs/xid" - "log" - "math" - "net" - "net/http" - "strings" - - "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/giongto35/cloud-game/v2/pkg/cws/api" - "github.com/giongto35/cloud-game/v2/pkg/games" - "github.com/giongto35/cloud-game/v2/pkg/ice" - "github.com/giongto35/cloud-game/v2/pkg/network/websocket" - "github.com/giongto35/cloud-game/v2/pkg/service" - "github.com/gofrs/uuid" -) - -type Server struct { - service.Service - - cfg coordinator.Config - // games library - library games.GameLibrary - // roomToWorker map roomID to workerID - roomToWorker map[string]string - // workerClients are the map workerID to worker Client - workerClients map[string]*WorkerClient - // browserClients are the map sessionID to browser Client - browserClients map[string]*BrowserClient - - userWsUpgrader, workerWsUpgrader websocket.Upgrader -} - -func NewServer(cfg coordinator.Config, library games.GameLibrary) *Server { - // scan the lib right away - library.Scan() - - s := &Server{ - cfg: cfg, - library: library, - // Mapping roomID to server - roomToWorker: map[string]string{}, - // Mapping workerID to worker - workerClients: map[string]*WorkerClient{}, - // Mapping sessionID to browser - browserClients: map[string]*BrowserClient{}, - } - - // a custom Origin check - s.workerWsUpgrader = websocket.NewUpgrader(cfg.Coordinator.Origin.WorkerWs) - s.userWsUpgrader = websocket.NewUpgrader(cfg.Coordinator.Origin.UserWs) - - return s -} - -// WSO handles all connections from a new worker to coordinator -func (s *Server) WSO(w http.ResponseWriter, r *http.Request) { - log.Println("Coordinator: A worker is connecting...") - - connRt, err := GetConnectionRequest(r.URL.Query().Get("data")) - if err != nil { - log.Printf("Coordinator: got a malformed request: %v", err.Error()) - return - } - - log.Printf("%v", connRt) - - if connRt.PingURL == "" { - log.Printf("Warning! Ping address is not set.") - } - - if s.cfg.Coordinator.Server.Https && !connRt.IsHTTPS { - log.Printf("Warning! Unsecure connection. The worker may not work properly without HTTPS on its side!") - } - - c, err := s.workerWsUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Println("Coordinator: [!] WS upgrade:", err) - return - } - - // Generate workerID - var workerID string - for { - workerID = uuid.Must(uuid.NewV4()).String() - // check duplicate - if _, ok := s.workerClients[workerID]; !ok { - break - } - } - - // Create a workerClient instance - wc := NewWorkerClient(c, workerID) - wc.Println("Generated worker ID") - if connRt.Xid != "" { - if wc.Id, err = xid.FromString(connRt.Xid); err != nil { - wc.Id = xid.New() - } - } else { - wc.Id = xid.New() - } - wc.Addr = connRt.Addr - wc.Zone = connRt.Zone - wc.PingServer = connRt.PingURL - wc.Port = connRt.Port - wc.Tag = connRt.Tag - - addr := getIP(c.RemoteAddr()) - wc.Printf("id: %v | addr: %v | zone: %v | ping: %v | tag: %v", wc.Id, addr, wc.Zone, wc.PingServer, wc.Tag) - wc.StunTurnServer = ice.ToJson(s.cfg.Webrtc.IceServers, ice.Replacement{From: "server-ip", To: addr}) - - // Attach to Server instance with workerID, add defer - s.workerClients[workerID] = wc - defer s.cleanWorker(wc, workerID) - - wc.Send(api.ServerIdPacket(workerID), nil) - - s.workerRoutes(wc) - wc.Listen() -} - -// WS handles all connections from user/frontend to coordinator -func (s *Server) WS(w http.ResponseWriter, r *http.Request) { - log.Println("Coordinator: A user is connecting...") - - defer func() { - if r := recover(); r != nil { - log.Println("Warn: Something wrong. Recovered in ", r) - } - }() - - c, err := s.userWsUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Println("Coordinator: [!] WS upgrade:", err) - return - } - - // Generate sessionID for browserClient - var sessionID string - for { - sessionID = uuid.Must(uuid.NewV4()).String() - // check duplicate - if _, ok := s.browserClients[sessionID]; !ok { - break - } - } - - // Create browserClient instance - bc := NewBrowserClient(c, sessionID) - bc.Println("Generated worker ID") - - // Run browser listener first (to capture ping) - go bc.Listen() - - /* Create a session - mapping browserClient with workerClient */ - var wc *WorkerClient - - // get roomID if it is embeded in request. Server will pair the frontend with the server running the room. It only happens when we are trying to access a running room over share link. - // TODO: Update link to the wiki - q := r.URL.Query() - roomID := q.Get("room_id") - // zone param is to pick worker in that zone only - // if there is no zone param, we can pick - userZone := q.Get("zone") - workerId := q.Get("wid") - - // worker selection flow: - // by room -> by id -> by address -> by zone - - bc.Printf("Get Room %s Zone %s From URL %v", roomID, userZone, r.URL) - - if roomID != "" { - bc.Printf("Detected roomID %v from URL", roomID) - if workerID, ok := s.roomToWorker[roomID]; ok { - wc = s.workerClients[workerID] - if userZone != "" && wc.Zone != userZone { - // if there is zone param, we need to ensure the worker in that zone - // if not we consider the room is missing - wc = nil - } else { - bc.Printf("Found running server with id=%v client=%v", workerID, wc) - } - } - } - - // If there is no existing server to connect to, we find the best possible worker for the frontend - if wc == nil { - // when we select one particular worker - if workerId != "" { - if xid_, err := xid.FromString(workerId); err == nil { - if s.cfg.Coordinator.Debug { - for _, w := range s.getAvailableWorkers() { - if xid_ == w.Id { - wc = w - bc.Printf("[!] Worker found: %v", xid_) - break - } - } - } else { - for _, w := range s.getAvailableWorkers() { - if bytes.Equal(xid_.Machine(), w.Id.Machine()) { - wc = w - bc.Printf("[!] Machine %v found: %v", xid_.Machine(), xid_) - break - } - } - } - } - } - - if wc == nil { - // Get the best server for frontend to connect to - wc, err = s.getBestWorkerClient(bc, userZone) - if err != nil { - return - } - } - } - - // Assign available worker to browserClient - bc.WorkerID = wc.WorkerID - - wc.ChangeUserQuantityBy(1) - defer wc.ChangeUserQuantityBy(-1) - - // Everything is cool - // Attach to Server instance with sessionID - s.browserClients[sessionID] = bc - defer s.cleanBrowser(bc, sessionID) - - // Routing browserClient message - s.useragentRoutes(bc) - - bc.Send(cws.WSPacket{ - ID: "init", - Data: createInitPackage(wc.Id, wc.StunTurnServer, s.library.GetAll()), - }, nil) - - // If peerconnection is done (client.Done is signalled), we close peerconnection - <-bc.Done - - // Notify worker to clean session - wc.Send(api.TerminateSessionPacket(sessionID), nil) -} - -func (s *Server) getBestWorkerClient(client *BrowserClient, zone string) (*WorkerClient, error) { - workerClients := s.getAvailableWorkers() - serverID, err := s.findBestServerFromBrowser(workerClients, client, zone) - if err != nil { - log.Println(err) - return nil, err - } - return s.workerClients[serverID], nil -} - -// getAvailableWorkers returns the list of available worker -func (s *Server) getAvailableWorkers() map[string]*WorkerClient { - workerClients := map[string]*WorkerClient{} - for k, w := range s.workerClients { - if w.HasGameSlot() { - workerClients[k] = w - } - } - return workerClients -} - -// findBestServerFromBrowser returns the best server for a session -// All workers addresses are sent to user and user will ping to get latency -func (s *Server) findBestServerFromBrowser(workerClients map[string]*WorkerClient, client *BrowserClient, zone string) (string, error) { - // TODO: Find best Server by latency, currently return by ping - if len(workerClients) == 0 { - return "", errors.New("no server found") - } - - latencies := s.getLatencyMapFromBrowser(workerClients, client) - client.Println("Latency map", latencies) - - if len(latencies) == 0 { - return "", errors.New("no server found") - } - - var bestWorker *WorkerClient - var minLatency int64 = math.MaxInt64 - - // get the worker with lowest latency to user - for wc, l := range latencies { - if zone != "" && wc.Zone != zone { - // skip worker not in the zone if zone param is given - continue - } - - if l < minLatency { - bestWorker = wc - minLatency = l - } - } - - return bestWorker.WorkerID, nil -} - -// getLatencyMapFromBrowser get all latencies from worker to user -func (s *Server) getLatencyMapFromBrowser(workerClients map[string]*WorkerClient, client *BrowserClient) map[*WorkerClient]int64 { - var workersList []*WorkerClient - var addressList []string - uniqueAddresses := map[string]bool{} - latencyMap := map[*WorkerClient]int64{} - - // addressList is the list of worker addresses - for _, workerClient := range workerClients { - if _, ok := uniqueAddresses[workerClient.PingServer]; !ok { - addressList = append(addressList, workerClient.PingServer) - } - uniqueAddresses[workerClient.PingServer] = true - workersList = append(workersList, workerClient) - } - - // send this address to user and get back latency - client.Println("Send sync", addressList, strings.Join(addressList, ",")) - data := client.SyncSend(cws.WSPacket{ - ID: "checkLatency", - Data: strings.Join(addressList, ","), - }) - - respLatency := map[string]int64{} - err := json.Unmarshal([]byte(data.Data), &respLatency) - if err != nil { - log.Println(err) - return latencyMap - } - - for _, workerClient := range workersList { - if latency, ok := respLatency[workerClient.PingServer]; ok { - latencyMap[workerClient] = latency - } - } - return latencyMap -} - -// cleanBrowser is called when a browser is disconnected -func (s *Server) cleanBrowser(bc *BrowserClient, sessionID string) { - bc.Println("Disconnect from coordinator") - delete(s.browserClients, sessionID) - bc.Close() -} - -// cleanWorker is called when a worker is disconnected -// connection from worker to coordinator is also closed -func (s *Server) cleanWorker(wc *WorkerClient, workerID string) { - wc.Println("Unregister worker from coordinator") - // Remove workerID from workerClients - delete(s.workerClients, workerID) - // Clean all rooms connecting to that server - for roomID, roomServer := range s.roomToWorker { - if roomServer == workerID { - wc.Printf("Remove room %s", roomID) - delete(s.roomToWorker, roomID) - } - } - - wc.Close() -} - -// createInitPackage returns xid + serverhost + game list in encoded wspacket format -// This package will be sent to initialize -func createInitPackage(id xid.ID, stunturn string, games []games.GameMetadata) string { - var gameName []string - for _, game := range games { - gameName = append(gameName, game.Name) - } - initPackage := append([]string{id.String(), stunturn}, gameName...) - encodedList, _ := json.Marshal(initPackage) - return string(encodedList) -} - -func getIP(a net.Addr) (addr string) { - if parts := strings.Split(a.String(), ":"); len(parts) == 2 { - addr = parts[0] - } - if addr == "" { - return "localhost" - } - return -} diff --git a/pkg/coordinator/http.go b/pkg/coordinator/http.go deleted file mode 100644 index a59b7133..00000000 --- a/pkg/coordinator/http.go +++ /dev/null @@ -1,52 +0,0 @@ -package coordinator - -import ( - "html/template" - "log" - "net/http" - - "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" - "github.com/giongto35/cloud-game/v2/pkg/config/shared" - "github.com/giongto35/cloud-game/v2/pkg/network/httpx" -) - -func NewHTTPServer(conf coordinator.Config, fnMux func(mux *http.ServeMux)) (*httpx.Server, error) { - return httpx.NewServer( - conf.Coordinator.Server.GetAddr(), - func(*httpx.Server) http.Handler { - h := http.NewServeMux() - h.Handle("/", index(conf)) - h.Handle("/static/", static("./web")) - fnMux(h) - return h - }, - httpx.WithServerConfig(conf.Coordinator.Server), - ) -} - -func index(conf coordinator.Config) http.Handler { - tpl, err := template.ParseFiles("./web/index.html") - if err != nil { - log.Fatal(err) - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // return 404 on unknown - if r.URL.Path != "/" { - http.NotFound(w, r) - 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.Fatal(err) - } - }) -} - -func static(dir string) http.Handler { - return http.StripPrefix("/static/", http.FileServer(http.Dir(dir))) -} diff --git a/pkg/coordinator/hub.go b/pkg/coordinator/hub.go new file mode 100644 index 00000000..8c9c4ce9 --- /dev/null +++ b/pkg/coordinator/hub.go @@ -0,0 +1,162 @@ +package coordinator + +import ( + "net/http" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/service" +) + +type Hub struct { + service.Service + + conf coordinator.Config + launcher games.Launcher + users com.NetMap[*User] + workers com.NetMap[*Worker] + log *logger.Logger + + wConn, uConn *com.Connector +} + +func NewHub(conf coordinator.Config, lib games.GameLibrary, log *logger.Logger) *Hub { + return &Hub{ + conf: conf, + users: com.NewNetMap[*User](), + workers: com.NewNetMap[*Worker](), + launcher: games.NewGameLauncher(lib), + log: log, + wConn: com.NewConnector( + com.WithOrigin(conf.Coordinator.Origin.WorkerWs), + com.WithTag("w"), + ), + uConn: com.NewConnector( + com.WithOrigin(conf.Coordinator.Origin.UserWs), + com.WithTag("u"), + ), + } +} + +// handleUserConnection handles all connections from user/frontend. +func (h *Hub) handleUserConnection(w http.ResponseWriter, r *http.Request) { + h.log.Info().Str("c", "u").Str("d", "←").Msgf("Handshake %v", r.Host) + conn, err := h.uConn.NewClientServer(w, r, h.log) + if err != nil { + h.log.Error().Err(err).Msg("couldn't init user connection") + } + usr := NewUserConnection(conn) + defer func() { + if usr != nil { + usr.Disconnect() + h.users.Remove(usr) + } + }() + usr.HandleRequests(h, h.launcher, h.conf) + + q := r.URL.Query() + roomId := q.Get(api.RoomIdQueryParam) + zone := q.Get(api.ZoneQueryParam) + wid := q.Get(api.WorkerIdParam) + + usr.Log.Info().Msg("Search available workers") + var wkr *Worker + if wkr = h.findWorkerByRoom(roomId, zone); wkr != nil { + usr.Log.Info().Str("room", roomId).Msg("An existing worker has been found") + } else if wkr = h.findWorkerById(wid, h.conf.Coordinator.Debug); wkr != nil { + usr.Log.Info().Msgf("Worker with id: %v has been found", wid) + } else if h.conf.Coordinator.Selector == "" || h.conf.Coordinator.Selector == coordinator.SelectAny { + usr.Log.Debug().Msgf("Searching any free worker...") + if wkr = h.find1stFreeWorker(zone); wkr != nil { + usr.Log.Info().Msgf("Found next free worker") + } + } else if h.conf.Coordinator.Selector == coordinator.SelectByPing { + usr.Log.Debug().Msgf("Searching fastest free worker...") + if wkr = h.findFastestWorker(zone, + func(servers []string) (map[string]int64, error) { return usr.CheckLatency(servers) }); wkr != nil { + usr.Log.Info().Msg("The fastest worker has been found") + } + } + + if wkr == nil { + usr.Log.Warn().Msg("no free workers") + return + } + + usr.SetWorker(wkr) + h.users.Add(usr) + usr.InitSession(wkr.Id().String(), h.conf.Webrtc.IceServers, h.launcher.GetAppNames()) + <-usr.Done() +} + +// handleWorkerConnection handles all connections from a new worker to coordinator. +func (h *Hub) handleWorkerConnection(w http.ResponseWriter, r *http.Request) { + h.log.Info().Str("c", "w").Str("d", "←").Msgf("Handshake %v", r.Host) + + data := r.URL.Query().Get(api.DataQueryParam) + handshake, err := GetConnectionRequest(data) + if err != nil || handshake == nil { + h.log.Error().Err(err).Msg("got a malformed request") + return + } + + if handshake.PingURL == "" { + h.log.Warn().Msg("Ping address is not set") + } + + if h.conf.Coordinator.Server.Https && !handshake.IsHTTPS { + h.log.Warn().Msg("Unsecure connection. The worker may not work properly without HTTPS on its side!") + } + + conn, err := h.wConn.NewClientServer(w, r, h.log) + if err != nil { + h.log.Error().Err(err).Msg("couldn't init worker connection") + return + } + + worker := &Worker{ + SocketClient: *conn, + Addr: handshake.Addr, + PingServer: handshake.PingURL, + Port: handshake.Port, + Tag: handshake.Tag, + Zone: handshake.Zone, + } + // we duplicate uid from the handshake + hid := network.Uid(handshake.Id) + if !(handshake.Id == "" || !network.ValidUid(hid)) { + conn.SetId(hid) + worker.Log.Debug().Msgf("connection id has been changed to %s", hid) + } + defer func() { + if worker != nil { + worker.Disconnect() + h.workers.Remove(worker) + } + }() + + h.log.Info().Msgf("New worker / addr: %v, port: %v, zone: %v, ping addr: %v, tag: %v", + worker.Addr, worker.Port, worker.Zone, worker.PingServer, worker.Tag) + worker.HandleRequests(&h.users) + h.workers.Add(worker) + worker.Listen() +} + +func (h *Hub) GetServerList() (r []api.Server) { + for _, w := range h.workers.List() { + r = append(r, api.Server{ + Addr: w.Addr, + Id: w.Id(), + IsBusy: !w.HasSlot(), + PingURL: w.PingServer, + Port: w.Port, + Tag: w.Tag, + Zone: w.Zone, + }) + } + return +} diff --git a/pkg/coordinator/internalhandlers.go b/pkg/coordinator/internalhandlers.go deleted file mode 100644 index 9e502fad..00000000 --- a/pkg/coordinator/internalhandlers.go +++ /dev/null @@ -1,75 +0,0 @@ -package coordinator - -import ( - "encoding/base64" - "encoding/json" - "log" - - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/giongto35/cloud-game/v2/pkg/cws/api" -) - -func (wc *WorkerClient) handleHeartbeat() cws.PacketHandler { - return func(resp cws.WSPacket) cws.WSPacket { - return resp - } -} - -func GetConnectionRequest(data string) (api.ConnectionRequest, error) { - req := api.ConnectionRequest{} - if data == "" { - return req, nil - } - decodeString, err := base64.URLEncoding.DecodeString(data) - if err != nil { - return req, err - } - err = json.Unmarshal(decodeString, &req) - return req, err -} - -// handleRegisterRoom event from a worker, when worker created a new room. -// RoomID is global so it is managed by coordinator. -func (wc *WorkerClient) handleRegisterRoom(s *Server) cws.PacketHandler { - return func(resp cws.WSPacket) cws.WSPacket { - log.Printf("Coordinator: Received registerRoom room %s from worker %s", resp.Data, wc.WorkerID) - s.roomToWorker[resp.Data] = wc.WorkerID - log.Printf("Coordinator: Current room list is: %+v", s.roomToWorker) - return api.RegisterRoomPacket(api.NoData) - } -} - -// handleGetRoom returns the server ID based on requested roomID. -func (wc *WorkerClient) handleGetRoom(s *Server) cws.PacketHandler { - return func(resp cws.WSPacket) cws.WSPacket { - log.Println("Coordinator: Received a get room request") - log.Println("Result: ", s.roomToWorker[resp.Data]) - return api.GetRoomPacket(s.roomToWorker[resp.Data]) - } -} - -// handleCloseRoom event from a worker, when worker close a room. -func (wc *WorkerClient) handleCloseRoom(s *Server) cws.PacketHandler { - return func(resp cws.WSPacket) cws.WSPacket { - log.Printf("Coordinator: Received closeRoom room %s from worker %s", resp.Data, wc.WorkerID) - delete(s.roomToWorker, resp.Data) - log.Printf("Coordinator: Current room list is: %+v", s.roomToWorker) - return api.CloseRoomPacket(api.NoData) - } -} - -// handleIceCandidate passes an ICE candidate (WebRTC) to the browser. -func (wc *WorkerClient) handleIceCandidate(s *Server) cws.PacketHandler { - return func(resp cws.WSPacket) cws.WSPacket { - wc.Println("Received IceCandidate from worker -> relay to browser") - bc, ok := s.browserClients[resp.SessionID] - if ok { - // Remove SessionID while sending back to browser - resp.SessionID = "" - bc.Send(resp, nil) - } else { - wc.Println("Error: unknown SessionID:", resp.SessionID) - } - return cws.EmptyPacket - } -} diff --git a/pkg/coordinator/routes.go b/pkg/coordinator/routes.go deleted file mode 100644 index 1a70db0c..00000000 --- a/pkg/coordinator/routes.go +++ /dev/null @@ -1,34 +0,0 @@ -package coordinator - -import "github.com/giongto35/cloud-game/v2/pkg/cws/api" - -// workerRoutes adds all worker request routes. -func (s *Server) workerRoutes(wc *WorkerClient) { - if wc == nil { - return - } - wc.Receive(api.Heartbeat, wc.handleHeartbeat()) - wc.Receive(api.RegisterRoom, wc.handleRegisterRoom(s)) - wc.Receive(api.GetRoom, wc.handleGetRoom(s)) - wc.Receive(api.CloseRoom, wc.handleCloseRoom(s)) - wc.Receive(api.IceCandidate, wc.handleIceCandidate(s)) -} - -// useragentRoutes adds all useragent (browser) request routes. -func (s *Server) useragentRoutes(bc *BrowserClient) { - if bc == nil { - return - } - bc.Receive(api.Heartbeat, bc.handleHeartbeat()) - bc.Receive(api.InitWebrtc, bc.handleInitWebrtc(s)) - bc.Receive(api.Answer, bc.handleAnswer(s)) - bc.Receive(api.IceCandidate, bc.handleIceCandidate(s)) - bc.Receive(api.GameStart, bc.handleGameStart(s)) - bc.Receive(api.GameQuit, bc.handleGameQuit(s)) - bc.Receive(api.GameSave, bc.handleGameSave(s)) - bc.Receive(api.GameLoad, bc.handleGameLoad(s)) - bc.Receive(api.GamePlayerSelect, bc.handleGamePlayerSelect(s)) - bc.Receive(api.GameMultitap, bc.handleGameMultitap(s)) - bc.Receive(api.GameRecording, bc.handleGameRecording(s)) - bc.Receive(api.GetServerList, bc.handleGetServerList(s)) -} diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go new file mode 100644 index 00000000..f4325b2f --- /dev/null +++ b/pkg/coordinator/user.go @@ -0,0 +1,89 @@ +package coordinator + +import ( + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/games" +) + +type User struct { + com.SocketClient + w *Worker // linked worker +} + +// NewUserConnection supposed to be a bidirectional one. +func NewUserConnection(conn *com.SocketClient) *User { return &User{SocketClient: *conn} } + +func (u *User) SetWorker(w *Worker) { u.w = w; u.w.Reserve() } + +func (u *User) Disconnect() { + u.SocketClient.Close() + if u.w != nil { + u.w.UnReserve() + u.w.TerminateSession(u.Id()) + } +} + +func (u *User) HandleRequests(info api.HasServerInfo, launcher games.Launcher, conf coordinator.Config) { + u.ProcessMessages() + u.OnPacket(func(x com.In) error { + // !to use proper channels + switch x.T { + case api.WebrtcInit: + if u.w != nil { + u.HandleWebrtcInit() + } + case api.WebrtcAnswer: + rq := api.Unwrap[api.WebrtcAnswerUserRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleWebrtcAnswer(*rq) + case api.WebrtcIce: + rq := api.Unwrap[api.WebrtcUserIceCandidate](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleWebrtcIceCandidate(*rq) + case api.StartGame: + rq := api.Unwrap[api.GameStartUserRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleStartGame(*rq, launcher, conf) + case api.QuitGame: + rq := api.Unwrap[api.GameQuitRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleQuitGame(*rq) + case api.SaveGame: + return u.HandleSaveGame() + case api.LoadGame: + return u.HandleLoadGame() + case api.ChangePlayer: + rq := api.Unwrap[api.ChangePlayerUserRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleChangePlayer(*rq) + case api.ToggleMultitap: + u.HandleToggleMultitap() + case api.RecordGame: + if !conf.Recording.Enabled { + return api.ErrForbidden + } + rq := api.Unwrap[api.RecordGameRequest](x.Payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleRecordGame(*rq) + case api.GetWorkerList: + u.handleGetWorkerList(conf.Coordinator.Debug, info) + default: + u.Log.Warn().Msgf("Unknown packet: %+v", x) + } + return nil + }) +} diff --git a/pkg/coordinator/useragenthandlers.go b/pkg/coordinator/useragenthandlers.go deleted file mode 100644 index 4eea9869..00000000 --- a/pkg/coordinator/useragenthandlers.go +++ /dev/null @@ -1,308 +0,0 @@ -package coordinator - -import ( - "errors" - "fmt" - "sort" - - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/giongto35/cloud-game/v2/pkg/cws/api" - "github.com/giongto35/cloud-game/v2/pkg/games" - "github.com/giongto35/cloud-game/v2/pkg/session" -) - -func (bc *BrowserClient) handleHeartbeat() cws.PacketHandler { - return func(resp cws.WSPacket) cws.WSPacket { return resp } -} - -func (bc *BrowserClient) handleInitWebrtc(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - // initWebrtc now only sends signal to worker, asks it to createOffer - bc.Printf("Received init_webrtc request -> relay to worker: %s", bc.WorkerID) - // relay request to target worker - // worker creates a PeerConnection, and createOffer - // send SDP back to browser - resp.SessionID = bc.SessionID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - sdp := wc.SyncSend(resp) - bc.Println("Received SDP from worker -> sending back to browser") - return sdp - } -} - -func (bc *BrowserClient) handleAnswer(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - // contains SDP of browser createAnswer - // forward to worker - bc.Println("Received browser answered SDP -> relay to worker") - resp.SessionID = bc.SessionID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - wc.Send(resp, nil) - // no need to response - return cws.EmptyPacket - } -} - -func (bc *BrowserClient) handleIceCandidate(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - // contains ICE candidate of browser - // forward to worker - bc.Println("Received IceCandidate from browser -> relay to worker") - resp.SessionID = bc.SessionID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - wc.Send(resp, nil) - return cws.EmptyPacket - } -} - -func (bc *BrowserClient) handleGameStart(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received start request from a browser -> relay to worker") - - // TODO: Async - resp.SessionID = bc.SessionID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - - // +injects game data into the original game request - gameStartCall, err := newGameStartCall(resp.RoomID, resp.Data, o.library, o.cfg.Recording.Enabled) - if err != nil { - return cws.EmptyPacket - } - if packet, err := gameStartCall.To(); err != nil { - return cws.EmptyPacket - } else { - resp.Data = packet - } - workerResp := wc.SyncSend(resp) - // Response from worker contains initialized roomID. Set roomID to the session - bc.RoomID = workerResp.RoomID - bc.Println("Received room response from browser: ", workerResp.RoomID) - - if o.cfg.Recording.Enabled && gameStartCall.Record { - bc.Send(cws.WSPacket{ - ID: api.GameRecording, - Data: "ok", - RoomID: workerResp.RoomID, - PlayerIndex: workerResp.PlayerIndex, - PacketID: workerResp.PacketID, - SessionID: workerResp.SessionID, - }, func(response cws.WSPacket) { - - }) - } - - return workerResp - } -} - -func (bc *BrowserClient) handleGameQuit(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received quit request from a browser -> relay to worker") - - // TODO: Async - resp.SessionID = bc.SessionID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - // Send but, waiting - wc.SyncSend(resp) - - return cws.EmptyPacket - } -} - -func (bc *BrowserClient) handleGameSave(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received save request from a browser -> relay to worker") - - // TODO: Async - resp.SessionID = bc.SessionID - resp.RoomID = bc.RoomID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - resp = wc.SyncSend(resp) - - return resp - } -} - -func (bc *BrowserClient) handleGameLoad(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received load request from a browser -> relay to worker") - - // TODO: Async - resp.SessionID = bc.SessionID - resp.RoomID = bc.RoomID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - resp = wc.SyncSend(resp) - - return resp - } -} - -func (bc *BrowserClient) handleGamePlayerSelect(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received update player index request from a browser -> relay to worker") - - // TODO: Async - resp.SessionID = bc.SessionID - resp.RoomID = bc.RoomID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - resp = wc.SyncSend(resp) - - return resp - } -} - -func (bc *BrowserClient) handleGameMultitap(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received multitap request from a browser -> relay to worker") - - // TODO: Async - resp.SessionID = bc.SessionID - resp.RoomID = bc.RoomID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - resp = wc.SyncSend(resp) - - return resp - } -} - -func (bc *BrowserClient) handleGameRecording(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - bc.Println("Received recording request from a browser -> relay to worker") - - if !o.cfg.Recording.Enabled { - bc.Printf("Recording should be disabled!") - return cws.EmptyPacket - } - - request := api.GameRecordingRequest{} - if err := request.From(resp.Data); err != nil { - return cws.EmptyPacket - } - - bc.Printf("Session: %v, room: %v, rec: %v user: %v", bc.SessionID, bc.RoomID, request.Active, request.User) - - if bc.RoomID == "" { - bc.Printf("Recording in the empty room is not allowed!") - return cws.EmptyPacket - } - - resp.SessionID = bc.SessionID - resp.RoomID = bc.RoomID - wc, ok := o.workerClients[bc.WorkerID] - if !ok { - return cws.EmptyPacket - } - resp = wc.SyncSend(resp) - - return resp - } -} - -// newGameStartCall gathers data for a new game start call of the worker -func newGameStartCall(roomId string, data string, library games.GameLibrary, recording bool) (api.GameStartCall, error) { - request := api.GameStartRequest{} - if err := request.From(data); err != nil { - return api.GameStartCall{}, errors.New("invalid request") - } - - // the name of the game either in the `room id` field or - // it's in the initial request - game := request.GameName - if roomId != "" { - // ! should be moved into coordinator - name := session.GetGameNameFromRoomID(roomId) - if name == "" { - return api.GameStartCall{}, errors.New("couldn't decode game name from the room id") - } - game = name - } - - gameInfo := library.FindGameByName(game) - if gameInfo.Path == "" { - return api.GameStartCall{}, fmt.Errorf("couldn't find game info for the game %v", game) - } - - call := api.GameStartCall{ - Name: gameInfo.Name, - Base: gameInfo.Base, - Path: gameInfo.Path, - Type: gameInfo.Type, - } - if recording { - call.Record = request.Record - call.RecordUser = request.RecordUser - } - return call, nil -} - -func (bc *BrowserClient) handleGetServerList(o *Server) cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - var request api.GetServerListRequest - if err := request.From(resp.Data); err != nil { - return cws.EmptyPacket - } - response := api.GetServerListResponse{} - var servers []api.Server - if o.cfg.Coordinator.Debug { - for _, s := range o.workerClients { - servers = append(servers, api.Server{ - Addr: s.Addr, Id: s.WorkerID, IsBusy: !s.HasGameSlot(), PingURL: s.PingServer, Port: s.Port, - Tag: s.Tag, Zone: s.Zone, Xid: s.Id.String(), - }) - } - } else { - // not sure if []byte to string always reversible :/ - unique := map[string]*api.Server{} - for _, s := range o.workerClients { - mid := string(s.Id.Machine()) - if _, ok := unique[mid]; !ok { - unique[mid] = &api.Server{Addr: s.Addr, PingURL: s.PingServer, Xid: s.Id.String()} - } - unique[mid].Replicas++ - } - for _, v := range unique { - servers = append(servers, *v) - } - } - sort.SliceStable(servers, func(i, j int) bool { - if servers[i].Addr != servers[j].Addr { - return servers[i].Addr < servers[j].Addr - } - return servers[i].Port < servers[j].Port - }) - response.Servers = servers - if packet, err := response.To(); err != nil { - return cws.EmptyPacket - } else { - resp.Data = packet - } - return resp - } -} diff --git a/pkg/coordinator/userapi.go b/pkg/coordinator/userapi.go new file mode 100644 index 00000000..71c9d76b --- /dev/null +++ b/pkg/coordinator/userapi.go @@ -0,0 +1,37 @@ +package coordinator + +import ( + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" +) + +// CheckLatency sends a list of server addresses to the user +// and waits get back this list with tested ping times for each server. +func (u *User) CheckLatency(req api.CheckLatencyUserResponse) (api.CheckLatencyUserRequest, error) { + 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, err +} + +// InitSession signals the user that the app is ready to go. +func (u *User) InitSession(wid string, ice []webrtc.IceServer, games []string) { + // don't do this at home + u.Notify(api.InitSessionResult(*(*[]api.IceServer)(unsafe.Pointer(&ice)), games, wid)) +} + +// SendWebrtcOffer sends SDP offer back to the user. +func (u *User) SendWebrtcOffer(sdp string) { u.Notify(api.WebrtcOffer, sdp) } + +// SendWebrtcIceCandidate sends remote ICE candidate back to the user. +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() { u.Notify(api.StartGame, u.w.RoomId) } diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go new file mode 100644 index 00000000..b5c2b238 --- /dev/null +++ b/pkg/coordinator/userhandlers.go @@ -0,0 +1,151 @@ +package coordinator + +import ( + "sort" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/config/coordinator" + "github.com/giongto35/cloud-game/v2/pkg/games" +) + +func (u *User) HandleWebrtcInit() { + 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 + } + u.SendWebrtcOffer(string(*resp)) +} + +func (u *User) HandleWebrtcAnswer(rq api.WebrtcAnswerUserRequest) { + u.w.WebrtcAnswer(u.Id(), string(rq)) +} + +func (u *User) HandleWebrtcIceCandidate(rq api.WebrtcUserIceCandidate) { + u.w.WebrtcIceCandidate(u.Id(), string(rq)) +} + +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 + } + + gameInfo, err := launcher.FindAppByName(game) + if err != nil { + u.Log.Error().Err(err).Str("game", game).Msg("couldn't find game info") + return + } + + startGameResp, err := u.w.StartGame(u.Id(), gameInfo, rq) + if err != nil || startGameResp == nil { + u.Log.Error().Err(err).Msg("malformed game start response") + return + } + if startGameResp.Rid == "" { + u.Log.Error().Msg("there is no room") + return + } + u.Log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker") + u.StartGame() + + // send back recording status + if conf.Recording.Enabled && rq.Record { + u.Notify(api.RecordGame, api.OK) + } +} + +func (u *User) HandleQuitGame(rq api.GameQuitRequest) { + if rq.Room.Rid == u.w.RoomId { + u.w.QuitGame(u.Id()) + } +} + +func (u *User) HandleSaveGame() error { + resp, err := u.w.SaveGame(u.Id()) + if err != nil { + return err + } + u.Notify(api.SaveGame, resp) + return nil +} + +func (u *User) HandleLoadGame() error { + resp, err := u.w.LoadGame(u.Id()) + if err != nil { + return err + } + u.Notify(api.LoadGame, resp) + return nil +} + +func (u *User) HandleChangePlayer(rq api.ChangePlayerUserRequest) { + 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).Msg("player switch failed for some reason") + return + } + u.Notify(api.ChangePlayer, rq) +} + +func (u *User) HandleToggleMultitap() { u.w.ToggleMultitap(u.Id()) } + +func (u *User) HandleRecordGame(rq api.RecordGameRequest) { + if u.w == nil { + return + } + + u.Log.Debug().Msgf("??? room: %v, rec: %v user: %v", u.w.RoomId, rq.Active, rq.User) + + if u.w.RoomId == "" { + u.Log.Error().Msg("Recording in the empty room is not allowed!") + return + } + + 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 + } + u.Notify(api.RecordGame, resp) +} + +func (u *User) handleGetWorkerList(debug bool, info api.HasServerInfo) { + response := api.GetWorkerListResponse{} + servers := info.GetServerList() + + if debug { + response.Servers = servers + } else { + // not sure if []byte to string always reversible :/ + unique := map[string]*api.Server{} + for _, s := range servers { + mid := s.Id.Machine() + if _, ok := unique[mid]; !ok { + unique[mid] = &api.Server{Addr: s.Addr, PingURL: s.PingURL, Id: s.Id, InGroup: true} + } + unique[mid].Replicas++ + } + for _, v := range unique { + response.Servers = append(response.Servers, *v) + } + } + if len(response.Servers) > 0 { + sort.SliceStable(response.Servers, func(i, j int) bool { + if response.Servers[i].Addr != response.Servers[j].Addr { + return response.Servers[i].Addr < response.Servers[j].Addr + } + return response.Servers[i].Port < response.Servers[j].Port + }) + } + u.Notify(api.GetWorkerList, response) +} diff --git a/pkg/coordinator/worker.go b/pkg/coordinator/worker.go index 32f0f9db..dc10b727 100644 --- a/pkg/coordinator/worker.go +++ b/pkg/coordinator/worker.go @@ -1,68 +1,81 @@ package coordinator import ( - "fmt" - "github.com/rs/xid" - "log" - "sync" + "sync/atomic" - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/gorilla/websocket" + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" ) -type WorkerClient struct { - *cws.Client +type Worker struct { + com.SocketClient + com.RegionalClient + slotted - Id xid.ID - - WorkerID string - Addr string - // public server used for ping check - PingServer string - Port string - StunTurnServer string - Tag string - userCount int // may be atomic - Zone string - - mu sync.Mutex + Addr string + PingServer string + Port string + RoomId string // room reference + Tag string + Zone string } -// NewWorkerClient returns a client connecting to worker. -// This connection exchanges information between workers and server. -func NewWorkerClient(c *websocket.Conn, workerID string) *WorkerClient { - return &WorkerClient{ - Client: cws.NewClient(c), - WorkerID: workerID, +func (w *Worker) HandleRequests(users *com.NetMap[*User]) { + // !to make a proper multithreading abstraction + w.OnPacket(func(p com.In) error { + switch p.T { + case api.RegisterRoom: + rq := api.Unwrap[api.RegisterRoomRequest](p.Payload) + if rq == nil { + return api.ErrMalformed + } + w.Log.Info().Msgf("set room [%v] = %v", w.Id(), *rq) + w.HandleRegisterRoom(*rq) + case api.CloseRoom: + rq := api.Unwrap[api.CloseRoomRequest](p.Payload) + if rq == nil { + return api.ErrMalformed + } + w.HandleCloseRoom(*rq) + case api.IceCandidate: + rq := api.Unwrap[api.WebrtcIceCandidateRequest](p.Payload) + if rq == nil { + return api.ErrMalformed + } + w.HandleIceCandidate(*rq, users) + default: + w.Log.Warn().Msgf("Unknown packet: %+v", p) + } + return nil + }) +} + +// 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 } + +// slotted used for tracking user slots and the availability. +type slotted int32 + +// HasSlot checks if the current worker has a free slot to start a new game. +// Workers support only one game at a time, so it returns true in case if +// there are no players in the room (worker). +func (s *slotted) HasSlot() bool { return atomic.LoadInt32((*int32)(s)) == 0 } + +// 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() { + if atomic.AddInt32((*int32)(s), -1) < 0 { + atomic.StoreInt32((*int32)(s), 0) } } -// ChangeUserQuantityBy increases or decreases the total amount of -// users connected to the current worker. -// We count users to determine when the worker becomes new game ready. -func (wc *WorkerClient) ChangeUserQuantityBy(n int) { - wc.mu.Lock() - wc.userCount += n - // just to be on a safe side - if wc.userCount < 0 { - wc.userCount = 0 - } - wc.mu.Unlock() -} +func (s *slotted) FreeSlots() { atomic.StoreInt32((*int32)(s), 0) } -// HasGameSlot tells whether the current worker has a -// free slot to start a new game. -// Workers support only one game at a time. -func (wc *WorkerClient) HasGameSlot() bool { - wc.mu.Lock() - defer wc.mu.Unlock() - return wc.userCount == 0 -} - -func (wc *WorkerClient) Printf(format string, args ...interface{}) { - log.Printf(fmt.Sprintf("Worker %s] %s", wc.WorkerID, format), args...) -} - -func (wc *WorkerClient) Println(args ...interface{}) { - log.Println(fmt.Sprintf("Worker %s] %s", wc.WorkerID, fmt.Sprint(args...))) +func (w *Worker) Disconnect() { + w.SocketClient.Close() + w.RoomId = "" + w.FreeSlots() } diff --git a/pkg/coordinator/workerapi.go b/pkg/coordinator/workerapi.go new file mode 100644 index 00000000..5a69e033 --- /dev/null +++ b/pkg/coordinator/workerapi.go @@ -0,0 +1,62 @@ +package coordinator + +import ( + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/network" +) + +func (w *Worker) WebrtcInit(id network.Uid) (*api.WebrtcInitResponse, error) { + return api.UnwrapChecked[api.WebrtcInitResponse]( + w.Send(api.WebrtcInit, api.WebrtcInitRequest{Stateful: api.Stateful{Id: id}})) +} + +func (w *Worker) WebrtcAnswer(id network.Uid, sdp string) { + w.Notify(api.WebrtcAnswer, api.WebrtcAnswerRequest{Stateful: api.Stateful{Id: id}, Sdp: sdp}) +} + +func (w *Worker) WebrtcIceCandidate(id network.Uid, can string) { + w.Notify(api.NewWebrtcIceCandidateRequest(id, can)) +} + +func (w *Worker) StartGame(id network.Uid, app games.AppMeta, req api.GameStartUserRequest) (*api.StartGameResponse, error) { + return api.UnwrapChecked[api.StartGameResponse](w.Send(api.StartGame, api.StartGameRequest{ + StatefulRoom: api.StateRoom(id, req.RoomId), + Game: api.GameInfo{Name: app.Name, Base: app.Base, Path: app.Path, Type: app.Type}, + PlayerIndex: req.PlayerIndex, + Record: req.Record, + RecordUser: req.RecordUser, + })) +} + +func (w *Worker) QuitGame(id network.Uid) { + w.Notify(api.QuitGame, api.GameQuitRequest{StatefulRoom: api.StateRoom(id, w.RoomId)}) +} + +func (w *Worker) SaveGame(id network.Uid) (*api.SaveGameResponse, error) { + return api.UnwrapChecked[api.SaveGameResponse]( + w.Send(api.SaveGame, api.SaveGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId)})) +} + +func (w *Worker) LoadGame(id network.Uid) (*api.LoadGameResponse, error) { + return api.UnwrapChecked[api.LoadGameResponse]( + w.Send(api.LoadGame, api.LoadGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId)})) +} + +func (w *Worker) ChangePlayer(id network.Uid, index int) (*api.ChangePlayerResponse, error) { + return api.UnwrapChecked[api.ChangePlayerResponse]( + w.Send(api.ChangePlayer, api.ChangePlayerRequest{StatefulRoom: api.StateRoom(id, w.RoomId), Index: index})) +} + +func (w *Worker) ToggleMultitap(id network.Uid) { + _, _ = w.Send(api.ToggleMultitap, api.ToggleMultitapRequest{StatefulRoom: api.StateRoom(id, w.RoomId)}) +} + +func (w *Worker) RecordGame(id network.Uid, rec bool, recUser string) (*api.RecordGameResponse, error) { + return api.UnwrapChecked[api.RecordGameResponse]( + w.Send(api.RecordGame, api.RecordGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId), Active: rec, User: recUser})) +} + +func (w *Worker) TerminateSession(id network.Uid) { + _, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest{Stateful: api.Stateful{Id: id}}) +} diff --git a/pkg/coordinator/workerhandlers.go b/pkg/coordinator/workerhandlers.go new file mode 100644 index 00000000..ee4e7137 --- /dev/null +++ b/pkg/coordinator/workerhandlers.go @@ -0,0 +1,31 @@ +package coordinator + +import ( + "encoding/base64" + "fmt" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" +) + +func GetConnectionRequest(data string) (*api.ConnectionRequest, error) { + if data == "" { + return nil, fmt.Errorf("no data") + } + return api.UnwrapChecked[api.ConnectionRequest](base64.URLEncoding.DecodeString(data)) +} + +func (w *Worker) HandleRegisterRoom(rq api.RegisterRoomRequest) { w.RoomId = string(rq) } +func (w *Worker) HandleCloseRoom(rq api.CloseRoomRequest) { + if string(rq) == w.RoomId { + w.RoomId = "" + } +} + +func (w *Worker) HandleIceCandidate(rq api.WebrtcIceCandidateRequest, users *com.NetMap[*User]) { + if usr, err := users.Find(string(rq.Id)); err == nil { + usr.SendWebrtcIceCandidate(rq.Candidate) + } else { + w.Log.Warn().Str("id", rq.Id.String()).Msg("unknown session") + } +} diff --git a/pkg/cws/api/api.go b/pkg/cws/api/api.go deleted file mode 100644 index d261a15a..00000000 --- a/pkg/cws/api/api.go +++ /dev/null @@ -1,23 +0,0 @@ -package api - -import "encoding/json" - -// This list of postfixes is used in the API: -// - *Request postfix denotes clients calls (i.e. from a browser to the HTTP-server). -// - *Call postfix denotes IPC calls (from the coordinator to a worker). - -func from(source interface{}, data string) error { - err := json.Unmarshal([]byte(data), source) - if err != nil { - return err - } - return nil -} - -func to(target interface{}) (string, error) { - b, err := json.Marshal(target) - if err != nil { - return "", err - } - return string(b), nil -} diff --git a/pkg/cws/api/coordinator.go b/pkg/cws/api/coordinator.go deleted file mode 100644 index ecf3e287..00000000 --- a/pkg/cws/api/coordinator.go +++ /dev/null @@ -1,94 +0,0 @@ -package api - -import "github.com/giongto35/cloud-game/v2/pkg/cws" - -const ( - GetRoom = "get_room" - CloseRoom = "close_room" - RegisterRoom = "register_room" - Heartbeat = "heartbeat" - IceCandidate = "ice_candidate" - - NoData = "" - - InitWebrtc = "init_webrtc" - Answer = "answer" - - GameStart = "start" - GameQuit = "quit" - GameSave = "save" - GameLoad = "load" - GamePlayerSelect = "player_index" - GameMultitap = "multitap" - GameRecording = "recording" - GetServerList = "get_server_list" -) - -type GameStartRequest struct { - GameName string `json:"game_name"` - Record bool `json:"record,omitempty"` - RecordUser string `json:"record_user,omitempty"` -} - -func (packet *GameStartRequest) From(data string) error { return from(packet, data) } - -type GameRecordingRequest struct { - Active bool `json:"active"` - User string `json:"user"` -} - -func (packet *GameRecordingRequest) From(data string) error { return from(packet, data) } - -type GameStartCall struct { - Name string `json:"name"` - Base string `json:"base"` - Path string `json:"path"` - Type string `json:"type"` - Record bool `json:"record,omitempty"` - RecordUser string `json:"record_user,omitempty"` -} - -func (packet *GameStartCall) From(data string) error { return from(packet, data) } -func (packet *GameStartCall) To() (string, error) { return to(packet) } - -type ConnectionRequest struct { - Addr string `json:"addr,omitempty"` - IsHTTPS bool `json:"is_https,omitempty"` - PingURL string `json:"ping_url,omitempty"` - Port string `json:"port,omitempty"` - Tag string `json:"tag,omitempty"` - Zone string `json:"zone,omitempty"` - Xid string `json:"xid,omitempty"` -} - -type GetServerListRequest struct{} -type GetServerListResponse struct { - Servers []Server `json:"servers"` -} - -// Server contains a list of server groups. -// Server is a separate machine that may contain -// multiple sub-processes. -type Server struct { - Addr string `json:"addr,omitempty"` - Id string `json:"id,omitempty"` - IsBusy bool `json:"is_busy,omitempty"` - PingURL string `json:"ping_url"` - Port string `json:"port,omitempty"` - Replicas uint32 `json:"replicas,omitempty"` - Tag string `json:"tag,omitempty"` - Zone string `json:"zone,omitempty"` - Xid string `json:"xid,omitempty"` -} - -func (packet *GetServerListRequest) From(data string) error { return from(packet, data) } -func (packet *GetServerListResponse) To() (string, error) { return to(packet) } - -// packets - -func RegisterRoomPacket(data string) cws.WSPacket { return cws.WSPacket{ID: RegisterRoom, Data: data} } -func GetRoomPacket(data string) cws.WSPacket { return cws.WSPacket{ID: GetRoom, Data: data} } -func CloseRoomPacket(data string) cws.WSPacket { return cws.WSPacket{ID: CloseRoom, Data: data} } -func IceCandidatePacket(data string, sessionId string) cws.WSPacket { - return cws.WSPacket{ID: IceCandidate, Data: data, SessionID: sessionId} -} diff --git a/pkg/cws/api/worker.go b/pkg/cws/api/worker.go deleted file mode 100644 index 0bd9f707..00000000 --- a/pkg/cws/api/worker.go +++ /dev/null @@ -1,21 +0,0 @@ -package api - -import "github.com/giongto35/cloud-game/v2/pkg/cws" - -const ( - ServerId = "server_id" - TerminateSession = "terminateSession" -) - -type ConfPushCall struct { - Data []byte `json:"data"` -} - -func (packet *ConfPushCall) From(data string) error { return from(packet, data) } -func (packet *ConfPushCall) To() (string, error) { return to(packet) } - -func ServerIdPacket(id string) cws.WSPacket { return cws.WSPacket{ID: ServerId, Data: id} } -func ConfigRequestPacket(conf []byte) cws.WSPacket { return cws.WSPacket{Data: string(conf)} } -func TerminateSessionPacket(sessionId string) cws.WSPacket { - return cws.WSPacket{ID: TerminateSession, SessionID: sessionId} -} diff --git a/pkg/cws/cws.go b/pkg/cws/cws.go deleted file mode 100644 index 41d311da..00000000 --- a/pkg/cws/cws.go +++ /dev/null @@ -1,219 +0,0 @@ -package cws - -import ( - "encoding/json" - "log" - "sync" - "time" - - "github.com/gofrs/uuid" - "github.com/gorilla/websocket" -) - -type ( - Client struct { - id string - - conn *websocket.Conn - - sendLock sync.Mutex - // sendCallback is callback based on packetID - sendCallback map[string]func(req WSPacket) - sendCallbackLock sync.Mutex - // recvCallback is callback when receive based on ID of the packet - recvCallback map[string]func(req WSPacket) - - Done chan struct{} - } - - WSPacket struct { - ID string `json:"id"` - // TODO: Make Data generic: map[string]interface{} for more usecases - Data string `json:"data"` - - RoomID string `json:"room_id"` - PlayerIndex int `json:"player_index"` - - PacketID string `json:"packet_id"` - // Globally ID of a browser session - SessionID string `json:"session_id"` - } - - PacketHandler func(resp WSPacket) (req WSPacket) -) - -var ( - EmptyPacket = WSPacket{} - HeartbeatPacket = WSPacket{ID: "heartbeat"} -) - -const WSWait = 20 * time.Second - -func NewClient(conn *websocket.Conn) *Client { - id := uuid.Must(uuid.NewV4()).String() - sendCallback := map[string]func(WSPacket){} - recvCallback := map[string]func(WSPacket){} - - return &Client{ - id: id, - conn: conn, - - sendCallback: sendCallback, - recvCallback: recvCallback, - - Done: make(chan struct{}), - } -} - -// Send sends a packet and trigger callback when the packet comes back -func (c *Client) Send(request WSPacket, callback func(response WSPacket)) { - request.PacketID = uuid.Must(uuid.NewV4()).String() - data, err := json.Marshal(request) - if err != nil { - return - } - - // TODO: Consider using lock free - // Wrap callback with sessionID and packetID - if callback != nil { - wrapperCallback := func(resp WSPacket) { - defer func() { - if err := recover(); err != nil { - log.Println("Recovered from err in client callback ", err) - } - }() - - resp.PacketID = request.PacketID - resp.SessionID = request.SessionID - callback(resp) - } - c.sendCallbackLock.Lock() - c.sendCallback[request.PacketID] = wrapperCallback - c.sendCallbackLock.Unlock() - } - - c.sendLock.Lock() - c.conn.SetWriteDeadline(time.Now().Add(WSWait)) - c.conn.WriteMessage(websocket.TextMessage, data) - c.sendLock.Unlock() -} - -// Receive receive and response back -func (c *Client) Receive(id string, f PacketHandler) { - c.recvCallback[id] = func(response WSPacket) { - defer func() { - if err := recover(); err != nil { - log.Println("Recovered from err ", err) - } - }() - - req := f(response) - // Add Meta data - req.PacketID = response.PacketID - req.SessionID = response.SessionID - - // Skip response if it is EmptyPacket - if response == EmptyPacket { - return - } - resp, err := json.Marshal(req) - if err != nil { - log.Println("[!] json marshal error:", err) - } - c.sendLock.Lock() - c.conn.SetWriteDeadline(time.Now().Add(WSWait)) - c.conn.WriteMessage(websocket.TextMessage, resp) - c.sendLock.Unlock() - } -} - -// SyncSend sends a packet and wait for callback till the packet comes back -func (c *Client) SyncSend(request WSPacket) (response WSPacket) { - res := make(chan WSPacket) - f := func(resp WSPacket) { - res <- resp - } - c.Send(request, f) - return <-res -} - -// SendAwait sends some packet while waiting for a tile-limited response -//func (c *Client) SendAwait(packet WSPacket) WSPacket { -// ch := make(chan WSPacket) -// defer close(ch) -// c.Send(packet, func(response WSPacket) { ch <- response }) -// -// for { -// select { -// case packet := <-ch: -// return packet -// case <-time.After(config.WsIpcTimeout): -// log.Printf("Packet receive timeout!") -// return EmptyPacket -// } -// } -//} - -// Heartbeat maintains connection to coordinator. -// Blocking. -func (c *Client) Heartbeat() { - // send heartbeat every 1s - t := time.NewTicker(time.Second) - // don't wait 1 second - c.Send(HeartbeatPacket, nil) - for { - select { - case <-c.Done: - t.Stop() - log.Printf("Close heartbeat") - return - case <-t.C: - c.Send(HeartbeatPacket, nil) - } - } -} - -func (c *Client) Listen() { - for { - c.conn.SetReadDeadline(time.Now().Add(WSWait)) - _, rawMsg, err := c.conn.ReadMessage() - if err != nil { - log.Println("[!] read:", err) - // TODO: Check explicit disconnect error to break - close(c.Done) - break - } - wspacket := WSPacket{} - err = json.Unmarshal(rawMsg, &wspacket) - - if err != nil { - log.Println("Warn: error decoding", rawMsg) - continue - } - - // Check if some async send is waiting for the response based on packetID - // TODO: Change to read lock. - //c.sendCallbackLock.Lock() - callback, ok := c.sendCallback[wspacket.PacketID] - //c.sendCallbackLock.Unlock() - if ok { - go callback(wspacket) - //c.sendCallbackLock.Lock() - delete(c.sendCallback, wspacket.PacketID) - //c.sendCallbackLock.Unlock() - // Skip receiveCallback to avoid duplication - continue - } - // Check if some receiver with the ID is registered - if callback, ok := c.recvCallback[wspacket.ID]; ok { - go callback(wspacket) - } - } -} - -func (c *Client) Close() { - if c == nil || c.conn == nil { - return - } - c.conn.Close() -} diff --git a/pkg/downloader/backend/backend.go b/pkg/downloader/backend/backend.go deleted file mode 100644 index 717b0654..00000000 --- a/pkg/downloader/backend/backend.go +++ /dev/null @@ -1,10 +0,0 @@ -package backend - -type Download struct { - Key string - Address string -} - -type Client interface { - Request(dest string, urls ...Download) ([]string, []string) -} diff --git a/pkg/downloader/backend/grab.go b/pkg/downloader/backend/grab.go deleted file mode 100644 index 08df198e..00000000 --- a/pkg/downloader/backend/grab.go +++ /dev/null @@ -1,58 +0,0 @@ -package backend - -import ( - "crypto/tls" - "log" - "net/http" - - "github.com/cavaliercoder/grab" -) - -type GrabDownloader struct { - client *grab.Client - concurrency int -} - -func NewGrabDownloader() GrabDownloader { - client := grab.Client{ - UserAgent: "Cloud-Game/2.2", - HTTPClient: &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - }, - } - return GrabDownloader{ - client: &client, - concurrency: 5, - } -} - -func (d GrabDownloader) Request(dest string, urls ...Download) (ok []string, nook []string) { - reqs := make([]*grab.Request, 0) - for _, url := range urls { - req, err := grab.NewRequest(dest, url.Address) - if err != nil { - log.Printf("error: couldn't make request URL: %v, %v", url, err) - } else { - req.Label = url.Key - reqs = append(reqs, req) - } - } - - // check each response - for resp := range d.client.DoBatch(d.concurrency, reqs...) { - r := resp.Request - if err := resp.Err(); err != nil { - log.Printf("error: download [%s] %s failed: %v\n", r.Label, r.URL(), err) - if resp.HTTPResponse == nil || resp.HTTPResponse.StatusCode == 404 { - nook = append(nook, resp.Request.Label) - } - } else { - log.Printf("Downloaded [%v] [%s] %v -> %s", resp.HTTPResponse.Status, r.Label, r.URL(), resp.Filename) - ok = append(ok, resp.Filename) - } - } - return -} diff --git a/pkg/downloader/downloader.go b/pkg/downloader/downloader.go deleted file mode 100644 index 66697063..00000000 --- a/pkg/downloader/downloader.go +++ /dev/null @@ -1,39 +0,0 @@ -package downloader - -import ( - "github.com/giongto35/cloud-game/v2/pkg/downloader/backend" - "github.com/giongto35/cloud-game/v2/pkg/downloader/pipe" -) - -type Downloader struct { - backend backend.Client - // pipe contains a sequential list of - // operations applied to some files and - // each operation will return a list of - // successfully processed files - pipe []Process -} - - -type Process func(string, []string) []string - -func NewDefaultDownloader() Downloader { - return Downloader{ - backend: backend.NewGrabDownloader(), - pipe: []Process{ - pipe.Unpack, - pipe.Delete, - }} -} - -// Download tries to download specified with URLs list of files and -// put them into the destination folder. -// It will return a partial or full list of downloaded files, -// a list of processed files if some pipe processing functions are set. -func (d *Downloader) Download(dest string, urls ...backend.Download) ([]string, []string) { - files, fails := d.backend.Request(dest, urls...) - for _, op := range d.pipe { - files = op(dest, files) - } - return files, fails -} diff --git a/pkg/downloader/pipe/pipe.go b/pkg/downloader/pipe/pipe.go deleted file mode 100644 index 79ce63d7..00000000 --- a/pkg/downloader/pipe/pipe.go +++ /dev/null @@ -1,29 +0,0 @@ -package pipe - -import ( - "os" - - "github.com/giongto35/cloud-game/v2/pkg/compression" -) - -func Unpack(dest string, files []string) []string { - var res []string - for _, file := range files { - if unpack := compression.NewExtractorFromExt(file); unpack != nil { - if _, err := unpack.Extract(file, dest); err == nil { - res = append(res, file) - } - } - } - return res -} - -func Delete(_ string, files []string) []string { - var res []string - for _, file := range files { - if e := os.Remove(file); e == nil { - res = append(res, file) - } - } - return res -} diff --git a/pkg/emulator/emulator.go b/pkg/emulator/emulator.go deleted file mode 100644 index 34b1d5d3..00000000 --- a/pkg/emulator/emulator.go +++ /dev/null @@ -1,41 +0,0 @@ -package emulator - -import "github.com/giongto35/cloud-game/v2/pkg/emulator/image" - -// CloudEmulator is the interface of cloud emulator. -type CloudEmulator interface { - // LoadMeta returns meta data of emulator. Refer below - LoadMeta(path string) Metadata - // Start is called after LoadGame - Start() - // SetViewport sets viewport size - SetViewport(width int, height int) - // SaveGame save game state - SaveGame() error - // LoadGame load game state - LoadGame() error - // GetHashPath returns the path emulator will save state to - GetHashPath() string - // Close will be called when the game is done - Close() - - ToggleMultitap() error -} - -type Metadata struct { - // the full path to some emulator lib - LibPath string - // the full path to the emulator config - ConfigPath string - - AudioSampleRate int - Fps float64 - BaseWidth int - BaseHeight int - Ratio float64 - Rotation image.Rotate - IsGlAllowed bool - UsesLibCo bool - AutoGlContext bool - HasMultitap bool -} diff --git a/pkg/emulator/graphics/opengl.go b/pkg/emulator/graphics/opengl.go deleted file mode 100644 index 36e5e2a7..00000000 --- a/pkg/emulator/graphics/opengl.go +++ /dev/null @@ -1,140 +0,0 @@ -package graphics - -import ( - "log" - "unsafe" - - "github.com/giongto35/cloud-game/v2/pkg/emulator/backend/gl" -) - -type offscreenSetup struct { - tex uint32 - fbo uint32 - rbo uint32 - - width int32 - height int32 - - pixType uint32 - pixFormat uint32 - - hasDepth bool - hasStencil bool -} - -var ( - opt = offscreenSetup{} - buf []byte -) - -type PixelFormat int - -const ( - UnsignedShort5551 PixelFormat = iota - UnsignedShort565 - UnsignedInt8888Rev -) - -func initContext(getProcAddr func(name string) unsafe.Pointer) { - if err := gl.InitWithProcAddrFunc(getProcAddr); err != nil { - panic(err) - } -} - -func initFramebuffer(w int, h int, hasDepth bool, hasStencil bool) { - opt.width = int32(w) - opt.height = int32(h) - opt.hasDepth = hasDepth - opt.hasStencil = hasStencil - - // texture init - gl.GenTextures(1, &opt.tex) - gl.BindTexture(gl.TEXTURE_2D, opt.tex) - - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) - - gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, opt.width, opt.height, 0, opt.pixType, opt.pixFormat, nil) - gl.BindTexture(gl.TEXTURE_2D, 0) - - // framebuffer init - gl.GenFramebuffers(1, &opt.fbo) - gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) - - gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, opt.tex, 0) - - // more buffers init - if opt.hasDepth { - gl.GenRenderbuffers(1, &opt.rbo) - gl.BindRenderbuffer(gl.RENDERBUFFER, opt.rbo) - if opt.hasStencil { - gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, opt.width, opt.height) - gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, opt.rbo) - } else { - gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, opt.width, opt.height) - gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, opt.rbo) - } - gl.BindRenderbuffer(gl.RENDERBUFFER, 0) - } - - status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER) - if status != gl.FRAMEBUFFER_COMPLETE { - if e := gl.GetError(); e != gl.NO_ERROR { - log.Printf("[OpenGL] GL error: 0x%X, Frame status: 0x%X", e, status) - panic("OpenGL error") - } - log.Printf("[OpenGL] frame status: 0x%X", status) - panic("OpenGL framebuffer is invalid") - } -} - -func destroyFramebuffer() { - if opt.hasDepth { - gl.DeleteRenderbuffers(1, &opt.rbo) - } - gl.DeleteFramebuffers(1, &opt.fbo) - gl.DeleteTextures(1, &opt.tex) -} - -func ReadFramebuffer(bytes int, w int, h int) []byte { - data := buf[:bytes] - gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) - gl.ReadPixels(0, 0, int32(w), int32(h), opt.pixType, opt.pixFormat, unsafe.Pointer(&data[0])) - gl.BindFramebuffer(gl.FRAMEBUFFER, 0) - return data -} - -func getFbo() uint32 { return opt.fbo } - -func SetBuffer(size int) { buf = make([]byte, size) } - -func SetPixelFormat(format PixelFormat) { - switch format { - case UnsignedShort5551: - opt.pixFormat = gl.UNSIGNED_SHORT_5_5_5_1 - opt.pixType = gl.BGRA - case UnsignedShort565: - opt.pixFormat = gl.UNSIGNED_SHORT_5_6_5 - opt.pixType = gl.RGB - case UnsignedInt8888Rev: - opt.pixFormat = gl.UNSIGNED_INT_8_8_8_8_REV - opt.pixType = gl.BGRA - default: - log.Fatalf("[opengl] Error! Unknown pixel type %v", format) - } -} - -// PrintDriverInfo prints OpenGL information. -func PrintDriverInfo() { - // OpenGL info - log.Printf("[OpenGL] Version: %v", get(gl.VERSION)) - log.Printf("[OpenGL] Vendor: %v", get(gl.VENDOR)) - // This string is often the name of the GPU. - // In the case of Mesa3d, it would be i.e "Gallium 0.4 on NVA8". - // It might even say "Direct3D" if the Windows Direct3D wrapper is being used. - log.Printf("[OpenGL] Renderer: %v", get(gl.RENDERER)) - log.Printf("[OpenGL] GLSL Version: %v", get(gl.SHADING_LANGUAGE_VERSION)) -} - -func get(name uint32) string { return gl.GoStr(gl.GetString(name)) } -func getDriverError() uint32 { return gl.GetError() } diff --git a/pkg/emulator/graphics/sdl.go b/pkg/emulator/graphics/sdl.go deleted file mode 100644 index 1f17cd1a..00000000 --- a/pkg/emulator/graphics/sdl.go +++ /dev/null @@ -1,132 +0,0 @@ -package graphics - -import ( - "log" - "unsafe" - - "github.com/giongto35/cloud-game/v2/pkg/thread" - "github.com/veandco/go-sdl2/sdl" -) - -type data struct { - w *sdl.Window - glWCtx sdl.GLContext -} - -// singleton state for SDL -var state = data{} - -type Config struct { - Ctx Context - W int - H int - Gl GlConfig -} -type GlConfig struct { - AutoContext bool - VersionMajor uint - VersionMinor uint - HasDepth bool - HasStencil bool -} - -// Init initializes SDL/OpenGL context. -// Uses main thread lock (see thread/mainthread). -func Init(cfg Config) { - log.Printf("[SDL] [OpenGL] initialization...") - if err := sdl.Init(sdl.INIT_VIDEO); err != nil { - log.Printf("[SDL] error: %v", err) - panic("SDL initialization failed") - } - - if cfg.Gl.AutoContext { - log.Printf("[OpenGL] CONTEXT_AUTO (type: %v v%v.%v)", cfg.Ctx, cfg.Gl.VersionMajor, cfg.Gl.VersionMinor) - } else { - switch cfg.Ctx { - case CtxOpenGlCore: - setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE) - log.Printf("[OpenGL] CONTEXT_PROFILE_CORE") - case CtxOpenGlEs2: - setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_ES) - setAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, 3) - setAttribute(sdl.GL_CONTEXT_MINOR_VERSION, 0) - log.Printf("[OpenGL] CONTEXT_PROFILE_ES 3.0") - case CtxOpenGl: - if cfg.Gl.VersionMajor >= 3 { - setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_COMPATIBILITY) - } - log.Printf("[OpenGL] CONTEXT_PROFILE_COMPATIBILITY") - default: - log.Printf("Unsupported hw context: %v", cfg.Ctx) - } - } - - // In OSX 10.14+ window creation and context creation must happen in the main thread - thread.Main(createWindow) - - BindContext() - - initContext(sdl.GLGetProcAddress) - PrintDriverInfo() - initFramebuffer(cfg.W, cfg.H, cfg.Gl.HasDepth, cfg.Gl.HasStencil) -} - -// Deinit destroys SDL/OpenGL context. -// Uses main thread lock (see thread/mainthread). -func Deinit() { - log.Printf("[SDL] [OpenGL] deinitialization...") - destroyFramebuffer() - // In OSX 10.14+ window deletion must happen in the main thread - thread.Main(destroyWindow) - sdl.Quit() - log.Printf("[SDL] [OpenGL] deinitialized (%v, %v)", sdl.GetError(), getDriverError()) -} - -// createWindow creates fake SDL window for OpenGL initialization purposes. -func createWindow() { - var winTitle = "CloudRetro dummy window" - var winWidth, winHeight int32 = 1, 1 - - var err error - if state.w, err = sdl.CreateWindow( - winTitle, - sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, - winWidth, winHeight, - sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN, - ); err != nil { - panic(err) - } - if state.glWCtx, err = state.w.GLCreateContext(); err != nil { - panic(err) - } -} - -// destroyWindow destroys previously created SDL window. -func destroyWindow() { - BindContext() - sdl.GLDeleteContext(state.glWCtx) - if err := state.w.Destroy(); err != nil { - log.Printf("[SDL] couldn't destroy the window, error: %v", err) - } -} - -// BindContext explicitly binds context to current thread. -func BindContext() { - if err := state.w.GLMakeCurrent(state.glWCtx); err != nil { - log.Printf("[SDL] error: %v", err) - } -} - -func GetGlFbo() uint32 { - return getFbo() -} - -func GetGlProcAddress(proc string) unsafe.Pointer { - return sdl.GLGetProcAddress(proc) -} - -func setAttribute(attr sdl.GLattr, value int) { - if err := sdl.GLSetAttribute(attr, value); err != nil { - log.Printf("[SDL] attribute error: %v", err) - } -} diff --git a/pkg/emulator/image/color.go b/pkg/emulator/image/color.go deleted file mode 100644 index 6104c3be..00000000 --- a/pkg/emulator/image/color.go +++ /dev/null @@ -1,27 +0,0 @@ -package image - -import "unsafe" - -const ( - // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha - BitFormatShort5551 = iota - // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha - BitFormatInt8888Rev - // BIT_FORMAT_SHORT_5_6_5 has 5 bits R, 6 bits G, 5 bits - BitFormatShort565 -) - -type RGB struct { - R, G, B uint8 -} - -type Format func(data []byte, index int) RGB - -func Rgb565(data []byte, index int) RGB { - pixel := *(*uint16)(unsafe.Pointer(&data[index])) - return RGB{R: uint8((pixel >> 8) & 0xf8), G: uint8((pixel >> 3) & 0xfc), B: uint8((pixel << 3) & 0xfc)} -} - -func Rgba8888(data []byte, index int) RGB { - return RGB{R: data[index+2], G: data[index+1], B: data[index]} -} diff --git a/pkg/emulator/image/draw.go b/pkg/emulator/image/draw.go deleted file mode 100644 index 0d1358c6..00000000 --- a/pkg/emulator/image/draw.go +++ /dev/null @@ -1,82 +0,0 @@ -package image - -import ( - "image" - "sync" -) - -type imageCache struct { - image *image.RGBA - w int - h int -} - -func (i *imageCache) get(w, h int) *image.RGBA { - if i.w == w && i.h == h { - return i.image - } - i.w, i.h = w, h - i.image = image.NewRGBA(image.Rect(0, 0, w, h)) - return i.image -} - -var ( - canvas1 = imageCache{image.NewRGBA(image.Rectangle{}), 0, 0} - canvas2 = imageCache{image.NewRGBA(image.Rectangle{}), 0, 0} - wg sync.WaitGroup -) - -func DrawRgbaImage(pixFormat Format, rot *Rotate, scaleType int, flipV bool, w, h, packedW, bpp int, - data []byte, dw, dh, th int) *image.RGBA { - // !to implement own image interfaces img.Pix = bytes[] - ww, hh := w, h - if rot != nil && rot.IsEven { - ww, hh = hh, ww - } - src := canvas1.get(ww, hh) - - normY := !flipV - hn := h / th - pwb := packedW * bpp - wg.Add(th) - for i := 0; i < th; i++ { - xx := hn * i - go func() { - for y, yy, l, lx, row := xx, 0, xx+hn, 0, 0; y < l; y++ { - if normY { - yy = y - } else { - yy = (h - 1) - y - } - row = yy * src.Stride - lx = y * pwb - for x, k := 0, 0; x < w; x++ { - if rot == nil { - k = x<<2 + row - } else { - dx, dy := rot.Call(x, yy, w, h) - k = dx<<2 + dy*src.Stride - } - r := pixFormat(data, x*bpp+lx) - src.Pix[k], src.Pix[k+1], src.Pix[k+2], src.Pix[k+3] = r.R, r.G, r.B, 255 - } - } - wg.Done() - }() - } - wg.Wait() - - if ww == dw && hh == dh { - return src - } else { - out := canvas2.get(dw, dh) - Resize(scaleType, src, out) - return out - } -} - -func Clear() { - wg = sync.WaitGroup{} - canvas1.get(0, 0) - canvas2.get(0, 0) -} diff --git a/pkg/emulator/libretro/nanoarch/cfuncs.go b/pkg/emulator/libretro/nanoarch/cfuncs.go deleted file mode 100644 index 936b12a4..00000000 --- a/pkg/emulator/libretro/nanoarch/cfuncs.go +++ /dev/null @@ -1,204 +0,0 @@ -package nanoarch - -/* -#include "libretro.h" -#include -#include -#include -#include - -void coreLog(enum retro_log_level level, const char *msg); - -void bridge_retro_init(void *f) { - coreLog(RETRO_LOG_INFO, "[Libretro] Initialization...\n"); - return ((void (*)(void))f)(); -} - -void bridge_retro_deinit(void *f) { - coreLog(RETRO_LOG_INFO, "[Libretro] Deinitialiazation...\n"); - return ((void (*)(void))f)(); -} - -unsigned bridge_retro_api_version(void *f) { - return ((unsigned (*)(void))f)(); -} - -void bridge_retro_get_system_info(void *f, struct retro_system_info *si) { - return ((void (*)(struct retro_system_info *))f)(si); -} - -void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si) { - return ((void (*)(struct retro_system_av_info *))f)(si); -} - -bool bridge_retro_set_environment(void *f, void *callback) { - return ((bool (*)(retro_environment_t))f)((retro_environment_t)callback); -} - -void bridge_retro_set_video_refresh(void *f, void *callback) { - ((bool (*)(retro_video_refresh_t))f)((retro_video_refresh_t)callback); -} - -void bridge_retro_set_input_poll(void *f, void *callback) { - ((bool (*)(retro_input_poll_t))f)((retro_input_poll_t)callback); -} - -void bridge_retro_set_input_state(void *f, void *callback) { - ((bool (*)(retro_input_state_t))f)((retro_input_state_t)callback); -} - -void bridge_retro_set_audio_sample(void *f, void *callback) { - ((bool (*)(retro_audio_sample_t))f)((retro_audio_sample_t)callback); -} - -void bridge_retro_set_audio_sample_batch(void *f, void *callback) { - ((bool (*)(retro_audio_sample_batch_t))f)((retro_audio_sample_batch_t)callback); -} - -bool bridge_retro_load_game(void *f, struct retro_game_info *gi) { - coreLog(RETRO_LOG_INFO, "[Libretro] Loading the game...\n"); - return ((bool (*)(struct retro_game_info *))f)(gi); -} - -void bridge_retro_unload_game(void *f) { - coreLog(RETRO_LOG_INFO, "[Libretro] Unloading the game...\n"); - return ((void (*)(void))f)(); -} - -void bridge_retro_run(void *f) { - return ((void (*)(void))f)(); -} - -size_t bridge_retro_get_memory_size(void *f, unsigned id) { - return ((size_t (*)(unsigned))f)(id); -} - -void* bridge_retro_get_memory_data(void *f, unsigned id) { - return ((void* (*)(unsigned))f)(id); -} - -size_t bridge_retro_serialize_size(void *f) { - return ((size_t (*)(void))f)(); -} - -bool bridge_retro_serialize(void *f, void *data, size_t size) { - return ((bool (*)(void*, size_t))f)(data, size); -} - -bool bridge_retro_unserialize(void *f, void *data, size_t size) { - return ((bool (*)(void*, size_t))f)(data, size); -} - -void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device) { - return ((void (*)(unsigned, unsigned))f)(port, device); -} - -bool coreEnvironment_cgo(unsigned cmd, void *data) { - bool coreEnvironment(unsigned, void*); - return coreEnvironment(cmd, data); -} - -void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch) { - void coreVideoRefresh(void*, unsigned, unsigned, size_t); - return coreVideoRefresh(data, width, height, pitch); -} - -void coreInputPoll_cgo() { - void coreInputPoll(); - return coreInputPoll(); -} - -int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id) { - int16_t coreInputState(unsigned, unsigned, unsigned, unsigned); - return coreInputState(port, device, index, id); -} - -void coreAudioSample_cgo(int16_t left, int16_t right) { - void coreAudioSample(int16_t, int16_t); - coreAudioSample(left, right); -} - -size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames) { - size_t coreAudioSampleBatch(const int16_t*, size_t); - return coreAudioSampleBatch(data, frames); -} - -void coreLog_cgo(enum retro_log_level level, const char *fmt, ...) { - char msg[4096] = {0}; - va_list va; - va_start(va, fmt); - vsnprintf(msg, sizeof(msg), fmt, va); - va_end(va); - - coreLog(level, msg); -} - -uintptr_t coreGetCurrentFramebuffer_cgo() { - uintptr_t coreGetCurrentFramebuffer(); - return coreGetCurrentFramebuffer(); -} - -retro_proc_address_t coreGetProcAddress_cgo(const char *sym) { - retro_proc_address_t coreGetProcAddress(const char *sym); - return coreGetProcAddress(sym); -} - -void bridge_context_reset(retro_hw_context_reset_t f) { - f(); -} - -void initVideo_cgo() { - void initVideo(); - return initVideo(); -} - -void deinitVideo_cgo() { - void deinitVideo(); - return deinitVideo(); -} - -void* function; -pthread_t thread; -int initialized = 0; -pthread_mutex_t run_mutex; -pthread_cond_t run_cv; -pthread_mutex_t done_mutex; -pthread_cond_t done_cv; - -void *run_loop(void *unused) { - pthread_mutex_lock(&done_mutex); - pthread_mutex_lock(&run_mutex); - pthread_cond_signal(&done_cv); - pthread_mutex_unlock(&done_mutex); - while(1) { - pthread_cond_wait(&run_cv, &run_mutex); - ((void (*)(void))function)(); - pthread_mutex_lock(&done_mutex); - pthread_cond_signal(&done_cv); - pthread_mutex_unlock(&done_mutex); - } - pthread_mutex_unlock(&run_mutex); -} - -void bridge_execute(void *f) { - if (!initialized) { - initialized = 1; - pthread_mutex_init(&run_mutex, NULL); - pthread_cond_init(&run_cv, NULL); - pthread_mutex_init(&done_mutex, NULL); - pthread_cond_init(&done_cv, NULL); - pthread_mutex_lock(&done_mutex); - pthread_create(&thread, NULL, run_loop, NULL); - pthread_cond_wait(&done_cv, &done_mutex); - pthread_mutex_unlock(&done_mutex); - } - pthread_mutex_lock(&run_mutex); - pthread_mutex_lock(&done_mutex); - function = f; - pthread_cond_signal(&run_cv); - pthread_mutex_unlock(&run_mutex); - pthread_cond_wait(&done_cv, &done_mutex); - pthread_mutex_unlock(&done_mutex); -} -*/ -import "C" diff --git a/pkg/emulator/libretro/nanoarch/configscanner.go b/pkg/emulator/libretro/nanoarch/configscanner.go deleted file mode 100644 index c61a816d..00000000 --- a/pkg/emulator/libretro/nanoarch/configscanner.go +++ /dev/null @@ -1,46 +0,0 @@ -package nanoarch - -import ( - "bufio" - "log" - "os" - "strings" -) - -import "C" - -type ConfigProperties map[string]*C.char - -func ScanConfigFile(filename string) ConfigProperties { - config := ConfigProperties{} - - if len(filename) == 0 { - return config - } - file, err := os.Open(filename) - if err != nil { - log.Printf("warning: couldn't find the %v config file", filename) - return config - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - if equal := strings.Index(line, "="); equal >= 0 { - if key := strings.TrimSpace(line[:equal]); len(key) > 0 { - value := "" - if len(line) > equal { - value = strings.TrimSpace(line[equal+1:]) - } - config[key] = C.CString(value) - } - } - } - - if err := scanner.Err(); err != nil { - log.Fatal(err) - } - - return config -} diff --git a/pkg/emulator/libretro/nanoarch/input.go b/pkg/emulator/libretro/nanoarch/input.go deleted file mode 100644 index b218350c..00000000 --- a/pkg/emulator/libretro/nanoarch/input.go +++ /dev/null @@ -1,97 +0,0 @@ -package nanoarch - -import "sync" - -const ( - // how many axes on the D-pad - dpadAxesNum = 4 - // the upper limit on how many controllers (players) - // are possible for one play session (emulator instance) - controllersNum = 8 -) - -const ( - InputTerminate = 0xFFFF -) - -type Players struct { - session playerSession -} - -type playerSession struct { - sync.RWMutex - - state map[string][]controllerState -} - -type controllerState struct { - keyState uint16 - axes [dpadAxesNum]int16 -} - -func NewPlayerSessionInput() Players { - return Players{ - session: playerSession{ - state: map[string][]controllerState{}, - }, - } -} - -// close terminates user input session. -func (ps *playerSession) close(id string) { - ps.Lock() - defer ps.Unlock() - - delete(ps.state, id) -} - -// setInput sets input state for some player in a game session. -func (ps *playerSession) setInput(id string, player int, buttons uint16, dpad []byte) { - ps.Lock() - defer ps.Unlock() - - if _, ok := ps.state[id]; !ok { - ps.state[id] = make([]controllerState, controllersNum) - } - - ps.state[id][player].keyState = buttons - for i, axes := 0, len(dpad); i < dpadAxesNum && (i+1)*2+1 < axes; i++ { - axis := (i + 1) * 2 - ps.state[id][player].axes[i] = int16(dpad[axis+1])<<8 + int16(dpad[axis]) - } -} - -// isKeyPressed checks if some button is pressed by any player. -func (p *Players) isKeyPressed(player uint, key int) (pressed bool) { - p.session.RLock() - defer p.session.RUnlock() - - for k := range p.session.state { - if ((p.session.state[k][player].keyState >> uint(key)) & 1) == 1 { - return true - } - } - return -} - -// isDpadTouched checks if D-pad is used by any player. -func (p *Players) isDpadTouched(player uint, axis uint) (shift int16) { - p.session.RLock() - defer p.session.RUnlock() - - for k := range p.session.state { - value := p.session.state[k][player].axes[axis] - if value != 0 { - return value - } - } - return -} - -type InputEvent struct { - RawState []byte - PlayerIdx int - ConnID string -} - -func (ie InputEvent) bitmap() uint16 { return uint16(ie.RawState[1])<<8 + uint16(ie.RawState[0]) } diff --git a/pkg/emulator/libretro/nanoarch/input_test.go b/pkg/emulator/libretro/nanoarch/input_test.go deleted file mode 100644 index 771c417b..00000000 --- a/pkg/emulator/libretro/nanoarch/input_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package nanoarch - -import ( - "math/rand" - "testing" -) - -func TestConcurrentInput(t *testing.T) { - players := NewPlayerSessionInput() - - session := "mad-test-session" - events := 1000 - go func() { - for i := 0; i < events*2; i++ { - player := rand.Intn(controllersNum) - go players.session.setInput(session, player, 100, []byte{}) - // here it usually crashes - go players.session.close(session) - } - }() - go func() { - for i := 0; i < events*2; i++ { - player := rand.Intn(controllersNum) - go players.isKeyPressed(uint(player), 100) - } - }() -} diff --git a/pkg/emulator/libretro/nanoarch/naemulator.go b/pkg/emulator/libretro/nanoarch/naemulator.go deleted file mode 100644 index dad2ee85..00000000 --- a/pkg/emulator/libretro/nanoarch/naemulator.go +++ /dev/null @@ -1,252 +0,0 @@ -package nanoarch - -import ( - "bytes" - "encoding/gob" - "fmt" - "image" - "log" - "net" - "sync" - "time" - - config "github.com/giongto35/cloud-game/v2/pkg/config/emulator" - "github.com/giongto35/cloud-game/v2/pkg/emulator" -) - -/* -#include "libretro.h" -#cgo LDFLAGS: -ldl -#include -#include -#include -#include - -void bridge_retro_deinit(void *f); -unsigned bridge_retro_api_version(void *f); -void bridge_retro_get_system_info(void *f, struct retro_system_info *si); -void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si); -bool bridge_retro_set_environment(void *f, void *callback); -void bridge_retro_set_video_refresh(void *f, void *callback); -void bridge_retro_set_input_poll(void *f, void *callback); -void bridge_retro_set_input_state(void *f, void *callback); -void bridge_retro_set_audio_sample(void *f, void *callback); -void bridge_retro_set_audio_sample_batch(void *f, void *callback); -bool bridge_retro_load_game(void *f, struct retro_game_info *gi); -void bridge_retro_run(void *f); -size_t bridge_retro_get_memory_size(void *f, unsigned id); -void* bridge_retro_get_memory_data(void *f, unsigned id); -bool bridge_retro_serialize(void *f, void *data, size_t size); -bool bridge_retro_unserialize(void *f, void *data, size_t size); -size_t bridge_retro_serialize_size(void *f); - -bool coreEnvironment_cgo(unsigned cmd, void *data); -void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch); -void coreInputPoll_cgo(); -void coreAudioSample_cgo(int16_t left, int16_t right); -size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames); -int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id); -void coreLog_cgo(enum retro_log_level level, const char *msg); -*/ -import "C" - -type naEmulator struct { - sync.Mutex - - imageChannel chan<- GameFrame - audioChannel chan<- []int16 - inputChannel <-chan InputEvent - videoExporter *VideoExporter - - meta emulator.Metadata - gamePath string - roomID string - gameName string - isSavingLoading bool - storage Storage - saveCompression bool - - // out frame size - vw, vh int - - // draw threads - th int - - players Players - - done chan struct{} -} - -// VideoExporter produces image frame to unix socket -type VideoExporter struct { - sock net.Conn - imageChannel chan<- GameFrame -} - -// GameFrame contains image and timeframe -type GameFrame struct { - Data *image.RGBA - Duration time.Duration -} - -var NAEmulator *naEmulator - -// NAEmulator implements CloudEmulator interface based on NanoArch(golang RetroArch) -func NewNAEmulator(roomID string, inputChannel <-chan InputEvent, storage Storage, conf config.LibretroCoreConfig, threads int) (*naEmulator, chan GameFrame, chan []int16) { - imageChannel := make(chan GameFrame, 30) - audioChannel := make(chan []int16, 30) - - return &naEmulator{ - meta: emulator.Metadata{ - LibPath: conf.Lib, - ConfigPath: conf.Config, - Ratio: conf.Ratio, - IsGlAllowed: conf.IsGlAllowed, - UsesLibCo: conf.UsesLibCo, - HasMultitap: conf.HasMultitap, - AutoGlContext: conf.AutoGlContext, - }, - storage: storage, - imageChannel: imageChannel, - audioChannel: audioChannel, - inputChannel: inputChannel, - players: NewPlayerSessionInput(), - roomID: roomID, - done: make(chan struct{}, 1), - th: threads, - }, imageChannel, audioChannel -} - -// NewVideoExporter creates new video Exporter that produces to unix socket -func NewVideoExporter(roomID string, imgChannel chan GameFrame) *VideoExporter { - sockAddr := fmt.Sprintf("/tmp/cloudretro-retro-%s.sock", roomID) - - go func(sockAddr string) { - log.Println("Dialing to ", sockAddr) - conn, err := net.Dial("unix", sockAddr) - if err != nil { - log.Fatal("accept error: ", err) - } - - defer conn.Close() - - for img := range imgChannel { - reqBodyBytes := new(bytes.Buffer) - gob.NewEncoder(reqBodyBytes).Encode(img) - //fmt.Printf("%+v %+v %+v \n", img.Image.Stride, img.Image.Rect.Max.X, len(img.Image.Pix)) - // conn.Write(img.Image.Pix) - b := reqBodyBytes.Bytes() - fmt.Printf("Bytes %d\n", len(b)) - conn.Write(b) - } - }(sockAddr) - - return &VideoExporter{imageChannel: imgChannel} -} - -// Init initialize new RetroArch cloud emulator -// withImageChan returns an image stream as Channel for output else it will write to unix socket -func Init(roomID string, withImageChannel bool, inputChannel <-chan InputEvent, storage Storage, config config.LibretroCoreConfig, threads int) (*naEmulator, chan GameFrame, chan []int16) { - emu, imageChannel, audioChannel := NewNAEmulator(roomID, inputChannel, storage, config, threads) - // Set to global NAEmulator - NAEmulator = emu - if !withImageChannel { - NAEmulator.videoExporter = NewVideoExporter(roomID, imageChannel) - } - - go NAEmulator.listenInput() - - return emu, imageChannel, audioChannel -} - -// listenInput handles user input. -// The user input is encoded as bitmap that we decode -// and send into the game emulator. -func (na *naEmulator) listenInput() { - for in := range NAEmulator.inputChannel { - bitmap := in.bitmap() - if bitmap == InputTerminate { - na.players.session.close(in.ConnID) - continue - } - na.players.session.setInput(in.ConnID, in.PlayerIdx, bitmap, in.RawState) - } -} - -func (na *naEmulator) LoadMeta(path string) emulator.Metadata { - coreLoad(na.meta) - coreLoadGame(path) - na.gamePath = path - return na.meta -} - -func (na *naEmulator) SetViewport(width int, height int) { na.vw, na.vh = width, height } - -func (na *naEmulator) Start() { - err := na.LoadGame() - if err != nil { - log.Printf("error: couldn't load a save, %v", err) - } - - framerate := 1 / na.meta.Fps - log.Printf("framerate: %vms", framerate) - ticker := time.NewTicker(time.Second / time.Duration(na.meta.Fps)) - defer ticker.Stop() - - lastFrameTime = time.Now().UnixNano() - - for { - na.Lock() - nanoarchRun() - na.Unlock() - - select { - case <-ticker.C: - continue - case <-na.done: - nanoarchShutdown() - close(na.imageChannel) - close(na.audioChannel) - log.Println("Closed Director") - return - } - } -} - -func (na *naEmulator) SaveGame() error { - // !to fix - if usesLibCo { - return nil - } - if na.roomID != "" { - return na.Save() - } - return nil -} - -func (na *naEmulator) LoadGame() error { - // !to fix - if usesLibCo { - return nil - } - if na.roomID != "" { - err := na.Load() - if err != nil { - return err - } - } - return nil -} - -func (na *naEmulator) ToggleMultitap() error { - if na.roomID != "" { - toggleMultitap() - } - return nil -} - -func (na *naEmulator) GetHashPath() string { return na.storage.GetSavePath() } - -func (na *naEmulator) GetSRAMPath() string { return na.storage.GetSRAMPath() } - -func (na *naEmulator) Close() { close(na.done) } diff --git a/pkg/emulator/libretro/nanoarch/nanoarch.go b/pkg/emulator/libretro/nanoarch/nanoarch.go deleted file mode 100644 index 6423ec83..00000000 --- a/pkg/emulator/libretro/nanoarch/nanoarch.go +++ /dev/null @@ -1,697 +0,0 @@ -package nanoarch - -import ( - "bufio" - "fmt" - "log" - "os" - "os/user" - "runtime" - "sync" - "time" - "unsafe" - - "github.com/giongto35/cloud-game/v2/pkg/emulator" - "github.com/giongto35/cloud-game/v2/pkg/emulator/graphics" - "github.com/giongto35/cloud-game/v2/pkg/emulator/image" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" - "github.com/giongto35/cloud-game/v2/pkg/thread" -) - -/* -#include "libretro.h" -#include -#include -#include - -void bridge_retro_init(void *f); -void bridge_retro_deinit(void *f); -unsigned bridge_retro_api_version(void *f); -void bridge_retro_get_system_info(void *f, struct retro_system_info *si); -void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si); -bool bridge_retro_set_environment(void *f, void *callback); -void bridge_retro_set_video_refresh(void *f, void *callback); -void bridge_retro_set_input_poll(void *f, void *callback); -void bridge_retro_set_input_state(void *f, void *callback); -void bridge_retro_set_audio_sample(void *f, void *callback); -void bridge_retro_set_audio_sample_batch(void *f, void *callback); -bool bridge_retro_load_game(void *f, struct retro_game_info *gi); -void bridge_retro_unload_game(void *f); -void bridge_retro_run(void *f); -void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device); - -bool coreEnvironment_cgo(unsigned cmd, void *data); -void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch); -void coreInputPoll_cgo(); -void coreAudioSample_cgo(int16_t left, int16_t right); -size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames); -int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id); -void coreLog_cgo(enum retro_log_level level, const char *msg); -uintptr_t coreGetCurrentFramebuffer_cgo(); -retro_proc_address_t coreGetProcAddress_cgo(const char *sym); - -void bridge_context_reset(retro_hw_context_reset_t f); - -void initVideo_cgo(); -void deinitVideo_cgo(); -void bridge_execute(void *f); -*/ -import "C" - -var mu sync.Mutex -var lastFrameTime int64 - -var video struct { - pitch uint32 - pixFmt uint32 - bpp uint32 - rotation image.Angle - - baseWidth int32 - baseHeight int32 - maxWidth int32 - maxHeight int32 - - hw *C.struct_retro_hw_render_callback - isGl bool - autoGlContext bool -} - -// default core pix format converter -var pixelFormatConverterFn = image.Rgb565 -var rotationFn *image.Rotate - -//const joypadNumKeys = int(C.RETRO_DEVICE_ID_JOYPAD_R3 + 1) -//var joy [joypadNumKeys]bool - -var isGlAllowed bool -var usesLibCo bool -var coreConfig ConfigProperties - -var multitap struct { - supported bool - enabled bool - value C.unsigned -} - -var systemDirectory = C.CString("./pkg/emulator/libretro/system") -var saveDirectory = C.CString(".") -var currentUser *C.char - -var bindKeysMap = map[int]int{ - C.RETRO_DEVICE_ID_JOYPAD_A: 0, - C.RETRO_DEVICE_ID_JOYPAD_B: 1, - C.RETRO_DEVICE_ID_JOYPAD_X: 2, - C.RETRO_DEVICE_ID_JOYPAD_Y: 3, - C.RETRO_DEVICE_ID_JOYPAD_L: 4, - C.RETRO_DEVICE_ID_JOYPAD_R: 5, - C.RETRO_DEVICE_ID_JOYPAD_SELECT: 6, - C.RETRO_DEVICE_ID_JOYPAD_START: 7, - C.RETRO_DEVICE_ID_JOYPAD_UP: 8, - C.RETRO_DEVICE_ID_JOYPAD_DOWN: 9, - C.RETRO_DEVICE_ID_JOYPAD_LEFT: 10, - C.RETRO_DEVICE_ID_JOYPAD_RIGHT: 11, - C.RETRO_DEVICE_ID_JOYPAD_R2: 12, - C.RETRO_DEVICE_ID_JOYPAD_L2: 13, - C.RETRO_DEVICE_ID_JOYPAD_R3: 14, - C.RETRO_DEVICE_ID_JOYPAD_L3: 15, -} - -type CloudEmulator interface { - Start(path string) - SaveGame(saveExtraFunc func() error) error - LoadGame() error - GetHashPath() string - Close() - ToggleMultitap() error -} - -//export coreVideoRefresh -func coreVideoRefresh(data unsafe.Pointer, width C.unsigned, height C.unsigned, pitch C.size_t) { - // some cores can return nothing - // !to add duplicate if can dup - if data == nil { - return - } - - // calculate real frame width in pixels from packed data (realWidth >= width) - packedWidth := int(uint32(pitch) / video.bpp) - if packedWidth < 1 { - packedWidth = int(width) - } - // calculate space for the video frame - bytes := int(height) * packedWidth * int(video.bpp) - - // if Libretro renders frame with OpenGL context - isOpenGLRender := data == C.RETRO_HW_FRAME_BUFFER_VALID - var data_ []byte - if isOpenGLRender { - data_ = graphics.ReadFramebuffer(bytes, int(width), int(height)) - } else { - data_ = (*[1 << 30]byte)(data)[:bytes:bytes] - } - - // the image is being resized and de-rotated - frame := image.DrawRgbaImage( - pixelFormatConverterFn, - rotationFn, - image.ScaleNearestNeighbour, - isOpenGLRender, - int(width), int(height), packedWidth, int(video.bpp), - data_, - NAEmulator.vw, - NAEmulator.vh, - NAEmulator.th, - ) - - t := time.Now().UnixNano() - dt := time.Duration(t - lastFrameTime) - lastFrameTime = t - - select { - case NAEmulator.imageChannel <- GameFrame{Data: frame, Duration: dt}: - default: - } -} - -//export coreInputPoll -func coreInputPoll() { -} - -//export coreInputState -func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t { - if device == C.RETRO_DEVICE_ANALOG { - if index > C.RETRO_DEVICE_INDEX_ANALOG_RIGHT || id > C.RETRO_DEVICE_ID_ANALOG_Y { - return 0 - } - axis := index*2 + id - value := NAEmulator.players.isDpadTouched(uint(port), uint(axis)) - if value != 0 { - return (C.int16_t)(value) - } - } - - if id >= 255 || index > 0 || device != C.RETRO_DEVICE_JOYPAD { - return 0 - } - - // map from id to control key - key, ok := bindKeysMap[int(id)] - if !ok { - return 0 - } - - if NAEmulator.players.isKeyPressed(uint(port), key) { - return 1 - } - - return 0 -} - -func audioWrite(buf unsafe.Pointer, frames C.size_t) C.size_t { - samples := int(frames) << 1 - pcm := (*[4096]int16)(buf)[:samples:samples] - p := make([]int16, samples) - copy(p, pcm) - - select { - case NAEmulator.audioChannel <- p: - default: - } - - return frames -} - -//export coreAudioSample -func coreAudioSample(left C.int16_t, right C.int16_t) { - buf := []C.int16_t{left, right} - audioWrite(unsafe.Pointer(&buf), 1) -} - -//export coreAudioSampleBatch -func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t { - return audioWrite(data, frames) -} - -//export coreLog -func coreLog(_ C.enum_retro_log_level, msg *C.char) { - log.Printf("[Log] %v", C.GoString(msg)) -} - -//export coreGetCurrentFramebuffer -func coreGetCurrentFramebuffer() C.uintptr_t { - return (C.uintptr_t)(graphics.GetGlFbo()) -} - -//export coreGetProcAddress -func coreGetProcAddress(sym *C.char) C.retro_proc_address_t { - return (C.retro_proc_address_t)(graphics.GetGlProcAddress(C.GoString(sym))) -} - -//export coreEnvironment -func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { - switch cmd { - case C.RETRO_ENVIRONMENT_GET_USERNAME: - username := (**C.char)(data) - if currentUser == nil { - currentUserGo, err := user.Current() - if err != nil { - currentUser = C.CString("") - } else { - currentUser = C.CString(currentUserGo.Username) - } - } - *username = currentUser - case C.RETRO_ENVIRONMENT_GET_LOG_INTERFACE: - cb := (*C.struct_retro_log_callback)(data) - cb.log = (C.retro_log_printf_t)(C.coreLog_cgo) - case C.RETRO_ENVIRONMENT_GET_CAN_DUPE: - bval := (*C.bool)(data) - *bval = C.bool(true) - case C.RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: - return videoSetPixelFormat(*(*C.enum_retro_pixel_format)(data)) - case C.RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: - path := (**C.char)(data) - *path = systemDirectory - return true - case C.RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: - path := (**C.char)(data) - *path = saveDirectory - return true - case C.RETRO_ENVIRONMENT_SHUTDOWN: - //window.SetShouldClose(true) - return true - /* - Sets screen rotation of graphics. - Valid values are 0, 1, 2, 3, which rotates screen by 0, 90, 180, 270 degrees - ccw respectively. - */ - case C.RETRO_ENVIRONMENT_SET_ROTATION: - setRotation(*(*uint)(data) % 4) - return true - case C.RETRO_ENVIRONMENT_GET_VARIABLE: - variable := (*C.struct_retro_variable)(data) - key := C.GoString(variable.key) - if val, ok := coreConfig[key]; ok { - log.Printf("[Env]: get variable: key:%v value:%v", key, C.GoString(val)) - variable.value = val - return true - } - // fmt.Printf("[Env]: get variable: key:%v not found\n", key) - return false - case C.RETRO_ENVIRONMENT_SET_HW_RENDER: - video.isGl = isGlAllowed - if isGlAllowed { - video.hw = (*C.struct_retro_hw_render_callback)(data) - video.hw.get_current_framebuffer = (C.retro_hw_get_current_framebuffer_t)(C.coreGetCurrentFramebuffer_cgo) - video.hw.get_proc_address = (C.retro_hw_get_proc_address_t)(C.coreGetProcAddress_cgo) - return true - } - return false - case C.RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: - if multitap.supported { - info := (*[100]C.struct_retro_controller_info)(data) - var i C.unsigned - for i = 0; unsafe.Pointer(info[i].types) != nil; i++ { - var j C.unsigned - types := (*[100]C.struct_retro_controller_description)(unsafe.Pointer(info[i].types)) - for j = 0; j < info[i].num_types; j++ { - if C.GoString(types[j].desc) == "Multitap" { - multitap.value = types[j].id - return true - } - } - } - } - return false - default: - //fmt.Println("[Env]: command not implemented", cmd) - return false - } - return true -} - -//export initVideo -func initVideo() { - var context graphics.Context - switch video.hw.context_type { - case C.RETRO_HW_CONTEXT_NONE: - context = graphics.CtxNone - case C.RETRO_HW_CONTEXT_OPENGL: - context = graphics.CtxOpenGl - case C.RETRO_HW_CONTEXT_OPENGLES2: - context = graphics.CtxOpenGlEs2 - case C.RETRO_HW_CONTEXT_OPENGL_CORE: - context = graphics.CtxOpenGlCore - case C.RETRO_HW_CONTEXT_OPENGLES3: - context = graphics.CtxOpenGlEs3 - case C.RETRO_HW_CONTEXT_OPENGLES_VERSION: - context = graphics.CtxOpenGlEsVersion - case C.RETRO_HW_CONTEXT_VULKAN: - context = graphics.CtxVulkan - case C.RETRO_HW_CONTEXT_DUMMY: - context = graphics.CtxDummy - default: - context = graphics.CtxUnknown - } - - graphics.Init(graphics.Config{ - Ctx: context, - W: int(video.maxWidth), - H: int(video.maxHeight), - Gl: graphics.GlConfig{ - AutoContext: video.autoGlContext, - VersionMajor: uint(video.hw.version_major), - VersionMinor: uint(video.hw.version_minor), - HasDepth: bool(video.hw.depth), - HasStencil: bool(video.hw.stencil), - }, - }) - C.bridge_context_reset(video.hw.context_reset) -} - -//export deinitVideo -func deinitVideo() { - C.bridge_context_reset(video.hw.context_destroy) - graphics.Deinit() - video.isGl = false - video.autoGlContext = false -} - -var ( - retroAPIVersion unsafe.Pointer - retroDeinit unsafe.Pointer - retroGetSystemAVInfo unsafe.Pointer - retroGetSystemInfo unsafe.Pointer - retroHandle unsafe.Pointer - retroInit unsafe.Pointer - retroLoadGame unsafe.Pointer - retroRun unsafe.Pointer - retroSetAudioSample unsafe.Pointer - retroSetAudioSampleBatch unsafe.Pointer - retroSetControllerPortDevice unsafe.Pointer - retroSetEnvironment unsafe.Pointer - retroSetInputPoll unsafe.Pointer - retroSetInputState unsafe.Pointer - retroSetVideoRefresh unsafe.Pointer - retroUnloadGame unsafe.Pointer -) - -func coreLoad(meta emulator.Metadata) { - isGlAllowed = meta.IsGlAllowed - usesLibCo = meta.UsesLibCo - video.autoGlContext = meta.AutoGlContext - coreConfig = ScanConfigFile(meta.ConfigPath) - - multitap.supported = meta.HasMultitap - multitap.enabled = false - multitap.value = 0 - - filePath := meta.LibPath - if arch, err := core.GetCoreExt(); err == nil { - filePath = filePath + arch.LibExt - } else { - log.Printf("warning: %v", err) - } - - mu.Lock() - var err error - retroHandle, err = loadLib(filePath) - // fallback to sequential lib loader (first successfully loaded) - if err != nil { - retroHandle, err = loadLibRollingRollingRolling(filePath) - if err != nil { - log.Fatalf("error core load: %s, %v", filePath, err) - } - } - - retroInit = loadFunction(retroHandle, "retro_init") - retroDeinit = loadFunction(retroHandle, "retro_deinit") - retroAPIVersion = loadFunction(retroHandle, "retro_api_version") - retroGetSystemInfo = loadFunction(retroHandle, "retro_get_system_info") - retroGetSystemAVInfo = loadFunction(retroHandle, "retro_get_system_av_info") - retroSetEnvironment = loadFunction(retroHandle, "retro_set_environment") - retroSetVideoRefresh = loadFunction(retroHandle, "retro_set_video_refresh") - retroSetInputPoll = loadFunction(retroHandle, "retro_set_input_poll") - retroSetInputState = loadFunction(retroHandle, "retro_set_input_state") - retroSetAudioSample = loadFunction(retroHandle, "retro_set_audio_sample") - retroSetAudioSampleBatch = loadFunction(retroHandle, "retro_set_audio_sample_batch") - retroRun = loadFunction(retroHandle, "retro_run") - retroLoadGame = loadFunction(retroHandle, "retro_load_game") - retroUnloadGame = loadFunction(retroHandle, "retro_unload_game") - retroSerializeSize = loadFunction(retroHandle, "retro_serialize_size") - retroSerialize = loadFunction(retroHandle, "retro_serialize") - retroUnserialize = loadFunction(retroHandle, "retro_unserialize") - retroSetControllerPortDevice = loadFunction(retroHandle, "retro_set_controller_port_device") - retroGetMemorySize = loadFunction(retroHandle, "retro_get_memory_size") - retroGetMemoryData = loadFunction(retroHandle, "retro_get_memory_data") - - mu.Unlock() - - C.bridge_retro_set_environment(retroSetEnvironment, C.coreEnvironment_cgo) - C.bridge_retro_set_video_refresh(retroSetVideoRefresh, C.coreVideoRefresh_cgo) - C.bridge_retro_set_input_poll(retroSetInputPoll, C.coreInputPoll_cgo) - C.bridge_retro_set_input_state(retroSetInputState, C.coreInputState_cgo) - C.bridge_retro_set_audio_sample(retroSetAudioSample, C.coreAudioSample_cgo) - C.bridge_retro_set_audio_sample_batch(retroSetAudioSampleBatch, C.coreAudioSampleBatch_cgo) - - C.bridge_retro_init(retroInit) - - v := C.bridge_retro_api_version(retroAPIVersion) - log.Printf("Libretro API version: %v", v) -} - -func slurp(path string, size int64) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - bytes := make([]byte, size) - buffer := bufio.NewReader(f) - _, err = buffer.Read(bytes) - if err != nil { - return nil, err - } - return bytes, nil -} - -func coreLoadGame(filename string) { - lastFrameTime = 0 - - file, err := os.Open(filename) - if err != nil { - panic(err) - } - - fi, err := file.Stat() - if err != nil { - panic(err) - } - _ = file.Close() - - size := fi.Size() - log.Printf("ROM size: %v", size) - - csFilename := C.CString(filename) - defer C.free(unsafe.Pointer(csFilename)) - gi := C.struct_retro_game_info{ - path: csFilename, - size: C.size_t(size), - } - - si := C.struct_retro_system_info{} - C.bridge_retro_get_system_info(retroGetSystemInfo, &si) - log.Printf(" library_name: %v", C.GoString(si.library_name)) - log.Printf(" library_version: %v", C.GoString(si.library_version)) - log.Printf(" valid_extensions: %v", C.GoString(si.valid_extensions)) - log.Printf(" need_fullpath: %v", bool(si.need_fullpath)) - log.Printf(" block_extract: %v", bool(si.block_extract)) - - if !si.need_fullpath { - bytes, err := os.ReadFile(filename) - if err != nil { - panic(err) - } - cstr := C.CString(string(bytes)) - defer C.free(unsafe.Pointer(cstr)) - gi.data = unsafe.Pointer(cstr) - } - - ok := C.bridge_retro_load_game(retroLoadGame, &gi) - if !ok { - log.Fatal("The core failed to load the content.") - } - - avi := C.struct_retro_system_av_info{} - C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &avi) - - // Append the library name to the window title. - NAEmulator.meta.AudioSampleRate = int(avi.timing.sample_rate) - NAEmulator.meta.Fps = float64(avi.timing.fps) - NAEmulator.meta.BaseWidth = int(avi.geometry.base_width) - NAEmulator.meta.BaseHeight = int(avi.geometry.base_height) - // set aspect ratio - /* Nominal aspect ratio of game. If aspect_ratio is <= 0.0, - an aspect ratio of base_width / base_height is assumed. - * A frontend could override this setting, if desired. */ - ratio := float64(avi.geometry.aspect_ratio) - if ratio <= 0.0 { - ratio = float64(avi.geometry.base_width) / float64(avi.geometry.base_height) - } - NAEmulator.meta.Ratio = ratio - - log.Printf("-----------------------------------") - log.Printf("--- Core audio and video info ---") - log.Printf("-----------------------------------") - log.Printf(" Frame: %vx%v (%vx%v)", - avi.geometry.base_width, avi.geometry.base_height, - avi.geometry.max_width, avi.geometry.max_height) - log.Printf(" AR: %v", ratio) - log.Printf(" FPS: %v", avi.timing.fps) - log.Printf(" Audio: %vHz", avi.timing.sample_rate) - log.Printf("-----------------------------------") - - video.maxWidth = int32(avi.geometry.max_width) - video.maxHeight = int32(avi.geometry.max_height) - video.baseWidth = int32(avi.geometry.base_width) - video.baseHeight = int32(avi.geometry.base_height) - if video.isGl { - bufS := int(video.maxWidth * video.maxHeight * int32(video.bpp)) - graphics.SetBuffer(bufS) - log.Printf("Set buffer: %v", byteCountBinary(int64(bufS))) - if usesLibCo { - C.bridge_execute(C.initVideo_cgo) - } else { - runtime.LockOSThread() - initVideo() - runtime.UnlockOSThread() - } - } - - // set default controller types on all ports - maxPort := 4 // controllersNum - for i := 0; i < maxPort; i++ { - C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD) - } -} - -func toggleMultitap() { - if multitap.supported && multitap.value != 0 { - // Official SNES games only support a single multitap device - // Most require it to be plugged in player 2 port - // And Snes9X requires it to be "plugged" after the game is loaded - // Control this from the browser since player 2 will stop working in some games if multitap is "plugged" in - if multitap.enabled { - C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, C.RETRO_DEVICE_JOYPAD) - } else { - C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, multitap.value) - } - multitap.enabled = !multitap.enabled - } -} - -func nanoarchShutdown() { - if usesLibCo { - thread.Main(func() { - C.bridge_execute(retroUnloadGame) - C.bridge_execute(retroDeinit) - if video.isGl { - C.bridge_execute(C.deinitVideo_cgo) - } - }) - } else { - if video.isGl { - thread.Main(func() { - // running inside a go routine, lock the thread to make sure the OpenGL context stays current - runtime.LockOSThread() - graphics.BindContext() - }) - } - C.bridge_retro_unload_game(retroUnloadGame) - C.bridge_retro_deinit(retroDeinit) - if video.isGl { - thread.Main(func() { - deinitVideo() - runtime.UnlockOSThread() - }) - } - } - - setRotation(0) - if err := closeLib(retroHandle); err != nil { - log.Printf("error when close: %v", err) - } - for _, element := range coreConfig { - C.free(unsafe.Pointer(element)) - } - image.Clear() -} - -func nanoarchRun() { - if usesLibCo { - C.bridge_execute(retroRun) - } else { - if video.isGl { - // running inside a go routine, lock the thread to make sure the OpenGL context stays current - runtime.LockOSThread() - graphics.BindContext() - } - C.bridge_retro_run(retroRun) - if video.isGl { - runtime.UnlockOSThread() - } - } -} - -func videoSetPixelFormat(format uint32) C.bool { - switch format { - case C.RETRO_PIXEL_FORMAT_0RGB1555: - video.pixFmt = image.BitFormatShort5551 - graphics.SetPixelFormat(graphics.UnsignedShort5551) - video.bpp = 2 - // format is not implemented - pixelFormatConverterFn = nil - case C.RETRO_PIXEL_FORMAT_XRGB8888: - video.pixFmt = image.BitFormatInt8888Rev - graphics.SetPixelFormat(graphics.UnsignedInt8888Rev) - video.bpp = 4 - pixelFormatConverterFn = image.Rgba8888 - case C.RETRO_PIXEL_FORMAT_RGB565: - video.pixFmt = image.BitFormatShort565 - graphics.SetPixelFormat(graphics.UnsignedShort565) - video.bpp = 2 - pixelFormatConverterFn = image.Rgb565 - default: - log.Fatalf("Unknown pixel type %v", format) - } - return true -} - -func setRotation(rotation uint) { - if rotation == uint(video.rotation) { - return - } - video.rotation = image.Angle(rotation) - r := image.GetRotation(video.rotation) - if rotation > 0 { - rotationFn = &r - } else { - rotationFn = nil - } - NAEmulator.meta.Rotation = r - log.Printf("[Env]: the game video is rotated %v°", map[uint]uint{0: 0, 1: 90, 2: 180, 3: 270}[rotation]) -} - -func byteCountBinary(b int64) string { - const unit = 1024 - if b < unit { - return fmt.Sprintf("%d B", b) - } - div, exp := int64(unit), 0 - for n := b / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) -} diff --git a/pkg/emulator/libretro/nanoarch/persistence.go b/pkg/emulator/libretro/nanoarch/persistence.go deleted file mode 100644 index 4de534c3..00000000 --- a/pkg/emulator/libretro/nanoarch/persistence.go +++ /dev/null @@ -1,45 +0,0 @@ -package nanoarch - -// Save writes the current state to the filesystem. -func (na *naEmulator) Save() error { - na.Lock() - defer na.Unlock() - - ss, err := getSaveState() - if err != nil { - return err - } - if err := na.storage.Save(na.GetHashPath(), ss); err != nil { - return err - } - - if sram := getSaveRAM(); sram != nil { - if err := na.storage.Save(na.GetSRAMPath(), sram); err != nil { - return err - } - } - return nil -} - -// Load restores the state from the filesystem. -func (na *naEmulator) Load() error { - na.Lock() - defer na.Unlock() - - ss, err := na.storage.Load(na.GetHashPath()) - if err != nil { - return err - } - if err := restoreSaveState(ss); err != nil { - return err - } - - sram, err := na.storage.Load(na.GetSRAMPath()) - if err != nil { - return err - } - if sram != nil { - restoreSaveRAM(sram) - } - return nil -} diff --git a/pkg/emulator/libretro/nanoarch/state.go b/pkg/emulator/libretro/nanoarch/state.go deleted file mode 100644 index cb655be3..00000000 --- a/pkg/emulator/libretro/nanoarch/state.go +++ /dev/null @@ -1,100 +0,0 @@ -package nanoarch - -/* -#include "libretro.h" -#include - -size_t bridge_retro_get_memory_size(void *f, unsigned id); -void* bridge_retro_get_memory_data(void *f, unsigned id); -bool bridge_retro_serialize(void *f, void *data, size_t size); -bool bridge_retro_unserialize(void *f, void *data, size_t size); -size_t bridge_retro_serialize_size(void *f); -*/ -import "C" -import ( - "errors" - "unsafe" -) - -// !global emulator lib state -var ( - retroGetMemoryData unsafe.Pointer - retroGetMemorySize unsafe.Pointer - retroSerialize unsafe.Pointer - retroSerializeSize unsafe.Pointer - retroUnserialize unsafe.Pointer -) - -// defines any memory state of the emulator -type state []byte - -type mem struct { - ptr unsafe.Pointer - size uint -} - -// saveStateSize returns the amount of data the implementation requires -// to serialize internal state (save states). -func saveStateSize() uint { return uint(C.bridge_retro_serialize_size(retroSerializeSize)) } - -// getSaveState returns emulator internal state. -func getSaveState() (state, error) { - size := saveStateSize() - data := C.malloc(C.size_t(size)) - defer C.free(data) - if !bool(C.bridge_retro_serialize(retroSerialize, data, C.size_t(size))) { - return nil, errors.New("retro_serialize failed") - } - return C.GoBytes(data, C.int(size)), nil -} - -// restoreSaveState restores emulator internal state. -func restoreSaveState(st state) error { - if len(st) == 0 { - return nil - } - size := saveStateSize() - if !bool(C.bridge_retro_unserialize(retroUnserialize, unsafe.Pointer(&st[0]), C.size_t(size))) { - return errors.New("retro_unserialize failed") - } - return nil -} - -// getSaveRAM returns the game save RAM (cartridge) data or a nil slice. -func getSaveRAM() state { - mem := ptSaveRAM() - if mem == nil { - return nil - } - return C.GoBytes(mem.ptr, C.int(mem.size)) -} - -// restoreSaveRAM restores game save RAM. -func restoreSaveRAM(st state) { - if len(st) == 0 { - return - } - if mem := ptSaveRAM(); mem != nil { - sram := (*[1 << 30]byte)(mem.ptr)[:mem.size:mem.size] - copy(sram, st) - } -} - -// getMemorySize returns memory region size. -func getMemorySize(id uint) uint { - return uint(C.bridge_retro_get_memory_size(retroGetMemorySize, C.uint(id))) -} - -// getMemoryData returns a pointer to memory data. -func getMemoryData(id uint) unsafe.Pointer { - return C.bridge_retro_get_memory_data(retroGetMemoryData, C.uint(id)) -} - -// ptSaveRam return SRAM memory pointer if core supports it or nil. -func ptSaveRAM() *mem { - ptr, size := getMemoryData(C.RETRO_MEMORY_SAVE_RAM), getMemorySize(C.RETRO_MEMORY_SAVE_RAM) - if ptr == nil || size == 0 { - return nil - } - return &mem{ptr: ptr, size: size} -} diff --git a/pkg/emulator/libretro/nanoarch/storage.go b/pkg/emulator/libretro/nanoarch/storage.go deleted file mode 100644 index 490780df..00000000 --- a/pkg/emulator/libretro/nanoarch/storage.go +++ /dev/null @@ -1,29 +0,0 @@ -package nanoarch - -import ( - "io/ioutil" - "path/filepath" -) - -type ( - Storage interface { - GetSavePath() string - GetSRAMPath() string - Load(path string) ([]byte, error) - Save(path string, data []byte) error - } - StateStorage struct { - // save path without the dir slash in the end - Path string - // contains the name of the main save file - // e.g. abc<...>293.dat - // needed for Google Cloud save/restore which - // doesn't support multiple files - MainSave string - } -) - -func (s *StateStorage) GetSavePath() string { return filepath.Join(s.Path, s.MainSave+".dat") } -func (s *StateStorage) GetSRAMPath() string { return filepath.Join(s.Path, s.MainSave+".srm") } -func (s *StateStorage) Load(path string) ([]byte, error) { return ioutil.ReadFile(path) } -func (s *StateStorage) Save(path string, dat []byte) error { return ioutil.WriteFile(path, dat, 0644) } diff --git a/pkg/emulator/libretro/nanoarch/zipstorage.go b/pkg/emulator/libretro/nanoarch/zipstorage.go deleted file mode 100644 index c935b118..00000000 --- a/pkg/emulator/libretro/nanoarch/zipstorage.go +++ /dev/null @@ -1,42 +0,0 @@ -package nanoarch - -import ( - "path/filepath" - "strings" - - "github.com/giongto35/cloud-game/v2/pkg/compression/zip" -) - -type ZipStorage struct { - Storage -} - -func (z *ZipStorage) GetSavePath() string { return z.Storage.GetSavePath() + zip.Ext } -func (z *ZipStorage) GetSRAMPath() string { return z.Storage.GetSRAMPath() + zip.Ext } - -// Load loads a zip file with the path specified. -func (z *ZipStorage) Load(path string) ([]byte, error) { - data, err := z.Storage.Load(path) - if err != nil { - return nil, err - } - d, _, err := zip.Read(data) - if err != nil { - return nil, err - } - return d, nil -} - -// Save saves the array of bytes into a file with the specified path. -func (z *ZipStorage) Save(path string, data []byte) error { - _, name := filepath.Split(path) - if name == "" || name == "." { - return zip.ErrorInvalidName - } - name = strings.TrimSuffix(name, zip.Ext) - compress, err := zip.Compress(data, name) - if err != nil { - return err - } - return z.Storage.Save(path, compress) -} diff --git a/pkg/encoder/h264/options.go b/pkg/encoder/h264/options.go deleted file mode 100644 index f14e475f..00000000 --- a/pkg/encoder/h264/options.go +++ /dev/null @@ -1,33 +0,0 @@ -package h264 - -type Options struct { - // Constant Rate Factor (CRF) - // This method allows the encoder to attempt to achieve a certain output quality for the whole file - // when output file size is of less importance. - // The range of the CRF scale is 0–51, where 0 is lossless, 23 is the default, and 51 is worst quality possible. - Crf uint8 - // film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency. - Tune string - // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo. - Preset string - // baseline, main, high, high10, high422, high444. - Profile string - LogLevel int32 -} - -type Option func(*Options) - -func WithOptions(arg Options) Option { - return func(args *Options) { - args.Crf = arg.Crf - args.Tune = arg.Tune - args.Preset = arg.Preset - args.Profile = arg.Profile - args.LogLevel = arg.LogLevel - } -} -func Crf(arg uint8) Option { return func(args *Options) { args.Crf = arg } } -func Tune(arg string) Option { return func(args *Options) { args.Tune = arg } } -func Preset(arg string) Option { return func(args *Options) { args.Preset = arg } } -func Profile(arg string) Option { return func(args *Options) { args.Profile = arg } } -func LogLevel(arg int32) Option { return func(args *Options) { args.LogLevel = arg } } diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go deleted file mode 100644 index 6b14e71a..00000000 --- a/pkg/encoder/h264/x264.go +++ /dev/null @@ -1,126 +0,0 @@ -package h264 - -import "C" -import ( - "fmt" - "log" -) - -type H264 struct { - ref *T - - width int32 - lumaSize int32 - chromaSize int32 - csp int32 - nnals int32 - nals []*Nal - - // keep monotonic pts to suppress warnings - pts int64 -} - -func NewEncoder(width, height int, options ...Option) (encoder *H264, err error) { - libVersion := int(Build) - - if libVersion < 150 { - return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", libVersion) - } - - if libVersion < 160 { - log.Printf("x264: warning, installed version of libx264 %v is older than minimally supported v160, expect bugs", libVersion) - } - - opts := &Options{ - Crf: 12, - Tune: "zerolatency", - Preset: "superfast", - Profile: "baseline", - } - - for _, opt := range options { - opt(opts) - } - - if opts.LogLevel > 0 { - log.Printf("x264: build v%v", Build) - } - - param := Param{} - if opts.Preset != "" && opts.Tune != "" { - if ParamDefaultPreset(¶m, opts.Preset, opts.Tune) < 0 { - return nil, fmt.Errorf("x264: invalid preset/tune name") - } - } else { - ParamDefault(¶m) - } - - if opts.Profile != "" { - if ParamApplyProfile(¶m, opts.Profile) < 0 { - return nil, fmt.Errorf("x264: invalid profile name") - } - } - - // legacy encoder lacks of this param - param.IBitdepth = 8 - - if libVersion > 155 { - param.ICsp = CspI420 - } else { - param.ICsp = 1 - } - param.IWidth = int32(width) - param.IHeight = int32(height) - param.ILogLevel = opts.LogLevel - - param.Rc.IRcMethod = RcCrf - param.Rc.FRfConstant = float32(opts.Crf) - - encoder = &H264{ - csp: param.ICsp, - lumaSize: int32(width * height), - chromaSize: int32(width*height) / 4, - nals: make([]*Nal, 1), - width: int32(width), - } - - if encoder.ref = EncoderOpen(¶m); encoder.ref == nil { - err = fmt.Errorf("x264: cannot open the encoder") - return - } - return -} - -func (e *H264) Encode(yuv []byte) []byte { - var picIn, picOut Picture - - picIn.Img.ICsp = e.csp - picIn.Img.IPlane = 3 - picIn.Img.IStride[0] = e.width - picIn.Img.IStride[1] = e.width / 2 - picIn.Img.IStride[2] = e.width / 2 - - picIn.Img.Plane[0] = C.CBytes(yuv[:e.lumaSize]) - picIn.Img.Plane[1] = C.CBytes(yuv[e.lumaSize : e.lumaSize+e.chromaSize]) - picIn.Img.Plane[2] = C.CBytes(yuv[e.lumaSize+e.chromaSize:]) - - picIn.IPts = e.pts - e.pts++ - - defer func() { - picIn.freePlane(0) - picIn.freePlane(1) - picIn.freePlane(2) - }() - - if ret := EncoderEncode(e.ref, e.nals, &e.nnals, &picIn, &picOut); ret > 0 { - return C.GoBytes(e.nals[0].PPayload, C.int(ret)) - // ret should be equal to writer writes - } - return []byte{} -} - -func (e *H264) Shutdown() error { - EncoderClose(e.ref) - return nil -} diff --git a/pkg/encoder/opus/encoder.go b/pkg/encoder/opus/encoder.go deleted file mode 100644 index f78c0a89..00000000 --- a/pkg/encoder/opus/encoder.go +++ /dev/null @@ -1,54 +0,0 @@ -package opus - -import ( - "fmt" - - "github.com/hashicorp/go-multierror" -) - -type Encoder struct { - *LibOpusEncoder - - buf []byte -} - -func NewEncoder(outFq, channels int, options ...func(*Encoder) error) (*Encoder, error) { - encoder, err := NewOpusEncoder(outFq, channels, AppRestrictedLowdelay) - if err != nil { - return nil, err - } - enc := &Encoder{LibOpusEncoder: encoder, buf: make([]byte, 1000)} - var result *multierror.Error - result = multierror.Append(result, - enc.SetMaxBandwidth(FullBand), - enc.SetBitrate(192000), - enc.SetComplexity(10), - ) - for _, option := range options { - result = multierror.Append(option(enc)) - } - return enc, result.ErrorOrNil() -} - -func (e *Encoder) Encode(pcm []int16) ([]byte, error) { - n, err := e.LibOpusEncoder.Encode(pcm, e.buf) - // n = 1 is DTX - if err != nil || n == 1 { - return []byte{}, err - } - return e.buf[:n], nil -} - -func (e *Encoder) GetInfo() string { - bitrate, _ := e.LibOpusEncoder.Bitrate() - complexity, _ := e.LibOpusEncoder.Complexity() - dtx, _ := e.LibOpusEncoder.DTX() - fec, _ := e.LibOpusEncoder.FEC() - maxBandwidth, _ := e.LibOpusEncoder.MaxBandwidth() - lossPercent, _ := e.LibOpusEncoder.PacketLossPerc() - sampleRate, _ := e.LibOpusEncoder.SampleRate() - return fmt.Sprintf( - "%v, Bitrate: %v bps, Complexity: %v, DTX: %v, FEC: %v, Max bandwidth: *%v, Loss%%: %v, Rate: %v Hz", - CodecVersion(), bitrate, complexity, dtx, fec, maxBandwidth, lossPercent, sampleRate, - ) -} diff --git a/pkg/encoder/opus/opus.go b/pkg/encoder/opus/opus.go deleted file mode 100644 index 823e2949..00000000 --- a/pkg/encoder/opus/opus.go +++ /dev/null @@ -1,200 +0,0 @@ -package opus - -/* -#cgo pkg-config: opus -#cgo CFLAGS: -Wall -O3 - -#include - -int bridge_encoder_get_bitrate(OpusEncoder *st, opus_int32 *bitrate) { return opus_encoder_ctl(st, OPUS_GET_BITRATE(bitrate)); } -int bridge_encoder_get_complexity(OpusEncoder *st, opus_int32 *complexity) { return opus_encoder_ctl(st, OPUS_GET_COMPLEXITY(complexity)); } -int bridge_encoder_get_dtx(OpusEncoder *st, opus_int32 *dtx) { return opus_encoder_ctl(st, OPUS_GET_DTX(dtx)); } -int bridge_encoder_get_inband_fec(OpusEncoder *st, opus_int32 *fec) { return opus_encoder_ctl(st, OPUS_GET_INBAND_FEC(fec)); } -int bridge_encoder_get_max_bandwidth(OpusEncoder *st, opus_int32 *max_bw) { return opus_encoder_ctl(st, OPUS_GET_MAX_BANDWIDTH(max_bw)); } -int bridge_encoder_get_packet_loss_perc(OpusEncoder *st, opus_int32 *loss_perc) { return opus_encoder_ctl(st, OPUS_GET_PACKET_LOSS_PERC(loss_perc)); } -int bridge_encoder_get_sample_rate(OpusEncoder *st, opus_int32 *sample_rate) { return opus_encoder_ctl(st, OPUS_GET_SAMPLE_RATE(sample_rate)); } -int bridge_encoder_set_bitrate(OpusEncoder *st, opus_int32 bitrate) { return opus_encoder_ctl(st, OPUS_SET_BITRATE(bitrate)); } -int bridge_encoder_set_complexity(OpusEncoder *st, opus_int32 complexity) { return opus_encoder_ctl(st, OPUS_SET_COMPLEXITY(complexity)); } -int bridge_encoder_set_dtx(OpusEncoder *st, opus_int32 use_dtx) { return opus_encoder_ctl(st, OPUS_SET_DTX(use_dtx)); } -int bridge_encoder_set_inband_fec(OpusEncoder *st, opus_int32 fec) { return opus_encoder_ctl(st, OPUS_SET_INBAND_FEC(fec)); } -int bridge_encoder_set_max_bandwidth(OpusEncoder *st, opus_int32 max_bw) { return opus_encoder_ctl(st, OPUS_SET_MAX_BANDWIDTH(max_bw)); } -int bridge_encoder_set_packet_loss_perc(OpusEncoder *st, opus_int32 loss_perc) { return opus_encoder_ctl(st, OPUS_SET_PACKET_LOSS_PERC(loss_perc)); } -*/ -import "C" -import ( - "fmt" - "unsafe" -) - -type ( - Application int - Bandwidth int - Bitrate int - Error int -) - -const ( - // Optimize encoding for VoIP - //AppVoIP = Application(C.OPUS_APPLICATION_VOIP) - // Optimize encoding for non-voice signals like music - //AppAudio = Application(C.OPUS_APPLICATION_AUDIO) - // Optimize encoding for low latency applications - AppRestrictedLowdelay = Application(C.OPUS_APPLICATION_RESTRICTED_LOWDELAY) - - // Auto/default setting - //BitrateAuto = Bitrate(-1000) - //BitrateMax = Bitrate(-1) - - // 20 kHz bandpass - FullBand = Bandwidth(C.OPUS_BANDWIDTH_FULLBAND) -) - -//const ( -// ErrorOK = Error(C.OPUS_OK) -// ErrorBadArg = Error(C.OPUS_BAD_ARG) -// ErrorBufferTooSmall = Error(C.OPUS_BUFFER_TOO_SMALL) -// ErrorInternalError = Error(C.OPUS_INTERNAL_ERROR) -// ErrorInvalidPacket = Error(C.OPUS_INVALID_PACKET) -// ErrorUnimplemented = Error(C.OPUS_UNIMPLEMENTED) -// ErrorInvalidState = Error(C.OPUS_INVALID_STATE) -// ErrorAllocFail = Error(C.OPUS_ALLOC_FAIL) -//) - -type LibOpusEncoder struct { - buf []byte - channels int - ptr *C.struct_OpusEncoder -} - -// NewOpusEncoder creates new Opus encoder. -func NewOpusEncoder(sampleRate int, channels int, app Application) (*LibOpusEncoder, error) { - var enc LibOpusEncoder - if enc.ptr != nil { - return nil, fmt.Errorf("opus: encoder reinit") - } - enc.channels = channels - // !to check mem leak - enc.buf = make([]byte, C.opus_encoder_get_size(C.int(channels))) - enc.ptr = (*C.OpusEncoder)(unsafe.Pointer(&enc.buf[0])) - err := unwrap(C.opus_encoder_init(enc.ptr, C.opus_int32(sampleRate), C.int(channels), C.int(app))) - if err != nil { - return nil, fmt.Errorf("opus: initializatoin error (%v)", err) - } - return &enc, nil -} - -// Encode converts raw PCM samples into the supplied Opus buffer. -// Returns the number of bytes converted. -func (enc *LibOpusEncoder) Encode(pcm []int16, data []byte) (rez int, err error) { - if len(pcm) == 0 { - return - } - samples := C.int(len(pcm) / enc.channels) - n := C.opus_encode(enc.ptr, (*C.opus_int16)(&pcm[0]), samples, (*C.uchar)(&data[0]), C.opus_int32(cap(data))) - if n > 0 { - rez = int(n) - } - return rez, unwrap(n) -} - -// SampleRate returns the sample rate of the encoder. -func (enc *LibOpusEncoder) SampleRate() (int, error) { - var sampleRate C.opus_int32 - res := C.bridge_encoder_get_sample_rate(enc.ptr, &sampleRate) - return int(sampleRate), unwrap(res) -} - -// Bitrate returns the bitrate of the encoder. -func (enc *LibOpusEncoder) Bitrate() (int, error) { - var bitrate C.opus_int32 - res := C.bridge_encoder_get_bitrate(enc.ptr, &bitrate) - return int(bitrate), unwrap(res) -} - -// SetBitrate sets the bitrate of the encoder. -// BitrateMax / BitrateAuto can be used here. -func (enc *LibOpusEncoder) SetBitrate(b Bitrate) error { - return unwrap(C.bridge_encoder_set_bitrate(enc.ptr, C.opus_int32(b))) -} - -// Complexity returns the value of the complexity. -func (enc *LibOpusEncoder) Complexity() (int, error) { - var complexity C.opus_int32 - res := C.bridge_encoder_get_complexity(enc.ptr, &complexity) - return int(complexity), unwrap(res) -} - -// SetComplexity sets the complexity factor for the encoder. -// Complexity is a value from 1 to 10, where 1 is the lowest complexity and 10 is the highest. -func (enc *LibOpusEncoder) SetComplexity(complexity int) error { - return unwrap(C.bridge_encoder_set_complexity(enc.ptr, C.opus_int32(complexity))) -} - -// DTX says if discontinuous transmission is enabled. -func (enc *LibOpusEncoder) DTX() (bool, error) { - var dtx C.opus_int32 - res := C.bridge_encoder_get_dtx(enc.ptr, &dtx) - return dtx > 0, unwrap(res) -} - -// SetDTX switches discontinuous transmission. -func (enc *LibOpusEncoder) SetDTX(dtx bool) error { - var i int - if dtx { - i = 1 - } - return unwrap(C.bridge_encoder_set_dtx(enc.ptr, C.opus_int32(i))) -} - -// MaxBandwidth returns the maximum allowed bandpass value. -func (enc *LibOpusEncoder) MaxBandwidth() (Bandwidth, error) { - var b C.opus_int32 - res := C.bridge_encoder_get_max_bandwidth(enc.ptr, &b) - return Bandwidth(b), unwrap(res) -} - -// SetMaxBandwidth sets the upper limit of the bandpass. -func (enc *LibOpusEncoder) SetMaxBandwidth(b Bandwidth) error { - return unwrap(C.bridge_encoder_set_max_bandwidth(enc.ptr, C.opus_int32(b))) -} - -// FEC says if forward error correction (FEC) is enabled. -func (enc *LibOpusEncoder) FEC() (bool, error) { - var fec C.opus_int32 - res := C.bridge_encoder_get_inband_fec(enc.ptr, &fec) - return fec > 0, unwrap(res) -} - -// SetFEC switches the forward error correction (FEC). -func (enc *LibOpusEncoder) SetFEC(fec bool) error { - var i int - if fec { - i = 1 - } - return unwrap(C.bridge_encoder_set_inband_fec(enc.ptr, C.opus_int32(i))) -} - -// PacketLossPerc returns configured packet loss percentage. -func (enc *LibOpusEncoder) PacketLossPerc() (int, error) { - var lossPerc C.opus_int32 - res := C.bridge_encoder_get_packet_loss_perc(enc.ptr, &lossPerc) - return int(lossPerc), unwrap(res) -} - -// SetPacketLossPerc sets expected packet loss percentage. -func (enc *LibOpusEncoder) SetPacketLossPerc(lossPerc int) error { - return unwrap(C.bridge_encoder_set_packet_loss_perc(enc.ptr, C.opus_int32(lossPerc))) -} - -func (e Error) Error() string { - return fmt.Sprintf("opus: %v", C.GoString(C.opus_strerror(C.int(e)))) -} - -func unwrap(error C.int) (err error) { - if error < C.OPUS_OK { - err = Error(int(error)) - } - return -} - -func CodecVersion() string { return C.GoString(C.opus_get_version_string()) } diff --git a/pkg/encoder/pipe.go b/pkg/encoder/pipe.go deleted file mode 100644 index 2b6ed75d..00000000 --- a/pkg/encoder/pipe.go +++ /dev/null @@ -1,65 +0,0 @@ -package encoder - -import ( - "log" - - "github.com/giongto35/cloud-game/v2/pkg/encoder/yuv" -) - -type VideoPipe struct { - Input chan InFrame - Output chan OutFrame - done chan struct{} - - encoder Encoder - - // frame size - w, h int -} - -// NewVideoPipe returns new video encoder pipe. -// 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 NewVideoPipe(enc Encoder, w, h int) *VideoPipe { - return &VideoPipe{ - Input: make(chan InFrame, 1), - Output: make(chan OutFrame, 2), - done: make(chan struct{}), - - encoder: enc, - - w: w, - h: h, - } -} - -// Start begins video encoding pipe. -// Should be wrapped into a goroutine. -func (vp *VideoPipe) Start() { - defer func() { - if r := recover(); r != nil { - log.Println("Warn: Recovered panic in encoding ", r) - } - close(vp.Output) - close(vp.done) - }() - - yuvProc := yuv.NewYuvImgProcessor(vp.w, vp.h) - for img := range vp.Input { - yCbCr := yuvProc.Process(img.Image).Get() - frame := vp.encoder.Encode(yCbCr) - if len(frame) > 0 { - vp.Output <- OutFrame{Data: frame, Duration: img.Duration} - } - } -} - -func (vp *VideoPipe) Stop() { - close(vp.Input) - <-vp.done - if err := vp.encoder.Shutdown(); err != nil { - log.Println("error: failed to close the encoder") - } -} diff --git a/pkg/encoder/type.go b/pkg/encoder/type.go deleted file mode 100644 index 8f6c33be..00000000 --- a/pkg/encoder/type.go +++ /dev/null @@ -1,21 +0,0 @@ -package encoder - -import ( - "image" - "time" -) - -type InFrame struct { - Image *image.RGBA - Duration time.Duration -} - -type OutFrame struct { - Data []byte - Duration time.Duration -} - -type Encoder interface { - Encode(input []byte) []byte - Shutdown() error -} diff --git a/pkg/encoder/vpx/options.go b/pkg/encoder/vpx/options.go deleted file mode 100644 index e582985b..00000000 --- a/pkg/encoder/vpx/options.go +++ /dev/null @@ -1,17 +0,0 @@ -package vpx - -type Options struct { - // Target bandwidth to use for this stream, in kilobits per second. - Bitrate uint - // Force keyframe interval. - KeyframeInt uint -} - -type Option func(*Options) - -func WithOptions(arg Options) Option { - return func(args *Options) { - args.Bitrate = arg.Bitrate - args.KeyframeInt = arg.KeyframeInt - } -} diff --git a/pkg/encoder/yuv/options.go b/pkg/encoder/yuv/options.go deleted file mode 100644 index 1f53ecfa..00000000 --- a/pkg/encoder/yuv/options.go +++ /dev/null @@ -1,42 +0,0 @@ -package yuv - -type Options struct { - ChromaP ChromaPos - Threaded bool - Threads int -} - -func (o *Options) override(options ...Option) { - for _, opt := range options { - opt(o) - } -} - -type Option func(*Options) - -func Threaded(t bool) Option { - return func(opts *Options) { - opts.Threaded = t - } -} - -func Threads(t int) Option { - return func(opts *Options) { - opts.Threads = t - } -} - -func ChromaP(cp ChromaPos) Option { - return func(opts *Options) { - opts.ChromaP = cp - } -} - -// WithOptions used for config files. -func WithOptions(arg Options) Option { - return func(args *Options) { - args.ChromaP = arg.ChromaP - args.Threaded = arg.Threaded - args.Threads = arg.Threads - } -} diff --git a/pkg/encoder/yuv/yuv.c b/pkg/encoder/yuv/yuv.c deleted file mode 100644 index d6ccb2ce..00000000 --- a/pkg/encoder/yuv/yuv.c +++ /dev/null @@ -1,143 +0,0 @@ -#include "yuv.h" - -// based on: https://stackoverflow.com/questions/9465815/rgb-to-yuv420-algorithm-efficiency - -// Converts RGBA image to YUV (I420) with BT.601 studio color range. -void rgbaToYuv(void *destination, void *source, int width, int height, chromaPos chroma) { - const int image_size = width * height; - unsigned char *rgba = source; - unsigned char *dst_y = destination; - unsigned char *dst_u = destination + image_size; - unsigned char *dst_v = destination + image_size + image_size / 4; - - int r1, g1, b1, stride; - // Y plane - for (int y = 0; y < height; ++y) { - stride = 4 * y * width; - for (int x = 0; x < width; ++x) { - r1 = 4 * x + stride; - g1 = r1 + 1; - b1 = g1 + 1; - *dst_y++ = ((66 * rgba[r1] + 129 * rgba[g1] + 25 * rgba[b1]) >> 8) + 16; - } - } - - // U+V plane - if (chroma == TOP_LEFT) { - for (int y = 0; y < height; y += 2) { - stride = 4 * y * width; - for (int x = 0; x < width; x += 2) { - r1 = 4 * x + stride; - g1 = r1 + 1; - b1 = g1 + 1; - *dst_u++ = ((-38 * rgba[r1] + -74 * rgba[g1] + 112 * rgba[b1]) >> 8) + 128; - *dst_v++ = ((112 * rgba[r1] + -94 * rgba[g1] + -18 * rgba[b1]) >> 8) + 128; - } - } - } else if (chroma == BETWEEN_FOUR) { - int r2, g2, b2, r3, g3, b3, r4, g4, b4; - - for (int y = 0; y < height; y += 2) { - stride = 4 * y * width; - for (int x = 0; x < width; x += 2) { - // (1 2) x x - // x x x x - r1 = 4 * x + stride; - g1 = r1 + 1; - b1 = g1 + 1; - r2 = r1 + 4; - g2 = r2 + 1; - b2 = g2 + 1; - // x x x x - // (3 4) x x - r3 = r1 + 4 * width; - - g3 = r3 + 1; - b3 = g3 + 1; - r4 = r3 + 4; - g4 = r4 + 1; - b4 = g4 + 1; - *dst_u++ = (((-38 * rgba[r1] + -74 * rgba[g1] + 112 * rgba[b1]) >> 8) + - ((-38 * rgba[r2] + -74 * rgba[g2] + 112 * rgba[b2]) >> 8) + - ((-38 * rgba[r3] + -74 * rgba[g3] + 112 * rgba[b3]) >> 8) + - ((-38 * rgba[r4] + -74 * rgba[g4] + 112 * rgba[b4]) >> 8) + 512) >> 2; - *dst_v++ = (((112 * rgba[r1] + -94 * rgba[g1] + -18 * rgba[b1]) >> 8) + - ((112 * rgba[r2] + -94 * rgba[g2] + -18 * rgba[b2]) >> 8) + - ((112 * rgba[r3] + -94 * rgba[g3] + -18 * rgba[b3]) >> 8) + - ((112 * rgba[r4] + -94 * rgba[g4] + -18 * rgba[b4]) >> 8) + 512) >> 2; - } - } - } -} - -void chroma(void *destination, void *source, int pos, int deu, int dev, int width, int height, chromaPos chroma) { - unsigned char *rgba = source + 4 * pos; - unsigned char *dst_u = destination + deu + pos / 4; - unsigned char *dst_v = destination + dev + pos / 4; - - int r1, g1, b1, stride; - - // U+V plane - if (chroma == TOP_LEFT) { - for (int y = 0; y < height; y += 2) { - stride = 4 * y * width; - for (int x = 0; x < width; x += 2) { - r1 = 4 * x + stride; - g1 = r1 + 1; - b1 = g1 + 1; - *dst_u++ = ((-38 * rgba[r1] + -74 * rgba[g1] + 112 * rgba[b1]) >> 8) + 128; - *dst_v++ = ((112 * rgba[r1] + -94 * rgba[g1] + -18 * rgba[b1]) >> 8) + 128; - } - } - } else if (chroma == BETWEEN_FOUR) { - int r2, g2, b2, r3, g3, b3, r4, g4, b4; - - for (int y = 0; y < height; y += 2) { - stride = 4 * y * width; - for (int x = 0; x < width; x += 2) { - // (1 2) x x - // x x x x - r1 = 4 * x + stride; - g1 = r1 + 1; - b1 = g1 + 1; - r2 = r1 + 4; - g2 = r2 + 1; - b2 = g2 + 1; - // x x x x - // (3 4) x x - r3 = r1 + 4 * width; - g3 = r3 + 1; - b3 = g3 + 1; - r4 = r3 + 4; - g4 = r4 + 1; - b4 = g4 + 1; - *dst_u++ = (((-38 * rgba[r1] + -74 * rgba[g1] + 112 * rgba[b1]) >> 8) + - ((-38 * rgba[r2] + -74 * rgba[g2] + 112 * rgba[b2]) >> 8) + - ((-38 * rgba[r3] + -74 * rgba[g3] + 112 * rgba[b3]) >> 8) + - ((-38 * rgba[r4] + -74 * rgba[g4] + 112 * rgba[b4]) >> 8) + 512) >> 2; - *dst_v++ = (((112 * rgba[r1] + -94 * rgba[g1] + -18 * rgba[b1]) >> 8) + - ((112 * rgba[r2] + -94 * rgba[g2] + -18 * rgba[b2]) >> 8) + - ((112 * rgba[r3] + -94 * rgba[g3] + -18 * rgba[b3]) >> 8) + - ((112 * rgba[r4] + -94 * rgba[g4] + -18 * rgba[b4]) >> 8) + 512) >> 2; - } - } - } -} - -void luma(void *destination, void *source, int pos, int width, int height) { - unsigned char *rgba = source + 4 * pos; - unsigned char *dst_y = destination + pos; - - int x, y, r1, g1, b1, stride; - - // Y plane - for (y = 0; y < height; ++y) { - stride = 4 * y * width; - for (x = 0; x < width; ++x) { - r1 = 4 * x + stride; - g1 = r1 + 1; - b1 = g1 + 1; - *dst_y++ = 16 + ((66 * rgba[r1] + 129 * rgba[g1] + 25 * rgba[b1]) >> 8); - } - } -} diff --git a/pkg/encoder/yuv/yuv.go b/pkg/encoder/yuv/yuv.go deleted file mode 100644 index 5c453fc7..00000000 --- a/pkg/encoder/yuv/yuv.go +++ /dev/null @@ -1,138 +0,0 @@ -package yuv - -import ( - "image" - "runtime" - "sync" - "unsafe" -) - -/* -#cgo CFLAGS: -Wall -O3 -#include "yuv.h" -*/ -import "C" - -type ImgProcessor interface { - Process(rgba *image.RGBA) ImgProcessor - Get() []byte -} - -type processor struct { - Data []byte - w, h int - pos ChromaPos - - // cache - dst unsafe.Pointer - ww C.int - chroma C.chromaPos -} - -type threadedProcessor struct { - *processor - - // threading - threads int - chunk int - - // cache - chromaU C.int - chromaV C.int -} - -type ChromaPos uint8 - -const ( - TopLeft ChromaPos = iota - BetweenFour -) - -// NewYuvImgProcessor creates new YUV image converter from RGBA. -func NewYuvImgProcessor(w, h int, options ...Option) ImgProcessor { - opts := &Options{ - ChromaP: BetweenFour, - Threaded: true, - Threads: runtime.NumCPU(), - } - opts.override(options...) - - bufSize := int(float32(w*h) * 1.5) - buf := make([]byte, bufSize) - - processor := processor{ - Data: buf, - chroma: C.chromaPos(opts.ChromaP), - dst: unsafe.Pointer(&buf[0]), - h: h, - pos: opts.ChromaP, - w: w, - ww: C.int(w), - } - - if opts.Threaded { - // chunks the image evenly - chunk := h / opts.Threads - if chunk%2 != 0 { - chunk-- - } - - return &threadedProcessor{ - chromaU: C.int(w * h), - chromaV: C.int(w*h + w*h/4), - chunk: chunk, - processor: &processor, - threads: opts.Threads, - } - } - return &processor -} - -func (yuv *processor) Get() []byte { - return yuv.Data -} - -// Process converts RGBA colorspace into YUV I420 format inside the internal buffer. -// Non-threaded version. -func (yuv *processor) Process(rgba *image.RGBA) ImgProcessor { - C.rgbaToYuv(yuv.dst, unsafe.Pointer(&rgba.Pix[0]), yuv.ww, C.int(yuv.h), yuv.chroma) - return yuv -} - -func (yuv *threadedProcessor) Get() []byte { - return yuv.Data -} - -// Process converts RGBA colorspace into YUV I420 format inside the internal buffer. -// Threaded version. -// -// We divide the input image into chunks by the number of available CPUs. -// Each chunk should contain 2, 4, 6, and etc. rows of the image. -// -// 8x4 CPU (2) -// x x x x x x x x | Coroutine 1 -// x x x x x x x x | Coroutine 1 -// x x x x x x x x | Coroutine 2 -// x x x x x x x x | Coroutine 2 -// -func (yuv *threadedProcessor) Process(rgba *image.RGBA) ImgProcessor { - src := unsafe.Pointer(&rgba.Pix[0]) - wg := sync.WaitGroup{} - wg.Add(2 * yuv.threads) - for i := 0; i < yuv.threads; i++ { - pos, hh := C.int(yuv.w*i*yuv.chunk), C.int(yuv.chunk) - // we need to know how many pixels left - // if the image can't be divided evenly - // between all the threads - if i == yuv.threads-1 { - hh = C.int(yuv.h - i*yuv.chunk) - } - go func() { defer wg.Done(); C.luma(yuv.dst, src, pos, yuv.ww, hh) }() - go func() { - defer wg.Done() - C.chroma(yuv.dst, src, pos, yuv.chromaU, yuv.chromaV, yuv.ww, hh, yuv.chroma) - }() - } - wg.Wait() - return yuv -} diff --git a/pkg/games/launcher.go b/pkg/games/launcher.go new file mode 100644 index 00000000..4ef5512e --- /dev/null +++ b/pkg/games/launcher.go @@ -0,0 +1,42 @@ +package games + +import "fmt" + +type Launcher interface { + FindAppByName(name string) (AppMeta, error) + ExtractAppNameFromUrl(name string) string + GetAppNames() []string +} + +type AppMeta struct { + Name string + Type string + Base string + Path string +} + +type GameLauncher struct { + lib GameLibrary +} + +func NewGameLauncher(lib GameLibrary) GameLauncher { return GameLauncher{lib: lib} } + +func (gl GameLauncher) FindAppByName(name string) (AppMeta, error) { + game := gl.lib.FindGameByName(name) + if game.Path == "" { + return AppMeta{}, fmt.Errorf("couldn't find game info for the game %v", name) + } + return AppMeta{Name: game.Name, Base: game.Base, Type: game.Type, Path: game.Path}, nil +} + +func (gl GameLauncher) ExtractAppNameFromUrl(name string) string { + return GetGameNameFromRoomID(name) +} + +func (gl GameLauncher) GetAppNames() []string { + var gameList []string + for _, game := range gl.lib.GetAll() { + gameList = append(gameList, game.Name) + } + return gameList +} diff --git a/pkg/games/game_library.go b/pkg/games/library.go similarity index 86% rename from pkg/games/game_library.go rename to pkg/games/library.go index dfd06228..4d456e2d 100644 --- a/pkg/games/game_library.go +++ b/pkg/games/library.go @@ -4,7 +4,6 @@ import ( "crypto/md5" "fmt" "io" - "log" "os" "path/filepath" "strings" @@ -12,6 +11,7 @@ import ( "time" "github.com/fsnotify/fsnotify" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) // Config is an external configuration @@ -49,6 +49,7 @@ type library struct { // game name -> game meta // games with duplicate names are merged games map[string]GameMetadata + log *logger.Logger // to restrict parallel execution // or throttling @@ -79,16 +80,18 @@ type GameMetadata struct { Path string } +func (g GameMetadata) FullPath() string { return filepath.Join(g.Base, g.Path) } + func (c Config) GetSupportedExtensions() []string { return c.Supported } -func NewLib(conf Config) GameLibrary { return NewLibWhitelisted(conf, conf) } +func NewLib(conf Config, log *logger.Logger) GameLibrary { return NewLibWhitelisted(conf, conf, log) } -func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist) GameLibrary { +func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist, log *logger.Logger) GameLibrary { hasSource := true dir, err := filepath.Abs(conf.BasePath) if err != nil { hasSource = false - log.Printf("[lib] invalid source: %v (%v)\n", conf.BasePath, err) + log.Error().Err(err).Str("dir", conf.BasePath).Msg("Lib has invalid source") } if len(conf.Supported) == 0 { @@ -106,6 +109,7 @@ func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist) GameLibrary { mu: sync.Mutex{}, games: map[string]GameMetadata{}, hasSource: hasSource, + log: log, } if conf.WatchMode && hasSource { @@ -135,7 +139,7 @@ func (lib *library) FindGameByName(name string) GameMetadata { func (lib *library) Scan() { if !lib.hasSource { - log.Printf("[lib] scan... skipped (no source)\n") + lib.log.Info().Msg("Lib scan... skipped (no source)") return } @@ -144,13 +148,13 @@ func (lib *library) Scan() { if lib.isScanning { defer lib.mu.Unlock() lib.isScanningDelayed = true - log.Printf("[lib] scan... delayed\n") + lib.log.Debug().Msg("Lib scan... delayed") return } lib.isScanning = true lib.mu.Unlock() - log.Printf("[lib] scan... started\n") + lib.log.Debug().Msg("Lib scan... started") start := time.Now() var games []GameMetadata @@ -172,7 +176,7 @@ func (lib *library) Scan() { }) if err != nil { - log.Printf("[lib] scan error with %q: %v\n", dir, err) + lib.log.Error().Err(err).Str("dir", dir).Msgf("Lib scan error") } if len(games) > 0 { @@ -193,7 +197,7 @@ func (lib *library) Scan() { go lib.Scan() } - log.Printf("[lib] scan... completed\n") + lib.log.Info().Msg("Lib scan... completed") } // watch adds the ability to rescan the entire library @@ -202,7 +206,7 @@ func (lib *library) Scan() { func (lib *library) watch() { watcher, err := fsnotify.NewWatcher() if err != nil { - log.Printf("[lib] watcher has failed: %v", err) + lib.log.Error().Err(err).Msg("Lib watcher has failed") return } @@ -228,11 +232,11 @@ func (lib *library) watch() { }(lib) if err = watcher.Add(lib.config.path); err != nil { - log.Printf("[lib] watch error %v", err) + lib.log.Error().Err(err).Msg("Lib watch error") } <-done _ = watcher.Close() - log.Printf("[lib] the watch has ended\n") + lib.log.Info().Msg("Lib watch has ended") } func (lib *library) set(games []GameMetadata) { @@ -271,14 +275,14 @@ func (lib *library) dumpLibrary() { gameList.WriteString(" " + game.Name + " (" + game.Path + ")" + "\n") } - log.Printf("\n"+ + lib.log.Debug().Msgf("Lib dump\n"+ "--------------------------------------------\n"+ "--- The Library of ROMs ---\n"+ "--------------------------------------------\n"+ "%v"+ "--------------------------------------------\n"+ "--- ROMs: %03d %26s ---\n"+ - "--------------------------------------------\n", + "--------------------------------------------", gameList.String(), len(lib.games), lib.lastScanDuration) } diff --git a/pkg/games/game_library_test.go b/pkg/games/library_test.go similarity index 91% rename from pkg/games/game_library_test.go rename to pkg/games/library_test.go index 638bc277..6693572c 100644 --- a/pkg/games/game_library_test.go +++ b/pkg/games/library_test.go @@ -2,6 +2,8 @@ package games import ( "testing" + + "github.com/giongto35/cloud-game/v2/pkg/logger" ) func TestLibraryScan(t *testing.T) { @@ -17,12 +19,13 @@ func TestLibraryScan(t *testing.T) { }, } + l := logger.NewConsole(false, "w", true) for _, test := range tests { library := NewLib(Config{ BasePath: test.directory, Supported: []string{"gba", "zip", "nes"}, Ignored: []string{"neogeo", "pgm"}, - }) + }, l) library.Scan() games := library.GetAll() diff --git a/pkg/session/session.go b/pkg/games/session.go similarity index 76% rename from pkg/session/session.go rename to pkg/games/session.go index f8fd2b58..79ea326d 100644 --- a/pkg/session/session.go +++ b/pkg/games/session.go @@ -1,4 +1,4 @@ -package session +package games import ( "math/rand" @@ -8,7 +8,7 @@ import ( const separator = "___" -// getGameNameFromRoomID parse roomID to get roomID and gameName +// GetGameNameFromRoomID parse roomID to get roomID and gameName. func GetGameNameFromRoomID(roomID string) string { parts := strings.Split(roomID, separator) if len(parts) > 1 { @@ -17,11 +17,10 @@ func GetGameNameFromRoomID(roomID string) string { return "" } -// generateRoomID generate a unique room ID containing 16 digits +// GenerateRoomID generate a unique room ID containing 16 digits. func GenerateRoomID(gameName string) string { // RoomID contains random number + gameName // Next time when we only get roomID, we can launch game based on gameName roomID := strconv.FormatInt(rand.Int63(), 16) + separator + gameName return roomID } - diff --git a/pkg/ice/ice.go b/pkg/ice/ice.go deleted file mode 100644 index 5f813251..00000000 --- a/pkg/ice/ice.go +++ /dev/null @@ -1,57 +0,0 @@ -package ice - -import ( - "strings" - - "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" -) - -type Replacement struct { - From string - To string -} - -func NewIceServer(url string) webrtc.IceServer { - return webrtc.IceServer{ - Url: url, - } -} - -func NewIceServerCredentials(url string, user string, credential string) webrtc.IceServer { - return webrtc.IceServer{ - Url: url, - Username: user, - Credential: credential, - } -} - -func ToJson(iceServers []webrtc.IceServer, replacements ...Replacement) string { - var sb strings.Builder - sn, n := len(iceServers), len(replacements) - if sn > 0 { - sb.Grow(sn * 64) - } - sb.WriteString("[") - for i, ice := range iceServers { - if i > 0 { - sb.WriteString(",{") - } else { - sb.WriteString("{") - } - if n > 0 { - for _, replacement := range replacements { - ice.Url = strings.Replace(ice.Url, "{"+replacement.From+"}", replacement.To, -1) - } - } - sb.WriteString("\"urls\":\"" + ice.Url + "\"") - if ice.Username != "" { - sb.WriteString(",\"username\":\"" + ice.Username + "\"") - } - if ice.Credential != "" { - sb.WriteString(",\"credential\":\"" + ice.Credential + "\"") - } - sb.WriteString("}") - } - sb.WriteString("]") - return sb.String() -} diff --git a/pkg/ice/ice_test.go b/pkg/ice/ice_test.go deleted file mode 100644 index e5f027be..00000000 --- a/pkg/ice/ice_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package ice - -import ( - "testing" - - "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" -) - -func TestIce(t *testing.T) { - tests := []struct { - input []webrtc.IceServer - replacements []Replacement - output string - }{ - { - input: []webrtc.IceServer{ - NewIceServer("stun:stun.l.google.com:19302"), - NewIceServer("stun:{server-ip}:3478"), - NewIceServerCredentials("turn:{server-ip}:3478", "root", "root"), - }, - replacements: []Replacement{ - { - From: "server-ip", - To: "localhost", - }, - }, - output: "[" + - "{\"urls\":\"stun:stun.l.google.com:19302\"}," + - "{\"urls\":\"stun:localhost:3478\"}," + - "{\"urls\":\"turn:localhost:3478\",\"username\":\"root\",\"credential\":\"root\"}" + - "]", - }, - { - input: []webrtc.IceServer{ - NewIceServer("stun:stun.l.google.com:19302"), - }, - output: "[{\"urls\":\"stun:stun.l.google.com:19302\"}]", - }, - { - input: []webrtc.IceServer{}, - replacements: []Replacement{}, - output: "[]", - }, - } - - for _, test := range tests { - result := ToJson(test.input, test.replacements...) - - if result != test.output { - t.Errorf("Not exactly what is expected") - } - } -} - -func BenchmarkIces(b *testing.B) { - benches := []struct { - name string - f func(iceServers []webrtc.IceServer, replacements ...Replacement) string - }{ - {name: "toJson", f: ToJson}, - } - servers := []webrtc.IceServer{ - NewIceServer("stun:stun.l.google.com:19302"), - NewIceServer("stun:{server-ip}:3478"), - NewIceServerCredentials("turn:{server-ip}:3478", "root", "root"), - } - replacements := []Replacement{ - {From: "server-ip", To: "localhost"}, - } - - for _, bench := range benches { - b.Run(bench.name, func(b *testing.B) { - for n := 0; n < b.N; n++ { - bench.f(servers, replacements...) - } - }) - } -} diff --git a/pkg/lock/lock.go b/pkg/lock/lock.go deleted file mode 100644 index 482ad100..00000000 --- a/pkg/lock/lock.go +++ /dev/null @@ -1,48 +0,0 @@ -package lock - -import ( - "sync/atomic" - "time" -) - -type TimeLock struct { - l chan struct{} - locked int32 -} - -// NewLock returns new lock (mutex) with a timeout option. -func NewLock() *TimeLock { - return &TimeLock{l: make(chan struct{}, 1)} -} - -// Lock unconditionally blocks the execution. -func (tl *TimeLock) Lock() { - if tl.isLocked() { - return - } - tl.lock() - <-tl.l -} - -// LockFor blocks the execution at most for -// the given period of time. -func (tl *TimeLock) LockFor(d time.Duration) { - tl.lock() - select { - case <-tl.l: - case <-time.After(d): - } -} - -// Unlock removes the current block if any. -func (tl *TimeLock) Unlock() { - if !tl.isLocked() { - return - } - tl.unlock() - tl.l <- struct{}{} -} - -func (tl *TimeLock) isLocked() bool { return atomic.LoadInt32(&tl.locked) == 1 } -func (tl *TimeLock) lock() { atomic.StoreInt32(&tl.locked, 1) } -func (tl *TimeLock) unlock() { atomic.StoreInt32(&tl.locked, 0) } diff --git a/pkg/lock/lock_test.go b/pkg/lock/lock_test.go deleted file mode 100644 index 7d26170a..00000000 --- a/pkg/lock/lock_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package lock - -import ( - "testing" - "time" -) - -func TestLock(t *testing.T) { - a := 1 - lock := NewLock() - wait := time.Millisecond * 10 - - lock.Unlock() - lock.Unlock() - lock.Unlock() - - go func(timeLock *TimeLock) { - time.Sleep(time.Second * 1) - lock.Unlock() - }(lock) - - lock.LockFor(time.Second * 30) - lock.LockFor(wait) - lock.LockFor(wait) - lock.LockFor(wait) - lock.LockFor(wait) - lock.LockFor(time.Millisecond * 10) - go func(timeLock *TimeLock) { - time.Sleep(time.Millisecond * 1) - lock.Unlock() - }(lock) - lock.Lock() - - a -= 1 - if a != 0 { - t.Errorf("lock test failed because a != 0") - } -} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 00000000..ff299756 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,181 @@ +package logger + +import ( + "fmt" + "io" + "os" + "strconv" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// Level defines log levels. +type Level int8 + +const ( + DebugLevel Level = iota + InfoLevel + WarnLevel + ErrorLevel + FatalLevel + PanicLevel + NoLevel + Disabled + TraceLevel Level = -1 + // Values less than TraceLevel are handled as numbers. +) + +func (l Level) String() string { + switch l { + case TraceLevel: + return zerolog.LevelTraceValue + case DebugLevel: + return zerolog.LevelDebugValue + case InfoLevel: + return zerolog.LevelInfoValue + case WarnLevel: + return zerolog.LevelWarnValue + case ErrorLevel: + return zerolog.LevelErrorValue + case FatalLevel: + return zerolog.LevelFatalValue + case PanicLevel: + return zerolog.LevelPanicValue + case Disabled: + return "disabled" + case NoLevel: + return "" + } + return strconv.Itoa(int(l)) +} + +var pid = os.Getpid() + +type Logger struct { + logger *zerolog.Logger +} + +func New(isDebug bool) *Logger { + logLevel := zerolog.InfoLevel + if isDebug { + logLevel = zerolog.DebugLevel + } + zerolog.SetGlobalLevel(logLevel) + logger := zerolog.New(os.Stderr).With().Timestamp().Fields(map[string]any{"pid": pid}).Logger() + return &Logger{logger: &logger} +} + +func NewConsole(isDebug bool, tag string, noColor bool) *Logger { + logLevel := zerolog.InfoLevel + if isDebug { + logLevel = zerolog.DebugLevel + } + zerolog.SetGlobalLevel(logLevel) + zerolog.TimeFieldFormat = time.RFC3339Nano + output := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05.0000", NoColor: noColor, + PartsOrder: []string{ + zerolog.TimestampFieldName, + "pid", + zerolog.LevelFieldName, + zerolog.CallerFieldName, + "s", + "d", + "c", + "m", + zerolog.MessageFieldName, + }, + FieldsExclude: []string{"s", "c", "d", "m", "pid"}, + } + + if output.NoColor { + output.FormatMessage = func(i any) string { + if i == nil { + return "" + } + return fmt.Sprintf("%v", i) + } + } + + //multi := zerolog.MultiLevelWriter(output, os.Stdout) + logger := zerolog.New(output).With(). + Str("pid", fmt.Sprintf("%4x", pid)). + Str("s", tag). + Str("m", ""). + Str("d", " "). + Str("c", " "). + // Str("tag", tag). use when a file writer + Timestamp().Logger() + return &Logger{logger: &logger} +} + +func Default() *Logger { return &Logger{logger: &log.Logger} } + +// GetLevel returns the current Level of l. +func (l *Logger) GetLevel() Level { return Level(l.logger.GetLevel()) } + +// Output duplicates the global logger and sets w as its output. +func (l *Logger) Output(w io.Writer) zerolog.Logger { return l.logger.Output(w) } + +// With creates a child logger with the field added to its context. +func (l *Logger) With() zerolog.Context { return l.logger.With() } + +// Level creates a child logger with the minimum accepted level set to level. +func (l *Logger) Level(level Level) zerolog.Logger { return l.logger.Level(zerolog.Level(level)) } + +// Sample returns a logger with the s sampler. +func (l *Logger) Sample(s zerolog.Sampler) zerolog.Logger { return l.logger.Sample(s) } + +// Hook returns a logger with the h Hook. +func (l *Logger) Hook(h zerolog.Hook) zerolog.Logger { return l.logger.Hook(h) } + +// Debug starts a new message with debug level. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) Debug() *zerolog.Event { return l.logger.Debug() } + +func (l *Logger) Trace() *zerolog.Event { return l.logger.Trace() } + +// Info starts a new message with info level. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) Info() *zerolog.Event { return l.logger.Info() } + +// Warn starts a new message with warn level. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) Warn() *zerolog.Event { return l.logger.Warn() } + +// Error starts a new message with error level. +func (l *Logger) Error() *zerolog.Event { return l.logger.Error() } + +// Fatal starts a new message with fatal level. The os.Exit(1) function +// is called by the Msg method. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) Fatal() *zerolog.Event { return l.logger.Fatal() } + +// Panic starts a new message with panic level. The message is also sent +// to the panic function. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) Panic() *zerolog.Event { return l.logger.Panic() } + +// WithLevel starts a new message with level. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) WithLevel(level zerolog.Level) *zerolog.Event { return l.logger.WithLevel(level) } + +// Log starts a new message with no level. Setting zerolog.GlobalLevel to +// zerolog.Disabled will still disable events produced by this method. +// You must call Msg on the returned event in order to send the event. +func (l *Logger) Log() *zerolog.Event { return l.logger.Log() } + +// Print sends a log event using debug level and no extra field. +// Arguments are handled in the manner of fmt.Print. +func (l *Logger) Print(v ...any) { l.logger.Print(v...) } + +// Printf sends a log event using debug level and no extra field. +// Arguments are handled in the manner of fmt.Printf. +func (l *Logger) Printf(format string, v ...any) { l.logger.Printf(format, v...) } + +// Extend adds some additional context to the existing logger. +func (l *Logger) Extend(ctx zerolog.Context) *Logger { + logger := ctx.Logger() + return &Logger{logger: &logger} +} diff --git a/pkg/media/buffer.go b/pkg/media/buffer.go deleted file mode 100644 index a666aaaa..00000000 --- a/pkg/media/buffer.go +++ /dev/null @@ -1,40 +0,0 @@ -package media - -// Buffer is a simple non-thread safe ring buffer for audio samples. -// It should be used for 16bit PCM (LE interleaved) data. -type ( - Buffer struct { - s Samples - wi int - } - OnFull func(s Samples) - Samples []int16 -) - -func NewBuffer(numSamples int) Buffer { return Buffer{s: make(Samples, numSamples)} } - -// Write fills the buffer with data calling a callback function when -// the internal buffer fills out. -// -// Consider two cases: -// -// 1. Underflow, when the length of written data is less than the buffer's available space. -// 2. Overflow, when the length exceeds the current available buffer space. -// In the both cases we overwrite any previous values in the buffer and move the internal -// write pointer on the length of written data. -// In the first case we won't call the callback, but it will be called every time -// when the internal buffer overflows until all samples are read. -func (b *Buffer) Write(s Samples, onFull OnFull) (r int) { - for r < len(s) { - w := copy(b.s[b.wi:], s[r:]) - r += w - b.wi += w - if b.wi == len(b.s) { - b.wi = 0 - if onFull != nil { - onFull(b.s) - } - } - } - return -} diff --git a/pkg/media/buffer_test.go b/pkg/media/buffer_test.go deleted file mode 100644 index 6407fa4c..00000000 --- a/pkg/media/buffer_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package media - -import ( - "reflect" - "testing" -) - -type bufWrite struct { - sample int16 - len int -} - -func TestBufferWrite(t *testing.T) { - tests := []struct { - bufLen int - writes []bufWrite - expect Samples - }{ - { - bufLen: 20, - writes: []bufWrite{ - {sample: 1, len: 10}, - {sample: 2, len: 20}, - {sample: 3, len: 30}, - }, - expect: Samples{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, - }, - { - bufLen: 11, - writes: []bufWrite{ - {sample: 1, len: 3}, - {sample: 2, len: 18}, - {sample: 3, len: 2}, - }, - expect: Samples{3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3}, - }, - } - - for _, test := range tests { - var lastResult Samples - buf := NewBuffer(test.bufLen) - for _, w := range test.writes { - buf.Write(samplesOf(w.sample, w.len), func(s Samples) { lastResult = s }) - } - if !reflect.DeepEqual(test.expect, lastResult) { - t.Errorf("not expted buffer, %v != %v", lastResult, test.expect) - } - } -} - -func BenchmarkBufferWrite(b *testing.B) { - fn := func(_ Samples) {} - l := 1920 - buf := NewBuffer(l) - samples1 := samplesOf(1, l/2) - samples2 := samplesOf(2, l*2) - for i := 0; i < b.N; i++ { - buf.Write(samples1, fn) - buf.Write(samples2, fn) - } -} - -func samplesOf(v int16, len int) (s Samples) { - s = make(Samples, len) - for i := range s { - s[i] = v - } - return -} diff --git a/pkg/media/resampler.go b/pkg/media/resampler.go deleted file mode 100644 index 1ada082f..00000000 --- a/pkg/media/resampler.go +++ /dev/null @@ -1,25 +0,0 @@ -package media - -// ResampleStretch does a simple stretching of audio samples. -func ResampleStretch(pcm []int16, size int) []int16 { - r, l, audio := make([]int16, size/2), make([]int16, size/2), make([]int16, size) - // ratio is basically the destination sample rate - // divided by the origin sample rate (i.e. 48000/44100) - ratio := float32(size) / float32(len(pcm)) - for i, n := 0, len(pcm)-1; i < n; i += 2 { - idx := int(float32(i/2) * ratio) - r[idx], l[idx] = pcm[i], pcm[i+1] - } - for i, n := 1, len(r); i < n; i++ { - if r[i] == 0 { - r[i] = r[i-1] - } - if l[i] == 0 { - l[i] = l[i-1] - } - } - for i := 0; i < size; i += 2 { - audio[i], audio[i+1] = r[i/2], l[i/2] - } - return audio -} diff --git a/pkg/monitoring/monitoring.go b/pkg/monitoring/monitoring.go index 257a744b..b153f9c5 100644 --- a/pkg/monitoring/monitoring.go +++ b/pkg/monitoring/monitoring.go @@ -1,64 +1,63 @@ package monitoring import ( - "context" "fmt" - "log" - "math" "net" - "net/http" "net/http/pprof" "strconv" - "strings" + "github.com/VictoriaMetrics/metrics" "github.com/giongto35/cloud-game/v2/pkg/config/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/giongto35/cloud-game/v2/pkg/network/httpx" - "github.com/giongto35/cloud-game/v2/pkg/service" - "github.com/prometheus/client_golang/prometheus/promhttp" ) const debugEndpoint = "/debug/pprof" const metricsEndpoint = "/metrics" type Monitoring struct { - service.RunnableService - conf monitoring.Config - tag string server *httpx.Server + log *logger.Logger } // New creates new monitoring service. // The tag param specifies owner label for logs. -func New(conf monitoring.Config, baseAddr string, tag string) *Monitoring { +func New(conf monitoring.Config, baseAddr string, log *logger.Logger) *Monitoring { serv, err := httpx.NewServer( net.JoinHostPort(baseAddr, strconv.Itoa(conf.Port)), - func(*httpx.Server) http.Handler { - h := http.NewServeMux() + func(s *httpx.Server) httpx.Handler { + h := s.Mux() if conf.ProfilingEnabled { - prefix := conf.URLPrefix + debugEndpoint - h.HandleFunc(prefix+"/", pprof.Index) - h.HandleFunc(prefix+"/cmdline", pprof.Cmdline) - h.HandleFunc(prefix+"/profile", pprof.Profile) - h.HandleFunc(prefix+"/symbol", pprof.Symbol) - h.HandleFunc(prefix+"/trace", pprof.Trace) - h.Handle(prefix+"/allocs", pprof.Handler("allocs")) - h.Handle(prefix+"/block", pprof.Handler("block")) - h.Handle(prefix+"/goroutine", pprof.Handler("goroutine")) - h.Handle(prefix+"/heap", pprof.Handler("heap")) - h.Handle(prefix+"/mutex", pprof.Handler("mutex")) - h.Handle(prefix+"/threadcreate", pprof.Handler("threadcreate")) + h.Prefix(conf.URLPrefix + debugEndpoint) + h.HandleFunc("/", pprof.Index). + HandleFunc("/cmdline", pprof.Cmdline). + HandleFunc("/profile", pprof.Profile). + HandleFunc("/symbol", pprof.Symbol). + HandleFunc("/trace", pprof.Trace). + Handle("/allocs", pprof.Handler("allocs")). + Handle("/block", pprof.Handler("block")). + Handle("/goroutine", pprof.Handler("goroutine")). + Handle("/heap", pprof.Handler("heap")). + Handle("/mutex", pprof.Handler("mutex")). + Handle("/threadcreate", pprof.Handler("threadcreate")) } if conf.MetricEnabled { - h.Handle(conf.URLPrefix+metricsEndpoint, promhttp.Handler()) + h.Prefix(conf.URLPrefix) + h.HandleFunc(metricsEndpoint, func(w httpx.ResponseWriter, _ *httpx.Request) { + metrics.WritePrometheus(w, true) + }) } + h.Prefix("") return h }, - httpx.WithPortRoll(true)) + httpx.WithPortRoll(true), + httpx.WithLogger(log), + ) if err != nil { - log.Fatalf("couldn't start monitoring server: %v", err) + log.Error().Err(err).Msg("couldn't start monitoring server") } - return &Monitoring{conf: conf, tag: tag, server: serv} + return &Monitoring{conf: conf, server: serv, log: log} } func (m *Monitoring) Run() { @@ -66,9 +65,9 @@ func (m *Monitoring) Run() { m.server.Run() } -func (m *Monitoring) Shutdown(ctx context.Context) error { - log.Printf("[%v] Shutting down monitoring server", m.tag) - return m.server.Shutdown(ctx) +func (m *Monitoring) Stop() error { + m.log.Info().Msg("Shutting down monitoring server") + return m.server.Stop() } func (m *Monitoring) String() string { @@ -84,28 +83,12 @@ func (m *Monitoring) GetProfilingAddress() string { } func (m *Monitoring) printInfo() { - length, pad := 42, 20 - var table, records strings.Builder - table.Grow(length * 4) - records.Grow(length * 2) - + message := m.log.Info() if m.conf.ProfilingEnabled { - addr := m.GetProfilingAddress() - length = int(math.Max(float64(length), float64(len(addr)+pad))) - records.WriteString(" Profiling " + addr + "\n") + message = message.Str("profiler", m.GetProfilingAddress()) } if m.conf.MetricEnabled { - addr := m.GetMetricsPublicAddress() - length = int(math.Max(float64(length), float64(len(addr)+pad))) - records.WriteString(" Prometheus " + addr + "\n") + message = message.Str("prometheus", m.GetMetricsPublicAddress()) } - - title := "Monitoring" - edge := strings.Repeat("-", length) - c := (length-len(title)-3)/2 + 1 + len(title) - 3 - table.WriteString(fmt.Sprintf("[%s]\n", m.tag)) - table.WriteString(fmt.Sprintf("%s\n---%*s%*s\n%s\n", edge, c, title, length-(c+len(title))+6+1, "---", edge)) - table.WriteString(records.String()) - table.WriteString(edge) - log.Printf(table.String()) + message.Msg("Monitoring") } diff --git a/pkg/network/address.go b/pkg/network/address.go new file mode 100644 index 00000000..5fcaca49 --- /dev/null +++ b/pkg/network/address.go @@ -0,0 +1,26 @@ +package network + +import ( + "errors" + "strconv" + "strings" +) + +type Address string + +func (a *Address) Port() (int, error) { + if len(string(*a)) == 0 { + return 0, errors.New("no address") + } + 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") +} diff --git a/pkg/network/address_test.go b/pkg/network/address_test.go new file mode 100644 index 00000000..f3f26935 --- /dev/null +++ b/pkg/network/address_test.go @@ -0,0 +1,26 @@ +package network + +import ( + "testing" +) + +func TestAddressPort(t *testing.T) { + tests := []struct { + input Address + port int + err string + }{ + {input: "", port: 0, err: "no address"}, + {input: ":", port: 0, err: "port is not a number"}, + {input: "https://garbage.com:99a9a", port: 0, err: "port is not a number"}, + {input: ":9000", port: 9000}, + {input: "not-garbage:9999", port: 9999}, + } + + for _, test := range tests { + port, err := test.input.Port() + if port != test.port || (err != nil && test.err != err.Error()) { + t.Errorf("Test fail for expected port %v but got %v with error %v", test.port, port, err) + } + } +} diff --git a/pkg/network/httpx/listener_test.go b/pkg/network/httpx/listener_test.go index 7a8900f4..49be5aac 100644 --- a/pkg/network/httpx/listener_test.go +++ b/pkg/network/httpx/listener_test.go @@ -38,7 +38,7 @@ func TestListenerCreation(t *testing.T) { continue } - defer ls.Close() + defer func() { _ = ls.Close() }() addr := ls.Addr().(*net.TCPAddr) port := ls.GetPort() diff --git a/pkg/network/httpx/options.go b/pkg/network/httpx/options.go index aa6fa666..930ae08e 100644 --- a/pkg/network/httpx/options.go +++ b/pkg/network/httpx/options.go @@ -4,6 +4,7 @@ import ( "time" "github.com/giongto35/cloud-game/v2/pkg/config/shared" + "github.com/giongto35/cloud-game/v2/pkg/logger" ) type ( @@ -18,6 +19,7 @@ type ( IdleTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration + Logger *logger.Logger Zone string } Option func(*Options) @@ -54,3 +56,4 @@ func WithServerConfig(conf shared.Server) Option { opts.HttpsRedirectAddress = conf.Address } } +func WithLogger(log *logger.Logger) Option { return func(opts *Options) { opts.Logger = log } } diff --git a/pkg/network/httpx/server.go b/pkg/network/httpx/server.go index 24cbd4b3..a5e40063 100644 --- a/pkg/network/httpx/server.go +++ b/pkg/network/httpx/server.go @@ -1,37 +1,80 @@ package httpx import ( - "context" - "log" + "fmt" "net/http" "net/url" "time" - "github.com/giongto35/cloud-game/v2/pkg/service" + "github.com/giongto35/cloud-game/v2/pkg/logger" "golang.org/x/crypto/acme/autocert" ) type Server struct { http.Server - service.RunnableService autoCert *autocert.Manager opts Options listener *Listener redirect *Server + log *logger.Logger } -func NewServer(address string, handler func(*Server) http.Handler, options ...Option) (*Server, error) { +type ( + Mux struct { + *http.ServeMux + prefix string + } + Handler = http.Handler + HandlerFunc = http.HandlerFunc + ResponseWriter = http.ResponseWriter + Request = http.Request +) + +// NewServeMux allocates and returns a new ServeMux. +func NewServeMux(prefix string) *Mux { + return &Mux{ServeMux: http.NewServeMux(), prefix: prefix} +} + +func (m *Mux) Prefix(v string) { m.prefix = v } + +func (m *Mux) HandleW(pattern string, h func(http.ResponseWriter)) *Mux { + m.ServeMux.HandleFunc(m.prefix+pattern, func(w http.ResponseWriter, _ *http.Request) { h(w) }) + return m +} + +func (m *Mux) Handle(pattern string, handler Handler) *Mux { + m.ServeMux.Handle(m.prefix+pattern, handler) + return m +} + +func (m *Mux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) *Mux { + 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, HttpsRedirect: true, IdleTimeout: 120 * time.Second, - ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, + ReadTimeout: 500 * time.Second, + WriteTimeout: 500 * time.Second, } opts.override(options...) + if opts.Logger == nil { + opts.Logger = logger.Default() + } + server := &Server{ Server: http.Server{ Addr: address, @@ -40,6 +83,7 @@ func NewServer(address string, handler func(*Server) http.Handler, options ...Op WriteTimeout: opts.WriteTimeout, }, opts: *opts, + log: opts.Logger, } // (╯°□°)╯︵ ┻━┻ server.Handler = handler(server) @@ -55,7 +99,7 @@ func NewServer(address string, handler func(*Server) http.Handler, options ...Op if opts.Https { addr = ":https" } - log.Printf("Warning! Empty server address has been changed to %v", addr) + opts.Logger.Warn().Msgf("Empty server address has been changed to %v", addr) } listener, err := NewListener(addr, server.opts.PortRoll) if err != nil { @@ -64,20 +108,25 @@ func NewServer(address string, handler func(*Server) http.Handler, options ...Op server.listener = listener addr = buildAddress(server.Addr, opts.Zone, *listener) - log.Printf("[server] address was set to %v (%v)", addr, server.Addr) + opts.Logger.Info().Msgf("httpx %v (%v)", addr, server.Addr) server.Addr = addr return server, nil } -func (s *Server) Run() { +func (s *Server) MuxX(prefix string) *Mux { return NewServeMux(prefix) } +func (s *Server) Mux() *Mux { return s.MuxX("") } + +func (s *Server) Run() { go s.run() } + +func (s *Server) run() { protocol := s.GetProtocol() - log.Printf("Starting %s server on %s", protocol, s.Addr) + s.log.Debug().Msgf("Starting %s server on %s", protocol, s.Addr) if s.opts.Https && s.opts.HttpsRedirect { rdr, err := s.redirection() if err != nil { - log.Fatalf("couldn't init redirection server: %v", err) + s.log.Error().Err(err).Msg("couldn't init redirection server") } s.redirect = rdr go s.redirect.Run() @@ -91,19 +140,18 @@ func (s *Server) Run() { } switch err { case http.ErrServerClosed: - log.Printf("%s server was closed", protocol) + s.log.Debug().Msgf("%s server was closed", protocol) return default: - log.Printf("error: %s", err) + s.log.Error().Err(err) } } -func (s *Server) Shutdown(ctx context.Context) (err error) { +func (s *Server) Stop() error { if s.redirect != nil { - err = s.redirect.Shutdown(ctx) + _ = s.redirect.Stop() } - err = s.Server.Shutdown(ctx) - return + return s.Server.Close() } func (s *Server) GetHost() string { return extractHost(s.Addr) } @@ -123,22 +171,26 @@ func (s *Server) redirection() (*Server, error) { } addr := buildAddress(address, s.opts.Zone, *s.listener) - srv, err := NewServer(s.opts.HttpsRedirectAddress, func(serv *Server) http.Handler { - h := http.NewServeMux() - - h.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv, err := NewServer(s.opts.HttpsRedirectAddress, func(serv *Server) Handler { + h := NewServeMux("") + h.Handle("/", HandlerFunc(func(w ResponseWriter, r *Request) { httpsURL := url.URL{Scheme: "https", Host: addr, Path: r.URL.Path, RawQuery: r.URL.RawQuery} rdr := httpsURL.String() - log.Printf("Redirect: http://%s%s -> %s", r.Host, r.URL.String(), rdr) + if s.log.GetLevel() < logger.InfoLevel { + s.log.Debug(). + Str("from", fmt.Sprintf("http://%s%s", r.Host, r.URL.String())). + Str("to", rdr). + Msg("Redirect") + } http.Redirect(w, r, rdr, http.StatusFound) })) - - // do we need this after all? if serv.autoCert != nil { return serv.autoCert.HTTPHandler(h) } return h - }) - log.Printf("Starting HTTP->HTTPS redirection server on %s", addr) + }, + WithLogger(s.log), + ) + s.log.Info().Str("addr", addr).Msg("Start HTTPS redirect server") return srv, err } diff --git a/pkg/network/socket/socket.go b/pkg/network/socket/socket.go index 6924211f..bceb3fdd 100644 --- a/pkg/network/socket/socket.go +++ b/pkg/network/socket/socket.go @@ -16,7 +16,7 @@ const udpBufferSize = 16 * 1024 * 1024 // udp, udp4, udp6, tcp, tcp4, tcp6 // The function result will be either *net.UDPConn for UDPs or // *net.TCPListener for TCPs. -func NewSocket(proto string, port int) (interface{}, error) { +func NewSocket(proto string, port int) (any, error) { if listener, err := socket(proto, port); err != nil { return nil, err } else { @@ -26,7 +26,7 @@ func NewSocket(proto string, port int) (interface{}, error) { // NewSocketPortRoll creates either TCP or UDP socket listener on the next free port. // See: NewSocket. -func NewSocketPortRoll(proto string, port int) (listener interface{}, err error) { +func NewSocketPortRoll(proto string, port int) (listener any, err error) { if listener, err = NewSocket(proto, port); err == nil { return listener, nil } @@ -42,7 +42,7 @@ func NewSocketPortRoll(proto string, port int) (listener interface{}, err error) return nil, err } -func socket(proto string, port int) (interface{}, error) { +func socket(proto string, port int) (any, error) { switch proto { case "udp", "udp4", "udp6": if l, err := net.ListenUDP(proto, &net.UDPAddr{Port: port}); err == nil { diff --git a/pkg/network/uid.go b/pkg/network/uid.go new file mode 100644 index 00000000..5d5b464a --- /dev/null +++ b/pkg/network/uid.go @@ -0,0 +1,22 @@ +package network + +import "github.com/rs/xid" + +type Uid string + +func NewUid() Uid { return Uid(xid.New().String()) } + +func ValidUid(u Uid) bool { + _, err := xid.FromString(string(u)) + return err == nil +} +func (u Uid) Empty() bool { return u == "" } +func (u Uid) Short() string { return string(u)[:3] + "." + string(u)[len(u)-3:] } +func (u Uid) String() string { return string(u) } +func (u Uid) Machine() string { + id, err := xid.FromString(string(u)) + if err != nil { + return "" + } + return string(id.Machine()) +} diff --git a/pkg/network/webrtc/factory.go b/pkg/network/webrtc/factory.go new file mode 100644 index 00000000..ab810416 --- /dev/null +++ b/pkg/network/webrtc/factory.go @@ -0,0 +1,90 @@ +package webrtc + +import ( + "fmt" + "net" + + conf "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network/socket" + "github.com/pion/interceptor" + "github.com/pion/webrtc/v3" +) + +type ApiFactory struct { + api *webrtc.API + conf webrtc.Configuration +} + +type ModApiFun func(m *webrtc.MediaEngine, i *interceptor.Registry, s *webrtc.SettingEngine) + +func NewApiFactory(conf conf.Webrtc, log *logger.Logger, mod ModApiFun) (api *ApiFactory, err error) { + m := &webrtc.MediaEngine{} + if err = m.RegisterDefaultCodecs(); err != nil { + return + } + i := &interceptor.Registry{} + if !conf.DisableDefaultInterceptors { + if err = webrtc.RegisterDefaultInterceptors(m, i); err != nil { + return + } + } + customLogger := NewPionLogger(log, conf.LogLevel) + s := webrtc.SettingEngine{LoggerFactory: customLogger} + s.SetIncludeLoopbackCandidate(true) + if conf.HasDtlsRole() { + log.Info().Msgf("A custom DTLS role [%v]", conf.DtlsRole) + err = s.SetAnsweringDTLSRole(webrtc.DTLSRole(conf.DtlsRole)) + if err != nil { + return + } + } + if conf.IceLite { + s.SetLite(conf.IceLite) + } + if conf.HasPortRange() { + if err = s.SetEphemeralUDPPortRange(conf.IcePorts.Min, conf.IcePorts.Max); err != nil { + return + } + } + if conf.HasSinglePort() { + var l any + l, err = socket.NewSocketPortRoll("udp", conf.SinglePort) + if err != nil { + return + } + udp, ok := l.(*net.UDPConn) + if !ok { + err = fmt.Errorf("use of not a UDP socket") + return + } + s.SetICEUDPMux(webrtc.NewICEUDPMux(customLogger, udp)) + log.Info().Msgf("The single port mode is active for %s", udp.LocalAddr()) + } + if conf.HasIceIpMap() { + s.SetNAT1To1IPs([]string{conf.IceIpMap}, webrtc.ICECandidateTypeHost) + log.Info().Msgf("The NAT mapping is active for %v", conf.IceIpMap) + } + + if mod != nil { + mod(m, i, &s) + } + + c := webrtc.Configuration{ICEServers: []webrtc.ICEServer{}} + for _, server := range conf.IceServers { + c.ICEServers = append(c.ICEServers, webrtc.ICEServer{ + URLs: []string{server.Urls}, + Username: server.Username, + Credential: server.Credential, + }) + } + + return &ApiFactory{ + api: webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i), webrtc.WithSettingEngine(s)), + conf: c, + }, err +} + +func (a *ApiFactory) NewPeer() (*webrtc.PeerConnection, error) { + return a.api.NewPeerConnection(a.conf) +} diff --git a/pkg/network/webrtc/pionlogger.go b/pkg/network/webrtc/pionlogger.go new file mode 100644 index 00000000..4d583e95 --- /dev/null +++ b/pkg/network/webrtc/pionlogger.go @@ -0,0 +1,33 @@ +package webrtc + +import ( + "github.com/pion/logging" + "github.com/rs/zerolog" + + "github.com/giongto35/cloud-game/v2/pkg/logger" +) + +type PionLog struct { + log *logger.Logger +} + +const trace = zerolog.Level(logger.TraceLevel) + +func NewPionLogger(root *logger.Logger, level int) *PionLog { + return &PionLog{log: root.Extend(root.Level(logger.Level(level)).With())} +} + +func (p PionLog) NewLogger(scope string) logging.LeveledLogger { + return PionLog{log: p.log.Extend(p.log.With().Str("mod", scope))} +} + +func (p PionLog) Debug(msg string) { p.log.Debug().Msg(msg) } +func (p PionLog) Debugf(format string, args ...any) { p.log.Debug().Msgf(format, args...) } +func (p PionLog) Error(msg string) { p.log.Error().Msg(msg) } +func (p PionLog) Errorf(format string, args ...any) { p.log.Error().Msgf(format, args...) } +func (p PionLog) Info(msg string) { p.log.Info().Msg(msg) } +func (p PionLog) Infof(format string, args ...any) { p.log.Info().Msgf(format, args...) } +func (p PionLog) Trace(msg string) { p.log.WithLevel(trace).Msg(msg) } +func (p PionLog) Tracef(format string, args ...any) { p.log.WithLevel(trace).Msgf(format, args...) } +func (p PionLog) Warn(msg string) { p.log.Warn().Msg(msg) } +func (p PionLog) Warnf(format string, args ...any) { p.log.Warn().Msgf(format, args...) } diff --git a/pkg/network/webrtc/webrtc.go b/pkg/network/webrtc/webrtc.go new file mode 100644 index 00000000..ddf5df45 --- /dev/null +++ b/pkg/network/webrtc/webrtc.go @@ -0,0 +1,216 @@ +package webrtc + +import ( + "errors" + "fmt" + "strings" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/pion/dtls/v2" + "github.com/pion/webrtc/v3" + "github.com/pion/webrtc/v3/pkg/media" +) + +type Peer struct { + api *ApiFactory + conn *webrtc.PeerConnection + log *logger.Logger + OnMessage func(data []byte) + + aTrack *webrtc.TrackLocalStaticSample + vTrack *webrtc.TrackLocalStaticSample + dTrack *webrtc.DataChannel +} + +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.IsConnected() { + p.log.Warn().Msg("Strange multiple init connection calls with the same peer") + return + } + p.log.Info().Msg("WebRTC start") + if p.conn, err = p.api.NewPeer(); err != nil { + return "", err + } + p.conn.OnICECandidate(p.handleICECandidate(onICECandidate)) + // plug in the [video] track (out) + video, err := newTrack("video", "game-video", vCodec) + if err != nil { + return "", err + } + if _, err = p.conn.AddTrack(video); err != nil { + return "", err + } + p.vTrack = video + p.log.Debug().Msgf("Added [%s] track", video.Codec().MimeType) + + // plug in the [audio] track (out) + audio, err := newTrack("audio", "game-audio", aCodec) + if err != nil { + return "", err + } + if _, err = p.conn.AddTrack(audio); err != nil { + return "", err + } + p.log.Debug().Msgf("Added [%s] track", audio.Codec().MimeType) + p.aTrack = audio + + // 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("Start streaming") + })) + // Stream provider supposes to send offer + offer, err := p.conn.CreateOffer(nil) + if err != nil { + return "", err + } + p.log.Info().Msg("Created Offer") + + err = p.conn.SetLocalDescription(offer) + if err != nil { + return "", err + } + + return offer, nil +} + +func (p *Peer) SetRemoteSDP(sdp string, decoder Decoder) error { + var answer webrtc.SessionDescription + if err := decoder(sdp, &answer); err != nil { + return err + } + if err := p.conn.SetRemoteDescription(answer); err != nil { + p.log.Error().Err(err).Msg("Set remote description from peer failed") + return err + } + p.log.Debug().Msg("Set Remote Description") + 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 + switch id { + case "audio": + switch codec { + case "opus": + mime = webrtc.MimeTypeOpus + } + case "video": + switch codec { + case "h264": + mime = webrtc.MimeTypeH264 + case "vpx", "vp8": + mime = webrtc.MimeTypeVP8 + } + } + if mime == "" { + return nil, fmt.Errorf("unsupported codec %s:%s", id, codec) + } + return webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: mime}, id, label) +} + +func (p *Peer) handleICECandidate(callback func(any)) func(*webrtc.ICECandidate) { + return func(ice *webrtc.ICECandidate) { + // ICE gathering finish condition + if ice == nil { + callback(nil) + p.log.Debug().Msg("ICE gathering was complete probably") + return + } + candidate := ice.ToJSON() + p.log.Debug().Str("candidate", candidate.Candidate).Msg("ICE") + callback(&candidate) + } +} + +func (p *Peer) handleICEState(onConnect func()) func(webrtc.ICEConnectionState) { + return func(state webrtc.ICEConnectionState) { + p.log.Debug().Str(".state", state.String()).Msg("ICE") + switch state { + case webrtc.ICEConnectionStateChecking: + // nothing + case webrtc.ICEConnectionStateConnected: + onConnect() + case webrtc.ICEConnectionStateFailed, + webrtc.ICEConnectionStateClosed, + webrtc.ICEConnectionStateDisconnected: + p.Disconnect() + default: + p.log.Debug().Msg("ICE state is not handled!") + } + } +} + +func (p *Peer) AddCandidate(candidate string, decoder Decoder) error { + var iceCandidate webrtc.ICECandidateInit + if err := decoder(candidate, &iceCandidate); err != nil { + return err + } + if err := p.conn.AddICECandidate(iceCandidate); err != nil { + return err + } + p.log.Debug().Str("candidate", iceCandidate.Candidate).Msg("Ice") + return nil +} + +func (p *Peer) Disconnect() { + if p.conn == nil { + return + } + + if p.conn.ConnectionState() < webrtc.PeerConnectionStateDisconnected { + if err := p.conn.Close(); err != nil && !errors.Is(err, dtls.ErrConnClosed) { + p.log.Error().Err(err).Msg("WebRTC close") + } + } + p.conn = nil + p.log.Debug().Msg("WebRTC stop") +} + +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) addInputChannel(label string) error { + ch, err := p.conn.CreateDataChannel(label, nil) + if err != nil { + return err + } + ch.OnOpen(func() { + p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).Msg("Data channel [input] opened") + }) + ch.OnError(p.logx) + 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 + } + p.OnMessage(mess.Data) + }) + p.dTrack = ch + ch.OnClose(func() { p.log.Debug().Msg("Data channel [input] has been closed") }) + return nil +} + +func (p *Peer) logx(err error) { p.log.Error().Err(err) } diff --git a/pkg/network/websocket/websocket.go b/pkg/network/websocket/websocket.go index 7f3c3a66..3a53f37d 100644 --- a/pkg/network/websocket/websocket.go +++ b/pkg/network/websocket/websocket.go @@ -2,27 +2,76 @@ package websocket import ( "crypto/tls" + "errors" + "net" "net/http" "net/url" + "sync" + "sync/atomic" + "time" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/gorilla/websocket" ) -type Upgrader struct { - websocket.Upgrader +const ( + maxMessageSize = 10 * 1024 + pingTime = pongTime * 9 / 10 + pongTime = 5 * time.Second + writeWait = 1 * time.Second +) - origin string +type ( + WS struct { + conn deadlineConn + send chan []byte + OnMessage WSMessageHandler + pingPong bool + once sync.Once + Done chan struct{} + closed uint32 + log *logger.Logger + server bool + } + WSMessageHandler func(message []byte, err error) + Upgrader struct { + websocket.Upgrader + origin string + } + deadlineConn struct { + *websocket.Conn + wt time.Duration + mu sync.Mutex // needed for concurrent writes of Gorilla + } +) + +func (conn *deadlineConn) write(t int, mess []byte) error { + conn.mu.Lock() + defer conn.mu.Unlock() + if err := conn.SetWriteDeadline(time.Now().Add(conn.wt)); err != nil { + return err + } + return conn.WriteMessage(t, mess) +} + +func (conn *deadlineConn) writeControl(messageType int, data []byte, deadline time.Time) error { + conn.mu.Lock() + defer conn.mu.Unlock() + return conn.Conn.WriteControl(messageType, data, deadline) } var DefaultUpgrader = Upgrader{ Upgrader: websocket.Upgrader{ - ReadBufferSize: 4096, - WriteBufferSize: 4096, - EnableCompression: false, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + WriteBufferPool: &sync.Pool{}, + EnableCompression: true, }, } -func NewUpgrader(origin string) Upgrader { +var ErrNilConnection = errors.New("nil connection") + +func NewUpgrader(origin string) *Upgrader { u := DefaultUpgrader switch { case origin == "*": @@ -30,7 +79,7 @@ func NewUpgrader(origin string) Upgrader { case origin != "": u.CheckOrigin = func(r *http.Request) bool { return r.Header.Get("Origin") == origin } } - return u + return &u } func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*websocket.Conn, error) { @@ -40,11 +89,136 @@ func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeade return u.Upgrader.Upgrade(w, r, responseHeader) } -func Connect(address url.URL) (*websocket.Conn, error) { - dialer := websocket.Dialer{} +func NewServerWithConn(conn *websocket.Conn, log *logger.Logger) (*WS, error) { + if conn == nil { + return nil, ErrNilConnection + } + return newSocket(conn, true, true, log), nil +} + +func NewClient(address url.URL, log *logger.Logger) (*WS, error) { + dialer := websocket.DefaultDialer if address.Scheme == "wss" { dialer.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } - ws, _, err := dialer.Dial(address.String(), nil) - return ws, err + conn, _, err := dialer.Dial(address.String(), nil) + if err != nil { + return nil, err + } + return newSocket(conn, false, false, log), nil +} + +func (ws *WS) IsServer() bool { return ws.server } + +// reader pumps messages from the websocket connection to the OnMessage callback. +// Blocking, must be called as goroutine. Serializes all websocket reads. +func (ws *WS) reader() { + defer func() { + atomic.StoreUint32(&ws.closed, 1) + close(ws.send) + ws.shutdown() + }() + + ws.conn.SetReadLimit(maxMessageSize) + _ = ws.conn.SetReadDeadline(time.Now().Add(pongTime)) + if ws.pingPong { + ws.conn.SetPongHandler(func(string) error { _ = ws.conn.SetReadDeadline(time.Now().Add(pongTime)); return nil }) + } else { + ws.conn.SetPingHandler(func(string) error { + _ = ws.conn.SetReadDeadline(time.Now().Add(pongTime)) + err := ws.conn.writeControl(websocket.PongMessage, nil, time.Now().Add(writeWait)) + if err == websocket.ErrCloseSent { + return nil + } else if e, ok := err.(net.Error); ok && e.Timeout() { + return nil + } + return err + }) + } + for { + _, message, err := ws.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + ws.log.Error().Err(err).Msg("WebSocket read fail") + } + break + } + ws.OnMessage(message, err) + } +} + +// writer pumps messages from the send channel to the websocket connection. +// Blocking, must be called as goroutine. Serializes all websocket writes. +func (ws *WS) writer() { + defer ws.shutdown() + + if ws.pingPong { + ticker := time.NewTicker(pingTime) + defer ticker.Stop() + + for { + select { + case message, ok := <-ws.send: + if !ws.handleMessage(message, ok) { + return + } + case <-ticker.C: + if err := ws.conn.write(websocket.PingMessage, nil); err != nil { + return + } + } + } + } else { + for message := range ws.send { + if !ws.handleMessage(message, true) { + return + } + } + } +} + +func (ws *WS) handleMessage(message []byte, ok bool) bool { + if !ok { + _ = ws.conn.write(websocket.CloseMessage, nil) + return false + } + if err := ws.conn.write(websocket.TextMessage, message); err != nil { + return false + } + return true +} + +func newSocket(conn *websocket.Conn, pingPong bool, server bool, log *logger.Logger) *WS { + return &WS{ + conn: deadlineConn{Conn: conn, wt: writeWait}, + send: make(chan []byte), + once: sync.Once{}, + Done: make(chan struct{}, 1), + pingPong: pingPong, + server: server, + OnMessage: func(message []byte, err error) {}, + log: log, + } +} + +func (ws *WS) Listen() { + go ws.writer() + go ws.reader() +} + +func (ws *WS) Write(data []byte) { + if atomic.LoadUint32(&ws.closed) == 0 { + ws.send <- data + } +} + +func (ws *WS) Close() { _ = ws.conn.write(websocket.CloseMessage, nil) } + +func (ws *WS) shutdown() { + ws.once.Do(func() { + atomic.StoreUint32(&ws.closed, 1) + _ = ws.conn.Close() + close(ws.Done) + ws.log.Debug().Msg("WebSocket should be closed now") + }) } diff --git a/pkg/os/os.go b/pkg/os/os.go index e5141520..e9fb31f4 100644 --- a/pkg/os/os.go +++ b/pkg/os/os.go @@ -1,15 +1,24 @@ package os import ( + "errors" + "io/fs" "os" "os/signal" "os/user" "syscall" ) -type Signal struct { - event chan os.Signal - done chan struct{} +func Exists(path string) bool { + _, err := os.Stat(path) + return !errors.Is(err, fs.ErrNotExist) +} + +func CheckCreateDir(path string) error { + if !Exists(path) { + return os.Mkdir(path, os.ModeDir) + } + return nil } func ExpectTermination() chan struct{} { @@ -30,3 +39,7 @@ func GetUserHome() (string, error) { } return me.HomeDir, nil } + +func WriteFile(name string, data []byte, perm os.FileMode) error { + return os.WriteFile(name, data, perm) +} diff --git a/pkg/recorder/ffmpegstream.go b/pkg/recorder/ffmpegstream.go deleted file mode 100644 index 7f04719e..00000000 --- a/pkg/recorder/ffmpegstream.go +++ /dev/null @@ -1,121 +0,0 @@ -package recorder - -import ( - "bytes" - "fmt" - "image" - "image/png" - "log" - "os" - "path/filepath" - "sync" - "sync/atomic" - "time" - - "github.com/hashicorp/go-multierror" -) - -type ffmpegStream struct { - VideoStream - - demux *file - - buf chan Video - dir string - pnge *png.Encoder - sequence uint32 - fps float64 - wg sync.WaitGroup -} - -const ( - demuxFile = "input.txt" - videoFile = "f%v.png" -) - -type pool struct{ sync.Pool } - -func pngBuf() *pool { return &pool{sync.Pool{New: func() interface{} { return &png.EncoderBuffer{} }}} } -func (p *pool) Get() *png.EncoderBuffer { return p.Pool.Get().(*png.EncoderBuffer) } -func (p *pool) Put(b *png.EncoderBuffer) { p.Pool.Put(b) } - -func NewFfmpegStream(dir string, opts Options) (*ffmpegStream, error) { - demux, err := newFile(dir, demuxFile) - if err != nil { - return nil, err - } - - _, err = demux.WriteString( - fmt.Sprintf("ffconcat version 1.0\n"+ - "# v: 1\n# date: %v\n# game: %v\n# fps: %v\n# freq (hz): %v\n\n", - time.Now().Format("20060102"), opts.Game, opts.Fps, opts.Frequency)) - - return &ffmpegStream{ - buf: make(chan Video, 1), - dir: dir, - demux: demux, - fps: opts.Fps, - pnge: &png.Encoder{ - CompressionLevel: png.CompressionLevel(opts.ImageCompressionLevel), - BufferPool: pngBuf(), - }, - }, nil -} - -func (f *ffmpegStream) Start() { - for frame := range f.buf { - if err := f.Save(frame.Image, frame.Duration); err != nil { - log.Printf("image write err: %v", err) - } - } -} - -func (f *ffmpegStream) Stop() error { - var result *multierror.Error - close(f.buf) - f.resetSeq() - result = multierror.Append(result, f.demux.Flush()) - result = multierror.Append(result, f.demux.Close()) - f.wg.Wait() - return result.ErrorOrNil() -} - -func (f *ffmpegStream) Save(img image.Image, dur time.Duration) error { - fileName := fmt.Sprintf(videoFile, f.nextSeq()) - f.wg.Add(1) - go f.saveImage(fileName, img) - // ffmpeg concat demuxer, see: https://ffmpeg.org/ffmpeg-formats.html#concat - inf := fmt.Sprintf("file %v\nduration %v\n#delta %v\n", fileName, 1/f.fps, dur.Seconds()) - if _, err := f.demux.WriteString(inf); err != nil { - return err - } - return nil -} - -func (f *ffmpegStream) saveImage(fileName string, img image.Image) { - defer f.wg.Done() - - var buf bytes.Buffer - x, y := (img).Bounds().Dx(), (img).Bounds().Dy() - buf.Grow(x * y * 4) - - if err := f.pnge.Encode(&buf, img); err != nil { - log.Printf("p err: %v", err) - } else { - file, err := os.Create(filepath.Join(f.dir, fileName)) - if err != nil { - log.Printf("c err: %v", err) - } - if _, err = file.Write(buf.Bytes()); err != nil { - log.Printf("f err: %v", err) - } - if err = file.Close(); err != nil { - log.Printf("fc err: %v", err) - } - } -} - -func (f *ffmpegStream) nextSeq() uint32 { return atomic.AddUint32(&f.sequence, 1) } -func (f *ffmpegStream) resetSeq() { atomic.StoreUint32(&f.sequence, 0) } - -func (f *ffmpegStream) Write(data Video) { f.buf <- data } diff --git a/pkg/service/service.go b/pkg/service/service.go index 84437a1a..48c3f063 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -1,19 +1,16 @@ package service -import ( - "context" - "log" -) +import "fmt" // Service defines a generic service. -type Service interface{} +type Service any // RunnableService defines a service that can be run. type RunnableService interface { Service Run() - Shutdown(ctx context.Context) error + Stop() error } // Group is a container for managing a bunch of services. @@ -21,28 +18,29 @@ type Group struct { list []Service } -func (g *Group) Add(services ...Service) { - for _, s := range services { - g.list = append(g.list, s) - } -} +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 { - go v.Run() + v.Run() } } } -// Shutdown terminates a group of services. -func (g *Group) Shutdown(ctx context.Context) { +// 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.Shutdown(ctx); err != nil && err != context.Canceled { - log.Printf("error: failed to stop [%s] because of %v", s, err) + if err := v.Stop(); err != nil { + errs = append(errs, fmt.Errorf("error: failed to stop [%s] because of %v", s, err)) } } } + if len(errs) > 0 { + err = fmt.Errorf("%s", errs) + } + return } diff --git a/pkg/storage/noop.go b/pkg/storage/noop.go deleted file mode 100644 index 342aab29..00000000 --- a/pkg/storage/noop.go +++ /dev/null @@ -1,19 +0,0 @@ -package storage - -import "errors" - -type NoopCloudStorage struct{} - -var noopErr = errors.New("an empty storage stub") - -func NewNoopCloudStorage() (*NoopCloudStorage, error) { - return nil, noopErr -} - -func (n *NoopCloudStorage) Save(_ string, _ string) (err error) { - return nil -} - -func (n *NoopCloudStorage) Load(_ string) (data []byte, err error) { - return nil, noopErr -} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go deleted file mode 100644 index 36cfa8eb..00000000 --- a/pkg/storage/storage.go +++ /dev/null @@ -1,6 +0,0 @@ -package storage - -type CloudStorage interface { - Save(name string, localPath string) (err error) - Load(name string) (data []byte, err error) -} diff --git a/pkg/util/logging/init.go b/pkg/util/logging/init.go deleted file mode 100644 index ebefcd33..00000000 --- a/pkg/util/logging/init.go +++ /dev/null @@ -1,38 +0,0 @@ -package logging - -import ( - "flag" - "log" - - "github.com/golang/glog" - "github.com/spf13/pflag" -) - -func init() { - _ = flag.Set("logtostderr", "true") -} - -// LogWriter serves as a bridge between the standard log package and the glog package. -type LogWriter struct{} - -// Write implements the io.Writer interface. -func (writer LogWriter) Write(data []byte) (n int, err error) { - glog.InfoDepth(3, string(data)) - return len(data), nil -} - -// Init initializes logs the way we want. -func Init() { - log.SetOutput(LogWriter{}) - log.SetFlags(0) - - pflag.CommandLine.AddGoFlagSet(flag.CommandLine) - pflag.Parse() - // Convinces goflags that we have called Parse() to avoid noisy logs. - _ = flag.CommandLine.Parse([]string{}) -} - -// Flush flushes logs immediately. -func Flush() { - glog.Flush() -} diff --git a/pkg/webrtc/connection.go b/pkg/webrtc/connection.go deleted file mode 100644 index 40a1ec58..00000000 --- a/pkg/webrtc/connection.go +++ /dev/null @@ -1,91 +0,0 @@ -package webrtc - -import ( - "log" - "net" - "sync" - - conf "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" - "github.com/giongto35/cloud-game/v2/pkg/network/socket" - "github.com/pion/interceptor" - pion "github.com/pion/webrtc/v3" -) - -type PeerConnection struct { - api *pion.API - config *pion.Configuration -} - -var ( - settingsOnce sync.Once - settings pion.SettingEngine -) - -func DefaultPeerConnection(conf conf.Webrtc) (*PeerConnection, error) { - m := &pion.MediaEngine{} - if err := m.RegisterDefaultCodecs(); err != nil { - return nil, err - } - - i := &interceptor.Registry{} - if !conf.DisableDefaultInterceptors { - if err := pion.RegisterDefaultInterceptors(m, i); err != nil { - return nil, err - } - } - - settingsOnce.Do(func() { - settingEngine := pion.SettingEngine{} - if conf.DtlsRole > 0 { - log.Printf("A custom DTLS role [%v]", conf.DtlsRole) - if err := settingEngine.SetAnsweringDTLSRole(pion.DTLSRole(conf.DtlsRole)); err != nil { - panic(err) - } - } - if conf.IceLite { - settingEngine.SetLite(conf.IceLite) - } - if conf.IcePorts.Min > 0 && conf.IcePorts.Max > 0 { - if err := settingEngine.SetEphemeralUDPPortRange(conf.IcePorts.Min, conf.IcePorts.Max); err != nil { - panic(err) - } - } else { - if conf.SinglePort > 0 { - l, err := socket.NewSocketPortRoll("udp", conf.SinglePort) - if err != nil { - panic(err) - } - udpListener := l.(*net.UDPConn) - log.Printf("Listening for WebRTC traffic at %s", udpListener.LocalAddr()) - settingEngine.SetICEUDPMux(pion.NewICEUDPMux(nil, udpListener)) - } - } - if conf.IceIpMap != "" { - settingEngine.SetNAT1To1IPs([]string{conf.IceIpMap}, pion.ICECandidateTypeHost) - } - settings = settingEngine - }) - - peerConf := pion.Configuration{ICEServers: []pion.ICEServer{}} - for _, server := range conf.IceServers { - peerConf.ICEServers = append(peerConf.ICEServers, pion.ICEServer{ - URLs: []string{server.Url}, - Username: server.Username, - Credential: server.Credential, - }) - } - - conn := PeerConnection{ - api: pion.NewAPI( - pion.WithMediaEngine(m), - pion.WithInterceptorRegistry(i), - pion.WithSettingEngine(settings), - ), - config: &peerConf, - } - return &conn, nil -} - -func (p *PeerConnection) NewConnection() (*pion.PeerConnection, error) { - return p.api.NewPeerConnection(*p.config) -} diff --git a/pkg/webrtc/webrtc.go b/pkg/webrtc/webrtc.go deleted file mode 100644 index fc55b1c0..00000000 --- a/pkg/webrtc/webrtc.go +++ /dev/null @@ -1,370 +0,0 @@ -package webrtc - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "log" - "runtime/debug" - "time" - - "github.com/giongto35/cloud-game/v2/pkg/codec" - webrtcConfig "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" - "github.com/gofrs/uuid" - "github.com/pion/webrtc/v3" - "github.com/pion/webrtc/v3/pkg/media" -) - -type WebFrame struct { - Data []byte - Duration time.Duration -} - -// WebRTC connection -type WebRTC struct { - ID string - - connection *webrtc.PeerConnection - cfg webrtcConfig.Config - defaultConnection *PeerConnection - isConnected bool - // for yuvI420 image - ImageChannel chan WebFrame - AudioChannel chan []byte - //VoiceInChannel chan []byte - //VoiceOutChannel chan []byte - InputChannel chan []byte - - Done bool - - RoomID string - PlayerIndex int -} - -type OnIceCallback func(candidate string) - -// Encode encodes the input in base64 -func Encode(obj interface{}) (string, error) { - b, err := json.Marshal(obj) - if err != nil { - return "", err - } - - return base64.StdEncoding.EncodeToString(b), nil -} - -// Decode decodes the input from base64 -func Decode(in string, obj interface{}) error { - b, err := base64.StdEncoding.DecodeString(in) - if err != nil { - return err - } - - err = json.Unmarshal(b, obj) - if err != nil { - return err - } - - return nil -} - -// NewWebRTC create -func NewWebRTC(conf webrtcConfig.Config) (*WebRTC, error) { - w := &WebRTC{ - ID: uuid.Must(uuid.NewV4()).String(), - - ImageChannel: make(chan WebFrame, 30), - AudioChannel: make(chan []byte, 1), - //VoiceInChannel: make(chan []byte, 1), - //VoiceOutChannel: make(chan []byte, 1), - InputChannel: make(chan []byte, 100), - cfg: conf, - } - conn, err := DefaultPeerConnection(w.cfg.Webrtc) - if err != nil { - return nil, err - } - w.defaultConnection = conn - return w, nil -} - -// StartClient start webrtc -func (w *WebRTC) StartClient(iceCB OnIceCallback) (string, error) { - defer func() { - if err := recover(); err != nil { - log.Println(err) - w.StopClient() - } - }() - var err error - var videoTrack *webrtc.TrackLocalStaticSample - - // reset client - if w.isConnected { - w.StopClient() - time.Sleep(2 * time.Second) - } - - log.Println("=== StartClient ===") - - w.connection, err = w.defaultConnection.NewConnection() - if err != nil { - return "", nil - } - - // add video track - rtpCodec := webrtc.RTPCodecCapability{MimeType: w.getVideoCodec()} - if videoTrack, err = webrtc.NewTrackLocalStaticSample(rtpCodec, "video", "game-video"); err != nil { - return "", err - } - - if _, err = w.connection.AddTrack(videoTrack); err != nil { - return "", err - } - log.Println("Add video track") - - // add audio track - opusTrack, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "game-audio") - if err != nil { - return "", err - } - _, err = w.connection.AddTrack(opusTrack) - if err != nil { - return "", err - } - - //_, err = w.connection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RtpTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}) - - // create data channel for input, and register callbacks - // order: true, negotiated: false, id: random - inputTrack, err := w.connection.CreateDataChannel("game-input", nil) - if err != nil { - return "", err - } - - inputTrack.OnOpen(func() { - log.Printf("Data channel '%s'-'%d' open.\n", inputTrack.Label(), inputTrack.ID()) - }) - - // Register text message handling - inputTrack.OnMessage(func(msg webrtc.DataChannelMessage) { - // TODO: Can add recover here - w.InputChannel <- msg.Data - }) - - inputTrack.OnClose(func() { - log.Println("Data channel closed") - log.Println("Closed webrtc") - }) - - // WebRTC state callback - w.connection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) { - log.Printf("ICE Connection State has changed: %s\n", connectionState.String()) - if connectionState == webrtc.ICEConnectionStateConnected { - go func() { - w.isConnected = true - log.Println("ConnectionStateConnected") - w.startStreaming(videoTrack, opusTrack) - }() - - } - if connectionState == webrtc.ICEConnectionStateFailed || connectionState == webrtc.ICEConnectionStateClosed || connectionState == webrtc.ICEConnectionStateDisconnected { - w.StopClient() - } - }) - - w.connection.OnICECandidate(func(iceCandidate *webrtc.ICECandidate) { - if iceCandidate != nil { - log.Println("OnIceCandidate:", iceCandidate.ToJSON().Candidate) - candidate, err := Encode(iceCandidate.ToJSON()) - if err != nil { - log.Println("Encode IceCandidate failed: " + iceCandidate.ToJSON().Candidate) - return - } - iceCB(candidate) - } else { - // finish, send null - iceCB("") - } - - }) - - w.connection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { - //NOTE: High CPU due to constantly for loop. Turn it off first, Fix it later. - //rtpBuf := make([]byte, 1400) - - //log.Println("Received Voice from Client") - //for { - //if w.RoomID == "" { - //// skip sending voice when game is not running - //continue - //} - - //i, err := remoteTrack.Read(rtpBuf) - //// TODO: can receive track but the voice doesn't work - //if err == nil { - //w.VoiceInChannel <- rtpBuf[:i] - //} - //} - - }) - - // Stream provider supposes to send offer - offer, err := w.connection.CreateOffer(nil) - if err != nil { - return "", err - } - log.Println("Created Offer") - - err = w.connection.SetLocalDescription(offer) - if err != nil { - return "", err - } - - localSession, err := Encode(offer) - if err != nil { - return "", err - } - - return localSession, nil -} - -func (w *WebRTC) getVideoCodec() string { - switch w.cfg.Encoder.Video.Codec { - case string(codec.H264): - return webrtc.MimeTypeH264 - case string(codec.VPX): - return webrtc.MimeTypeVP8 - default: - return webrtc.MimeTypeH264 - } -} - -func (w *WebRTC) AttachRoomID(roomID string) { - w.RoomID = roomID -} - -func (w *WebRTC) SetRemoteSDP(remoteSDP string) error { - var answer webrtc.SessionDescription - err := Decode(remoteSDP, &answer) - if err != nil { - log.Println("Decode remote sdp from peer failed") - return err - } - - err = w.connection.SetRemoteDescription(answer) - if err != nil { - log.Println("Set remote description from peer failed") - return err - } - - log.Println("Set Remote Description") - return nil -} - -func (w *WebRTC) AddCandidate(candidate string) error { - var iceCandidate webrtc.ICECandidateInit - err := Decode(candidate, &iceCandidate) - if err != nil { - log.Println("Decode Ice candidate from peer failed") - return err - } - log.Println("Decoded Ice: " + iceCandidate.Candidate) - - err = w.connection.AddICECandidate(iceCandidate) - if err != nil { - log.Println("Add Ice candidate from peer failed") - return err - } - - log.Println("Add Ice Candidate: " + iceCandidate.Candidate) - return nil -} - -// StopClient disconnect -func (w *WebRTC) StopClient() { - // if stopped, bypass - if !w.isConnected { - return - } - - w.isConnected = false - if w.connection != nil { - if err := w.connection.Close(); err != nil { - log.Printf("error: couldn't close WebRTC connection, %v", err) - } - } - w.connection = nil - //close(w.InputChannel) - // webrtc is producer, so we close - // NOTE: ImageChannel is waiting for input. Close in writer is not correct for this - close(w.ImageChannel) - close(w.AudioChannel) - //close(w.VoiceInChannel) - //close(w.VoiceOutChannel) - log.Println("===StopClient===") -} - -func (w *WebRTC) IsConnected() bool { return w.isConnected } - -func (w *WebRTC) startStreaming(vp8Track *webrtc.TrackLocalStaticSample, opusTrack *webrtc.TrackLocalStaticSample) { - log.Println("Start streaming") - // receive frame buffer - go func() { - defer func() { - if r := recover(); r != nil { - fmt.Println("Recovered from err", r) - log.Println(debug.Stack()) - } - }() - - for data := range w.ImageChannel { - if err := vp8Track.WriteSample(media.Sample{Data: data.Data, Duration: data.Duration}); err != nil { - log.Println("Warn: Err write sample: ", err) - break - } - } - }() - - // send audio - go func() { - defer func() { - if r := recover(); r != nil { - fmt.Println("Recovered from err", r) - log.Println(debug.Stack()) - } - }() - - audioDuration := time.Duration(w.cfg.Encoder.Audio.Frame) * time.Millisecond - for data := range w.AudioChannel { - if !w.isConnected { - return - } - err := opusTrack.WriteSample(media.Sample{Data: data, Duration: audioDuration}) - if err != nil { - log.Println("Warn: Err write sample: ", err) - } - } - }() - - //// send voice - //go func() { - // defer func() { - // if r := recover(); r != nil { - // fmt.Println("Recovered from err", r) - // log.Println(debug.Stack()) - // } - // }() - // - // for data := range w.VoiceOutChannel { - // if !w.isConnected { - // return - // } - // // !to pass duration from the input - // err := opusTrack.WriteSample(media.Sample{Data: data}) - // if err != nil { - // log.Println("Warn: Err write sample: ", err) - // } - // } - //}() -} diff --git a/pkg/worker/cloudsave.go b/pkg/worker/cloudsave.go new file mode 100644 index 00000000..a3e0be64 --- /dev/null +++ b/pkg/worker/cloudsave.go @@ -0,0 +1,56 @@ +package worker + +import "os" + +type CloudSaveRoom struct { + GamingRoom + storage CloudStorage // a cloud storage to store room state online +} + +func WithCloudStorage(room GamingRoom, storage CloudStorage) *CloudSaveRoom { + cr := CloudSaveRoom{ + GamingRoom: room, + storage: storage, + } + if err := room.(*CloudSaveRoom).Download(); err != nil { + room.GetLog().Warn().Err(err).Msg("The room is not in the cloud") + } + return &cr +} + +func (c *CloudSaveRoom) Download() error { + // saveOnlineRoomToLocal save online room to local. + // !Supports only one file of main save state. + + data, err := c.storage.Load(c.GetId()) + if err != nil { + return err + } + // Save the data fetched from a cloud provider to the local server + if data != nil { + if err := os.WriteFile(c.GetEmulator().GetHashPath(), data, 0644); err != nil { + return err + } + c.GetLog().Debug().Msg("Successfully downloaded cloud save") + } + return nil +} + +func (c *CloudSaveRoom) HasSave() bool { + _, err := c.storage.Load(c.GetId()) + if err == nil { + return true + } + return c.GamingRoom.HasSave() +} + +func (c *CloudSaveRoom) SaveGame() error { + if err := c.GamingRoom.SaveGame(); err != nil { + return err + } + if err := c.storage.Save(c.GetId(), c.GetEmulator().GetHashPath()); err != nil { + return err + } + c.GetLog().Debug().Msg("Cloud save is successful") + return nil +} diff --git a/pkg/compression/compression.go b/pkg/worker/compression/compression.go similarity index 50% rename from pkg/compression/compression.go rename to pkg/worker/compression/compression.go index 6fb32e13..ea2269e4 100644 --- a/pkg/compression/compression.go +++ b/pkg/worker/compression/compression.go @@ -3,17 +3,18 @@ package compression import ( "path/filepath" - "github.com/giongto35/cloud-game/v2/pkg/compression/zip" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/compression/zip" ) type Extractor interface { Extract(src string, dest string) ([]string, error) } -func NewExtractorFromExt(path string) Extractor { +func NewFromExt(path string, log *logger.Logger) Extractor { switch filepath.Ext(path) { case zip.Ext: - return zip.New() + return zip.New(log) default: return nil } diff --git a/pkg/compression/zip/compression.go b/pkg/worker/compression/zip/compression.go similarity index 87% rename from pkg/compression/zip/compression.go rename to pkg/worker/compression/zip/compression.go index 21065c61..0357cd84 100644 --- a/pkg/compression/zip/compression.go +++ b/pkg/worker/compression/zip/compression.go @@ -5,10 +5,11 @@ import ( "bytes" "errors" "io" - "log" "os" "path/filepath" "strings" + + "github.com/giongto35/cloud-game/v2/pkg/logger" ) const Ext = ".zip" @@ -18,9 +19,15 @@ var ( ErrorInvalidName = errors.New("invalid name") ) -type Extractor struct{} +type Extractor struct { + log *logger.Logger +} -func New() Extractor { return Extractor{} } +func New(log *logger.Logger) Extractor { + return Extractor{ + log: log, + } +} // Compress compresses the bytes (a single file) with a name specified into a ZIP file (as bytes). func Compress(data []byte, name string) ([]byte, error) { @@ -85,34 +92,34 @@ func (e Extractor) Extract(src string, dest string) (files []string, err error) // negate ZipSlip vulnerability (http://bit.ly/2MsjAWE) if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { - log.Printf("warning: %s is illegal path", path) + e.log.Warn().Msgf("%s is illegal path", path) continue } // remake directory if f.FileInfo().IsDir() { if err := os.MkdirAll(path, os.ModePerm); err != nil { - log.Printf("error: %v", err) + e.log.Error().Err(err) } continue } // make file if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { - log.Printf("error: %v", err) + e.log.Error().Err(err) continue } out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { - log.Printf("error: %v", err) + e.log.Error().Err(err) continue } rc, err := f.Open() if err != nil { - log.Printf("error: %v", err) + e.log.Error().Err(err) continue } if _, err = io.Copy(out, rc); err != nil { - log.Printf("error: %v", err) + e.log.Error().Err(err) _ = out.Close() _ = rc.Close() continue diff --git a/pkg/compression/zip/compression_test.go b/pkg/worker/compression/zip/compression_test.go similarity index 92% rename from pkg/compression/zip/compression_test.go rename to pkg/worker/compression/zip/compression_test.go index 11a60e0b..d0328d32 100644 --- a/pkg/compression/zip/compression_test.go +++ b/pkg/worker/compression/zip/compression_test.go @@ -39,6 +39,10 @@ func TestCompression(t *testing.T) { return } got, name, err := Read(got) + if (err != nil) != tt.wantErr { + t.Errorf("Compress() error = %v, wantErr %v", err, tt.wantErr) + return + } if name != tt.wantName { t.Errorf("Compress() got name = %v, want %v", name, tt.wantName) } diff --git a/pkg/worker/coordinator.go b/pkg/worker/coordinator.go index 45951bd1..b12353a0 100644 --- a/pkg/worker/coordinator.go +++ b/pkg/worker/coordinator.go @@ -1,25 +1,135 @@ package worker import ( - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/gorilla/websocket" + "net/url" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/network/webrtc" ) -// CoordinatorClient maintains connection to coordinator. -// We expect only one CoordinatorClient for each server. -type CoordinatorClient struct { - *cws.Client +type coordinator struct { + com.SocketClient } -// NewCoordinatorClient returns a client connecting to coordinator -// for coordination between different server. -func NewCoordinatorClient(oc *websocket.Conn) *CoordinatorClient { - if oc == nil { - return nil - } +var connector = com.NewConnector() - oClient := &CoordinatorClient{ - Client: cws.NewClient(oc), +// connect to a coordinator. +func connect(host string, conf worker.Worker, addr string, log *logger.Logger) (*coordinator, error) { + scheme := "ws" + if conf.Network.Secure { + scheme = "wss" } - return oClient + address := url.URL{Scheme: scheme, Host: host, Path: conf.Network.Endpoint} + + log.Info().Str("c", "c").Str("d", "→").Msgf("Handshake %s", address.String()) + + id := network.NewUid() + req, err := buildConnQuery(id, conf, addr) + if req != "" && err == nil { + address.RawQuery = "data=" + req + } + conn, err := connector.NewClient(address, log) + if err != nil { + return nil, err + } + return &coordinator{com.New(conn, "c", id, log)}, nil +} + +func (c *coordinator) HandleRequests(w *Worker) { + ap, err := webrtc.NewApiFactory(w.conf.Webrtc, c.Log, nil) + if err != nil { + c.Log.Panic().Err(err).Msg("WebRTC API creation has been failed") + } + c.ProcessMessages() + skipped := com.Out{} + + c.OnPacket(func(x com.In) (err error) { + var out com.Out + switch x.T { + case api.WebrtcInit: + if dat := api.Unwrap[api.WebrtcInitRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleWebrtcInit(*dat, w, ap) + } + case api.WebrtcAnswer: + dat := api.Unwrap[api.WebrtcAnswerRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleWebrtcAnswer(*dat, w) + case api.WebrtcIce: + dat := api.Unwrap[api.WebrtcIceCandidateRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleWebrtcIceCandidate(*dat, w) + case api.StartGame: + if dat := api.Unwrap[api.StartGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleGameStart(*dat, w) + } + case api.TerminateSession: + dat := api.Unwrap[api.TerminateSessionRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleTerminateSession(*dat, w) + case api.QuitGame: + dat := api.Unwrap[api.GameQuitRequest](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleQuitGame(*dat, w) + case api.SaveGame: + if dat := api.Unwrap[api.SaveGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleSaveGame(*dat, w) + } + case api.LoadGame: + if dat := api.Unwrap[api.LoadGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleLoadGame(*dat, w) + } + case api.ChangePlayer: + if dat := api.Unwrap[api.ChangePlayerRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleChangePlayer(*dat, w) + } + case api.ToggleMultitap: + if dat := api.Unwrap[api.ToggleMultitapRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + c.HandleToggleMultitap(*dat, w) + } + case api.RecordGame: + if dat := api.Unwrap[api.RecordGameRequest](x.Payload); dat == nil { + err, out = api.ErrMalformed, com.EmptyPacket + } else { + out = c.HandleRecordGame(*dat, w) + } + default: + c.Log.Warn().Msgf("unhandled packet type %v", x.T) + } + if out != skipped { + w.cord.Route(x, out) + } + return err + }) +} + +func (c *coordinator) RegisterRoom(id string) { c.Notify(api.RegisterRoom, id) } + +// CloseRoom sends a signal to coordinator which will remove that room from its list. +func (c *coordinator) CloseRoom(id string) { c.Notify(api.CloseRoom, id) } +func (c *coordinator) IceCandidate(candidate string, sessionId network.Uid) { + c.Notify(api.NewWebrtcIceCandidateRequest(sessionId, candidate)) } diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go new file mode 100644 index 00000000..c341986a --- /dev/null +++ b/pkg/worker/coordinatorhandlers.go @@ -0,0 +1,209 @@ +package worker + +import ( + "fmt" + + "github.com/giongto35/cloud-game/v2/pkg/api" + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/network/webrtc" +) + +// buildConnQuery builds initial connection data query to a coordinator. +func buildConnQuery[S fmt.Stringer](id S, conf worker.Worker, address string) (string, error) { + addr := conf.GetPingAddr(address) + return api.ToBase64Json(api.ConnectionRequest{ + Addr: addr.Hostname(), + Id: id.String(), + IsHTTPS: conf.Server.Https, + PingURL: addr.String(), + Port: conf.GetPort(address), + Tag: conf.Tag, + Zone: conf.Network.Zone, + }) +} + +func (c *coordinator) HandleWebrtcInit(rq api.WebrtcInitRequest, w *Worker, connApi *webrtc.ApiFactory) com.Out { + peer := webrtc.New(c.Log, connApi) + localSDP, err := peer.NewCall(w.conf.Encoder.Video.Codec, audioCodec, func(data any) { + candidate, err := api.ToBase64Json(data) + if err != nil { + c.Log.Error().Err(err).Msgf("ICE candidate encode fail for [%v]", data) + return + } + c.IceCandidate(candidate, rq.Id) + }) + if err != nil { + c.Log.Error().Err(err).Msg("cannot create new webrtc session") + return com.EmptyPacket + } + sdp, err := api.ToBase64Json(localSDP) + if err != nil { + c.Log.Error().Err(err).Msgf("SDP encode fail fro [%v]", localSDP) + return com.EmptyPacket + } + + // use user uid from the coordinator + user := NewSession(peer, rq.Id) + w.router.AddUser(user) + c.Log.Info().Str("id", string(rq.Id)).Msgf("Peer connection (uid:%s)", user.Id()) + + return com.Out{Payload: sdp} +} + +func (c *coordinator) HandleWebrtcAnswer(rq api.WebrtcAnswerRequest, w *Worker) { + if user := w.router.GetUser(rq.Id); user != nil { + if err := user.GetPeerConn().SetRemoteSDP(rq.Sdp, api.FromBase64Json); err != nil { + c.Log.Error().Err(err).Msgf("cannot set remote SDP of client [%v]", rq.Id) + } + } +} + +func (c *coordinator) HandleWebrtcIceCandidate(rs api.WebrtcIceCandidateRequest, w *Worker) { + if user := w.router.GetUser(rs.Id); user != nil { + if err := user.GetPeerConn().AddCandidate(rs.Candidate, api.FromBase64Json); err != nil { + c.Log.Error().Err(err).Msgf("cannot add ICE candidate of the client [%v]", rs.Id) + } + } +} + +func (c *coordinator) HandleGameStart(rq api.StartGameRequest, w *Worker) com.Out { + user := w.router.GetUser(rq.Id) + if user == nil { + c.Log.Error().Msgf("no user [%v]", rq.Id) + return com.EmptyPacket + } + w.log.Info().Msgf("Starting game: %v", rq.Game.Name) + + room := w.router.GetRoom(rq.Rid) + if room == nil { + room = NewRoom( + rq.Room.Rid, + games.GameMetadata{Name: rq.Game.Name, Base: rq.Game.Base, Type: rq.Game.Type, Path: rq.Game.Path}, + func(room *Room) { + w.router.RemoveRoom() + c.CloseRoom(room.id) + w.log.Debug().Msgf("Room close has been called %v", room.id) + }, + w.conf, + w.log, + ) + user.SetPlayerIndex(rq.PlayerIndex) + + if w.storage != nil { + room = WithCloudStorage(room, w.storage) + } + if w.conf.Recording.Enabled { + room = WithRecording(room.(*Room), rq.Record, rq.RecordUser, rq.Game.Name, w.conf) + } + w.router.SetRoom(room) + + room.StartEmulator() + + if w.conf.Emulator.AutosaveSec > 0 { + // !to can crash if emulator starts earlier + go room.EnableAutosave(w.conf.Emulator.AutosaveSec) + } + } + + if room == nil { + c.Log.Error().Msgf("couldn't create a room [%v]", rq.Id) + return com.EmptyPacket + } + + if !room.HasUser(user) { + room.AddUser(user) + room.PollUserInput(user) + } + user.SetRoom(room) + + c.RegisterRoom(room.GetId()) + + return com.Out{Payload: api.StartGameResponse{Room: api.Room{Rid: room.GetId()}, Record: w.conf.Recording.Enabled}} +} + +// HandleTerminateSession handles cases when a user has been disconnected from the websocket of coordinator. +func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest, w *Worker) { + if session := w.router.GetUser(rq.Id); session != nil { + w.router.RemoveUser(session) + if room := session.GetSetRoom(nil); room != nil { + room.CleanupUser(session) + } + } +} + +// HandleQuitGame handles cases when a user manually exits the game. +func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest, w *Worker) { + if user := w.router.GetUser(rq.Id); user != nil { + // we don't strictly need a room id form the request, + // since users hold their room reference + // !to remove rid, maybe + if room := w.router.GetRoom(rq.Rid); room != nil { + room.CleanupUser(user) + } + } +} + +func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest, w *Worker) com.Out { + if room := roomy(rq, w); room != nil { + if err := room.SaveGame(); err != nil { + c.Log.Error().Err(err).Msg("cannot save game state") + return com.ErrPacket + } + return com.OkPacket + } + return com.ErrPacket +} + +func (c *coordinator) HandleLoadGame(rq api.LoadGameRequest, w *Worker) com.Out { + if room := roomy(rq, w); room != nil { + if err := room.LoadGame(); err != nil { + c.Log.Error().Err(err).Msg("cannot load game state") + return com.ErrPacket + } + return com.OkPacket + } + return com.ErrPacket +} + +func (c *coordinator) HandleChangePlayer(rq api.ChangePlayerRequest, w *Worker) com.Out { + user := w.router.GetUser(rq.Id) + if user == nil || w.router.GetRoom(rq.Rid) == nil { + return com.Out{Payload: -1} // semi-predicates + } + user.SetPlayerIndex(rq.Index) + w.log.Info().Msgf("Updated player index to: %d", rq.Index) + return com.Out{Payload: rq.Index} +} + +func (c *coordinator) HandleToggleMultitap(rq api.ToggleMultitapRequest, w *Worker) com.Out { + if room := roomy(rq, w); room != nil { + room.ToggleMultitap() + return com.OkPacket + } + return com.ErrPacket +} + +func (c *coordinator) HandleRecordGame(rq api.RecordGameRequest, w *Worker) com.Out { + if !w.conf.Recording.Enabled { + return com.ErrPacket + } + if room := roomy(rq, w); room != nil { + room.(*RecordingRoom).ToggleRecording(rq.Active, rq.User) + return com.OkPacket + } + return com.ErrPacket +} + +func roomy(rq api.RoomInterface, w *Worker) GamingRoom { + rid := rq.GetRoom() + if rid == "" { + return nil + } + room := w.router.GetRoom(rid) + if room == nil { + return nil + } + return room +} diff --git a/pkg/worker/emulator/emulator.go b/pkg/worker/emulator/emulator.go new file mode 100644 index 00000000..319d1abc --- /dev/null +++ b/pkg/worker/emulator/emulator.go @@ -0,0 +1,72 @@ +package emulator + +import ( + img "image" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/image" +) + +type Emulator interface { + // SetAudio sets the audio callback + SetAudio(func(*GameAudio)) + // SetVideo sets the video callback + SetVideo(func(*GameFrame)) + GetAudio() func(*GameAudio) + GetVideo() func(*GameFrame) + LoadMetadata(name string) + LoadGame(path string) error + GetFps() uint + GetSampleRate() uint + GetFrameSize() (w, h int) + HasVerticalFrame() bool + // Start is called after LoadGame + Start() + // SetViewport sets viewport size + SetViewport(width int, height int) + // SetMainSaveName sets distinct name for saves naming + SetMainSaveName(name string) + // SaveGameState save game state + SaveGameState() error + // LoadGameState load game state + LoadGameState() error + // GetHashPath returns the path emulator will save state to + GetHashPath() string + // Close will be called when the game is done + Close() + // ToggleMultitap toggles multitap controller. + ToggleMultitap() + // Input passes input to the emulator + Input(player int, data []byte) +} + +type Metadata struct { + // the full path to some emulator lib + LibPath string + // the full path to the emulator config + ConfigPath string + + AudioSampleRate int + Fps float64 + BaseWidth int + BaseHeight int + Rotation image.Rotate + IsGlAllowed bool + UsesLibCo bool + AutoGlContext bool + HasMultitap bool +} + +type ( + GameFrame struct { + Data *img.RGBA + Duration time.Duration + } + GameAudio struct { + Data *[]int16 + Duration time.Duration + } + InputEvent struct { + RawState []byte + } +) diff --git a/pkg/emulator/graphics/context.go b/pkg/worker/emulator/graphics/context.go similarity index 100% rename from pkg/emulator/graphics/context.go rename to pkg/worker/emulator/graphics/context.go diff --git a/pkg/emulator/backend/gl/KHR/khrplatform.h b/pkg/worker/emulator/graphics/gl/KHR/khrplatform.h similarity index 100% rename from pkg/emulator/backend/gl/KHR/khrplatform.h rename to pkg/worker/emulator/graphics/gl/KHR/khrplatform.h diff --git a/pkg/emulator/backend/gl/gl.go b/pkg/worker/emulator/graphics/gl/gl.go similarity index 91% rename from pkg/emulator/backend/gl/gl.go rename to pkg/worker/emulator/graphics/gl/gl.go index 68b03ccc..d8f95a62 100644 --- a/pkg/emulator/backend/gl/gl.go +++ b/pkg/worker/emulator/graphics/gl/gl.go @@ -4,8 +4,6 @@ package gl // Based on https://github.com/go-gl/gl/tree/master/v2.1/gl /* -#cgo CFLAGS: -Wall -O3 - #cgo egl,windows LDFLAGS: -lEGL #cgo egl,darwin LDFLAGS: -lEGL #cgo !gles2,darwin LDFLAGS: -framework OpenGL @@ -123,29 +121,29 @@ import ( ) const ( - VENDOR = 0x1F00 - VERSION = 0x1F02 - RENDERER = 0x1F01 - SHADING_LANGUAGE_VERSION = 0x8B8C - TEXTURE_2D = 0x0DE1 - RENDERBUFFER = 0x8D41 - FRAMEBUFFER = 0x8D40 - TEXTURE_MIN_FILTER = 0x2801 - TEXTURE_MAG_FILTER = 0x2800 - NEAREST = 0x2600 - RGBA8 = 0x8058 - BGRA = 0x80E1 - RGB = 0x1907 - COLOR_ATTACHMENT0 = 0x8CE0 - DEPTH24_STENCIL8 = 0x88F0 - DEPTH_STENCIL_ATTACHMENT = 0x821A - DEPTH_COMPONENT24 = 0x81A6 - DEPTH_ATTACHMENT = 0x8D00 - FRAMEBUFFER_COMPLETE = 0x8CD5 - NO_ERROR = 0 - UNSIGNED_SHORT_5_5_5_1 = 0x8034 - UNSIGNED_SHORT_5_6_5 = 0x8363 - UNSIGNED_INT_8_8_8_8_REV = 0x8367 + VENDOR = 0x1F00 + VERSION = 0x1F02 + RENDERER = 0x1F01 + ShadingLanguageVersion = 0x8B8C + Texture2d = 0x0DE1 + RENDERBUFFER = 0x8D41 + FRAMEBUFFER = 0x8D40 + TextureMinFilter = 0x2801 + TextureMagFilter = 0x2800 + NEAREST = 0x2600 + RGBA8 = 0x8058 + BGRA = 0x80E1 + RGB = 0x1907 + ColorAttachment0 = 0x8CE0 + Depth24Stencil8 = 0x88F0 + DepthStencilAttachment = 0x821A + DepthComponent24 = 0x81A6 + DepthAttachment = 0x8D00 + FramebufferComplete = 0x8CD5 + + UnsignedShort5551 = 0x8034 + UnsignedShort565 = 0x8363 + UnsignedInt8888Rev = 0x8367 ) var ( @@ -229,8 +227,8 @@ func TexImage2D(target uint32, level int32, internalformat int32, width int32, h func GenFramebuffers(n int32, framebuffers *uint32) { C.genFramebuffers(gpGenFramebuffers, (C.GLsizei)(n), (*C.GLuint)(unsafe.Pointer(framebuffers))) } -func FramebufferTexture2D(target uint32, attachment uint32, textarget uint32, texture uint32, level int32) { - C.framebufferTexture2D(gpFramebufferTexture2D, (C.GLenum)(target), (C.GLenum)(attachment), (C.GLenum)(textarget), (C.GLuint)(texture), (C.GLint)(level)) +func FramebufferTexture2D(target uint32, attachment uint32, texTarget uint32, texture uint32, level int32) { + C.framebufferTexture2D(gpFramebufferTexture2D, (C.GLenum)(target), (C.GLenum)(attachment), (C.GLenum)(texTarget), (C.GLuint)(texture), (C.GLint)(level)) } func GenRenderbuffers(n int32, renderbuffers *uint32) { C.genRenderbuffers(gpGenRenderbuffers, (C.GLsizei)(n), (*C.GLuint)(unsafe.Pointer(renderbuffers))) @@ -241,8 +239,8 @@ func BindRenderbuffer(target uint32, renderbuffer uint32) { func RenderbufferStorage(target uint32, internalformat uint32, width int32, height int32) { C.renderbufferStorage(gpRenderbufferStorage, (C.GLenum)(target), (C.GLenum)(internalformat), (C.GLsizei)(width), (C.GLsizei)(height)) } -func FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) { - C.framebufferRenderbuffer(gpFramebufferRenderbuffer, (C.GLenum)(target), (C.GLenum)(attachment), (C.GLenum)(renderbuffertarget), (C.GLuint)(renderbuffer)) +func FramebufferRenderbuffer(target uint32, attachment uint32, renderbufferTarget uint32, renderbuffer uint32) { + C.framebufferRenderbuffer(gpFramebufferRenderbuffer, (C.GLenum)(target), (C.GLenum)(attachment), (C.GLenum)(renderbufferTarget), (C.GLuint)(renderbuffer)) } func CheckFramebufferStatus(target uint32) uint32 { return (uint32)(C.checkFramebufferStatus(gpCheckFramebufferStatus, (C.GLenum)(target))) diff --git a/pkg/worker/emulator/graphics/opengl.go b/pkg/worker/emulator/graphics/opengl.go new file mode 100644 index 00000000..0de9911e --- /dev/null +++ b/pkg/worker/emulator/graphics/opengl.go @@ -0,0 +1,131 @@ +package graphics + +import ( + "errors" + "fmt" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/graphics/gl" +) + +type ( + offscreenSetup struct { + tex uint32 + fbo uint32 + rbo uint32 + + width int32 + height int32 + + pixType uint32 + pixFormat uint32 + + hasDepth bool + hasStencil bool + } + PixelFormat int +) + +const ( + UnsignedShort5551 PixelFormat = iota + UnsignedShort565 + UnsignedInt8888Rev +) + +var ( + opt = offscreenSetup{} + buf []byte +) + +func initContext(getProcAddr func(name string) unsafe.Pointer) { + if err := gl.InitWithProcAddrFunc(getProcAddr); err != nil { + panic(err) + } +} + +func initFramebuffer(w int, h int, hasDepth bool, hasStencil bool) error { + opt.width = int32(w) + opt.height = int32(h) + opt.hasDepth = hasDepth + opt.hasStencil = hasStencil + + // texture init + gl.GenTextures(1, &opt.tex) + gl.BindTexture(gl.Texture2d, opt.tex) + + gl.TexParameteri(gl.Texture2d, gl.TextureMinFilter, gl.NEAREST) + gl.TexParameteri(gl.Texture2d, gl.TextureMagFilter, gl.NEAREST) + + gl.TexImage2D(gl.Texture2d, 0, gl.RGBA8, opt.width, opt.height, 0, opt.pixType, opt.pixFormat, nil) + gl.BindTexture(gl.Texture2d, 0) + + // framebuffer init + gl.GenFramebuffers(1, &opt.fbo) + gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) + + gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.ColorAttachment0, gl.Texture2d, opt.tex, 0) + + // more buffers init + if opt.hasDepth { + gl.GenRenderbuffers(1, &opt.rbo) + gl.BindRenderbuffer(gl.RENDERBUFFER, opt.rbo) + if opt.hasStencil { + gl.RenderbufferStorage(gl.RENDERBUFFER, gl.Depth24Stencil8, opt.width, opt.height) + gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DepthStencilAttachment, gl.RENDERBUFFER, opt.rbo) + } else { + gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DepthComponent24, opt.width, opt.height) + gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DepthAttachment, gl.RENDERBUFFER, opt.rbo) + } + gl.BindRenderbuffer(gl.RENDERBUFFER, 0) + } + + if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FramebufferComplete { + return fmt.Errorf("invalid framebuffer (0x%X)", status) + } + return nil +} + +func destroyFramebuffer() { + if opt.hasDepth { + gl.DeleteRenderbuffers(1, &opt.rbo) + } + gl.DeleteFramebuffers(1, &opt.fbo) + gl.DeleteTextures(1, &opt.tex) +} + +func ReadFramebuffer(bytes int, w int, h int) []byte { + data := buf[:bytes] + gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) + gl.ReadPixels(0, 0, int32(w), int32(h), opt.pixType, opt.pixFormat, unsafe.Pointer(&data[0])) + gl.BindFramebuffer(gl.FRAMEBUFFER, 0) + return data +} + +func getFbo() uint32 { return opt.fbo } + +func SetBuffer(size int) { buf = make([]byte, size) } + +func SetPixelFormat(format PixelFormat) error { + switch format { + case UnsignedShort5551: + opt.pixFormat = gl.UnsignedShort5551 + opt.pixType = gl.BGRA + case UnsignedShort565: + opt.pixFormat = gl.UnsignedShort565 + opt.pixType = gl.RGB + case UnsignedInt8888Rev: + opt.pixFormat = gl.UnsignedInt8888Rev + opt.pixType = gl.BGRA + default: + return errors.New("unknown pixel format") + } + return nil +} + +func GetGLVersionInfo() string { return get(gl.VERSION) } +func GetGLVendorInfo() string { return get(gl.VENDOR) } +func GetGLRendererInfo() string { return get(gl.RENDERER) } +func GetGLSLInfo() string { return get(gl.ShadingLanguageVersion) } +func GetGLError() uint32 { return gl.GetError() } + +func get(name uint32) string { return gl.GoStr(gl.GetString(name)) } diff --git a/pkg/worker/emulator/graphics/sdl.go b/pkg/worker/emulator/graphics/sdl.go new file mode 100644 index 00000000..77b8ceaa --- /dev/null +++ b/pkg/worker/emulator/graphics/sdl.go @@ -0,0 +1,139 @@ +package graphics + +import ( + "fmt" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" + "github.com/veandco/go-sdl2/sdl" +) + +type SDL struct { + glWCtx sdl.GLContext + w *sdl.Window + log *logger.Logger +} + +type Config struct { + Ctx Context + W int + H int + GLAutoContext bool + GLVersionMajor uint + GLVersionMinor uint + GLHasDepth bool + GLHasStencil bool +} + +// NewSDLContext initializes SDL/OpenGL context. +// Uses main thread lock (see thread/mainthread). +func NewSDLContext(cfg Config, log *logger.Logger) (*SDL, error) { + log.Debug().Msg("[SDL/OpenGL] initialization...") + + if err := sdl.Init(sdl.INIT_VIDEO); err != nil { + return nil, fmt.Errorf("SDL initialization fail: %w", err) + } + + display := SDL{log: log} + + if cfg.GLAutoContext { + log.Debug().Msgf("[OpenGL] CONTEXT_AUTO (type: %v v%v.%v)", cfg.Ctx, cfg.GLVersionMajor, cfg.GLVersionMinor) + } else { + switch cfg.Ctx { + case CtxOpenGlCore: + display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE) + log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_CORE") + case CtxOpenGlEs2: + display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_ES) + display.setAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, 3) + display.setAttribute(sdl.GL_CONTEXT_MINOR_VERSION, 0) + log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_ES 3.0") + case CtxOpenGl: + if cfg.GLVersionMajor >= 3 { + display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_COMPATIBILITY) + } + log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_COMPATIBILITY") + default: + log.Error().Msgf("[OpenGL] Unsupported hw context: %v", cfg.Ctx) + } + } + + var err error + // In OSX 10.14+ window creation and context creation must happen in the main thread + thread.Main(func() { display.w, display.glWCtx, err = createWindow() }) + if err != nil { + return nil, fmt.Errorf("window fail: %w", err) + } + + if err := display.BindContext(); err != nil { + return nil, fmt.Errorf("bind context fail: %w", err) + } + initContext(sdl.GLGetProcAddress) + if err := initFramebuffer(cfg.W, cfg.H, cfg.GLHasDepth, cfg.GLHasStencil); err != nil { + return nil, fmt.Errorf("OpenGL initialization fail: %w", err) + } + return &display, nil +} + +// Deinit destroys SDL/OpenGL context. +// Uses main thread lock (see thread/mainthread). +func (s *SDL) Deinit() error { + s.log.Debug().Msg("[SDL/OpenGL] shutdown...") + destroyFramebuffer() + var err error + // In OSX 10.14+ window deletion must happen in the main thread + thread.Main(func() { + err = s.destroyWindow() + }) + if err != nil { + return fmt.Errorf("[SDL/OpenGL] deinit fail: %w", err) + } + sdl.Quit() + s.log.Debug().Msgf("[SDL/OpenGL] shutdown codes:(%v, %v)", sdl.GetError(), GetGLError()) + return nil +} + +// createWindow creates a fake SDL window just for OpenGL initialization purposes. +func createWindow() (*sdl.Window, sdl.GLContext, error) { + w, err := sdl.CreateWindow( + "CloudRetro dummy window", + sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, + 1, 1, + sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN, + ) + if err != nil { + return nil, nil, fmt.Errorf("window creation fail: %w", err) + } + glWCtx, err := w.GLCreateContext() + if err != nil { + return nil, nil, fmt.Errorf("window OpenGL context fail: %w", err) + } + return w, glWCtx, nil +} + +// destroyWindow destroys previously created SDL window. +func (s *SDL) destroyWindow() error { + if err := s.BindContext(); err != nil { + return err + } + sdl.GLDeleteContext(s.glWCtx) + if err := s.w.Destroy(); err != nil { + return fmt.Errorf("window destroy fail: %w", err) + } + return nil +} + +// BindContext explicitly binds context to current thread. +func (s *SDL) BindContext() error { return s.w.GLMakeCurrent(s.glWCtx) } + +// setAttribute tries to set a GL attribute or prints error. +func (s *SDL) setAttribute(attr sdl.GLattr, value int) { + if err := sdl.GLSetAttribute(attr, value); err != nil { + s.log.Error().Err(err).Msg("[SDL] attribute") + } +} + +func GetGlFbo() uint32 { return getFbo() } + +func GetGlProcAddress(proc string) unsafe.Pointer { return sdl.GLGetProcAddress(proc) } diff --git a/pkg/worker/emulator/image/draw.go b/pkg/worker/emulator/image/draw.go new file mode 100644 index 00000000..81b5d72f --- /dev/null +++ b/pkg/worker/emulator/image/draw.go @@ -0,0 +1,112 @@ +package image + +import ( + "image" + "sync" + "unsafe" +) + +const ( + BitFormatShort5551 = iota // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha + BitFormatInt8888Rev // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha + BitFormatShort565 // BIT_FORMAT_SHORT_5_6_5 has 5 bits R, 6 bits G, 5 bits +) + +type imageCache struct { + image *image.RGBA + w, h int +} + +func (i *imageCache) get(w, h int) *image.RGBA { + if i.w == w && i.h == h { + return i.image + } + i.w, i.h = w, h + i.image = image.NewRGBA(image.Rect(0, 0, w, h)) + return i.image +} + +var ( + canvas1 = imageCache{image.NewRGBA(image.Rectangle{}), 0, 0} + canvas2 = imageCache{image.NewRGBA(image.Rectangle{}), 0, 0} + wg sync.WaitGroup +) + +func DrawRgbaImage(encoding uint32, rot *Rotate, scaleType int, flipV bool, w, h, packedW, bpp int, + data []byte, dw, dh, th int) *image.RGBA { + // !to implement own image interfaces img.Pix = bytes[] + ww, hh := w, h + if rot != nil && rot.IsEven { + ww, hh = hh, ww + } + src := canvas1.get(ww, hh) + + pwb := packedW * bpp + if th == 0 { + frame(encoding, src, data, 0, h, flipV, h, w, pwb, bpp, rot) + } else { + hn := h / th + wg.Add(th) + for i := 0; i < th; i++ { + xx := hn * i + go func() { + frame(encoding, src, data, xx, hn, flipV, h, w, pwb, bpp, rot) + wg.Done() + }() + } + wg.Wait() + } + + if ww == dw && hh == dh { + return src + } else { + out := canvas2.get(dw, dh) + Resize(scaleType, src, out) + return out + } +} + +func frame(encoding uint32, src *image.RGBA, data []byte, xx int, hn int, flipV bool, h int, w int, pwb int, bpp int, rot *Rotate) { + var px uint32 + var dst *uint32 + for y, yy, l, lx, row := xx, 0, xx+hn, 0, 0; y < l; y++ { + yy = y + if flipV { + yy = (h - 1) - yy + } + row = yy * src.Stride + lx = y * pwb + for x, k := 0, 0; x < w; x++ { + if rot == nil { + k = x<<2 + row + } else { + dx, dy := rot.Call(x, yy, w, h) + k = dx<<2 + dy*src.Stride + } + dst = (*uint32)(unsafe.Pointer(&src.Pix[k])) + px = *(*uint32)(unsafe.Pointer(&data[x*bpp+lx])) + // LE, BE might not work + switch encoding { + case BitFormatShort565: + i565(dst, px) + case BitFormatInt8888Rev: + ix8888(dst, px) + } + } + } +} + +func i565(dst *uint32, px uint32) { + *dst = ((px >> 8) & 0xf8) | (((px >> 3) & 0xfc) << 8) | (((px << 3) & 0xfc) << 16) + 0xff000000 + // setting the last byte to 255 allows saving RGBA images to PNG not as black squares +} + +func ix8888(dst *uint32, px uint32) { + *dst = ((px >> 16) & 0xff) | (px & 0xff00) | ((px << 16) & 0xff0000) + 0xff000000 +} + +func Clear() { + wg = sync.WaitGroup{} + canvas1.get(0, 0) + canvas2.get(0, 0) +} diff --git a/pkg/worker/emulator/image/draw_test.go b/pkg/worker/emulator/image/draw_test.go new file mode 100644 index 00000000..1f6f0bed --- /dev/null +++ b/pkg/worker/emulator/image/draw_test.go @@ -0,0 +1,71 @@ +package image + +import ( + "fmt" + "testing" +) + +func BenchmarkDraw(b *testing.B) { + type args struct { + encoding uint32 + rot *Rotate + scaleType int + flipV bool + w int + h int + packedW int + bpp int + data []byte + dw int + dh int + th int + } + tests := []struct { + name string + args args + }{ + { + name: "0th", + args: args{ + encoding: BitFormatInt8888Rev, + rot: nil, + scaleType: ScaleNearestNeighbour, + flipV: false, + w: 256, + h: 240, + packedW: 256, + bpp: 4, + data: make([]uint8, 256*240*4), + dw: 256, + dh: 240, + th: 0, + }, + }, + { + name: "4th", + args: args{ + encoding: BitFormatInt8888Rev, + rot: nil, + scaleType: ScaleNearestNeighbour, + flipV: false, + w: 256, + h: 240, + packedW: 256, + bpp: 4, + data: make([]uint8, 256*240*4), + dw: 256, + dh: 240, + th: 4, + }, + }, + } + + for _, bn := range tests { + b.Run(fmt.Sprintf("%v", bn.name), func(b *testing.B) { + for i := 0; i < b.N; i++ { + DrawRgbaImage(bn.args.encoding, bn.args.rot, bn.args.scaleType, bn.args.flipV, bn.args.w, bn.args.h, bn.args.packedW, bn.args.bpp, bn.args.data, bn.args.dw, bn.args.dh, bn.args.th) + } + b.ReportAllocs() + }) + } +} diff --git a/pkg/emulator/image/rotation.go b/pkg/worker/emulator/image/rotation.go similarity index 70% rename from pkg/emulator/image/rotation.go rename to pkg/worker/emulator/image/rotation.go index ba61ed94..80a2e9ec 100644 --- a/pkg/emulator/image/rotation.go +++ b/pkg/worker/emulator/image/rotation.go @@ -1,3 +1,4 @@ +// Package image contains functions for rotations of points in a 2-dimensional space. package image type Angle uint @@ -11,15 +12,13 @@ const ( // Angles is a helper to choose appropriate rotation based on its angle. var Angles = [4]Rotate{ - Angle0: {Call: Rotate0, IsEven: false}, - Angle90: {Call: Rotate90, IsEven: true}, - Angle180: {Call: Rotate180, IsEven: false}, - Angle270: {Call: Rotate270, IsEven: true}, + Angle0: {Angle: Angle0, Call: Rotate0}, + Angle90: {Angle: Angle90, Call: Rotate90, IsEven: true}, + Angle180: {Angle: Angle180, Call: Rotate180}, + Angle270: {Angle: Angle270, Call: Rotate270, IsEven: true}, } -func GetRotation(angle Angle) Rotate { - return Angles[angle] -} +func GetRotation(angle Angle) Rotate { return Angles[angle] } // Rotate is an interface for rotation of a given point. // @@ -29,6 +28,7 @@ func GetRotation(angle Angle) Rotate { // and it's meant to be used with h corresponded // to matrix height and y coordinate, and with w to x coordinate. type Rotate struct { + Angle Angle Call func(x, y, w, h int) (int, int) IsEven bool } @@ -38,36 +38,28 @@ type Rotate struct { // 1 2 3 1 2 3 // 4 5 6 -> 4 5 6 // 7 8 9 7 8 9 -func Rotate0(x, y, _, _ int) (int, int) { - return x, y -} +func Rotate0(x, y, _, _ int) (int, int) { return x, y } // Rotate90 is 90° CCW or 270° CW. // // 1 2 3 3 6 9 // 4 5 6 -> 2 5 8 // 7 8 9 1 4 7 -func Rotate90(x, y, w, _ int) (int, int) { - return y, (w - 1) - x -} +func Rotate90(x, y, w, _ int) (int, int) { return y, (w - 1) - x } // Rotate180 is 180° CCW. // // 1 2 3 9 8 7 // 4 5 6 -> 6 5 4 // 7 8 9 3 2 1 -func Rotate180(x, y, w, h int) (int, int) { - return (w - 1) - x, (h - 1) - y -} +func Rotate180(x, y, w, h int) (int, int) { return (w - 1) - x, (h - 1) - y } // Rotate270 is 270° CCW or 90° CW. // // 1 2 3 7 4 1 // 4 5 6 -> 8 5 2 // 7 8 9 9 6 3 -func Rotate270(x, y, _, h int) (int, int) { - return (h - 1) - y, x -} +func Rotate270(x, y, _, h int) (int, int) { return (h - 1) - y, x } // ExampleRotate is an example of rotation usage. // @@ -76,7 +68,6 @@ func Rotate270(x, y, _, h int) (int, int) { func ExampleRotate(data []uint8, w int, h int, angle Angle) []uint8 { dest := make([]uint8, len(data)) rotationFn := Angles[angle] - for y := 0; y < h; y++ { for x := 0; x < w; x++ { nx, ny := rotationFn.Call(x, y, w, h) @@ -85,10 +76,8 @@ func ExampleRotate(data []uint8, w int, h int, angle Angle) []uint8 { stride = h } //fmt.Printf("%v:%v (%v) -> %v:%v (%v)\n", x, y, n1, nx, ny, n2) - dest[nx+ny*stride] = data[x+y*w] } } - return dest } diff --git a/pkg/emulator/image/rotation_test.go b/pkg/worker/emulator/image/rotation_test.go similarity index 100% rename from pkg/emulator/image/rotation_test.go rename to pkg/worker/emulator/image/rotation_test.go diff --git a/pkg/emulator/image/scale.go b/pkg/worker/emulator/image/scale.go similarity index 73% rename from pkg/emulator/image/scale.go rename to pkg/worker/emulator/image/scale.go index 2a507c92..32750d73 100644 --- a/pkg/emulator/image/scale.go +++ b/pkg/worker/emulator/image/scale.go @@ -7,12 +7,9 @@ import ( ) const ( - // skips image interpolation - ScaleNot = iota - // nearest neighbour interpolation - ScaleNearestNeighbour - // bilinear interpolation - ScaleBilinear + ScaleNot = iota // skips image interpolation + ScaleNearestNeighbour // nearest neighbour interpolation + ScaleBilinear // bilinear interpolation ) func Resize(scaleType int, src *image.RGBA, out *image.RGBA) { diff --git a/pkg/emulator/libretro/core/core.go b/pkg/worker/emulator/libretro/core.go similarity index 98% rename from pkg/emulator/libretro/core/core.go rename to pkg/worker/emulator/libretro/core.go index cacab064..72393ee6 100644 --- a/pkg/emulator/libretro/core/core.go +++ b/pkg/worker/emulator/libretro/core.go @@ -1,4 +1,4 @@ -package core +package libretro import ( "errors" diff --git a/pkg/worker/emulator/libretro/frontend.go b/pkg/worker/emulator/libretro/frontend.go new file mode 100644 index 00000000..c5f80a05 --- /dev/null +++ b/pkg/worker/emulator/libretro/frontend.go @@ -0,0 +1,240 @@ +package libretro + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + conf "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" +) + +type Frontend struct { + onVideo func(*emulator.GameFrame) + onAudio func(*emulator.GameAudio) + + input InputState + + conf conf.Emulator + storage Storage + + // out frame size + vw, vh int + // draw threads + th int + + done chan struct{} + log *logger.Logger + + mu sync.Mutex +} + +// InputState stores full controller state. +// It consists of: +// - uint16 button values +// - int16 analog stick values +type ( + InputState [maxPort]State + State struct { + keys uint32 + axes [dpadAxes]int32 + } +) + +const ( + maxPort = 4 + dpadAxes = 4 + KeyPressed = 1 + KeyReleased = 0 +) + +// NewFrontend implements Emulator interface for a Libretro frontend. +func NewFrontend(conf conf.Emulator, log *logger.Logger) (*Frontend, error) { + log = log.Extend(log.With().Str("m", "Libretro")) + ll := log.Extend(log.Level(logger.Level(conf.Libretro.LogLevel)).With()) + SetLibretroLogger(ll) + + // Check if room is on local storage, if not, pull from GCS to local storage + log.Info().Msgf("Local storage path: %v", conf.Storage) + if err := os.MkdirAll(conf.Storage, 0755); err != nil && !os.IsExist(err) { + return nil, fmt.Errorf("failed to create local storage path: %v, %w", conf.Storage, err) + } + + path, err := filepath.Abs(conf.LocalPath) + if err != nil { + return nil, fmt.Errorf("failed to use emulator path: %v, %w", conf.LocalPath, err) + } + if err := os.MkdirAll(path, 0755); err != nil && !os.IsExist(err) { + return nil, fmt.Errorf("failed to create local path: %v, %w", conf.LocalPath, err) + } + log.Info().Msgf("Emulator save path is %v", path) + Init(path) + + var store Storage = &StateStorage{Path: conf.Storage} + if conf.Libretro.SaveCompression { + store = &ZipStorage{Storage: store} + } + + // set global link to the Libretro + frontend = &Frontend{ + conf: conf, + storage: store, + input: NewGameSessionInput(), + done: make(chan struct{}), + th: conf.Threads, + log: log, + } + return frontend, nil +} + +func (f *Frontend) Input(player int, data []byte) { f.input.setInput(player, data) } + +func (f *Frontend) LoadMetadata(emu string) { + libretroConf := f.conf.GetLibretroCoreConfig(emu) + f.mu.Lock() + coreLoad(emulator.Metadata{ + LibPath: libretroConf.Lib, + ConfigPath: libretroConf.Config, + IsGlAllowed: libretroConf.IsGlAllowed, + UsesLibCo: libretroConf.UsesLibCo, + HasMultitap: libretroConf.HasMultitap, + AutoGlContext: libretroConf.AutoGlContext, + }) + f.mu.Unlock() +} + +func (f *Frontend) Start() { + // start only when it is available + <-nano.reserved + + if err := f.LoadGameState(); err != nil { + f.log.Error().Err(err).Msg("couldn't load a save file") + } + ticker := time.NewTicker(time.Second / time.Duration(nano.sysAvInfo.timing.fps)) + + defer func() { + ticker.Stop() + nanoarchShutdown() + f.log.Debug().Msgf("run loop finished") + }() + + // start time for the first frame + lastFrameTime = time.Now().UnixNano() + for { + f.mu.Lock() + run() + f.mu.Unlock() + select { + case <-ticker.C: + continue + case <-f.done: + return + } + } +} + +func (f *Frontend) GetFrameSize() (int, int) { + return int(nano.sysAvInfo.geometry.base_width), int(nano.sysAvInfo.geometry.base_height) +} + +func (f *Frontend) SetAudio(ff func(*emulator.GameAudio)) { f.onAudio = ff } +func (f *Frontend) GetAudio() func(*emulator.GameAudio) { return f.onAudio } +func (f *Frontend) SetVideo(ff func(*emulator.GameFrame)) { f.onVideo = ff } +func (f *Frontend) GetVideo() func(*emulator.GameFrame) { return f.onVideo } +func (f *Frontend) GetFps() uint { return uint(nano.sysAvInfo.timing.fps) } +func (f *Frontend) GetHashPath() string { return f.storage.GetSavePath() } +func (f *Frontend) GetSRAMPath() string { return f.storage.GetSRAMPath() } +func (f *Frontend) GetSampleRate() uint { return uint(nano.sysAvInfo.timing.sample_rate) } +func (f *Frontend) LoadGame(path string) error { return LoadGame(path) } +func (f *Frontend) LoadGameState() error { return f.Load() } +func (f *Frontend) HasVerticalFrame() bool { return nano.rot != nil && nano.rot.IsEven } +func (f *Frontend) SaveGameState() error { return f.Save() } +func (f *Frontend) SetMainSaveName(name string) { f.storage.SetMainSaveName(name) } +func (f *Frontend) SetViewport(width int, height int) { f.vw, f.vh = width, height } +func (f *Frontend) ToggleMultitap() { toggleMultitap() } + +func (f *Frontend) Close() { + f.mu.Lock() + f.SetViewport(0, 0) + f.mu.Unlock() + close(f.done) + nano.reserved <- struct{}{} +} + +// Save writes the current state to the filesystem. +func (f *Frontend) Save() error { + f.mu.Lock() + defer f.mu.Unlock() + + if usesLibCo { + return nil + } + + ss, err := getSaveState() + if err != nil { + return err + } + if err := f.storage.Save(f.GetHashPath(), ss); err != nil { + return err + } + + if sram := getSaveRAM(); sram != nil { + if err := f.storage.Save(f.GetSRAMPath(), sram); err != nil { + return err + } + } + return nil +} + +// Load restores the state from the filesystem. +func (f *Frontend) Load() error { + f.mu.Lock() + defer f.mu.Unlock() + + if usesLibCo { + return nil + } + + ss, err := f.storage.Load(f.GetHashPath()) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := restoreSaveState(ss); err != nil { + return err + } + + sram, err := f.storage.Load(f.GetSRAMPath()) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if sram != nil { + restoreSaveRAM(sram) + } + return nil +} + +func NewGameSessionInput() InputState { return [maxPort]State{} } + +// setInput sets input state for some player in a game session. +func (s *InputState) setInput(player int, data []byte) { + atomic.StoreUint32(&s[player].keys, uint32(uint16(data[1])<<8+uint16(data[0]))) + for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ { + axis := i<<1 + 2 + atomic.StoreInt32(&s[player].axes[i], int32(data[axis+1])<<8+int32(data[axis])) + } +} + +// isKeyPressed checks if some button is pressed by any player. +func (s *InputState) isKeyPressed(port uint, key int) int { + return int((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1) +} + +// isDpadTouched checks if D-pad is used by any player. +func (s *InputState) isDpadTouched(port uint, axis uint) (shift int16) { + return int16(atomic.LoadInt32(&s[port].axes[axis])) +} diff --git a/pkg/emulator/libretro/nanoarch/persistence_test.go b/pkg/worker/emulator/libretro/frontend_test.go similarity index 83% rename from pkg/emulator/libretro/nanoarch/persistence_test.go rename to pkg/worker/emulator/libretro/frontend_test.go index 409318a8..2a77e287 100644 --- a/pkg/emulator/libretro/nanoarch/persistence_test.go +++ b/pkg/worker/emulator/libretro/frontend_test.go @@ -1,4 +1,4 @@ -package nanoarch +package libretro import ( "fmt" @@ -6,6 +6,8 @@ import ( "sync" "testing" "time" + + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" ) // Tests a successful emulator state save. @@ -58,8 +60,8 @@ func TestSave(t *testing.T) { // Emulate n ticks again. // Call load from the save (b). // Compare states (a) and (b), should be =. -// func TestLoad(t *testing.T) { + t.Skip() tests := []testRun{ { room: "test_load_00", @@ -155,11 +157,15 @@ func TestStateConcurrency(t *testing.T) { // quantum lock qLock := &sync.Mutex{} op := 0 + <-nano.reserved mock.loadRom(test.run.rom) - go mock.handleVideo(func(frame GameFrame) {}) - go mock.handleAudio(func(_ []int16) {}) - go mock.handleInput(func(_ InputEvent) {}) + mock.handleVideo(func(frame *emulator.GameFrame) { + if len(frame.Data.Pix) == 0 { + t.Errorf("It seems that rom video frame was empty, which is strange!") + } + }) + mock.handleAudio(func(_ *emulator.GameAudio) {}) rand.Seed(int64(test.seed)) t.Logf("Random seed is [%v]\n", test.seed) @@ -168,8 +174,8 @@ func TestStateConcurrency(t *testing.T) { _ = mock.Save() // emulation fps ROM cap - ticker := time.NewTicker(time.Second / time.Duration(mock.meta.Fps)) - t.Logf("FPS limit is [%v]\n", mock.meta.Fps) + ticker := time.NewTicker(time.Second / time.Duration(mock.GetFps())) + t.Logf("FPS limit is [%v]\n", mock.GetFps()) for range ticker.C { select { @@ -224,6 +230,35 @@ func TestStateConcurrency(t *testing.T) { } // lucky returns random boolean. -func lucky() bool { - return rand.Intn(2) == 1 +func lucky() bool { return rand.Intn(2) == 1 } + +func TestConcurrentInput(t *testing.T) { + players := NewGameSessionInput() + + events := 1000 + var wg sync.WaitGroup + + wg.Add(events * 2) + + go func() { + for i := 0; i < events; i++ { + player := rand.Intn(maxPort) + go func() { + players.setInput(player, []byte{0, 1}) + wg.Done() + }() + } + }() + + go func() { + for i := 0; i < events; i++ { + player := rand.Intn(maxPort) + go func() { + players.isKeyPressed(uint(player), 100) + wg.Done() + }() + } + }() + + wg.Wait() } diff --git a/pkg/emulator/libretro/nanoarch/libretro.h b/pkg/worker/emulator/libretro/libretro.h similarity index 100% rename from pkg/emulator/libretro/nanoarch/libretro.h rename to pkg/worker/emulator/libretro/libretro.h diff --git a/pkg/emulator/libretro/nanoarch/loader.go b/pkg/worker/emulator/libretro/loader.go similarity index 92% rename from pkg/emulator/libretro/nanoarch/loader.go rename to pkg/worker/emulator/libretro/loader.go index f5f0e61c..f715bce1 100644 --- a/pkg/emulator/libretro/nanoarch/loader.go +++ b/pkg/worker/emulator/libretro/loader.go @@ -1,8 +1,8 @@ -package nanoarch +package libretro import ( "errors" - "io/ioutil" + "os" "path" "strconv" "strings" @@ -16,17 +16,10 @@ import ( */ import "C" -func open(file string) unsafe.Pointer { - cs := C.CString(file) - defer C.free(unsafe.Pointer(cs)) - return C.dlopen(cs, C.RTLD_LAZY) -} - func loadFunction(handle unsafe.Pointer, name string) unsafe.Pointer { cs := C.CString(name) defer C.free(unsafe.Pointer(cs)) - pointer := C.dlsym(handle, cs) - return pointer + return C.dlsym(handle, cs) } func loadLib(filepath string) (handle unsafe.Pointer, err error) { @@ -44,7 +37,7 @@ func loadLib(filepath string) (handle unsafe.Pointer, err error) { func loadLibRollingRollingRolling(filepath string) (handle unsafe.Pointer, err error) { dir, lib := path.Dir(filepath), path.Base(filepath) - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil { return nil, errors.New("couldn't find 'n load the lib") } @@ -60,11 +53,16 @@ func loadLibRollingRollingRolling(filepath string) (handle unsafe.Pointer, err e return nil, errors.New("couldn't find 'n load the lib") } +func open(file string) unsafe.Pointer { + cs := C.CString(file) + defer C.free(unsafe.Pointer(cs)) + return C.dlopen(cs, C.RTLD_LAZY) +} + func closeLib(handle unsafe.Pointer) (err error) { if handle == nil { return } - code := int(C.dlclose(handle)) if code != 0 { return errors.New("couldn't close the lib (" + strconv.Itoa(code) + ")") diff --git a/pkg/emulator/libretro/manager/manager.go b/pkg/worker/emulator/libretro/manager/manager.go similarity index 69% rename from pkg/emulator/libretro/manager/manager.go rename to pkg/worker/emulator/libretro/manager/manager.go index 54dcfe3c..a4e853f6 100644 --- a/pkg/emulator/libretro/manager/manager.go +++ b/pkg/worker/emulator/libretro/manager/manager.go @@ -1,13 +1,12 @@ package manager import ( - "io/ioutil" - "log" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "os" "path/filepath" "strings" "github.com/giongto35/cloud-game/v2/pkg/config/emulator" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" ) type Manager interface { @@ -18,17 +17,15 @@ type BasicManager struct { Conf emulator.LibretroConfig } -func (m BasicManager) GetInstalled() (installed []emulator.CoreInfo) { +func (m BasicManager) GetInstalled() (installed []emulator.CoreInfo, err error) { dir := m.Conf.GetCoresStorePath() - arch, err := core.GetCoreExt() + arch, err := libretro.GetCoreExt() if err != nil { - log.Printf("error: %v", err) return } - files, err := ioutil.ReadDir(dir) + files, err := os.ReadDir(dir) if err != nil { - log.Printf("error: couldn't get installed cores, %v", err) return } diff --git a/pkg/worker/emulator/libretro/manager/remotehttp/downloader.go b/pkg/worker/emulator/libretro/manager/remotehttp/downloader.go new file mode 100644 index 00000000..96b6212b --- /dev/null +++ b/pkg/worker/emulator/libretro/manager/remotehttp/downloader.go @@ -0,0 +1,63 @@ +package remotehttp + +import ( + "os" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/compression" +) + +type Download struct { + Key string + Address string +} + +type Client interface { + Request(dest string, urls ...Download) ([]string, []string) +} + +type Downloader struct { + backend Client + // pipe contains a sequential list of + // operations applied to some files and + // each operation will return a list of + // successfully processed files + pipe []Process + log *logger.Logger +} + +type Process func(string, []string, *logger.Logger) []string + +func NewDefaultDownloader(log *logger.Logger) Downloader { + return Downloader{ + backend: NewGrabDownloader(log), + pipe: []Process{unpackDelete}, + log: log, + } +} + +// Download tries to download specified with URLs list of files and +// put them into the destination folder. +// It will return a partial or full list of downloaded files, +// a list of processed files if some pipe processing functions are set. +func (d *Downloader) Download(dest string, urls ...Download) ([]string, []string) { + files, fails := d.backend.Request(dest, urls...) + for _, op := range d.pipe { + files = op(dest, files, d.log) + } + return files, fails +} + +func unpackDelete(dest string, files []string, log *logger.Logger) []string { + var res []string + for _, file := range files { + if unpack := compression.NewFromExt(file, log); unpack != nil { + if _, err := unpack.Extract(file, dest); err == nil { + if e := os.Remove(file); e == nil { + res = append(res, file) + } + } + } + } + return res +} diff --git a/pkg/worker/emulator/libretro/manager/remotehttp/grab.go b/pkg/worker/emulator/libretro/manager/remotehttp/grab.go new file mode 100644 index 00000000..7a2b5704 --- /dev/null +++ b/pkg/worker/emulator/libretro/manager/remotehttp/grab.go @@ -0,0 +1,59 @@ +package remotehttp + +import ( + "crypto/tls" + "net/http" + + "github.com/cavaliergopher/grab/v3" + "github.com/giongto35/cloud-game/v2/pkg/logger" +) + +type GrabDownloader struct { + client *grab.Client + parallelism int + log *logger.Logger +} + +func NewGrabDownloader(log *logger.Logger) GrabDownloader { + return GrabDownloader{ + client: &grab.Client{ + UserAgent: "Cloud-Game/3.0", + HTTPClient: &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + }, + parallelism: 5, + log: log, + } +} + +func (d GrabDownloader) Request(dest string, urls ...Download) (ok []string, nook []string) { + reqs := make([]*grab.Request, 0) + for _, url := range urls { + req, err := grab.NewRequest(dest, url.Address) + if err != nil { + d.log.Error().Err(err).Msgf("couldn't make request URL: %v, %v", url, err) + } else { + req.Label = url.Key + reqs = append(reqs, req) + } + } + + // check each response + for resp := range d.client.DoBatch(d.parallelism, reqs...) { + r := resp.Request + if err := resp.Err(); err != nil { + d.log.Error().Err(err).Msgf("download [%s] %s has failed: %v", r.Label, r.URL(), err) + if resp.HTTPResponse.StatusCode == 404 { + nook = append(nook, resp.Request.Label) + } + } else { + d.log.Info().Msgf("Downloaded [%v] [%s] -> %s", resp.HTTPResponse.Status, r.Label, resp.Filename) + ok = append(ok, resp.Filename) + } + } + return +} diff --git a/pkg/emulator/libretro/manager/remotehttp/manager.go b/pkg/worker/emulator/libretro/manager/remotehttp/manager.go similarity index 53% rename from pkg/emulator/libretro/manager/remotehttp/manager.go rename to pkg/worker/emulator/libretro/manager/remotehttp/manager.go index bc4cb773..e05b2eb4 100644 --- a/pkg/emulator/libretro/manager/remotehttp/manager.go +++ b/pkg/worker/emulator/libretro/manager/remotehttp/manager.go @@ -1,29 +1,28 @@ package remotehttp import ( - "log" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" "os" "github.com/giongto35/cloud-game/v2/pkg/config/emulator" - "github.com/giongto35/cloud-game/v2/pkg/downloader" - "github.com/giongto35/cloud-game/v2/pkg/downloader/backend" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/manager" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/manager" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo" "github.com/gofrs/flock" ) type Manager struct { manager.BasicManager - arch core.ArchInfo + arch libretro.ArchInfo repo repo.Repository altRepo repo.Repository - client downloader.Downloader + client Downloader fmu *flock.Flock + log *logger.Logger } -func NewRemoteHttpManager(conf emulator.LibretroConfig) Manager { +func NewRemoteHttpManager(conf emulator.LibretroConfig, log *logger.Logger) Manager { repoConf := conf.Cores.Repo.Main altRepoConf := conf.Cores.Repo.Secondary // used for synchronization of multiple process @@ -31,17 +30,19 @@ func NewRemoteHttpManager(conf emulator.LibretroConfig) Manager { if fileLock == "" { fileLock = os.TempDir() + string(os.PathSeparator) + "cloud_game.lock" } + log.Debug().Msgf("Using .lock file: %v", fileLock) - arch, err := core.GetCoreExt() + arch, err := libretro.GetCoreExt() if err != nil { - log.Printf("error: %v", err) + log.Error().Err(err).Msg("couldn't get Libretro core file extension") } m := Manager{ BasicManager: manager.BasicManager{Conf: conf}, arch: arch, - client: downloader.NewDefaultDownloader(), + client: NewDefaultDownloader(log), fmu: flock.New(fileLock), + log: log, } if repoConf.Type != "" { @@ -54,21 +55,42 @@ func NewRemoteHttpManager(conf emulator.LibretroConfig) Manager { return m } +func CheckCores(conf emulator.Emulator, log *logger.Logger) error { + if !conf.Libretro.Cores.Repo.Sync { + return nil + } + log.Info().Msg("Starting Libretro cores sync...") + coreManager := NewRemoteHttpManager(conf.Libretro, log) + // make a dir for cores + dir := coreManager.Conf.GetCoresStorePath() + if err := os.MkdirAll(dir, os.ModeDir); err != nil { + return err + } + if err := coreManager.Sync(); err != nil { + return err + } + return nil +} + func (m *Manager) Sync() error { // IPC lock if multiple worker processes on the same machine m.fmu.Lock() defer m.fmu.Unlock() - download := diff(m.Conf.GetCores(), m.GetInstalled()) + installed, err := m.GetInstalled() + if err != nil { + return err + } + download := diff(m.Conf.GetCores(), installed) if failed := m.download(download); len(failed) > 0 { - log.Printf("[core-dl] error: unable to download these cores: %v", failed) + m.log.Warn().Msgf("[core-dl] error: unable to download these cores: %v", failed) } return nil } -func (m *Manager) getCoreUrls(names []string, repo repo.Repository) (urls []backend.Download) { +func (m *Manager) getCoreUrls(names []string, repo repo.Repository) (urls []Download) { for _, c := range names { - urls = append(urls, backend.Download{Key: c, Address: repo.GetCoreUrl(c, m.arch)}) + urls = append(urls, Download{Key: c, Address: repo.GetCoreUrl(c, m.arch)}) } return } @@ -85,17 +107,17 @@ func (m *Manager) download(cores []emulator.CoreInfo) (failed []string) { second = append(second, n.Name) } } - log.Printf("[core-dl] <<< download | main: %v | alt: %v", prime, second) - unavailable := m.down(prime, m.repo) - if len(unavailable) > 0 && m.altRepo != nil { - log.Printf("[core-dl] error: unable to download some cores, trying 2nd repository") - failed = append(failed, m.down(unavailable, m.altRepo)...) + m.log.Info().Msgf("[core-dl] <<< download | main: %v | alt: %v", prime, second) + primeFails := m.down(prime, m.repo) + if len(primeFails) > 0 && m.altRepo != nil { + m.log.Warn().Msgf("[core-dl] error: unable to download some cores, trying 2nd repository") + failed = append(failed, m.down(primeFails, m.altRepo)...) } if m.altRepo != nil { - unavailable := m.down(second, m.altRepo) - if len(unavailable) > 0 { - log.Printf("[core-dl] error: unable to download some cores, trying 1st repository") - failed = append(failed, m.down(unavailable, m.repo)...) + altFails := m.down(second, m.altRepo) + if len(altFails) > 0 { + m.log.Error().Msgf("[core-dl] error: unable to download some cores, trying 1st repository") + failed = append(failed, m.down(altFails, m.repo)...) } } return diff --git a/pkg/emulator/libretro/manager/remotehttp/manager_test.go b/pkg/worker/emulator/libretro/manager/remotehttp/manager_test.go similarity index 100% rename from pkg/emulator/libretro/manager/remotehttp/manager_test.go rename to pkg/worker/emulator/libretro/manager/remotehttp/manager_test.go diff --git a/pkg/worker/emulator/libretro/nanoarch.c b/pkg/worker/emulator/libretro/nanoarch.c new file mode 100644 index 00000000..a3d7b5b3 --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch.c @@ -0,0 +1,206 @@ +#include "libretro.h" +#include +#include +#include +#include +//#include +//#include + +void coreLog(enum retro_log_level level, const char *msg); + +void bridge_retro_init(void *f) { + coreLog(RETRO_LOG_INFO, "Initialization...\n"); + return ((void (*)(void)) f)(); +} + +void bridge_retro_deinit(void *f) { + coreLog(RETRO_LOG_INFO, "Deinitialiazation...\n"); + return ((void (*)(void)) f)(); +} + +unsigned bridge_retro_api_version(void *f) { + return ((unsigned (*)(void)) f)(); +} + +void bridge_retro_get_system_info(void *f, struct retro_system_info *si) { + return ((void (*)(struct retro_system_info *)) f)(si); +} + +void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si) { + return ((void (*)(struct retro_system_av_info *)) f)(si); +} + +bool bridge_retro_set_environment(void *f, void *callback) { + return ((bool (*)(retro_environment_t)) f)((retro_environment_t) callback); +} + +void bridge_retro_set_video_refresh(void *f, void *callback) { + ((bool (*)(retro_video_refresh_t)) f)((retro_video_refresh_t) callback); +} + +void bridge_retro_set_input_poll(void *f, void *callback) { + ((bool (*)(retro_input_poll_t)) f)((retro_input_poll_t) callback); +} + +void bridge_retro_set_input_state(void *f, void *callback) { + ((bool (*)(retro_input_state_t)) f)((retro_input_state_t) callback); +} + +void bridge_retro_set_audio_sample(void *f, void *callback) { + ((bool (*)(retro_audio_sample_t)) f)((retro_audio_sample_t) callback); +} + +void bridge_retro_set_audio_sample_batch(void *f, void *callback) { + ((bool (*)(retro_audio_sample_batch_t)) f)((retro_audio_sample_batch_t) callback); +} + +bool bridge_retro_load_game(void *f, struct retro_game_info *gi) { + coreLog(RETRO_LOG_INFO, "Loading the game...\n"); + return ((bool (*)(struct retro_game_info *)) f)(gi); +} + +void bridge_retro_unload_game(void *f) { + coreLog(RETRO_LOG_INFO, "Unloading the game...\n"); + return ((void (*)(void)) f)(); +} + +void bridge_retro_run(void *f) { + return ((void (*)(void)) f)(); +} + +size_t bridge_retro_get_memory_size(void *f, unsigned id) { + return ((size_t (*)(unsigned)) f)(id); +} + +void *bridge_retro_get_memory_data(void *f, unsigned id) { + return ((void *(*)(unsigned)) f)(id); +} + +size_t bridge_retro_serialize_size(void *f) { + return ((size_t (*)(void)) f)(); +} + +bool bridge_retro_serialize(void *f, void *data, size_t size) { + return ((bool (*)(void *, size_t)) f)(data, size); +} + +bool bridge_retro_unserialize(void *f, void *data, size_t size) { + return ((bool (*)(void *, size_t)) f)(data, size); +} + +void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device) { + return ((void (*)(unsigned, unsigned)) f)(port, device); +} + +bool coreEnvironment_cgo(unsigned cmd, void *data) { + bool coreEnvironment(unsigned, void *); + return coreEnvironment(cmd, data); +} + +void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch) { + void coreVideoRefresh(void *, unsigned, unsigned, size_t); + return coreVideoRefresh(data, width, height, pitch); +} + +void coreInputPoll_cgo() { + void coreInputPoll(); + return coreInputPoll(); +} + +int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id) { + int16_t coreInputState(unsigned, unsigned, unsigned, unsigned); + return coreInputState(port, device, index, id); +} + +void coreAudioSample_cgo(int16_t left, int16_t right) { + void coreAudioSample(int16_t, int16_t); + coreAudioSample(left, right); +} + +size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames) { + size_t coreAudioSampleBatch(const int16_t *, size_t); + return coreAudioSampleBatch(data, frames); +} + +void coreLog_cgo(enum retro_log_level level, const char *fmt, ...) { + char msg[4096] = {0}; + va_list va; + va_start(va, fmt); + vsnprintf(msg, sizeof(msg), fmt, va); + va_end(va); + coreLog(level, msg); +} + +uintptr_t coreGetCurrentFramebuffer_cgo() { + uintptr_t coreGetCurrentFramebuffer(); + return coreGetCurrentFramebuffer(); +} + +retro_proc_address_t coreGetProcAddress_cgo(const char *sym) { + retro_proc_address_t coreGetProcAddress(const char *sym); + return coreGetProcAddress(sym); +} + +void bridge_context_reset(retro_hw_context_reset_t f) { + f(); +} + +void initVideo_cgo() { + void initVideo(); + return initVideo(); +} + +void deinitVideo_cgo() { + void deinitVideo(); + return deinitVideo(); +} + +void *function; +pthread_t thread; +int initialized = 0; +pthread_mutex_t run_mutex; +pthread_cond_t run_cv; +pthread_mutex_t done_mutex; +pthread_cond_t done_cv; + +// hack: go hangs with run_loop if SIGINT signal, so we handle it here +//static void sig_handler(int _) { +// exit(0); +//} + +void *run_loop(void *unused) { + pthread_mutex_lock(&done_mutex); + pthread_mutex_lock(&run_mutex); + pthread_cond_signal(&done_cv); + pthread_mutex_unlock(&done_mutex); + while (1) { + pthread_cond_wait(&run_cv, &run_mutex); + ((void (*)(void)) function)(); + pthread_mutex_lock(&done_mutex); + pthread_cond_signal(&done_cv); + pthread_mutex_unlock(&done_mutex); + } + pthread_mutex_unlock(&run_mutex); +} + +void bridge_execute(void *f) { + if (!initialized) { + //signal(SIGINT, sig_handler); + initialized = 1; + pthread_mutex_init(&run_mutex, NULL); + pthread_cond_init(&run_cv, NULL); + pthread_mutex_init(&done_mutex, NULL); + pthread_cond_init(&done_cv, NULL); + pthread_mutex_lock(&done_mutex); + pthread_create(&thread, NULL, run_loop, NULL); + pthread_cond_wait(&done_cv, &done_mutex); + pthread_mutex_unlock(&done_mutex); + } + pthread_mutex_lock(&run_mutex); + pthread_mutex_lock(&done_mutex); + function = f; + pthread_cond_signal(&run_cv); + pthread_mutex_unlock(&run_mutex); + pthread_cond_wait(&done_cv, &done_mutex); + pthread_mutex_unlock(&done_mutex); +} diff --git a/pkg/worker/emulator/libretro/nanoarch.go b/pkg/worker/emulator/libretro/nanoarch.go new file mode 100644 index 00000000..3e2bde0f --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch.go @@ -0,0 +1,734 @@ +package libretro + +import ( + "errors" + "fmt" + "os" + "os/user" + "runtime" + "strings" + "sync" + "time" + "unsafe" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/graphics" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/image" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" +) + +/* +#include "libretro.h" +#include "nanoarch.h" +#include +*/ +import "C" + +const lastKey = int(C.RETRO_DEVICE_ID_JOYPAD_R3) + +type ( + nanoarch struct { + v video + multitap multitap + rot *image.Rotate + sysInfo C.struct_retro_system_info + sysAvInfo C.struct_retro_system_av_info + reserved chan struct{} // limits concurrent use + } + video struct { + pixFmt uint32 + bpp int + hw *C.struct_retro_hw_render_callback + isGl bool + autoGlContext bool + } + multitap struct { + supported bool + enabled bool + value C.unsigned + } + // defines any memory state of the emulator + state []byte + mem struct { + ptr unsafe.Pointer + size uint + } +) + +// Global link for C callbacks to Go +var nano = nanoarch{ + // this thing forbids concurrent use of the emulator + reserved: make(chan struct{}, 1), +} + +var ( + coreConfig *CoreProperties + frontend *Frontend + lastFrameTime int64 + libretroLogger = logger.Default() + sdlCtx *graphics.SDL + usesLibCo bool + cSaveDirectory *C.char + cSystemDirectory *C.char + cUserName *C.char + + initOnce sync.Once +) + +const rawAudioBuffer = 4096 // 4K +var ( + audioCopyPool sync.Pool + audioPool sync.Pool + videoPool sync.Pool +) + +func init() { + nano.reserved <- struct{}{} + usr, err := user.Current() + if err == nil { + cUserName = C.CString(usr.Name) + } else { + cUserName = C.CString("retro") + } +} + +func Init(localPath string) { + initOnce.Do(func() { + cSaveDirectory = C.CString(localPath + string(os.PathSeparator) + "legacy_save") + cSystemDirectory = C.CString(localPath + string(os.PathSeparator) + "system") + }) +} + +//export coreVideoRefresh +func coreVideoRefresh(data unsafe.Pointer, width C.unsigned, height C.unsigned, pitch C.size_t) { + // some cores can return nothing + // !to add duplicate if can dup + if data == nil { + return + } + + // calculate real frame width in pixels from packed data (realWidth >= width) + packedWidth := int(pitch) / nano.v.bpp + if packedWidth < 1 { + packedWidth = int(width) + } + // calculate space for the video frame + bytes := int(height) * packedWidth * nano.v.bpp + + // if Libretro renders frame with OpenGL context + isOpenGLRender := data == C.RETRO_HW_FRAME_BUFFER_VALID + var data_ []byte + if isOpenGLRender { + data_ = graphics.ReadFramebuffer(bytes, int(width), int(height)) + } else { + data_ = unsafe.Slice((*byte)(data), bytes) + } + + // the image is being resized and de-rotated + frame := image.DrawRgbaImage( + nano.v.pixFmt, + nano.rot, + image.ScaleNearestNeighbour, + isOpenGLRender, + int(width), int(height), packedWidth, nano.v.bpp, + data_, + frontend.vw, + frontend.vh, + frontend.th, + ) + + t := time.Now().UnixNano() + dt := time.Duration(t - lastFrameTime) + lastFrameTime = t + + if len(frame.Pix) == 0 { + // this should not be happening, will crash yuv + libretroLogger.Error().Msgf("skip empty frame %v", frame.Bounds()) + return + } + + fr, _ := videoPool.Get().(*emulator.GameFrame) + if fr == nil { + fr = &emulator.GameFrame{} + } + fr.Data = frame + fr.Duration = dt + frontend.onVideo(fr) + videoPool.Put(fr) +} + +//export coreInputPoll +func coreInputPoll() {} + +//export coreInputState +func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t { + if port >= maxPort { + return KeyReleased + } + + if device == C.RETRO_DEVICE_ANALOG { + if index > C.RETRO_DEVICE_INDEX_ANALOG_RIGHT || id > C.RETRO_DEVICE_ID_ANALOG_Y { + return 0 + } + axis := index*2 + id + value := frontend.input.isDpadTouched(uint(port), uint(axis)) + if value != 0 { + return (C.int16_t)(value) + } + } + + key := int(id) + if key > lastKey || index > 0 || device != C.RETRO_DEVICE_JOYPAD { + return KeyReleased + } + if frontend.input.isKeyPressed(uint(port), key) == KeyPressed { + return KeyPressed + } + return KeyReleased +} + +func audioWrite(buf unsafe.Pointer, frames C.size_t) C.size_t { + samples := int(frames) << 1 + src := unsafe.Slice((*int16)(buf), samples) + dst, _ := audioCopyPool.Get().(*[]int16) + if dst == nil { + x := make([]int16, rawAudioBuffer) + dst = &x + } + xx := (*dst)[:samples] + copy(xx, src) + + // 1600 = x / 1000 * 48000 * 2 + estimate := float64(samples) / float64(int(nano.sysAvInfo.timing.sample_rate)<<1) * 1000000000 + + fr, _ := audioPool.Get().(*emulator.GameAudio) + if fr == nil { + fr = &emulator.GameAudio{} + } + fr.Data = &xx + fr.Duration = time.Duration(estimate) // used in recordings + frontend.onAudio(fr) + audioPool.Put(fr) + audioCopyPool.Put(dst) + + return frames +} + +//export coreAudioSample +func coreAudioSample(left C.int16_t, right C.int16_t) { + buf := []C.int16_t{left, right} + audioWrite(unsafe.Pointer(&buf), 1) +} + +//export coreAudioSampleBatch +func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t { + return audioWrite(data, frames) +} + +func m(m *C.char) string { return strings.TrimRight(C.GoString(m), "\n") } + +//export coreLog +func coreLog(level C.enum_retro_log_level, msg *C.char) { + switch int(level) { + // with debug level cores have too much logs + case 0: // RETRO_LOG_DEBUG + libretroLogger.Debug().MsgFunc(func() string { return m(msg) }) + case 1: // RETRO_LOG_INFO + libretroLogger.Info().MsgFunc(func() string { return m(msg) }) + case 2: // RETRO_LOG_WARN + libretroLogger.Warn().MsgFunc(func() string { return m(msg) }) + case 3: // RETRO_LOG_ERROR + libretroLogger.Error().MsgFunc(func() string { return m(msg) }) + default: + libretroLogger.Log().MsgFunc(func() string { return m(msg) }) + // RETRO_LOG_DUMMY = INT_MAX + } +} + +//export coreGetCurrentFramebuffer +func coreGetCurrentFramebuffer() C.uintptr_t { return (C.uintptr_t)(graphics.GetGlFbo()) } + +//export coreGetProcAddress +func coreGetProcAddress(sym *C.char) C.retro_proc_address_t { + return (C.retro_proc_address_t)(graphics.GetGlProcAddress(C.GoString(sym))) +} + +//export coreEnvironment +func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { + switch cmd { + case C.RETRO_ENVIRONMENT_GET_USERNAME: + *(**C.char)(data) = cUserName + case C.RETRO_ENVIRONMENT_GET_LOG_INTERFACE: + cb := (*C.struct_retro_log_callback)(data) + cb.log = (C.retro_log_printf_t)(C.coreLog_cgo) + case C.RETRO_ENVIRONMENT_GET_CAN_DUPE: + *(*C.bool)(data) = C.bool(true) + case C.RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: + res, err := videoSetPixelFormat(*(*C.enum_retro_pixel_format)(data)) + if err != nil { + libretroLogger.Fatal().Err(err).Msg("pix format failed") + } + return res + case C.RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: + *(**C.char)(data) = cSystemDirectory + return true + case C.RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: + *(**C.char)(data) = cSaveDirectory + return true + case C.RETRO_ENVIRONMENT_SHUTDOWN: + //window.SetShouldClose(true) + return true + /* + Sets screen rotation of graphics. + Valid values are 0, 1, 2, 3, which rotates screen by 0, 90, 180, 270 degrees + ccw respectively. + */ + case C.RETRO_ENVIRONMENT_SET_ROTATION: + setRotation(*(*uint)(data) % 4) + return true + case C.RETRO_ENVIRONMENT_GET_VARIABLE: + variable := (*C.struct_retro_variable)(data) + key := C.GoString(variable.key) + if val, ok := coreConfig.Get(key); ok { + variable.value = (*C.char)(val) + libretroLogger.Debug().Msgf("Set %s=%v", key, C.GoString(variable.value)) + return true + } + return false + case C.RETRO_ENVIRONMENT_SET_HW_RENDER: + if nano.v.isGl { + nano.v.hw = (*C.struct_retro_hw_render_callback)(data) + nano.v.hw.get_current_framebuffer = (C.retro_hw_get_current_framebuffer_t)(C.coreGetCurrentFramebuffer_cgo) + nano.v.hw.get_proc_address = (C.retro_hw_get_proc_address_t)(C.coreGetProcAddress_cgo) + return true + } + return false + case C.RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: + if !nano.multitap.supported { + return false + } + info := (*[100]C.struct_retro_controller_info)(data) + var i C.unsigned + for i = 0; unsafe.Pointer(info[i].types) != nil; i++ { + var j C.unsigned + types := (*[100]C.struct_retro_controller_description)(unsafe.Pointer(info[i].types)) + for j = 0; j < info[i].num_types; j++ { + if C.GoString(types[j].desc) == "Multitap" { + nano.multitap.value = types[j].id + return true + } + } + } + return false + default: + return false + } + return true +} + +//export initVideo +func initVideo() { + var context graphics.Context + switch nano.v.hw.context_type { + case C.RETRO_HW_CONTEXT_NONE: + context = graphics.CtxNone + case C.RETRO_HW_CONTEXT_OPENGL: + context = graphics.CtxOpenGl + case C.RETRO_HW_CONTEXT_OPENGLES2: + context = graphics.CtxOpenGlEs2 + case C.RETRO_HW_CONTEXT_OPENGL_CORE: + context = graphics.CtxOpenGlCore + case C.RETRO_HW_CONTEXT_OPENGLES3: + context = graphics.CtxOpenGlEs3 + case C.RETRO_HW_CONTEXT_OPENGLES_VERSION: + context = graphics.CtxOpenGlEsVersion + case C.RETRO_HW_CONTEXT_VULKAN: + context = graphics.CtxVulkan + case C.RETRO_HW_CONTEXT_DUMMY: + context = graphics.CtxDummy + default: + context = graphics.CtxUnknown + } + + sdl, err := graphics.NewSDLContext(graphics.Config{ + Ctx: context, + W: int(nano.sysAvInfo.geometry.max_width), + H: int(nano.sysAvInfo.geometry.max_height), + GLAutoContext: nano.v.autoGlContext, + GLVersionMajor: uint(nano.v.hw.version_major), + GLVersionMinor: uint(nano.v.hw.version_minor), + GLHasDepth: bool(nano.v.hw.depth), + GLHasStencil: bool(nano.v.hw.stencil), + }, libretroLogger) + if err != nil { + panic(err) + } + sdlCtx = sdl + + C.bridge_context_reset(nano.v.hw.context_reset) + if libretroLogger.GetLevel() < logger.InfoLevel { + printOpenGLDriverInfo() + } +} + +//export deinitVideo +func deinitVideo() { + C.bridge_context_reset(nano.v.hw.context_destroy) + if err := sdlCtx.Deinit(); err != nil { + libretroLogger.Error().Err(err).Msg("deinit fail") + } + nano.v.isGl = false + nano.v.autoGlContext = false +} + +var ( + //retroAPIVersion unsafe.Pointer + retroDeinit unsafe.Pointer + retroGetSystemAVInfo unsafe.Pointer + retroGetSystemInfo unsafe.Pointer + coreLib unsafe.Pointer + retroInit unsafe.Pointer + retroLoadGame unsafe.Pointer + retroRun unsafe.Pointer + retroSetAudioSample unsafe.Pointer + retroSetAudioSampleBatch unsafe.Pointer + retroSetControllerPortDevice unsafe.Pointer + retroSetEnvironment unsafe.Pointer + retroSetInputPoll unsafe.Pointer + retroSetInputState unsafe.Pointer + retroSetVideoRefresh unsafe.Pointer + retroUnloadGame unsafe.Pointer + retroGetMemoryData unsafe.Pointer + retroGetMemorySize unsafe.Pointer + retroSerialize unsafe.Pointer + retroSerializeSize unsafe.Pointer + retroUnserialize unsafe.Pointer +) + +func SetLibretroLogger(log *logger.Logger) { libretroLogger = log } + +func coreLoad(meta emulator.Metadata) { + var err error + nano.v.isGl = meta.IsGlAllowed + usesLibCo = meta.UsesLibCo + nano.v.autoGlContext = meta.AutoGlContext + coreConfig, err = ReadProperties(meta.ConfigPath) + if err != nil { + libretroLogger.Warn().Err(err).Msg("config scan has been failed") + } + + nano.multitap.supported = meta.HasMultitap + nano.multitap.enabled = false + nano.multitap.value = 0 + + filePath := meta.LibPath + if arch, err := GetCoreExt(); err == nil { + filePath = filePath + arch.LibExt + } else { + libretroLogger.Warn().Err(err).Msg("system arch guesser failed") + } + + coreLib, err = loadLib(filePath) + // fallback to sequential lib loader (first successfully loaded) + if err != nil { + coreLib, err = loadLibRollingRollingRolling(filePath) + if err != nil { + libretroLogger.Fatal().Err(err).Msgf("core load: %s, %v", filePath, err) + } + } + + retroInit = loadFunction(coreLib, "retro_init") + retroDeinit = loadFunction(coreLib, "retro_deinit") + //retroAPIVersion = loadFunction(coreLib, "retro_api_version") + retroGetSystemInfo = loadFunction(coreLib, "retro_get_system_info") + retroGetSystemAVInfo = loadFunction(coreLib, "retro_get_system_av_info") + retroSetEnvironment = loadFunction(coreLib, "retro_set_environment") + retroSetVideoRefresh = loadFunction(coreLib, "retro_set_video_refresh") + retroSetInputPoll = loadFunction(coreLib, "retro_set_input_poll") + retroSetInputState = loadFunction(coreLib, "retro_set_input_state") + retroSetAudioSample = loadFunction(coreLib, "retro_set_audio_sample") + retroSetAudioSampleBatch = loadFunction(coreLib, "retro_set_audio_sample_batch") + retroRun = loadFunction(coreLib, "retro_run") + retroLoadGame = loadFunction(coreLib, "retro_load_game") + retroUnloadGame = loadFunction(coreLib, "retro_unload_game") + retroSerializeSize = loadFunction(coreLib, "retro_serialize_size") + retroSerialize = loadFunction(coreLib, "retro_serialize") + retroUnserialize = loadFunction(coreLib, "retro_unserialize") + retroSetControllerPortDevice = loadFunction(coreLib, "retro_set_controller_port_device") + retroGetMemorySize = loadFunction(coreLib, "retro_get_memory_size") + retroGetMemoryData = loadFunction(coreLib, "retro_get_memory_data") + + C.bridge_retro_set_environment(retroSetEnvironment, C.coreEnvironment_cgo) + C.bridge_retro_set_video_refresh(retroSetVideoRefresh, C.coreVideoRefresh_cgo) + C.bridge_retro_set_input_poll(retroSetInputPoll, C.coreInputPoll_cgo) + C.bridge_retro_set_input_state(retroSetInputState, C.coreInputState_cgo) + C.bridge_retro_set_audio_sample(retroSetAudioSample, C.coreAudioSample_cgo) + C.bridge_retro_set_audio_sample_batch(retroSetAudioSampleBatch, C.coreAudioSampleBatch_cgo) + + C.bridge_retro_init(retroInit) + + C.bridge_retro_get_system_info(retroGetSystemInfo, &nano.sysInfo) + libretroLogger.Debug().Msgf("System >>> %s (%s) [%s] nfp: %v", + C.GoString(nano.sysInfo.library_name), C.GoString(nano.sysInfo.library_version), + C.GoString(nano.sysInfo.valid_extensions), bool(nano.sysInfo.need_fullpath)) +} + +func LoadGame(path string) error { + lastFrameTime = 0 + + fi, err := os.Stat(path) + if err != nil { + return err + } + fileSize := fi.Size() + libretroLogger.Debug().Msgf("ROM size: %v", byteCountBinary(fileSize)) + + fPath := C.CString(path) + defer C.free(unsafe.Pointer(fPath)) + gi := C.struct_retro_game_info{path: fPath, size: C.size_t(fileSize)} + + if !bool(nano.sysInfo.need_fullpath) { + bytes, err := os.ReadFile(path) + if err != nil { + return err + } + dat := C.CString(string(bytes)) + gi.data = unsafe.Pointer(dat) + defer C.free(unsafe.Pointer(dat)) + } + + if ok := C.bridge_retro_load_game(retroLoadGame, &gi); !ok { + return fmt.Errorf("core failed to load ROM: %v", path) + } + + C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &nano.sysAvInfo) + libretroLogger.Info().Msgf("System A/V >>> %vx%v (%vx%v), [%vfps], AR [%v], audio [%vHz]", + nano.sysAvInfo.geometry.base_width, nano.sysAvInfo.geometry.base_height, + nano.sysAvInfo.geometry.max_width, nano.sysAvInfo.geometry.max_height, + nano.sysAvInfo.timing.fps, nano.sysAvInfo.geometry.aspect_ratio, nano.sysAvInfo.timing.sample_rate, + ) + + if nano.v.isGl { + bufS := int(nano.sysAvInfo.geometry.max_width*nano.sysAvInfo.geometry.max_height) * nano.v.bpp + graphics.SetBuffer(bufS) + libretroLogger.Info().Msgf("Set buffer: %v", byteCountBinary(int64(bufS))) + if usesLibCo { + C.bridge_execute(C.initVideo_cgo) + } else { + runtime.LockOSThread() + initVideo() + runtime.UnlockOSThread() + } + } + + // set default controller types on all ports + for i := 0; i < maxPort; i++ { + C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD) + } + + return nil +} + +func toggleMultitap() { + if nano.multitap.supported && nano.multitap.value != 0 { + // Official SNES games only support a single multitap device + // Most require it to be plugged in player 2 port + // And Snes9X requires it to be "plugged" after the game is loaded + // Control this from the browser since player 2 will stop working in some games if multitap is "plugged" in + if nano.multitap.enabled { + C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, C.RETRO_DEVICE_JOYPAD) + } else { + C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, nano.multitap.value) + } + nano.multitap.enabled = !nano.multitap.enabled + } +} + +func nanoarchShutdown() { + if usesLibCo { + thread.Main(func() { + C.bridge_execute(retroUnloadGame) + C.bridge_execute(retroDeinit) + if nano.v.isGl { + C.bridge_execute(C.deinitVideo_cgo) + } + }) + } else { + if nano.v.isGl { + thread.Main(func() { + // running inside a go routine, lock the thread to make sure the OpenGL context stays current + runtime.LockOSThread() + if err := sdlCtx.BindContext(); err != nil { + libretroLogger.Error().Err(err).Msg("ctx switch fail") + } + }) + } + C.bridge_retro_unload_game(retroUnloadGame) + C.bridge_retro_deinit(retroDeinit) + if nano.v.isGl { + thread.Main(func() { + deinitVideo() + runtime.UnlockOSThread() + }) + } + } + + setRotation(0) + if err := closeLib(coreLib); err != nil { + libretroLogger.Error().Err(err).Msg("lib close failed") + } + coreConfig.Free() + image.Clear() +} + +func run() { + if usesLibCo { + C.bridge_execute(retroRun) + } else { + if nano.v.isGl { + // running inside a go routine, lock the thread to make sure the OpenGL context stays current + runtime.LockOSThread() + if err := sdlCtx.BindContext(); err != nil { + libretroLogger.Error().Err(err).Msg("ctx bind fail") + } + } + C.bridge_retro_run(retroRun) + if nano.v.isGl { + runtime.UnlockOSThread() + } + } +} + +func videoSetPixelFormat(format uint32) (C.bool, error) { + switch format { + case C.RETRO_PIXEL_FORMAT_0RGB1555: + nano.v.pixFmt = image.BitFormatShort5551 + if err := graphics.SetPixelFormat(graphics.UnsignedShort5551); err != nil { + return false, fmt.Errorf("unknown pixel format %v", nano.v.pixFmt) + } + nano.v.bpp = 2 + // format is not implemented + return false, fmt.Errorf("unsupported pixel type %v converter", format) + case C.RETRO_PIXEL_FORMAT_XRGB8888: + nano.v.pixFmt = image.BitFormatInt8888Rev + if err := graphics.SetPixelFormat(graphics.UnsignedInt8888Rev); err != nil { + return false, fmt.Errorf("unknown pixel format %v", nano.v.pixFmt) + } + nano.v.bpp = 4 + case C.RETRO_PIXEL_FORMAT_RGB565: + nano.v.pixFmt = image.BitFormatShort565 + if err := graphics.SetPixelFormat(graphics.UnsignedShort565); err != nil { + return false, fmt.Errorf("unknown pixel format %v", nano.v.pixFmt) + } + nano.v.bpp = 2 + default: + return false, fmt.Errorf("unknown pixel type %v", format) + } + return true, nil +} + +func setRotation(rotation uint) { + if nano.rot != nil && rotation == uint(nano.rot.Angle) { + return + } + if rotation > 0 { + r := image.GetRotation(image.Angle(rotation)) + nano.rot = &r + } else { + nano.rot = nil + } + libretroLogger.Debug().Msgf("Image rotated %v°", map[uint]uint{0: 0, 1: 90, 2: 180, 3: 270}[rotation]) +} + +func printOpenGLDriverInfo() { + var openGLInfo strings.Builder + openGLInfo.Grow(128) + openGLInfo.WriteString(fmt.Sprintf("\n[OpenGL] Version: %v\n", graphics.GetGLVersionInfo())) + openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Vendor: %v\n", graphics.GetGLVendorInfo())) + // This string is often the name of the GPU. + // In the case of Mesa3d, it would be i.e "Gallium 0.4 on NVA8". + // It might even say "Direct3D" if the Windows Direct3D wrapper is being used. + openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Renderer: %v\n", graphics.GetGLRendererInfo())) + openGLInfo.WriteString(fmt.Sprintf("[OpenGL] GLSL Version: %v", graphics.GetGLSLInfo())) + libretroLogger.Debug().Msg(openGLInfo.String()) +} + +// saveStateSize returns the amount of data the implementation requires +// to serialize internal state (save states). +func saveStateSize() uint { return uint(C.bridge_retro_serialize_size(retroSerializeSize)) } + +// getSaveState returns emulator internal state. +func getSaveState() (state, error) { + size := saveStateSize() + data := C.malloc(C.size_t(size)) + defer C.free(data) + if !bool(C.bridge_retro_serialize(retroSerialize, data, C.size_t(size))) { + return nil, errors.New("retro_serialize failed") + } + return C.GoBytes(data, C.int(size)), nil +} + +// restoreSaveState restores emulator internal state. +func restoreSaveState(st state) error { + if len(st) == 0 { + return nil + } + size := saveStateSize() + if !bool(C.bridge_retro_unserialize(retroUnserialize, unsafe.Pointer(&st[0]), C.size_t(size))) { + return errors.New("retro_unserialize failed") + } + return nil +} + +// getSaveRAM returns the game save RAM (cartridge) data or a nil slice. +func getSaveRAM() state { + memory := ptSaveRAM() + if memory == nil { + return nil + } + return C.GoBytes(memory.ptr, C.int(memory.size)) +} + +// restoreSaveRAM restores game save RAM. +func restoreSaveRAM(st state) { + if len(st) == 0 { + return + } + if memory := ptSaveRAM(); memory != nil { + sram := (*[1 << 30]byte)(memory.ptr)[:memory.size:memory.size] + copy(sram, st) + } +} + +// getMemorySize returns memory region size. +func getMemorySize(id uint) uint { + return uint(C.bridge_retro_get_memory_size(retroGetMemorySize, C.uint(id))) +} + +// getMemoryData returns a pointer to memory data. +func getMemoryData(id uint) unsafe.Pointer { + return C.bridge_retro_get_memory_data(retroGetMemoryData, C.uint(id)) +} + +// ptSaveRam return SRAM memory pointer if core supports it or nil. +func ptSaveRAM() *mem { + ptr, size := getMemoryData(C.RETRO_MEMORY_SAVE_RAM), getMemorySize(C.RETRO_MEMORY_SAVE_RAM) + if ptr == nil || size == 0 { + return nil + } + return &mem{ptr: ptr, size: size} +} + +func byteCountBinary(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} diff --git a/pkg/worker/emulator/libretro/nanoarch.h b/pkg/worker/emulator/libretro/nanoarch.h new file mode 100644 index 00000000..d049e9e4 --- /dev/null +++ b/pkg/worker/emulator/libretro/nanoarch.h @@ -0,0 +1,38 @@ +#ifndef FRONTEND_H__ +#define FRONTEND_H__ + +bool bridge_retro_load_game(void *f, struct retro_game_info *gi); +bool bridge_retro_serialize(void *f, void *data, size_t size); +bool bridge_retro_set_environment(void *f, void *callback); +bool bridge_retro_unserialize(void *f, void *data, size_t size); +bool coreEnvironment_cgo(unsigned cmd, void *data); +int16_t coreInputState_cgo(unsigned port, unsigned device, unsigned index, unsigned id); +retro_proc_address_t coreGetProcAddress_cgo(const char *sym); +size_t bridge_retro_get_memory_size(void *f, unsigned id); +size_t bridge_retro_serialize_size(void *f); +size_t coreAudioSampleBatch_cgo(const int16_t *data, size_t frames); +uintptr_t coreGetCurrentFramebuffer_cgo(); +unsigned bridge_retro_api_version(void *f); +void *bridge_retro_get_memory_data(void *f, unsigned id); +void bridge_context_reset(retro_hw_context_reset_t f); +void bridge_execute(void *f); +void bridge_retro_deinit(void *f); +void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si); +void bridge_retro_get_system_info(void *f, struct retro_system_info *si); +void bridge_retro_init(void *f); +void bridge_retro_run(void *f); +void bridge_retro_set_audio_sample(void *f, void *callback); +void bridge_retro_set_audio_sample_batch(void *f, void *callback); +void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device); +void bridge_retro_set_input_poll(void *f, void *callback); +void bridge_retro_set_input_state(void *f, void *callback); +void bridge_retro_set_video_refresh(void *f, void *callback); +void bridge_retro_unload_game(void *f); +void coreAudioSample_cgo(int16_t left, int16_t right); +void coreInputPoll_cgo(); +void coreLog_cgo(int level, const char *msg); +void coreVideoRefresh_cgo(void *data, unsigned width, unsigned height, size_t pitch); +void deinitVideo_cgo(); +void initVideo_cgo(); + +#endif diff --git a/pkg/emulator/libretro/nanoarch/nanoarch_test.go b/pkg/worker/emulator/libretro/nanoarch_test.go similarity index 71% rename from pkg/emulator/libretro/nanoarch/nanoarch_test.go rename to pkg/worker/emulator/libretro/nanoarch_test.go index 515a6381..13507aae 100644 --- a/pkg/emulator/libretro/nanoarch/nanoarch_test.go +++ b/pkg/worker/emulator/libretro/nanoarch_test.go @@ -1,4 +1,4 @@ -package nanoarch +package libretro import ( "crypto/md5" @@ -9,10 +9,12 @@ import ( "path" "path/filepath" "testing" + "unsafe" "github.com/giongto35/cloud-game/v2/pkg/config" "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "github.com/giongto35/cloud-game/v2/pkg/emulator" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" ) type testRun struct { @@ -22,19 +24,14 @@ type testRun struct { emulationTicks int } -// EmulatorMock contains naEmulator mocking data. +// EmulatorMock contains Frontend mocking data. type EmulatorMock struct { - naEmulator + Frontend // Libretro compiled lib core name core string // shared core paths (can't be changed) paths EmulatorPaths - - // channels - imageInCh <-chan GameFrame - audioInCh <-chan []int16 - inputOutCh chan<- InputEvent } // EmulatorPaths defines various emulator file paths. @@ -60,32 +57,20 @@ func GetEmulatorMock(room string, system string) *EmulatorMock { meta := conf.Emulator.GetLibretroCoreConfig(system) - images := make(chan GameFrame, 30) - audio := make(chan []int16, 30) - inputs := make(chan InputEvent, 100) + l := logger.Default() + SetLibretroLogger(l.Extend(l.Level(logger.ErrorLevel).With())) // an emu emu := &EmulatorMock{ - naEmulator: naEmulator{ - imageChannel: images, - audioChannel: audio, - inputChannel: inputs, + Frontend: Frontend{ + conf: conf.Emulator, storage: &StateStorage{ Path: os.TempDir(), MainSave: room, }, - meta: emulator.Metadata{ - LibPath: meta.Lib, - ConfigPath: meta.Config, - Ratio: meta.Ratio, - IsGlAllowed: meta.IsGlAllowed, - UsesLibCo: meta.UsesLibCo, - HasMultitap: meta.HasMultitap, - }, - players: NewPlayerSessionInput(), - roomID: room, - done: make(chan struct{}, 1), - th: conf.Emulator.Threads, + input: NewGameSessionInput(), + done: make(chan struct{}), + th: conf.Emulator.Threads, }, core: path.Base(meta.Lib), @@ -95,17 +80,12 @@ func GetEmulatorMock(room string, system string) *EmulatorMock { cores: cleanPath(rootPath + "assets/cores/"), games: cleanPath(rootPath + "assets/games/"), }, - - imageInCh: images, - audioInCh: audio, - inputOutCh: inputs, } - // stub globals - NAEmulator = &emu.naEmulator - //outputImg = emu.canvas - emu.paths.save = cleanPath(emu.GetHashPath()) + frontend = &emu.Frontend + + Init(cleanPath(conf.Emulator.LocalPath)) return emu } @@ -116,8 +96,8 @@ func GetEmulatorMock(room string, system string) *EmulatorMock { func GetDefaultEmulatorMock(room string, system string, rom string) *EmulatorMock { mock := GetEmulatorMock(room, system) mock.loadRom(rom) - go mock.handleVideo(func(_ GameFrame) {}) - go mock.handleAudio(func(_ []int16) {}) + mock.handleVideo(func(_ *emulator.GameFrame) {}) + mock.handleAudio(func(_ *emulator.GameAudio) {}) return mock } @@ -127,7 +107,11 @@ func GetDefaultEmulatorMock(room string, system string, rom string) *EmulatorMoc func (emu *EmulatorMock) loadRom(game string) { fmt.Printf("%v %v\n", emu.paths.cores, emu.core) coreLoad(emulator.Metadata{LibPath: emu.paths.cores + emu.core}) - coreLoadGame(emu.paths.games + game) + err := LoadGame(emu.paths.games + game) + if err != nil { + log.Fatal(err) + } + emu.vw, emu.vh = emu.GetFrameSize() } // shutdownEmulator closes the emulator and cleans its resources. @@ -135,50 +119,35 @@ func (emu *EmulatorMock) shutdownEmulator() { _ = os.Remove(emu.GetHashPath()) _ = os.Remove(emu.GetSRAMPath()) - close(emu.imageChannel) - close(emu.audioChannel) - close(emu.inputOutCh) - nanoarchShutdown() } // emulateOneFrame emulates one frame with exclusive lock. func (emu *EmulatorMock) emulateOneFrame() { - emu.Lock() - nanoarchRun() - emu.Unlock() + emu.mu.Lock() + run() + emu.mu.Unlock() } // Who needs generics anyway? // handleVideo is a custom message handler for the video channel. -func (emu *EmulatorMock) handleVideo(handler func(image GameFrame)) { - for frame := range emu.imageInCh { - handler(frame) - } +func (emu *EmulatorMock) handleVideo(handler func(image *emulator.GameFrame)) { + emu.Frontend.onVideo = handler } // handleAudio is a custom message handler for the audio channel. -func (emu *EmulatorMock) handleAudio(handler func(sample []int16)) { - for frame := range emu.audioInCh { - handler(frame) - } -} - -// handleInput is a custom message handler for the input channel. -func (emu *EmulatorMock) handleInput(handler func(event InputEvent)) { - for event := range emu.inputChannel { - handler(event) - } +func (emu *EmulatorMock) handleAudio(handler func(sample *emulator.GameAudio)) { + emu.Frontend.onAudio = handler } // dumpState returns the current emulator state and // the latest saved state for its session. // Locks the emulator. func (emu *EmulatorMock) dumpState() (string, string) { - emu.Lock() + emu.mu.Lock() bytes, _ := os.ReadFile(emu.paths.save) persistedStateHash := getHash(bytes) - emu.Unlock() + emu.mu.Unlock() stateHash := emu.getStateHash() fmt.Printf("mem: %v, dat: %v\n", stateHash, persistedStateHash) @@ -188,13 +157,18 @@ func (emu *EmulatorMock) dumpState() (string, string) { // getStateHash returns the current emulator state hash. // Locks the emulator. func (emu *EmulatorMock) getStateHash() string { - emu.Lock() + emu.mu.Lock() state, _ := getSaveState() - emu.Unlock() + emu.mu.Unlock() return getHash(state) } +func (emu *EmulatorMock) Close() { + emu.Frontend.Close() + <-nano.reserved +} + // getRootPath returns absolute path to the root directory. func getRootPath() string { p, _ := filepath.Abs("../../../../") @@ -214,10 +188,14 @@ func cleanPath(path string) string { // benchmarkEmulator is a generic function for // measuring emulator performance for one emulation frame. func benchmarkEmulator(system string, rom string, b *testing.B) { + b.StopTimer() log.SetOutput(io.Discard) os.Stdout, _ = os.Open(os.DevNull) + libretroLogger = logger.New(false) s := GetDefaultEmulatorMock("bench_"+system+"_performance", system, rom) + + b.StartTimer() for i := 0; i < b.N; i++ { s.emulateOneFrame() } @@ -231,3 +209,16 @@ func BenchmarkEmulatorGba(b *testing.B) { func BenchmarkEmulatorNes(b *testing.B) { benchmarkEmulator("nes", "Super Mario Bros.nes", b) } + +func TestSwap(t *testing.T) { + data := []byte{1, 254, 255, 32} + pixel := *(*uint32)(unsafe.Pointer(&data[0])) + // 0 1 2 3 + // 2 1 0 3 + ll := ((pixel >> 16) & 0xff) | (pixel & 0xff00) | ((pixel << 16) & 0xff0000) | 0xff000000 + + rez := []byte{0, 0, 0, 0} + *(*uint32)(unsafe.Pointer(&rez[0])) = ll + + log.Printf("%v\n%v", data, rez) +} diff --git a/pkg/worker/emulator/libretro/properties.go b/pkg/worker/emulator/libretro/properties.go new file mode 100644 index 00000000..7d8580cf --- /dev/null +++ b/pkg/worker/emulator/libretro/properties.go @@ -0,0 +1,71 @@ +package libretro + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + "unsafe" +) + +// #include +import "C" + +type CoreProperties struct { + m map[string]*C.char + mu sync.Mutex +} + +func ReadProperties(filename string) (*CoreProperties, error) { + config := CoreProperties{ + m: make(map[string]*C.char), + } + + if len(filename) == 0 { + return &config, nil + } + + file, err := os.Open(filename) + if err != nil { + return &config, fmt.Errorf("couldn't find the %v config file", filename) + } + defer func() { + _ = file.Close() + }() + + scanner := bufio.NewScanner(file) + config.mu.Lock() + defer config.mu.Unlock() + for scanner.Scan() { + line := scanner.Text() + if equal := strings.Index(line, "="); equal >= 0 { + if key := strings.TrimSpace(line[:equal]); len(key) > 0 { + value := "" + if len(line) > equal { + value = strings.TrimSpace(line[equal+1:]) + } + config.m[key] = C.CString(value) + } + } + } + if err := scanner.Err(); err != nil { + panic(err) + } + return &config, nil +} + +func (c *CoreProperties) Get(key string) (*C.char, bool) { + c.mu.Lock() + defer c.mu.Unlock() + v, ok := c.m[key] + return v, ok +} + +func (c *CoreProperties) Free() { + c.mu.Lock() + for _, element := range c.m { + C.free(unsafe.Pointer(element)) + } + c.mu.Unlock() +} diff --git a/pkg/emulator/libretro/repo/buildbot/repository.go b/pkg/worker/emulator/libretro/repo/buildbot/repository.go similarity index 71% rename from pkg/emulator/libretro/repo/buildbot/repository.go rename to pkg/worker/emulator/libretro/repo/buildbot/repository.go index 6ced435c..aa2fba6e 100644 --- a/pkg/emulator/libretro/repo/buildbot/repository.go +++ b/pkg/worker/emulator/libretro/repo/buildbot/repository.go @@ -1,10 +1,10 @@ package buildbot import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" "strings" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo/raw" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/raw" ) type RepoBuildbot struct { @@ -20,7 +20,7 @@ func NewBuildbotRepo(address string, compression string) RepoBuildbot { } } -func (r RepoBuildbot) GetCoreUrl(file string, info core.ArchInfo) string { +func (r RepoBuildbot) GetCoreUrl(file string, info libretro.ArchInfo) string { var sb strings.Builder sb.WriteString(r.Address + "/") if info.Vendor != "" { diff --git a/pkg/emulator/libretro/repo/buildbot/repository_test.go b/pkg/worker/emulator/libretro/repo/buildbot/repository_test.go similarity index 82% rename from pkg/emulator/libretro/repo/buildbot/repository_test.go rename to pkg/worker/emulator/libretro/repo/buildbot/repository_test.go index 37cb302c..ff79f9a0 100644 --- a/pkg/emulator/libretro/repo/buildbot/repository_test.go +++ b/pkg/worker/emulator/libretro/repo/buildbot/repository_test.go @@ -1,22 +1,21 @@ package buildbot import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" "testing" - - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" ) func TestBuildbotRepo(t *testing.T) { - testAddress := "http://test.me" + testAddress := "https://test.me" tests := []struct { file string compression string - arch core.ArchInfo + arch libretro.ArchInfo resultUrl string }{ { file: "uber_core", - arch: core.ArchInfo{ + arch: libretro.ArchInfo{ Os: "linux", Arch: "x86_64", LibExt: ".so", @@ -26,7 +25,7 @@ func TestBuildbotRepo(t *testing.T) { { file: "uber_core", compression: "zip", - arch: core.ArchInfo{ + arch: libretro.ArchInfo{ Os: "linux", Arch: "x86_64", LibExt: ".so", @@ -35,7 +34,7 @@ func TestBuildbotRepo(t *testing.T) { }, { file: "uber_core", - arch: core.ArchInfo{ + arch: libretro.ArchInfo{ Os: "osx", Arch: "x86_64", Vendor: "apple", diff --git a/pkg/emulator/libretro/repo/github/repository.go b/pkg/worker/emulator/libretro/repo/github/repository.go similarity index 56% rename from pkg/emulator/libretro/repo/github/repository.go rename to pkg/worker/emulator/libretro/repo/github/repository.go index 01687126..c27c917d 100644 --- a/pkg/emulator/libretro/repo/github/repository.go +++ b/pkg/worker/emulator/libretro/repo/github/repository.go @@ -1,8 +1,8 @@ package github import ( - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo/buildbot" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/buildbot" ) type RepoGithub struct { @@ -13,6 +13,6 @@ func NewGithubRepo(address string, compression string) RepoGithub { return RepoGithub{RepoBuildbot: buildbot.NewBuildbotRepo(address, compression)} } -func (r RepoGithub) GetCoreUrl(file string, info core.ArchInfo) string { +func (r RepoGithub) GetCoreUrl(file string, info libretro.ArchInfo) string { return r.RepoBuildbot.GetCoreUrl(file, info) + "?raw=true" } diff --git a/pkg/emulator/libretro/repo/github/repository_test.go b/pkg/worker/emulator/libretro/repo/github/repository_test.go similarity index 82% rename from pkg/emulator/libretro/repo/github/repository_test.go rename to pkg/worker/emulator/libretro/repo/github/repository_test.go index 01dea346..30eb402c 100644 --- a/pkg/emulator/libretro/repo/github/repository_test.go +++ b/pkg/worker/emulator/libretro/repo/github/repository_test.go @@ -1,22 +1,21 @@ package github import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" "testing" - - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" ) func TestBuildbotRepo(t *testing.T) { - testAddress := "http://test.me" + testAddress := "https://test.me" tests := []struct { file string compression string - arch core.ArchInfo + arch libretro.ArchInfo resultUrl string }{ { file: "uber_core", - arch: core.ArchInfo{ + arch: libretro.ArchInfo{ Os: "linux", Arch: "x86_64", LibExt: ".so", @@ -26,7 +25,7 @@ func TestBuildbotRepo(t *testing.T) { { file: "uber_core", compression: "zip", - arch: core.ArchInfo{ + arch: libretro.ArchInfo{ Os: "linux", Arch: "x86_64", LibExt: ".so", @@ -35,7 +34,7 @@ func TestBuildbotRepo(t *testing.T) { }, { file: "uber_core", - arch: core.ArchInfo{ + arch: libretro.ArchInfo{ Os: "osx", Arch: "x86_64", Vendor: "apple", diff --git a/pkg/emulator/libretro/repo/raw/repository.go b/pkg/worker/emulator/libretro/repo/raw/repository.go similarity index 66% rename from pkg/emulator/libretro/repo/raw/repository.go rename to pkg/worker/emulator/libretro/repo/raw/repository.go index a00c3b30..b215a31a 100644 --- a/pkg/emulator/libretro/repo/raw/repository.go +++ b/pkg/worker/emulator/libretro/repo/raw/repository.go @@ -1,6 +1,8 @@ package raw -import "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" +import ( + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" +) type Repo struct { Address string @@ -13,6 +15,6 @@ func NewRawRepo(address string) Repo { return Repo{Address: address, Compression: "zip"} } -func (r Repo) GetCoreUrl(_ string, _ core.ArchInfo) string { +func (r Repo) GetCoreUrl(_ string, _ libretro.ArchInfo) string { return r.Address } diff --git a/pkg/emulator/libretro/repo/repository.go b/pkg/worker/emulator/libretro/repo/repository.go similarity index 60% rename from pkg/emulator/libretro/repo/repository.go rename to pkg/worker/emulator/libretro/repo/repository.go index f3eaa44c..a7857742 100644 --- a/pkg/emulator/libretro/repo/repository.go +++ b/pkg/worker/emulator/libretro/repo/repository.go @@ -1,10 +1,10 @@ package repo import ( - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo/buildbot" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo/github" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo/raw" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/buildbot" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/github" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/repo/raw" ) type ( @@ -14,7 +14,7 @@ type ( } Repository interface { - GetCoreUrl(file string, info core.ArchInfo) (url string) + GetCoreUrl(file string, info libretro.ArchInfo) (url string) } ) diff --git a/pkg/worker/emulator/libretro/storage.go b/pkg/worker/emulator/libretro/storage.go new file mode 100644 index 00000000..f6760e13 --- /dev/null +++ b/pkg/worker/emulator/libretro/storage.go @@ -0,0 +1,67 @@ +package libretro + +import ( + "os" + "path/filepath" + "strings" + + "github.com/giongto35/cloud-game/v2/pkg/worker/compression/zip" +) + +type ( + Storage interface { + GetSavePath() string + GetSRAMPath() string + SetMainSaveName(name string) + Load(path string) ([]byte, error) + Save(path string, data []byte) error + } + StateStorage struct { + // save path without the dir slash in the end + Path string + // contains the name of the main save file + // e.g. abc<...>293.dat + // needed for Google Cloud save/restore which + // doesn't support multiple files + MainSave string + } + ZipStorage struct { + Storage + } +) + +func (s *StateStorage) SetMainSaveName(name string) { s.MainSave = name } +func (s *StateStorage) GetSavePath() string { return filepath.Join(s.Path, s.MainSave+".dat") } +func (s *StateStorage) GetSRAMPath() string { return filepath.Join(s.Path, s.MainSave+".srm") } +func (s *StateStorage) Load(path string) ([]byte, error) { return os.ReadFile(path) } +func (s *StateStorage) Save(path string, dat []byte) error { return os.WriteFile(path, dat, 0644) } + +func (z *ZipStorage) GetSavePath() string { return z.Storage.GetSavePath() + zip.Ext } +func (z *ZipStorage) GetSRAMPath() string { return z.Storage.GetSRAMPath() + zip.Ext } + +// Load loads a zip file with the path specified. +func (z *ZipStorage) Load(path string) ([]byte, error) { + data, err := z.Storage.Load(path) + if err != nil { + return nil, err + } + d, _, err := zip.Read(data) + if err != nil { + return nil, err + } + return d, nil +} + +// Save saves the array of bytes into a file with the specified path. +func (z *ZipStorage) Save(path string, data []byte) error { + _, name := filepath.Split(path) + if name == "" || name == "." { + return zip.ErrorInvalidName + } + name = strings.TrimSuffix(name, zip.Ext) + compress, err := zip.Compress(data, name) + if err != nil { + return err + } + return z.Storage.Save(path, compress) +} diff --git a/pkg/emulator/libretro/nanoarch/zipstorage_test.go b/pkg/worker/emulator/libretro/storage_test.go similarity index 97% rename from pkg/emulator/libretro/nanoarch/zipstorage_test.go rename to pkg/worker/emulator/libretro/storage_test.go index fae19dd2..f4a421b0 100644 --- a/pkg/emulator/libretro/nanoarch/zipstorage_test.go +++ b/pkg/worker/emulator/libretro/storage_test.go @@ -1,4 +1,4 @@ -package nanoarch +package libretro import ( "os" diff --git a/pkg/worker/encoder/encoder.go b/pkg/worker/encoder/encoder.go new file mode 100644 index 00000000..8c20b5fc --- /dev/null +++ b/pkg/worker/encoder/encoder.go @@ -0,0 +1,70 @@ +package encoder + +import ( + "image" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/yuv" +) + +type ( + InFrame *image.RGBA + OutFrame []byte + Encoder interface { + LoadBuf(input []byte) + Encode() []byte + IntraRefresh() + Shutdown() error + } +) + +type VideoEncoder struct { + encoder Encoder + + y yuv.ImgProcessor + + // frame size + w, h int + log *logger.Logger +} + +type VideoCodec string + +const ( + H264 VideoCodec = "h264" + VP8 VideoCodec = "vp8" +) + +// NewVideoEncoder returns new video encoder. +// By default, it waits for RGBA images on the input channel, +// converts them into YUV I420 format, +// encodes with provided video encoder, and +// puts the result into the output channel. +func NewVideoEncoder(enc Encoder, w, h int, concurrency int, log *logger.Logger) *VideoEncoder { + y := yuv.NewYuvImgProcessor(w, h, &yuv.Options{Threads: concurrency}) + if concurrency > 0 { + log.Info().Msgf("Use concurrent image processor: %v", concurrency) + } + return &VideoEncoder{encoder: enc, y: y, w: w, h: h, log: log} +} + +func (vp VideoEncoder) Encode(img InFrame) OutFrame { + yCbCr := vp.y.Process(img) + vp.encoder.LoadBuf(yCbCr) + vp.y.Put(&yCbCr) + + if frame := vp.encoder.Encode(); len(frame) > 0 { + return frame + } + return nil +} + +// Start begins video encoding pipe. +// Should be wrapped into a goroutine. +func (vp VideoEncoder) Start() {} + +func (vp VideoEncoder) Stop() { + if err := vp.encoder.Shutdown(); err != nil { + vp.log.Error().Err(err).Msg("failed to close the encoder") + } +} diff --git a/pkg/encoder/h264/libx264.go b/pkg/worker/encoder/h264/libx264.go similarity index 56% rename from pkg/encoder/h264/libx264.go rename to pkg/worker/encoder/h264/libx264.go index ca019be4..ac6d944d 100644 --- a/pkg/encoder/h264/libx264.go +++ b/pkg/worker/encoder/h264/libx264.go @@ -1,9 +1,8 @@ -// Implements cgo bindings for [x264](https://www.videolan.org/developers/x264.html) library. +// Package h264 implements cgo bindings for [x264](https://www.videolan.org/developers/x264.html) library. package h264 /* #cgo pkg-config: x264 -#cgo CFLAGS: -Wall -O3 #include "stdint.h" #include "x264.h" @@ -14,42 +13,14 @@ import "unsafe" const Build = C.X264_BUILD -/* T is opaque handler for encoder */ +// T is opaque handler for encoder type T struct{} -/**************************************************************************** - * NAL structure and functions - ****************************************************************************/ - -/* enum nal_unit_type_e */ -const ( - NalUnknown = 0 - NalSlice = 1 - NalSliceDpa = 2 - NalSliceDpb = 3 - NalSliceDpc = 4 - NalSliceIdr = 5 /* ref_idc != 0 */ - NalSei = 6 /* ref_idc == 0 */ - NalSps = 7 - NalPps = 8 - NalAud = 9 - NalFiller = 12 - /* ref_idc == 0 for 6,9,10,11,12 */ -) - -/* enum nal_priority_e */ -const ( - NalPriorityDisposable = 0 - NalPriorityLow = 1 - NalPriorityHigh = 2 - NalPriorityHighest = 3 -) - -/* The data within the payload is already NAL-encapsulated; the ref_idc and type - * are merely in the struct for easy access by the calling application. - * All data returned in an x264_nal_t, including the data in p_payload, is no longer - * valid after the next call to x264_encoder_encode. Thus it must be used or copied - * before calling x264_encoder_encode or x264_encoder_headers again. */ +// Nal is The data within the payload is already NAL-encapsulated; the ref_idc and type +// are merely in the struct for easy access by the calling application. +// All data returned in x264_nal_t, including the data in p_payload, is no longer +// valid after the next call to x264_encoder_encode. Thus, it must be used or copied +// before calling x264_encoder_encode or x264_encoder_headers again. type Nal struct { IRefIdc int32 /* nal_priority_e */ IType int32 /* nal_unit_type_e */ @@ -69,175 +40,34 @@ type Nal struct { IPadding int32 } -/**************************************************************************** - * Encoder parameters - ****************************************************************************/ -/* CPU flags */ +const RcCrf = 1 const ( - /* x86 */ - CpuMmx uint32 = 1 << 0 - CpuMmx2 uint32 = 1 << 1 /* MMX2 aka MMXEXT aka ISSE */ - CpuMmxext = CpuMmx2 - CpuSse uint32 = 1 << 2 - CpuSse2 uint32 = 1 << 3 - CpuLzcnt uint32 = 1 << 4 - CpuSse3 uint32 = 1 << 5 - CpuSsse3 uint32 = 1 << 6 - CpuSse4 uint32 = 1 << 7 /* SSE4.1 */ - CpuSse42 uint32 = 1 << 8 /* SSE4.2 */ - CpuAvx uint32 = 1 << 9 /* Requires OS support even if YMM registers aren't used */ - CpuXop uint32 = 1 << 10 /* AMD XOP */ - CpuFma4 uint32 = 1 << 11 /* AMD FMA4 */ - CpuFma3 uint32 = 1 << 12 - CpuBmi1 uint32 = 1 << 13 - CpuBmi2 uint32 = 1 << 14 - CpuAvx2 uint32 = 1 << 15 - CpuAvx512 uint32 = 1 << 16 /* AVX-512 {F, CD, BW, DQ, VL}, requires OS support */ - /* x86 modifiers */ - CpuCacheline32 uint32 = 1 << 17 /* avoid memory loads that span the border between two cachelines */ - CpuCacheline64 uint32 = 1 << 18 /* 32/64 is the size of a cacheline in bytes */ - CpuSse2IsSlow uint32 = 1 << 19 /* avoid most SSE2 functions on Athlon64 */ - CpuSse2IsFast uint32 = 1 << 20 /* a few functions are only faster on Core2 and Phenom */ - CpuSlowShuffle uint32 = 1 << 21 /* The Conroe has a slow shuffle unit (relative to overall SSE performance) */ - CpuStackMod4 uint32 = 1 << 22 /* if stack is only mod4 and not mod16 */ - CpuSlowAtom uint32 = 1 << 23 /* The Atom is terrible: slow SSE unaligned loads, slow - * SIMD multiplies, slow SIMD variable shifts, slow pshufb, - * cacheline split penalties -- gather everything here that - * isn't shared by other CPUs to avoid making half a dozen - * new SLOW flags. */ - CpuSlowPshufb uint32 = 1 << 24 /* such as on the Intel Atom */ - CpuSlowPalignr uint32 = 1 << 25 /* such as on the AMD Bobcat */ + CspI420 = 0x0002 // yuv 4:2:0 planar - /* PowerPC */ - CpuAltivec uint32 = 0x0000001 + // CspMask = 0x00ff /* */ + // CspNone = 0x0000 /* Invalid mode */ + // CspI400 = 0x0001 /* monochrome 4:0:0 */ - /* ARM and AArch64 */ - CpuArmv6 uint32 = 0x0000001 - CpuNeon uint32 = 0x0000002 /* ARM NEON */ - CpuFastNeonMrc uint32 = 0x0000004 /* Transfer from NEON to ARM register is fast (Cortex-A9) */ - CpuArmv8 uint32 = 0x0000008 - - /* MIPS */ - CpuMsa uint32 = 0x0000001 /* MIPS MSA */ - - /* Analyse flags */ - AnalyseI4x4 uint32 = 0x0001 /* Analyse i4x4 */ - AnalyseI8x8 uint32 = 0x0002 /* Analyse i8x8 (requires 8x8 transform) */ - AnalysePsub16x16 uint32 = 0x0010 /* Analyse p16x8, p8x16 and p8x8 */ - AnalysePsub8x8 uint32 = 0x0020 /* Analyse p8x4, p4x8, p4x4 */ - AnalyseBsub16x16 uint32 = 0x0100 /* Analyse b16x8, b8x16 and b8x8 */ - - DirectPredNone = 0 - DirectPredSpatial = 1 - DirectPredTemporal = 2 - DirectPredAuto = 3 - MeDia = 0 - MeHex = 1 - MeUmh = 2 - MeEsa = 3 - MeTesa = 4 - CqmFlat = 0 - CqmJvt = 1 - CqmCustom = 2 - RcCqp = 0 - RcCrf = 1 - RcAbr = 2 - QpAuto = 0 - AqNone = 0 - AqVariance = 1 - AqAutovariance = 2 - AqAutovarianceBiased = 3 - BAdaptNone = 0 - BAdaptFast = 1 - BAdaptTrellis = 2 - WeightpNone = 0 - WeightpSimple = 1 - WeightpSmart = 2 - BPyramidNone = 0 - BPyramidStrict = 1 - BPyramidNormal = 2 - KeyintMinAuto = 0 - KeyintMaxInfinite = 1 << 30 - - /* AVC-Intra flavors */ - AvcintraFlavorPanasonic = 0 - AvcintraFlavorSony = 1 - - /* !to add missing names */ - /* static const char * const x264_direct_pred_names[] = { "none", "spatial", "temporal", "auto", 0 }; */ - /* static const char * const x264_motion_est_names[] = { "dia", "hex", "umh", "esa", "tesa", 0 }; */ - /* static const char * const x264_b_pyramid_names[] = { "none", "strict", "normal", 0 }; */ - /* static const char * const x264_overscan_names[] = { "undef", "show", "crop", 0 }; */ - /* static const char * const x264_vidformat_names[] = { "component", "pal", "ntsc", "secam", "mac", "undef", 0 }; */ - /* static const char * const x264_fullrange_names[] = { "off", "on", 0 }; */ - /* static const char * const x264_colorprim_names[] = { "", "bt709", "undef", "", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020", "smpte428", "smpte431", "smpte432", 0 }; */ - /* static const char * const x264_transfer_names[] = { "", "bt709", "undef", "", "bt470m", "bt470bg", "smpte170m", "smpte240m", "linear", "log100", "log316", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", "bt2020-12", "smpte2084", "smpte428", "arib-std-b67", 0 }; */ - /* static const char * const x264_colmatrix_names[] = { "GBR", "bt709", "undef", "", "fcc", "bt470bg", "smpte170m", "smpte240m", "YCgCo", "bt2020nc", "bt2020c", "smpte2085", "chroma-derived-nc", "chroma-derived-c", "ICtCp", 0 }; */ - /* static const char * const x264_nal_hrd_names[] = { "none", "vbr", "cbr", 0 }; */ - /* static const char * const x264_avcintra_flavor_names[] = { "panasonic", "sony", 0 }; */ - - /* Colorspace type */ - CspMask = 0x00ff /* */ - CspNone = 0x0000 /* Invalid mode */ - CspI400 = 0x0001 /* monochrome 4:0:0 */ - CspI420 = 0x0002 /* yuv 4:2:0 planar */ - CspYv12 = 0x0003 /* yvu 4:2:0 planar */ - CspNv12 = 0x0004 /* yuv 4:2:0, with one y plane and one packed u+v */ - CspNv21 = 0x0005 /* yuv 4:2:0, with one y plane and one packed v+u */ - CspI422 = 0x0006 /* yuv 4:2:2 planar */ - CspYv16 = 0x0007 /* yvu 4:2:2 planar */ - CspNv16 = 0x0008 /* yuv 4:2:2, with one y plane and one packed u+v */ - CspYuyv = 0x0009 /* yuyv 4:2:2 packed */ - CspUyvy = 0x000a /* uyvy 4:2:2 packed */ - CspV210 = 0x000b /* 10-bit yuv 4:2:2 packed in 32 */ - CspI444 = 0x000c /* yuv 4:4:4 planar */ - CspYv24 = 0x000d /* yvu 4:4:4 planar */ - CspBgr = 0x000e /* packed bgr 24bits */ - CspBgra = 0x000f /* packed bgr 32bits */ - CspRgb = 0x0010 /* packed rgb 24bits */ - CspMax = 0x0011 /* end of list */ - CspVflip = 0x1000 /* the csp is vertically flipped */ - CspHighDepth = 0x2000 /* the csp has a depth of 16 bits per pixel component */ - - /* Slice type */ - TypeAuto = 0x0000 /* Let x264 choose the right type */ - TypeIdr = 0x0001 - TypeI = 0x0002 - TypeP = 0x0003 - TypeBref = 0x0004 /* Non-disposable B-frame */ - TypeB = 0x0005 - TypeKeyframe = 0x0006 /* IDR or I depending on b_open_gop option */ - /* !to reimplement macro */ - /* #define IS_X264_TYPE_I(x) ((x)==X264_TYPE_I || (x)==X264_TYPE_IDR || (x)==X264_TYPE_KEYFRAME) */ - /* #define IS_X264_TYPE_B(x) ((x)==X264_TYPE_B || (x)==X264_TYPE_BREF) */ - - /* Log level */ - LogNone = -1 - LogError = 0 - LogWarning = 1 - LogInfo = 2 - LogDebug = 3 - - /* Threading */ - ThreadsAuto = 0 /* Automatically select optimal number of threads */ - SyncLookaheadAuto = -1 /* Automatically select optimal lookahead thread buffer size */ - - /* HRD */ - NalHrdNone = 0 - NalHrdVbr = 1 - NalHrdCbr = 2 + //CspYv12 = 0x0003 /* yvu 4:2:0 planar */ + //CspNv12 = 0x0004 /* yuv 4:2:0, with one y plane and one packed u+v */ + //CspNv21 = 0x0005 /* yuv 4:2:0, with one y plane and one packed v+u */ + //CspI422 = 0x0006 /* yuv 4:2:2 planar */ + //CspYv16 = 0x0007 /* yvu 4:2:2 planar */ + //CspNv16 = 0x0008 /* yuv 4:2:2, with one y plane and one packed u+v */ + //CspYuyv = 0x0009 /* yuyv 4:2:2 packed */ + //CspUyvy = 0x000a /* uyvy 4:2:2 packed */ + //CspV210 = 0x000b /* 10-bit yuv 4:2:2 packed in 32 */ + //CspI444 = 0x000c /* yuv 4:4:4 planar */ + //CspYv24 = 0x000d /* yvu 4:4:4 planar */ + //CspBgr = 0x000e /* packed bgr 24bits */ + //CspBgra = 0x000f /* packed bgr 32bits */ + //CspRgb = 0x0010 /* packed rgb 24bits */ + //CspMax = 0x0011 /* end of list */ + //CspVflip = 0x1000 /* the csp is vertically flipped */ + //CspHighDepth = 0x2000 /* the csp has a depth of 16 bits per pixel component */ ) -const ( - /* The macroblock is constant and remains unchanged from the previous frame. */ - MbinfoConstant = 1 << 0 - /* More flags may be added in the future. */ -) - -/* Zones: override ratecontrol or other options for specific sections of the video. - * See x264_encoder_reconfig() for which options can be changed. - * If zones overlap, whichever comes later in the list takes precedence. */ type Zone struct { IStart, IEnd int32 /* range of frame numbers */ BForceQp int32 /* whether to use qp vs bitrate factor */ @@ -509,18 +339,6 @@ type Level struct { type PicStruct int32 -const ( - PicStructAuto = iota // automatically decide (default) - PicStructProgressive = 1 // progressive frame - // "TOP" and "BOTTOM" are not supported in x264 (PAFF only) - PicStructTopBottom = 4 // top field followed by bottom - PicStructBottomTop = 5 // bottom field followed by top - PicStructTopBottomTop = 6 // top field, bottom field, top field repeated - PicStructBottomTopBottom = 7 // bottom field, top field, bottom field repeated - PicStructDouble = 8 // double frame - PicStructTriple = 9 // triple frame -) - type Hrd struct { CpbInitialArrivalTime float64 CpbFinalArrivalTime float64 @@ -529,14 +347,6 @@ type Hrd struct { DpbOutputTime float64 } -/* Arbitrary user SEI: - * Payload size is in bytes and the payload pointer must be valid. - * Payload types and syntax can be found in Annex D of the H.264 Specification. - * SEI payload alignment bits as described in Annex D must be included at the - * end of the payload if needed. - * The payload should not be NAL-encapsulated. - * Payloads are written first in order of input, apart from in the case when HRD - * is enabled where payloads are written after the Buffering Period SEI. */ type SeiPayload struct { PayloadSize int32 PayloadType int32 @@ -557,12 +367,6 @@ type Image struct { Plane [4]unsafe.Pointer /* Pointers to each plane */ } -/* All arrays of data here are ordered as follows: - * each array contains one offset per macroblock, in raster scan order. In interlaced - * mode, top-field MBs and bottom-field MBs are interleaved at the row level. - * Macroblocks are 16x16 blocks of pixels (with respect to the luma plane). For the - * purposes of calculating the number of macroblocks, width and height are rounded up to - * the nearest 16. If in interlaced mode, height is rounded up to the nearest 32 instead. */ type ImageProperties struct { /* In: an array of quantizer offsets to be applied to this image during encoding. * These are added on top of the decisions made by x264. @@ -650,8 +454,10 @@ type Picture struct { Opaque unsafe.Pointer } -func (p *Picture) freePlane(n int) { - C.free(p.Img.Plane[n]) +func (p *Picture) freePlanes() { + for _, ptr := range p.Img.Plane { + C.free(ptr) + } } func (t *T) cptr() *C.x264_t { return (*C.x264_t)(unsafe.Pointer(t)) } @@ -662,33 +468,8 @@ func (p *Param) cptr() *C.x264_param_t { return (*C.x264_param_t)(unsafe.Pointer func (p *Picture) cptr() *C.x264_picture_t { return (*C.x264_picture_t)(unsafe.Pointer(p)) } -// NalEncode - encode Nal. -func NalEncode(h *T, dst []byte, nal *Nal) { - ch := h.cptr() - cdst := (*C.uint8_t)(unsafe.Pointer(&dst[0])) - cnal := nal.cptr() - C.x264_nal_encode(ch, cdst, cnal) -} - // ParamDefault - fill Param with default values and do CPU detection. -func ParamDefault(param *Param) { - C.x264_param_default(param.cptr()) -} - -// ParamParse - set one parameter by name. Returns 0 on success. -func ParamParse(param *Param, name string, value string) int32 { - cparam := param.cptr() - - cname := C.CString(name) - defer C.free(unsafe.Pointer(cname)) - - cvalue := C.CString(value) - defer C.free(unsafe.Pointer(cvalue)) - - ret := C.x264_param_parse(cparam, cname, cvalue) - v := (int32)(ret) - return v -} +func ParamDefault(param *Param) { C.x264_param_default(param.cptr()) } // ParamDefaultPreset - the same as ParamDefault, but also use the passed preset and tune to modify the default settings // (either can be nil, which implies no preset or no tune, respectively). @@ -701,24 +482,11 @@ func ParamParse(param *Param, name string, value string) int32 { // // Returns 0 on success, negative on failure (e.g. invalid preset/tune name). func ParamDefaultPreset(param *Param, preset string, tune string) int32 { - cparam := param.cptr() - cpreset := C.CString(preset) defer C.free(unsafe.Pointer(cpreset)) - ctune := C.CString(tune) defer C.free(unsafe.Pointer(ctune)) - - ret := C.x264_param_default_preset(cparam, cpreset, ctune) - v := (int32)(ret) - return v -} - -// ParamApplyFastfirstpass - if first-pass mode is set (rc.b_stat_read == 0, rc.b_stat_write == 1), -// modify the encoder settings to disable options generally not useful on the first pass. -func ParamApplyFastfirstpass(param *Param) { - cparam := param.cptr() - C.x264_param_apply_fastfirstpass(cparam) + return (int32)(C.x264_param_default_preset(param.cptr(), cpreset, ctune)) } // ParamApplyProfile - applies the restrictions of the given profile. @@ -729,82 +497,15 @@ func ParamApplyFastfirstpass(param *Param) { // // Returns 0 on success, negative on failure (e.g. invalid profile name). func ParamApplyProfile(param *Param, profile string) int32 { - cparam := param.cptr() - cprofile := C.CString(profile) defer C.free(unsafe.Pointer(cprofile)) - - ret := C.x264_param_apply_profile(cparam, cprofile) - v := (int32)(ret) - return v -} - -// PictureInit - initialize an Picture. Needs to be done if the calling application -// allocates its own Picture as opposed to using PictureAlloc. -func PictureInit(pic *Picture) { - cpic := pic.cptr() - C.x264_picture_init(cpic) -} - -// PictureAlloc - alloc data for a Picture. You must call PictureClean on it. -// Returns 0 on success, or -1 on malloc failure or invalid colorspace. -func PictureAlloc(pic *Picture, iCsp int32, iWidth int32, iHeight int32) int32 { - cpic := pic.cptr() - - ciCsp := (C.int)(iCsp) - ciWidth := (C.int)(iWidth) - ciHeight := (C.int)(iHeight) - - ret := C.x264_picture_alloc(cpic, ciCsp, ciWidth, ciHeight) - v := (int32)(ret) - return v -} - -// PictureClean - free associated resource for a Picture allocated with PictureAlloc ONLY. -func PictureClean(pic *Picture) { - cpic := pic.cptr() - C.x264_picture_clean(cpic) + return (int32)(C.x264_param_apply_profile(param.cptr(), cprofile)) } // EncoderOpen - create a new encoder handler, all parameters from Param are copied. func EncoderOpen(param *Param) *T { - cparam := param.cptr() - - ret := C.x264_encoder_open(cparam) - v := *(**T)(unsafe.Pointer(&ret)) - return v -} - -// EncoderReconfig - various parameters from Param are copied. -// Returns 0 on success, negative on parameter validation error. -func EncoderReconfig(enc *T, param *Param) int32 { - cenc := enc.cptr() - cparam := param.cptr() - - ret := C.x264_encoder_reconfig(cenc, cparam) - v := (int32)(ret) - return v -} - -// EncoderParameters - copies the current internal set of parameters to the pointer provided. -func EncoderParameters(enc *T, param *Param) { - cenc := enc.cptr() - cparam := param.cptr() - - C.x264_encoder_parameters(cenc, cparam) -} - -// EncoderHeaders - return the SPS and PPS that will be used for the whole stream. -// Returns the number of bytes in the returned NALs or negative on error. -func EncoderHeaders(enc *T, ppNal []*Nal, piNal *int32) int32 { - cenc := enc.cptr() - - cppNal := (**C.x264_nal_t)(unsafe.Pointer(&ppNal[0])) - cpiNal := (*C.int)(unsafe.Pointer(piNal)) - - ret := C.x264_encoder_headers(cenc, cppNal, cpiNal) - v := (int32)(ret) - return v + ret := C.x264_encoder_open(param.cptr()) + return *(**T)(unsafe.Pointer(&ret)) } // EncoderEncode - encode one picture. @@ -818,55 +519,15 @@ func EncoderEncode(enc *T, ppNal []*Nal, piNal *int32, picIn *Picture, picOut *P cpicIn := picIn.cptr() cpicOut := picOut.cptr() - ret := C.x264_encoder_encode(cenc, cppNal, cpiNal, cpicIn, cpicOut) - v := (int32)(ret) - return v + return (int32)(C.x264_encoder_encode(cenc, cppNal, cpiNal, cpicIn, cpicOut)) } -// EncoderClose - close an encoder handler. -func EncoderClose(enc *T) { - cenc := enc.cptr() - C.x264_encoder_close(cenc) -} - -// EncoderDelayedFrames - return the number of currently delayed (buffered) frames. -// This should be used at the end of the stream, to know when you have all the encoded frames. -func EncoderDelayedFrames(enc *T) int32 { - cenc := enc.cptr() - - ret := C.x264_encoder_delayed_frames(cenc) - v := (int32)(ret) - return v -} - -// EncoderMaximumDelayedFrames - return the maximum number of delayed (buffered) frames that can occur with the current parameters. -func EncoderMaximumDelayedFrames(enc *T) int32 { - cenc := enc.cptr() - - ret := C.x264_encoder_maximum_delayed_frames(cenc) - v := (int32)(ret) - return v -} +// EncoderClose closes an encoder handler. +func EncoderClose(enc *T) { C.x264_encoder_close(enc.cptr()) } // EncoderIntraRefresh - If an intra refresh is not in progress, begin one with the next P-frame. // If an intra refresh is in progress, begin one as soon as the current one finishes. // Requires that BIntraRefresh be set. // // Should not be called during an x264_encoder_encode. -func EncoderIntraRefresh(enc *T) { - cenc := enc.cptr() - C.x264_encoder_intra_refresh(cenc) -} - -// EncoderInvalidateReference - An interactive error resilience tool, designed for use in a low-latency one-encoder-few-clients system. -// Should not be called during an EncoderEncode, but multiple calls can be made simultaneously. -// -// Returns 0 on success, negative on failure. -func EncoderInvalidateReference(enc *T, pts int) int32 { - cenc := enc.cptr() - cpts := (C.int64_t)(pts) - - ret := C.x264_encoder_invalidate_reference(cenc, cpts) - v := (int32)(ret) - return v -} +//func EncoderIntraRefresh(enc *T) { C.x264_encoder_intra_refresh(enc.cptr()) } diff --git a/pkg/worker/encoder/h264/x264.go b/pkg/worker/encoder/h264/x264.go new file mode 100644 index 00000000..ca18adcb --- /dev/null +++ b/pkg/worker/encoder/h264/x264.go @@ -0,0 +1,149 @@ +package h264 + +/* +#include +*/ +import "C" +import ( + "fmt" + "unsafe" +) + +type H264 struct { + ref *T + + width int32 + lumaSize int32 + chromaSize int32 + csp int32 + nnals int32 + nals []*Nal + + in, out *Picture + y, u, v []byte +} + +type Options struct { + // Constant Rate Factor (CRF) + // This method allows the encoder to attempt to achieve a certain output quality for the whole file + // when output file size is of less importance. + // The range of the CRF scale is 0–51, where 0 is lossless, 23 is the default, and 51 is the worst quality possible. + Crf uint8 + // film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency. + Tune string + // ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo. + Preset string + // baseline, main, high, high10, high422, high444. + Profile string + LogLevel int32 +} + +func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) { + libVersion := LibVersion() + + if libVersion < 150 { + return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", libVersion) + } + + if opts == nil { + opts = &Options{ + Crf: 23, + Tune: "zerolatency", + Preset: "superfast", + Profile: "baseline", + } + } + + param := Param{} + if opts.Preset != "" && opts.Tune != "" { + if ParamDefaultPreset(¶m, opts.Preset, opts.Tune) < 0 { + return nil, fmt.Errorf("x264: invalid preset/tune name") + } + } else { + ParamDefault(¶m) + } + + if opts.Profile != "" { + if ParamApplyProfile(¶m, opts.Profile) < 0 { + return nil, fmt.Errorf("x264: invalid profile name") + } + } + + // legacy encoder lacks of this param + param.IBitdepth = 8 + + if libVersion > 155 { + param.ICsp = CspI420 + } else { + param.ICsp = 1 + } + param.IWidth = int32(w) + param.IHeight = int32(h) + param.ILogLevel = opts.LogLevel + + param.Rc.IRcMethod = RcCrf + param.Rc.FRfConstant = float32(opts.Crf) + + encoder = &H264{ + csp: param.ICsp, + lumaSize: int32(w * h), + chromaSize: int32(w*h) / 4, + nals: make([]*Nal, 1), + width: int32(w), + out: new(Picture), + } + + // pool + var picIn Picture + + picIn.Img.ICsp = encoder.csp + picIn.Img.IPlane = 3 + picIn.Img.IStride[0] = encoder.width + picIn.Img.IStride[1] = encoder.width >> 1 + picIn.Img.IStride[2] = encoder.width >> 1 + + picIn.Img.Plane[0] = C.malloc(C.size_t(encoder.lumaSize)) + picIn.Img.Plane[1] = C.malloc(C.size_t(encoder.chromaSize)) + picIn.Img.Plane[2] = C.malloc(C.size_t(encoder.chromaSize)) + + encoder.y = unsafe.Slice((*byte)(picIn.Img.Plane[0]), encoder.lumaSize) + encoder.u = unsafe.Slice((*byte)(picIn.Img.Plane[1]), encoder.chromaSize) + encoder.v = unsafe.Slice((*byte)(picIn.Img.Plane[2]), encoder.chromaSize) + + encoder.in = &picIn + + if encoder.ref = EncoderOpen(¶m); encoder.ref == nil { + err = fmt.Errorf("x264: cannot open the encoder") + return + } + return +} + +func LibVersion() int { return int(Build) } + +func (e *H264) LoadBuf(yuv []byte) { + copy(e.y, yuv[:e.lumaSize]) + copy(e.u, yuv[e.lumaSize:e.lumaSize+e.chromaSize]) + copy(e.v, yuv[e.lumaSize+e.chromaSize:]) +} + +func (e *H264) Encode() []byte { + e.in.IPts += 1 + if ret := EncoderEncode(e.ref, e.nals, &e.nnals, e.in, e.out); ret > 0 { + return C.GoBytes(e.nals[0].PPayload, C.int(ret)) + } + return []byte{} +} + +func (e *H264) IntraRefresh() { + // !to implement +} + +func (e *H264) Shutdown() error { + e.y = nil + e.u = nil + e.v = nil + e.in.freePlanes() + EncoderClose(e.ref) + return nil +} diff --git a/pkg/worker/encoder/opus/opus.go b/pkg/worker/encoder/opus/opus.go new file mode 100644 index 00000000..7d42b6c8 --- /dev/null +++ b/pkg/worker/encoder/opus/opus.go @@ -0,0 +1,198 @@ +package opus + +/* +#cgo pkg-config: opus + +#include + +int get_bitrate(OpusEncoder *st, opus_int32 *bitrate) { return opus_encoder_ctl(st, OPUS_GET_BITRATE(bitrate)); } +int get_complexity(OpusEncoder *st, opus_int32 *complexity) { return opus_encoder_ctl(st, OPUS_GET_COMPLEXITY(complexity)); } +int get_dtx(OpusEncoder *st, opus_int32 *dtx) { return opus_encoder_ctl(st, OPUS_GET_DTX(dtx)); } +int get_inband_fec(OpusEncoder *st, opus_int32 *fec) { return opus_encoder_ctl(st, OPUS_GET_INBAND_FEC(fec)); } +int get_max_bandwidth(OpusEncoder *st, opus_int32 *max_bw) { return opus_encoder_ctl(st, OPUS_GET_MAX_BANDWIDTH(max_bw)); } +int get_packet_loss_perc(OpusEncoder *st, opus_int32 *loss_perc) { return opus_encoder_ctl(st, OPUS_GET_PACKET_LOSS_PERC(loss_perc)); } +int get_sample_rate(OpusEncoder *st, opus_int32 *sample_rate) { return opus_encoder_ctl(st, OPUS_GET_SAMPLE_RATE(sample_rate)); } +int set_bitrate(OpusEncoder *st, opus_int32 bitrate) { return opus_encoder_ctl(st, OPUS_SET_BITRATE(bitrate)); } +int set_complexity(OpusEncoder *st, opus_int32 complexity) { return opus_encoder_ctl(st, OPUS_SET_COMPLEXITY(complexity)); } +int set_dtx(OpusEncoder *st, opus_int32 use_dtx) { return opus_encoder_ctl(st, OPUS_SET_DTX(use_dtx)); } +int set_inband_fec(OpusEncoder *st, opus_int32 fec) { return opus_encoder_ctl(st, OPUS_SET_INBAND_FEC(fec)); } +int set_max_bandwidth(OpusEncoder *st, opus_int32 max_bw) { return opus_encoder_ctl(st, OPUS_SET_MAX_BANDWIDTH(max_bw)); } +int set_packet_loss_perc(OpusEncoder *st, opus_int32 loss_perc) { return opus_encoder_ctl(st, OPUS_SET_PACKET_LOSS_PERC(loss_perc)); } +int reset_state(OpusEncoder *st) { return opus_encoder_ctl(st, OPUS_RESET_STATE); } +*/ +import "C" +import ( + "fmt" + "unsafe" +) + +type ( + Application int + Bandwidth int + Bitrate int + Error int +) + +const ( + // AppRestrictedLowDelay optimizes encoding for low latency applications + AppRestrictedLowDelay = Application(C.OPUS_APPLICATION_RESTRICTED_LOWDELAY) + // FullBand is 20 kHz bandpass + FullBand = Bandwidth(C.OPUS_BANDWIDTH_FULLBAND) +) +const stereo = C.int(2) + +type Encoder struct { + mem []byte + out []byte + st *C.struct_OpusEncoder +} + +func NewEncoder(outFq int) (*Encoder, error) { + mem := make([]byte, C.opus_encoder_get_size(stereo)) + out := make([]byte, 1024) + enc := Encoder{ + mem: mem, + st: (*C.OpusEncoder)(unsafe.Pointer(&mem[0])), + out: out, + } + err := unwrap(C.opus_encoder_init(enc.st, C.opus_int32(outFq), stereo, C.int(AppRestrictedLowDelay))) + if err != nil { + return nil, fmt.Errorf("opus: initializatoin error (%v)", err) + } + _ = enc.SetMaxBandwidth(FullBand) + _ = enc.SetBitrate(96000) + _ = enc.SetComplexity(5) + + return &enc, nil +} + +func (e *Encoder) Reset() error { return e.ResetState() } + +func (e *Encoder) Encode(pcm []int16) ([]byte, error) { + if len(pcm) == 0 { + return nil, nil + } + n := C.opus_encode(e.st, (*C.opus_int16)(&pcm[0]), C.int(len(pcm)>>1), (*C.uchar)(&e.out[0]), C.opus_int32(cap(pcm))) + err := unwrap(n) + // n = 1 is DTX + if err != nil || n == 1 { + return []byte{}, err + } + return e.out[:int(n)], nil +} + +func (e *Encoder) GetInfo() string { + bitrate, _ := e.Bitrate() + complexity, _ := e.Complexity() + dtx, _ := e.DTX() + fec, _ := e.FEC() + maxBandwidth, _ := e.MaxBandwidth() + lossPercent, _ := e.PacketLossPerc() + sampleRate, _ := e.SampleRate() + return fmt.Sprintf( + "%v, Bitrate: %v bps, Complexity: %v, DTX: %v, FEC: %v, Max bandwidth: *%v, Loss%%: %v, Rate: %v Hz", + CodecVersion(), bitrate, complexity, dtx, fec, maxBandwidth, lossPercent, sampleRate, + ) +} + +// SampleRate returns the sample rate of the encoder. +func (e *Encoder) SampleRate() (int, error) { + var sampleRate C.opus_int32 + res := C.get_sample_rate(e.st, &sampleRate) + return int(sampleRate), unwrap(res) +} + +// Bitrate returns the bitrate of the encoder. +func (e *Encoder) Bitrate() (int, error) { + var bitrate C.opus_int32 + res := C.get_bitrate(e.st, &bitrate) + return int(bitrate), unwrap(res) +} + +// SetBitrate sets the bitrate of the encoder. +// BitrateMax / BitrateAuto can be used here. +func (e *Encoder) SetBitrate(b Bitrate) error { + return unwrap(C.set_bitrate(e.st, C.opus_int32(b))) +} + +// Complexity returns the value of the complexity. +func (e *Encoder) Complexity() (int, error) { + var complexity C.opus_int32 + res := C.get_complexity(e.st, &complexity) + return int(complexity), unwrap(res) +} + +// SetComplexity sets the complexity factor for the encoder. +// Complexity is a value from 1 to 10, where 1 is the lowest complexity and 10 is the highest. +func (e *Encoder) SetComplexity(complexity int) error { + return unwrap(C.set_complexity(e.st, C.opus_int32(complexity))) +} + +// DTX says if discontinuous transmission is enabled. +func (e *Encoder) DTX() (bool, error) { + var dtx C.opus_int32 + res := C.get_dtx(e.st, &dtx) + return dtx > 0, unwrap(res) +} + +// SetDTX switches discontinuous transmission. +func (e *Encoder) SetDTX(dtx bool) error { + var i int + if dtx { + i = 1 + } + return unwrap(C.set_dtx(e.st, C.opus_int32(i))) +} + +// MaxBandwidth returns the maximum allowed bandpass value. +func (e *Encoder) MaxBandwidth() (Bandwidth, error) { + var b C.opus_int32 + res := C.get_max_bandwidth(e.st, &b) + return Bandwidth(b), unwrap(res) +} + +// SetMaxBandwidth sets the upper limit of the bandpass. +func (e *Encoder) SetMaxBandwidth(b Bandwidth) error { + return unwrap(C.set_max_bandwidth(e.st, C.opus_int32(b))) +} + +// FEC says if forward error correction (FEC) is enabled. +func (e *Encoder) FEC() (bool, error) { + var fec C.opus_int32 + res := C.get_inband_fec(e.st, &fec) + return fec > 0, unwrap(res) +} + +// SetFEC switches the forward error correction (FEC). +func (e *Encoder) SetFEC(fec bool) error { + var i int + if fec { + i = 1 + } + return unwrap(C.set_inband_fec(e.st, C.opus_int32(i))) +} + +// PacketLossPerc returns configured packet loss percentage. +func (e *Encoder) PacketLossPerc() (int, error) { + var lossPerc C.opus_int32 + res := C.get_packet_loss_perc(e.st, &lossPerc) + return int(lossPerc), unwrap(res) +} + +// SetPacketLossPerc sets expected packet loss percentage. +func (e *Encoder) SetPacketLossPerc(lossPerc int) error { + return unwrap(C.set_packet_loss_perc(e.st, C.opus_int32(lossPerc))) +} + +func (e *Encoder) ResetState() error { return unwrap(C.reset_state(e.st)) } + +func (e Error) Error() string { return fmt.Sprintf("opus: %v", C.GoString(C.opus_strerror(C.int(e)))) } + +func unwrap(error C.int) (err error) { + if error < C.OPUS_OK { + err = Error(int(error)) + } + return +} + +func CodecVersion() string { return C.GoString(C.opus_get_version_string()) } diff --git a/pkg/encoder/vpx/libvpx.go b/pkg/worker/encoder/vpx/libvpx.go similarity index 85% rename from pkg/encoder/vpx/libvpx.go rename to pkg/worker/encoder/vpx/libvpx.go index 5e4d8b09..7db5a2e6 100644 --- a/pkg/encoder/vpx/libvpx.go +++ b/pkg/worker/encoder/vpx/libvpx.go @@ -2,7 +2,6 @@ package vpx /* #cgo pkg-config: vpx -#cgo CFLAGS: -Wall -O3 #include "vpx/vpx_encoder.h" #include "vpx/vpx_image.h" @@ -86,19 +85,24 @@ type Vpx struct { kfi C.int } -func NewEncoder(width, height int, options ...Option) (*Vpx, error) { +type Options struct { + // Target bandwidth to use for this stream, in kilobits per second. + Bitrate uint + // Force keyframe interval. + KeyframeInt uint +} + +func NewEncoder(w, h int, opts *Options) (*Vpx, error) { encoder := &C.vpx_encoders[0] if encoder == nil { return nil, fmt.Errorf("couldn't get the encoder") } - opts := &Options{ - Bitrate: 1200, - KeyframeInt: 5, - } - - for _, opt := range options { - opt(opts) + if opts == nil { + opts = &Options{ + Bitrate: 1200, + KeyframeInt: 5, + } } vpx := Vpx{ @@ -106,7 +110,7 @@ func NewEncoder(width, height int, options ...Option) (*Vpx, error) { kfi: C.int(opts.KeyframeInt), } - if C.vpx_img_alloc(&vpx.image, C.VPX_IMG_FMT_I420, C.uint(width), C.uint(height), 1) == nil { + if C.vpx_img_alloc(&vpx.image, C.VPX_IMG_FMT_I420, C.uint(w), C.uint(h), 1) == nil { return nil, fmt.Errorf("vpx_img_alloc failed") } @@ -115,8 +119,8 @@ func NewEncoder(width, height int, options ...Option) (*Vpx, error) { return nil, fmt.Errorf("failed to get default codec config") } - cfg.g_w = C.uint(width) - cfg.g_h = C.uint(height) + cfg.g_w = C.uint(w) + cfg.g_h = C.uint(h) cfg.rc_target_bitrate = C.uint(opts.Bitrate) cfg.g_error_resilient = 1 @@ -127,10 +131,14 @@ func NewEncoder(width, height int, options ...Option) (*Vpx, error) { return &vpx, nil } -// see: https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c -func (vpx *Vpx) Encode(yuv []byte) []byte { - var iter C.vpx_codec_iter_t +func (vpx *Vpx) LoadBuf(yuv []byte) { C.vpx_img_read(&vpx.image, unsafe.Pointer(&yuv[0])) +} + +// Encode encodes yuv image with the VPX8 encoder. +// see: https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c +func (vpx *Vpx) Encode() []byte { + var iter C.vpx_codec_iter_t var flags C.int if vpx.kfi > 0 && vpx.frameCount%vpx.kfi == 0 { @@ -148,6 +156,10 @@ func (vpx *Vpx) Encode(yuv []byte) []byte { return C.GoBytes(fb.ptr, fb.size) } +func (vpx *Vpx) IntraRefresh() { + // !to implement +} + func (vpx *Vpx) Shutdown() error { if &vpx.image != nil { C.vpx_img_free(&vpx.image) diff --git a/pkg/worker/encoder/yuv/yuv.c b/pkg/worker/encoder/yuv/yuv.c new file mode 100644 index 00000000..6763aa2c --- /dev/null +++ b/pkg/worker/encoder/yuv/yuv.c @@ -0,0 +1,130 @@ +#include "yuv.h" + +#define Y601_STUDIO 1 + +// BT.601 STUDIO + +#ifdef Y601_STUDIO +// 66*R+129*G+25*B +static __inline int Y(uint8_t *rgb) { + int R = *rgb; + int G = *(rgb+1); + int B = *(rgb+2); + return (66*R+129*G+25*B+128)>>8; +} + +// 112*B-38*R-74G +static __inline int U(uint8_t *rgb) { + int R = *rgb; + int G = *(rgb+1); + int B = *(rgb+2); + return (-38*R-74*G+112*B+128) >> 8; +} + +// 112*R-94*G-18*B +static __inline int V(uint8_t *rgb) { + int R = 56**(rgb); + int G = 47**(rgb+1); + int B = *(rgb+2); + return (R-G-(B+(B<<3))+64) >> 7; +} + +static const int Y_MIN = 16; + +#else + +// BT.601 FULL + +// 77*R+150*G+29*B +static __inline int Y(uint8_t *rgb) { + int R = 77**(rgb); + int G = 150**(rgb+1); + int B = 29**(rgb+2); + return (R+G+B+128) >> 8; +} + +// 127*B-43*R-84*G +static __inline int U(uint8_t *rgb) { + int R = 43**(rgb); + int G = 84**(rgb+1); + int B = 127**(rgb+2); + return (-R-G+B+128) >> 8; +} + +// 127*R-106*G-21*B +static __inline int V(uint8_t *rgb) { + int R = 127**rgb; + int G = -106**(rgb+1); + int B = -21**(rgb+2); + return (G+B+R+128) >> 8; +} + +static const int Y_MIN = 0; +#endif + +static __inline void _y(uint8_t *p, uint8_t *y, int size) { + do { + *y++ = Y_MIN + Y(p); + p += 4; + } while (--size); +} + +// It will take an average color from the 2x2 pixel group for chroma values. +// X X X X +// O O +// X X X X +static __inline void _4uv(uint8_t *p, uint8_t *u, uint8_t *v, const int w, const int h) { + uint8_t *p2, *p3, *p4; + const int row = w << 2; + const int next = 4; + + int x = w, y = h, sumU = 0, sumV = 0; + while (y > 0) { + while (x > 0) { + // xx.. + // .... + p2 = p+next; + sumU = U(p) + U(p2); + sumV = V(p) + V(p2); + // .... + // xx.. + p3 = p+row; + p4 = p3+next; + sumU += U(p3) + U(p4); + sumV += V(p3) + V(p4); + *u++ = 128 + (sumU >> 2); + *v++ = 128 + (sumV >> 2); + // ..x. + p += 8; + x -= 2; + } + p += row; + y -=2; + x = w; + } +} + +// Converts RGBA image to YUV (I420) with BT.601 studio color range. +void rgbaToYuv(void *destination, void *source, const int w, const int h) { + const int image_size = w * h; + uint8_t *src = source; + uint8_t *dst_y = destination; + uint8_t *dst_u = destination + image_size; + uint8_t *dst_v = destination + image_size + image_size / 4; + _y(src, dst_y, image_size); + src = source; + _4uv(src, dst_u, dst_v, w, h); +} + +void luma(void *destination, void *source, const int pos, const int w, const int h) { + uint8_t *rgba = source + 4 * pos; + uint8_t *dst = destination + pos; + _y(rgba, dst, w*h); +} + +void chroma(void *dst, void *source, const int pos, const int deu, const int dev, const int w, const int h) { + uint8_t *src = source + 4 * pos; + uint8_t *dst_u = dst + deu + pos / 4; + uint8_t *dst_v = dst + dev + pos / 4; + _4uv(src, dst_u, dst_v, w, h); +} diff --git a/pkg/worker/encoder/yuv/yuv.go b/pkg/worker/encoder/yuv/yuv.go new file mode 100644 index 00000000..19a33318 --- /dev/null +++ b/pkg/worker/encoder/yuv/yuv.go @@ -0,0 +1,125 @@ +package yuv + +import ( + "image" + "sync" + "unsafe" +) + +/* +#cgo CFLAGS: -Wall +#include "yuv.h" +*/ +import "C" + +type ImgProcessor interface { + Process(rgba *image.RGBA) []byte + Put(*[]byte) +} + +type Options struct { + Threads int +} + +type processor struct { + w, h int + + // cache + ww C.int + pool sync.Pool +} + +type threadedProcessor struct { + *processor + + // threading + threads int + chunk int + + // cache + chromaU C.int + chromaV C.int + wg sync.WaitGroup +} + +// NewYuvImgProcessor creates new YUV image converter from RGBA. +func NewYuvImgProcessor(w, h int, opts *Options) ImgProcessor { + bufSize := int(float32(w*h) * 1.5) + + processor := processor{ + w: w, + h: h, + ww: C.int(w), + pool: sync.Pool{New: func() any { + b := make([]byte, bufSize) + return &b + }}, + } + + if opts != nil && opts.Threads > 0 { + // chunks the image evenly + chunk := h / opts.Threads + if chunk%2 != 0 { + chunk-- + } + + return &threadedProcessor{ + chromaU: C.int(w * h), + chromaV: C.int(w*h + w*h/4), + chunk: chunk, + processor: &processor, + threads: opts.Threads, + wg: sync.WaitGroup{}, + } + } + return &processor +} + +// Process converts RGBA colorspace into YUV I420 format inside the internal buffer. +// Non-threaded version. +func (yuv *processor) Process(rgba *image.RGBA) []byte { + buf := *yuv.pool.Get().(*[]byte) + C.rgbaToYuv(unsafe.Pointer(&buf[0]), unsafe.Pointer(&rgba.Pix[0]), yuv.ww, C.int(yuv.h)) + return buf +} + +func (yuv *processor) Put(x *[]byte) { yuv.pool.Put(x) } + +// Process converts RGBA colorspace into YUV I420 format inside the internal buffer. +// Threaded version. +// +// We divide the input image into chunks by the number of available CPUs. +// Each chunk should contain 2, 4, 6, etc. rows of the image. +// +// 8x4 CPU (2) +// x x x x x x x x | Coroutine 1 +// x x x x x x x x | Coroutine 1 +// x x x x x x x x | Coroutine 2 +// x x x x x x x x | Coroutine 2 +func (yuv *threadedProcessor) Process(rgba *image.RGBA) []byte { + src := unsafe.Pointer(&rgba.Pix[0]) + buf := *yuv.pool.Get().(*[]byte) + dst := unsafe.Pointer(&buf[0]) + yuv.wg.Add(yuv.threads << 1) + chunk := yuv.w * yuv.chunk + for i := 0; i < yuv.threads; i++ { + pos, hh := C.int(i*chunk), C.int(yuv.chunk) + if i == yuv.threads-1 { + hh = C.int(yuv.h - i*yuv.chunk) + } + go yuv.chroma_(src, dst, pos, hh) + go yuv.luma_(src, dst, pos, hh) + } + yuv.wg.Wait() + return buf +} + +func (yuv *threadedProcessor) luma_(src unsafe.Pointer, dst unsafe.Pointer, pos C.int, hh C.int) { + C.luma(dst, src, pos, yuv.ww, hh) + yuv.wg.Done() +} + +func (yuv *threadedProcessor) chroma_(src unsafe.Pointer, dst unsafe.Pointer, pos C.int, hh C.int) { + C.chroma(dst, src, pos, yuv.chromaU, yuv.chromaV, yuv.ww, hh) + yuv.wg.Done() +} diff --git a/pkg/encoder/yuv/yuv.h b/pkg/worker/encoder/yuv/yuv.h similarity index 61% rename from pkg/encoder/yuv/yuv.h rename to pkg/worker/encoder/yuv/yuv.h index 931b857e..6b39ec52 100644 --- a/pkg/encoder/yuv/yuv.h +++ b/pkg/worker/encoder/yuv/yuv.h @@ -1,24 +1,18 @@ +#ifndef YUV_H__ +#define YUV_H__ -typedef enum { - // It will take each TL pixel for chroma values. - // XO X XO X - // X X X X - TOP_LEFT = 0, - // It will take an average color from the 2x2 pixel group for chroma values. - // X X X X - // O O - // X X X X - BETWEEN_FOUR = 1 -} chromaPos; +#include // Converts RGBA image to YUV (I420) with BT.601 studio color range. -void rgbaToYuv(void *destination, void *source, int width, int height, chromaPos chroma); +void rgbaToYuv(void *destination, void *source, int width, int height); // Converts RGBA image chunk to YUV (I420) chroma with BT.601 studio color range. // pos contains a shift value for chunks. // deu, dev contains constant shifts for U, V planes in the resulting array. // chroma (0, 1) selects chroma estimation algorithm. -void chroma(void *destination, void *source, int pos, int deu, int dev, int width, int height, chromaPos chroma); +void chroma(void *destination, void *source, int pos, int deu, int dev, int width, int height); // Converts RGBA image chunk to YUV (I420) luma with BT.601 studio color range. void luma(void *destination, void *source, int pos, int width, int height); + +#endif diff --git a/pkg/encoder/yuv/yuv_test.go b/pkg/worker/encoder/yuv/yuv_test.go similarity index 87% rename from pkg/encoder/yuv/yuv_test.go rename to pkg/worker/encoder/yuv/yuv_test.go index 977e3a2b..1bbe47b7 100644 --- a/pkg/encoder/yuv/yuv_test.go +++ b/pkg/worker/encoder/yuv/yuv_test.go @@ -1,10 +1,15 @@ package yuv import ( + "fmt" "image" "image/color" + "image/png" + "math" "math/rand" + "os" "reflect" + "runtime" "testing" "time" ) @@ -13,14 +18,14 @@ func TestYuv(t *testing.T) { size1, size2 := 32, 32 for i := 1; i < 100; i++ { img := generateImage(size1, size2, randomColor()) - pc := NewYuvImgProcessor(size1, size2, Threaded(false)) - pct := NewYuvImgProcessor(size1, size2, Threaded(true)) + pc := NewYuvImgProcessor(size1, size2, new(Options)) + pct := NewYuvImgProcessor(size1, size2, &Options{Threads: runtime.NumCPU()}) - pc.Process(img) - pct.Process(img) + a := pc.Process(img) + b := pct.Process(img) - if !reflect.DeepEqual(pc.Get(), pct.Get()) { - t.Fatalf("couldn't convert %v, \n %v \n %v", img.Pix, pc.Get(), pct.Get()) + if !reflect.DeepEqual(a, b) { + t.Fatalf("couldn't convert %v, \n %v \n %v", img.Pix, a, b) } } } @@ -100,12 +105,12 @@ func TestYuvPredefined(t *testing.T) { 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, 94, - 94, 94, 94, 94, 94, 94, 94, 94, 94, 105, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, - 47, 47, 47, 47, 105, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 105, - 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 105, 47, 47, 47, 47, 47, - 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 105, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, - 47, 47, 47, 47, 47, 105, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, - 105, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 105, 47, 47, 47, 47, + 94, 94, 94, 94, 94, 94, 94, 94, 94, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, + 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 106, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 76, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, 47, @@ -115,34 +120,31 @@ func TestYuvPredefined(t *testing.T) { 47, 47, 47, 47, 47, 47, 47, } - pc := NewYuvImgProcessor(32, 32, Threaded(false)) - pct := NewYuvImgProcessor(32, 32, Threaded(true)) + pc := NewYuvImgProcessor(32, 32, new(Options)) + pct := NewYuvImgProcessor(32, 32, &Options{Threads: runtime.NumCPU()}) img := image.NewRGBA(image.Rect(0, 0, 32, 32)) img.Pix = im - pc.Process(img) - pct.Process(img) + a := pc.Process(img) + b := pct.Process(img) - if !reflect.DeepEqual(pc.Get(), should) { - t.Fatalf("couldn't convert with base %v \n %v", pc.Get(), should) + if len(a) != len(b) || len(a) != len(should) || len(b) != len(should) { + t.Fatalf("diffrent size a: %v, b: %v, o: %v", len(a), len(b), len(should)) } - if !reflect.DeepEqual(pct.Get(), should) { - for i := 0; i < len(pct.Get()); i++ { - if pct.Get()[i] != should[i] { - t.Logf("index is: %v", i) - } + for i := 0; i < len(a); i++ { + if a[i] != b[i] || a[i] != should[i] || b[i] != should[i] { + t.Fatalf("diff in %vth, %v != %v != %v \n%v\n%v", i, a[i], b[i], should[i], im, should) } - t.Fatalf("couldn't convert with threaded %v \n %v", pct.Get(), should) } } -func generateImage(w, h int, pixelColor color.RGBA) *image.RGBA { +func generateImage(w, h int, color color.RGBA) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, w, h)) for x := 0; x < w; x++ { for y := 0; y < h; y++ { - img.Set(x, y, randomColor()) + img.Set(x, y, color) } } return img @@ -158,25 +160,39 @@ func randomColor() color.RGBA { } } -func BenchmarkTopLeft(b *testing.B) { - benchmarkConverter(1920, 1080, 0, true, b) +func BenchmarkYUV(b *testing.B) { + cpu := runtime.NumCPU() + tests := []struct { + cpu int + w int + h int + }{ + {cpu: cpu * 0, w: 1920, h: 1080}, + {cpu: cpu * 2, w: 1920, h: 1080}, + {cpu: cpu * 4, w: 1920, h: 1080}, + {cpu: cpu * 0, w: 320, h: 240}, + {cpu: cpu * 2, w: 320, h: 240}, + {cpu: cpu * 4, w: 320, h: 240}, + } + for _, bn := range tests { + b.Run(fmt.Sprintf("%d-%vx%v", bn.cpu, bn.w, bn.h), func(b *testing.B) { + _processYUV(bn.w, bn.h, bn.cpu, b) + }) + } } -func BenchmarkBetweenFour(b *testing.B) { - benchmarkConverter(1920, 1080, 1, true, b) -} +func BenchmarkYUVReference(b *testing.B) { _processYUV(1920, 1080, 0, b) } -func BenchmarkBetweenFourNonThreaded(b *testing.B) { - benchmarkConverter(1920, 1080, 1, false, b) -} - -func benchmarkConverter(w, h int, chroma ChromaPos, threaded bool, b *testing.B) { +func _processYUV(w, h, cpu int, b *testing.B) { b.StopTimer() - pc := NewYuvImgProcessor(w, h, ChromaP(chroma), Threaded(threaded)) + r1 := rand.New(rand.NewSource(int64(1))).Float32() + r2 := rand.New(rand.NewSource(int64(2))).Float32() - image1 := genTestImage(w, h, rand.New(rand.NewSource(int64(1))).Float32()) - image2 := genTestImage(w, h, rand.New(rand.NewSource(int64(2))).Float32()) + pc := NewYuvImgProcessor(w, h, &Options{Threads: cpu}) + + image1 := genTestImage(w, h, r1) + image2 := genTestImage(w, h, r2) for i := 0; i < b.N; i++ { im := image1 @@ -188,6 +204,7 @@ func benchmarkConverter(w, h int, chroma ChromaPos, threaded bool, b *testing.B) b.StopTimer() b.SetBytes(int64(len(im.Pix))) } + b.ReportAllocs() } func genTestImage(w, h int, seed float32) *image.RGBA { @@ -200,3 +217,74 @@ func genTestImage(w, h int, seed float32) *image.RGBA { } return img } + +func TestGen24bitFull(t *testing.T) { + t.Skip() + const tau = 2 * math.Pi + const deg = 3 * math.Pi / 2 + //const depth = 1 << 24 + var wh = 255 //int(math.Sqrt(depth)) + + img := image.NewRGBA(image.Rectangle{Max: image.Point{X: wh, Y: wh}}) + + centerX, centerY := wh/2, wh/2 + radius := centerX + //if centerY < radius { + // radius = centerY + //} + + for y := 0; y < wh; y++ { + dy := float64(y - centerY) + for x := 0; x < wh; x++ { + dx := float64(x - centerX) + dist := math.Sqrt(dx*dx + dy*dy) + if dist <= float64(radius) { + hue := (math.Atan2(dx, dy) + deg) / tau + r, g, b := hsb2rgb(hue, linear(0, float64(centerX), dist), 1) + img.Set(x, y, color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) + } + } + } + + f, err := os.Create("outimage.png") + if err != nil { + // Handle error + } + defer func() { _ = f.Close() }() + + // Encode to `PNG` with `DefaultCompression` level + // then save to file + err = png.Encode(f, img) + if err != nil { + // Handle error + } +} + +func linear(a, b, x float64) float64 { return (x - a) / (b - a) } + +func hsb2rgb(hue, s, bri float64) (r, g, b int) { + u := int(bri*255 + 0.5) + if s == 0 { + return u, u, u + } + h := (hue - math.Floor(hue)) * 6 + f := h - math.Floor(h) + p := int(bri*(1-s)*255 + 0.5) + q := int(bri*(1-s*f)*255 + 0.5) + t := int(bri*(1-s*(1-f))*255 + 0.5) + switch int(h) { + case 0: + r, g, b = u, t, p + case 1: + r, g, b = q, u, p + case 2: + r, g, b = p, u, t + case 3: + r, g, b = p, q, u + case 4: + r, g, b = t, p, u + case 5: + r, g, b = u, p, q + } + return +} diff --git a/pkg/worker/handlers.go b/pkg/worker/handlers.go deleted file mode 100644 index 56797c57..00000000 --- a/pkg/worker/handlers.go +++ /dev/null @@ -1,233 +0,0 @@ -package worker - -import ( - "context" - "encoding/base64" - "encoding/json" - "log" - "net/url" - "os" - "time" - - "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "github.com/giongto35/cloud-game/v2/pkg/cws/api" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/manager/remotehttp" - "github.com/giongto35/cloud-game/v2/pkg/games" - "github.com/giongto35/cloud-game/v2/pkg/network/websocket" - "github.com/giongto35/cloud-game/v2/pkg/service" - "github.com/giongto35/cloud-game/v2/pkg/storage" - "github.com/giongto35/cloud-game/v2/pkg/webrtc" - "github.com/giongto35/cloud-game/v2/pkg/worker/room" - "github.com/rs/xid" -) - -type Handler struct { - service.RunnableService - - address string - // Client that connects to coordinator - oClient *CoordinatorClient - cfg worker.Config - // Rooms map : RoomID -> Room - rooms map[string]*room.Room - // global ID of the current server - serverID string - // onlineStorage is client accessing to online storage (GCP) - onlineStorage storage.CloudStorage - // sessions handles all sessions server is handler (key is sessionID) - sessions map[string]*Session -} - -func NewHandler(conf worker.Config, address string) *Handler { - createOfflineStorage(conf.Emulator.Storage) - onlineStorage := initCloudStorage(conf) - return &Handler{ - address: address, - cfg: conf, - onlineStorage: onlineStorage, - rooms: map[string]*room.Room{}, - sessions: map[string]*Session{}, - } -} - -// Run starts a Handler running logic -func (h *Handler) Run() { - coordinatorAddress := h.cfg.Worker.Network.CoordinatorAddress - for { - conn, err := newCoordinatorConnection(coordinatorAddress, h.cfg.Worker, h.address) - if err != nil { - log.Printf("Cannot connect to coordinator. %v Retrying...", err) - time.Sleep(time.Second) - continue - } - log.Printf("[worker] connected to: %v", coordinatorAddress) - - h.oClient = conn - go h.oClient.Heartbeat() - h.routes() - h.oClient.Listen() - // If cannot listen, reconnect to coordinator - } -} - -func (h *Handler) Shutdown(context.Context) error { return nil } - -func (h *Handler) Prepare() { - if !h.cfg.Emulator.Libretro.Cores.Repo.Sync { - return - } - - log.Printf("Starting Libretro cores sync...") - coreManager := remotehttp.NewRemoteHttpManager(h.cfg.Emulator.Libretro) - // make a dir for cores - dir := coreManager.Conf.GetCoresStorePath() - if err := os.MkdirAll(dir, os.ModeDir); err != nil { - log.Printf("error: couldn't make %v directory", dir) - return - } - if err := coreManager.Sync(); err != nil { - log.Printf("error: cores sync has failed, %v", err) - } -} - -func initCloudStorage(conf worker.Config) storage.CloudStorage { - var st storage.CloudStorage - var err error - switch conf.Storage.Provider { - case "oracle": - st, err = storage.NewOracleDataStorageClient(conf.Storage.Key) - case "coordinator": - default: - st, _ = storage.NewNoopCloudStorage() - } - if err != nil { - log.Printf("Switching to noop cloud save") - st, _ = storage.NewNoopCloudStorage() - } - return st -} - -func newCoordinatorConnection(host string, conf worker.Worker, addr string) (*CoordinatorClient, error) { - scheme := "ws" - if conf.Network.Secure { - scheme = "wss" - } - address := url.URL{Scheme: scheme, Host: host, Path: conf.Network.Endpoint} - - req, err := MakeConnectionRequest(conf, addr) - if req != "" && err == nil { - address.RawQuery = "data=" + req - } - - conn, err := websocket.Connect(address) - if err != nil { - return nil, err - } - return NewCoordinatorClient(conn), nil -} - -func MakeConnectionRequest(w worker.Worker, address string) (string, error) { - addr := w.GetPingAddr(address) - req := api.ConnectionRequest{ - Addr: addr.Hostname(), - IsHTTPS: w.Server.Https, - PingURL: addr.String(), - Port: w.GetPort(address), - Tag: w.Tag, - Zone: w.Network.Zone, - Xid: xid.New().String(), - } - rez, err := json.Marshal(req) - if err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(rez), nil -} - -func (h *Handler) GetCoordinatorClient() *CoordinatorClient { - return h.oClient -} - -// detachPeerConn detaches a peerconnection from the current room. -func (h *Handler) detachPeerConn(pc *webrtc.WebRTC) { - log.Printf("[worker] closing peer connection") - gameRoom := h.getRoom(pc.RoomID) - if gameRoom == nil || gameRoom.IsEmpty() { - return - } - gameRoom.RemoveSession(pc) - if gameRoom.IsEmpty() { - log.Printf("[worker] closing an empty room") - gameRoom.Close() - pc.InputChannel <- []byte{0xFF, 0xFF} - close(pc.InputChannel) - } -} - -func (h *Handler) getRoom(roomID string) (r *room.Room) { - r, ok := h.rooms[roomID] - if !ok { - return nil - } - return -} - -// getRoom returns session from sessionID -func (h *Handler) getSession(sessionID string) *Session { - session, ok := h.sessions[sessionID] - if !ok { - return nil - } - - return session -} - -// detachRoom detach room from Handler -func (h *Handler) detachRoom(roomID string) { - delete(h.rooms, roomID) -} - -// createNewRoom creates a new room -// Return nil in case of room is existed -func (h *Handler) createNewRoom(game games.GameMetadata, recUser string, rec bool, roomID string) *room.Room { - // If the roomID doesn't have any running sessions (room was closed) - // we spawn a new room - if !h.isRoomBusy(roomID) { - newRoom := room.NewRoom(roomID, game, recUser, rec, h.onlineStorage, h.cfg) - // TODO: Might have race condition (and it has (:) - h.rooms[newRoom.ID] = newRoom - return newRoom - } - return nil -} - -// isRoomBusy check if there is any running sessions. -// TODO: If we remove sessions from room anytime a session is closed, -// we can check if the sessions list is empty or not. -func (h *Handler) isRoomBusy(roomID string) bool { - if roomID == "" { - return false - } - // If no roomID is registered - r, ok := h.rooms[roomID] - if !ok { - return false - } - return r.IsRunningSessions() -} - -func (h *Handler) Close() { - if h.oClient != nil { - h.oClient.Close() - } - for _, r := range h.rooms { - r.Close() - } -} - -func createOfflineStorage(path string) { - log.Printf("Set storage: %v", path) - if err := os.MkdirAll(path, 0755); err != nil { - log.Println("Failed to create offline storage, err: ", err) - } -} diff --git a/pkg/worker/http.go b/pkg/worker/http.go deleted file mode 100644 index f7de3fa8..00000000 --- a/pkg/worker/http.go +++ /dev/null @@ -1,31 +0,0 @@ -package worker - -import ( - "net/http" - - "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "github.com/giongto35/cloud-game/v2/pkg/network/httpx" -) - -func NewHTTPServer(conf worker.Config) (*httpx.Server, error) { - srv, err := httpx.NewServer( - conf.Worker.GetAddr(), - func(*httpx.Server) http.Handler { - h := http.NewServeMux() - h.HandleFunc(conf.Worker.Network.PingEndpoint, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - _, _ = w.Write([]byte{0x65, 0x63, 0x68, 0x6f}) // echo - }) - return h - }, - httpx.WithServerConfig(conf.Worker.Server), - // no need just for one route - httpx.HttpsRedirect(false), - httpx.WithPortRoll(true), - httpx.WithZone(conf.Worker.Network.Zone), - ) - if err != nil { - return nil, err - } - return srv, nil -} diff --git a/pkg/worker/internalhandlers.go b/pkg/worker/internalhandlers.go deleted file mode 100644 index 9e5a795a..00000000 --- a/pkg/worker/internalhandlers.go +++ /dev/null @@ -1,314 +0,0 @@ -package worker - -import ( - "log" - "strconv" - - webrtcConfig "github.com/giongto35/cloud-game/v2/pkg/config/webrtc" - "github.com/giongto35/cloud-game/v2/pkg/cws" - "github.com/giongto35/cloud-game/v2/pkg/cws/api" - "github.com/giongto35/cloud-game/v2/pkg/games" - "github.com/giongto35/cloud-game/v2/pkg/webrtc" - "github.com/giongto35/cloud-game/v2/pkg/worker/room" -) - -func (h *Handler) handleServerId() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Printf("[worker] new id: %s", resp.Data) - h.serverID = resp.Data - return - } -} - -func (h *Handler) handleTerminateSession() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a terminate session ", resp.SessionID) - session := h.getSession(resp.SessionID) - if session != nil { - session.Close() - delete(h.sessions, resp.SessionID) - h.detachPeerConn(session.peerconnection) - } else { - log.Printf("Error: No session for ID: %s\n", resp.SessionID) - } - - return cws.EmptyPacket - } -} - -func (h *Handler) handleInitWebrtc() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a request to createOffer from browser via coordinator") - - peerconnection, err := webrtc.NewWebRTC(webrtcConfig.Config{Encoder: h.cfg.Encoder, Webrtc: h.cfg.Webrtc}) - if err != nil { - log.Println("error: Cannot create new WebRTC connection", err) - return cws.EmptyPacket - } - localSession, err := peerconnection.StartClient( - // send back candidate string to browser - func(cd string) { h.oClient.Send(api.IceCandidatePacket(cd, resp.SessionID), nil) }, - ) - - // localSession, err := peerconnection.StartClient(initPacket.IsMobile, iceCandidates[resp.SessionID]) - // h.peerconnections[resp.SessionID] = peerconnection - - // Create new sessions when we have new peerconnection initialized - session := &Session{ - peerconnection: peerconnection, - } - h.sessions[resp.SessionID] = session - log.Println("Start peerconnection", resp.SessionID) - - if err != nil { - log.Println("Error: Cannot create new webrtc session", err) - return cws.EmptyPacket - } - - return cws.WSPacket{ID: "offer", Data: localSession} - } -} - -func (h *Handler) handleAnswer() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received answer SDP from browser") - session := h.getSession(resp.SessionID) - if session != nil { - peerconnection := session.peerconnection - err := peerconnection.SetRemoteSDP(resp.Data) - if err != nil { - log.Printf("Error: cannot set RemoteSDP of client: %v beacuse %v", resp.SessionID, err) - } - } else { - log.Printf("Error: No session for ID: %s\n", resp.SessionID) - } - return cws.EmptyPacket - } -} - -func (h *Handler) handleIceCandidate() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received remote Ice Candidate from browser") - session := h.getSession(resp.SessionID) - - if session != nil { - peerconnection := session.peerconnection - - err := peerconnection.AddCandidate(resp.Data) - if err != nil { - log.Println("Error: Cannot add IceCandidate of client: " + resp.SessionID) - } - } else { - log.Printf("Error: No session for ID: %s\n", resp.SessionID) - } - - return cws.EmptyPacket - } -} - -func (h *Handler) handleGameStart() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a start request from coordinator") - session := h.getSession(resp.SessionID) - if session == nil { - log.Printf("error: no session with id: %s", resp.SessionID) - return cws.EmptyPacket - } - - // TODO: Standardize for all types of packet. Make WSPacket generic - rom := api.GameStartCall{} - if err := rom.From(resp.Data); err != nil { - return cws.EmptyPacket - } - game := games.GameMetadata{Name: rom.Name, Type: rom.Type, Base: rom.Base, Path: rom.Path} - - // recording - if h.cfg.Recording.Enabled { - log.Printf("RECORD: %v %v", rom.Record, rom.RecordUser) - } else { - log.Printf("RECORD OFF") - } - - room := h.startGameHandler(game, rom.RecordUser, rom.Record, resp.RoomID, resp.PlayerIndex, session.peerconnection) - session.RoomID = room.ID - // TODO: can data race (and it does) - h.rooms[room.ID] = room - return cws.WSPacket{ID: api.GameStart, RoomID: room.ID} - } -} - -func (h *Handler) handleGameQuit() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a quit request from coordinator") - session := h.getSession(resp.SessionID) - - if session != nil { - room := h.getRoom(session.RoomID) - // Defensive coding, check if the peerconnection is in room - if room.IsPCInRoom(session.peerconnection) { - h.detachPeerConn(session.peerconnection) - } - } else { - log.Printf("Error: No session for ID: %s\n", resp.SessionID) - } - - return cws.EmptyPacket - } -} - -func (h *Handler) handleGameSave() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a save game from coordinator") - log.Println("RoomID:", resp.RoomID) - req.ID = api.GameSave - req.Data = "ok" - if resp.RoomID != "" { - room := h.getRoom(resp.RoomID) - if room == nil { - return - } - err := room.SaveGame() - if err != nil { - log.Printf("error, cannot save game: %v", err) - req.Data = "error" - } - } else { - req.Data = "error" - } - - return req - } -} - -func (h *Handler) handleGameLoad() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a load game from coordinator") - log.Println("Loading game state") - req.ID = api.GameLoad - req.Data = "ok" - if resp.RoomID != "" { - room := h.getRoom(resp.RoomID) - err := room.LoadGame() - if err != nil { - log.Println("[!] Cannot load game state: ", err) - req.Data = "error" - } - } else { - req.Data = "error" - } - - return req - } -} - -func (h *Handler) handleGamePlayerSelect() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received an update player index event from coordinator") - req.ID = api.GamePlayerSelect - - room := h.getRoom(resp.RoomID) - session := h.getSession(resp.SessionID) - idx, err := strconv.Atoi(resp.Data) - log.Printf("Got session %v and room %v", session, room) - - if room != nil && session != nil && err == nil { - room.UpdatePlayerIndex(session.peerconnection, idx) - req.Data = strconv.Itoa(idx) - } else { - req.Data = "error" - } - - return req - } -} - -func (h *Handler) handleGameMultitap() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Println("Received a multitap toggle from coordinator") - req.ID = api.GameMultitap - req.Data = "ok" - if resp.RoomID != "" { - room := h.getRoom(resp.RoomID) - err := room.ToggleMultitap() - if err != nil { - log.Println("[!] Could not toggle multitap state: ", err) - req.Data = "error" - } - } else { - req.Data = "error" - } - - return req - } -} - -func (h *Handler) handleGameRecording() cws.PacketHandler { - return func(resp cws.WSPacket) (req cws.WSPacket) { - log.Printf("Received recording request from coordinator: %v", resp) - - req.ID = api.GameRecording - req.Data = "ok" - - if !h.cfg.Recording.Enabled { - req.Data = "error" - return req - } - - if resp.RoomID != "" { - r := h.getRoom(resp.RoomID) - if r == nil { - req.Data = "error" - return req - } - - request := api.GameRecordingRequest{} - if err := request.From(resp.Data); err != nil { - req.Data = "error" - return req - } - - r.ToggleRecording(request.Active, request.User) - } else { - req.Data = "error" - } - - return req - } -} - -// startGameHandler starts a game if roomID is given, if not create new room -func (h *Handler) startGameHandler(game games.GameMetadata, recUser string, rec bool, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC) *room.Room { - log.Printf("Loading game: %v\n", game.Name) - // If we are connecting to coordinator, request corresponding serverID based on roomID - // TODO: check if existedRoomID is in the current server - room := h.getRoom(existedRoomID) - // If room is not running - if room == nil { - log.Println("Got Room from local ", room, " ID: ", existedRoomID) - // Create new room and update player index - room = h.createNewRoom(game, recUser, rec, existedRoomID) - room.UpdatePlayerIndex(peerconnection, playerIndex) - - // Wait for done signal from room - go func() { - <-room.Done - h.detachRoom(room.ID) - // send signal to coordinator that the room is closed, coordinator will remove that room - h.oClient.Send(api.CloseRoomPacket(room.ID), nil) - }() - } - - // Attach peerconnection to room. If PC is already in room, don't detach - log.Println("Is PC in room", room.IsPCInRoom(peerconnection)) - if !room.IsPCInRoom(peerconnection) { - h.detachPeerConn(peerconnection) - room.AddConnectionToRoom(peerconnection) - } - - // Register room to coordinator if we are connecting to coordinator - if room != nil && h.oClient != nil { - h.oClient.Send(api.RegisterRoomPacket(room.ID), nil) - } - - return room -} diff --git a/pkg/worker/media.go b/pkg/worker/media.go new file mode 100644 index 00000000..6767047e --- /dev/null +++ b/pkg/worker/media.go @@ -0,0 +1,170 @@ +package worker + +import ( + "sync" + "time" + + conf "github.com/giongto35/cloud-game/v2/pkg/config/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/h264" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/opus" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/vpx" + webrtc "github.com/pion/webrtc/v3/pkg/media" +) + +var ( + encoderOnce = sync.Once{} + opusCoder *opus.Encoder + samplePool sync.Pool + audioPool = sync.Pool{New: func() any { b := make([]int16, 3000); return &b }} +) + +const ( + audioChannels = 2 + audioCodec = "opus" + audioFrequency = 48000 +) + +// Buffer is a simple non-thread safe ring buffer for audio samples. +// It should be used for 16bit PCM (LE interleaved) data. +type ( + Buffer struct { + s Samples + wi int + } + OnFull func(s Samples) + Samples []int16 +) + +func NewBuffer(numSamples int) Buffer { return Buffer{s: make(Samples, numSamples)} } + +// Write fills the buffer with data calling a callback function when +// the internal buffer fills out. +// +// Consider two cases: +// +// 1. Underflow, when the length of written data is less than the buffer's available space. +// 2. Overflow, when the length exceeds the current available buffer space. +// In the both cases we overwrite any previous values in the buffer and move the internal +// write pointer on the length of written data. +// In the first case we won't call the callback, but it will be called every time +// when the internal buffer overflows until all samples are read. +func (b *Buffer) Write(s Samples, onFull OnFull) (r int) { + for r < len(s) { + w := copy(b.s[b.wi:], s[r:]) + r += w + b.wi += w + if b.wi == len(b.s) { + b.wi = 0 + if onFull != nil { + onFull(b.s) + } + } + } + return +} + +// GetFrameSizeFor calculates audio frame size, i.e. 48k*frame/1000*2 +func GetFrameSizeFor(hz int, frame int) int { return hz * frame / 1000 * audioChannels } + +func (r *Room) initAudio(frequency int, conf conf.Audio) { + buf := NewBuffer(GetFrameSizeFor(frequency, conf.Frame)) + resample, frameLen := frequency != audioFrequency, 0 + if resample { + frameLen = GetFrameSizeFor(audioFrequency, conf.Frame) + } + + encoderOnce.Do(func() { + enc, err := opus.NewEncoder(audioFrequency) + if err != nil { + r.log.Fatal().Err(err).Msg("couldn't create audio encoder") + } + opusCoder = enc + }) + if err := opusCoder.Reset(); err != nil { + r.log.Error().Err(err).Msgf("opus state reset fail") + } + r.log.Debug().Msgf("Opus: %v", opusCoder.GetInfo()) + + dur := time.Duration(conf.Frame) * time.Millisecond + + fn := func(s Samples) { + if resample { + s = ResampleStretchNew(s, frameLen) + } + f, err := opusCoder.Encode(s) + audioPool.Put((*[]int16)(&s)) + if err == nil { + r.handleSample(f, dur, func(u *Session, s *webrtc.Sample) { _ = u.SendAudio(s) }) + } + } + r.emulator.SetAudio(func(samples *emulator.GameAudio) { buf.Write(*samples.Data, fn) }) +} + +// initVideo processes videoFrames images with an encoder (codec) then pushes the result to WebRTC. +func (r *Room) initVideo(width, height int, conf conf.Video) { + var enc encoder.Encoder + var err error + + r.log.Info().Msgf("Video codec: %v", conf.Codec) + if conf.Codec == string(encoder.H264) { + r.log.Debug().Msgf("x264: build v%v", h264.LibVersion()) + enc, err = h264.NewEncoder(width, height, &h264.Options{ + Crf: conf.H264.Crf, + Tune: conf.H264.Tune, + Preset: conf.H264.Preset, + Profile: conf.H264.Profile, + LogLevel: int32(conf.H264.LogLevel), + }) + } else { + enc, err = vpx.NewEncoder(width, height, &vpx.Options{ + Bitrate: conf.Vpx.Bitrate, + KeyframeInt: conf.Vpx.KeyframeInterval, + }) + } + + if err != nil { + r.log.Error().Err(err).Msg("couldn't create a video encoder") + return + } + + r.vEncoder = encoder.NewVideoEncoder(enc, width, height, conf.Concurrency, r.log) + + r.emulator.SetVideo(func(frame *emulator.GameFrame) { + if fr := r.vEncoder.Encode(frame.Data); fr != nil { + r.handleSample(fr, frame.Duration, func(u *Session, s *webrtc.Sample) { _ = u.SendVideo(s) }) + } + }) +} + +func (r *Room) handleSample(b []byte, d time.Duration, fn func(*Session, *webrtc.Sample)) { + sample, _ := samplePool.Get().(*webrtc.Sample) + if sample == nil { + sample = new(webrtc.Sample) + } + sample.Data = b + sample.Duration = d + r.users.ForEach(func(u *Session) { + if u.IsConnected() { + fn(u, sample) + } + }) + samplePool.Put(sample) +} + +// ResampleStretchNew does a simple stretching of audio samples. +// something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] +func ResampleStretchNew(pcm []int16, size int) []int16 { + out := (*audioPool.Get().(*[]int16))[:size] + n := len(pcm) + ratio := float32(size) / float32(n) + for i, l, r := 0, 0, 0; i < n; i += 2 { + l, r = r, int(float32((i+2)>>1)*ratio)<<1 + for j := l; j < r-1; j += 2 { + out[j] = pcm[i] + out[j+1] = pcm[i+1] + } + } + return out +} diff --git a/pkg/worker/media_test.go b/pkg/worker/media_test.go new file mode 100644 index 00000000..8ec9c270 --- /dev/null +++ b/pkg/worker/media_test.go @@ -0,0 +1,214 @@ +package worker + +import ( + "fmt" + "image" + "math/rand" + "reflect" + "testing" + "time" + + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/h264" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder/vpx" +) + +var l = logger.New(false) + +func TestEncoders(t *testing.T) { + tests := []struct { + n int + w, h int + codec encoder.VideoCodec + frames int + }{ + {n: 3, w: 1920, h: 1080, codec: encoder.H264, frames: 60}, + {n: 3, w: 1920, h: 1080, codec: encoder.VP8, frames: 60}, + } + + for _, test := range tests { + a := genTestImage(test.w, test.h, rand.New(rand.NewSource(int64(1))).Float32()) + b := genTestImage(test.w, test.h, rand.New(rand.NewSource(int64(2))).Float32()) + for i := 0; i < test.n; i++ { + run(test.w, test.h, test.codec, test.frames, a, b, t) + } + } +} + +func BenchmarkH264(b *testing.B) { run(1920, 1080, encoder.H264, b.N, nil, nil, b) } +func BenchmarkVP8(b *testing.B) { run(1920, 1080, encoder.VP8, b.N, nil, nil, b) } + +func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RGBA, backend testing.TB) { + var enc encoder.Encoder + if cod == encoder.H264 { + enc, _ = h264.NewEncoder(w, h, nil) + } else { + enc, _ = vpx.NewEncoder(w, h, nil) + } + + ve := encoder.NewVideoEncoder(enc, w, h, 8, l) + defer ve.Stop() + + if a == nil { + a = genTestImage(w, h, rand.New(rand.NewSource(int64(1))).Float32()) + } + if b == nil { + b = genTestImage(w, h, rand.New(rand.NewSource(int64(2))).Float32()) + } + + for i := 0; i < count; i++ { + im := a + if i%2 == 0 { + im = b + } + out := ve.Encode(im) + if out == nil { + backend.Fatalf("encoder closed abnormally") + } + } +} + +func genTestImage(w, h int, seed float32) *image.RGBA { + img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + i := img.PixOffset(x, y) + s := img.Pix[i : i+4 : i+4] + s[0] = uint8(seed * 255) + s[1] = uint8(seed * 255) + s[2] = uint8(seed * 255) + s[3] = 0xff + } + } + return img +} + +func TestResampleStretch(t *testing.T) { + type args struct { + pcm []int16 + size int + } + tests := []struct { + name string + args args + want []int16 + }{ + //1764:1920 + { + name: "", + args: args{ + pcm: gen(1764), + size: 1920, + }, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rez2 := ResampleStretchNew(tt.args.pcm, tt.args.size) + + if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || + rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || + rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { + t.Logf("%v\n%v", tt.args.pcm, rez2) + t.Errorf("2nd is wrong (2)") + } + }) + } +} + +func BenchmarkResampler(b *testing.B) { + tests := []struct { + name string + fn func(pcm []int16, size int) []int16 + }{ + {name: "new", fn: ResampleStretchNew}, + } + pcm := gen(1764) + size := 1920 + for _, bn := range tests { + b.Run(fmt.Sprintf("%v", bn.name), func(b *testing.B) { + for i := 0; i < b.N; i++ { + bn.fn(pcm, size) + } + }) + } +} + +func gen(l int) []int16 { + rand.Seed(time.Now().Unix()) + + nums := make([]int16, l) + for i := range nums { + nums[i] = int16(rand.Intn(10)) + } + //for i := len(nums) / 2; i < len(nums)/2+42; i++ { + // nums[i] = 0 + //} + + return nums +} + +type bufWrite struct { + sample int16 + len int +} + +func TestBufferWrite(t *testing.T) { + tests := []struct { + bufLen int + writes []bufWrite + expect Samples + }{ + { + bufLen: 20, + writes: []bufWrite{ + {sample: 1, len: 10}, + {sample: 2, len: 20}, + {sample: 3, len: 30}, + }, + expect: Samples{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, + }, + { + bufLen: 11, + writes: []bufWrite{ + {sample: 1, len: 3}, + {sample: 2, len: 18}, + {sample: 3, len: 2}, + }, + expect: Samples{3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3}, + }, + } + + for _, test := range tests { + var lastResult Samples + buf := NewBuffer(test.bufLen) + for _, w := range test.writes { + buf.Write(samplesOf(w.sample, w.len), func(s Samples) { lastResult = s }) + } + if !reflect.DeepEqual(test.expect, lastResult) { + t.Errorf("not expted buffer, %v != %v", lastResult, test.expect) + } + } +} + +func BenchmarkBufferWrite(b *testing.B) { + fn := func(_ Samples) {} + l := 1920 + buf := NewBuffer(l) + samples1 := samplesOf(1, l/2) + samples2 := samplesOf(2, l*2) + for i := 0; i < b.N; i++ { + buf.Write(samples1, fn) + buf.Write(samples2, fn) + } +} + +func samplesOf(v int16, len int) (s Samples) { + s = make(Samples, len) + for i := range s { + s[i] = v + } + return +} diff --git a/pkg/recorder/draw.go b/pkg/worker/recorder/draw.go similarity index 100% rename from pkg/recorder/draw.go rename to pkg/worker/recorder/draw.go diff --git a/pkg/worker/recorder/ffmpegmux.go b/pkg/worker/recorder/ffmpegmux.go new file mode 100644 index 00000000..4869ef71 --- /dev/null +++ b/pkg/worker/recorder/ffmpegmux.go @@ -0,0 +1,63 @@ +package recorder + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const demuxFile = "input.txt" + +// createFfmpegMuxFile makes FFMPEG concat demuxer file. +// +// ffmpeg concat demuxer, see: https://ffmpeg.org/ffmpeg-formats.html#concat +// example: +// +// ffmpeg -f concat -i input.txt \ +// -ac 2 -channel_layout stereo -i audio.wav \ +// -b:a 192K -crf 23 -vf fps=30 -pix_fmt yuv420p \ +// out.mp4 +func createFfmpegMuxFile(dir string, fPattern string, frameTimes []time.Duration, opts Options) (er error) { + demux, err := newFile(dir, demuxFile) + if err != nil { + return err + } + defer func() { er = demux.Close() }() + _, err = demux.WriteString( + fmt.Sprintf("ffconcat version 1.0\n# v: 1\n# date: %v\n# game: %v\n# fps: %v\n# freq (hz): %v\n\n", + time.Now().Format("20060102"), opts.Game, opts.Fps, opts.Frequency)) + if err != nil { + return err + } + files, err := os.ReadDir(dir) + if err != nil { + return err + } + i := 0 + sync := opts.Vsync && len(frameTimes) > 0 + ext := filepath.Ext(fPattern) + for _, file := range files { + name := file.Name() + if !strings.HasSuffix(strings.ToLower(name), ext) { + continue + } + dur := 1 / opts.Fps + if sync && i < len(frameTimes) { + dur = frameTimes[i].Seconds() + if dur == 0 { + dur = 1 / opts.Fps + } + i++ + } + inf := fmt.Sprintf("file %v\nduration %f\n", name, dur) + if _, err := demux.WriteString(inf); err != nil { + er = err + } + } + if err = demux.Flush(); err != nil { + er = err + } + return er +} diff --git a/pkg/recorder/file.go b/pkg/worker/recorder/file.go similarity index 100% rename from pkg/recorder/file.go rename to pkg/worker/recorder/file.go diff --git a/pkg/recorder/options.go b/pkg/worker/recorder/options.go similarity index 91% rename from pkg/recorder/options.go rename to pkg/worker/recorder/options.go index e68773ac..9707e171 100644 --- a/pkg/recorder/options.go +++ b/pkg/worker/recorder/options.go @@ -8,10 +8,9 @@ type Options struct { ImageCompressionLevel int Name string Zip bool + Vsync bool } -type Option func(*Options) - type Meta struct { UserName string } diff --git a/pkg/worker/recorder/pngstream.go b/pkg/worker/recorder/pngstream.go new file mode 100644 index 00000000..39fee7bb --- /dev/null +++ b/pkg/worker/recorder/pngstream.go @@ -0,0 +1,74 @@ +package recorder + +import ( + "bytes" + "fmt" + "image" + "image/png" + "log" + "os" + "path/filepath" + "sync" + "sync/atomic" +) + +type pngStream struct { + videoStream + + dir string + e *png.Encoder + id uint32 + wg sync.WaitGroup +} + +const videoFile = "f%07d.png" + +type pool struct{ sync.Pool } + +func pngBuf() *pool { return &pool{sync.Pool{New: func() any { return &png.EncoderBuffer{} }}} } +func (p *pool) Get() *png.EncoderBuffer { return p.Pool.Get().(*png.EncoderBuffer) } +func (p *pool) Put(b *png.EncoderBuffer) { p.Pool.Put(b) } + +func newPngStream(dir string, opts Options) (*pngStream, error) { + return &pngStream{ + dir: dir, + e: &png.Encoder{ + CompressionLevel: png.CompressionLevel(opts.ImageCompressionLevel), + BufferPool: pngBuf(), + }, + }, nil +} + +func (p *pngStream) Close() error { + atomic.StoreUint32(&p.id, 0) + p.wg.Wait() + return nil +} + +func (p *pngStream) Write(data Video) { + fileName := fmt.Sprintf(videoFile, atomic.AddUint32(&p.id, 1)) + p.wg.Add(1) + go p.saveImage(fileName, data.Image) +} + +func (p *pngStream) saveImage(fileName string, img image.Image) { + var buf bytes.Buffer + x, y := (img).Bounds().Dx(), (img).Bounds().Dy() + buf.Grow(x * y * 4) + + if err := p.e.Encode(&buf, img); err != nil { + log.Printf("p err: %v", err) + } else { + file, err := os.Create(filepath.Join(p.dir, fileName)) + if err != nil { + log.Printf("c err: %v", err) + } + if _, err = file.Write(buf.Bytes()); err != nil { + log.Printf("f err: %v", err) + } + if err = file.Close(); err != nil { + log.Printf("fc err: %v", err) + } + } + p.wg.Done() +} diff --git a/pkg/recorder/recorder.go b/pkg/worker/recorder/recorder.go similarity index 57% rename from pkg/recorder/recorder.go rename to pkg/worker/recorder/recorder.go index 465c4c7d..7527d16b 100644 --- a/pkg/recorder/recorder.go +++ b/pkg/worker/recorder/recorder.go @@ -2,7 +2,7 @@ package recorder import ( "image" - "log" + "io" "math/rand" "os" "path/filepath" @@ -11,7 +11,8 @@ import ( "sync" "time" - "github.com/hashicorp/go-multierror" + "github.com/giongto35/cloud-game/v2/pkg/logger" + oss "github.com/giongto35/cloud-game/v2/pkg/os" ) type Recording struct { @@ -19,13 +20,16 @@ type Recording struct { enabled bool - audio AudioStream - video VideoStream + audio audioStream + video videoStream dir string saveDir string meta Meta opts Options + log *logger.Logger + + vsync []time.Duration } // naming regexp @@ -36,24 +40,24 @@ var ( reRand = regexp.MustCompile(`%rand:(\d+)%`) ) -// Stream represent an output stream of the recording. -type Stream interface { - Start() - Stop() error +// stream represent an output stream of the recording. +type stream interface { + io.Closer } -type AudioStream interface { - Stream +type audioStream interface { + stream Write(data Audio) } -type VideoStream interface { - Stream +type videoStream interface { + stream Write(data Video) } type ( Audio struct { - Samples *[]int16 + Samples *[]int16 + Duration time.Duration } Video struct { Image image.Image @@ -61,29 +65,18 @@ type ( } ) -func init() { - rand.Seed(time.Now().UnixNano()) -} +func init() { rand.Seed(time.Now().UnixNano()) } -// NewRecording creates new recorder of the emulator. -// -// FFMPEG: -// -// Example of conversion: -// ffmpeg -r 60 -f concat -i ./recording/psxtest/input.txt \ -// -ac 2 -channel_layout stereo -i ./recording/psxtest/audio.wav \ -// -b:a 192K -crf 23 -pix_fmt yuv420p out.mp4 -func NewRecording(meta Meta, opts Options) *Recording { +// NewRecording creates new media recorder for the emulator. +func NewRecording(meta Meta, log *logger.Logger, opts Options) *Recording { savePath, err := filepath.Abs(opts.Dir) if err != nil { - log.Fatal(err) + log.Error().Err(err).Send() } - if _, err := os.Stat(savePath); os.IsNotExist(err) { - if err = os.Mkdir(savePath, os.ModeDir); err != nil { - log.Fatal(err) - } + if err := oss.CheckCreateDir(savePath); err != nil { + log.Error().Err(err).Send() } - return &Recording{dir: savePath, meta: meta, opts: opts} + return &Recording{dir: savePath, meta: meta, opts: opts, log: log, vsync: []time.Duration{}} } func (r *Recording) Start() { @@ -94,50 +87,50 @@ func (r *Recording) Start() { r.saveDir = parseName(r.opts.Name, r.opts.Game, r.meta.UserName) path := filepath.Join(r.dir, r.saveDir) - log.Printf("[recording] path will be [%v]", path) + r.log.Info().Msgf("[recording] path will be [%v]", path) - if _, err := os.Stat(path); os.IsNotExist(err) { - if err = os.Mkdir(path, os.ModeDir); err != nil { - log.Fatal(err) - } + if err := oss.CheckCreateDir(path); err != nil { + r.log.Fatal().Err(err) } - audio, err := NewWavStream(path, r.opts) + audio, err := newWavStream(path, r.opts) if err != nil { - log.Fatal(err) + r.log.Fatal().Err(err) } r.audio = audio - video, err := NewFfmpegStream(path, r.opts) + video, err := newPngStream(path, r.opts) if err != nil { - log.Fatal(err) + r.log.Fatal().Err(err) } r.video = video - - go r.audio.Start() - go r.video.Start() } -func (r *Recording) Stop() error { - var result *multierror.Error +func (r *Recording) Stop() (err error) { r.Lock() defer r.Unlock() r.enabled = false - result = multierror.Append(result, r.audio.Stop()) - result = multierror.Append(result, r.video.Stop()) - if result.ErrorOrNil() == nil && r.opts.Zip && r.saveDir != "" { + err = r.audio.Close() + err = r.video.Close() + + path := filepath.Join(r.dir, r.saveDir) + // FFMPEG + err = createFfmpegMuxFile(path, videoFile, r.vsync, r.opts) + + if err == nil && r.opts.Zip && r.saveDir != "" { src := filepath.Join(r.dir, r.saveDir) dst := filepath.Join(src, "..", r.saveDir) go func() { if err := compress(src, dst); err != nil { - log.Printf("error during result compress, %v", result) + r.log.Error().Err(err).Msg("error during result compress") return } if err := os.RemoveAll(src); err != nil { - log.Printf("error during result compress, %v", result) + r.log.Error().Err(err).Msg("error during result compress") } }() } - return result.ErrorOrNil() + r.vsync = []time.Duration{} + return err } func (r *Recording) Set(enable bool, user string) { @@ -150,8 +143,9 @@ func (r *Recording) Set(enable bool, user string) { if r.enabled && !enable { r.Unlock() if err := r.Stop(); err != nil { - log.Printf("failed to stop recording, %v", err) + r.log.Error().Err(err).Msg("failed to stop recording") } + r.log.Debug().Msg("recording has stopped") r.Lock() } } @@ -167,7 +161,13 @@ func (r *Recording) Enabled() bool { } func (r *Recording) WriteVideo(frame Video) { r.video.Write(frame) } -func (r *Recording) WriteAudio(audio Audio) { r.audio.Write(audio) } + +func (r *Recording) WriteAudio(audio Audio) { + r.audio.Write(audio) + r.Lock() + r.vsync = append(r.vsync, audio.Duration) + r.Unlock() +} func parseName(name, game, user string) (out string) { if d := reDate.FindStringSubmatch(name); d != nil { diff --git a/pkg/recorder/recorder_test.go b/pkg/worker/recorder/recorder_test.go similarity index 90% rename from pkg/recorder/recorder_test.go rename to pkg/worker/recorder/recorder_test.go index 26ac88dc..81f51320 100644 --- a/pkg/recorder/recorder_test.go +++ b/pkg/worker/recorder/recorder_test.go @@ -4,7 +4,6 @@ import ( "fmt" "image" "image/color" - "io/ioutil" "log" "math/rand" "os" @@ -12,10 +11,12 @@ import ( "sync/atomic" "testing" "time" + + "github.com/giongto35/cloud-game/v2/pkg/logger" ) func TestName(t *testing.T) { - dir, err := ioutil.TempDir("", "rec_test_") + dir, err := os.MkdirTemp("", "rec_test_") if err != nil { log.Fatal(err) } @@ -27,6 +28,7 @@ func TestName(t *testing.T) { recorder := NewRecording( Meta{UserName: "test"}, + logger.Default(), Options{ Dir: dir, Fps: 60, @@ -51,7 +53,7 @@ func TestName(t *testing.T) { imgWg.Done() }() go func() { - recorder.WriteAudio(Audio{&[]int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1}}) + recorder.WriteAudio(Audio{&[]int16{0, 0, 0, 0, 0, 1, 11, 11, 11, 1}, 1}) audioWg.Done() }() } @@ -70,14 +72,14 @@ func BenchmarkNewRecording100x100(b *testing.B) { func BenchmarkNewRecording320x240_compressed(b *testing.B) { benchmarkRecorder(320, 240, 0, b) } -func BenchmarkNewRecording320x240_nocompression(b *testing.B) { +func BenchmarkNewRecording320x240_nocompress(b *testing.B) { benchmarkRecorder(320, 240, -1, b) } func benchmarkRecorder(w, h int, comp int, b *testing.B) { b.StopTimer() - dir, err := ioutil.TempDir("", "rec_bench_") + dir, err := os.MkdirTemp("", "rec_bench_") if err != nil { b.Fatal(err) } @@ -99,6 +101,7 @@ func benchmarkRecorder(w, h int, comp int, b *testing.B) { recorder := NewRecording( Meta{UserName: "test"}, + logger.Default(), Options{ Dir: dir, Fps: 60, @@ -122,7 +125,7 @@ func benchmarkRecorder(w, h int, comp int, b *testing.B) { ticks.Done() }() go func() { - recorder.WriteAudio(Audio{&samples}) + recorder.WriteAudio(Audio{&samples, 1}) atomic.AddInt64(&bytes, int64(len(samples)*2)) ticks.Done() }() diff --git a/pkg/recorder/wavstream.go b/pkg/worker/recorder/wavstream.go similarity index 68% rename from pkg/recorder/wavstream.go rename to pkg/worker/recorder/wavstream.go index 59c764d2..c445a6d5 100644 --- a/pkg/recorder/wavstream.go +++ b/pkg/worker/recorder/wavstream.go @@ -1,16 +1,10 @@ package recorder -import ( - "encoding/binary" - "log" - - "github.com/hashicorp/go-multierror" -) +import "encoding/binary" type wavStream struct { - AudioStream + audioStream - buf chan Audio frequency int wav *file } @@ -20,7 +14,7 @@ const ( audioFileRIFFSize = 44 ) -func NewWavStream(dir string, opts Options) (*wavStream, error) { +func newWavStream(dir string, opts Options) (*wavStream, error) { wav, err := newFile(dir, audioFile) if err != nil { return nil, err @@ -32,42 +26,32 @@ func NewWavStream(dir string, opts Options) (*wavStream, error) { return &wavStream{ frequency: opts.Frequency, wav: wav, - buf: make(chan Audio, 1), }, nil } -func (w *wavStream) Start() { - for audio := range w.buf { - if err := w.Save(*audio.Samples); err != nil { - log.Printf("wav write err: %v", err) - } - } -} - -func (w *wavStream) Stop() error { - var result *multierror.Error - close(w.buf) - result = multierror.Append(result, w.wav.Flush()) +func (w *wavStream) Close() (err error) { + err = w.wav.Flush() size, er := w.wav.Size() if er != nil { - result = multierror.Append(result, er) + err = er } if size > 0 { // write an actual RIFF header - result = multierror.Append(result, w.wav.WriteAtStart(rIFFWavHeader(uint32(size), w.frequency))) - result = multierror.Append(result, w.wav.Flush()) + err = w.wav.WriteAtStart(rIFFWavHeader(uint32(size), w.frequency)) + err = w.wav.Flush() } - result = multierror.Append(result, w.wav.Close()) - return result.ErrorOrNil() + err = w.wav.Close() + return } -func (w *wavStream) Save(pcm []int16) error { +func (w *wavStream) Write(data Audio) { + pcm := *data.Samples bs := make([]byte, len(pcm)*2) // int & 0xFF + (int >> 8) & 0xFF for i, ln := 0, len(pcm); i < ln; i++ { binary.LittleEndian.PutUint16(bs[i*2:i*2+2], uint16(pcm[i])) } - return w.wav.Write(bs) + _ = w.wav.Write(bs) } // rIFFWavHeader creates RIFF WAV header. @@ -111,5 +95,3 @@ func rIFFWavHeader(fSize uint32, fq int) []byte { } return header[:] } - -func (w *wavStream) Write(data Audio) { w.buf <- data } diff --git a/pkg/recorder/zipfile.go b/pkg/worker/recorder/zipfile.go similarity index 100% rename from pkg/recorder/zipfile.go rename to pkg/worker/recorder/zipfile.go diff --git a/pkg/worker/recording.go b/pkg/worker/recording.go new file mode 100644 index 00000000..8efa30a4 --- /dev/null +++ b/pkg/worker/recording.go @@ -0,0 +1,71 @@ +package worker + +import ( + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/recorder" +) + +type RecordingRoom struct { + GamingRoom + rec *recorder.Recording +} + +func WithRecording(room GamingRoom, rec bool, recUser string, game string, conf worker.Config) *RecordingRoom { + room.GetLog().Info().Msgf("RECORD: %v %v", rec, recUser) + + rr := &RecordingRoom{GamingRoom: room, rec: recorder.NewRecording( + recorder.Meta{UserName: recUser}, + room.GetLog(), + recorder.Options{ + Dir: conf.Recording.Folder, + Fps: float64(room.GetEmulator().GetFps()), + Frequency: int(room.GetEmulator().GetSampleRate()), + Game: game, + ImageCompressionLevel: conf.Recording.CompressLevel, + Name: conf.Recording.Name, + Zip: conf.Recording.Zip, + Vsync: true, + })} + rr.ToggleRecording(rec, recUser) + rr.captureAudio() + rr.captureVideo() + return rr +} + +func (r *RecordingRoom) captureAudio() { + handler := r.GetEmulator().GetAudio() + r.GetEmulator().SetAudio(func(samples *emulator.GameAudio) { + if r.IsRecording() { + r.rec.WriteAudio(recorder.Audio{Samples: samples.Data, Duration: samples.Duration}) + } + handler(samples) + }) +} + +func (r *RecordingRoom) captureVideo() { + handler := r.GetEmulator().GetVideo() + r.GetEmulator().SetVideo(func(frame *emulator.GameFrame) { + if r.IsRecording() { + r.rec.WriteVideo(recorder.Video{Image: frame.Data, Duration: frame.Duration}) + } + handler(frame) + }) +} + +func (r *RecordingRoom) ToggleRecording(active bool, user string) { + if r.rec == nil { + return + } + r.GetLog().Debug().Msgf("[REC] set: %v, %v", active, user) + r.rec.Set(active, user) +} + +func (r *RecordingRoom) IsRecording() bool { return r.rec != nil && r.rec.Enabled() } + +func (r *RecordingRoom) Close() { + r.GamingRoom.Close() + if r.rec != nil { + r.rec.Set(false, "") + } +} diff --git a/pkg/worker/room.go b/pkg/worker/room.go new file mode 100644 index 00000000..c3b9aba0 --- /dev/null +++ b/pkg/worker/room.go @@ -0,0 +1,187 @@ +package worker + +import ( + "time" + + "github.com/giongto35/cloud-game/v2/pkg/com" + conf "github.com/giongto35/cloud-game/v2/pkg/config/emulator" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/os" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" +) + +type GamingRoom interface { + GetId() string + Close() + CleanupUser(*Session) + HasSave() bool + StartEmulator() + SaveGame() error + LoadGame() error + ToggleMultitap() + HasUser(*Session) bool + AddUser(*Session) + PollUserInput(*Session) + EnableAutosave(periodS int) + GetEmulator() emulator.Emulator + GetLog() *logger.Logger +} + +type Room struct { + id string + done chan struct{} + vEncoder *encoder.VideoEncoder + users com.NetMap[*Session] // a list of users in the room + emulator emulator.Emulator + onClose func(self *Room) + closed bool + log *logger.Logger +} + +func NewRoom(id string, game games.GameMetadata, onClose func(*Room), conf worker.Config, log *logger.Logger) *Room { + if id == "" { + id = games.GenerateRoomID(game.Name) + } + log = log.Extend(log.With().Str("room", id[:5])) + log.Info().Str("game", game.Name).Send() + room := &Room{id: id, users: com.NewNetMap[*Session](), done: make(chan struct{}), onClose: onClose, log: log} + + nano, err := libretro.NewFrontend(conf.Emulator, log) + if err != nil { + log.Fatal().Err(err).Send() + } + room.emulator = nano + room.emulator.SetMainSaveName(id) + room.emulator.LoadMetadata(conf.Emulator.GetEmulator(game.Type, game.Path)) + err = room.emulator.LoadGame(game.FullPath()) + if err != nil { + log.Fatal().Err(err).Msgf("couldn't load the game %v", game) + } + // calc output frame size and rotation + fw, fh := room.emulator.GetFrameSize() + w, h := room.whatsFrame(conf.Emulator, fw, fh) + if room.emulator.HasVerticalFrame() { + w, h = h, w + } + room.emulator.SetViewport(w, h) + + log.Info().Str("game", game.Name).Msg("The room is open") + + room.initVideo(w, h, conf.Encoder.Video) + room.initAudio(int(room.emulator.GetSampleRate()), conf.Encoder.Audio) + log.Info().Str("room", room.GetId()).Msg("New room") + return room +} + +func (r *Room) GetEmulator() emulator.Emulator { return r.emulator } +func (r *Room) GetId() string { return r.id } +func (r *Room) GetLog() *logger.Logger { return r.log } +func (r *Room) HasSave() bool { return os.Exists(r.emulator.GetHashPath()) } +func (r *Room) HasUser(u *Session) bool { return r != nil && r.users.Has(u.id) } +func (r *Room) IsEmpty() bool { return r.users.IsEmpty() } +func (r *Room) LoadGame() error { return r.emulator.LoadGameState() } +func (r *Room) SaveGame() error { return r.emulator.SaveGameState() } +func (r *Room) StartEmulator() { go r.emulator.Start() } +func (r *Room) ToggleMultitap() { r.emulator.ToggleMultitap() } + +func (r *Room) EnableAutosave(periodSec int) { + r.log.Info().Msgf("Autosave every [%vs]", periodSec) + ticker := time.NewTicker(time.Duration(periodSec) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if r.closed { + continue + } + if err := r.emulator.SaveGameState(); err != nil { + r.log.Error().Msgf("Autosave failed: %v", err) + } else { + r.log.Debug().Msgf("Autosave done") + } + case <-r.done: + return + } + } +} + +func (r *Room) whatsFrame(conf conf.Emulator, w, h int) (ww int, hh int) { + // nwidth, nheight are the WebRTC output size + var nwidth, nheight int + emu, ar := conf, conf.AspectRatio + + if ar.Keep { + baseAspectRatio := float64(w) / float64(ar.Height) + nwidth, nheight = ar.ResizeToAspect(baseAspectRatio, ar.Width, ar.Height) + r.log.Info().Msgf("Viewport size will be changed from %dx%d (%f) -> %dx%d", ar.Width, ar.Height, + baseAspectRatio, nwidth, nheight) + } else { + nwidth, nheight = w, h + r.log.Info().Msgf("Viewport resolution: %dx%d", nwidth, nheight) + } + + if emu.Scale > 1 { + nwidth, nheight = nwidth*emu.Scale, nheight*emu.Scale + r.log.Info().Msgf("Viewport size has scaled to %dx%d", nwidth, nheight) + } + + // set game frame size considering its orientation + ww, hh = nwidth, nheight + return +} + +func (r *Room) PollUserInput(session *Session) { + r.log.Debug().Msg("Start session input poll") + session.GetPeerConn().OnMessage = func(data []byte) { r.emulator.Input(session.GetPlayerIndex(), data) } +} + +func (r *Room) AddUser(user *Session) { + r.users.Add(user) + user.SetRoom(r) + r.log.Debug().Str("user", string(user.Id())).Msg("User has joined the room") +} + +func (r *Room) CleanupUser(user *Session) { + user.SetRoom(nil) + if r.HasUser(user) { + r.users.Remove(user) + r.log.Debug().Str("user", string(user.Id())).Msg("User has left the room") + } + if r.IsEmpty() { + r.log.Debug().Msg("The room is empty") + r.Close() + } +} + +func (r *Room) Close() { + r.log.Debug().Msg("Closing the room") + if r.closed { + r.log.Debug().Msg("Close room skip") + return + } + + r.closed = true + + // Save game before quit. Only save for game which was previous saved to avoid flooding database + if r.HasSave() { + r.log.Debug().Msg("Save game before closing room") + if err := r.SaveGame(); err != nil { + r.log.Error().Err(err).Msg("couldn't save the game during close") + } + } + r.emulator.Close() + close(r.done) + + if r.vEncoder != nil { + r.vEncoder.Stop() + } + + if r.onClose != nil { + r.onClose(r) + } +} diff --git a/pkg/worker/room/media.go b/pkg/worker/room/media.go deleted file mode 100644 index 7c46b644..00000000 --- a/pkg/worker/room/media.go +++ /dev/null @@ -1,145 +0,0 @@ -package room - -import ( - "fmt" - "log" - - "github.com/giongto35/cloud-game/v2/pkg/codec" - encoderConfig "github.com/giongto35/cloud-game/v2/pkg/config/encoder" - "github.com/giongto35/cloud-game/v2/pkg/encoder" - "github.com/giongto35/cloud-game/v2/pkg/encoder/h264" - "github.com/giongto35/cloud-game/v2/pkg/encoder/opus" - "github.com/giongto35/cloud-game/v2/pkg/encoder/vpx" - "github.com/giongto35/cloud-game/v2/pkg/media" - "github.com/giongto35/cloud-game/v2/pkg/recorder" - "github.com/giongto35/cloud-game/v2/pkg/webrtc" -) - -//func (r *Room) startVoice() { -// // broadcast voice -// go func() { -// for sample := range r.voiceInChannel { -// r.voiceOutChannel <- sample -// } -// }() -// -// // fanout voice -// go func() { -// for sample := range r.voiceOutChannel { -// for _, webRTC := range r.rtcSessions { -// if webRTC.IsConnected() { -// // NOTE: can block here -// webRTC.VoiceOutChannel <- sample -// } -// } -// } -// for _, webRTC := range r.rtcSessions { -// close(webRTC.VoiceOutChannel) -// } -// }() -//} - -func (r *Room) isRecording() bool { return r.rec != nil && r.rec.Enabled() } - -func (r *Room) startAudio(sampleRate int, audio encoderConfig.Audio) { - buf := media.NewBuffer(audio.GetFrameSizeFor(sampleRate)) - resample, resampleSize := sampleRate != audio.Frequency, 0 - if resample { - resampleSize = audio.GetFrameSize() - } - enc, err := opus.NewEncoder(audio.Frequency, audio.Channels) - if err != nil { - log.Fatalf("error: cannot create audio encoder, %v", err) - } - log.Printf("OPUS: %v", enc.GetInfo()) - - for samples := range r.audioChannel { - if r.isRecording() { - r.rec.WriteAudio(recorder.Audio{Samples: &samples}) - } - buf.Write(samples, func(s media.Samples) { - if resample { - s = media.ResampleStretch(s, resampleSize) - } - dat, err := enc.Encode(s) - if err == nil { - r.broadcastAudio(dat) - } - }) - } - log.Println("Room ", r.ID, " audio channel closed") -} - -func (r *Room) broadcastAudio(audio []byte) { - for _, webRTC := range r.rtcSessions { - if webRTC.IsConnected() { - // NOTE: can block here - webRTC.AudioChannel <- audio - } - } -} - -// startVideo processes imageChannel images with an encoder (codec) then pushes the result to WebRTC. -func (r *Room) startVideo(width, height int, video encoderConfig.Video) { - var enc encoder.Encoder - var err error - - log.Println("Video codec:", video.Codec) - if video.Codec == string(codec.H264) { - enc, err = h264.NewEncoder(width, height, h264.WithOptions(h264.Options{ - Crf: video.H264.Crf, - Tune: video.H264.Tune, - Preset: video.H264.Preset, - Profile: video.H264.Profile, - LogLevel: int32(video.H264.LogLevel), - })) - } else { - enc, err = vpx.NewEncoder(width, height, vpx.WithOptions(vpx.Options{ - Bitrate: video.Vpx.Bitrate, - KeyframeInt: video.Vpx.KeyframeInterval, - })) - } - - if err != nil { - fmt.Println("error create new encoder", err) - return - } - - r.vPipe = encoder.NewVideoPipe(enc, width, height) - einput, eoutput := r.vPipe.Input, r.vPipe.Output - - go r.vPipe.Start() - defer r.vPipe.Stop() - - go func() { - defer func() { - if r := recover(); r != nil { - fmt.Println("Recovered when sent to close Image Channel") - } - }() - - // fanout Screen - for data := range eoutput { - // TODO: r.rtcSessions is rarely updated. Lock will hold down perf - for _, webRTC := range r.rtcSessions { - if !webRTC.IsConnected() { - continue - } - // encode frame - // fanout imageChannel - // NOTE: can block here - webRTC.ImageChannel <- webrtc.WebFrame{Data: data.Data, Duration: data.Duration} - } - } - }() - - for frame := range r.imageChannel { - if len(einput) < cap(einput) { - if r.isRecording() { - go r.rec.WriteVideo(recorder.Video{Image: frame.Data, Duration: frame.Duration}) - } - einput <- encoder.InFrame{Image: frame.Data, Duration: frame.Duration} - } - } - log.Println("Room ", r.ID, " video channel closed") -} diff --git a/pkg/worker/room/media_test.go b/pkg/worker/room/media_test.go deleted file mode 100644 index 76906056..00000000 --- a/pkg/worker/room/media_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package room - -import ( - "image" - "math/rand" - "testing" - "time" - - "github.com/giongto35/cloud-game/v2/pkg/codec" - "github.com/giongto35/cloud-game/v2/pkg/encoder" - "github.com/giongto35/cloud-game/v2/pkg/encoder/h264" - "github.com/giongto35/cloud-game/v2/pkg/encoder/vpx" -) - -func TestEncoders(t *testing.T) { - tests := []struct { - n int - w, h int - codec codec.VideoCodec - frames int - }{ - {n: 3, w: 1920, h: 1080, codec: codec.H264, frames: 60 * 2}, - {n: 3, w: 1920, h: 1080, codec: codec.VPX, frames: 60 * 2}, - } - - for _, test := range tests { - a := genTestImage(test.w, test.h, rand.New(rand.NewSource(int64(1))).Float32()) - b := genTestImage(test.w, test.h, rand.New(rand.NewSource(int64(2))).Float32()) - for i := 0; i < test.n; i++ { - run(test.w, test.h, test.codec, test.frames, a, b, t) - } - } -} - -func BenchmarkH264(b *testing.B) { run(1920, 1080, codec.H264, b.N, nil, nil, b) } -func BenchmarkVP8(b *testing.B) { run(1920, 1080, codec.VPX, b.N, nil, nil, b) } - -func run(w, h int, cod codec.VideoCodec, count int, a *image.RGBA, b *image.RGBA, backend testing.TB) { - var enc encoder.Encoder - if cod == codec.H264 { - enc, _ = h264.NewEncoder(w, h) - } else { - enc, _ = vpx.NewEncoder(w, h) - } - - pipe := encoder.NewVideoPipe(enc, w, h) - go pipe.Start() - defer pipe.Stop() - - if a == nil { - a = genTestImage(w, h, rand.New(rand.NewSource(int64(1))).Float32()) - } - if b == nil { - b = genTestImage(w, h, rand.New(rand.NewSource(int64(2))).Float32()) - } - - for i := 0; i < count; i++ { - im := a - if i%2 == 0 { - im = b - } - pipe.Input <- encoder.InFrame{Image: im} - select { - case _, ok := <-pipe.Output: - if !ok { - backend.Fatalf("encoder closed abnormally") - } - case <-time.After(5 * time.Second): - backend.Fatalf("encoder didn't produce an image") - } - } -} - -func genTestImage(w, h int, seed float32) *image.RGBA { - img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) - for x := 0; x < w; x++ { - for y := 0; y < h; y++ { - i := img.PixOffset(x, y) - s := img.Pix[i : i+4 : i+4] - s[0] = uint8(seed * 255) - s[1] = uint8(seed * 255) - s[2] = uint8(seed * 255) - s[3] = 0xff - } - } - return img -} diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go deleted file mode 100644 index c13e64b1..00000000 --- a/pkg/worker/room/room.go +++ /dev/null @@ -1,480 +0,0 @@ -package room - -import ( - "bytes" - "encoding/gob" - "errors" - "fmt" - "io" - "io/ioutil" - "log" - "math" - "net" - "os" - "path/filepath" - "sync" - "time" - - "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "github.com/giongto35/cloud-game/v2/pkg/emulator" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/nanoarch" - "github.com/giongto35/cloud-game/v2/pkg/encoder" - "github.com/giongto35/cloud-game/v2/pkg/games" - "github.com/giongto35/cloud-game/v2/pkg/recorder" - "github.com/giongto35/cloud-game/v2/pkg/session" - "github.com/giongto35/cloud-game/v2/pkg/storage" - "github.com/giongto35/cloud-game/v2/pkg/webrtc" -) - -// Room is a game session. multi webRTC sessions can connect to a same game. -// A room stores all the channel for interaction between all webRTCs session and emulator -type Room struct { - ID string - - // imageChannel is image stream received from director - imageChannel <-chan nanoarch.GameFrame - // audioChannel is audio stream received from director - audioChannel <-chan []int16 - // inputChannel is input stream send to director. This inputChannel is combined - // input from webRTC + connection info (player index) - inputChannel chan<- nanoarch.InputEvent - // voiceInChannel is voice stream received from users - //voiceInChannel chan []byte - // voiceOutChannel is voice stream routed to all users - //voiceOutChannel chan []byte - //voiceSample [][]byte - // State of room - IsRunning bool - // Done channel is to fire exit event when room is closed - Done chan struct{} - // List of peer connections in the room - rtcSessions []*webrtc.WebRTC - // NOTE: Not in use, lock rtcSessions - sessionsLock *sync.Mutex - // Director is emulator - director emulator.CloudEmulator - // Cloud storage to store room state online - onlineStorage storage.CloudStorage - - rec *recorder.Recording - - vPipe *encoder.VideoPipe -} - -const ( - bufSize = 245969 - SocketAddrTmpl = "/tmp/cloudretro-retro-%s.sock" -) - -// NewVideoImporter return image Channel from stream -func NewVideoImporter(roomID string) chan nanoarch.GameFrame { - sockAddr := fmt.Sprintf(SocketAddrTmpl, roomID) - imgChan := make(chan nanoarch.GameFrame) - - l, err := net.Listen("unix", sockAddr) - if err != nil { - log.Fatal("listen error:", err) - } - - log.Println("Creating uds server", sockAddr) - go func(l net.Listener) { - defer l.Close() - - conn, err := l.Accept() - if err != nil { - log.Fatal("Accept error: ", err) - } - defer conn.Close() - - log.Println("Received new conn") - log.Println("Spawn Importer") - - fullBuf := make([]byte, bufSize*2) - fullBuf = fullBuf[:0] - - for { - // TODO: Not reallocate - buf := make([]byte, bufSize) - l, err := conn.Read(buf) - if err != nil { - if err != io.EOF { - log.Printf("error: %v", err) - } - continue - } - - buf = buf[:l] - fullBuf = append(fullBuf, buf...) - if len(fullBuf) >= bufSize { - buff := bytes.NewBuffer(fullBuf) - dec := gob.NewDecoder(buff) - - frame := nanoarch.GameFrame{} - err := dec.Decode(&frame) - if err != nil { - log.Fatalf("%v", err) - } - imgChan <- frame - fullBuf = fullBuf[bufSize:] - } - } - }(l) - - return imgChan -} - -// NewRoom creates a new room -func NewRoom(roomID string, game games.GameMetadata, recUser string, rec bool, onlineStorage storage.CloudStorage, cfg worker.Config) *Room { - if roomID == "" { - roomID = session.GenerateRoomID(game.Name) - } - - log.Println("New room: ", roomID, game) - inputChannel := make(chan nanoarch.InputEvent, 100) - - room := &Room{ - ID: roomID, - - inputChannel: inputChannel, - imageChannel: nil, - //voiceInChannel: make(chan []byte, 1), - //voiceOutChannel: make(chan []byte, 1), - rtcSessions: []*webrtc.WebRTC{}, - sessionsLock: &sync.Mutex{}, - IsRunning: true, - onlineStorage: onlineStorage, - - Done: make(chan struct{}, 1), - } - - // Check if room is on local storage, if not, pull from GCS to local storage - go func(game games.GameMetadata, roomID string) { - var store nanoarch.Storage = &nanoarch.StateStorage{ - Path: cfg.Emulator.Storage, - MainSave: roomID, - } - if cfg.Emulator.Libretro.SaveCompression { - store = &nanoarch.ZipStorage{Storage: store} - } - - // Check room is on local or fetch from server - log.Printf("Check for %s in the online storage", roomID) - if err := room.saveOnlineRoomToLocal(roomID, store.GetSavePath()); err != nil { - log.Printf("warn: room %s is not in the online storage, error %s", roomID, err) - } - - // If not then load room or create room from local. - log.Printf("Room %s started. GameName: %s, WithGame: %t", roomID, game.Name, cfg.Encoder.WithoutGame) - - // Spawn new emulator and plug-in all channels - emuName := cfg.Emulator.GetEmulator(game.Type, game.Path) - libretroConfig := cfg.Emulator.GetLibretroCoreConfig(emuName) - - th := cfg.Emulator.Threads - if th == 0 { - th = 1 - } - log.Printf("Image processing threads = %v", th) - - if cfg.Encoder.WithoutGame { - // Run without game, image stream is communicated over a unix socket - imageChannel := NewVideoImporter(roomID) - director, _, audioChannel := nanoarch.Init(roomID, false, inputChannel, store, libretroConfig, th) - room.imageChannel = imageChannel - room.director = director - room.audioChannel = audioChannel - } else { - // Run without game, image stream is communicated over image channel - director, imageChannel, audioChannel := nanoarch.Init(roomID, true, inputChannel, store, libretroConfig, th) - room.imageChannel = imageChannel - room.director = director - room.audioChannel = audioChannel - } - - gameMeta := room.director.LoadMeta(filepath.Join(game.Base, game.Path)) - - // nwidth, nheight are the WebRTC output size - var nwidth, nheight int - emu, ar := cfg.Emulator, cfg.Emulator.AspectRatio - - if ar.Keep { - baseAspectRatio := float64(gameMeta.BaseWidth) / float64(ar.Height) - nwidth, nheight = resizeToAspect(baseAspectRatio, ar.Width, ar.Height) - log.Printf("Viewport size will be changed from %dx%d (%f) -> %dx%d", ar.Width, ar.Height, - baseAspectRatio, nwidth, nheight) - } else { - nwidth, nheight = gameMeta.BaseWidth, gameMeta.BaseHeight - log.Printf("Viewport custom size is disabled, base size will be used instead %dx%d", nwidth, nheight) - } - - if emu.Scale > 1 { - nwidth, nheight = nwidth*emu.Scale, nheight*emu.Scale - log.Printf("Viewport size has scaled to %dx%d", nwidth, nheight) - } - - // set game frame size considering its orientation - encoderW, encoderH := nwidth, nheight - if gameMeta.Rotation.IsEven { - encoderW, encoderH = nheight, nwidth - } - - if cfg.Recording.Enabled { - room.rec = recorder.NewRecording( - recorder.Meta{UserName: recUser}, - recorder.Options{ - Dir: cfg.Recording.Folder, - Fps: gameMeta.Fps, - Frequency: gameMeta.AudioSampleRate, - Game: game.Name, - ImageCompressionLevel: cfg.Recording.CompressLevel, - Name: cfg.Recording.Name, - Zip: cfg.Recording.Zip, - }) - room.ToggleRecording(rec, recUser) - } - - room.director.SetViewport(encoderW, encoderH) - - // Spawn video and audio encoding for webRTC - go room.startVideo(encoderW, encoderH, cfg.Encoder.Video) - go room.startAudio(gameMeta.AudioSampleRate, cfg.Encoder.Audio) - //go room.startVoice() - - if cfg.Emulator.AutosaveSec > 0 { - go room.enableAutosave(cfg.Emulator.AutosaveSec) - } - - room.director.Start() - }(game, roomID) - return room -} - -func (r *Room) enableAutosave(periodSec int) { - log.Printf("Autosave is enabled with the period of [%vs]", periodSec) - ticker := time.NewTicker(time.Duration(periodSec) * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if !r.IsRunning { - continue - } - if err := r.director.SaveGame(); err != nil { - log.Printf("Autosave failed: %v", err) - } - case <-r.Done: - return - } - } -} - -func 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 -} - -func isGameOnLocal(path string) bool { - file, err := os.Open(path) - if err == nil { - defer func() { - _ = file.Close() - }() - } - return !errors.Is(err, os.ErrNotExist) -} - -func (r *Room) AddConnectionToRoom(peerconnection *webrtc.WebRTC) { - peerconnection.AttachRoomID(r.ID) - r.rtcSessions = append(r.rtcSessions, peerconnection) - - go r.startWebRTCSession(peerconnection) -} - -func (r *Room) UpdatePlayerIndex(peerconnection *webrtc.WebRTC, playerIndex int) { - log.Println("Updated player Index to: ", playerIndex) - peerconnection.PlayerIndex = playerIndex -} - -func (r *Room) startWebRTCSession(peerconnection *webrtc.WebRTC) { - defer func() { - if r := recover(); r != nil { - log.Println("Warn: Recovered when sent to close inputChannel") - } - }() - - log.Println("Start WebRTC session") - //go func() { - // - // // set up voice input and output. A room has multiple voice input and only one combined voice output. - // for voiceInput := range peerconnection.VoiceInChannel { - // // NOTE: when room is no longer running. InputChannel needs to have extra event to go inside the loop - // if peerconnection.Done || !peerconnection.IsConnected() || !r.IsRunning { - // break - // } - // - // if peerconnection.IsConnected() { - // r.voiceInChannel <- voiceInput - // } - // - // } - //}() - - // bug: when input channel here = nil, skip and finish - for input := range peerconnection.InputChannel { - // NOTE: when room is no longer running. InputChannel needs to have extra event to go inside the loop - if peerconnection.Done || !peerconnection.IsConnected() || !r.IsRunning { - break - } - - if peerconnection.IsConnected() { - select { - case r.inputChannel <- nanoarch.InputEvent{RawState: input, PlayerIdx: peerconnection.PlayerIndex, ConnID: peerconnection.ID}: - default: - } - } - } - log.Printf("[worker] peer connection is done") -} - -// RemoveSession removes a peerconnection from room and return true if there is no more room -func (r *Room) RemoveSession(w *webrtc.WebRTC) { - log.Println("Cleaning session: ", w.ID) - // TODO: get list of r.rtcSessions in lock - for i, s := range r.rtcSessions { - log.Println("found session: ", w.ID) - if s.ID == w.ID { - r.rtcSessions = append(r.rtcSessions[:i], r.rtcSessions[i+1:]...) - s.RoomID = "" - log.Println("Removed session ", s.ID, " from room: ", r.ID) - break - } - } - // Detach input. Send end signal - select { - case r.inputChannel <- nanoarch.InputEvent{RawState: []byte{0xFF, 0xFF}, ConnID: w.ID}: - default: - } -} - -// TODO: Reuse for remove Session -func (r *Room) IsPCInRoom(w *webrtc.WebRTC) bool { - if r == nil { - return false - } - for _, s := range r.rtcSessions { - if s.ID == w.ID { - return true - } - } - return false -} - -func (r *Room) Close() { - if !r.IsRunning { - return - } - - r.IsRunning = false - log.Println("Closing room and director of room ", r.ID) - - // Save game before quit. Only save for game which was previous saved to avoid flooding database - if r.isRoomExisted() { - log.Println("Saved Game before closing room") - // use goroutine here because SaveGame attempt to acquire a emulator lock. - // the lock is holding before coming to close, so it will cause deadlock if SaveGame is synchronous - go func() { - // Save before close, so save can have correct state (Not sure) may again cause deadlock - if err := r.SaveGame(); err != nil { - log.Println("[error] couldn't save the game during closing") - } - r.director.Close() - }() - } else { - r.director.Close() - } - log.Println("Closing input of room ", r.ID) - close(r.inputChannel) - //close(r.voiceOutChannel) - //close(r.voiceInChannel) - close(r.Done) - // Close here is a bit wrong because this read channel - // Just dont close it, let it be gc - //close(r.imageChannel) - //close(r.audioChannel) - if r.rec != nil { - if err := r.rec.Stop(); err != nil { - log.Printf("record close err, %v", err) - } - } -} - -func (r *Room) isRoomExisted() bool { - // Check if room is in online storage - _, err := r.onlineStorage.Load(r.ID) - if err == nil { - return true - } - return isGameOnLocal(r.director.GetHashPath()) -} - -// SaveGame writes save state on the disk as well as -// uploads it to a cloud storage. -func (r *Room) SaveGame() error { - // TODO: Move to game view - if err := r.director.SaveGame(); err != nil { - return err - } - if err := r.onlineStorage.Save(r.ID, r.director.GetHashPath()); err != nil { - return err - } - log.Printf("success, cloud save") - return nil -} - -// saveOnlineRoomToLocal save online room to local. -// !Supports only one file of main save state. -func (r *Room) saveOnlineRoomToLocal(roomID string, savePath string) error { - data, err := r.onlineStorage.Load(roomID) - if err != nil { - return err - } - // Save the data fetched from a cloud provider to the local server - if data != nil { - if err := ioutil.WriteFile(savePath, data, 0644); err != nil { - return err - } - log.Printf("successfully downloaded cloud save") - } - return nil -} - -func (r *Room) LoadGame() error { return r.director.LoadGame() } - -func (r *Room) ToggleMultitap() error { return r.director.ToggleMultitap() } - -func (r *Room) IsEmpty() bool { return len(r.rtcSessions) == 0 } - -func (r *Room) IsRunningSessions() bool { - // If there is running session - for _, s := range r.rtcSessions { - if s.IsConnected() { - return true - } - } - - return false -} - -func (r *Room) ToggleRecording(active bool, user string) { - if r.rec == nil { - return - } - r.rec.Set(active, user) -} diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room_test.go similarity index 71% rename from pkg/worker/room/room_test.go rename to pkg/worker/room_test.go index 9abd4576..87d743dd 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room_test.go @@ -1,4 +1,4 @@ -package room +package worker import ( "flag" @@ -8,23 +8,24 @@ import ( "image/color" "image/draw" "image/png" - "io/ioutil" + "io" "log" "os" "path/filepath" "runtime" "sync" + "sync/atomic" "testing" "time" - "github.com/giongto35/cloud-game/v2/pkg/codec" "github.com/giongto35/cloud-game/v2/pkg/config" "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/manager/remotehttp" - "github.com/giongto35/cloud-game/v2/pkg/encoder" "github.com/giongto35/cloud-game/v2/pkg/games" - "github.com/giongto35/cloud-game/v2/pkg/storage" - "github.com/giongto35/cloud-game/v2/pkg/thread" + "github.com/giongto35/cloud-game/v2/pkg/logger" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/manager/remotehttp" + "github.com/giongto35/cloud-game/v2/pkg/worker/encoder" + "github.com/giongto35/cloud-game/v2/pkg/worker/thread" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" @@ -37,15 +38,17 @@ var ( ) type roomMock struct { - Room + *Room + startEmulator bool } type roomMockConfig struct { - roomName string - gamesPath string - game games.GameMetadata - vCodec codec.VideoCodec - autoGlContext bool + roomName string + gamesPath string + game games.GameMetadata + vCodec encoder.VideoCodec + autoGlContext bool + dontStartEmulator bool } // Store absolute path to test games @@ -69,7 +72,7 @@ func TestRoom(t *testing.T) { tests := []struct { roomName string game games.GameMetadata - vCodec codec.VideoCodec + vCodec encoder.VideoCodec frames int }{ { @@ -78,8 +81,8 @@ func TestRoom(t *testing.T) { Type: "nes", Path: "Super Mario Bros.nes", }, - vCodec: codec.VPX, - frames: 5, + vCodec: encoder.H264, + frames: 300, }, } @@ -90,8 +93,9 @@ func TestRoom(t *testing.T) { game: test.game, vCodec: test.vCodec, }) + t.Logf("The game [%v] has been loaded", test.game.Name) - waitNOutFrames(test.frames, room.vPipe.Output) + waitNFrames(test.frames, room) room.Close() } // hack: wait room destruction @@ -101,7 +105,7 @@ func TestRoom(t *testing.T) { func TestRoomWithGL(t *testing.T) { tests := []struct { game games.GameMetadata - vCodec codec.VideoCodec + vCodec encoder.VideoCodec frames int }{ { @@ -110,7 +114,7 @@ func TestRoomWithGL(t *testing.T) { Type: "n64", Path: "Sample Demo by Florian (PD).z64", }, - vCodec: codec.VPX, + vCodec: encoder.VP8, frames: 50, }, } @@ -123,7 +127,7 @@ func TestRoomWithGL(t *testing.T) { vCodec: test.vCodec, }) t.Logf("The game [%v] has been loaded", test.game.Name) - waitNOutFrames(test.frames, room.vPipe.Output) + waitNFrames(test.frames, room) room.Close() } // hack: wait room destruction @@ -140,7 +144,7 @@ func TestAllEmulatorRooms(t *testing.T) { }{ { game: games.GameMetadata{Name: "Sushi", Type: "gba", Path: "Sushi The Cat.gba"}, - frames: 100, + frames: 150, }, { game: games.GameMetadata{Name: "Mario", Type: "nes", Path: "Super Mario Bros.nes"}, @@ -156,33 +160,32 @@ func TestAllEmulatorRooms(t *testing.T) { for _, test := range tests { room := getRoomMock(roomMockConfig{ - gamesPath: whereIsGames, - game: test.game, - vCodec: codec.VPX, - autoGlContext: autoGlContext, + gamesPath: whereIsGames, + game: test.game, + vCodec: encoder.VP8, + autoGlContext: autoGlContext, + dontStartEmulator: true, }) t.Logf("The game [%v] has been loaded", test.game.Name) - frame := waitNFrames(test.frames, room.vPipe.Input) + frame := waitNFrames(test.frames, room) if renderFrames { - tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(frame.Image.Pix, crc32q)) - dumpCanvas(frame.Image, tag, fmt.Sprintf("%v [%v]", tag, test.frames), outputPath) + tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(frame.Data.Pix, crc32q)) + dumpCanvas(frame.Data, tag, fmt.Sprintf("%v [%v]", tag, test.frames), outputPath) } room.Close() // hack: wait room destruction - time.Sleep(2 * time.Second) + time.Sleep(1 * time.Second) } } -func dumpCanvas(f *image.RGBA, name string, caption string, path string) { - frame := *f - +func dumpCanvas(frame *image.RGBA, name string, caption string, path string) { // slap 'em caption if len(caption) > 0 { - draw.Draw(&frame, image.Rect(8, 8, 8+len(caption)*7+3, 24), &image.Uniform{C: color.RGBA{}}, image.Point{}, draw.Src) + draw.Draw(frame, image.Rect(8, 8, 8+len(caption)*7+3, 24), &image.Uniform{C: color.RGBA{}}, image.Point{}, draw.Src) (&font.Drawer{ - Dst: &frame, + Dst: frame, Src: image.NewUniform(color.RGBA{R: 255, G: 255, B: 255, A: 255}), Face: basicfont.Face7x13, Dot: fixed.Point26_6{X: fixed.Int26_6(10 * 64), Y: fixed.Int26_6(20 * 64)}, @@ -203,7 +206,7 @@ func dumpCanvas(f *image.RGBA, name string, caption string, path string) { } if f, err := os.Create(filepath.Join(outPath, name+".png")); err == nil { - if err = png.Encode(f, &frame); err != nil { + if err = png.Encode(f, frame); err != nil { log.Printf("Couldn't encode the image, %v", err) } _ = f.Close() @@ -221,15 +224,20 @@ func getRoomMock(cfg roomMockConfig) roomMock { panic(err) } fixEmulators(&conf, cfg.autoGlContext) + l := logger.NewConsole(conf.Worker.Debug, "w", true) + // sync cores - coreManager := remotehttp.NewRemoteHttpManager(conf.Emulator.Libretro) + coreManager := remotehttp.NewRemoteHttpManager(conf.Emulator.Libretro, l) if err := coreManager.Sync(); err != nil { log.Printf("error: cores sync has failed, %v", err) } conf.Encoder.Video.Codec = string(cfg.vCodec) - cloudStore, _ := storage.NewNoopCloudStorage() - room := NewRoom(cfg.roomName, cfg.game, "", false, cloudStore, conf) + room := NewRoom(cfg.roomName, cfg.game, nil, conf, l) + + if !cfg.dontStartEmulator { + room.StartEmulator() + } // loop-wait the room initialization var init sync.WaitGroup @@ -237,7 +245,7 @@ func getRoomMock(cfg roomMockConfig) roomMock { wasted := 0 go func() { sleepDeltaMs := 10 - for room.director == nil || room.vPipe == nil { + for room.emulator == nil { time.Sleep(time.Duration(sleepDeltaMs) * time.Millisecond) wasted++ if wasted > 1000 { @@ -247,8 +255,7 @@ func getRoomMock(cfg roomMockConfig) roomMock { init.Done() }() init.Wait() - - return roomMock{*room} + return roomMock{Room: room, startEmulator: !cfg.dontStartEmulator} } // fixEmulators makes absolute game paths in global GameList and passes GL context config. @@ -260,6 +267,8 @@ func fixEmulators(config *worker.Config, autoGlContext bool) { filepath.FromSlash(rootPath + config.Emulator.Libretro.Cores.Paths.Libs) config.Emulator.Libretro.Cores.Paths.Configs = filepath.FromSlash(rootPath + config.Emulator.Libretro.Cores.Paths.Configs) + config.Emulator.LocalPath = filepath.FromSlash(filepath.Join(rootPath, "tests", config.Emulator.LocalPath)) + config.Emulator.Storage = filepath.FromSlash(filepath.Join(rootPath, "tests", "storage")) for k, conf := range config.Emulator.Libretro.Cores.List { if conf.IsGlAllowed && autoGlContext { @@ -269,54 +278,37 @@ func fixEmulators(config *worker.Config, autoGlContext bool) { } } -// getRootPath returns absolute path to the assets directory. +// getRootPath returns absolute path to the assets. func getRootPath() string { - p, _ := filepath.Abs("../../../") + p, _ := filepath.Abs("../../") return p + string(filepath.Separator) } -func waitNFrames(n int, ch chan encoder.InFrame) encoder.InFrame { - var frames sync.WaitGroup - frames.Add(n) - - var last encoder.InFrame - done := false - go func() { - for f := range ch { - last = f - if done { - break - } - frames.Done() +func waitNFrames(n int, room roomMock) *emulator.GameFrame { + var i = int32(n) + wg := sync.WaitGroup{} + wg.Add(n) + var frame *emulator.GameFrame + handler := room.emulator.GetVideo() + room.emulator.SetVideo(func(video *emulator.GameFrame) { + handler(video) + if atomic.AddInt32(&i, -1) >= 0 { + frame = video + wg.Done() } - }() - - frames.Wait() - done = true - return last -} - -func waitNOutFrames(n int, ch chan encoder.OutFrame) { - var frames sync.WaitGroup - frames.Add(n) - done := false - go func() { - for range ch { - if done { - break - } - frames.Done() - } - }() - frames.Wait() - done = true + }) + if !room.startEmulator { + room.StartEmulator() + } + wg.Wait() + return frame } // benchmarkRoom measures app performance for n emulation frames. // Measure period: the room initialization, n emulated and encoded frames, the room shutdown. -func benchmarkRoom(rom games.GameMetadata, codec codec.VideoCodec, frames int, suppressOutput bool, b *testing.B) { +func benchmarkRoom(rom games.GameMetadata, codec encoder.VideoCodec, frames int, suppressOutput bool, b *testing.B) { if suppressOutput { - log.SetOutput(ioutil.Discard) + log.SetOutput(io.Discard) os.Stdout, _ = os.Open(os.DevNull) } @@ -326,7 +318,7 @@ func benchmarkRoom(rom games.GameMetadata, codec codec.VideoCodec, frames int, s game: rom, vCodec: codec, }) - waitNOutFrames(frames, room.vPipe.Output) + waitNFrames(frames, room) room.Close() } } @@ -337,7 +329,7 @@ func BenchmarkRoom(b *testing.B) { benches := []struct { system string game games.GameMetadata - codecs []codec.VideoCodec + codecs []encoder.VideoCodec frames int }{ // warm up @@ -348,7 +340,7 @@ func BenchmarkRoom(b *testing.B) { Type: "gba", Path: "Sushi The Cat.gba", }, - codecs: []codec.VideoCodec{codec.VPX}, + codecs: []encoder.VideoCodec{encoder.VP8}, frames: 50, }, { @@ -358,7 +350,7 @@ func BenchmarkRoom(b *testing.B) { Type: "gba", Path: "Sushi The Cat.gba", }, - codecs: []codec.VideoCodec{codec.VPX, codec.H264}, + codecs: []encoder.VideoCodec{encoder.VP8, encoder.H264}, frames: 100, }, { @@ -368,7 +360,7 @@ func BenchmarkRoom(b *testing.B) { Type: "nes", Path: "Super Mario Bros.nes", }, - codecs: []codec.VideoCodec{codec.VPX, codec.H264}, + codecs: []encoder.VideoCodec{encoder.VP8, encoder.H264}, frames: 100, }, } diff --git a/pkg/worker/router.go b/pkg/worker/router.go new file mode 100644 index 00000000..878fb918 --- /dev/null +++ b/pkg/worker/router.go @@ -0,0 +1,57 @@ +package worker + +import ( + "github.com/giongto35/cloud-game/v2/pkg/com" + "github.com/giongto35/cloud-game/v2/pkg/network" + "github.com/giongto35/cloud-game/v2/pkg/network/webrtc" + "github.com/pion/webrtc/v3/pkg/media" +) + +// Router tracks and routes freshly connected users to a game room. +// Basically, it holds user connection data until some user makes (connects to) +// a new room (game), then it manages all the cross-references between room and users. +// Rooms and users has 1-to-n relationship. +type Router struct { + room GamingRoom + users com.NetMap[*Session] +} + +// Session represents WebRTC connection of the user. +type Session struct { + id network.Uid + conn *webrtc.Peer + pi int + room GamingRoom // back reference +} + +func NewRouter() Router { return Router{users: com.NewNetMap[*Session]()} } + +func (r *Router) SetRoom(room GamingRoom) { r.room = room } +func (r *Router) AddUser(user *Session) { r.users.Add(user) } +func (r *Router) Close() { + if r.room != nil { + r.room.Close() + } +} +func (r *Router) GetRoom(id string) GamingRoom { + if r.room != nil && r.room.GetId() == id { + return r.room + } + return nil +} +func (r *Router) GetUser(uid network.Uid) *Session { sess, _ := r.users.Find(string(uid)); return sess } +func (r *Router) RemoveRoom() { r.room = nil } +func (r *Router) RemoveUser(user *Session) { r.users.Remove(user); user.Close() } + +func NewSession(rtc *webrtc.Peer, id network.Uid) *Session { return &Session{id: id, conn: rtc} } + +func (s *Session) Id() network.Uid { return s.id } +func (s *Session) GetSetRoom(v GamingRoom) GamingRoom { vv := s.room; s.room = v; return vv } +func (s *Session) GetPeerConn() *webrtc.Peer { return s.conn } +func (s *Session) GetPlayerIndex() int { return s.pi } +func (s *Session) IsConnected() bool { return s.conn.IsConnected() } +func (s *Session) SendVideo(sample *media.Sample) error { return s.conn.WriteVideo(sample) } +func (s *Session) SendAudio(sample *media.Sample) error { return s.conn.WriteAudio(sample) } +func (s *Session) SetRoom(room GamingRoom) { s.room = room } +func (s *Session) SetPlayerIndex(index int) { s.pi = index } +func (s *Session) Close() { s.conn.Disconnect() } diff --git a/pkg/worker/routes.go b/pkg/worker/routes.go deleted file mode 100644 index 2c0565ac..00000000 --- a/pkg/worker/routes.go +++ /dev/null @@ -1,23 +0,0 @@ -package worker - -import "github.com/giongto35/cloud-game/v2/pkg/cws/api" - -func (h *Handler) routes() { - if h.oClient == nil { - return - } - - h.oClient.Receive(api.ServerId, h.handleServerId()) - h.oClient.Receive(api.TerminateSession, h.handleTerminateSession()) - h.oClient.Receive(api.InitWebrtc, h.handleInitWebrtc()) - h.oClient.Receive(api.Answer, h.handleAnswer()) - h.oClient.Receive(api.IceCandidate, h.handleIceCandidate()) - - h.oClient.Receive(api.GameStart, h.handleGameStart()) - h.oClient.Receive(api.GameQuit, h.handleGameQuit()) - h.oClient.Receive(api.GameSave, h.handleGameSave()) - h.oClient.Receive(api.GameLoad, h.handleGameLoad()) - h.oClient.Receive(api.GamePlayerSelect, h.handleGamePlayerSelect()) - h.oClient.Receive(api.GameMultitap, h.handleGameMultitap()) - h.oClient.Receive(api.GameRecording, h.handleGameRecording()) -} diff --git a/pkg/worker/session.go b/pkg/worker/session.go deleted file mode 100644 index 9815f8aa..00000000 --- a/pkg/worker/session.go +++ /dev/null @@ -1,21 +0,0 @@ -package worker - -import "github.com/giongto35/cloud-game/v2/pkg/webrtc" - -// Session represents a session connected from the browser to the current server -// It requires one connection to browser and one connection to the coordinator -// connection to browser is 1-1. connection to coordinator is n - 1 -// Peerconnection can be from other server to ensure better latency -type Session struct { - ID string - peerconnection *webrtc.WebRTC - - // Should I make direct reference - RoomID string -} - -// Close close a session -func (s *Session) Close() { - // TODO: Use event base - s.peerconnection.StopClient() -} diff --git a/pkg/storage/oracle.go b/pkg/worker/storage.go similarity index 82% rename from pkg/storage/oracle.go rename to pkg/worker/storage.go index 19bec618..183f1441 100644 --- a/pkg/storage/oracle.go +++ b/pkg/worker/storage.go @@ -1,4 +1,4 @@ -package storage +package worker import ( "bytes" @@ -6,16 +6,34 @@ import ( "encoding/base64" "errors" "fmt" - "io/ioutil" + "io" "net/http" + "os" "time" ) +type CloudStorage interface { + Save(name string, localPath string) (err error) + Load(name string) (data []byte, err error) +} + type OracleDataStorageClient struct { accessURL string client *http.Client } +func GetCloudStorage(provider, key string) (CloudStorage, error) { + var st CloudStorage + var err error + switch provider { + case "oracle": + st, err = NewOracleDataStorageClient(key) + case "coordinator": + default: + } + return st, err +} + // NewOracleDataStorageClient returns either a new Oracle Data Storage // client or some error in case of failure. // Oracle infrastructure access is based on pre-authenticated requests, @@ -39,7 +57,7 @@ func (s *OracleDataStorageClient) Save(name string, localPath string) (err error return nil } - dat, err := ioutil.ReadFile(localPath) + dat, err := os.ReadFile(localPath) if err != nil { return err } @@ -86,7 +104,7 @@ func (s *OracleDataStorageClient) Load(name string) (data []byte, err error) { return nil, errors.New(res.Status) } - dat, err := ioutil.ReadAll(res.Body) + dat, err := io.ReadAll(res.Body) if err != nil { return nil, err } diff --git a/pkg/storage/oracle_test.go b/pkg/worker/storage_test.go similarity index 81% rename from pkg/storage/oracle_test.go rename to pkg/worker/storage_test.go index 45d3ff73..cb80830a 100644 --- a/pkg/storage/oracle_test.go +++ b/pkg/worker/storage_test.go @@ -1,7 +1,7 @@ -package storage +package worker import ( - "io/ioutil" + "io" "net/http" "os" "strings" @@ -19,18 +19,18 @@ func newTestClient(fn rtFunc) *http.Client { } func TestOracleSave(t *testing.T) { - client, err := NewOracleDataStorageClient("test-url/") + client, _ := NewOracleDataStorageClient("test-url/") client.client = newTestClient(func(req *http.Request) *http.Response { return &http.Response{ StatusCode: 200, - Body: ioutil.NopCloser(strings.NewReader("")), + Body: io.NopCloser(strings.NewReader("")), Header: map[string][]string{ "Opc-Content-Md5": {"CY9rzUYh03PK3k6DJie09g=="}, }, } }) - tempFile, err := ioutil.TempFile("", "oracle_test.file") + tempFile, err := os.CreateTemp("", "oracle_test.file") if err != nil { t.Errorf("%v", err) } diff --git a/pkg/thread/mainthread_darwin.go b/pkg/worker/thread/mainthread_darwin.go similarity index 89% rename from pkg/thread/mainthread_darwin.go rename to pkg/worker/thread/mainthread_darwin.go index 7f817b20..730a3f27 100644 --- a/pkg/thread/mainthread_darwin.go +++ b/pkg/worker/thread/mainthread_darwin.go @@ -10,7 +10,7 @@ type fun struct { done chan struct{} } -var dPool = sync.Pool{New: func() interface{} { return make(chan struct{}) }} +var dPool = sync.Pool{New: func() any { return make(chan struct{}) }} var fq = make(chan fun, runtime.GOMAXPROCS(0)) func init() { diff --git a/pkg/thread/mainthread_darwin_test.go b/pkg/worker/thread/mainthread_darwin_test.go similarity index 100% rename from pkg/thread/mainthread_darwin_test.go rename to pkg/worker/thread/mainthread_darwin_test.go diff --git a/pkg/thread/thread.go b/pkg/worker/thread/thread.go similarity index 83% rename from pkg/thread/thread.go rename to pkg/worker/thread/thread.go index 10de34cc..20582a85 100644 --- a/pkg/thread/thread.go +++ b/pkg/worker/thread/thread.go @@ -1,5 +1,4 @@ //go:build !darwin -// +build !darwin package thread diff --git a/pkg/thread/thread_darwin.go b/pkg/worker/thread/thread_darwin.go similarity index 100% rename from pkg/thread/thread_darwin.go rename to pkg/worker/thread/thread_darwin.go diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index c5d443ae..51614455 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -1,25 +1,93 @@ package worker import ( - "log" + "time" "github.com/giongto35/cloud-game/v2/pkg/config/worker" + "github.com/giongto35/cloud-game/v2/pkg/logger" "github.com/giongto35/cloud-game/v2/pkg/monitoring" + "github.com/giongto35/cloud-game/v2/pkg/network/httpx" "github.com/giongto35/cloud-game/v2/pkg/service" + "github.com/giongto35/cloud-game/v2/pkg/worker/emulator/libretro/manager/remotehttp" ) -func New(conf worker.Config) (services service.Group) { - httpSrv, err := NewHTTPServer(conf) +type Worker struct { + address string + conf worker.Config + cord *coordinator + log *logger.Logger + router Router + storage CloudStorage + done chan struct{} +} + +const retry = 10 * time.Second + +func New(conf worker.Config, log *logger.Logger, done chan struct{}) (services service.Group) { + if err := remotehttp.CheckCores(conf.Emulator, log); err != nil { + log.Error().Err(err).Msg("cores sync error") + } + h, err := httpx.NewServer( + conf.Worker.GetAddr(), + func(s *httpx.Server) httpx.Handler { + return s.Mux().HandleW(conf.Worker.Network.PingEndpoint, func(w httpx.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + _, _ = w.Write([]byte{0x65, 0x63, 0x68, 0x6f}) // echo + }) + }, + httpx.WithServerConfig(conf.Worker.Server), + // no need just for one route + httpx.HttpsRedirect(false), + httpx.WithPortRoll(true), + httpx.WithZone(conf.Worker.Network.Zone), + httpx.WithLogger(log), + ) if err != nil { - log.Fatalf("http init fail: %v", err) + log.Error().Err(err).Msg("http init fail") + return } - - mainHandler := NewHandler(conf, httpSrv.Addr) - mainHandler.Prepare() - - services.Add(httpSrv, mainHandler) + services.Add(h) if conf.Worker.Monitoring.IsEnabled() { - services.Add(monitoring.New(conf.Worker.Monitoring, httpSrv.GetHost(), "worker")) + services.Add(monitoring.New(conf.Worker.Monitoring, h.GetHost(), log)) } + st, err := GetCloudStorage(conf.Storage.Provider, conf.Storage.Key) + if err != nil { + log.Error().Err(err).Msgf("cloud storage fail, using dummy cloud storage instead") + } + services.Add(&Worker{address: h.Addr, conf: conf, done: done, log: log, storage: st, router: NewRouter()}) + return } + +func (w *Worker) Run() { + go func() { + remoteAddr := w.conf.Worker.Network.CoordinatorAddress + defer func() { + if w.cord != nil { + w.cord.Close() + } + w.router.Close() + w.log.Debug().Msgf("Service loop end") + }() + + for { + select { + case <-w.done: + return + default: + conn, err := connect(remoteAddr, w.conf.Worker, w.address, w.log) + if err != nil { + w.log.Error().Err(err).Msgf("no connection: %v. Retrying in %v", remoteAddr, retry) + time.Sleep(retry) + continue + } + w.cord = conn + w.cord.Log.Info().Msgf("Connected to the coordinator %v", remoteAddr) + w.cord.HandleRequests(w) + <-w.cord.Done() + w.router.Close() + } + } + }() +} +func (w *Worker) Stop() error { return nil } diff --git a/tests/e2e/main_test.go b/tests/e2e/main_test.go deleted file mode 100644 index dffc7cad..00000000 --- a/tests/e2e/main_test.go +++ /dev/null @@ -1,516 +0,0 @@ -package e2e - -// import ( -// "fmt" -// "log" -// "net/http" -// "net/http/httptest" -// "os" -// "strings" -// "testing" -// "time" -// -// "github.com/giongto35/cloud-game/pkg/cws" -// "github.com/giongto35/cloud-game/pkg/overlord" -// "github.com/giongto35/cloud-game/pkg/util" -// gamertc "github.com/giongto35/cloud-game/pkg/webrtc" -// "github.com/giongto35/cloud-game/pkg/worker" -// "github.com/gorilla/websocket" -// "github.com/pion/webrtc/v2" -// ) -// -// var host = "http://localhost:8000" -// -// // Test is in cmd, so gamePath is in parent path -// var testGamePath = "../games" -// var webrtcconfig = webrtc.Configuration{ICEServers: []webrtc.ICEServer{{ -// URLs: []string{"stun:stun.l.google.com:19302"}, -// }}} -// -// func initCoordinator() (*httptest.Server, *httptest.Server) { -// server := overlord.NewServer() -// overlordWorker := httptest.NewServer(http.HandlerFunc(server.WSO)) -// overlordBrowser := httptest.NewServer(http.HandlerFunc(server.WS)) -// return overlordWorker, overlordBrowser -// } -// -// func initWorker(t *testing.T, overlordURL string) *worker.Handler { -// fmt.Println("Spawn new worker") -// if overlordURL == "" { -// return nil -// } else { -// overlordURL = "ws" + strings.TrimPrefix(overlordURL, "http") -// fmt.Println("connecting to overlord: ", overlordURL) -// } -// -// handler := worker.NewHandler(overlordURL, testGamePath) -// -// go handler.Run() -// time.Sleep(time.Second) -// return handler -// } -// -// func initClient(t *testing.T, host string) (client *cws.Client) { -// // Convert http://127.0.0.1 to ws://127.0.0. -// u := "ws" + strings.TrimPrefix(host, "http") -// -// fmt.Println("Connecting to", u) -// ws, _, err := websocket.DefaultDialer.Dial(u, nil) -// if err != nil { -// t.Fatalf("%v", err) -// } -// -// handshakedone := make(chan struct{}) -// // Simulate peerconnection initialization from client -// fmt.Println("Simulating PeerConnection") -// peerConnection, err := webrtc.NewPeerConnection(webrtcconfig) -// if err != nil { -// t.Fatalf("%v", err) -// } -// -// offer, err := peerConnection.CreateOffer(nil) -// if err != nil { -// t.Fatalf("%v", err) -// } -// -// // Sets the LocalDescription, and starts our UDP listeners -// err = peerConnection.SetLocalDescription(offer) -// if err != nil { -// panic(err) -// } -// // Send offer to overlord -// log.Println("Browser Client") -// client = cws.NewClient(ws) -// go client.Listen() -// -// fmt.Println("Sending offer...") -// client.Send(cws.WSPacket{ -// ID: "init_webrtc", -// Data: gamertc.Encode(offer), -// }, nil) -// fmt.Println("Waiting sdp...") -// -// client.Receive("sdp", func(resp cws.WSPacket) cws.WSPacket { -// log.Println("Received SDP", resp.Data, "client: ", client) -// answer := webrtc.SessionDescription{} -// gamertc.Decode(resp.Data, &answer) -// // Apply the answer as the remote description -// err = peerConnection.SetRemoteDescription(answer) -// if err != nil { -// panic(err) -// } -// -// // TODO: may block in the second call -// handshakedone <- struct{}{} -// -// return cws.EmptyPacket -// }) -// -// // Request Offer routing -// client.Receive("requestOffer", func(resp cws.WSPacket) cws.WSPacket { -// log.Println("Frontend received requestOffer") -// peerConnection, err = webrtc.NewPeerConnection(webrtcconfig) -// if err != nil { -// t.Fatalf("%v", err) -// } -// -// log.Println("Recreating offer") -// offer, err := peerConnection.CreateOffer(nil) -// if err != nil { -// t.Fatalf("%v", err) -// } -// -// log.Println("Set localDesc") -// err = peerConnection.SetLocalDescription(offer) -// if err != nil { -// panic(err) -// } -// log.Println("return offer") -// return cws.WSPacket{ -// ID: "init_webrtc", -// Data: gamertc.Encode(offer), -// } -// }) -// -// <-handshakedone -// return client -// // If receive roomID, the server is running correctly -// } -// -// func TestSingleServerOneCoordinator(t *testing.T) { -// /* -// Case scenario: -// - A server X are initilized -// - Client join room with coordinator -// Expected behavior: -// - Room received not empty. -// */ -// -// oworker, obrowser := initCoordinator() -// defer obrowser.Close() -// defer oworker.Close() -// -// // Init worker -// worker := initWorker(t, oworker.URL) -// defer worker.Close() -// -// // connect overlord -// client := initClient(t, obrowser.URL) -// defer client.Close() -// -// fmt.Println("Sending start...") -// roomID := make(chan string) -// client.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: "", -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// roomID <- resp.RoomID -// }) -// -// respRoomID := <-roomID -// if respRoomID == "" { -// fmt.Println("RoomID should not be empty") -// t.Fail() -// } -// time.Sleep(time.Second) -// fmt.Println("Done") -// } -// -// func TestTwoServerOneCoordinator(t *testing.T) { -// /* -// Case scenario: -// - Two server X, Y are initilized -// - Client A creates a room on server X -// - Client B creates a room on server Y -// - Client B join a room created by A -// Expected behavior: -// - Bridge connection will be conducted between server Y and X -// - Client B can join a room hosted on A -// */ -// -// oworker, obrowser := initCoordinator() -// defer obrowser.Close() -// defer oworker.Close() -// -// worker1 := initWorker(t, oworker.URL) -// defer worker1.Close() -// -// worker2 := initWorker(t, oworker.URL) -// defer worker2.Close() -// -// client1 := initClient(t, obrowser.URL) -// defer client1.Close() -// -// roomID := make(chan string) -// client1.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: "", -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// roomID <- resp.RoomID -// }) -// -// remoteRoomID := <-roomID -// if remoteRoomID == "" { -// fmt.Println("RoomID should not be empty") -// t.Fail() -// } -// fmt.Println("Done create a room in server 1") -// -// // ------------------------------------ -// // Client2 trying to create a random room and later join the the room on server1 -// client2 := initClient(t, obrowser.URL) -// defer client2.Close() -// // Wait -// // Doing the same create local room. -// localRoomID := make(chan string) -// client2.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: "", -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// localRoomID <- resp.RoomID -// }) -// -// <-localRoomID -// -// fmt.Println("Request the room from server 1", remoteRoomID) -// log.Println("Server2 trying to join server1 room") -// // After trying loging in to one session, login to other with the roomID -// bridgeRoom := make(chan string) -// client2.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: remoteRoomID, -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// bridgeRoom <- resp.RoomID -// }) -// -// <-bridgeRoom -// //respRoomID := <-bridgeRoom -// //if respRoomID == "" { -// //fmt.Println("The room ID should be equal to the saved room") -// //t.Fail() -// //} -// // If receive roomID, the server is running correctly -// time.Sleep(time.Second) -// fmt.Println("Done") -// } -// -// func TestReconnectRoom(t *testing.T) { -// /* -// Case scenario: -// - A server X is initialized connecting to overlord -// - Client A creates a room K on server X -// - Server X is turned down, Client is closed -// - Spawn a new server and a new client connecting to the same room K -// Expected behavior: -// - The game should be continue -// TODO: Current test just make sure the game is running, not check if the game is the same -// */ -// -// oworker, obrowser := initCoordinator() -// defer obrowser.Close() -// defer oworker.Close() -// -// // Init worker -// worker := initWorker(t, oworker.URL) -// -// client := initClient(t, obrowser.URL) -// -// fmt.Println("Sending start...") -// roomID := make(chan string) -// client.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: "", -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// roomID <- resp.RoomID -// }) -// -// saveRoomID := <-roomID -// if saveRoomID == "" { -// fmt.Println("RoomID should not be empty") -// t.Fail() -// } -// -// log.Println("Closing room and server") -// client.Close() -// worker.GetCoordinatorClient().Close() -// worker.Close() -// -// // Close server and reconnect -// -// log.Println("Server respawn") -// // Init slave server again -// worker = initWorker(t, oworker.URL) -// defer worker.Close() -// -// client = initClient(t, obrowser.URL) -// defer client.Close() -// -// fmt.Println("Re-access room ", saveRoomID) -// roomID = make(chan string) -// client.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: saveRoomID, -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// roomID <- resp.RoomID -// }) -// -// respRoomID := <-roomID -// if respRoomID == "" || respRoomID != saveRoomID { -// fmt.Println("The room ID should be equal to the saved room") -// t.Fail() -// } -// -// time.Sleep(time.Second) -// fmt.Println("Done") -// } -// -// func TestReconnectRoomNoLocal(t *testing.T) { -// /* -// Case scenario: -// - A server X is initialized connecting to overlord -// - Client A creates a room K on server X -// - Server X is turned down, Client is closed -// - room on local is deleted -// - Spawn a new server and a new client connecting to the same room K -// Expected behavior: -// - room on local storage is refetched from cloud storage -// - The game should be continue where it is closed -// TODO: Current test just make sure the game is running, not check if the game is the same -// */ -// // This test only run if GCP storage is set -// -// oworker, obrowser := initCoordinator() -// defer obrowser.Close() -// defer oworker.Close() -// -// // Init worker -// ggCredential := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") -// if ggCredential == "" { -// return -// } -// -// worker := initWorker(t, oworker.URL) -// -// client := initClient(t, obrowser.URL) -// -// fmt.Println("Sending start...") -// roomID := make(chan string) -// client.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: "", -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// roomID <- resp.RoomID -// }) -// -// saveRoomID := <-roomID -// if saveRoomID == "" { -// fmt.Println("RoomID should not be empty") -// t.Fail() -// } -// -// log.Println("Closing room and server") -// client.Close() -// worker.GetCoordinatorClient().Close() -// worker.Close() -// // Remove room on local -// path := util.GetSavePath(saveRoomID) -// os.Remove(path) -// // Expect Google cloud call -// -// // Close server and reconnect -// -// log.Println("Server respawn") -// // Init slave server again -// worker = initWorker(t, oworker.URL) -// defer worker.Close() -// -// client = initClient(t, obrowser.URL) -// defer client.Close() -// -// fmt.Println("Re-access room ", saveRoomID) -// roomID = make(chan string) -// client.Send(cws.WSPacket{ -// ID: "start", -// Data: "Contra.nes", -// RoomID: saveRoomID, -// PlayerIndex: 1, -// }, func(resp cws.WSPacket) { -// fmt.Println("RoomID:", resp.RoomID) -// roomID <- resp.RoomID -// }) -// -// respRoomID := <-roomID -// if respRoomID == "" || respRoomID != saveRoomID { -// fmt.Println("The room ID should be equal to the saved room") -// t.Fail() -// } -// -// time.Sleep(time.Second) -// fmt.Println("Done") -// } - -//func TestRejoinNoCoordinatorMultiple(t *testing.T) { -//[> -//Case scenario: -//- A server X without connecting to overlord -//- Client A keeps creating a new room -//Expected behavior: -//- The game should running normally -//*/ - -//// Init slave server -//s := initServer(t, nil) -//defer s.Close() - -//fmt.Println("Num goRoutine before start: ", runtime.NumGoroutine()) -//client := initClient(t, s.URL) -//for i := 0; i < 100; i++ { -//fmt.Println("Sending start...") -//// Keep starting game -//roomID := make(chan string) -//client.Send(cws.WSPacket{ -//ID: "start", -//Data: "Contra.nes", -//RoomID: "", -//PlayerIndex: 1, -//}, func(resp cws.WSPacket) { -//fmt.Println("RoomID:", resp.RoomID) -//roomID <- resp.RoomID -//}) - -//respRoomID := <-roomID -//if respRoomID == "" { -//fmt.Println("The room ID should be equal to the saved room") -//t.Fail() -//} -//} -//time.Sleep(time.Second) -//fmt.Println("Num goRoutine should be small: ", runtime.NumGoroutine()) -//fmt.Println("Done") - -//} - -//func TestRejoinWithCoordinatorMultiple(t *testing.T) { -//[> -//Case scenario: -//- A server X is initialized connecting to overlord -//- Client A keeps creating a new room -//Expected behavior: -//- The game should running normally -//*/ - -//// Init slave server -//o := initCoordinator() -//defer o.Close() - -//oconn := connectTestCoordinatorServer(t, o.URL) -//// Init slave server -//s := initServer(t, oconn) -//defer s.Close() - -//fmt.Println("Num goRoutine before start: ", runtime.NumGoroutine()) -//client := initClient(t, s.URL) -//for i := 0; i < 100; i++ { -//fmt.Println("Sending start...") -//// Keep starting game -//roomID := make(chan string) -//client.Send(cws.WSPacket{ -//ID: "start", -//Data: "Contra.nes", -//RoomID: "", -//PlayerIndex: 1, -//}, func(resp cws.WSPacket) { -//fmt.Println("RoomID:", resp.RoomID) -//roomID <- resp.RoomID -//}) - -//respRoomID := <-roomID -//if respRoomID == "" { -//fmt.Println("The room ID should be equal to the saved room") -//t.Fail() -//} -//} -//fmt.Println("Num goRoutine should be small: ", runtime.NumGoroutine()) -//fmt.Println("Done") - -//} diff --git a/web/index.html b/web/index.html index 78f02b9d..d6f83333 100644 --- a/web/index.html +++ b/web/index.html @@ -1,5 +1,4 @@ - - + @@ -18,8 +17,8 @@ + Cloud Retro -
W
@@ -64,7 +63,6 @@
-
@@ -95,7 +93,6 @@
{{end}}
- - - + - - - + + - + - - + + - - + + + + - + {{if .Analytics.Inject}} @@ -158,7 +155,5 @@ gtag('config', '{{.Analytics.Gtag}}'); {{end}} - - diff --git a/web/js/api/api.js b/web/js/api/api.js new file mode 100644 index 00000000..7fafe225 --- /dev/null +++ b/web/js/api/api.js @@ -0,0 +1,64 @@ +/** + * Server API. + * + * @version 1 + * + */ +const api = (() => { + const endpoints = Object.freeze({ + LATENCY_CHECK: 3, + INIT: 4, + INIT_WEBRTC: 100, + OFFER: 101, + ANSWER: 102, + ICE_CANDIDATE: 103, + GAME_START: 104, + GAME_QUIT: 105, + GAME_SAVE: 106, + GAME_LOAD: 107, + GAME_SET_PLAYER_INDEX: 108, + GAME_TOGGLE_MULTITAP: 109, + GAME_RECORDING: 110, + GET_WORKER_LIST: 111, + }); + + const packet = (type, payload, id) => { + const packet = {t: type}; + if (id !== undefined) packet.id = id; + if (payload !== undefined) packet.p = payload; + + socket.send(packet); + }; + + return Object.freeze({ + endpoint: endpoints, + server: + Object.freeze({ + initWebrtc: () => packet(endpoints.INIT_WEBRTC), + sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))), + sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))), + latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id), + getWorkerList: () => packet(endpoints.GET_WORKER_LIST), + }), + game: + Object.freeze({ + load: () => packet(endpoints.GAME_LOAD), + save: () => packet(endpoints.GAME_SAVE), + setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), + start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, { + game_name: game, + room_id: roomId, + player_index: player, + record: record, + record_user: recordUser, + }), + toggleMultitap: () => packet(endpoints.GAME_TOGGLE_MULTITAP), + toggleRecording: (active = false, userName = '') => + packet(endpoints.GAME_RECORDING, { + active: active, + user: userName, + }), + quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), + }) + }) +})(socket); diff --git a/web/js/controller.js b/web/js/controller.js index 15510c94..ab0ad129 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -10,6 +10,9 @@ // first user interaction let interacted = false; + // ping-pong + // let pingPong = 0; + const DIR = (() => { return { IDLE: 'idle', @@ -63,7 +66,23 @@ message.show('Now you can share you game!'); }; + // const onWebrtcMessage = () => { + // event.pub(PING_RESPONSE); + // }; + const onConnectionReady = () => { + // ping / pong + // if (pingPong === 0) { + // pingPong = setInterval(() => { + // if (!webrtc.message('x')) { + // clearInterval(pingPong); + // pingPong = 0; + // log.info("ping-pong was disabled due to remote channel error"); + // } + // event.pub(PING_REQUEST, {time: Date.now()}) + // }, 10000); + // } + // start a game right away or show the menu if (room.getId()) { startGame(); @@ -77,7 +96,7 @@ const servers = await workerManager.checkLatencies(data); const latencies = Object.assign({}, ...servers); log.info('[ping] <->', latencies); - socket.latency(latencies, data.packetId); + api.server.latencyCheck(data.packetId, latencies); }; const helpScreen = { @@ -119,12 +138,12 @@ }; const startGame = () => { - if (!rtcp.isConnected()) { + if (!webrtc.isConnected()) { message.show('Game cannot load. Please refresh'); return; } - if (!rtcp.isInputReady()) { + if (!webrtc.isInputReady()) { message.show('Game is not ready yet. Please wait'); return; } @@ -140,26 +159,63 @@ // currently it's a game with the index 1 // on the server this game is ignored and the actual game will be extracted from the share link // so there's no point in doing this and this' really confusing - socket.startGame( + + api.game.start( gameList.getCurrentGame(), - env.isMobileDevice(), room.getId(), recording.isActive(), recording.getUser(), - +playerIndex.value - 1); + +playerIndex.value - 1, + ); // clear menu screen - input.poll().disable(); + input.poll.disable(); gui.hide(menuScreen); stream.toggle(true); gui.show(keyButtons[KEY.SAVE]); gui.show(keyButtons[KEY.LOAD]); // end clear - input.poll().enable(); + input.poll.enable(); }; - const saveGame = utils.debounce(socket.saveGame, 1000); - const loadGame = utils.debounce(socket.loadGame, 1000); + const saveGame = utils.debounce(() => api.game.save(), 1000); + const loadGame = utils.debounce(() => api.game.load(), 1000); + + const onMessage = (message) => { + const {id, t, p: payload} = message; + switch (t) { + case api.endpoint.INIT: + event.pub(WEBRTC_NEW_CONNECTION, payload); + break; + case api.endpoint.OFFER: + event.pub(WEBRTC_SDP_OFFER, {sdp: payload}); + break; + case api.endpoint.ICE_CANDIDATE: + event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); + break; + case api.endpoint.GAME_START: + event.pub(GAME_ROOM_AVAILABLE, {roomId: payload}); + break; + case api.endpoint.GAME_SAVE: + event.pub(GAME_SAVED); + break; + case api.endpoint.GAME_LOAD: + event.pub(GAME_LOADED); + break; + case api.endpoint.GAME_SET_PLAYER_INDEX: + event.pub(GAME_PLAYER_IDX_SET, payload); + break; + case api.endpoint.GET_WORKER_LIST: + event.pub(WORKER_LIST_FETCHED, payload); + break; + case api.endpoint.LATENCY_CHECK: + event.pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload}); + break; + case api.endpoint.GAME_RECORDING: + event.pub(RECORDING_STATUS_CHANGED, payload); + break; + } + } const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; @@ -209,12 +265,11 @@ const updatePlayerIndex = idx => { playerIndex.value = idx + 1; - socket.updatePlayerIndex(idx); + api.game.setPlayerIndex(idx); }; // noop function for the state - const _nil = () => { - } + const _nil = () => ({/*_*/}) const onAxisChanged = (data) => { // maybe move it somewhere @@ -228,19 +283,19 @@ }; const handleToggle = () => { - let toggle = document.getElementById('dpad-toggle'); + const toggle = document.getElementById('dpad-toggle'); toggle.checked = !toggle.checked; event.pub(DPAD_TOGGLE, {checked: toggle.checked}); }; const handleRecording = (data) => { const {recording, userName} = data; - socket.toggleRecording(recording, userName); + api.game.toggleRecording(recording, userName); } const handleRecordingStatus = (data) => { if (data === 'ok') { - message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`) + message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`, true) if (recording.isActive()) { recording.setIndicator(true) } @@ -251,21 +306,25 @@ console.log("recording is ", recording.isActive()) } + const _default = { + name: 'default', + axisChanged: _nil, + keyPress: _nil, + keyRelease: _nil, + menuReady: _nil, + } const app = { state: { eden: { + ..._default, name: 'eden', - axisChanged: _nil, - keyPress: _nil, - keyRelease: _nil, menuReady: showMenuScreen }, settings: { + ..._default, _uber: true, name: 'settings', - axisChanged: _nil, - keyPress: _nil, keyRelease: key => { if (key === KEY.SETTINGS) { const isSettingsOpened = settings.ui.toggle(); @@ -276,6 +335,7 @@ }, menu: { + ..._default, name: 'menu', axisChanged: (id, value) => { if (id === 1) { // Left Stick, Y Axis @@ -340,17 +400,13 @@ break; } }, - menuReady: _nil }, game: { + ..._default, name: 'game', - axisChanged: (id, value) => { - input.setAxisChanged(id, value); - }, - keyPress: key => { - input.setKeyState(key, true); - }, + axisChanged: (id, value) => input.setAxisChanged(id, value), + keyPress: key => input.setKeyState(key, true), keyRelease: function (key) { input.setKeyState(key, false); @@ -387,15 +443,14 @@ // toggle multitap case KEY.MULTITAP: - socket.toggleMultitap(); + api.game.toggleMultitap(); break; // quit case KEY.QUIT: - input.poll().disable(); + input.poll.disable(); - // TODO: Stop game - socket.quitGame(room.getId()); + api.game.quit(room.getId()); room.reset(); message.show('Quit!'); @@ -411,36 +466,45 @@ break; } }, - menuReady: _nil } } }; // subscriptions + event.sub(MESSAGE, onMessage); + event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2); event.sub(GAME_SAVED, () => message.show('Saved')); event.sub(GAME_LOADED, () => message.show('Loaded')); - event.sub(GAME_PLAYER_IDX_CHANGE, data => { - updatePlayerIndex(data.index); + event.sub(GAME_PLAYER_IDX, data => { + updatePlayerIndex(+data.index); }); - event.sub(GAME_PLAYER_IDX, idx => { + event.sub(GAME_PLAYER_IDX_SET, idx => { if (!isNaN(+idx)) message.show(+idx + 1); }); - - event.sub(MEDIA_STREAM_INITIALIZED, (data) => { - workerManager.whoami(data.xid); - rtcp.start(data.stunturn); + event.sub(WEBRTC_NEW_CONNECTION, (data) => { + // if (pingPong) { + // webrtc.setMessageHandler(onWebrtcMessage); + // } + workerManager.whoami(data.wid); + webrtc.start(data.ice); + api.server.initWebrtc() gameList.set(data.games); }); - event.sub(MEDIA_STREAM_SDP_AVAILABLE, (data) => rtcp.setRemoteDescription(data.sdp, stream.video.el())); - event.sub(MEDIA_STREAM_CANDIDATE_ADD, (data) => rtcp.addCandidate(data.candidate)); - event.sub(MEDIA_STREAM_CANDIDATE_FLUSH, () => rtcp.flushCandidate()); - event.sub(MEDIA_STREAM_READY, () => rtcp.start()); - event.sub(CONNECTION_READY, onConnectionReady); - event.sub(CONNECTION_CLOSED, () => { - input.poll().disable(); - socket.abort(); - rtcp.stop(); + event.sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate)); + event.sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp)); + event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el())); + event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)); + event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); + // event.sub(MEDIA_STREAM_READY, () => rtcp.start()); + event.sub(WEBRTC_CONNECTION_READY, onConnectionReady); + event.sub(WEBRTC_CONNECTION_CLOSED, () => { + input.poll.disable(); + // if (pingPong > 0) { + // clearInterval(pingPong); + // pingPong = 0; + // } + webrtc.stop(); }); event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheck); event.sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected')); @@ -451,13 +515,12 @@ }); event.sub(KEY_PRESSED, onKeyPress); event.sub(KEY_RELEASED, onKeyRelease); - event.sub(KEY_STATE_UPDATED, data => rtcp.input(data)); event.sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); event.sub(SETTINGS_CLOSED, () => { state.keyRelease(KEY.SETTINGS); }); event.sub(AXIS_CHANGED, onAxisChanged); - event.sub(CONTROLLER_UPDATED, data => rtcp.input(data)); + event.sub(CONTROLLER_UPDATED, data => webrtc.input(data)); // recording event.sub(RECORDING_TOGGLED, handleRecording); event.sub(RECORDING_STATUS_CHANGED, handleRecordingStatus); @@ -471,4 +534,4 @@ // initial app state setState(app.state.eden); -})(document, event, env, gameList, input, KEY, log, message, recording, room, rtcp, settings, socket, stats, stream, utils, workerManager); +})(api, document, event, env, gameList, input, KEY, log, message, recording, room, settings, socket, stats, stream, utils, webrtc, workerManager); diff --git a/web/js/event/event.js b/web/js/event/event.js index 42748b04..922d468f 100644 --- a/web/js/event/event.js +++ b/web/js/event/event.js @@ -58,23 +58,24 @@ const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; const PING_REQUEST = 'pingRequest'; const PING_RESPONSE = 'pingResponse'; -const GET_SERVER_LIST = 'getServerList'; +const WORKER_LIST_FETCHED = 'workerListFetched'; const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; const GAME_SAVED = 'gameSaved'; const GAME_LOADED = 'gameLoaded'; -// used to transfer the index value between touch and controller -const GAME_PLAYER_IDX_CHANGE = 'gamePlayerIndexChange'; const GAME_PLAYER_IDX = 'gamePlayerIndex'; +const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet' -const CONNECTION_READY = 'connectionReady'; -const CONNECTION_CLOSED = 'connectionClosed'; +const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed'; +const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady'; +const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound' +const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived'; +const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush'; +const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection'; +const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer' +const WEBRTC_SDP_OFFER = 'webrtcSdpOffer'; -const MEDIA_STREAM_INITIALIZED = 'mediaStreamInitialized'; -const MEDIA_STREAM_SDP_AVAILABLE = 'mediaStreamSdpAvailable'; -const MEDIA_STREAM_CANDIDATE_ADD = 'mediaStreamCandidateAdd'; -const MEDIA_STREAM_CANDIDATE_FLUSH = 'mediaStreamCandidateFlush'; -const MEDIA_STREAM_READY = 'mediaStreamReady'; +const MESSAGE = 'message' const GAMEPAD_CONNECTED = 'gamepadConnected'; const GAMEPAD_DISCONNECTED = 'gamepadDisconnected'; @@ -85,7 +86,6 @@ const MENU_RELEASED = 'menuReleased'; const KEY_PRESSED = 'keyPressed'; const KEY_RELEASED = 'keyReleased'; -const KEY_STATE_UPDATED = 'keyStateUpdated'; const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode'; const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed'; const AXIS_CHANGED = 'axisChanged'; diff --git a/web/js/input/input.js b/web/js/input/input.js index 9116b01a..0ccf5b48 100644 --- a/web/js/input/input.js +++ b/web/js/input/input.js @@ -1,56 +1,73 @@ const input = (() => { - let pollIntervalMs = 10; - let pollIntervalId = 0; + const pollingIntervalMs = 4; let controllerChangedIndex = -1; + // Libretro config let controllerState = { - // control - [KEY.A]: false, [KEY.B]: false, - [KEY.X]: false, [KEY.Y]: false, - [KEY.L]: false, - [KEY.R]: false, [KEY.SELECT]: false, [KEY.START]: false, - // dpad [KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false, + [KEY.A]: false, + [KEY.X]: false, // extra - [KEY.R2]: false, + [KEY.L]: false, + [KEY.R]: false, [KEY.L2]: false, - [KEY.R3]: false, - [KEY.L3]: false + [KEY.R2]: false, + [KEY.L3]: false, + [KEY.R3]: false }; - const controllerEncoded = new Array(5).fill(0); - - const keys = Object.keys(controllerState); - - const poll = () => { + const poll = (intervalMs, callback) => { + let _ticker = 0; return { - setPollInterval: (ms) => pollIntervalMs = ms, enable: () => { - if (pollIntervalId > 0) return; - - log.info(`[input] poll set to ${pollIntervalMs}ms`); - pollIntervalId = setInterval(sendControllerState, pollIntervalMs) + if (_ticker > 0) return; + log.debug(`[input] poll set to ${intervalMs}ms`); + _ticker = setInterval(callback, intervalMs) }, disable: () => { - if (pollIntervalId < 1) return; - - log.info('[input] poll has been disabled'); - clearInterval(pollIntervalId); - pollIntervalId = 0; + if (_ticker < 1) return; + log.debug('[input] poll has been disabled'); + clearInterval(_ticker); + _ticker = 0; } } }; + const controllerEncoded = [0, 0, 0, 0, 0]; + const keys = Object.keys(controllerState); + + const compare = (a, b) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + }; + + + // let lastState = controllerEncoded; + const sendControllerState = () => { if (controllerChangedIndex >= 0) { - event.pub(CONTROLLER_UPDATED, _encodeState()); + const state = _getState(); + + // log.debug(state) + + // if (compare(lastState, state)) { + // log.debug('!skip') + // } else { + event.pub(CONTROLLER_UPDATED, _encodeState(state)); + // } + // lastState = state; controllerChangedIndex = -1; } }; @@ -63,9 +80,9 @@ const input = (() => { }; const setAxisChanged = (index, value) => { - if (controllerEncoded[index+1] !== undefined) { - controllerEncoded[index+1] = Math.floor(32767 * value); - controllerChangedIndex = Math.max(controllerChangedIndex, index+1); + if (controllerEncoded[index + 1] !== undefined) { + controllerEncoded[index + 1] = Math.floor(32767 * value); + controllerChangedIndex = Math.max(controllerChangedIndex, index + 1); } }; @@ -79,16 +96,19 @@ const input = (() => { * * @private */ - const _encodeState = () => { - controllerEncoded[0] = 0; - for (let i = 0, len = keys.length; i < len; i++) controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0; + const _encodeState = (state) => new Uint16Array(state) - return new Uint16Array(controllerEncoded.slice(0, controllerChangedIndex+1)); + const _getState = () => { + controllerEncoded[0] = 0; + for (let i = 0, len = keys.length; i < len; i++) { + controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0; + } + return controllerEncoded.slice(0, controllerChangedIndex + 1); } return { - poll, + poll: poll(pollingIntervalMs, sendControllerState), setKeyState, setAxisChanged, } -})(event, KEY); +})(event, KEY, log); diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index 386e6920..91b9f548 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -55,51 +55,45 @@ const keyboard = (() => { if (dpadMode === checked) { return //error? } + + dpadMode = !dpadMode if (dpadMode) { - dpadMode = false; // reset dpad keys pressed before moving to analog stick mode for (const key in dpadState) { - if (dpadState[key] === true) { + if (dpadState[key]) { dpadState[key] = false; event.pub(KEY_RELEASED, {key: key}); } } } else { - dpadMode = true; // reset analog stick axes before moving to dpad mode - value = (dpadState[KEY.RIGHT] === true ? 1 : 0) - (dpadState[KEY.LEFT] === true ? 1 : 0) - if (value !== 0) { + if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) { event.pub(AXIS_CHANGED, {id: 0, value: 0}); } - value = (dpadState[KEY.DOWN] === true ? 1 : 0) - (dpadState[KEY.UP] === true ? 1 : 0) - if (value !== 0) { + if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) { event.pub(AXIS_CHANGED, {id: 1, value: 0}); } dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; } } - const onKey = (code, callback, state) => { - if (code in keyMap) { - key = keyMap[code] - if (key in dpadState) { - dpadState[key] = state - if (dpadMode) { - callback(key); - } else { - if (key === KEY.LEFT || key === KEY.RIGHT) { - value = (dpadState[KEY.RIGHT] === true ? 1 : 0) - (dpadState[KEY.LEFT] === true ? 1 : 0) - event.pub(AXIS_CHANGED, {id: 0, value: value}); - } else { - value = (dpadState[KEY.DOWN] === true ? 1 : 0) - (dpadState[KEY.UP] === true ? 1 : 0) - event.pub(AXIS_CHANGED, {id: 1, value: value}); - } - } - } else { - callback(key); + const onKey = (code, evt, state) => { + const key = keyMap[code] + if (key === undefined) return + + if (dpadState[key] !== undefined) { + dpadState[key] = state + if (!dpadMode) { + const LR = key === KEY.LEFT || key === KEY.RIGHT + event.pub(AXIS_CHANGED, { + id: !LR, + value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP] + }) + return } } - }; + event.pub(evt, {key: key}) + } event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); @@ -111,25 +105,24 @@ const keyboard = (() => { body.addEventListener('keyup', e => { e.stopPropagation(); if (isKeysFilteredMode) { - onKey(e.code, key => event.pub(KEY_RELEASED, {key: key})); + onKey(e.code, KEY_RELEASED, false) } else { event.pub(KEYBOARD_KEY_PRESSED, {key: e.code}); } }, false); + body.addEventListener('keydown', e => { e.stopPropagation(); if (isKeysFilteredMode) { - onKey(e.code, key => event.pub(KEY_PRESSED, {key: key}), true) + onKey(e.code, KEY_PRESSED, true) } else { event.pub(KEYBOARD_KEY_PRESSED, {key: e.code}); } }); log.info('[input] keyboard has been initialized'); - }, - settings: { + }, settings: { remap } } -}) -(event, document, KEY, log, opts, settings); +})(event, document, KEY, log, opts, settings); diff --git a/web/js/input/touch.js b/web/js/input/touch.js index 4693b1e0..97b45a9a 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -165,9 +165,8 @@ const touch = (() => { }, 30); } - // !to rewrite slider completely function handlePlayerSlider() { - event.pub(GAME_PLAYER_IDX_CHANGE, {index: +this.value - 1}) + event.pub(GAME_PLAYER_IDX, {index: this.value - 1}); } // Touch menu diff --git a/web/js/network/socket.js b/web/js/network/socket.js index b675cb84..06351246 100644 --- a/web/js/network/socket.js +++ b/web/js/network/socket.js @@ -4,157 +4,50 @@ * Needs init() call. * * @version 1 + * + * Events: + * @link MESSAGE + * */ const socket = (() => { - // TODO: this ping is for maintain websocket state - /* - https://tools.ietf.org/html/rfc6455#section-5.5.2 - - Chrome doesn't support - https://groups.google.com/a/chromium.org/forum/#!topic/net-dev/2RAm-ZYAIYY - https://bugs.chromium.org/p/chromium/issues/detail?id=706002 - - Firefox has option but not enable 'network.websocket.timeout.ping.request' - - Suppose ping message must be sent from WebSocket Server. - Gorilla WS doesnot support it. - https://github.com/gorilla/websocket/blob/5ed622c449da6d44c3c8329331ff47a9e5844f71/examples/filewatch/main.go#L104 - - Below is high level implementation of ping. - // TODO: find the best ping time, currently 2 seconds works well in Chrome+Firefox - */ - const pingIntervalMs = 2000; - let pingIntervalId = 0; - let conn; + const buildUrl = (params = {}) => { + const url = new URL(window.location); + url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss'; + url.pathname = "/ws"; + Object.keys(params).forEach(k => { + if (!!params[k]) url.searchParams.set(k, params[k]) + }) + return url + } + const init = (roomId, wid, zone) => { let objParams = {room_id: roomId, zone: zone}; if (wid) objParams.wid = wid; - const params = new URLSearchParams(objParams).toString() - const address = `${location.protocol !== 'https:' ? 'ws' : 'wss'}://${location.host}/ws?${params}`; - console.info(`[ws] connecting to ${address}`); - conn = new WebSocket(address); - - // Clear old roomID + const url = buildUrl(objParams) + console.info(`[ws] connecting to ${url}`); + conn = new WebSocket(url.toString()); conn.onopen = () => { - if (pingIntervalId > 0) return; log.info('[ws] <- open connection'); - log.info(`[ws] -> setting ping interval to ${pingIntervalMs}ms`); - // !to add destructor if SPA - pingIntervalId = setInterval(ping, pingIntervalMs); }; conn.onerror = () => log.error('[ws] some error!'); conn.onclose = (event) => log.info(`[ws] closed (${event.code})`); - // Message received from server conn.onmessage = response => { const data = JSON.parse(response.data); - const message = data.id; - - if (message !== 'heartbeat') log.debug(`[ws] <- message '${message}' `, data); - - switch (message) { - case 'init': - // TODO: Read from struct - // init package has 3 part [xid, stunturn, game1, game2, game3 ...] - const [xid, stunturn, ...games] = JSON.parse(data.data); - // let serverData = data.data); - event.pub(MEDIA_STREAM_INITIALIZED, {xid: xid, stunturn: stunturn, games: games}); - break; - case 'offer': - // this is offer from worker - event.pub(MEDIA_STREAM_SDP_AVAILABLE, {sdp: data.data}); - break; - case 'ice_candidate': - event.pub(MEDIA_STREAM_CANDIDATE_ADD, {candidate: data.data}); - break; - case 'heartbeat': - event.pub(PING_RESPONSE); - break; - case 'start': - event.pub(GAME_ROOM_AVAILABLE, {roomId: data.room_id}); - break; - case 'save': - event.pub(GAME_SAVED); - break; - case 'load': - event.pub(GAME_LOADED); - break; - case 'player_index': - event.pub(GAME_PLAYER_IDX, data.data); - break; - case 'checkLatency': - event.pub(LATENCY_CHECK_REQUESTED, {packetId: data.packet_id, addresses: data.data}); - break; - case 'recording': - event.pub(RECORDING_STATUS_CHANGED, data.data); - break; - case 'get_server_list': - event.pub(GET_SERVER_LIST, JSON.parse(data.data)); - break; - } + log.debug('[ws] <- ', data); + event.pub(MESSAGE, data); }; }; - /** - * Abnormal connection termination cleanup. - */ - const abort = () => { - if (pingIntervalId < 0) return; - - log.info('[ws] ping has been disabled'); - clearInterval(pingIntervalId); - pingIntervalId = 0; - } - - // TODO: format the package with time - const ping = () => { - const time = Date.now(); - send({"id": "heartbeat", "data": time.toString()}); - event.pub(PING_REQUEST, {time: time}); - } const send = (data) => { if (conn.readyState === 1) { conn.send(JSON.stringify(data)); } } - const latency = (workers, packetId) => send({ - "id": "checkLatency", - "data": JSON.stringify(workers), - "packet_id": packetId - }); - const saveGame = () => send({"id": "save", "data": ""}); - const loadGame = () => send({"id": "load", "data": ""}); - const updatePlayerIndex = (idx) => send({"id": "player_index", "data": idx.toString()}); - const startGame = (gameName, isMobile, roomId, record, recordUser, playerIndex) => send({ - "id": "start", - "data": JSON.stringify({ - "game_name": gameName, - "record": record, - "record_user": recordUser, - }), - "room_id": roomId != null ? roomId : '', - "player_index": playerIndex - }); - const quitGame = (roomId) => send({"id": "quit", "data": "", "room_id": roomId}); - const toggleMultitap = () => send({"id": "multitap", "data": ""}); - const toggleRecording = (active = false, userName = '') => send({ - "id": "recording", "data": JSON.stringify({"active": active, "user": userName,}) - }) - const getServerList = () => send({"id": "get_server_list", "data": "{}"}) return { init: init, - abort: abort, send: send, - latency: latency, - saveGame: saveGame, - loadGame: loadGame, - updatePlayerIndex: updatePlayerIndex, - startGame: startGame, - quitGame: quitGame, - toggleMultitap: toggleMultitap, - toggleRecording: toggleRecording, - getServerList, } })(event, log); diff --git a/web/js/network/rtcp.js b/web/js/network/webrtc.js similarity index 58% rename from web/js/network/rtcp.js rename to web/js/network/webrtc.js index 8f29a9f7..218e9e75 100644 --- a/web/js/network/rtcp.js +++ b/web/js/network/webrtc.js @@ -1,8 +1,16 @@ /** - * RTCP connection module. + * WebRTC connection module. * @version 1 + * + * Events: + * @link WEBRTC_CONNECTION_CLOSED + * @link WEBRTC_CONNECTION_READY + * @link WEBRTC_ICE_CANDIDATE_FOUND + * @link WEBRTC_ICE_CANDIDATES_FLUSH + * @link WEBRTC_SDP_ANSWER + * */ -const rtcp = (() => { +const webrtc = (() => { let connection; let inputChannel; let mediaStream; @@ -13,61 +21,35 @@ const rtcp = (() => { let connected = false; let inputReady = false; + let onMessage; + const start = (iceservers) => { - log.info(`[rtcp] <- received coordinator's ICE STUN/TURN config: ${iceservers}`); - - connection = new RTCPeerConnection({ - iceServers: JSON.parse(iceservers) - }); - + log.info('[rtc] <- ICE servers', iceservers); + const servers = iceservers || []; + connection = new RTCPeerConnection({iceServers: servers}); mediaStream = new MediaStream(); - // input channel, ordered + reliable, id 0 - // inputChannel = connection.createDataChannel('a', {ordered: true, negotiated: true, id: 0,}); - // recv dataChannel from worker connection.ondatachannel = e => { - log.debug(`[rtcp] ondatachannel: ${e.channel.label}`) + log.debug('[rtc] ondatachannel', e.channel.label) inputChannel = e.channel; inputChannel.onopen = () => { - log.debug('[rtcp] the input channel has opened'); + log.info('[rtc] the input channel has been opened'); inputReady = true; - event.pub(CONNECTION_READY) + event.pub(WEBRTC_CONNECTION_READY) }; - inputChannel.onclose = () => log.debug('[rtcp] the input channel has closed'); + if (onMessage) { + inputChannel.onmessage = onMessage; + } + inputChannel.onclose = () => log.info('[rtc] the input channel has been closed'); } - - // addVoiceStream(connection) - connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; connection.onicegatheringstatechange = ice.onIceStateChange; connection.onicecandidate = ice.onIcecandidate; connection.ontrack = event => { mediaStream.addTrack(event.track); } - - socket.send({'id': 'init_webrtc'}); }; - async function addVoiceStream(connection) { - let stream = null; - - try { - stream = await navigator.mediaDevices.getUserMedia({video: false, audio: true}); - - stream.getTracks().forEach(function (track) { - log.info("Added voice track") - connection.addTrack(track); - }); - - } catch (e) { - log.info("Error getting audio stream from getUserMedia") - log.info(e) - - } finally { - socket.send({'id': 'init_webrtc'}); - } - } - const stop = () => { if (mediaStream) { mediaStream.getTracks().forEach(t => { @@ -85,7 +67,7 @@ const rtcp = (() => { inputChannel = null; } candidates = Array(); - log.info('[rtcp] WebRTC has been closed'); + log.info('[rtc] WebRTC has been closed'); } const ice = (() => { @@ -93,45 +75,43 @@ const rtcp = (() => { let timeForIceGathering; return { - onIcecandidate: event => { - if (!event.candidate) return; - // send ICE candidate to the worker - const candidate = JSON.stringify(event.candidate); - log.info(`[rtcp] user candidate: ${candidate}`); - socket.send({'id': 'ice_candidate', 'data': btoa(candidate)}) + onIcecandidate: data => { + if (!data.candidate) return; + log.info('[rtc] user candidate', data.candidate); + event.pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate}) }, onIceStateChange: event => { switch (event.target.iceGatheringState) { case 'gathering': - log.info('[rtcp] ice gathering'); + log.info('[rtc] ice gathering'); timeForIceGathering = setTimeout(() => { - log.info(`[rtcp] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`); + log.warning(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`); // sendCandidates(); }, ICE_TIMEOUT); break; case 'complete': - log.info('[rtcp] ice gathering completed'); + log.info('[rtc] ice gathering has been completed'); if (timeForIceGathering) { clearTimeout(timeForIceGathering); } } }, onIceConnectionStateChange: () => { - log.info(`[rtcp] <- iceConnectionState: ${connection.iceConnectionState}`); + log.info('[rtc] <- iceConnectionState', connection.iceConnectionState); switch (connection.iceConnectionState) { case 'connected': { - log.info('[rtcp] connected...'); + log.info('[rtc] connected...'); connected = true; break; } case 'disconnected': { - log.info('[rtcp] disconnected...'); + log.info('[rtc] disconnected...'); connected = false; - event.pub(CONNECTION_CLOSED); + event.pub(WEBRTC_CONNECTION_CLOSED); break; } case 'failed': { - log.error('[rtcp] connection failed, retry...'); + log.error('[rtc] failed establish connection, retry...'); connected = false; connection.createOffer({iceRestart: true}) .then(description => connection.setLocalDescription(description).catch(log.error)) @@ -146,6 +126,7 @@ const rtcp = (() => { return { start: start, setRemoteDescription: async (data, media) => { + log.debug('[rtc] remote SDP', data) const offer = new RTCSessionDescription(JSON.parse(atob(data))); await connection.setRemoteDescription(offer); @@ -154,37 +135,46 @@ const rtcp = (() => { // force stereo params for Opus tracks (a=fmtp:111 ...) answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1'); await connection.setLocalDescription(answer); - log.debug("Local SDP: ", answer) + log.debug("[rtc] local SDP", answer) isAnswered = true; - event.pub(MEDIA_STREAM_CANDIDATE_FLUSH); - - socket.send({'id': 'answer', 'data': btoa(JSON.stringify(answer))}); - + event.pub(WEBRTC_ICE_CANDIDATES_FLUSH); + event.pub(WEBRTC_SDP_ANSWER, {sdp: answer}); media.srcObject = mediaStream; }, + // setMessageHandler: (handler) => onMessage = handler, addCandidate: (data) => { if (data === '') { - event.pub(MEDIA_STREAM_CANDIDATE_FLUSH); + event.pub(WEBRTC_ICE_CANDIDATES_FLUSH); } else { candidates.push(data); } }, - flushCandidate: () => { + flushCandidates: () => { if (isFlushing || !isAnswered) return; isFlushing = true; + log.debug('[rtc] flushing candidates', candidates); candidates.forEach(data => { - d = atob(data); - candidate = new RTCIceCandidate(JSON.parse(d)); - log.debug('[rtcp] add candidate: ' + d); - connection.addIceCandidate(candidate); + const candidate = new RTCIceCandidate(JSON.parse(atob(data))) + connection.addIceCandidate(candidate).catch(e => { + log.error('[rtc] candidate add failed', e.name); + }); }); isFlushing = false; }, + // message: (mess = '') => { + // try { + // inputChannel.send(mess) + // return true + // } catch (error) { + // log.error('[rtc] input channel broken ' + error) + // return false + // } + // }, input: (data) => inputChannel.send(data), isConnected: () => connected, isInputReady: () => inputReady, getConnection: () => connection, stop, } -})(event, socket, env, log); +})(event, log); diff --git a/web/js/stats/stats.js b/web/js/stats/stats.js index 6606945e..c71b96e7 100644 --- a/web/js/stats/stats.js +++ b/web/js/stats/stats.js @@ -267,8 +267,8 @@ const stats = (() => { let interval = null function getStats() { - if (!rtcp.isConnected()) return; - rtcp.getConnection().getStats(null).then(stats => { + if (!webrtc.isConnected()) return; + webrtc.getConnection().getStats(null).then(stats => { let frameStatValue = '?'; stats.forEach(report => { if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) { @@ -293,7 +293,7 @@ const stats = (() => { const disable = () => window.clearInterval(interval); return {enable, disable, internal: true} - })(event, rtcp, window); + })(event, webrtc, window); /** * User agent frame stats. @@ -331,13 +331,13 @@ const stats = (() => { } return {get, enable, disable, render} - })(moduleUi, rtcp, window); + })(env, event, moduleUi); const webRTCRttStats = (() => { let value = 0; let listener; - const ui = moduleUi('RTT(w)', true, () => 'ms'); + const ui = moduleUi('RTT', true, () => 'ms'); const get = () => ui.el; @@ -357,16 +357,9 @@ const stats = (() => { } return {get, enable, disable, render} - })(moduleUi, rtcp, window); + })(event, moduleUi); - const modules = (fn, force = true) => { - _modules.forEach(m => { - if (force || !m.internal) { - fn(m); - } - } - ) - } + const modules = (fn, force = true) => _modules.forEach(m => (force || !m.internal) && fn(m)) const enable = () => { active = true; @@ -426,7 +419,7 @@ const stats = (() => { // add submodules _modules.push( webRTCRttStats, - latency, + // latency, clientMemory, webRTCStats_, webRTCFrameStats @@ -437,4 +430,4 @@ const stats = (() => { event.sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle) return {enable, disable} -})(document, env, event, log, rtcp, window); +})(document, env, event, log, webrtc, window); diff --git a/web/js/workerManager.js b/web/js/workerManager.js index 38b6e447..4601530e 100644 --- a/web/js/workerManager.js +++ b/web/js/workerManager.js @@ -23,7 +23,7 @@ const workerManager = (() => { }, 'id': { caption: 'ID', - renderer: (data) => data?.id ? data.xid : `${data.xid} x ${data['replicas']}` + renderer: (data) => data?.in_group ? `${data.id} x ${data.replicas}` : data.id }, 'addr': { caption: 'Address', @@ -70,7 +70,7 @@ const workerManager = (() => { content.append(header) const renderRow = (server) => (row) => { - if (server?.id && state.lastId && state.lastId === server?.xid) { + if (server?.id && state.lastId && state.lastId === server?.id) { row.classList.add('active'); } return fields.forEach(field => { @@ -85,21 +85,19 @@ const workerManager = (() => { function handleReload() { panel.setLoad(true); - socket.getServerList(); + api.server.getWorkerList(); } function renderIdEl(server) { const id = String(index.v()).padStart(2, '0'); - const isActive = server?.id && state.lastId && state.lastId === server?.xid + const isActive = server?.id && state.lastId && state.lastId === server?.id return `${(isActive ? '>' : '')}${id}` } function renderServerChangeEl(server) { const handleServerChange = (e) => { e.preventDefault(); - window.location.search = `wid=${server.xid}` - // window.location = window.location.pathname; - console.log(server.addr, server.id); + window.location.search = `wid=${server.id}` } return gui.create('a', (el) => { el.innerText = '>>'; @@ -116,10 +114,9 @@ const workerManager = (() => { }) const checkLatencies = (data) => { - const _addresses = data.addresses?.split(',') || []; const timeoutMs = 1111; // deduplicate - const addresses = [...new Set(_addresses)]; + const addresses = [...new Set(data.addresses || [])]; return Promise.all(addresses.map(address => { const start = Date.now(); @@ -134,10 +131,10 @@ const workerManager = (() => { _render(state.workers); } - event.sub(GET_SERVER_LIST, onNewData); + event.sub(WORKER_LIST_FETCHED, onNewData); return { checkLatencies, whoami, } -})(ajax, document, event, gui, log, socket, utils); +})(ajax, api, document, event, gui, log, utils);