Add new Libretro core manager module (#249)

* Add initial external configuration files support.

These external configuration files allow changing app params at the runtime without recompilation.

* Find config files with specified directory in the tests

* Add aspect ratio recalculation config

* Clean code

* Add new configuration files into the Docker container image

* Add shared core and config paths into the Libretro cores config

* Split ROM <-> Emulator mapping between workers and coordinators

* Extract coordinator config

* Add shared worker/coordinator server config

* Add explicit embedded shared worker/coordinator struct for auto-config reflection fill

* Remove default stun/turn servers from the config

* Extract and add new ice servers config structures

* Update coordinator config params

* Add auto emulation lib loader based on the runtime OS/arch

* Update configuration structures

* Remove shared config embedding

* Add missing network config params

* Add game library external config

* Remove unused config parameters

* Add WebRTC encoder external options

* Add user dir for config search

* Update config loader

* Update config

* Add generic downloader with Grab lib implementation

* Add a simple file downloader backed by the grab lib

* Add initial Libretro core repos abstractions

* Expose compression info for Libretro cores repository records

* Add pipe-based abstract file downloader

* Refactor downloader

* Refactor Libretro repos

* Add worker coresync stubs

* Add multiprocess-safe HTTP-based core manager implementation

* Remove Libretro cores from the repo

* Keep custom N64 cores in te repo for now

* Add Libretro cores repo select in the config

* Fix http manager repo switch

* Cleanup code

* Add greedy Libretro lib loader

* Don't crash when arch map is not set

* Disable dynamic recompiler for pcsx core by default since it's could cause a crash

* Use global Libretro dynalib handler

* Shorten the default Libretro cores store path

* Update zip extractor implementation

* Remove explicit fig lib field markings

* Add config note to the README file

* Add GitHub repo backend for the core downloader

* Fix GitHub repo param list in the manager factory

* Add env variables reader with CLOUD_GAME prefix

* Re-optimize ice server info struct custom marshaler
This commit is contained in:
sergystepanov 2020-12-31 13:24:27 +03:00 committed by Sergey Stepanov
parent 7299ecc6d1
commit 1fcf34ee02
No known key found for this signature in database
GPG key ID: A56B4929BAA8556B
95 changed files with 1984 additions and 866 deletions

View file

@ -72,7 +72,7 @@ jobs:
./mesa/systemwidedeploy.cmd < ./commands
wget -q https://buildbot.libretro.com/nightly/windows/x86_64/latest/mupen64plus_next_libretro.dll.zip
"/c/Program Files/7-Zip/7z.exe" x mupen64plus_next_libretro.dll.zip -oassets/emulator/libretro/cores
"/c/Program Files/7-Zip/7z.exe" x mupen64plus_next_libretro.dll.zip -oassets/cores
make build

7
Dockerfile vendored
View file

@ -41,8 +41,9 @@ RUN apt-get update && apt-get install -y \
COPY --from=build ${BUILD_PATH}/bin/ ./
RUN cp -s $(pwd)/* /usr/local/bin
COPY web ./web
COPY assets/emulator/libretro/cores/*.so \
assets/emulator/libretro/cores/*.cfg \
./assets/emulator/libretro/cores/
COPY assets/cores/*.so \
assets/cores/*.cfg \
./assets/cores/
COPY configs ./configs
EXPOSE 8000 9000 3478/tcp 3478/udp

4
Makefile vendored
View file

@ -75,7 +75,7 @@ dev.build-local:
dev.run: dev.build-local
./bin/coordinator --v=5 &
./bin/worker --coordinatorhost localhost:8000
dev.run-docker:
docker rm cloud-game-local -f || true
CLOUD_GAME_GAMES_PATH=$(PWD)/assets/games docker-compose up --build
@ -111,7 +111,7 @@ DLIB_ALTER ?= false
CORE_EXT ?= *_libretro.so
COORDINATOR_DIR = ./$(RELEASE_DIR)
WORKER_DIR = ./$(RELEASE_DIR)
CORES_DIR = assets/emulator/libretro/cores
CORES_DIR = assets/cores
GAMES_DIR = assets/games
.PHONY: release
.SILENT: release

7
README.md vendored
View file

@ -75,6 +75,13 @@ It will spawn a docker environment and you can access the service on `localhost:
*Note.*
Docker for Windows is not supposed to work with provided configuration, use WSL2 instead.
## Configuration
The configuration parameters are stored in the [`configs/config.yaml`](configs/config.yaml) file which is shared for
all application instances on the same host system.
It is possible to specify individual configuration files for each instance as well as override some parameters,
for that purpose, please refer to the list of command-line options of the apps.
## Technical Document
- [webrtchacks Blog: Open Source Cloud Gaming with WebRTC](https://webrtchacks.com/open-source-cloud-gaming-with-webrtc/)
- [Wiki (outdated)](https://github.com/giongto35/cloud-game/wiki)

View file

@ -0,0 +1 @@
pcsx_rearmed_drc = disabled

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,22 +2,25 @@ package main
import (
"context"
goflag "flag"
"math/rand"
"os"
"os/signal"
"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/util/logging"
"github.com/golang/glog"
"github.com/spf13/pflag"
flag "github.com/spf13/pflag"
)
func main() {
rand.Seed(time.Now().UTC().UnixNano())
cfg := coordinator.NewDefaultConfig()
cfg.AddFlags(pflag.CommandLine)
conf := config.NewConfig()
flag.CommandLine.AddGoFlagSet(goflag.CommandLine)
conf.ParseFlags()
logging.Init()
defer logging.Flush()
@ -25,8 +28,8 @@ func main() {
ctx, cancelCtx := context.WithCancel(context.Background())
glog.Infof("Initializing coordinator server")
glog.V(4).Infof("Coordinator configs %v", cfg)
o := coordinator.New(ctx, cfg)
glog.V(4).Infof("Coordinator configs %v", conf)
o := coordinator.New(ctx, conf)
if err := o.Run(); err != nil {
glog.Errorf("Failed to run coordinator server, reason %v", err)
os.Exit(1)

View file

@ -2,6 +2,7 @@ package main
import (
"context"
goflag "flag"
"math/rand"
"os"
"os/signal"
@ -12,14 +13,15 @@ import (
"github.com/giongto35/cloud-game/v2/pkg/util/logging"
"github.com/giongto35/cloud-game/v2/pkg/worker"
"github.com/golang/glog"
"github.com/spf13/pflag"
flag "github.com/spf13/pflag"
)
func run() {
rand.Seed(time.Now().UTC().UnixNano())
cfg := config.NewDefaultConfig()
cfg.AddFlags(pflag.CommandLine)
conf := config.NewConfig()
flag.CommandLine.AddGoFlagSet(goflag.CommandLine)
conf.ParseFlags()
logging.Init()
defer logging.Flush()
@ -27,8 +29,8 @@ func run() {
ctx, cancelCtx := context.WithCancel(context.Background())
glog.Infof("Initializing worker server")
glog.V(4).Infof("Worker configs %v", cfg)
o := worker.New(ctx, cfg)
glog.V(4).Infof("Worker configs %v", conf)
o := worker.New(ctx, conf)
if err := o.Run(); err != nil {
glog.Errorf("Failed to run worker, reason %v", err)
os.Exit(1)

161
configs/config.yaml vendored Normal file
View file

@ -0,0 +1,161 @@
#
# Application configuration file
#
# application environment (dev, staging, prod)
environment: dev
coordinator:
# address if the server want to connect directly to debug
debugHost:
# games library
library:
# some directory which is gonna be the root folder for the library
# where games are stored
basePath: assets/games
# an explicit list of supported file extensions
# which overrides Libretro emulator ROMs configs
supported:
# a list of ignored words in the ROM filenames
ignored:
- neogeo
- pgm
# print some additional info
verbose: true
# enable library directory live reload
# (experimental)
watchMode: false
monitoring:
port: 6601
# enable Go profiler HTTP server
profilingEnabled: false
metricEnabled: false
urlPrefix: /coordinator
# the public domain of the coordinator
publicDomain: http://localhost:8000
# specify the worker address that the client can ping (with protocol and port)
pingServer:
# HTTP(S) server config
server:
port: 8000
httpsPort: 443
httpsKey:
httpsChain:
worker:
network:
# a coordinator address to connect to
coordinatorAddress: localhost:8000
# ISO Alpha-2 country code to group workers by zones
zone:
monitoring:
# monitoring server port
port: 6601
profilingEnabled: false
# monitoring server URL prefix
metricEnabled: true
urlPrefix: /worker
server:
port: 9000
httpsPort: 443
httpsKey:
httpsChain:
emulator:
# set output viewport scale factor
scale: 1
aspectRatio:
# enable aspect ratio changing
# (experimental)
keep: false
# recalculate emulator game frame size to the given WxH
width: 320
height: 240
libretro:
cores:
paths:
libs: assets/cores
configs: assets/cores
# Config params for Libretro cores repository,
# available types are:
# - buildbot (the default Libretro nightly repository)
# - github (GitHub raw repository with a similar structure to buildbot)
# - raw (just a link to a zip file extracted as is)
repo:
# enable auto-download for the list of cores (list->lib)
sync: true
type: buildbot
url: https://buildbot.libretro.com/nightly
# if repo has file compression
compression: zip
# Libretro core configuration
#
# Available config params:
# - lib (string)
# - config (string)
# - roms ([]string)
# - width (int)
# - height (int)
# - ratio (float)
# - isGlAllowed (bool)
# - usesLibCo (bool)
# - hasMultitap (bool)
list:
gba:
lib: mgba_libretro
roms: [ "gba", "gbc" ]
width: 240
height: 160
pcsx:
lib: pcsx_rearmed_libretro
config: pcsx_rearmed_libretro.cfg
roms: [ "cue" ]
width: 350
height: 240
nes:
lib: nestopia_libretro
roms: [ "nes" ]
width: 256
height: 240
snes:
lib: snes9x_libretro
roms: [ "smc", "sfc", "swc", "fig", "bs" ]
width: 256
height: 224
hasMultitap: true
mame:
lib: fbneo_libretro
roms: [ "zip" ]
width: 240
height: 160
n64:
lib: mupen64plus_next_libretro
config: mupen64plus_next_libretro.cfg
roms: [ "n64", "v64", "z64" ]
width: 320
height: 240
isGlAllowed: true
usesLibCo: true
encoder:
audio:
channels: 2
# audio frame duration needed for WebRTC (Opus)
frame: 20
frequency: 48000
# run without a game
# (experimental)
withoutGame: false
webrtc:
# a list of STUN/TURN servers for the client
# {server-ip} placeholder will be replaced with
# the current server IP
iceServers:
- url: stun:stun.l.google.com:19302
- url: stun:{server-ip}:3478
- url: turn:{server-ip}:3478
username: root
credential: root

4
go.mod vendored
View file

@ -5,14 +5,17 @@ go 1.13
require (
cloud.google.com/go v0.70.0 // indirect
cloud.google.com/go/storage v1.12.0
github.com/cavaliercoder/grab v1.0.1-0.20201108051000-98a5bfe305ec
github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3
github.com/fsnotify/fsnotify v1.4.9
github.com/gen2brain/x264-go v0.0.0-20200605131102-0523307cbe23
github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7
github.com/gofrs/flock v0.8.0
github.com/gofrs/uuid v3.3.0+incompatible
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/kkyr/fig v0.2.0
github.com/lucas-clemente/quic-go v0.18.1 // indirect
github.com/marten-seemann/qtls-go1-15 v0.1.1 // indirect
github.com/pion/dtls/v2 v2.0.3 // indirect
@ -35,4 +38,5 @@ require (
google.golang.org/genproto v0.0.0-20201030142918-24207fddd1c3 // indirect
google.golang.org/grpc v1.33.1 // indirect
gopkg.in/hraban/opus.v2 v2.0.0-20201025103112-d779bb1cc5a2
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
)

10
go.sum vendored
View file

@ -76,6 +76,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
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/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
@ -140,6 +142,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY=
github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
@ -281,6 +285,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkyr/fig v0.2.0 h1:t/5yENaBw8ATXbQSWpPqwXLCn6wdhEi6jWXRfUgytZI=
github.com/kkyr/fig v0.2.0/go.mod h1:iqSnedEGFSofGzaB8p34xOhX1ppE1kMulSmJLZ2tNnw=
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=
@ -324,6 +330,7 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
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=
@ -369,6 +376,8 @@ github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnh
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4=
github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
@ -951,6 +960,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
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.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=

View file

@ -1,113 +0,0 @@
package config
import (
"flag"
"time"
"github.com/giongto35/cloud-game/v2/pkg/emulator/image"
)
const DefaultSTUNTURN = `[{"urls":"stun:stun-turn.webgame2d.com:3478"},{"urls":"turn:stun-turn.webgame2d.com:3478","username":"root","credential":"root"}]`
const CODEC_VP8 = "VP8"
const CODEC_H264 = "H264"
const AUDIO_RATE = 48000
const AUDIO_CHANNELS = 2
const AUDIO_MS = 20
const AUDIO_FRAME = AUDIO_RATE * AUDIO_MS / 1000 * AUDIO_CHANNELS
var FrontendSTUNTURN = flag.String("stunturn", DefaultSTUNTURN, "Frontend STUN TURN servers")
var Mode = flag.String("mode", "dev", "Environment")
var StunTurnTemplate = `[{"urls":"stun:stun.l.google.com:19302"},{"urls":"stun:%s:3478"},{"urls":"turn:%s:3478","username":"root","credential":"root"}]`
var HttpPort = flag.String("httpPort", "8000", "User agent port of the app")
var HttpsPort = flag.Int("httpsPort", 443, "Https Port")
var HttpsKey = flag.String("httpsKey", "", "Https Key")
var HttpsChain = flag.String("httpsChain", "", "Https Chain")
var WSWait = 20 * time.Second
var ProdEnv = "prod"
var StagingEnv = "staging"
var FileTypeToEmulator = map[string]string{
"gba": "gba",
"gbc": "gba",
"cue": "pcsx",
"zip": "mame",
"nes": "nes",
"smc": "snes",
"sfc": "snes",
"swc": "snes",
"fig": "snes",
"bs": "snes",
"n64": "n64",
"v64": "n64",
"z64": "n64",
}
var SupportedRomExtensions = listSupportedRomExtensions()
// There is no good way to determine main width and height of the emulator.
// When game run, frame width and height can scale abnormally.
type EmulatorMeta struct {
Path string
Config string
Width int
Height int
AudioSampleRate int
Fps float64
BaseWidth int
BaseHeight int
Ratio float64
Rotation image.Rotate
IsGlAllowed bool
UsesLibCo bool
AutoGlContext bool
HasMultitap bool
}
var EmulatorConfig = map[string]EmulatorMeta{
"gba": {
Path: "assets/emulator/libretro/cores/mgba_libretro",
Width: 240,
Height: 160,
},
"pcsx": {
Path: "assets/emulator/libretro/cores/pcsx_rearmed_libretro",
Width: 350,
Height: 240,
},
"nes": {
Path: "assets/emulator/libretro/cores/nestopia_libretro",
Width: 256,
Height: 240,
},
"snes": {
Path: "assets/emulator/libretro/cores/snes9x_libretro",
Width: 256,
Height: 224,
HasMultitap: true,
},
"mame": {
Path: "assets/emulator/libretro/cores/fbneo_libretro",
Width: 240,
Height: 160,
},
"n64": {
Path: "assets/emulator/libretro/cores/mupen64plus_next_libretro",
Config: "assets/emulator/libretro/cores/mupen64plus_next_libretro.cfg",
Width: 320,
Height: 240,
IsGlAllowed: true,
UsesLibCo: true,
},
}
var EmulatorExtension = []string{".so", ".armv7-neon-hf.so", ".dylib", ".dll"}
func listSupportedRomExtensions() []string {
m := make([]string, 0, len(FileTypeToEmulator))
for k := range FileTypeToEmulator {
m = append(m, k)
}
return m
}

View file

@ -0,0 +1,43 @@
package coordinator
import (
"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/shared"
webrtcConfig "github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
"github.com/giongto35/cloud-game/v2/pkg/games"
"github.com/giongto35/cloud-game/v2/pkg/monitoring"
flag "github.com/spf13/pflag"
)
type Config struct {
Coordinator struct {
PublicDomain string
PingServer string
DebugHost string
Library games.Config
Monitoring monitoring.ServerMonitoringConfig
Server shared.Server
}
Emulator emulator.Emulator
Environment shared.Environment
Webrtc struct {
IceServers []webrtcConfig.IceServer
}
}
// allows custom config path
var configPath string
func NewConfig() (conf Config) {
config.LoadConfig(&conf, configPath)
return
}
func (c *Config) ParseFlags() {
c.Environment.WithFlags()
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.Parse()
}

View file

@ -0,0 +1,95 @@
package emulator
import (
"path"
"path/filepath"
)
type Emulator struct {
Scale int
AspectRatio struct {
Keep bool
Width int
Height int
}
Libretro LibretroConfig
}
type LibretroConfig struct {
Cores struct {
Paths struct {
Libs string
Configs string
}
Repo struct {
Sync bool
Type string
Url string
Compression string
}
List map[string]LibretroCoreConfig
}
}
type LibretroCoreConfig struct {
Lib string
Config string
Roms []string
Width int
Height int
Ratio float64
IsGlAllowed bool
UsesLibCo bool
HasMultitap bool
// hack: keep it here to pass it down the emulator
AutoGlContext bool
}
// GetLibretroCoreConfig returns a core config with expanded paths.
func (e *Emulator) GetLibretroCoreConfig(emulator string) LibretroCoreConfig {
cores := e.Libretro.Cores
conf := cores.List[emulator]
conf.Lib = path.Join(cores.Paths.Libs, conf.Lib)
if conf.Config != "" {
conf.Config = path.Join(cores.Paths.Configs, conf.Config)
}
return conf
}
// GetEmulatorByRom returns emulator name by its supported ROM name.
// !to cache into an optimized data structure
func (e *Emulator) GetEmulatorByRom(rom string) string {
for emu, core := range e.Libretro.Cores.List {
for _, romName := range core.Roms {
if rom == romName {
return emu
}
}
}
return ""
}
func (e *Emulator) GetSupportedExtensions() []string {
var extensions []string
for _, core := range e.Libretro.Cores.List {
extensions = append(extensions, core.Roms...)
}
return extensions
}
func (l *LibretroConfig) GetCores() []string {
var cores []string
for _, core := range l.Cores.List {
cores = append(cores, core.Lib)
}
return cores
}
func (l *LibretroConfig) GetCoresStorePath() string {
pth, err := filepath.Abs(l.Cores.Paths.Libs)
if err != nil {
return ""
}
return pth
}

View file

@ -0,0 +1,16 @@
package encoder
type Encoder struct {
Audio Audio
WithoutGame bool
}
type Audio struct {
Channels int
Frame int
Frequency int
}
func (a *Audio) GetFrameDuration() int {
return a.Frequency * a.Frame / 1000 * a.Channels
}

25
pkg/config/loader.go Normal file
View file

@ -0,0 +1,25 @@
package config
import (
"os"
"github.com/kkyr/fig"
)
// LoadConfig loads a configuration file into the given struct.
// The path param specifies a custom path to the configuration file.
// Reads and puts environment variables with the prefix CLOUD_GAME_.
// Params from the config should be in uppercase separated with _.
func LoadConfig(config interface{}, path string) interface{} {
envPrefix := "CLOUD_GAME"
dirs := []string{path}
if path == "" {
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, ".", "configs", home+"/.cr", "../../../configs")
}
}
if err := fig.Load(config, fig.Dirs(dirs...), fig.UseEnv(envPrefix)); err != nil {
panic(err)
}
return config
}

View file

@ -0,0 +1,31 @@
package shared
import (
"github.com/giongto35/cloud-game/v2/pkg/environment"
flag "github.com/spf13/pflag"
)
type Environment environment.Env
type Server struct {
Port int
HttpsPort int
HttpsKey string
HttpsChain string
}
func (s *Server) WithFlags() {
flag.IntVar(&s.Port, "port", s.Port, "HTTP server port")
flag.IntVar(&s.HttpsPort, "httpsPort", s.HttpsPort, "HTTPS server port (just why?)")
flag.StringVar(&s.HttpsKey, "httpsKey", s.HttpsKey, "HTTPS key")
flag.StringVar(&s.HttpsChain, "httpsChain", s.HttpsChain, "HTTPS chain")
}
func (env *Environment) Get() environment.Env {
return (environment.Env)(*env)
}
func (env *Environment) WithFlags() {
val := string(*env)
flag.StringVar(&val, "env", val, "Specify environment type: [dev, staging, prod]")
}

View file

@ -0,0 +1,18 @@
package webrtc
import "github.com/giongto35/cloud-game/v2/pkg/config/encoder"
type Webrtc struct {
IceServers []IceServer
}
type IceServer struct {
Url string
Username string
Credential string
}
type Config struct {
Encoder encoder.Encoder
Webrtc Webrtc
}

View file

@ -1,63 +1,46 @@
package worker
import (
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/giongto35/cloud-game/v2/pkg/config/emulator"
"github.com/giongto35/cloud-game/v2/pkg/config/encoder"
"github.com/giongto35/cloud-game/v2/pkg/config/shared"
webrtcConfig "github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
"github.com/giongto35/cloud-game/v2/pkg/monitoring"
"github.com/spf13/pflag"
flag "github.com/spf13/pflag"
)
type Config struct {
Port int
CoordinatorAddress string
HttpPort int
// video
Scale int
EnableAspectRatio bool
Width int
Height int
Zone string
// WithoutGame to launch encoding with Game
WithoutGame bool
MonitoringConfig monitoring.ServerMonitoringConfig
}
func NewDefaultConfig() Config {
return Config{
Port: 8800,
CoordinatorAddress: "localhost:8000",
HttpPort: 9000,
Scale: 1,
EnableAspectRatio: false,
Width: 320,
Height: 240,
WithoutGame: false,
Zone: "",
MonitoringConfig: monitoring.ServerMonitoringConfig{
Port: 6601,
URLPrefix: "/worker",
MetricEnabled: true,
},
Encoder encoder.Encoder
Emulator emulator.Emulator
Environment shared.Environment
Worker struct {
Monitoring monitoring.ServerMonitoringConfig
Network struct {
CoordinatorAddress string
Zone string
}
Server shared.Server
}
Webrtc webrtcConfig.Webrtc
}
func (c *Config) AddFlags(fs *pflag.FlagSet) *Config {
fs.IntVarP(&c.Port, "port", "", 8800, "Worker server port")
fs.StringVarP(&c.CoordinatorAddress, "coordinatorhost", "", c.CoordinatorAddress, "Worker URL to connect")
fs.IntVarP(&c.HttpPort, "httpPort", "", c.HttpPort, "Set external HTTP port")
fs.StringVarP(&c.Zone, "zone", "z", c.Zone, "Zone of the worker")
// allows custom config path
var configPath string
fs.IntVarP(&c.Scale, "scale", "s", c.Scale, "Set output viewport scale factor")
fs.BoolVarP(&c.EnableAspectRatio, "ar", "", c.EnableAspectRatio, "Enable Aspect Ratio")
fs.IntVarP(&c.Width, "width", "w", c.Width, "Set custom viewport width")
fs.IntVarP(&c.Height, "height", "h", c.Height, "Set custom viewport height")
fs.BoolVarP(&c.WithoutGame, "wogame", "", c.WithoutGame, "launch worker with game")
fs.BoolVarP(&c.MonitoringConfig.MetricEnabled, "monitoring.metric", "m", c.MonitoringConfig.MetricEnabled, "Enable prometheus metric for server")
fs.BoolVarP(&c.MonitoringConfig.ProfilingEnabled, "monitoring.pprof", "p", c.MonitoringConfig.ProfilingEnabled, "Enable golang pprof for server")
fs.IntVarP(&c.MonitoringConfig.Port, "monitoring.port", "", c.MonitoringConfig.Port, "Monitoring server port")
fs.StringVarP(&c.MonitoringConfig.URLPrefix, "monitoring.prefix", "", c.MonitoringConfig.URLPrefix, "Monitoring server url prefix")
return c
func NewConfig() (conf Config) {
config.LoadConfig(&conf, configPath)
return
}
// ParseFlags updates config values from passed runtime flags.
// Define own flags with default value set to the current config param.
// Don't forget to call flag.Parse().
func (c *Config) ParseFlags() {
c.Environment.WithFlags()
c.Worker.Server.WithFlags()
flag.IntVar(&c.Worker.Monitoring.Port, "monitoring.port", c.Worker.Monitoring.Port, "Monitoring server port")
flag.StringVar(&c.Worker.Network.CoordinatorAddress, "coordinatorhost", c.Worker.Network.CoordinatorAddress, "Worker URL to connect")
flag.StringVarP(&configPath, "conf", "c", configPath, "Set custom configuration file path")
flag.Parse()
}

View file

@ -1,48 +0,0 @@
package coordinator
import (
"github.com/giongto35/cloud-game/v2/pkg/monitoring"
"github.com/spf13/pflag"
)
type Config struct {
Port int
PublicDomain string
PingServer string
URLPrefix string
DebugHost string
LibraryMonitoring bool
MonitoringConfig monitoring.ServerMonitoringConfig
}
func NewDefaultConfig() Config {
return Config{
Port: 8800,
PublicDomain: "http://localhost:8000",
PingServer: "",
LibraryMonitoring: false,
MonitoringConfig: monitoring.ServerMonitoringConfig{
Port: 6601,
URLPrefix: "/coordinator",
MetricEnabled: false,
ProfilingEnabled: false,
},
}
}
func (c *Config) AddFlags(fs *pflag.FlagSet) *Config {
fs.IntVarP(&c.Port, "port", "", 8800, "Coordinator server port")
fs.BoolVarP(&c.MonitoringConfig.MetricEnabled, "monitoring.metric", "m", c.MonitoringConfig.MetricEnabled, "Enable prometheus metric for server")
fs.BoolVarP(&c.MonitoringConfig.ProfilingEnabled, "monitoring.pprof", "p", c.MonitoringConfig.ProfilingEnabled, "Enable golang pprof for server")
fs.IntVarP(&c.MonitoringConfig.Port, "monitoring.port", "", c.MonitoringConfig.Port, "Monitoring server port")
fs.StringVarP(&c.MonitoringConfig.URLPrefix, "monitoring.prefix", "", c.MonitoringConfig.URLPrefix, "Monitoring server url prefix")
fs.StringVarP(&c.DebugHost, "debughost", "d", "", "Specify the server want to connect directly to debug")
fs.StringVarP(&c.PublicDomain, "domain", "n", c.PublicDomain, "Specify the public domain of the coordinator")
fs.StringVarP(&c.PingServer, "pingServer", "", c.PingServer, "Specify the worker address that the client can ping (with protocol and port)")
fs.BoolVarP(&c.LibraryMonitoring, "libMonitor", "", c.LibraryMonitoring, "Enable ROM library monitoring")
return c
}

View file

@ -6,9 +6,11 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"time"
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
"github.com/giongto35/cloud-game/v2/pkg/environment"
"github.com/giongto35/cloud-game/v2/pkg/games"
"github.com/giongto35/cloud-game/v2/pkg/monitoring"
"github.com/golang/glog"
@ -21,17 +23,17 @@ const stagingLEURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
type Coordinator struct {
ctx context.Context
cfg Config
cfg coordinator.Config
monitoringServer *monitoring.ServerMonitoring
}
func New(ctx context.Context, cfg Config) *Coordinator {
func New(ctx context.Context, cfg coordinator.Config) *Coordinator {
return &Coordinator{
ctx: ctx,
cfg: cfg,
monitoringServer: monitoring.NewServerMonitoring(cfg.MonitoringConfig),
monitoringServer: monitoring.NewServerMonitoring(cfg.Coordinator.Monitoring),
}
}
@ -96,31 +98,31 @@ func makeHTTPToHTTPSRedirectServer(server *Server) *http.Server {
// initializeCoordinator setup an coordinator server
func (o *Coordinator) initializeCoordinator() {
// init games library
lib := games.NewLibrary(games.Config{
BasePath: "assets/games",
Supported: config.SupportedRomExtensions,
Ignored: []string{"neogeo", "pgm"},
Verbose: true,
WatchMode: o.cfg.LibraryMonitoring,
})
libraryConf := o.cfg.Coordinator.Library
if len(libraryConf.Supported) == 0 {
libraryConf.Supported = o.cfg.Emulator.GetSupportedExtensions()
}
lib := games.NewLibrary(libraryConf)
lib.Scan()
coordinator := NewServer(o.cfg, lib)
server := NewServer(o.cfg, lib)
var certManager *autocert.Manager
var httpsSrv *http.Server
log.Println("Initializing Coordinator Server")
if *config.Mode == config.ProdEnv || *config.Mode == config.StagingEnv {
httpsSrv = makeHTTPServer(coordinator)
httpsSrv.Addr = fmt.Sprintf(":%d", *config.HttpsPort)
mode := o.cfg.Environment.Get()
if mode.AnyOf(environment.Production, environment.Staging) {
serverConfig := o.cfg.Coordinator.Server
httpsSrv = makeHTTPServer(server)
httpsSrv.Addr = fmt.Sprintf(":%d", serverConfig.HttpsPort)
if *config.HttpsChain == "" || *config.HttpsKey == "" {
*config.HttpsChain = ""
*config.HttpsKey = ""
if serverConfig.HttpsChain == "" || serverConfig.HttpsKey == "" {
serverConfig.HttpsChain = ""
serverConfig.HttpsKey = ""
var leurl string
if *config.Mode == config.StagingEnv {
if mode == environment.Staging {
leurl = stagingLEURL
} else {
leurl = acme.LetsEncryptURL
@ -128,7 +130,7 @@ func (o *Coordinator) initializeCoordinator() {
certManager = &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(o.cfg.PublicDomain),
HostPolicy: autocert.HostWhitelist(o.cfg.Coordinator.PublicDomain),
Cache: autocert.DirCache("assets/cache"),
Client: &acme.Client{DirectoryURL: leurl},
}
@ -136,27 +138,27 @@ func (o *Coordinator) initializeCoordinator() {
httpsSrv.TLSConfig = &tls.Config{GetCertificate: certManager.GetCertificate}
}
go func() {
go func(chain string, key string) {
fmt.Printf("Starting HTTPS server on %s\n", httpsSrv.Addr)
err := httpsSrv.ListenAndServeTLS(*config.HttpsChain, *config.HttpsKey)
err := httpsSrv.ListenAndServeTLS(chain, key)
if err != nil {
log.Fatalf("httpsSrv.ListendAndServeTLS() failed with %s", err)
}
}()
}(serverConfig.HttpsChain, serverConfig.HttpsKey)
}
var httpSrv *http.Server
if *config.Mode == config.ProdEnv || *config.Mode == config.StagingEnv {
httpSrv = makeHTTPToHTTPSRedirectServer(coordinator)
if mode.AnyOf(environment.Production, environment.Staging) {
httpSrv = makeHTTPToHTTPSRedirectServer(server)
} else {
httpSrv = makeHTTPServer(coordinator)
httpSrv = makeHTTPServer(server)
}
if certManager != nil {
httpSrv.Handler = certManager.HTTPHandler(httpSrv.Handler)
}
httpSrv.Addr = ":" + *config.HttpPort
httpSrv.Addr = ":" + strconv.Itoa(o.cfg.Coordinator.Server.Port)
err := httpSrv.ListenAndServe()
if err != nil {
log.Fatalf("httpSrv.ListenAndServe() failed with %s", err)

View file

@ -10,20 +10,20 @@ import (
"net/http"
"strings"
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
"github.com/giongto35/cloud-game/v2/pkg/cws"
"github.com/giongto35/cloud-game/v2/pkg/environment"
"github.com/giongto35/cloud-game/v2/pkg/games"
"github.com/giongto35/cloud-game/v2/pkg/util"
"github.com/giongto35/cloud-game/v2/pkg/webrtc"
"github.com/gofrs/uuid"
"github.com/gorilla/websocket"
)
const (
gameboyIndex = "./web/game.html"
)
const index = "./web/index.html"
type Server struct {
cfg Config
cfg coordinator.Config
// games library
library games.GameLibrary
// roomToWorker map roomID to workerID
@ -40,7 +40,7 @@ const devPingServer = "http://localhost:9000/echo"
var upgrader = websocket.Upgrader{}
var errNotFound = errors.New("Not found")
func NewServer(cfg Config, library games.GameLibrary) *Server {
func NewServer(cfg coordinator.Config, library games.GameLibrary) *Server {
return &Server{
cfg: cfg,
library: library,
@ -53,36 +53,25 @@ func NewServer(cfg Config, library games.GameLibrary) *Server {
}
}
type RenderData struct {
STUNTURN string
}
// GetWeb returns web frontend
func (o *Server) GetWeb(w http.ResponseWriter, r *http.Request) {
stunturn := *config.FrontendSTUNTURN
if stunturn == "" {
stunturn = config.DefaultSTUNTURN
}
data := RenderData{
STUNTURN: stunturn,
}
tmpl, err := template.ParseFiles(gameboyIndex)
tmpl, err := template.ParseFiles(index)
if err != nil {
log.Fatal(err)
}
tmpl.Execute(w, data)
tmpl.Execute(w, struct{}{})
}
// getPingServer returns the server for latency check of a zone. In latency check to find best worker step, we use this server to find the closest worker.
func (o *Server) getPingServer(zone string) string {
if o.cfg.PingServer != "" {
return fmt.Sprintf("%s/echo", o.cfg.PingServer)
if o.cfg.Coordinator.PingServer != "" {
return fmt.Sprintf("%s/echo", o.cfg.Coordinator.PingServer)
}
if *config.Mode == config.ProdEnv || *config.Mode == config.StagingEnv {
return fmt.Sprintf(pingServerTemp, zone, o.cfg.PublicDomain)
mode := o.cfg.Environment.Get()
if mode.AnyOf(environment.Production, environment.Staging) {
return fmt.Sprintf(pingServerTemp, zone, o.cfg.Coordinator.PublicDomain)
}
// If not Prod or Staging, return dev environment
@ -127,7 +116,7 @@ func (o *Server) WSO(w http.ResponseWriter, r *http.Request) {
wc.Printf("Set ping server address: %s", pingServer)
// In case worker and coordinator in the same host
if !util.IsPublicIP(address) && *config.Mode == config.ProdEnv {
if !util.IsPublicIP(address) && o.cfg.Environment.Get() == environment.Production {
// Don't accept private IP for worker's address in prod mode
// However, if the worker in the same host with coordinator, we can get public IP of worker
wc.Printf("[!] Address %s is invalid", address)
@ -144,7 +133,7 @@ func (o *Server) WSO(w http.ResponseWriter, r *http.Request) {
// Create a workerClient instance
wc.Address = address
wc.StunTurnServer = fmt.Sprintf(config.StunTurnTemplate, address, address)
wc.StunTurnServer = webrtc.ToJson(o.cfg.Webrtc.IceServers, webrtc.Replacement{From: "server-ip", To: address})
wc.Zone = zone
wc.PingServer = pingServer
@ -265,9 +254,10 @@ func (o *Server) WS(w http.ResponseWriter, r *http.Request) {
}
func (o *Server) getBestWorkerClient(client *BrowserClient, zone string) (*WorkerClient, error) {
if o.cfg.DebugHost != "" {
client.Println("Connecting to debug host instead prod servers", o.cfg.DebugHost)
wc := o.getWorkerFromAddress(o.cfg.DebugHost)
conf := o.cfg.Coordinator
if conf.DebugHost != "" {
client.Println("Connecting to debug host instead prod servers", conf.DebugHost)
wc := o.getWorkerFromAddress(conf.DebugHost)
if wc != nil {
return wc, nil
}

View file

@ -7,7 +7,6 @@ import (
"sync"
"time"
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/gofrs/uuid"
"github.com/gorilla/websocket"
)
@ -42,6 +41,8 @@ type WSPacket struct {
var EmptyPacket = WSPacket{}
const WSWait = 20 * time.Second
func NewClient(conn *websocket.Conn) *Client {
id := uuid.Must(uuid.NewV4()).String()
sendCallback := map[string]func(WSPacket){}
@ -86,7 +87,7 @@ func (c *Client) Send(request WSPacket, callback func(response WSPacket)) {
}
c.sendLock.Lock()
c.conn.SetWriteDeadline(time.Now().Add(config.WSWait))
c.conn.SetWriteDeadline(time.Now().Add(WSWait))
c.conn.WriteMessage(websocket.TextMessage, data)
c.sendLock.Unlock()
}
@ -115,7 +116,7 @@ func (c *Client) Receive(id string, f func(response WSPacket) (request WSPacket)
log.Println("[!] json marshal error:", err)
}
c.sendLock.Lock()
c.conn.SetWriteDeadline(time.Now().Add(config.WSWait))
c.conn.SetWriteDeadline(time.Now().Add(WSWait))
c.conn.WriteMessage(websocket.TextMessage, resp)
c.sendLock.Unlock()
}
@ -166,7 +167,7 @@ func (c *Client) Heartbeat() {
func (c *Client) Listen() {
for {
c.conn.SetReadDeadline(time.Now().Add(config.WSWait))
c.conn.SetReadDeadline(time.Now().Add(WSWait))
_, rawMsg, err := c.conn.ReadMessage()
if err != nil {
log.Println("[!] read:", err)

View file

@ -0,0 +1,42 @@
package backend
import (
"log"
"github.com/cavaliercoder/grab"
)
type GrabDownloader struct {
client *grab.Client
concurrency int
}
func NewGrabDownloader() GrabDownloader {
return GrabDownloader{
client: grab.NewClient(),
concurrency: 5,
}
}
func (d GrabDownloader) Request(dest string, urls ...string) (files []string) {
reqs := make([]*grab.Request, 0)
for _, url := range urls {
req, err := grab.NewRequest(dest, url)
if err != nil {
log.Printf("error: couldn't make request URL: %v, %v", url, err)
} else {
reqs = append(reqs, req)
}
}
// check each response
for resp := range d.client.DoBatch(d.concurrency, reqs...) {
if err := resp.Err(); err != nil {
log.Printf("error: download failed: %v\n", err)
} else {
log.Printf("Downloaded [%v] %s\n", resp.HTTPResponse.Status, resp.Filename)
files = append(files, resp.Filename)
}
}
return
}

View file

@ -0,0 +1,42 @@
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 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 client interface {
Request(dest string, urls ...string) []string
}
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 ...string) []string {
files := d.backend.Request(dest, urls...)
for _, op := range d.pipe {
files = op(dest, files)
}
return files
}

View file

@ -0,0 +1,29 @@
package pipe
import (
"os"
"github.com/giongto35/cloud-game/v2/pkg/extractor"
)
func Unpack(dest string, files []string) []string {
var res []string
for _, file := range files {
if unpack := extractor.NewFromExt(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
}

View file

@ -1,11 +1,11 @@
package emulator
import "github.com/giongto35/cloud-game/v2/pkg/config"
import "github.com/giongto35/cloud-game/v2/pkg/emulator/image"
// CloudEmulator is the interface of cloud emulator. Currently NES emulator and RetroArch implements this in codebase
type CloudEmulator interface {
// LoadMeta returns meta data of emulator. Refer below
LoadMeta(path string) config.EmulatorMeta
LoadMeta(path string) Metadata
// Start is called after LoadGame
Start()
// SetViewport sets viewport size
@ -23,3 +23,21 @@ type CloudEmulator interface {
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
}

View file

@ -0,0 +1,38 @@
package core
import (
"errors"
"runtime"
)
// See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63.
var libretroOsArchMap = map[string]ArchInfo{
"linux:amd64": {Os: "linux", Arch: "x86_64", LibExt: ".so"},
"linux:arm": {Os: "linux", Arch: "armv7-neon-hf", LibExt: ".armv7-neon-hf.so"},
"windows:amd64": {Os: "windows", Arch: "x86_64", LibExt: ".dll"},
"darwin:amd64": {Os: "osx", Arch: "x86_64", Vendor: "apple", LibExt: ".dylib"},
}
// ArchInfo contains Libretro core lib platform info.
// And cores are just C-compiled libraries.
// See: https://buildbot.libretro.com/nightly.
type ArchInfo struct {
// bottom: x86_64, x86, ...
Arch string
// middle: windows, ios, ...
Os string
// top level: apple, nintendo, ...
Vendor string
// platform dependent library file extension (dot-prefixed)
LibExt string
}
func GetCoreExt() (ArchInfo, error) {
key := runtime.GOOS + ":" + runtime.GOARCH
if arch, ok := libretroOsArchMap[key]; ok {
return arch, nil
} else {
return ArchInfo{}, errors.New("core mapping not found for " + key)
}
}

View file

@ -0,0 +1,42 @@
package manager
import (
"io/ioutil"
"log"
"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 {
Sync() error
}
type BasicManager struct {
Conf emulator.LibretroConfig
}
func (m BasicManager) GetInstalled() (installed []string) {
dir := m.Conf.GetCoresStorePath()
arch, err := core.GetCoreExt()
if err != nil {
log.Printf("error: %v", err)
return
}
files, err := ioutil.ReadDir(dir)
if err != nil {
log.Printf("error: couldn't get installed cores, %v", err)
return
}
for _, file := range files {
name := file.Name()
if filepath.Ext(name) == arch.LibExt {
installed = append(installed, strings.TrimSuffix(name, arch.LibExt))
}
}
return
}

View file

@ -0,0 +1,96 @@
package remotehttp
import (
"log"
"os"
"strings"
"github.com/giongto35/cloud-game/v2/pkg/config/emulator"
"github.com/giongto35/cloud-game/v2/pkg/downloader"
"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/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/gofrs/flock"
)
type Manager struct {
manager.BasicManager
repo repo.Repository
client downloader.Downloader
fmu *flock.Flock
}
func NewRemoteHttpManager(conf emulator.LibretroConfig) Manager {
repoConf := conf.Cores.Repo
var repository repo.Repository
switch repoConf.Type {
case "raw":
repository = raw.NewRawRepo(repoConf.Url)
case "github":
repository = github.NewGithubRepo(repoConf.Url, repoConf.Compression)
case "buildbot":
fallthrough
default:
repository = buildbot.NewBuildbotRepo(repoConf.Url, repoConf.Compression)
}
// used for synchronization of multiple process
fileLock := os.TempDir() + string(os.PathSeparator) + "cloud_game.lock"
return Manager{
BasicManager: manager.BasicManager{
Conf: conf,
},
repo: repository,
client: downloader.NewDefaultDownloader(),
fmu: flock.New(fileLock),
}
}
func (m Manager) Sync() error {
declared := m.Conf.GetCores()
dir := m.Conf.GetCoresStorePath()
// IPC lock if multiple worker processes on the same machine
m.fmu.Lock()
defer m.fmu.Unlock()
installed := m.GetInstalled()
download := diff(installed, declared)
if len(download) > 0 {
log.Printf("Starting Libretro cores download: %v", strings.Join(download, ", "))
m.client.Download(dir, m.getCoreUrls(download)...)
}
return nil
}
func (m Manager) getCoreUrls(names []string) (urls []string) {
arch, err := core.GetCoreExt()
if err != nil {
return
}
for _, c := range names {
urls = append(urls, m.repo.GetCoreData(c, arch).Url)
}
return
}
// diff returns a list of not installed cores.
func diff(declared, installed []string) (diff []string) {
v := map[string]struct{}{}
for _, x := range declared {
v[x] = struct{}{}
}
for _, x := range installed {
if _, ok := v[x]; !ok {
diff = append(diff, x)
}
}
return
}

View file

@ -0,0 +1,37 @@
package remotehttp
import (
"reflect"
"testing"
)
func TestDiff(t *testing.T) {
tests := []struct {
declared []string
installed []string
diff []string
}{
{},
{
installed: []string{"c"},
},
{
declared: []string{"a", "b", "c"},
installed: []string{"c"},
diff: []string{"a", "b"},
},
{
declared: []string{"a", "b", "c", "c", "c", "a", "d"},
installed: []string{"c", "c", "c", "a", "a", "a"},
diff: []string{"b", "d"},
},
}
for _, test := range tests {
difference := diff(test.declared, test.installed)
if !reflect.DeepEqual(test.diff, difference) {
t.Errorf("wrong diff for %v <- %v = %v != %v",
test.declared, test.installed, test.diff, difference)
}
}
}

View file

@ -0,0 +1,73 @@
package nanoarch
import (
"errors"
"io/ioutil"
"path"
"strconv"
"strings"
"unsafe"
)
/*
#cgo LDFLAGS: -ldl
#include <stdlib.h>
#include <dlfcn.h>
*/
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
}
func loadLib(filepath string) (handle unsafe.Pointer, err error) {
handle = open(filepath)
if handle == nil {
e := C.dlerror()
if e != nil {
err = errors.New(C.GoString(e))
} else {
err = errors.New("couldn't load the lib")
}
}
return
}
func loadLibRollingRollingRolling(filepath string) (handle unsafe.Pointer, err error) {
dir, lib := path.Dir(filepath), path.Base(filepath)
files, err := ioutil.ReadDir(dir)
if err != nil {
return nil, errors.New("couldn't find 'n load the lib")
}
for _, file := range files {
if !file.IsDir() && strings.HasPrefix(file.Name(), lib) {
handle = open(path.Join(dir, file.Name()))
if handle != nil {
return handle, nil
}
}
}
return nil, errors.New("couldn't find 'n load the lib")
}
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) + ")")
}
return
}

View file

@ -10,7 +10,8 @@ import (
"sync"
"time"
"github.com/giongto35/cloud-game/v2/pkg/config"
config "github.com/giongto35/cloud-game/v2/pkg/config/emulator"
"github.com/giongto35/cloud-game/v2/pkg/emulator"
"github.com/giongto35/cloud-game/v2/pkg/util"
)
@ -64,7 +65,7 @@ type naEmulator struct {
inputChannel <-chan InputEvent
videoExporter *VideoExporter
meta config.EmulatorMeta
meta emulator.Metadata
gamePath string
roomID string
gameName string
@ -103,13 +104,20 @@ const maxPort = 8
const SocketAddrTmpl = "/tmp/cloudretro-retro-%s.sock"
// NAEmulator implements CloudEmulator interface based on NanoArch(golang RetroArch)
func NewNAEmulator(etype string, roomID string, inputChannel <-chan InputEvent) (*naEmulator, chan GameFrame, chan []int16) {
meta := config.EmulatorConfig[etype]
func NewNAEmulator(roomID string, inputChannel <-chan InputEvent, conf config.LibretroCoreConfig) (*naEmulator, chan GameFrame, chan []int16) {
imageChannel := make(chan GameFrame, 30)
audioChannel := make(chan []int16, 30)
return &naEmulator{
meta: meta,
meta: emulator.Metadata{
LibPath: conf.Lib,
ConfigPath: conf.Config,
Ratio: conf.Ratio,
IsGlAllowed: conf.IsGlAllowed,
UsesLibCo: conf.UsesLibCo,
HasMultitap: conf.HasMultitap,
AutoGlContext: conf.AutoGlContext,
},
imageChannel: imageChannel,
audioChannel: audioChannel,
inputChannel: inputChannel,
@ -152,8 +160,8 @@ func NewVideoExporter(roomID string, imgChannel chan GameFrame) *VideoExporter {
// Init initialize new RetroArch cloud emulator
// withImageChan returns an image stream as Channel for output else it will write to unix socket
func Init(etype string, roomID string, withImageChannel bool, inputChannel <-chan InputEvent) (*naEmulator, chan GameFrame, chan []int16) {
emulator, imageChannel, audioChannel := NewNAEmulator(etype, roomID, inputChannel)
func Init(roomID string, withImageChannel bool, inputChannel <-chan InputEvent, config config.LibretroCoreConfig) (*naEmulator, chan GameFrame, chan []int16) {
emulator, imageChannel, audioChannel := NewNAEmulator(roomID, inputChannel, config)
// Set to global NAEmulator
NAEmulator = emulator
if !withImageChannel {
@ -188,7 +196,7 @@ func (na *naEmulator) listenInput() {
}
}
func (na *naEmulator) LoadMeta(path string) config.EmulatorMeta {
func (na *naEmulator) LoadMeta(path string) emulator.Metadata {
coreLoad(na.meta)
coreLoadGame(path)
na.gamePath = path

View file

@ -12,18 +12,17 @@ import (
"time"
"unsafe"
"github.com/giongto35/cloud-game/v2/pkg/config"
"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"
#cgo LDFLAGS: -ldl
#include <stdlib.h>
#include <stdio.h>
#include <dlfcn.h>
#include <string.h>
void bridge_retro_init(void *f);
@ -413,39 +412,31 @@ var retroSerialize unsafe.Pointer
var retroUnserialize unsafe.Pointer
var retroSetControllerPortDevice unsafe.Pointer
func loadFunction(handle unsafe.Pointer, name string) unsafe.Pointer {
cs := C.CString(name)
pointer := C.dlsym(handle, cs)
C.free(unsafe.Pointer(cs))
return pointer
}
func coreLoad(meta config.EmulatorMeta) {
func coreLoad(meta emulator.Metadata) {
isGlAllowed = meta.IsGlAllowed
usesLibCo = meta.UsesLibCo
video.autoGlContext = meta.AutoGlContext
coreConfig = ScanConfigFile(meta.Config)
coreConfig = ScanConfigFile(meta.ConfigPath)
multitap.supported = meta.HasMultitap
multitap.enabled = false
multitap.value = 0
mu.Lock()
// Different OS requires different library, bruteforce till it finish
for _, ext := range config.EmulatorExtension {
pathWithExt := meta.Path + ext
cs := C.CString(pathWithExt)
retroHandle = C.dlopen(cs, C.RTLD_LAZY)
C.free(unsafe.Pointer(cs))
if retroHandle != nil {
break
}
filePath := meta.LibPath
if arch, err := core.GetCoreExt(); err == nil {
filePath = filePath + arch.LibExt
} else {
log.Printf("warning: %v", err)
}
if retroHandle == nil {
err := C.dlerror()
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", meta.Path, C.GoString(err))
log.Fatalf("error core load: %s, %v", filePath, err)
}
}
@ -669,8 +660,8 @@ func nanoarchShutdown() {
}
setRotation(0)
if r := C.dlclose(retroHandle); r != 0 {
log.Printf("couldn't close the core")
if err := closeLib(retroHandle); err != nil {
log.Printf("error when close: %v", err)
}
for _, element := range coreConfig {
C.free(unsafe.Pointer(element))

View file

@ -9,12 +9,12 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"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"
)
type testRun struct {
@ -57,8 +57,13 @@ type EmulatorPaths struct {
// Don't forget to init one image channel consumer, it will lock-out otherwise.
// Make sure you call shutdownEmulator().
func GetEmulatorMock(room string, system string) *EmulatorMock {
assetsPath := getAssetsPath()
metadata := config.EmulatorConfig[system]
rootPath := getRootPath()
configPath := rootPath + "configs/"
var conf worker.Config
config.LoadConfig(&conf, configPath)
meta := conf.Emulator.GetLibretroCoreConfig(system)
images := make(chan GameFrame, 30)
audio := make(chan []int16, 30)
@ -71,20 +76,27 @@ func GetEmulatorMock(room string, system string) *EmulatorMock {
audioChannel: audio,
inputChannel: inputs,
meta: metadata,
meta: emulator.Metadata{
LibPath: meta.Lib,
ConfigPath: meta.Config,
Ratio: meta.Ratio,
IsGlAllowed: meta.IsGlAllowed,
UsesLibCo: meta.UsesLibCo,
HasMultitap: meta.HasMultitap,
},
controllersMap: map[string][]controllerState{},
roomID: room,
done: make(chan struct{}, 1),
lock: &sync.Mutex{},
},
canvas: image.NewRGBA(image.Rect(0, 0, metadata.Width, metadata.Height)),
core: path.Base(metadata.Path),
canvas: image.NewRGBA(image.Rect(0, 0, meta.Width, meta.Height)),
core: path.Base(meta.Lib),
paths: EmulatorPaths{
assets: cleanPath(assetsPath),
cores: cleanPath(assetsPath + "emulator/libretro/cores/"),
games: cleanPath(assetsPath + "games/"),
assets: cleanPath(rootPath),
cores: cleanPath(rootPath + "assets/cores/"),
games: cleanPath(rootPath + "assets/games/"),
},
imageInCh: images,
@ -117,8 +129,8 @@ func GetDefaultEmulatorMock(room string, system string, rom string) *EmulatorMoc
// The rom will be loaded from emulators' games path.
func (emu *EmulatorMock) loadRom(game string) {
fmt.Printf("%v %v\n", emu.paths.cores, emu.core)
coreLoad(config.EmulatorMeta{
Path: emu.paths.cores + emu.core,
coreLoad(emulator.Metadata{
LibPath: emu.paths.cores + emu.core,
})
coreLoadGame(emu.paths.games + game)
}
@ -192,12 +204,10 @@ func (emu *EmulatorMock) getStateHash() string {
return getHash(state)
}
// getAssetsPath returns absolute path to the assets directory.
func getAssetsPath() string {
appName := "cloud-game"
// get app path at runtime
_, b, _, _ := runtime.Caller(0)
return filepath.Dir(strings.SplitAfter(b, appName)[0]) + "/" + appName + "/assets/"
// getRootPath returns absolute path to the root directory.
func getRootPath() string {
p, _ := filepath.Abs("../../../../")
return p + string(filepath.Separator)
}
// getHash returns MD5 hash.

View file

@ -0,0 +1,30 @@
package buildbot
import (
"strings"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo"
)
type Repo struct {
address string
compression repo.CompressionType
}
func NewBuildbotRepo(address string, compression string) Repo {
return Repo{address: address, compression: (repo.CompressionType)(compression)}
}
func (r Repo) GetCoreData(file string, info core.ArchInfo) repo.Data {
var sb strings.Builder
sb.WriteString(r.address + "/")
if info.Vendor != "" {
sb.WriteString(info.Vendor + "/")
}
sb.WriteString(info.Os + "/" + info.Arch + "/latest/" + file + info.LibExt)
if r.compression != "" {
sb.WriteString("." + r.compression.GetExt())
}
return repo.Data{Url: sb.String(), Compression: r.compression}
}

View file

@ -0,0 +1,56 @@
package buildbot
import (
"testing"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core"
)
func TestBuildbotRepo(t *testing.T) {
testAddress := "http://test.me"
tests := []struct {
file string
compression string
arch core.ArchInfo
resultUrl string
}{
{
file: "uber_core",
arch: core.ArchInfo{
Os: "linux",
Arch: "x86_64",
LibExt: ".so",
},
resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so",
},
{
file: "uber_core",
compression: "zip",
arch: core.ArchInfo{
Os: "linux",
Arch: "x86_64",
LibExt: ".so",
},
resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip",
},
{
file: "uber_core",
arch: core.ArchInfo{
Os: "osx",
Arch: "x86_64",
Vendor: "apple",
LibExt: ".dylib",
},
resultUrl: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib",
},
}
for _, test := range tests {
repo := NewBuildbotRepo(testAddress, test.compression)
data := repo.GetCoreData(test.file, test.arch)
if data.Url != test.resultUrl {
t.Errorf("seems that expected link address is incorrect (%v) for file %s %+v",
data.Url, test.file, test.arch)
}
}
}

View file

@ -0,0 +1,7 @@
package repo
type CompressionType string
func (c *CompressionType) GetExt() string {
return (string)(*c)
}

View file

@ -0,0 +1,20 @@
package github
import (
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo/buildbot"
)
type Repo struct {
buildbot.Repo
}
func NewGithubRepo(address string, compression string) Repo {
return Repo{Repo: buildbot.NewBuildbotRepo(address, compression)}
}
func (r Repo) GetCoreData(file string, info core.ArchInfo) repo.Data {
dat := r.Repo.GetCoreData(file, info)
return repo.Data{Url: dat.Url + "?raw=true", Compression: dat.Compression}
}

View file

@ -0,0 +1,56 @@
package github
import (
"testing"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core"
)
func TestBuildbotRepo(t *testing.T) {
testAddress := "http://test.me"
tests := []struct {
file string
compression string
arch core.ArchInfo
resultUrl string
}{
{
file: "uber_core",
arch: core.ArchInfo{
Os: "linux",
Arch: "x86_64",
LibExt: ".so",
},
resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so?raw=true",
},
{
file: "uber_core",
compression: "zip",
arch: core.ArchInfo{
Os: "linux",
Arch: "x86_64",
LibExt: ".so",
},
resultUrl: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip?raw=true",
},
{
file: "uber_core",
arch: core.ArchInfo{
Os: "osx",
Arch: "x86_64",
Vendor: "apple",
LibExt: ".dylib",
},
resultUrl: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib?raw=true",
},
}
for _, test := range tests {
repo := NewGithubRepo(testAddress, test.compression)
data := repo.GetCoreData(test.file, test.arch)
if data.Url != test.resultUrl {
t.Errorf("seems that expected link address is incorrect (%v) for file %s %+v",
data.Url, test.file, test.arch)
}
}
}

View file

@ -0,0 +1,24 @@
package raw
import (
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/repo"
)
type Repo struct {
address string
compression repo.CompressionType
}
// NewRawRepo defines a simple zip file containing
// all the cores that will be extracted as is.
func NewRawRepo(address string) Repo {
return Repo{
address: address,
compression: "zip",
}
}
func (r Repo) GetCoreData(_ string, _ core.ArchInfo) repo.Data {
return repo.Data{Url: r.address, Compression: r.compression}
}

View file

@ -0,0 +1,14 @@
package repo
import "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/core"
type (
Data struct {
Url string
Compression CompressionType
}
Repository interface {
GetCoreData(file string, info core.ArchInfo) Data
}
)

8
pkg/encoder/codec.go Normal file
View file

@ -0,0 +1,8 @@
package encoder
type VideoCodec int
const (
H264 VideoCodec = iota
VPX
)

18
pkg/environment/env.go Normal file
View file

@ -0,0 +1,18 @@
package environment
type Env string
const (
Dev Env = "dev"
Staging = "staging"
Production = "prod"
)
func (env *Env) AnyOf(what ...Env) bool {
for _, cur := range what {
if *env == cur {
return true
}
}
return false
}

View file

@ -0,0 +1,24 @@
package extractor
import (
"path/filepath"
"github.com/giongto35/cloud-game/v2/pkg/extractor/zip"
)
type Extractor interface {
Extract(src string, dest string) ([]string, error)
}
const (
zipExt = ".zip"
)
func NewFromExt(path string) Extractor {
switch filepath.Ext(path) {
case zipExt:
return zip.New()
default:
return nil
}
}

View file

@ -0,0 +1,67 @@
package zip
import (
"archive/zip"
"io"
"log"
"os"
"path/filepath"
"strings"
)
type Extractor struct{}
func New() Extractor { return Extractor{} }
func (e Extractor) Extract(src string, dest string) (files []string, err error) {
r, err := zip.OpenReader(src)
if err != nil {
return files, err
}
defer r.Close()
for _, f := range r.File {
path := filepath.Join(dest, f.Name)
// 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)
continue
}
// remake directory
if f.FileInfo().IsDir() {
if err := os.MkdirAll(path, os.ModePerm); err != nil {
log.Printf("error: %v", err)
}
continue
}
// make file
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
log.Printf("error: %v", 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)
continue
}
rc, err := f.Open()
if err != nil {
log.Printf("error: %v", err)
continue
}
if _, err = io.Copy(out, rc); err != nil {
log.Printf("error: %v", err)
_ = out.Close()
_ = rc.Close()
continue
}
_ = out.Close()
_ = rc.Close()
files = append(files, path)
}
return files, nil
}

View file

@ -7,7 +7,7 @@ import (
"os/user"
"unsafe"
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/giongto35/cloud-game/v2/pkg/encoder"
)
// https://stackoverflow.com/questions/9465815/rgb-to-yuv420-algorithm-efficiency
@ -87,6 +87,6 @@ func savePath(hash string) string {
// GetVideoEncoder returns video encoder based on some qualification.
// Actually Android is only supporting VP8 but H264 has better encoding performance
// TODO: Better use useragent attribute from frontend
func GetVideoEncoder(isMobile bool) string {
return config.CODEC_VP8
func GetVideoEncoder(isMobile bool) encoder.VideoCodec {
return encoder.VPX
}

57
pkg/webrtc/ice.go Normal file
View file

@ -0,0 +1,57 @@
package webrtc
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()
}

78
pkg/webrtc/ice_test.go Normal file
View file

@ -0,0 +1,78 @@
package webrtc
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...)
}
})
}
}

View file

@ -10,7 +10,8 @@ import (
"runtime/debug"
"time"
"github.com/giongto35/cloud-game/v2/pkg/config"
webrtcConfig "github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
"github.com/giongto35/cloud-game/v2/pkg/encoder"
"github.com/giongto35/cloud-game/v2/pkg/util"
"github.com/gofrs/uuid"
"github.com/pion/webrtc/v2"
@ -35,6 +36,7 @@ type WebRTC struct {
ID string
connection *webrtc.PeerConnection
cfg webrtcConfig.Config
isConnected bool
isClosed bool
// for yuvI420 image
@ -100,6 +102,11 @@ func NewWebRTC() *WebRTC {
return w
}
func (w *WebRTC) WithConfig(conf webrtcConfig.Config) *WebRTC {
w.cfg = conf
return w
}
// StartClient start webrtc
func (w *WebRTC) StartClient(isMobile bool, iceCB OnIceCallback) (string, error) {
defer func() {
@ -124,7 +131,7 @@ func (w *WebRTC) StartClient(isMobile bool, iceCB OnIceCallback) (string, error)
}
// add video track
if util.GetVideoEncoder(isMobile) == config.CODEC_H264 {
if util.GetVideoEncoder(isMobile) == encoder.H264 {
videoTrack, err = w.connection.NewTrack(webrtc.DefaultPayloadTypeH264, rand.Uint32(), "video", "game-video")
} else {
videoTrack, err = w.connection.NewTrack(webrtc.DefaultPayloadTypeVP8, rand.Uint32(), "video", "game-video")
@ -343,14 +350,13 @@ func (w *WebRTC) startStreaming(vp8Track *webrtc.Track, opusTrack *webrtc.Track)
}
}()
opusSamples := uint32(w.cfg.Encoder.Audio.GetFrameDuration() / w.cfg.Encoder.Audio.Channels)
for data := range w.AudioChannel {
if !w.isConnected {
return
}
err := opusTrack.WriteSample(media.Sample{
Data: data,
Samples: uint32(config.AUDIO_FRAME / config.AUDIO_CHANNELS),
})
err := opusTrack.WriteSample(media.Sample{Data: data, Samples: opusSamples})
if err != nil {
log.Println("Warn: Err write sample: ", err)
}

View file

@ -6,7 +6,9 @@ import (
"strconv"
"github.com/giongto35/cloud-game/v2/pkg/api"
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/encoder"
"github.com/giongto35/cloud-game/v2/pkg/games"
"github.com/giongto35/cloud-game/v2/pkg/util"
"github.com/giongto35/cloud-game/v2/pkg/webrtc"
@ -14,13 +16,14 @@ import (
"github.com/gorilla/websocket"
)
// CoordinatorClient maintans connection to coordinator
// We expect only one CoordinatorClient for each server
// CoordinatorClient maintains connection to coordinator.
// We expect only one CoordinatorClient for each server.
type CoordinatorClient struct {
*cws.Client
}
// NewCoordinatorClient returns a client connecting to coordinator for coordiation between different server
// NewCoordinatorClient returns a client connecting to coordinator
// for coordination between different server.
func NewCoordinatorClient(oc *websocket.Conn) *CoordinatorClient {
if oc == nil {
return nil
@ -32,276 +35,248 @@ func NewCoordinatorClient(oc *websocket.Conn) *CoordinatorClient {
return oClient
}
// RouteCoordinator are all routes server received from coordinator
// RouteCoordinator are all routes server received from coordinator.
func (h *Handler) RouteCoordinator() {
// iceCandidates := map[string][]string{}
oClient := h.oClient
/* Coordinator */
// Received from coordinator the serverID
oClient.Receive(
"serverID",
func(response cws.WSPacket) (request cws.WSPacket) {
// Stick session with serverID got from coordinator
log.Println("Received serverID ", response.Data)
h.serverID = response.Data
oClient.Receive("serverID", func(response cws.WSPacket) (request cws.WSPacket) {
// Stick session with serverID got from coordinator
log.Println("Received serverID ", response.Data)
h.serverID = response.Data
return cws.EmptyPacket
},
)
return cws.EmptyPacket
})
/* WebRTC Connection */
oClient.Receive(
"initwebrtc",
func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a request to createOffer from browser via coordinator")
peerconnection := webrtc.NewWebRTC()
var initPacket struct {
IsMobile bool `json:"is_mobile"`
}
err := json.Unmarshal([]byte(resp.Data), &initPacket)
if err != nil {
log.Println("Error: Cannot decode json:", err)
return cws.EmptyPacket
}
localSession, err := peerconnection.StartClient(
initPacket.IsMobile,
func(candidate string) {
// send back candidate string to browser
oClient.Send(cws.WSPacket{
ID: "candidate",
Data: candidate,
SessionID: 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,
}
},
)
oClient.Receive(
"answer",
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.Println("Error: Cannot set RemoteSDP of client: " + resp.SessionID)
}
} else {
log.Printf("Error: No session for ID: %s\n", resp.SessionID)
}
oClient.Receive("initwebrtc", func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a request to createOffer from browser via coordinator")
peerconnection := webrtc.NewWebRTC().WithConfig(
webrtcConfig.Config{Encoder: h.cfg.Encoder, Webrtc: h.cfg.Webrtc},
)
var initPacket struct {
IsMobile bool `json:"is_mobile"`
}
err := json.Unmarshal([]byte(resp.Data), &initPacket)
if err != nil {
log.Println("Error: Cannot decode json:", err)
return cws.EmptyPacket
},
)
}
oClient.Receive(
"candidate",
func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received remote Ice Candidate from browser")
session := h.getSession(resp.SessionID)
localSession, err := peerconnection.StartClient(
initPacket.IsMobile,
func(candidate string) {
// send back candidate string to browser
oClient.Send(cws.WSPacket{
ID: "candidate",
Data: candidate,
SessionID: resp.SessionID,
}, nil)
},
)
if session != nil {
peerconnection := session.peerconnection
// localSession, err := peerconnection.StartClient(initPacket.IsMobile, iceCandidates[resp.SessionID])
// h.peerconnections[resp.SessionID] = 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)
}
// 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,
}
})
oClient.Receive("answer", 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.Println("Error: Cannot set RemoteSDP of client: " + resp.SessionID)
}
} else {
log.Printf("Error: No session for ID: %s\n", resp.SessionID)
}
return cws.EmptyPacket
})
oClient.Receive("candidate", 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
})
/* Game Logic */
oClient.Receive(
"start",
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 for ID: %s\n", resp.SessionID)
return cws.EmptyPacket
}
peerconnection := session.peerconnection
// TODO: Standardize for all types of packet. Make WSPacket generic
startPacket := api.GameStartCall{}
if err := startPacket.From(resp.Data); err != nil {
return cws.EmptyPacket
}
gameMeta := games.GameMetadata{
Name: startPacket.Name,
Type: startPacket.Type,
Path: startPacket.Path,
}
room := h.startGameHandler(gameMeta, resp.RoomID, resp.PlayerIndex, peerconnection, util.GetVideoEncoder(false))
session.RoomID = room.ID
// TODO: can data race
h.rooms[room.ID] = room
return cws.WSPacket{
ID: "start",
RoomID: room.ID,
}
},
)
oClient.Receive(
"quit",
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)
}
oClient.Receive("start", 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 for ID: %s\n", resp.SessionID)
return cws.EmptyPacket
},
)
}
oClient.Receive(
"save",
func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a save game from coordinator")
log.Println("RoomID:", resp.RoomID)
req.ID = "save"
req.Data = "ok"
if resp.RoomID != "" {
room := h.getRoom(resp.RoomID)
if room == nil {
return
}
err := room.SaveGame()
if err != nil {
log.Println("[!] Cannot save game state: ", err)
req.Data = "error"
}
} else {
req.Data = "error"
}
peerconnection := session.peerconnection
// TODO: Standardize for all types of packet. Make WSPacket generic
startPacket := api.GameStartCall{}
if err := startPacket.From(resp.Data); err != nil {
return cws.EmptyPacket
}
gameMeta := games.GameMetadata{
Name: startPacket.Name,
Type: startPacket.Type,
Path: startPacket.Path,
}
return req
})
room := h.startGameHandler(gameMeta, resp.RoomID, resp.PlayerIndex, peerconnection, util.GetVideoEncoder(false))
session.RoomID = room.ID
// TODO: can data race
h.rooms[room.ID] = room
oClient.Receive(
"load",
func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a load game from coordinator")
log.Println("Loading game state")
req.ID = "load"
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 cws.WSPacket{
ID: "start",
RoomID: room.ID,
}
})
return req
})
oClient.Receive("quit", func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a quit request from coordinator")
session := h.getSession(resp.SessionID)
oClient.Receive(
"playerIdx",
func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received an update player index event from coordinator")
req.ID = "playerIdx"
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
})
oClient.Receive(
"multitap",
func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a multitap toggle from coordinator")
req.ID = "multitap"
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
})
oClient.Receive(
"terminateSession",
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)
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)
}
} else {
log.Printf("Error: No session for ID: %s\n", resp.SessionID)
}
return cws.EmptyPacket
},
)
return cws.EmptyPacket
})
oClient.Receive("save", func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a save game from coordinator")
log.Println("RoomID:", resp.RoomID)
req.ID = "save"
req.Data = "ok"
if resp.RoomID != "" {
room := h.getRoom(resp.RoomID)
if room == nil {
return
}
err := room.SaveGame()
if err != nil {
log.Println("[!] Cannot save game state: ", err)
req.Data = "error"
}
} else {
req.Data = "error"
}
return req
})
oClient.Receive("load", func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a load game from coordinator")
log.Println("Loading game state")
req.ID = "load"
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
})
oClient.Receive("playerIdx", func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received an update player index event from coordinator")
req.ID = "playerIdx"
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
})
oClient.Receive("multitap", func(resp cws.WSPacket) (req cws.WSPacket) {
log.Println("Received a multitap toggle from coordinator")
req.ID = "multitap"
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
})
oClient.Receive("terminateSession", 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 getServerIDOfRoom(oc *CoordinatorClient, roomID string) string {
@ -318,7 +293,7 @@ func getServerIDOfRoom(oc *CoordinatorClient, roomID string) string {
}
// startGameHandler starts a game if roomID is given, if not create new room
func (h *Handler) startGameHandler(game games.GameMetadata, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC, videoEncoderType string) *room.Room {
func (h *Handler) startGameHandler(game games.GameMetadata, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC, videoCodec encoder.VideoCodec) *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
@ -327,7 +302,7 @@ func (h *Handler) startGameHandler(game games.GameMetadata, existedRoomID string
if room == nil {
log.Println("Got Room from local ", room, " ID: ", existedRoomID)
// Create new room and update player index
room = h.createNewRoom(game, existedRoomID, videoEncoderType)
room = h.createNewRoom(game, existedRoomID, videoCodec)
room.UpdatePlayerIndex(peerconnection, playerIndex)
// Wait for done signal from room

View file

@ -8,8 +8,10 @@ import (
"path"
"time"
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/giongto35/cloud-game/v2/pkg/config/worker"
"github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/manager/remotehttp"
"github.com/giongto35/cloud-game/v2/pkg/encoder"
"github.com/giongto35/cloud-game/v2/pkg/environment"
"github.com/giongto35/cloud-game/v2/pkg/games"
"github.com/giongto35/cloud-game/v2/pkg/util"
"github.com/giongto35/cloud-game/v2/pkg/webrtc"
@ -18,11 +20,6 @@ import (
"github.com/gorilla/websocket"
)
const (
gameboyIndex = "./static/game.html"
debugIndex = "./static/game.html"
)
// Flag to determine if the server is coordinator or not
var upgrader = websocket.Upgrader{}
@ -52,7 +49,7 @@ func NewHandler(cfg worker.Config) *Handler {
return &Handler{
rooms: map[string]*room.Room{},
sessions: map[string]*Session{},
coordinatorHost: cfg.CoordinatorAddress,
coordinatorHost: cfg.Worker.Network.CoordinatorAddress,
cfg: cfg,
onlineStorage: onlineStorage,
}
@ -61,7 +58,8 @@ func NewHandler(cfg worker.Config) *Handler {
// Run starts a Handler running logic
func (h *Handler) Run() {
for {
oClient, err := setupCoordinatorConnection(h.coordinatorHost, h.cfg.Zone)
conf := h.cfg.Worker.Network
oClient, err := setupCoordinatorConnection(conf.CoordinatorAddress, conf.Zone, h.cfg)
if err != nil {
log.Printf("Cannot connect to coordinator. %v Retrying...", err)
time.Sleep(time.Second)
@ -77,10 +75,22 @@ func (h *Handler) Run() {
}
}
func setupCoordinatorConnection(ohost string, zone string) (*CoordinatorClient, error) {
var scheme string
func (h *Handler) Prepare() {
if !h.cfg.Emulator.Libretro.Cores.Repo.Sync {
return
}
if *config.Mode == config.ProdEnv || *config.Mode == config.StagingEnv {
log.Printf("Starting Libretro cores sync...")
coreManager := remotehttp.NewRemoteHttpManager(h.cfg.Emulator.Libretro)
if err := coreManager.Sync(); err != nil {
log.Printf("error: cores sync has failed, %v", err)
}
}
func setupCoordinatorConnection(ohost string, zone string, cfg worker.Config) (*CoordinatorClient, error) {
var scheme string
env := cfg.Environment.Get()
if env.AnyOf(environment.Production, environment.Staging) {
scheme = "wss"
} else {
scheme = "ws"
@ -170,12 +180,12 @@ func (h *Handler) detachRoom(roomID string) {
// createNewRoom creates a new room
// Return nil in case of room is existed
func (h *Handler) createNewRoom(game games.GameMetadata, roomID string, videoEncoderType string) *room.Room {
func (h *Handler) createNewRoom(game games.GameMetadata, roomID string, videoCodec encoder.VideoCodec) *room.Room {
// If the roomID is empty,
// or the roomID doesn't have any running sessions (room was closed)
// we spawn a new room
if roomID == "" || !h.isRoomRunning(roomID) {
room := room.NewRoom(roomID, game, videoEncoderType, h.onlineStorage, h.cfg)
room := room.NewRoom(roomID, game, videoCodec, h.onlineStorage, h.cfg)
// TODO: Might have race condition
h.rooms[room.ID] = room
return room

View file

@ -4,7 +4,7 @@ import (
"fmt"
"log"
"github.com/giongto35/cloud-game/v2/pkg/config"
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/h264encoder"
vpxencoder "github.com/giongto35/cloud-game/v2/pkg/encoder/vpx-encoder"
@ -39,13 +39,6 @@ func resample(pcm []int16, targetSize int, srcSampleRate int, dstSampleRate int)
return newPCM
}
func min(x int, y int) int {
if x < y {
return x
}
return y
}
func (r *Room) startVoice() {
// broadcast voice
go func() {
@ -70,11 +63,11 @@ func (r *Room) startVoice() {
}()
}
func (r *Room) startAudio(sampleRate int) {
func (r *Room) startAudio(sampleRate int, audio encoderConfig.Audio) {
log.Println("Enter fan audio")
srcSampleRate := sampleRate
enc, err := opus.NewEncoder(config.AUDIO_RATE, 2, opus.AppAudio)
srcSampleRate := sampleRate
enc, err := opus.NewEncoder(audio.Frequency, audio.Channels, opus.AppAudio)
if err != nil {
log.Println("[!] Cannot create audio encoder", err)
}
@ -83,8 +76,8 @@ func (r *Room) startAudio(sampleRate int) {
enc.SetBitrateToAuto()
enc.SetComplexity(10)
dstBufferSize := config.AUDIO_FRAME
srcBufferSize := dstBufferSize * srcSampleRate / config.AUDIO_RATE
dstBufferSize := audio.GetFrameDuration()
srcBufferSize := dstBufferSize * srcSampleRate / audio.Frequency
pcm := make([]int16, srcBufferSize) // 640 * 1000 / 16000 == 40 ms
idx := 0
@ -98,7 +91,7 @@ func (r *Room) startAudio(sampleRate int) {
if idx == len(pcm) {
data := make([]byte, 1024*2)
dstpcm := resample(pcm, dstBufferSize, srcSampleRate, config.AUDIO_RATE)
dstpcm := resample(pcm, dstBufferSize, srcSampleRate, audio.Frequency)
n, err := enc.Encode(dstpcm, data)
if err != nil {
@ -126,13 +119,13 @@ func (r *Room) startAudio(sampleRate int) {
log.Println("Room ", r.ID, " audio channel closed")
}
// startVideo listen from imageChannel and push to Encoder. The output of encoder will be pushed to webRTC
func (r *Room) startVideo(width, height int, videoEncoderType string) {
// startVideo processes imageChannel images with an encoder (codec) then pushes the result to WebRTC.
func (r *Room) startVideo(width, height int, videoCodec encoder.VideoCodec) {
var enc encoder.Encoder
var err error
log.Println("Video Encoder: ", videoEncoderType)
if videoEncoderType == config.CODEC_H264 {
log.Println("Video Encoder: ", videoCodec)
if videoCodec == encoder.H264 {
enc, err = h264encoder.NewH264Encoder(width, height, 1)
} else {
enc, err = vpxencoder.NewVpxEncoder(width, height, 20, 1200, 5)

View file

@ -16,7 +16,6 @@ import (
"strings"
"sync"
"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/emulator/libretro/nanoarch"
@ -128,7 +127,7 @@ func NewVideoImporter(roomID string) chan nanoarch.GameFrame {
}
// NewRoom creates a new room
func NewRoom(roomID string, game games.GameMetadata, videoEncoderType string, onlineStorage *storage.Client, cfg worker.Config) *Room {
func NewRoom(roomID string, game games.GameMetadata, videoCodec encoder.VideoCodec, onlineStorage *storage.Client, cfg worker.Config) *Room {
if roomID == "" {
roomID = generateRoomID(game.Name)
}
@ -161,21 +160,22 @@ func NewRoom(roomID string, game games.GameMetadata, videoEncoderType string, on
}
// If not then load room or create room from local.
log.Printf("Room %s started. GameName: %s, WithGame: %t", roomID, game.Name, cfg.WithoutGame)
log.Printf("Room %s started. GameName: %s, WithGame: %t", roomID, game.Name, cfg.Encoder.WithoutGame)
// Spawn new emulator based on gameName and plug-in all channels
emuName, _ := config.FileTypeToEmulator[game.Type]
emuName := cfg.Emulator.GetEmulatorByRom(game.Type)
libretroConfig := cfg.Emulator.GetLibretroCoreConfig(emuName)
if cfg.WithoutGame {
if cfg.Encoder.WithoutGame {
// Run without game, image stream is communicated over unixsocket
imageChannel := NewVideoImporter(roomID)
director, _, audioChannel := nanoarch.Init(emuName, roomID, false, inputChannel)
director, _, audioChannel := nanoarch.Init(roomID, false, inputChannel, libretroConfig)
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(emuName, roomID, true, inputChannel)
director, imageChannel, audioChannel := nanoarch.Init(roomID, true, inputChannel, libretroConfig)
room.imageChannel = imageChannel
room.director = director
room.audioChannel = audioChannel
@ -183,20 +183,22 @@ func NewRoom(roomID string, game games.GameMetadata, videoEncoderType string, on
gameMeta := room.director.LoadMeta(game.Path)
// nwidth, nheight are the webRTC output size.
// There are currently two approach
// nwidth, nheight are the WebRTC output size
var nwidth, nheight int
if cfg.EnableAspectRatio {
baseAspectRatio := float64(gameMeta.BaseWidth) / float64(gameMeta.Height)
nwidth, nheight = resizeToAspect(baseAspectRatio, cfg.Width, cfg.Height)
log.Printf("Viewport size will be changed from %dx%d (%f) -> %dx%d", cfg.Width, cfg.Height,
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 cfg.Scale > 1 {
nwidth, nheight = nwidth*cfg.Scale, nheight*cfg.Scale
if emu.Scale > 1 {
nwidth, nheight = nwidth*emu.Scale, nheight*emu.Scale
log.Printf("Viewport size has scaled to %dx%d", nwidth, nheight)
}
@ -212,8 +214,8 @@ func NewRoom(roomID string, game games.GameMetadata, videoEncoderType string, on
room.director.SetViewport(encoderW, encoderH)
// Spawn video and audio encoding for webRTC
go room.startVideo(encoderW, encoderH, videoEncoderType)
go room.startAudio(gameMeta.AudioSampleRate)
go room.startVideo(encoderW, encoderH, videoCodec)
go room.startAudio(gameMeta.AudioSampleRate, cfg.Encoder.Audio)
go room.startVoice()
room.director.Start()

View file

@ -19,6 +19,7 @@ import (
"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/thread"
@ -42,16 +43,13 @@ type roomMockConfig struct {
roomName string
gamesPath string
game games.GameMetadata
codec string
codec encoder.VideoCodec
autoGlContext bool
}
// Restricts a re-config call
// to only one invocation.
var configOnce sync.Once
// Store absolute path to test games
var whereIsGames = getAppPath() + "assets/games/"
var whereIsGames = getRootPath() + "assets/games/"
var whereIsConfigs = getRootPath() + "configs/"
var testTempDir = filepath.Join(os.TempDir(), "cloud-game-core-tests")
func init() {
@ -70,7 +68,7 @@ func TestRoom(t *testing.T) {
tests := []struct {
roomName string
game games.GameMetadata
codec string
codec encoder.VideoCodec
frames int
}{
{
@ -79,7 +77,7 @@ func TestRoom(t *testing.T) {
Type: "nes",
Path: "Super Mario Bros.nes",
},
codec: config.CODEC_VP8,
codec: encoder.VPX,
frames: 5,
},
}
@ -95,12 +93,14 @@ func TestRoom(t *testing.T) {
waitNFrames(test.frames, room.encoder.GetOutputChan())
room.Close()
}
// hack: wait room destruction
time.Sleep(2 * time.Second)
}
func TestRoomWithGL(t *testing.T) {
tests := []struct {
game games.GameMetadata
codec string
codec encoder.VideoCodec
frames int
}{
{
@ -109,7 +109,7 @@ func TestRoomWithGL(t *testing.T) {
Type: "n64",
Path: "Sample Demo by Florian (PD).z64",
},
codec: config.CODEC_VP8,
codec: encoder.VPX,
frames: 50,
},
}
@ -125,6 +125,8 @@ func TestRoomWithGL(t *testing.T) {
waitNFrames(test.frames, room.encoder.GetOutputChan())
room.Close()
}
// hack: wait room destruction
time.Sleep(2 * time.Second)
}
thread.MainMaybe(run)
@ -155,7 +157,7 @@ func TestAllEmulatorRooms(t *testing.T) {
room := getRoomMock(roomMockConfig{
gamesPath: whereIsGames,
game: test.game,
codec: config.CODEC_VP8,
codec: encoder.VPX,
autoGlContext: autoGlContext,
})
t.Logf("The game [%v] has been loaded", test.game.Name)
@ -221,9 +223,18 @@ func dumpCanvas(f *image.RGBA, name string, caption string, path string) {
// getRoomMock returns mocked Room struct.
func getRoomMock(cfg roomMockConfig) roomMock {
configOnce.Do(func() { fixEmulators(cfg.autoGlContext) })
cfg.game.Path = cfg.gamesPath + cfg.game.Path
room := NewRoom(cfg.roomName, cfg.game, cfg.codec, storage.NewInitClient(), worker.NewDefaultConfig())
var conf worker.Config
config.LoadConfig(&conf, whereIsConfigs)
fixEmulators(&conf, cfg.autoGlContext)
// sync cores
coreManager := remotehttp.NewRemoteHttpManager(conf.Emulator.Libretro)
if err := coreManager.Sync(); err != nil {
log.Printf("error: cores sync has failed, %v", err)
}
room := NewRoom(cfg.roomName, cfg.game, cfg.codec, storage.NewInitClient(), conf)
// loop-wait the room initialization
var init sync.WaitGroup
@ -246,24 +257,25 @@ func getRoomMock(cfg roomMockConfig) roomMock {
}
// fixEmulators makes absolute game paths in global GameList and passes GL context config.
func fixEmulators(autoGlContext bool) {
appPath := getAppPath()
// hack: emulator paths should be absolute and visible to the tests.
func fixEmulators(config *worker.Config, autoGlContext bool) {
rootPath := getRootPath()
for k, conf := range config.EmulatorConfig {
conf.Path = appPath + conf.Path
if len(conf.Config) > 0 {
conf.Config = appPath + conf.Config
}
config.Emulator.Libretro.Cores.Paths.Libs =
filepath.FromSlash(rootPath + config.Emulator.Libretro.Cores.Paths.Libs)
config.Emulator.Libretro.Cores.Paths.Configs =
filepath.FromSlash(rootPath + config.Emulator.Libretro.Cores.Paths.Configs)
for k, conf := range config.Emulator.Libretro.Cores.List {
if conf.IsGlAllowed && autoGlContext {
conf.AutoGlContext = true
}
config.EmulatorConfig[k] = conf
config.Emulator.Libretro.Cores.List[k] = conf
}
}
// getAppPath returns absolute path to the assets directory.
func getAppPath() string {
// getRootPath returns absolute path to the assets directory.
func getRootPath() string {
p, _ := filepath.Abs("../../../")
return p + string(filepath.Separator)
}
@ -288,7 +300,7 @@ func waitNFrames(n int, ch chan encoder.OutFrame) {
// 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 string, 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)
os.Stdout, _ = os.Open(os.DevNull)
@ -311,7 +323,7 @@ func BenchmarkRoom(b *testing.B) {
benches := []struct {
system string
game games.GameMetadata
codecs []string
codecs []encoder.VideoCodec
frames int
}{
// warm up
@ -322,7 +334,7 @@ func BenchmarkRoom(b *testing.B) {
Type: "gba",
Path: "Sushi The Cat.gba",
},
codecs: []string{"vp8"},
codecs: []encoder.VideoCodec{encoder.VPX},
frames: 50,
},
{
@ -332,7 +344,7 @@ func BenchmarkRoom(b *testing.B) {
Type: "gba",
Path: "Sushi The Cat.gba",
},
codecs: []string{"vp8", "x264"},
codecs: []encoder.VideoCodec{encoder.VPX, encoder.H264},
frames: 100,
},
{
@ -342,14 +354,14 @@ func BenchmarkRoom(b *testing.B) {
Type: "nes",
Path: "Super Mario Bros.nes",
},
codecs: []string{"vp8", "x264"},
codecs: []encoder.VideoCodec{encoder.VPX, encoder.H264},
frames: 100,
},
}
for _, bench := range benches {
for _, codec := range bench.codecs {
b.Run(fmt.Sprintf("%s-%s-%d", bench.system, codec, bench.frames), func(b *testing.B) {
b.Run(fmt.Sprintf("%s-%v-%d", bench.system, codec, bench.frames), func(b *testing.B) {
benchmarkRoom(bench.game, codec, bench.frames, true, b)
})
// hack: wait room destruction

View file

@ -5,13 +5,12 @@ import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"strconv"
"time"
"github.com/giongto35/cloud-game/v2/pkg/config"
"github.com/giongto35/cloud-game/v2/pkg/config/worker"
"github.com/giongto35/cloud-game/v2/pkg/environment"
"github.com/giongto35/cloud-game/v2/pkg/monitoring"
"github.com/golang/glog"
"golang.org/x/crypto/acme"
@ -32,7 +31,7 @@ func New(ctx context.Context, cfg worker.Config) *Worker {
ctx: ctx,
cfg: cfg,
monitoringServer: monitoring.NewServerMonitoring(cfg.MonitoringConfig),
monitoringServer: monitoring.NewServerMonitoring(cfg.Worker.Monitoring),
}
}
@ -51,6 +50,7 @@ func (o *Worker) RunMonitoringServer() {
}
func (o *Worker) Shutdown() {
// !to add a proper HTTP(S) server shutdown (cws/handler bad loop)
if err := o.monitoringServer.Shutdown(o.ctx); err != nil {
glog.Errorln("Failed to shutdown monitoring server")
}
@ -92,16 +92,18 @@ func (o *Worker) spawnServer(port int) {
var certManager *autocert.Manager
var httpsSrv *http.Server
if *config.Mode == config.ProdEnv || *config.Mode == config.StagingEnv {
mode := o.cfg.Environment.Get()
if mode.AnyOf(environment.Production, environment.Staging) {
serverConfig := o.cfg.Worker.Server
httpsSrv = makeHTTPServer()
httpsSrv.Addr = fmt.Sprintf(":%d", *config.HttpsPort)
httpsSrv.Addr = fmt.Sprintf(":%d", serverConfig.HttpsPort)
if *config.HttpsChain == "" || *config.HttpsKey == "" {
*config.HttpsChain = ""
*config.HttpsKey = ""
if serverConfig.HttpsChain == "" || serverConfig.HttpsKey == "" {
serverConfig.HttpsChain = ""
serverConfig.HttpsKey = ""
var leurl string
if *config.Mode == config.StagingEnv {
if mode == environment.Staging {
leurl = stagingLEURL
} else {
leurl = acme.LetsEncryptURL
@ -116,17 +118,17 @@ func (o *Worker) spawnServer(port int) {
httpsSrv.TLSConfig = &tls.Config{GetCertificate: certManager.GetCertificate}
}
go func() {
go func(chain string, key string) {
fmt.Printf("Starting HTTPS server on %s\n", httpsSrv.Addr)
err := httpsSrv.ListenAndServeTLS(*config.HttpsChain, *config.HttpsKey)
err := httpsSrv.ListenAndServeTLS(chain, key)
if err != nil {
log.Printf("httpsSrv.ListendAndServeTLS() failed with %s", err)
}
}()
}(serverConfig.HttpsChain, serverConfig.HttpsKey)
}
var httpSrv *http.Server
if *config.Mode == config.ProdEnv || *config.Mode == config.StagingEnv {
if mode.AnyOf(environment.Production, environment.Staging) {
httpSrv = makeHTTPToHTTPSRedirectServer()
} else {
httpSrv = makeHTTPServer()
@ -136,43 +138,41 @@ func (o *Worker) spawnServer(port int) {
httpSrv.Handler = certManager.HTTPHandler(httpSrv.Handler)
}
httpSrv.Addr = ":" + strconv.Itoa(port)
err := httpSrv.ListenAndServe()
if err != nil {
log.Printf("httpSrv.ListenAndServe() failed with %s", err)
startServer(httpSrv, port)
}
func startServer(serv *http.Server, startPort int) {
// It's recommend to run one worker on one instance.
// This logic is to make sure more than 1 workers still work
for port, n := startPort, startPort+100; port < n; port++ {
serv.Addr = ":" + strconv.Itoa(port)
err := serv.ListenAndServe()
switch err {
case http.ErrServerClosed:
log.Printf("HTTP(S) server was closed")
return
default:
}
port++
if port == n {
log.Printf("error: couldn't find an open port in range %v-%v\n", startPort, port)
}
}
}
// initializeWorker setup a worker
func (o *Worker) initializeWorker() {
worker := NewHandler(o.cfg)
wrk := NewHandler(o.cfg)
defer func() {
log.Println("Close worker")
worker.Close()
wrk.Close()
}()
go worker.Run()
port := o.cfg.HttpPort
// It's recommend to run one worker on one instance.
// This logic is to make sure more than 1 workers still work
portsNum := 100
for {
portsNum--
l, err := net.Listen("tcp", ":"+strconv.Itoa(port))
if err != nil {
port++
continue
}
go wrk.Run()
// will block here
wrk.Prepare()
if portsNum < 1 {
log.Printf("Couldn't find an open port in range %v-%v\n", o.cfg.HttpPort, port)
// Cannot find port
return
}
_ = l.Close()
o.spawnServer(port)
}
o.spawnServer(o.cfg.Worker.Server.Port)
}

150
web/game.html vendored
View file

@ -1,150 +0,0 @@
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta property="og:type" content="cloud-game" />
<meta property="og:title" content="Web-based Cloud Gaming for Retro Games" />
<meta property="og:description" content="Play and share cloud gaming experience with your friends" />
<meta property="og:image" content="http://cloud.webgame2d.com/static/img/ogimage.jpg" />
<meta property="og:url" content="" />
<meta property="og:site_name" content="Cloud Retro" />
<meta property="og:author" content="giongto35 trichimtrich" />
<link href="/static/css/font-awesome.css?2" rel="stylesheet">
<link href="/static/css/main.css?4" rel="stylesheet">
<link href="/static/css/ui.css?v=1" rel="stylesheet">
</head>
<body>
<div id="gamebody">
<!--<div id="ui-emulator-bg"></div>-->
<div id="circle-pad-holder">
<div id="btn-up" class="dpad" value="up"></div>
<div id="btn-down" class="dpad" value="down"></div>
<div id="btn-left" class="dpad" value="left"></div>
<div id="btn-right" class="dpad" value="right"></div>
<div id="circle-pad"></div>
</div>
<div id="bottom-screen">
<div id="stats-overlay" class="no-select" hidden></div>
<!--NOTE: New browser doesn't allow unmuted video player. So we muted here.
There is still audio because current audio flow is not from media but it is manually encoded (technical webRTC challenge). Later, when we can integrate audio to media, we can face the issue with mute again .
https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
-->
<video id="game-screen" muted playinfullscreen="false" playsinline></video>
<div id="menu-screen">
<div id="menu-container">
</div>
<div id="menu-item-choice"></div>
</div>
</div>
<div id="guide-txt"><b>Arrows</b>(move),<b>ZXCVAS</b>(game ABXYLR),<b>1/2</b>(1st/2nd player),<b>Shift/Enter/K/L</b>(select/start/save/load),<b>F</b>(fullscreen),<b>share</b>(copy sharelink to clipboard)</div>
<div id="btn-load" unselectable="on" class="btn big unselectable" value="load"></div>
<div id="btn-save" unselectable="on" class="btn big unselectable" value="save"></div>
<div id="btn-join" unselectable="on" class="btn big unselectable" value="join"></div>
<div id="slider-playeridx" class="slidecontainer">
player choice
<input type="range" min="1" max="4" value="1" class="slider" id="playeridx" onkeydown="event.preventDefault()">
</div>
<div id="btn-quit" unselectable="on" class="btn big unselectable" value="quit"></div>
<div id="btn-select" unselectable="on" class="btn big unselectable" value="select"></div>
<div id="btn-start" unselectable="on" class="btn big unselectable" value="start"></div>
<div id="color-button-holder">
<div id="btn-a" unselectable="on" class="btn unselectable" value="a"></div>
<div id="btn-b" unselectable="on" class="btn unselectable" value="b"></div>
<div id="btn-x" unselectable="on" class="btn unselectable" value="x"></div>
<div id="btn-y" unselectable="on" class="btn unselectable" value="y"></div>
</div>
<!-- TODO: remove -->
<input id="room-txt" type="text" placeholder="room id..." unselectable="on" class=" unselectable" disabled>
<div id="noti-box" unselectable="on" class="unselectable">Oh my god</div>
<div id="help-overlay">
<div id="help-overlay-background"></div>
<div id="help-overlay-detail"></div>
</div>
<div id="btn-help" unselectable="on" class="btn unselectable" value="help"></div>
<div id="btn-settings" class="btn btn2 unselectable" value="settings">
Settings
</div>
</div>
<div id="app-settings" class="modal-window">
<div>
<div class="settings__controls">
<span title="Save" id="settings__controls__save" class="semi-button"></span>
<span title="Load" id="settings__controls__load" class="semi-button"></span>
<span title="Reset" id="settings__controls__reset" class="semi-button"></span>
<span title="Close" id="settings__controls__close" class="semi-button">X</span>
</div>
<h1>Settings</h1>
<div id="settings-data"></div>
<div>
* -- applied after application restart
</div>
</div>
<label class="dpad-toggle-label">
<input type="checkbox" id="dpad-toggle" checked>
<span class="dpad-toggle-slider"></span>
</label>
</div>
<a id="ribbon" style="position: fixed; right: 0; top: 0;" href="https://github.com/giongto35/cloud-game"><img width="149" height="149" src="https://github.blog/wp-content/uploads/2008/12/forkme_right_gray_6d6d6d.png?resize=149%2C149" class="attachment-full size-full" alt="Fork me on GitHub" data-recalc-dims="1"></a>
<script>
DEBUG = true;
STUNTURN = {{.STUNTURN}};
</script>
<script src="/static/js/lib/jquery-3.4.1.min.js"></script>
<script src="/static/js/gui/gui.js?v=1"></script>
<script src="/static/js/log.js?v=5"></script>
<script src="/static/js/utils.js?v1"></script>
<script src="/static/js/event/event.js?v=5"></script>
<script src="/static/js/input/keys.js?v=3"></script>
<script src="/static/js/settings/opts.js?v=1"></script>
<script src="/static/js/settings/settings.js?v=2"></script>
<script src="/static/js/env.js?v=5"></script>
<script src="/static/js/input/input.js?v=3"></script>
<script src="/static/js/gameList.js?v=3"></script>
<script src="/static/js/room.js?v=3"></script>
<script src="/static/js/stats/stats.js?v=1"></script>
<script src="/static/js/network/ajax.js?v=3"></script>
<script src="/static/js/network/socket.js?v=4"></script>
<script src="/static/js/network/rtcp.js?v=3"></script>
<script src="/static/js/controller.js?v=6"></script>
<script src="/static/js/input/keyboard.js?v=5"></script>
<script src="/static/js/input/touch.js?v=3"></script>
<script src="/static/js/input/joystick.js?v=3"></script>
<script src="/static/js/init.js?v=5"></script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-145078282-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-145078282-1');
</script>
</body>
</html>

153
web/index.html vendored
View file

@ -0,0 +1,153 @@
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="user-scalable=0">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta property="og:type" content="cloud-game"/>
<meta property="og:title" content="Web-based Cloud Gaming for Retro Games"/>
<meta property="og:description" content="Play and share cloud gaming experience with your friends"/>
<meta property="og:image" content="http://cloud.webgame2d.com/static/img/ogimage.jpg"/>
<meta property="og:url" content=""/>
<meta property="og:site_name" content="Cloud Retro"/>
<meta property="og:author" content="giongto35 trichimtrich"/>
<link href="/static/css/font-awesome.css?2" rel="stylesheet">
<link href="/static/css/main.css?4" rel="stylesheet">
<link href="/static/css/ui.css?v=1" rel="stylesheet">
</head>
<body>
<div id="gamebody">
<!--<div id="ui-emulator-bg"></div>-->
<div id="circle-pad-holder">
<div id="btn-up" class="dpad" value="up"></div>
<div id="btn-down" class="dpad" value="down"></div>
<div id="btn-left" class="dpad" value="left"></div>
<div id="btn-right" class="dpad" value="right"></div>
<div id="circle-pad"></div>
</div>
<div id="bottom-screen">
<div id="stats-overlay" class="no-select" hidden></div>
<!--NOTE: New browser doesn't allow unmuted video player. So we muted here.
There is still audio because current audio flow is not from media but it is manually encoded (technical webRTC challenge). Later, when we can integrate audio to media, we can face the issue with mute again .
https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
-->
<video id="game-screen" muted playinfullscreen="false" playsinline></video>
<div id="menu-screen">
<div id="menu-container">
</div>
<div id="menu-item-choice"></div>
</div>
</div>
<div id="guide-txt"><b>Arrows</b>(move),<b>ZXCVAS</b>(game ABXYLR),<b>1/2</b>(1st/2nd player),<b>Shift/Enter/K/L</b>(select/start/save/load),<b>F</b>(fullscreen),<b>share</b>(copy
sharelink to clipboard)
</div>
<div id="btn-load" unselectable="on" class="btn big unselectable" value="load"></div>
<div id="btn-save" unselectable="on" class="btn big unselectable" value="save"></div>
<div id="btn-join" unselectable="on" class="btn big unselectable" value="join"></div>
<div id="slider-playeridx" class="slidecontainer">
player choice
<input type="range" min="1" max="4" value="1" class="slider" id="playeridx" onkeydown="event.preventDefault()">
</div>
<div id="btn-quit" unselectable="on" class="btn big unselectable" value="quit"></div>
<div id="btn-select" unselectable="on" class="btn big unselectable" value="select"></div>
<div id="btn-start" unselectable="on" class="btn big unselectable" value="start"></div>
<div id="color-button-holder">
<div id="btn-a" unselectable="on" class="btn unselectable" value="a"></div>
<div id="btn-b" unselectable="on" class="btn unselectable" value="b"></div>
<div id="btn-x" unselectable="on" class="btn unselectable" value="x"></div>
<div id="btn-y" unselectable="on" class="btn unselectable" value="y"></div>
</div>
<!-- TODO: remove -->
<input id="room-txt" type="text" placeholder="room id..." unselectable="on" class=" unselectable" disabled>
<div id="noti-box" unselectable="on" class="unselectable">Oh my god</div>
<div id="help-overlay">
<div id="help-overlay-background"></div>
<div id="help-overlay-detail"></div>
</div>
<div id="btn-help" unselectable="on" class="btn unselectable" value="help"></div>
<div id="btn-settings" class="btn btn2 unselectable" value="settings">
Settings
</div>
</div>
<div id="app-settings" class="modal-window">
<div>
<div class="settings__controls">
<span title="Save" id="settings__controls__save" class="semi-button"></span>
<span title="Load" id="settings__controls__load" class="semi-button"></span>
<span title="Reset" id="settings__controls__reset" class="semi-button"></span>
<span title="Close" id="settings__controls__close" class="semi-button">X</span>
</div>
<h1>Settings</h1>
<div id="settings-data"></div>
<div>
* -- applied after application restart
</div>
</div>
<label class="dpad-toggle-label">
<input type="checkbox" id="dpad-toggle" checked>
<span class="dpad-toggle-slider"></span>
</label>
</div>
<a id="ribbon" style="position: fixed; right: 0; top: 0;" href="https://github.com/giongto35/cloud-game"><img
width="149" height="149"
src="https://github.blog/wp-content/uploads/2008/12/forkme_right_gray_6d6d6d.png?resize=149%2C149"
class="attachment-full size-full" alt="Fork me on GitHub" data-recalc-dims="1"></a>
<script src="/static/js/lib/jquery-3.4.1.min.js"></script>
<script src="/static/js/gui/gui.js?v=1"></script>
<script src="/static/js/log.js?v=5"></script>
<script src="/static/js/utils.js?v1"></script>
<script src="/static/js/event/event.js?v=5"></script>
<script src="/static/js/input/keys.js?v=3"></script>
<script src="/static/js/settings/opts.js?v=1"></script>
<script src="/static/js/settings/settings.js?v=2"></script>
<script src="/static/js/env.js?v=5"></script>
<script src="/static/js/input/input.js?v=3"></script>
<script src="/static/js/gameList.js?v=3"></script>
<script src="/static/js/room.js?v=3"></script>
<script src="/static/js/stats/stats.js?v=1"></script>
<script src="/static/js/network/ajax.js?v=3"></script>
<script src="/static/js/network/socket.js?v=4"></script>
<script src="/static/js/network/rtcp.js?v=3"></script>
<script src="/static/js/controller.js?v=6"></script>
<script src="/static/js/input/keyboard.js?v=5"></script>
<script src="/static/js/input/touch.js?v=3"></script>
<script src="/static/js/input/joystick.js?v=3"></script>
<script src="/static/js/init.js?v=5"></script>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-145078282-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'UA-145078282-1');
</script>
</body>
</html>