mirror of
https://github.com/giongto35/cloud-game.git
synced 2026-01-23 18:46:11 +00:00
Compare commits
415 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6efc2319 | ||
|
|
1d5bae0c62 | ||
|
|
368bae8c07 | ||
|
|
58a19affcb | ||
|
|
8754a5edfa | ||
|
|
aeb41008c9 | ||
|
|
059e19d790 | ||
|
|
baa9bad6f8 | ||
|
|
94e13cb93b | ||
|
|
c800dd4bf9 | ||
|
|
7c91d200e4 | ||
|
|
d45daeab7a | ||
|
|
b3ccea5f0e | ||
|
|
3178086dd7 | ||
|
|
1e4e5b3c65 | ||
|
|
7c8e74716d | ||
|
|
46a5799079 | ||
|
|
9feb788108 | ||
|
|
e2f3e005ef | ||
|
|
9d54ea4c49 | ||
|
|
671e875f12 | ||
|
|
f708fce112 | ||
|
|
460c466053 | ||
|
|
84ad0a4cac | ||
|
|
129690e901 | ||
|
|
9191861cab | ||
|
|
c05e42f597 | ||
|
|
09a0c9c3f2 | ||
|
|
859d0c8f1a | ||
|
|
baaeaf43b1 | ||
|
|
76b376aef7 | ||
|
|
3df6a24a0a | ||
|
|
efa7a1d7b5 | ||
|
|
5c6406c1e7 | ||
|
|
3392251dda | ||
|
|
bbad4539b1 | ||
|
|
6b0d7c0ce1 | ||
|
|
e03fbadcaa | ||
|
|
42b003db62 | ||
|
|
d8eed66a1d | ||
|
|
8083ba086b | ||
|
|
02210f1f8d | ||
|
|
817a19c757 | ||
|
|
36da07f277 | ||
|
|
83056bbf4f | ||
|
|
37a4a80996 | ||
|
|
9d4256306e | ||
|
|
ddfc9249ec | ||
|
|
a431b7050f | ||
|
|
debd4b23df | ||
|
|
410610349b | ||
|
|
3ac7a559df | ||
|
|
7c878b1ee3 | ||
|
|
a1506d0f31 | ||
|
|
15ff2f3282 | ||
|
|
ffb0abe4da | ||
|
|
3dbf4f9b19 | ||
|
|
b02cd5c4f0 | ||
|
|
0c768bb3d6 | ||
|
|
f78bcf3e4b | ||
|
|
535e725618 | ||
|
|
4aaeda3fbb | ||
|
|
600243c87d | ||
|
|
82aebf6647 | ||
|
|
89ae98b035 | ||
|
|
ed3b195b26 | ||
|
|
f54089e072 | ||
|
|
6bb82b2204 | ||
|
|
d77d69a331 | ||
|
|
297ec9005c | ||
|
|
5649d4410a | ||
|
|
db32479c4e | ||
|
|
8fa53f4e32 | ||
|
|
a7acebc5d0 | ||
|
|
954bb23bb8 | ||
|
|
7134782245 | ||
|
|
5a42dc9857 | ||
|
|
9caf45af78 | ||
|
|
56e3ce328e | ||
|
|
6de1828ffe | ||
|
|
b2e275a6cd | ||
|
|
45dba68b15 | ||
|
|
31c670252c | ||
|
|
1831e44eef | ||
|
|
71f5de3bf9 | ||
|
|
88a0911f93 | ||
|
|
8686c4a6e5 | ||
|
|
68acb5d790 | ||
|
|
2c50ae2290 | ||
|
|
f09500f289 | ||
|
|
1147aeda14 | ||
|
|
7b57f73b26 | ||
|
|
45cc9e8245 | ||
|
|
795771e3d6 | ||
|
|
2ef1a93eaf | ||
|
|
6ccbea8bd9 | ||
|
|
003eb5b995 | ||
|
|
0ab6f58d36 | ||
|
|
763f1e5d11 | ||
|
|
16cf91f669 | ||
|
|
0d8db25c3c | ||
|
|
2084d0958b | ||
|
|
a67a077024 | ||
|
|
f1ece58c7b | ||
|
|
fd34d5a972 | ||
|
|
bdf3598367 | ||
|
|
b9d35fa626 | ||
|
|
7da993a4c7 | ||
|
|
ddb16f899f | ||
|
|
dea9926e4f | ||
|
|
61eb55f736 | ||
|
|
e2521eea94 | ||
|
|
8f859cd600 | ||
|
|
0232384fe2 | ||
|
|
7873631613 | ||
|
|
466257d3be | ||
|
|
1ff7be38eb | ||
|
|
c87b5cec65 | ||
|
|
80afc18892 | ||
|
|
34a947ac6d | ||
|
|
d855e56a2f | ||
|
|
7ee98c1b03 | ||
|
|
af8569a605 | ||
|
|
d6199c9598 | ||
|
|
ba7db72093 | ||
|
|
83b040b39f | ||
|
|
d8a3e82f1e | ||
|
|
c40f9c9127 | ||
|
|
7e612458a0 | ||
|
|
72b791cc5e | ||
|
|
e46b739311 | ||
|
|
daf6a20e1d | ||
|
|
ba45936d77 | ||
|
|
ca64bd127e | ||
|
|
b93eb4911c | ||
|
|
20e9449bb1 | ||
|
|
c2e9d67bcb | ||
|
|
3989a735ac | ||
|
|
ede15c4fe5 | ||
|
|
99976dd560 | ||
|
|
0500550fc0 | ||
|
|
c5c2578d0f | ||
|
|
b843538fea | ||
|
|
9b56ffc87c | ||
|
|
a4f0dbbca8 | ||
|
|
b530f7a6cf | ||
|
|
421e9115cc | ||
|
|
b812887f6e | ||
|
|
b3f677d32f | ||
|
|
b755bcd1bf | ||
|
|
8d79680b81 | ||
|
|
a013192bf8 | ||
|
|
dceb6f9993 | ||
|
|
d922e58278 | ||
|
|
8caad44ade | ||
|
|
22d1bd7620 | ||
|
|
effa5c46c5 | ||
|
|
cebbcdf256 | ||
|
|
f557d16997 | ||
|
|
3e0fcfbfcf | ||
|
|
7377b4f15b | ||
|
|
ecbe7f6ad9 | ||
|
|
084c14175e | ||
|
|
5da77a6b4f | ||
|
|
84f55691eb | ||
|
|
4d5033f03c | ||
|
|
ff6c344a15 | ||
|
|
104498dec0 | ||
|
|
8654604b9b | ||
|
|
2bc64a3be8 | ||
|
|
2aaf37b766 | ||
|
|
47bd72e1cd | ||
|
|
29eedee3ec | ||
|
|
a349fdd0cf | ||
|
|
cf5248ec54 | ||
|
|
4fc53e7220 | ||
|
|
f8392ab0be | ||
|
|
43d3f84993 | ||
|
|
72e846894e | ||
|
|
84d2261391 | ||
|
|
608da9f64b | ||
|
|
91ace06f8b | ||
|
|
cdbb5e98f5 | ||
|
|
92e59672f9 | ||
|
|
3568b7a12a | ||
|
|
4195b7f2dc | ||
|
|
17fe1a938a | ||
|
|
c699455b58 | ||
|
|
000bc4f661 | ||
|
|
9308e1b388 | ||
|
|
1452317d45 | ||
|
|
41bfe4f4d3 | ||
|
|
b79b4c405a | ||
|
|
e7e281083f | ||
|
|
3459c7e8d6 | ||
|
|
6258f9a5e4 | ||
|
|
61b4108dce | ||
|
|
ce7aa1be62 | ||
|
|
e2226e7492 | ||
|
|
b903700077 | ||
|
|
46067dec8f | ||
|
|
11295a28f6 | ||
|
|
4e241d0448 | ||
|
|
a6e56a208c | ||
|
|
ccb0f410ab | ||
|
|
1a44b94c85 | ||
|
|
53e55728db | ||
|
|
53a3624aef | ||
|
|
d6ceaad220 | ||
|
|
e67b98d6fe | ||
|
|
a3f07057f4 | ||
|
|
610e087bcd | ||
|
|
fca46f1a32 | ||
|
|
f7d12e65e5 | ||
|
|
27c9ce681b | ||
|
|
1993950cd7 | ||
|
|
f475dbabb7 | ||
|
|
b7b530fe60 | ||
|
|
4fbfa1d4e3 | ||
|
|
a77069a634 | ||
|
|
aa10008d1b | ||
|
|
e6e537d799 | ||
|
|
2e91feb861 | ||
|
|
d805ba8eb8 | ||
|
|
ad07ad2014 | ||
|
|
5d65ff14d5 | ||
|
|
99ceb5d72c | ||
|
|
7f2f1d70b1 | ||
|
|
07f40351fa | ||
|
|
6525106116 | ||
|
|
3e116fcc52 | ||
|
|
cb968d782a | ||
|
|
10507d9c53 | ||
|
|
10c4cd9b7f | ||
|
|
38dc69e4a2 | ||
|
|
377306dc80 | ||
|
|
da7059dc79 | ||
|
|
9ec6541322 | ||
|
|
e80e31da42 | ||
|
|
a69a934029 | ||
|
|
e4aab1019c | ||
|
|
fb5d8c216b | ||
|
|
7977bce8a3 | ||
|
|
a8d47fd1bf | ||
|
|
1b82c48dc1 | ||
|
|
f7c2524098 | ||
|
|
61c70d3289 | ||
|
|
494ac0ed3b | ||
|
|
9c768277c7 | ||
|
|
e8cec39476 | ||
|
|
afb76aa970 | ||
|
|
d698660c19 | ||
|
|
f8fb128e97 | ||
|
|
f11cad157b | ||
|
|
b1b33713d6 | ||
|
|
072b674fb1 | ||
|
|
989d3b1c85 | ||
|
|
92aea18a8c | ||
|
|
f875d3fc42 | ||
|
|
cddf081b8f | ||
|
|
c1c4731640 | ||
|
|
a901c84d99 | ||
|
|
226bb0384e | ||
|
|
8703309090 | ||
|
|
da51639625 | ||
|
|
e83614068f | ||
|
|
56d8c4a928 | ||
|
|
85cef0dfec | ||
|
|
8dd9e9c9be | ||
|
|
196930281b | ||
|
|
878d7fe298 | ||
|
|
cccb3dce84 | ||
|
|
992b6e06da | ||
|
|
d7e7112e25 | ||
|
|
594b02d37e | ||
|
|
3b06905e15 | ||
|
|
f2d21c67dc | ||
|
|
ae3f91dfc6 | ||
|
|
882aae9daf | ||
|
|
c27c88c0fa | ||
|
|
240a1f92ce | ||
|
|
8e92f6822e | ||
|
|
b2e4848ed3 | ||
|
|
ddc841e8c6 | ||
|
|
49cb752b5c | ||
|
|
7fe3a893f6 | ||
|
|
7c0a2051d4 | ||
|
|
b7fb079243 | ||
|
|
f9a5ce0e68 | ||
|
|
d278ab6e3d | ||
|
|
4a627a30f2 | ||
|
|
75bb90c90d | ||
|
|
a0e549a6b9 | ||
|
|
d7e8ca5ace | ||
|
|
be83b1c287 | ||
|
|
8fef57aa8d | ||
|
|
cc414881ea | ||
|
|
65b6e3208f | ||
|
|
d5bb271469 | ||
|
|
c063dd92c6 | ||
|
|
42b82a368c | ||
|
|
6106eee97e | ||
|
|
2e1c837643 | ||
|
|
860c8b9d45 | ||
|
|
1f7b5139c6 | ||
|
|
e5ac43a59e | ||
|
|
a6bcf5cd94 | ||
|
|
7668ef7bd8 | ||
|
|
2ed6e8724f | ||
|
|
a4f47396e5 | ||
|
|
d237a5b6ea | ||
|
|
5b4f74e2b7 | ||
|
|
624eecd4e8 | ||
|
|
851d9a6fc1 | ||
|
|
167071af6f | ||
|
|
75e41e4fd0 | ||
|
|
0aeaa5580c | ||
|
|
70d9ff32b7 | ||
|
|
d985440930 | ||
|
|
39c63ec44a | ||
|
|
1dc0cabc2b | ||
|
|
b227260060 | ||
|
|
9231120a55 | ||
|
|
63e3a7f6bd | ||
|
|
3815e18027 | ||
|
|
73639eeb64 | ||
|
|
3175747ee5 | ||
|
|
2add701c39 | ||
|
|
f7d8a5dc50 | ||
|
|
ed21e32b1f | ||
|
|
bee6192894 | ||
|
|
e50dc102bb | ||
|
|
30584b3faa | ||
|
|
dc48c93804 | ||
|
|
0c532a7f93 | ||
|
|
c4895fae84 | ||
|
|
e17a358ade | ||
|
|
bb9f904104 | ||
|
|
40bd57c9a0 | ||
|
|
ea3263ca6c | ||
|
|
7455ad3f47 | ||
|
|
317079ddd3 | ||
|
|
d9def73096 | ||
|
|
e485cc8862 | ||
|
|
b6d6e464ed | ||
|
|
494979cf38 | ||
|
|
229cd4044c | ||
|
|
62fc68e88b | ||
|
|
ece1efad16 | ||
|
|
9bb0c151a9 | ||
|
|
3bd32715cb | ||
|
|
e10490adda | ||
|
|
8893e1e5bf | ||
|
|
c13099c56a | ||
|
|
eda90d74e6 | ||
|
|
ee58af488d | ||
|
|
c8eb672ecd | ||
|
|
b65e8dc3f3 | ||
|
|
137d1b63d8 | ||
|
|
31638b0805 | ||
|
|
df980c5cf4 | ||
|
|
f00c27a226 | ||
|
|
853c7a6f81 | ||
|
|
fbdb517d24 | ||
|
|
b48a6c92f6 | ||
|
|
5034f86667 | ||
|
|
d830821ceb | ||
|
|
137397dbc1 | ||
|
|
66dbdba863 | ||
|
|
33099d098f | ||
|
|
43cb05e088 | ||
|
|
a1b24b0a85 | ||
|
|
7c2b88716b | ||
|
|
9325fddc1f | ||
|
|
13e49a8c3f | ||
|
|
5920af5a36 | ||
|
|
256896df52 | ||
|
|
f6e86a465f | ||
|
|
7b0d212fc0 | ||
|
|
6539ddd09c | ||
|
|
e3ecd8fef0 | ||
|
|
b0b7966b47 | ||
|
|
ec5bcbc9c7 | ||
|
|
686f5e7e87 | ||
|
|
cfd5b1ae8d | ||
|
|
7667948bf5 | ||
|
|
b5cb33812b | ||
|
|
9178dab7dd | ||
|
|
583e4659ad | ||
|
|
c797365641 | ||
|
|
99687a1c28 | ||
|
|
0f66f35673 | ||
|
|
40c8867e44 | ||
|
|
d7f61c4b30 | ||
|
|
c6d35cba5e | ||
|
|
361c3a67ee | ||
|
|
83b40e10e5 | ||
|
|
3c2c24038d | ||
|
|
4a686e9fce | ||
|
|
9c65d28933 | ||
|
|
b24e82c145 | ||
|
|
f30e017101 | ||
|
|
b6366e7b88 | ||
|
|
3bb54fdad4 | ||
|
|
c8f5bdbcdb | ||
|
|
cd056ee976 | ||
|
|
faf347a44a | ||
|
|
c237ee1a47 | ||
|
|
04d9817b94 | ||
|
|
207d514e8a | ||
|
|
d27d819821 | ||
|
|
beaf862dec | ||
|
|
8b4b238cf9 | ||
|
|
3076be593e | ||
|
|
21a2680027 |
285 changed files with 24141 additions and 16930 deletions
|
|
@ -1,18 +1,9 @@
|
|||
.git/
|
||||
.github/
|
||||
.idea/
|
||||
.vscode/
|
||||
.gitignore
|
||||
|
||||
.editorconfig
|
||||
.env
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
|
||||
LICENSE
|
||||
README.md
|
||||
bin/
|
||||
docs/
|
||||
release/
|
||||
|
||||
assets/games/
|
||||
/**
|
||||
!cmd/
|
||||
!pkg/
|
||||
!scripts/
|
||||
!web/
|
||||
!go.mod
|
||||
!go.sum
|
||||
!LICENSE
|
||||
!Makefile
|
||||
|
|
|
|||
120
.github/workflows/build.yml
vendored
120
.github/workflows/build.yml
vendored
|
|
@ -1,5 +1,5 @@
|
|||
# ------------------------------------------------------------
|
||||
# Build workflow (Linux x64, macOS x64, Windows x64)
|
||||
# Build and test workflow (Linux x64, macOS x64, Windows x64)
|
||||
# ------------------------------------------------------------
|
||||
|
||||
name: build
|
||||
|
|
@ -16,101 +16,73 @@ on:
|
|||
jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
step: [ build, check ]
|
||||
os: [ ubuntu-latest, windows-latest ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ^1.20
|
||||
go-version: 'stable'
|
||||
|
||||
- name: Get Linux dev libraries and tools
|
||||
- name: Linux
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
MESA_GL_VERSION_OVERRIDE: 3.3COMPAT
|
||||
run: |
|
||||
sudo apt-get -qq update
|
||||
sudo apt-get -qq install -y make pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libgl1-mesa-glx
|
||||
sudo apt-get -qq install -y \
|
||||
make pkg-config \
|
||||
libvpx-dev libx264-dev libopus-dev libyuv-dev libjpeg-turbo8-dev \
|
||||
libsdl2-dev libgl1 libglx-mesa0 libspeexdsp-dev
|
||||
|
||||
make build
|
||||
xvfb-run --auto-servernum make test verify-cores
|
||||
|
||||
- name: Get MacOS dev libraries and tools
|
||||
if: matrix.os == 'macos-latest'
|
||||
- name: macOS
|
||||
if: matrix.os == 'macos-12'
|
||||
run: |
|
||||
brew install pkg-config libvpx x264 opus sdl2
|
||||
brew install libvpx x264 sdl2 speexdsp
|
||||
make build test verify-cores
|
||||
|
||||
- name: Get Windows dev libraries and tools
|
||||
- uses: msys2/setup-msys2@v2
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
msystem: ucrt64
|
||||
path-type: inherit
|
||||
release: false
|
||||
install: >
|
||||
mingw-w64-x86_64-gcc
|
||||
mingw-w64-x86_64-pkgconf
|
||||
mingw-w64-x86_64-dlfcn
|
||||
mingw-w64-x86_64-libvpx
|
||||
mingw-w64-x86_64-opus
|
||||
mingw-w64-x86_64-x264-git
|
||||
mingw-w64-x86_64-SDL2
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-dlfcn
|
||||
mingw-w64-ucrt-x86_64-libvpx
|
||||
mingw-w64-ucrt-x86_64-opus
|
||||
mingw-w64-ucrt-x86_64-libx264
|
||||
mingw-w64-ucrt-x86_64-SDL2
|
||||
mingw-w64-ucrt-x86_64-libyuv
|
||||
mingw-w64-ucrt-x86_64-libjpeg-turbo
|
||||
mingw-w64-ucrt-x86_64-speexdsp
|
||||
|
||||
- name: Get Windows OpenGL drivers
|
||||
if: matrix.step == 'check' && matrix.os == 'windows-latest'
|
||||
- name: Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
MESA_VERSION: '24.0.7'
|
||||
MESA_GL_VERSION_OVERRIDE: 3.3COMPAT
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
wget -q https://github.com/pal1000/mesa-dist-win/releases/download/20.2.1/mesa3d-20.2.1-release-mingw.7z
|
||||
"/c/Program Files/7-Zip/7z.exe" x mesa3d-20.2.1-release-mingw.7z -omesa
|
||||
echo -e " 2\r\n 8\r\n " >> commands
|
||||
set MSYSTEM=UCRT64
|
||||
|
||||
wget -q https://github.com/pal1000/mesa-dist-win/releases/download/$MESA_VERSION/mesa3d-$MESA_VERSION-release-msvc.7z
|
||||
"/c/Program Files/7-Zip/7z.exe" x mesa3d-$MESA_VERSION-release-msvc.7z -omesa
|
||||
echo -e " 1\r\n 9\r\n " >> commands
|
||||
./mesa/systemwidedeploy.cmd < ./commands
|
||||
|
||||
make build test verify-cores
|
||||
|
||||
- name: Build Windows app
|
||||
if: matrix.step == 'build' && matrix.os == 'windows-latest'
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
make build
|
||||
|
||||
- name: Build Linux app
|
||||
if: matrix.step == 'build' && matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
make build
|
||||
|
||||
- name: Build macOS app
|
||||
if: matrix.step == 'build' && matrix.os == 'macos-latest'
|
||||
run: |
|
||||
make build
|
||||
|
||||
- name: Verify core rendering (windows-latest)
|
||||
if: matrix.step == 'check' && matrix.os == 'windows-latest' && always()
|
||||
shell: msys2 {0}
|
||||
env:
|
||||
MESA_GL_VERSION_OVERRIDE: 3.3COMPAT
|
||||
run: |
|
||||
GL_CTX=-autoGlContext make verify-cores
|
||||
|
||||
- name: Verify core rendering (ubuntu-latest)
|
||||
if: matrix.step == 'check' && matrix.os == 'ubuntu-latest' && always()
|
||||
env:
|
||||
MESA_GL_VERSION_OVERRIDE: 3.3COMPAT
|
||||
run: |
|
||||
GL_CTX=-autoGlContext xvfb-run --auto-servernum make verify-cores
|
||||
|
||||
- name: Verify core rendering (macos-latest)
|
||||
if: matrix.step == 'check' && matrix.os == 'macos-latest' && always()
|
||||
run: |
|
||||
make verify-cores
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: matrix.step == 'check' && always()
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: emulator-test-frames
|
||||
name: emulator-test-frames-${{ matrix.os }}
|
||||
path: _rendered/*.png
|
||||
|
||||
build_docker:
|
||||
name: Build (docker)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: docker build --build-arg VERSION=$(./scripts/version.sh) .
|
||||
|
|
|
|||
40
.github/workflows/cd/cloudretro.io/config.yaml
vendored
Normal file
40
.github/workflows/cd/cloudretro.io/config.yaml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
version: 4
|
||||
|
||||
coordinator:
|
||||
debug: true
|
||||
server:
|
||||
address:
|
||||
frameOptions: SAMEORIGIN
|
||||
https: true
|
||||
tls:
|
||||
domain: cloudretro.io
|
||||
analytics:
|
||||
inject: true
|
||||
gtag: UA-145078282-1
|
||||
|
||||
worker:
|
||||
debug: true
|
||||
network:
|
||||
coordinatorAddress: cloudretro.io
|
||||
publicAddress: cloudretro.io
|
||||
secure: true
|
||||
server:
|
||||
https: true
|
||||
tls:
|
||||
address: :444
|
||||
# domain: cloudretro.io
|
||||
|
||||
emulator:
|
||||
libretro:
|
||||
logLevel: 1
|
||||
cores:
|
||||
list:
|
||||
dos:
|
||||
uniqueSaveDir: true
|
||||
mame:
|
||||
options:
|
||||
"fbneo-diagnostic-input": "Hold Start"
|
||||
nes:
|
||||
scale: 2
|
||||
snes:
|
||||
scale: 2
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
CLOUD_GAME_COORDINATOR_ANALYTICS_GTAG=UA-145078282-1
|
||||
CLOUD_GAME_COORDINATOR_ANALYTICS_INJECT=true
|
||||
CLOUD_GAME_COORDINATOR_SERVER_ADDRESS=
|
||||
CLOUD_GAME_COORDINATOR_SERVER_HTTPS=true
|
||||
CLOUD_GAME_COORDINATOR_SERVER_TLS_DOMAIN=cloudretro.io
|
||||
10
.github/workflows/cd/cloudretro.io/script.env
vendored
10
.github/workflows/cd/cloudretro.io/script.env
vendored
|
|
@ -1,6 +1,6 @@
|
|||
COORDINATORS="167.172.70.98 cloudretro.io"
|
||||
DOCKER_IMAGE_TAG=dev
|
||||
DO_ADDRESS_LIST="cloud-gaming cloud-gaming-eu cloud-gaming-usw"
|
||||
SPLIT_HOSTS=1
|
||||
COORDINATORS="138.68.48.200"
|
||||
DOCKER_IMAGE_TAG=master
|
||||
#DO_ADDRESS_LIST="cloud-gaming cloud-gaming-eu cloud-gaming-usw"
|
||||
#SPLIT_HOSTS=1
|
||||
USER=root
|
||||
WORKERS=${WORKERS:-5}
|
||||
WORKERS=${WORKERS:-4}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
CLOUD_GAME_STORAGE_KEY=https://objectstorage.eu-frankfurt-1.oraclecloud.com/p/VVlJPTNcP28wlnBXtA0ezjD5fTut2T01qz5TVwdNejQc6OR1DF5VYYjKTTP2HIlL/n/frmb1qagq0wf/b/cloud-retro-st-001/o/
|
||||
CLOUD_GAME_STORAGE_PROVIDER=oracle
|
||||
CLOUD_GAME_WORKER_NETWORK_COORDINATORADDRESS=cloudretro.io
|
||||
CLOUD_GAME_WORKER_NETWORK_PUBLICADDRESS=cloudretro.io
|
||||
CLOUD_GAME_WORKER_NETWORK_SECURE=true
|
||||
CLOUD_GAME_WORKER_SERVER_ADDRESS=:80
|
||||
CLOUD_GAME_WORKER_SERVER_HTTPS=true
|
||||
CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:443
|
||||
CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io
|
||||
40
.github/workflows/cd/deploy-app.sh
vendored
40
.github/workflows/cd/deploy-app.sh
vendored
|
|
@ -54,6 +54,10 @@ IP_LIST=${IP_LIST:-}
|
|||
# a list of machines mark some addresses to deploy only a coordinator there
|
||||
COORDINATORS=${COORDINATORS:-}
|
||||
|
||||
if [ -z "$SPLIT_HOSTS" ]; then
|
||||
IP_LIST+=$COORDINATORS
|
||||
fi
|
||||
|
||||
# Digital Ocean operations
|
||||
#DO_TOKEN
|
||||
DO_ADDRESS_LIST=${DO_ADDRESS_LIST:-}
|
||||
|
|
@ -64,7 +68,7 @@ REMOTE_WORK_DIR=${REMOTE_WORK_DIR:-"/cloud-game"}
|
|||
DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG:-latest}
|
||||
echo "Docker tag:$DOCKER_IMAGE_TAG"
|
||||
# the total number of worker replicas to deploy
|
||||
WORKERS=${WORKERS:-5}
|
||||
WORKERS=${WORKERS:-4}
|
||||
USER=${USER:-root}
|
||||
|
||||
compose_src=$(cat $LOCAL_WORK_DIR/docker-compose.yml)
|
||||
|
|
@ -124,7 +128,7 @@ echo "IPs:" $IP_LIST
|
|||
|
||||
# Run command builder
|
||||
#
|
||||
# By default it will run docker-compose with both coordinator and worker apps.
|
||||
# By default it will run docker compose with both coordinator and worker apps.
|
||||
# With the SPLIT_HOSTS parameter specified, it will run either coordinator app
|
||||
# if the current server address is found in the IP_LIST variable, otherwise it
|
||||
# will run just the worker app.
|
||||
|
|
@ -142,28 +146,39 @@ for ip in $IP_LIST; do
|
|||
fi
|
||||
|
||||
# build run command
|
||||
cmd="ZONE=\$zone docker-compose up -d --remove-orphans --scale worker=\${workers:-$WORKERS}"
|
||||
cmd="ZONE=\$zone docker compose up -d --remove-orphans"
|
||||
if [ ! -z "$SPLIT_HOSTS" ]; then
|
||||
cmd+=" worker"
|
||||
deploy_coordinator=0
|
||||
deploy_worker=1
|
||||
else
|
||||
cmd+=" worker"
|
||||
fi
|
||||
|
||||
# override run command
|
||||
if [ ! -z "$SPLIT_HOSTS" ]; then
|
||||
for addr in $COORDINATORS; do
|
||||
if [ "$ip" == $addr ]; then
|
||||
cmd="docker-compose up -d --remove-orphans coordinator"
|
||||
cmd="docker compose up -d --remove-orphans coordinator"
|
||||
deploy_coordinator=1
|
||||
deploy_worker=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
else
|
||||
cmd+=" coordinator"
|
||||
fi
|
||||
|
||||
# build Docker container env file
|
||||
run_env=""
|
||||
custom_config=""
|
||||
if [[ ! -z "${ENV_DIR}" ]]; then
|
||||
env_f=$ENV_DIR/config.yaml
|
||||
if [[ -e "$env_f" ]]; then
|
||||
echo "config.yaml found"
|
||||
custom_config=$(cat $env_f)
|
||||
fi
|
||||
|
||||
if [ $deploy_coordinator == 1 ]; then
|
||||
env_f=$ENV_DIR/coordinator.env
|
||||
if [[ -e "$env_f" ]]; then
|
||||
|
|
@ -190,13 +205,13 @@ for ip in $IP_LIST; do
|
|||
run="#!/bin/bash"$'\n'
|
||||
run+=$(remote_run_commands "$ENV_DIR")$'\n'
|
||||
run+=$(remote_run_commands "$PROVIDER_DIR")$'\n'
|
||||
run+="IMAGE_TAG=$DOCKER_IMAGE_TAG APP_DIR=$REMOTE_WORK_DIR $cmd"
|
||||
run+="IMAGE_TAG=$DOCKER_IMAGE_TAG APP_DIR=$REMOTE_WORK_DIR WORKER_REPLICAS=$WORKERS $cmd"
|
||||
|
||||
echo ""
|
||||
echo "run.sh:"$'\n'"$run"
|
||||
echo ""
|
||||
|
||||
# !to add docker-compose install / warning
|
||||
# !to add docker compose install / warning
|
||||
|
||||
# custom scripts
|
||||
remote_sudo_run_once $ip "$PROVIDER_DIR" "$ssh_i"
|
||||
|
|
@ -205,14 +220,13 @@ for ip in $IP_LIST; do
|
|||
echo "Update the remote host"
|
||||
|
||||
ssh -o ConnectTimeout=10 $USER@$ip ${ssh_i:-} "\
|
||||
docker-compose -v; \
|
||||
docker compose version; \
|
||||
mkdir -p $REMOTE_WORK_DIR; \
|
||||
cd $REMOTE_WORK_DIR; \
|
||||
mkdir -p $REMOTE_WORK_DIR/home; \
|
||||
echo \"$custom_config\" > $REMOTE_WORK_DIR/home/config.yaml; \
|
||||
echo '$compose_src' > ./docker-compose.yml; \
|
||||
echo '$run_env' > ./run.env; \
|
||||
docker image prune -f -a; \
|
||||
IMAGE_TAG=$DOCKER_IMAGE_TAG docker-compose pull; \
|
||||
echo '$run' > ./run.sh; \
|
||||
chmod +x ./run.sh; \
|
||||
./run.sh"
|
||||
docker compose down; \
|
||||
IMAGE_TAG=$DOCKER_IMAGE_TAG docker compose pull; \
|
||||
docker compose up -d;"
|
||||
done
|
||||
|
|
|
|||
101
.github/workflows/cd/docker-compose.yml
vendored
101
.github/workflows/cd/docker-compose.yml
vendored
|
|
@ -1,36 +1,93 @@
|
|||
version: "3.4"
|
||||
|
||||
x-params:
|
||||
&default-params
|
||||
env_file: run.env
|
||||
image: ghcr.io/giongto35/cloud-game/cloud-game:${IMAGE_TAG:-latest}
|
||||
x-params: &default-params
|
||||
image: ghcr.io/giongto35/cloud-game/cloud-game:${IMAGE_TAG:-master}
|
||||
network_mode: "host"
|
||||
privileged: true
|
||||
restart: always
|
||||
security_opt:
|
||||
- seccomp=unconfined
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "64m"
|
||||
max-file: "5"
|
||||
compress: "true"
|
||||
driver: "journald"
|
||||
x-worker: &worker
|
||||
depends_on:
|
||||
- coordinator
|
||||
command: ./worker
|
||||
volumes:
|
||||
- ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache
|
||||
- ${APP_DIR:-/cloud-game}/cores:/usr/local/share/cloud-game/assets/cores
|
||||
- ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games
|
||||
- ${APP_DIR:-/cloud-game}/libretro:/usr/local/share/cloud-game/libretro
|
||||
- ${APP_DIR:-/cloud-game}/home:/root/.cr
|
||||
- x11:/tmp/.X11-unix
|
||||
healthcheck:
|
||||
test: curl -f https://cloudretro.io/echo || exit 1
|
||||
interval: 1m
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_interval: 5s
|
||||
|
||||
services:
|
||||
|
||||
coordinator:
|
||||
<<: *default-params
|
||||
command: coordinator
|
||||
volumes:
|
||||
- ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache
|
||||
- ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games
|
||||
|
||||
worker:
|
||||
<<: *default-params
|
||||
command: ./coordinator
|
||||
environment:
|
||||
- MESA_GL_VERSION_OVERRIDE=3.3
|
||||
entrypoint: [ "/bin/sh", "-c", "xvfb-run -a $$@", "" ]
|
||||
command: worker --zone=${ZONE:-}
|
||||
- CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
|
||||
volumes:
|
||||
- ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache
|
||||
- ${APP_DIR:-/cloud-game}/cores:/usr/local/share/cloud-game/assets/cores
|
||||
- ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games
|
||||
- ${APP_DIR:-/cloud-game}/home:/root/.cr
|
||||
|
||||
worker01:
|
||||
<<: [ *default-params, *worker ]
|
||||
environment:
|
||||
- DISPLAY=:99
|
||||
- MESA_GL_VERSION_OVERRIDE=4.5
|
||||
- CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
|
||||
- CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:444
|
||||
healthcheck:
|
||||
test: curl -f https://cloudretro.io:444/echo || exit 1
|
||||
worker02:
|
||||
<<: [ *default-params, *worker ]
|
||||
environment:
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:445
|
||||
- DISPLAY=:99
|
||||
- MESA_GL_VERSION_OVERRIDE=4.5
|
||||
- CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
|
||||
- CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io
|
||||
healthcheck:
|
||||
test: curl -f https://cloudretro.io:445/echo || exit 1
|
||||
worker03:
|
||||
<<: [ *default-params, *worker ]
|
||||
environment:
|
||||
- DISPLAY=:99
|
||||
- MESA_GL_VERSION_OVERRIDE=4.5
|
||||
- CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
|
||||
- CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:446
|
||||
healthcheck:
|
||||
test: curl -f https://cloudretro.io:446/echo || exit 1
|
||||
worker04:
|
||||
<<: [ *default-params, *worker ]
|
||||
environment:
|
||||
- DISPLAY=:99
|
||||
- MESA_GL_VERSION_OVERRIDE=4.5
|
||||
- CLOUD_GAME_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
|
||||
- CLOUD_GAME_EMULATOR_LIBRETRO_CORES_PATHS_LIBS=/usr/local/share/cloud-game/assets/cores
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_DOMAIN=cloudretro.io
|
||||
- CLOUD_GAME_WORKER_SERVER_TLS_ADDRESS=:447
|
||||
healthcheck:
|
||||
test: curl -f https://cloudretro.io:447/echo || exit 1
|
||||
|
||||
xvfb:
|
||||
image: kcollins/xvfb:latest
|
||||
volumes:
|
||||
- x11:/tmp/.X11-unix
|
||||
command: [ ":99", "-screen", "0", "320x240x16" ]
|
||||
|
||||
volumes:
|
||||
x11:
|
||||
|
|
|
|||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
known_hosts: 'PLACEHOLDER'
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy to all servers
|
||||
env:
|
||||
|
|
|
|||
12
.github/workflows/docker_build.yml
vendored
Normal file
12
.github/workflows/docker_build.yml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name: docker_build
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: DOCKER_BUILDKIT=1 docker build --build-arg VERSION=$(./scripts/version.sh) .
|
||||
48
.github/workflows/docker_publish.yml
vendored
Normal file
48
.github/workflows/docker_publish.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# ----------------------------------------------------------------------------------
|
||||
# Publish Docker image from the current master branch or v* into Github repository
|
||||
# ----------------------------------------------------------------------------------
|
||||
|
||||
name: docker-publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
docker-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: echo "V=$(./scripts/version.sh)" >> $GITHUB_ENV
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/cloud-game
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
- uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v4
|
||||
with:
|
||||
build-args: VERSION=${{ env.V }}
|
||||
context: .
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
31
.github/workflows/docker_publish_stable.yml
vendored
31
.github/workflows/docker_publish_stable.yml
vendored
|
|
@ -1,31 +0,0 @@
|
|||
# ------------------------------------------------------------------------
|
||||
# Publish Docker image from the stable snapshot into Github repository
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
name: publish-stable
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
docker-publish-stable:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
- run: echo "V=$(./scripts/version.sh)" >> $GITHUB_ENV
|
||||
|
||||
- uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
add_git_labels: true
|
||||
tags: latest,${{ env.TAG }}
|
||||
build_args: VERSION=${{ env.V }}
|
||||
|
||||
registry: docker.pkg.github.com
|
||||
repository: ${{ github.REPOSITORY }}/cloud-game
|
||||
29
.github/workflows/docker_publish_unstable.yml
vendored
29
.github/workflows/docker_publish_unstable.yml
vendored
|
|
@ -1,29 +0,0 @@
|
|||
# ----------------------------------------------------------------------------
|
||||
# Publish Docker image from the current master branch into Github repository
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
name: publish-unstable
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
docker-publish-unstable:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- run: echo "V=$(./scripts/version.sh)" >> $GITHUB_ENV
|
||||
|
||||
- uses: docker/build-push-action@v1
|
||||
with:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
add_git_labels: true
|
||||
tags: dev
|
||||
build_args: VERSION=${{ env.V }}
|
||||
|
||||
registry: docker.pkg.github.com
|
||||
repository: ${{ github.REPOSITORY }}/cloud-game
|
||||
4
.github/workflows/release.yml_
vendored
4
.github/workflows/release.yml_
vendored
|
|
@ -37,9 +37,9 @@ jobs:
|
|||
env:
|
||||
release-dir: _release
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v2
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ^1.20
|
||||
|
||||
|
|
|
|||
79
DESIGNv2.md
Normal file
79
DESIGNv2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Cloud Gaming Service Design Document
|
||||
|
||||
Cloud Gaming Service contains multiple workers for gaming streams and a coordinator for distributing traffic and pairing
|
||||
up connections.
|
||||
|
||||
## Coordinator
|
||||
|
||||
Coordinator is a web-frontend, load balancer and signalling server for WebRTC.
|
||||
|
||||
```
|
||||
WORKERS
|
||||
┌──────────────────────────────────┐
|
||||
│ │
|
||||
│ REGION 1 REGION 2 REGION N │
|
||||
│ (US) (DE) (XX) │
|
||||
│ ┌──────┐ ┌──────┐ ┌──────┐ |
|
||||
COORDINATOR │ │WORKER│ │WORKER│ │WORKER│ |
|
||||
┌───────────┐ │ └──────┘ └──────┘ └──────┘ |
|
||||
│ │ ───────────────────────HEALTH────────────────────► │ • • • |
|
||||
│ HTTP/WS │ ◄─────────────────────REG/DEREG─────────────────── │ • • • |
|
||||
│┌─────────┐│ │ • • • |
|
||||
│| |│ USER │ ┌──────┐* ┌──────┐ ┌──────┐ |
|
||||
│└─────────┘│ ┌──────┐ │ │WORKER│ │WORKER│ │WORKER│ |
|
||||
│ │ ◄──(1)CONNECT───────── │ │ ────(3)SELECT────► │ └──────┘ └──────┘ └──────┘ |
|
||||
│ │ ───(2)LIST WORKERS───► │ │ ◄───(4)STREAM───── │ │
|
||||
└───────────┘ └──────┘ │ * MULTIPLAYER │
|
||||
│ ┌──────┐────► ONE GAME │
|
||||
│ ┌───►│WORKER│◄──┐ │
|
||||
│ │ └──────┘ │ │
|
||||
│ │ ▲ ▲ │ │
|
||||
│ ┌┴─┐ │ │ ┌┴─┐ |
|
||||
│ │U1│ ┌─┴┐ ┌┴─┐ │U4│ |
|
||||
│ └──┘ │U2│ │U3│ └──┘ |
|
||||
│ └──┘ └──┘ |
|
||||
│ |
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
- (1) A user opens the main page of the app in the browser, i.e. connects to the coordinator.
|
||||
- (2) The coordinator searches and serves a list of most suitable workers to the user.
|
||||
- (3) The user proceeds with latency check of each worker from the list, then coordinator collects user-to-worker
|
||||
latency data and picks the best candidate.
|
||||
- (4) The coordinator sets up peer-to-peer connection between a worker and the user based on the WebRTC protocol and a
|
||||
game hosted on the worker is streamed to the user.
|
||||
|
||||
## Worker
|
||||
|
||||
Worker is responsible for running and streaming games to users.
|
||||
|
||||
```
|
||||
WORKER
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ EMULATOR WEBRTC │ BROWSER
|
||||
│ ┌─────────────────┐ ENCODER ┌────────┐ │ ┌──────────┐
|
||||
│ │ │ ┌─────────┐ | DMUX | | ───RTP──► | WEBRTC |
|
||||
│ │ AUDIO SAMPLES │ ──PCM──► │ │ ──OPUS──► │ ┌──► │ │ ◄──SCTP── | |
|
||||
│ │ VIDEO FRAMES │ ──RGB──► │ │ ──H264──► │ └──► | | └──────────┘ COORDINATOR
|
||||
│ │ │ └─────────┘ │ │ │ • ┌─────────────┐
|
||||
│ │ │ | MUX | | ───TCP──────── • ───────► | WEBSOCKET |
|
||||
│ │ │ │ ┌── │ │ • └─────────────┘
|
||||
| | | BINARY | ▼ | | BROWSER
|
||||
│ │ INPUT STATE │ ◄───────────────────────────── │ • │ │ ┌──────────┐
|
||||
│ │ │ │ ▲ │ │ ───RTP──► | WEBRTC |
|
||||
│ └─────────────────┘ HTTP/WS | └── | │ ◄──SCTP── │ │
|
||||
│ ┌─────────┐ └────────┘ │ └──────────┘
|
||||
| | | |
|
||||
| └─────────┘ |
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- After coordinator matches the most appropriate server (peer 1) to the user (peer 2), a WebRTC peer-to-peer handshake
|
||||
will be conducted. The coordinator will help initiate the session between the two peers over a WebSocket connection.
|
||||
- The worker either spawns new rooms running game emulators or connects users to existing rooms.
|
||||
- Raw image and audio streams from the emulator are captured and encoded to a WebRTC-supported streaming format. Next,
|
||||
these stream are piped out (dmux) to all users in the room.
|
||||
- On the other hand, input from players is sent to the worker over WebRTC DataChannel. The game logic on the emulator
|
||||
will be updated based on the input stream of all players, for that each stream is multiplexed (mux) into one.
|
||||
- Game states (saves) are stored in cloud storage, so all distributed workers can keep game states in sync and players
|
||||
can continue their games where they left off.
|
||||
132
Dockerfile
132
Dockerfile
|
|
@ -1,61 +1,101 @@
|
|||
# The base cloud-game image
|
||||
ARG BUILD_PATH=/go/src/github.com/giongto35/cloud-game
|
||||
ARG BUILD_PATH=/tmp/cloud-game
|
||||
ARG VERSION=master
|
||||
|
||||
# build image
|
||||
FROM debian:bullseye-slim AS build
|
||||
# base build stage
|
||||
FROM ubuntu:plucky AS build0
|
||||
ARG GO=1.26rc1
|
||||
ARG GO_DIST=go${GO}.linux-amd64.tar.gz
|
||||
|
||||
ADD https://go.dev/dl/$GO_DIST ./
|
||||
RUN tar -C /usr/local -xzf $GO_DIST && \
|
||||
rm $GO_DIST
|
||||
ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
|
||||
RUN apt-get -q update && apt-get -q install --no-install-recommends -y \
|
||||
ca-certificates \
|
||||
make \
|
||||
upx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# next conditional build stage
|
||||
FROM build0 AS build_coordinator
|
||||
ARG BUILD_PATH
|
||||
ARG VERSION
|
||||
ENV GIT_VERSION=${VERSION}
|
||||
|
||||
WORKDIR ${BUILD_PATH}
|
||||
|
||||
# system libs layer
|
||||
RUN apt-get -qq update && apt-get -qq install --no-install-recommends -y \
|
||||
gcc \
|
||||
ca-certificates \
|
||||
# by default we ignore all except some folders and files, see .dockerignore
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build make build.coordinator
|
||||
RUN find ./bin/* | xargs upx --best --lzma
|
||||
|
||||
WORKDIR /usr/local/share/cloud-game
|
||||
RUN mv ${BUILD_PATH}/bin/* ./ && \
|
||||
mv ${BUILD_PATH}/web ./web && \
|
||||
mv ${BUILD_PATH}/LICENSE ./
|
||||
RUN ${BUILD_PATH}/scripts/version.sh ./web/index.html ${VERSION} && \
|
||||
${BUILD_PATH}/scripts/mkdirs.sh
|
||||
|
||||
# next worker build stage
|
||||
FROM build0 AS build_worker
|
||||
ARG BUILD_PATH
|
||||
ARG VERSION
|
||||
ENV GIT_VERSION=${VERSION}
|
||||
|
||||
WORKDIR ${BUILD_PATH}
|
||||
|
||||
# install deps
|
||||
RUN apt-get -q update && apt-get -q install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libopus-dev \
|
||||
libsdl2-dev \
|
||||
libvpx-dev \
|
||||
libyuv-dev \
|
||||
libjpeg-turbo8-dev \
|
||||
libx264-dev \
|
||||
make \
|
||||
libspeexdsp-dev \
|
||||
pkg-config \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# go setup layer
|
||||
ARG GO=go1.20.linux-amd64.tar.gz
|
||||
RUN wget -q https://golang.org/dl/$GO \
|
||||
&& rm -rf /usr/local/go \
|
||||
&& tar -C /usr/local -xzf $GO \
|
||||
&& rm $GO
|
||||
ENV PATH="${PATH}:/usr/local/go/bin"
|
||||
# by default we ignore all except some folders and files, see .dockerignore
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build make GO_TAGS=static,st build.worker
|
||||
RUN find ./bin/* | xargs upx --best --lzma
|
||||
|
||||
# go deps layer
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
WORKDIR /usr/local/share/cloud-game
|
||||
RUN mv ${BUILD_PATH}/bin/* ./ && \
|
||||
mv ${BUILD_PATH}/LICENSE ./
|
||||
RUN ${BUILD_PATH}/scripts/mkdirs.sh worker
|
||||
|
||||
# app build layer
|
||||
COPY pkg ./pkg
|
||||
COPY cmd ./cmd
|
||||
COPY Makefile .
|
||||
COPY scripts/version.sh scripts/version.sh
|
||||
ARG VERSION
|
||||
RUN GIT_VERSION=${VERSION} make build
|
||||
FROM scratch AS coordinator
|
||||
|
||||
COPY --from=build_coordinator /usr/local/share/cloud-game /cloud-game
|
||||
# autocertbot (SSL) requires these on the first run
|
||||
COPY --from=build_coordinator /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
FROM ubuntu:plucky AS worker
|
||||
|
||||
RUN apt-get -q update && apt-get -q install --no-install-recommends -y \
|
||||
curl \
|
||||
libx11-6 \
|
||||
libxext6 \
|
||||
&& apt-get autoremove \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/log/* /usr/share/bug /usr/share/doc /usr/share/doc-base \
|
||||
/usr/share/X11/locale/*
|
||||
|
||||
COPY --from=build_worker /usr/local/share/cloud-game /cloud-game
|
||||
COPY --from=build_worker /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
|
||||
ADD https://github.com/sergystepanov/mesa-llvmpipe/releases/download/v1.0.0/libGL.so.1.5.0 \
|
||||
/usr/lib/x86_64-linux-gnu/
|
||||
RUN cd /usr/lib/x86_64-linux-gnu && \
|
||||
ln -s libGL.so.1.5.0 libGL.so.1 && \
|
||||
ln -s libGL.so.1 libGL.so
|
||||
|
||||
FROM worker AS cloud-game
|
||||
|
||||
# base image
|
||||
FROM debian:bullseye-slim
|
||||
ARG BUILD_PATH
|
||||
WORKDIR /usr/local/share/cloud-game
|
||||
|
||||
COPY scripts/install.sh install.sh
|
||||
RUN bash install.sh && \
|
||||
rm -rf /var/lib/apt/lists/* install.sh
|
||||
|
||||
COPY --from=build ${BUILD_PATH}/bin/ ./
|
||||
RUN cp -s $(pwd)/* /usr/local/bin
|
||||
COPY assets/cores ./assets/cores
|
||||
COPY configs ./configs
|
||||
COPY web ./web
|
||||
ARG VERSION
|
||||
COPY scripts/version.sh version.sh
|
||||
RUN bash ./version.sh ./web/index.html ${VERSION} && \
|
||||
rm -rf version.sh
|
||||
|
||||
EXPOSE 8000 9000
|
||||
COPY --from=coordinator /cloud-game ./
|
||||
COPY --from=worker /cloud-game ./
|
||||
|
|
|
|||
28
Makefile
28
Makefile
|
|
@ -2,8 +2,11 @@ PROJECT = cloud-game
|
|||
REPO_ROOT = github.com/giongto35
|
||||
ROOT = ${REPO_ROOT}/${PROJECT}
|
||||
|
||||
CGO_CFLAGS='-g -O3 -funroll-loops'
|
||||
CGO_CFLAGS='-g -O3'
|
||||
CGO_LDFLAGS='-g -O3'
|
||||
GO_TAGS=
|
||||
|
||||
.PHONY: clean test
|
||||
|
||||
fmt:
|
||||
@goimports -w cmd pkg tests
|
||||
|
|
@ -17,26 +20,39 @@ clean:
|
|||
@rm -rf build
|
||||
@go clean ./cmd/*
|
||||
|
||||
build:
|
||||
|
||||
build.coordinator:
|
||||
mkdir -p bin/
|
||||
go build -ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" -o bin/ ./cmd/coordinator
|
||||
|
||||
build.worker:
|
||||
mkdir -p bin/
|
||||
CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} \
|
||||
go build -buildmode=exe -tags static \
|
||||
go build -pgo=auto -buildmode=exe $(if $(GO_TAGS),-tags $(GO_TAGS),) \
|
||||
-ldflags "-w -s -X 'main.Version=$(GIT_VERSION)'" $(EXT_WFLAGS) \
|
||||
-o bin/ ./cmd/worker
|
||||
|
||||
build: build.coordinator build.worker
|
||||
|
||||
test:
|
||||
go test -v ./pkg/...
|
||||
|
||||
verify-cores:
|
||||
go test -run TestAllEmulatorRooms ./pkg/worker -v -renderFrames $(GL_CTX) -outputPath "../../_rendered"
|
||||
go test -run TestAll ./pkg/worker/room -v -renderFrames $(GL_CTX) -outputPath "./_rendered"
|
||||
|
||||
dev.build: compile build
|
||||
|
||||
dev.build-local:
|
||||
mkdir -p bin/
|
||||
go build -o bin/ ./cmd/coordinator
|
||||
CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} go build -o bin/ ./cmd/worker
|
||||
CGO_CFLAGS=${CGO_CFLAGS} CGO_LDFLAGS=${CGO_LDFLAGS} go build -pgo=auto -o bin/ ./cmd/worker
|
||||
|
||||
dev.run: dev.build-local
|
||||
ifeq ($(OS),Windows_NT)
|
||||
./bin/coordinator.exe & ./bin/worker.exe
|
||||
else
|
||||
./bin/coordinator & ./bin/worker
|
||||
endif
|
||||
|
||||
dev.run.debug:
|
||||
go build -race -o bin/ ./cmd/coordinator
|
||||
|
|
@ -46,7 +62,7 @@ dev.run.debug:
|
|||
|
||||
dev.run-docker:
|
||||
docker rm cloud-game-local -f || true
|
||||
docker-compose up --build
|
||||
docker compose up --build
|
||||
|
||||
# RELEASE
|
||||
# Builds the app for new release.
|
||||
|
|
|
|||
48
README.md
48
README.md
|
|
@ -1,4 +1,5 @@
|
|||
# CloudRetro
|
||||
|
||||
[](https://github.com/giongto35/cloud-game/actions?query=workflow:build)
|
||||
[](https://github.com/giongto35/cloud-game/releases/latest)
|
||||
|
||||
|
|
@ -10,10 +11,10 @@ on generic solution for cloudgaming
|
|||
|
||||
Discord: [Join Us](https://discord.gg/sXRQZa2zeP)
|
||||
|
||||
## Announcement
|
||||
**Due to the current economic recession, i'm unable to keep demo server. Google Stadia also shutdown the Cloud service because of high cost and low adoption. I still believe Cloud Gaming is a brilliant idea and it should keep getting more investment. I open source my works so that everyone can experience self-hosting cloud gaming service to hold this spirit. You can check the rest of idea in the wiki**
|
||||

|
||||
|
||||
## Try it at **[cloudretro.io](https://cloudretro.io)**
|
||||
|
||||
## Try the service at **[cloudretro.io](https://cloudretro.io)**
|
||||
Direct play an existing game: **[Pokemon Emerald](https://cloudretro.io/?id=1bd37d4b5dfda87c___Pokemon%20-%20Emerald%20Version%20(U))**
|
||||
|
||||
## Introduction
|
||||
|
|
@ -55,19 +56,24 @@ a better sense of performance.
|
|||
* Install [Go](https://golang.org/doc/install)
|
||||
* Install [libvpx](https://www.webmproject.org/code/), [libx264](https://www.videolan.org/developers/x264.html)
|
||||
, [libopus](http://opus-codec.org/), [pkg-config](https://www.freedesktop.org/wiki/Software/pkg-config/)
|
||||
, [sdl2](https://wiki.libsdl.org/Installation)
|
||||
, [sdl2](https://wiki.libsdl.org/Installation), [libyuv](https://chromium.googlesource.com/libyuv/libyuv/)+[libjpeg-turbo](https://github.com/libjpeg-turbo/libjpeg-turbo)
|
||||
|
||||
```
|
||||
# Ubuntu / Windows (WSL2)
|
||||
apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev
|
||||
apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libyuv-dev libjpeg-turbo8-dev libspeexdsp-dev
|
||||
|
||||
# MacOS
|
||||
brew install pkg-config libvpx x264 opus sdl2
|
||||
brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo speexdsp
|
||||
|
||||
# Windows (MSYS2)
|
||||
pacman -Sy --noconfirm --needed git make mingw-w64-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,x264-git,SDL2}
|
||||
pacman -Sy --noconfirm --needed git make mingw-w64-ucrt-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,SDL2,libyuv,libjpeg-turbo,speexdsp}
|
||||
```
|
||||
|
||||
(You don't need to download libyuv on macOS)
|
||||
|
||||
(If you need to use the app on an older version of Ubuntu that does not have libyuv (when it says: unable to locate package libyuv-dev), you can add a custom apt repository:
|
||||
`add sudo add-apt-repository ppa:savoury1/graphics`)
|
||||
|
||||
Because the coordinator and workers need to run simultaneously. Workers connect to the coordinator.
|
||||
|
||||
1. Script
|
||||
|
|
@ -86,16 +92,16 @@ __See the `docker-compose.yml` file for Xvfb example config.__
|
|||
|
||||
## Run with Docker
|
||||
|
||||
Use makefile script: `make dev.run-docker` or Docker Compose directly: `docker-compose up --build`
|
||||
(`CLOUD_GAME_GAMES_PATH` is env variable for games on your host). It will spawn a docker environment and you can access
|
||||
the service on `localhost:8000`.
|
||||
Use makefile script: `make dev.run-docker` or Docker Compose directly: `docker compose up --build`.
|
||||
It will spawn a docker environment and you can access the service on `localhost:8000`.
|
||||
|
||||
## 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.
|
||||
The default configuration file is stored in the [`pkg/configs/config.yaml`](pkg/config/config.yaml) file.
|
||||
This configuration file will be embedded into the applications and loaded automatically during startup.
|
||||
In order to change the default parameters you can specify environment variables with the `CLOUD_GAME_` prefix, or place
|
||||
a custom `config.yaml` file into one of these places: just near the application, `.cr` folder in user's home, or
|
||||
specify own directory with `-w-conf` application param (`worker -w-conf /usr/conf`).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
|
@ -106,15 +112,11 @@ application [installed](https://docs.docker.com/compose/install/).
|
|||
|
||||
## Technical documents
|
||||
|
||||
- [Design document v2](DESIGNv2.md)
|
||||
- [webrtchacks Blog: Open Source Cloud Gaming with WebRTC](https://webrtchacks.com/open-source-cloud-gaming-with-webrtc/)
|
||||
- [Wiki (outdated)](https://github.com/giongto35/cloud-game/wiki)
|
||||
|
||||
- [Code Pointer Wiki](https://github.com/giongto35/cloud-game/wiki/Code-Deep-Dive)
|
||||
|
||||
| High level | Worker internal |
|
||||
| :----------------------------------: | :-----------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
## FAQ
|
||||
|
||||
- [FAQ](https://github.com/giongto35/cloud-game/wiki/FAQ)
|
||||
|
|
@ -123,7 +125,7 @@ application [installed](https://docs.docker.com/compose/install/).
|
|||
|
||||
By clicking these deep link, you can join the game directly and play it together with other people.
|
||||
|
||||
- [Play Pokemon Emerald](https://cloudretro.io/?id=652e45d78d2b91cd%7CPokemon%20-%20Emerald%20Version%20%28U%29)
|
||||
- [Play Pokemon Emerald](https://cloudretro.io/?id=652e45d78d2b91cd___Pokemon%20-%20Emerald%20Version%20(U))
|
||||
- [Fire Emblem](https://cloudretro.io/?id=314ea4d7f9c94d25___Fire%20Emblem%20%28U%29%20%5B%21%5D)
|
||||
- [Samurai Showdown 4](https://cloudretro.io/?id=733c73064c368832___samsho4)
|
||||
- [Metal Slug X](https://cloudretro.io/?id=2a9c4b3f1c872d28___mslugx)
|
||||
|
|
@ -131,11 +133,6 @@ By clicking these deep link, you can join the game directly and play it together
|
|||
And you can host the new game by yourself by accessing [cloudretro.io](https://cloudretro.io) and click "share" button
|
||||
to generate a permanent link to your game.
|
||||
|
||||
<p align="center">
|
||||
<img width="420" height="300" src="docs/img/multiplatform.png"> <br>
|
||||
Synchronize a game session on multiple devices
|
||||
</p>
|
||||
|
||||
## Credits
|
||||
|
||||
We are very much thankful to [everyone](https://github.com/giongto35/cloud-game/graphs/contributors) we've been lucky to
|
||||
|
|
@ -161,7 +158,6 @@ Thanks:
|
|||
|
||||
# Announcement
|
||||
|
||||
|
||||
**[CloudMorph](https://github.com/giongto35/cloud-morph) is a sibling project that offers a more generic to
|
||||
run any offline games/application on browser in Cloud Gaming
|
||||
approach: [https://github.com/giongto35/cloud-morph](https://github.com/giongto35/cloud-morph))**
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
mupen64plus-169screensize = 480x270
|
||||
mupen64plus-43screensize = 320x240
|
||||
mupen64plus-alt-map = False
|
||||
mupen64plus-aspect = 4:3
|
||||
mupen64plus-astick-deadzone = 15
|
||||
mupen64plus-astick-sensitivity = 100
|
||||
mupen64plus-BackgroundMode = OnePiece
|
||||
mupen64plus-BilinearMode = standard
|
||||
mupen64plus-CorrectTexrectCoords = Off
|
||||
mupen64plus-CountPerOp = 0
|
||||
mupen64plus-cpucore = dynamic_recompiler
|
||||
mupen64plus-CropMode = Auto
|
||||
mupen64plus-d-cbutton = C3
|
||||
mupen64plus-EnableCopyColorToRDRAM = Off
|
||||
mupen64plus-EnableCopyDepthToRDRAM = Software
|
||||
mupen64plus-EnableEnhancedHighResStorage = False
|
||||
mupen64plus-EnableEnhancedTextureStorage = False
|
||||
mupen64plus-EnableFBEmulation = True
|
||||
mupen64plus-EnableFragmentDepthWrite = False
|
||||
mupen64plus-EnableHWLighting = False
|
||||
mupen64plus-EnableLegacyBlending = True
|
||||
mupen64plus-EnableLODEmulation = True
|
||||
mupen64plus-EnableNativeResTexrects = Disabled
|
||||
mupen64plus-EnableOverscan = Enabled
|
||||
mupen64plus-EnableShadersStorage = True
|
||||
mupen64plus-EnableTextureCache = True
|
||||
mupen64plus-ForceDisableExtraMem = False
|
||||
mupen64plus-FrameDuping = False
|
||||
mupen64plus-Framerate = Original
|
||||
mupen64plus-FXAA = 0
|
||||
mupen64plus-l-cbutton = C2
|
||||
mupen64plus-MaxTxCacheSize = 8000
|
||||
mupen64plus-NoiseEmulation = True
|
||||
mupen64plus-OverscanBottom = 0
|
||||
mupen64plus-OverscanLeft = 0
|
||||
mupen64plus-OverscanRight = 0
|
||||
mupen64plus-OverscanTop = 0
|
||||
mupen64plus-pak1 = memory
|
||||
mupen64plus-pak2 = none
|
||||
mupen64plus-pak3 = none
|
||||
mupen64plus-pak4 = none
|
||||
mupen64plus-r-cbutton = C1
|
||||
mupen64plus-rdp-plugin = gliden64
|
||||
mupen64plus-rsp-plugin = hle
|
||||
mupen64plus-rspmode = HLE
|
||||
mupen64plus-txCacheCompression = True
|
||||
mupen64plus-txEnhancementMode = None
|
||||
mupen64plus-txFilterIgnoreBG = True
|
||||
mupen64plus-txFilterMode = None
|
||||
mupen64plus-txHiresEnable = False
|
||||
mupen64plus-txHiresFullAlphaChannel = False
|
||||
mupen64plus-u-cbutton = C4
|
||||
mupen64plus-virefresh = Auto
|
||||
|
|
@ -1 +0,0 @@
|
|||
nestopia_audio_type=stereo
|
||||
Binary file not shown.
2
assets/games/dos/rogue.conf
Normal file
2
assets/games/dos/rogue.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[autoexec]
|
||||
ROGUE.EXE
|
||||
BIN
assets/games/dos/rogue.zip
Normal file
BIN
assets/games/dos/rogue.zip
Normal file
Binary file not shown.
BIN
assets/games/nes/Alwa's Awakening (Demo).nes
Normal file
BIN
assets/games/nes/Alwa's Awakening (Demo).nes
Normal file
Binary file not shown.
|
|
@ -1,33 +1,32 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
config "github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/coordinator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/os"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/coordinator"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/os"
|
||||
)
|
||||
|
||||
var Version = "?"
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UTC().UnixNano())
|
||||
conf := config.NewConfig()
|
||||
conf, paths := config.NewCoordinatorConfig()
|
||||
conf.ParseFlags()
|
||||
|
||||
log := logger.NewConsole(conf.Coordinator.Debug, "c", false)
|
||||
|
||||
log.Info().Msgf("version %s", Version)
|
||||
log.Info().Msgf("conf version: %v", conf.Version)
|
||||
log.Info().Msgf("conf: v%v, loaded: %v", conf.Version, paths)
|
||||
if log.GetLevel() < logger.InfoLevel {
|
||||
log.Debug().Msgf("config: %+v", conf)
|
||||
log.Debug().Msgf("conf: %+v", conf)
|
||||
}
|
||||
c, err := coordinator.New(conf, log)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("init fail")
|
||||
return
|
||||
}
|
||||
c := coordinator.New(conf, log)
|
||||
c.Start()
|
||||
<-os.ExpectTermination()
|
||||
if err := c.Stop(); err != nil {
|
||||
log.Error().Err(err).Msg("service shutdown errors")
|
||||
log.Error().Err(err).Msg("shutdown fail")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
cmd/worker/default.pgo
Normal file
BIN
cmd/worker/default.pgo
Normal file
Binary file not shown.
|
|
@ -1,40 +1,40 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
config "github.com/giongto35/cloud-game/v2/pkg/config/worker"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/os"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/worker"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/worker/thread"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/os"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/worker"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/worker/thread"
|
||||
)
|
||||
|
||||
var Version = "?"
|
||||
|
||||
func run() {
|
||||
rand.Seed(time.Now().UTC().UnixNano())
|
||||
conf := config.NewConfig()
|
||||
conf, paths := config.NewWorkerConfig()
|
||||
conf.ParseFlags()
|
||||
|
||||
log := logger.NewConsole(conf.Worker.Debug, "w", false)
|
||||
log.Info().Msgf("version %s", Version)
|
||||
log.Info().Msgf("conf version: %v", conf.Version)
|
||||
log.Info().Msgf("conf: v%v, loaded: %v", conf.Version, paths)
|
||||
if log.GetLevel() < logger.InfoLevel {
|
||||
log.Debug().Msgf("config: %+v", conf)
|
||||
log.Debug().Msgf("conf: %+v", conf)
|
||||
}
|
||||
|
||||
done := os.ExpectTermination()
|
||||
wrk := worker.New(conf, log, done)
|
||||
wrk.Start()
|
||||
w, err := worker.New(conf, log)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("init fail")
|
||||
return
|
||||
}
|
||||
w.Start(done)
|
||||
<-done
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if err := wrk.Stop(); err != nil {
|
||||
log.Error().Err(err).Msg("service shutdown errors")
|
||||
time.Sleep(100 * time.Millisecond) // hack
|
||||
if err := w.Stop(); err != nil {
|
||||
log.Error().Err(err).Msg("shutdown fail")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
thread.Wrap(run)
|
||||
}
|
||||
func main() { thread.Wrap(run) }
|
||||
|
|
|
|||
|
|
@ -1,306 +0,0 @@
|
|||
#
|
||||
# Application configuration file
|
||||
#
|
||||
|
||||
# for the compatibility purposes
|
||||
version: 3
|
||||
|
||||
coordinator:
|
||||
# debugging switch
|
||||
# - shows debug logs
|
||||
# - allows selecting worker instances
|
||||
debug: false
|
||||
# selects free workers:
|
||||
# - any (default, any free)
|
||||
# - ping (with the lowest ping)
|
||||
selector: any
|
||||
# games library
|
||||
library:
|
||||
# root folder for the library (where games are stored)
|
||||
basePath: assets/games
|
||||
# an explicit list of supported file extensions
|
||||
# which overrides Libretro emulator ROMs configs
|
||||
supported:
|
||||
# a list of ignored words in the ROM filenames
|
||||
ignored:
|
||||
- neogeo
|
||||
- pgm
|
||||
# print some additional info
|
||||
verbose: true
|
||||
# enable library directory live reload
|
||||
# (experimental)
|
||||
watchMode: false
|
||||
monitoring:
|
||||
port: 6601
|
||||
# enable Go profiler HTTP server
|
||||
profilingEnabled: false
|
||||
metricEnabled: false
|
||||
urlPrefix: /coordinator
|
||||
# a custom Origins for incoming Websocket connections:
|
||||
# "" -- checks same origin policy
|
||||
# "*" -- allows all
|
||||
# "your address" -- checks for that address
|
||||
origin:
|
||||
userWs:
|
||||
workerWs:
|
||||
# HTTP(S) server config
|
||||
server:
|
||||
address: :8000
|
||||
https: false
|
||||
# Letsencrypt or self cert config
|
||||
tls:
|
||||
address: :443
|
||||
# allowed host name
|
||||
domain:
|
||||
# if both are set then will use certs
|
||||
# and Letsencryt instead
|
||||
httpsCert:
|
||||
httpsKey:
|
||||
analytics:
|
||||
inject: false
|
||||
gtag:
|
||||
|
||||
worker:
|
||||
# show more logs
|
||||
debug: false
|
||||
network:
|
||||
# a coordinator address to connect to
|
||||
coordinatorAddress: localhost:8000
|
||||
# where to connect
|
||||
endpoint: /wso
|
||||
# ping endpoint
|
||||
pingEndpoint: /echo
|
||||
# set public ping address (IP or hostname)
|
||||
publicAddress:
|
||||
# make coordinator connection secure (wss)
|
||||
secure: false
|
||||
# ISO Alpha-2 country code to group workers by zones
|
||||
zone:
|
||||
monitoring:
|
||||
# monitoring server port
|
||||
port: 6602
|
||||
profilingEnabled: false
|
||||
# monitoring server URL prefix
|
||||
metricEnabled: false
|
||||
urlPrefix: /worker
|
||||
server:
|
||||
address: :9000
|
||||
https: false
|
||||
tls:
|
||||
address: :444
|
||||
# LetsEncrypt config
|
||||
# allowed host name
|
||||
domain:
|
||||
# Own certs config
|
||||
httpsCert:
|
||||
httpsKey:
|
||||
# optional server tag
|
||||
tag:
|
||||
|
||||
emulator:
|
||||
# set output viewport scale factor
|
||||
scale: 1
|
||||
|
||||
# set the total number of threads for the image processing
|
||||
# (experimental)
|
||||
threads: 4
|
||||
|
||||
aspectRatio:
|
||||
# enable aspect ratio changing
|
||||
# (experimental)
|
||||
keep: false
|
||||
# recalculate emulator game frame size to the given WxH
|
||||
width: 320
|
||||
height: 240
|
||||
|
||||
# enable autosave for emulator states if set to a non-zero value of seconds
|
||||
autosaveSec: 0
|
||||
|
||||
# save directory for emulator states
|
||||
# special tag {user} will be replaced with current user's home dir
|
||||
storage: "{user}/.cr/save"
|
||||
|
||||
# path for storing emulator generated files
|
||||
localPath: "./libretro"
|
||||
|
||||
libretro:
|
||||
# use zip compression for emulator save states
|
||||
saveCompression: true
|
||||
# Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX
|
||||
logLevel: 1
|
||||
cores:
|
||||
paths:
|
||||
libs: assets/cores
|
||||
configs: assets/cores
|
||||
# Config params for Libretro cores repository,
|
||||
# available types are:
|
||||
# - buildbot (the default Libretro nightly repository)
|
||||
# - github (GitHub raw repository with a similar structure to buildbot)
|
||||
# - raw (just a link to a zip file extracted as is)
|
||||
repo:
|
||||
# enable auto-download for the list of cores (list->lib)
|
||||
sync: true
|
||||
# external cross-process mutex lock
|
||||
extLock: "{user}/.cr/cloud-game.lock"
|
||||
main:
|
||||
type: buildbot
|
||||
url: https://buildbot.libretro.com/nightly
|
||||
# if repo has file compression
|
||||
compression: zip
|
||||
# a secondary repo to use i.e. for not found in the main cores
|
||||
secondary:
|
||||
type: github
|
||||
url: https://github.com/sergystepanov/libretro-spiegel/blob/main
|
||||
compression: zip
|
||||
# Libretro core configuration
|
||||
#
|
||||
# The emulator selection will happen in this order:
|
||||
# - based on the folder name in the folder param
|
||||
# - based on the folder name (core name) in the list (i.e. nes, snes)
|
||||
# - based on the rom names in the roms param
|
||||
#
|
||||
# Available config params:
|
||||
# - altRepo (bool) prioritize secondary repo as the download source
|
||||
# - lib (string)
|
||||
# - config (string)
|
||||
# - roms ([]string)
|
||||
# - folder (string)
|
||||
# By default emulator selection is based on the folder named as cores
|
||||
# in the list (i.e. nes, snes) but if you specify folder param,
|
||||
# then it will try to load the ROM file from that folder first.
|
||||
# - width (int) -- broken
|
||||
# - height (int) -- broken
|
||||
# - ratio (float)
|
||||
# - isGlAllowed (bool)
|
||||
# - usesLibCo (bool)
|
||||
# - hasMultitap (bool)
|
||||
list:
|
||||
gba:
|
||||
lib: mgba_libretro
|
||||
roms: [ "gba", "gbc" ]
|
||||
pcsx:
|
||||
lib: pcsx_rearmed_libretro
|
||||
roms: [ "cue" ]
|
||||
# example of folder override
|
||||
folder: psx
|
||||
# MAME core requires additional manual setup, please read:
|
||||
# https://docs.libretro.com/library/fbneo/
|
||||
mame:
|
||||
lib: fbneo_libretro
|
||||
roms: [ "zip" ]
|
||||
nes:
|
||||
lib: nestopia_libretro
|
||||
config: nestopia_libretro.cfg
|
||||
roms: [ "nes" ]
|
||||
snes:
|
||||
lib: snes9x_libretro
|
||||
roms: [ "smc", "sfc", "swc", "fig", "bs" ]
|
||||
hasMultitap: true
|
||||
n64:
|
||||
lib: mupen64plus_next_libretro
|
||||
altRepo: true
|
||||
config: mupen64plus_next_libretro.cfg
|
||||
roms: [ "n64", "v64", "z64" ]
|
||||
isGlAllowed: true
|
||||
usesLibCo: true
|
||||
|
||||
encoder:
|
||||
audio:
|
||||
# audio frame duration needed for WebRTC (Opus)
|
||||
# most of the emulators have ~1400 samples per a video frame,
|
||||
# so we keep the frame buffer roughly half of that size or 2 RTC packets per frame
|
||||
frame: 10
|
||||
video:
|
||||
# h264, vpx (VP8)
|
||||
codec: h264
|
||||
# concurrent execution units (0 - disabled)
|
||||
concurrency: 0
|
||||
# see: https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||
h264:
|
||||
# Constant Rate Factor (CRF) 0-51 (default: 23)
|
||||
crf: 23
|
||||
# ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
|
||||
preset: superfast
|
||||
# baseline, main, high, high10, high422, high444
|
||||
profile: baseline
|
||||
# film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency
|
||||
tune: zerolatency
|
||||
# 0-3
|
||||
logLevel: 0
|
||||
# see: https://www.webmproject.org/docs/encoder-parameters
|
||||
vpx:
|
||||
# target bitrate (KBit/s)
|
||||
bitrate: 1200
|
||||
# force keyframe interval
|
||||
keyframeInterval: 5
|
||||
|
||||
# game recording
|
||||
# (experimental)
|
||||
# recording allows export RAW a/v streams of games
|
||||
# by default, it will export audio as WAV files,
|
||||
# video as a list of PNG-encoded images, and
|
||||
# one additional FFMPEG concat demux file
|
||||
recording:
|
||||
enabled: false
|
||||
# image compression level:
|
||||
# 0 - default compression
|
||||
# -1 - no compression
|
||||
# -2 - best speed
|
||||
# -3 - best compression
|
||||
compressLevel: 0
|
||||
# name contains the name of the recording dir (or zip)
|
||||
# format:
|
||||
# %date:go_time_format% -- refer: https://go.dev/src/time/format.go
|
||||
# %user% -- user name who started the recording
|
||||
# %game% -- game name (game ROM name)
|
||||
# %rand:len% -- a random string of given length
|
||||
# as example: 20210101101010_yeE_user1_badApple
|
||||
name: "%date:20060102150405%_%rand:3%_%user%_%game%"
|
||||
# zip and remove recording dir on completion
|
||||
zip: true
|
||||
# save directory
|
||||
folder: ./recording
|
||||
|
||||
storage:
|
||||
# cloud storage provider:
|
||||
# - empty (No op storage stub)
|
||||
# - oracle [Oracle Object Storage](https://www.oracle.com/cloud/storage/object-storage.html)
|
||||
provider:
|
||||
# this value contains arbitrary key attribute:
|
||||
# - oracle: pre-authenticated URL (see: https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/usingpreauthenticatedrequests.htm)
|
||||
key:
|
||||
|
||||
webrtc:
|
||||
# turn off default Pion interceptors (see: https://github.com/pion/interceptor)
|
||||
# (performance)
|
||||
disableDefaultInterceptors: true
|
||||
# indicates the role of the DTLS transport (see: https://github.com/pion/webrtc/blob/master/dtlsrole.go)
|
||||
# (debug)
|
||||
# - (default)
|
||||
# - 1 (auto)
|
||||
# - 2 (client)
|
||||
# - 3 (server)
|
||||
dtlsRole:
|
||||
# a list of STUN/TURN servers to use
|
||||
iceServers:
|
||||
- urls: stun:stun.l.google.com:19302
|
||||
# configures whether the ice agent should be a lite agent (true/false)
|
||||
# (performance)
|
||||
# don't use iceServers when enabled
|
||||
iceLite: false
|
||||
# ICE configuration
|
||||
# by default, ICE ports are random and unlimited
|
||||
# alternatives:
|
||||
# 1. instead of random unlimited port range for
|
||||
# WebRTC connections, these params limit port range of ICE connections
|
||||
icePorts:
|
||||
min:
|
||||
max:
|
||||
# 2. select a single port to forward all ICE connections there
|
||||
singlePort:
|
||||
# override ICE candidate IP, see: https://github.com/pion/webrtc/issues/835,
|
||||
# can be used for Docker bridged network internal IP override
|
||||
iceIpMap:
|
||||
# set additional log level for WebRTC separately
|
||||
# -1 - trace, 6 - nothing, ..., debug - 0
|
||||
logLevel: 6
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
version: '3'
|
||||
services:
|
||||
|
||||
cloud-game:
|
||||
|
|
@ -7,20 +6,28 @@ services:
|
|||
container_name: cloud-game-local
|
||||
environment:
|
||||
- DISPLAY=:99
|
||||
- MESA_GL_VERSION_OVERRIDE=3.3
|
||||
- MESA_GL_VERSION_OVERRIDE=4.5
|
||||
- CLOUD_GAME_WEBRTC_SINGLEPORT=8443
|
||||
- CLOUD_GAME_WEBRTC_ICEIPMAP=127.0.0.1
|
||||
# - CLOUD_GAME_WEBRTC_ICEIPMAP=127.0.0.1
|
||||
- CLOUD_GAME_COORDINATOR_DEBUG=true
|
||||
- CLOUD_GAME_WORKER_DEBUG=true
|
||||
# - PION_LOG_TRACE=all
|
||||
ports:
|
||||
- 8000:8000
|
||||
- 9000:9000
|
||||
- 8443:8443/udp
|
||||
- "8000:8000"
|
||||
- "9000:9000"
|
||||
- "8443:8443/udp"
|
||||
command: >
|
||||
bash -c "Xvfb :99 & coordinator & worker"
|
||||
bash -c "./coordinator & ./worker"
|
||||
volumes:
|
||||
# keep cores persistent in the cloud-game_cores volume
|
||||
- cores:/usr/local/share/cloud-game/assets/cores
|
||||
- ${CLOUD_GAME_GAMES_PATH:-./assets/games}:/usr/local/share/cloud-game/assets/games
|
||||
- ./assets/cores:/usr/local/share/cloud-game/assets/cores
|
||||
- ./assets/games:/usr/local/share/cloud-game/assets/games
|
||||
- x11:/tmp/.X11-unix
|
||||
|
||||
xvfb:
|
||||
image: kcollins/xvfb:latest
|
||||
volumes:
|
||||
- x11:/tmp/.X11-unix
|
||||
command: [ ":99", "-screen", "0", "320x240x16" ]
|
||||
|
||||
volumes:
|
||||
cores:
|
||||
x11:
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
# Web-based Cloud Gaming Service Design Document
|
||||
|
||||
Web-based Cloud Gaming Service contains multiple workers for gaming stream and a coordinator (Coordinator) for distributing traffic and pairing up connection.
|
||||
|
||||
## Worker
|
||||
|
||||
Worker is responsible for streaming game to frontend
|
||||

|
||||
|
||||
- After Coordinator matches the most appropriate server to the user, webRTC peer-to-peer handshake will be conducted. The coordinator will exchange the signature (WebRTC Session Remote Description) between two peers over Web Socket connection.
|
||||
- On worker, each user session will spawn a new room running a gaming emulator. Image stream and audio stream from emulator is captured and encoded to WebRTC streaming format. We applied Vp8 for Video compression and Opus for audio compression to ensure the smoothest experience. After finish encoded, these stream is then piped out to user and observers joining that room.
|
||||
- On the other hand, input from users is sent to workers over WebRTC DataChannel. Game logic on the emulator will be updated based on the input stream.
|
||||
- Game state is stored in cloud storage, so all workers can collaborate and keep the same understanding with each other. It allows user can continue from the saved state in the next time.
|
||||
|
||||
## Coordinator
|
||||
|
||||
Coordinator is loadbalancer and coordinator, which is in charge of picking the most suitable workers for a user. Every time a user connects to Coordinator, it will collect all the metric from all workers, i.e free CPU resources and latency from worker to user. Coordinator will decide the best candidate based on the metric and setup peer-to-peer connection between worker and user based on WebRTC protocol
|
||||
|
||||

|
||||
|
||||
1. A user connected to Coordinator .
|
||||
2. Coordinator will find the most suitable worker to serve the user.
|
||||
3. Coordinator collects all latencies from workers to users as well as CPU usage on each machine.
|
||||
4. Coordinator setup peer-to-peer handshake between worker and user by exchanging Session Description Protocol.
|
||||
5. A game is hosted on worker and streamed to the user.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 347 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
90
go.mod
90
go.mod
|
|
@ -1,50 +1,62 @@
|
|||
module github.com/giongto35/cloud-game/v2
|
||||
module github.com/giongto35/cloud-game/v3
|
||||
|
||||
go 1.18
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/VictoriaMetrics/metrics v1.23.1
|
||||
github.com/VictoriaMetrics/metrics v1.40.2
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/goccy/go-json v0.10.0
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/kkyr/fig v0.3.1
|
||||
github.com/pion/interceptor v0.1.12
|
||||
github.com/pion/logging v0.2.2
|
||||
github.com/pion/webrtc/v3 v3.1.53
|
||||
github.com/rs/xid v1.4.0
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/veandco/go-sdl2 v0.4.31
|
||||
golang.org/x/crypto v0.5.0
|
||||
golang.org/x/image v0.3.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/knadh/koanf/maps v0.1.2
|
||||
github.com/knadh/koanf/v2 v2.3.0
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/pion/ice/v4 v4.1.0
|
||||
github.com/pion/interceptor v0.1.42
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/webrtc/v4 v4.1.8
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/veandco/go-sdl2 v0.4.40
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.34.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pion/datachannel v1.5.5 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.2 // indirect
|
||||
github.com/pion/ice/v2 v2.2.16 // indirect
|
||||
github.com/pion/mdns v0.0.7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.9 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.10 // indirect
|
||||
github.com/pion/rtp v1.7.13 // indirect
|
||||
github.com/pion/sctp v1.8.6 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.6 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.11 // indirect
|
||||
github.com/pion/stun v0.4.0 // indirect
|
||||
github.com/pion/transport v0.14.1 // indirect
|
||||
github.com/pion/transport/v2 v2.0.0 // indirect
|
||||
github.com/pion/turn/v2 v2.0.9 // indirect
|
||||
github.com/pion/udp v0.1.4 // indirect
|
||||
github.com/pion/rtcp v1.2.16 // indirect
|
||||
github.com/pion/rtp v1.8.27 // indirect
|
||||
github.com/pion/sctp v1.8.41 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.17 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.9 // indirect
|
||||
github.com/pion/stun/v3 v3.0.2 // indirect
|
||||
github.com/pion/transport/v3 v3.1.1 // indirect
|
||||
github.com/pion/turn/v4 v4.1.3 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/valyala/fastrand v1.1.0 // indirect
|
||||
github.com/valyala/histogram v1.2.0 // indirect
|
||||
golang.org/x/net v0.5.0 // indirect
|
||||
golang.org/x/sys v0.4.0 // indirect
|
||||
golang.org/x/text v0.6.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
)
|
||||
|
|
|
|||
323
go.sum
323
go.sum
|
|
@ -1,230 +1,133 @@
|
|||
github.com/VictoriaMetrics/metrics v1.23.1 h1:/j8DzeJBxSpL2qSIdqnRFLvQQhbJyJbbEi22yMm7oL0=
|
||||
github.com/VictoriaMetrics/metrics v1.23.1/go.mod h1:rAr/llLpEnAdTehiNlUxKgnjcOuROSzpw0GvjpEbvFc=
|
||||
github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac=
|
||||
github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA=
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
|
||||
github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
|
||||
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/kkyr/fig v0.3.1 h1:GqsamO9dwY05t2xh6ubzjPPYw2It4hoWbKZEWmDxM0o=
|
||||
github.com/kkyr/fig v0.3.1/go.mod h1:ItUILF8IIzgZOMhx5xpJ1W/bviQsWRKOwKXfE/tqUoA=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
|
||||
github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
|
||||
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
|
||||
github.com/pion/dtls/v2 v2.1.5/go.mod h1:BqCE7xPZbPSubGasRoDFJeTsyJtdD1FanJYL0JGheqY=
|
||||
github.com/pion/dtls/v2 v2.2.2 h1:ProtrjiUsniOnqzxc2N1l8s31LjzEx6CnOR/VYI4mBM=
|
||||
github.com/pion/dtls/v2 v2.2.2/go.mod h1:jabr7NM22jSGqytLKlPJ872ruQZRPb96+I6q+kPp6aQ=
|
||||
github.com/pion/ice/v2 v2.2.16 h1:ht10A9FxLrFouaQQy9oSzZHaN+HJqN07jQ0SmzsBgpU=
|
||||
github.com/pion/ice/v2 v2.2.16/go.mod h1:bygTkwN2e4U4v57VE77qS2wk5P8kc951WZZkf4LeA2E=
|
||||
github.com/pion/interceptor v0.1.11/go.mod h1:tbtKjZY14awXd7Bq0mmWvgtHB5MDaRN7HV3OZ/uy7s8=
|
||||
github.com/pion/interceptor v0.1.12 h1:CslaNriCFUItiXS5o+hh5lpL0t0ytQkFnUcbbCs2Zq8=
|
||||
github.com/pion/interceptor v0.1.12/go.mod h1:bDtgAD9dRkBZpWHGKaoKb42FhDHTG2rX8Ii9LRALLVA=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/mdns v0.0.7 h1:P0UB4Sr6xDWEox0kTVxF0LmQihtCbSAdW0H2nEgkA3U=
|
||||
github.com/pion/mdns v0.0.7/go.mod h1:4iP2UbeFhLI/vWju/bw6ZfwjJzk0z8DNValjGxR/dD8=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
|
||||
github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
|
||||
github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU=
|
||||
github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4=
|
||||
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
|
||||
github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.9/go.mod h1:qVPhiCzAm4D/rxb6XzKeyZiQK69yJpbUDJSF7TgrqNo=
|
||||
github.com/pion/rtcp v1.2.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
|
||||
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
|
||||
github.com/pion/rtp v1.7.13 h1:qcHwlmtiI50t1XivvoawdCGTP4Uiypzfrsap+bijcoA=
|
||||
github.com/pion/rtp v1.7.13/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko=
|
||||
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sctp v1.8.6 h1:CUex11Vkt9YS++VhLf8b55O3VqKrWL6W3SDwX4jAqsI=
|
||||
github.com/pion/sctp v1.8.6/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
|
||||
github.com/pion/sdp/v3 v3.0.6 h1:WuDLhtuFUUVpTfus9ILC4HRyHsW6TdugjEX/QY9OiUw=
|
||||
github.com/pion/sdp/v3 v3.0.6/go.mod h1:iiFWFpQO8Fy3S5ldclBkpXqmWy02ns78NOKoLLL0YQw=
|
||||
github.com/pion/srtp/v2 v2.0.11 h1:6cEEgT1oCLWgE+BynbfaSMAxtsqU0M096x9dNH6olY0=
|
||||
github.com/pion/srtp/v2 v2.0.11/go.mod h1:vzHprzbuVoYJ9NfaRMycnFrkHcLSaLVuBZDOtFQNZjY=
|
||||
github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
|
||||
github.com/pion/stun v0.4.0 h1:vgRrbBE2htWHy7l3Zsxckk7rkjnjOsSM7PHZnBwo8rk=
|
||||
github.com/pion/stun v0.4.0/go.mod h1:QPsh1/SbXASntw3zkkrIk3ZJVKz4saBY2G7S10P3wCw=
|
||||
github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q=
|
||||
github.com/pion/transport v0.13.0/go.mod h1:yxm9uXpK9bpBBWkITk13cLo1y5/ur5VQpG22ny6EP7g=
|
||||
github.com/pion/transport v0.13.1/go.mod h1:EBxbqzyv+ZrmDb82XswEE0BjfQFtuw1Nu6sjnjWCsGg=
|
||||
github.com/pion/transport v0.14.1 h1:XSM6olwW+o8J4SCmOBb/BpwZypkHeyM0PGFCxNQBr40=
|
||||
github.com/pion/transport v0.14.1/go.mod h1:4tGmbk00NeYA3rUa9+n+dzCCoKkcy3YlYb99Jn2fNnI=
|
||||
github.com/pion/transport/v2 v2.0.0 h1:bsMYyqHCbkvHwj+eNCFBuxtlKndKfyGI2vaQmM3fIE4=
|
||||
github.com/pion/transport/v2 v2.0.0/go.mod h1:HS2MEBJTwD+1ZI2eSXSvHJx/HnzQqRy2/LXxt6eVMHc=
|
||||
github.com/pion/turn/v2 v2.0.9 h1:jcDPw0Vfd5I4iTc7s0Upfc2aMnyu2lgJ9vV0SUrNC1o=
|
||||
github.com/pion/turn/v2 v2.0.9/go.mod h1:DQlwUwx7hL8Xya6TTAabbd9DdKXTNR96Xf5g5Qqso/M=
|
||||
github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M=
|
||||
github.com/pion/udp v0.1.2/go.mod h1:CuqU2J4MmF3sjqKfk1SaIhuNXdum5PJRqd2LHuLMQSk=
|
||||
github.com/pion/udp v0.1.4 h1:OowsTmu1Od3sD6i3fQUJxJn2fEvJO6L1TidgadtbTI8=
|
||||
github.com/pion/udp v0.1.4/go.mod h1:G8LDo56HsFwC24LIcnT4YIDU5qcB6NepqqjP0keL2us=
|
||||
github.com/pion/webrtc/v3 v3.1.53 h1:vVyBPndwclEb2sClJHegQvlXWlA1YAbADyOhVQgphlM=
|
||||
github.com/pion/webrtc/v3 v3.1.53/go.mod h1:CJ3+hHptn5qzgeeTRGN5zuAlVtTXwGJYH18Zznn+onw=
|
||||
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||
github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc=
|
||||
github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/rtp v1.8.27 h1:kbWTdZr62RDlYjatVAW4qFwrAu9XcGnwMsofCfAHlOU=
|
||||
github.com/pion/rtp v1.8.27/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||
github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs=
|
||||
github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY=
|
||||
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
|
||||
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
|
||||
github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
|
||||
github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
|
||||
github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
|
||||
github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
|
||||
github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk=
|
||||
github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
|
||||
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w=
|
||||
github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0=
|
||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
||||
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
||||
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
||||
github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY=
|
||||
github.com/veandco/go-sdl2 v0.4.31 h1:hANDTXYfoRiFrDDD4OkkTBQHCMhXgQnXl1IXC/V9Jbc=
|
||||
github.com/veandco/go-sdl2 v0.4.31/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20221010152910-d6f0a8c073c2/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
|
||||
golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.5.0 h1:GyT4nK/YDHSqa1c4753ouYCDajOYKTja9Xb/OHtgvSw=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U=
|
||||
github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
139
pkg/api/api.go
139
pkg/api/api.go
|
|
@ -1,42 +1,71 @@
|
|||
// Package api defines the general API for both coordinator and worker applications.
|
||||
//
|
||||
// Each API call (request and response) is a JSON-encoded "packet" of the following structure:
|
||||
//
|
||||
// id - (optional) a globally unique packet id;
|
||||
// t - (required) one of the predefined unique packet types;
|
||||
// p - (optional) packet payload with arbitrary data.
|
||||
//
|
||||
// The basic idea behind this API is that the packets differentiate by their predefined types
|
||||
// with which it is possible to unwrap the payload into distinct request/response data structures.
|
||||
// And the id field is used for tracking packets through a chain of different network points (apps, devices),
|
||||
// for example, passing a packet from a browser forward to a worker and back through a coordinator.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// {"t":4,"p":{"ice":[{"urls":"stun:stun.l.google.com:19302"}],"games":["Sushi The Cat"],"wid":"cfv68irdrc3ifu3jn6bg"}}
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
"github.com/goccy/go-json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
Id interface {
|
||||
String() string
|
||||
}
|
||||
Stateful struct {
|
||||
Id network.Uid `json:"id"`
|
||||
Id string `json:"id"`
|
||||
}
|
||||
Room struct {
|
||||
Rid string `json:"room_id"` // room id
|
||||
Rid string `json:"room_id"`
|
||||
}
|
||||
StatefulRoom struct {
|
||||
Stateful
|
||||
Room
|
||||
Id string `json:"id"`
|
||||
Rid string `json:"room_id"`
|
||||
}
|
||||
PT uint8
|
||||
)
|
||||
|
||||
type (
|
||||
RoomInterface interface {
|
||||
GetRoom() string
|
||||
}
|
||||
)
|
||||
|
||||
func StateRoom(id network.Uid, rid string) StatefulRoom {
|
||||
return StatefulRoom{Stateful: Stateful{id}, Room: Room{rid}}
|
||||
type In[I Id] struct {
|
||||
Id I `json:"id,omitempty"`
|
||||
T PT `json:"t"`
|
||||
Payload json.RawMessage `json:"p,omitempty"` // should be json.RawMessage for 2-pass unmarshal
|
||||
}
|
||||
func (sr StatefulRoom) GetRoom() string { return sr.Rid }
|
||||
|
||||
func (i In[I]) GetId() I { return i.Id }
|
||||
func (i In[I]) GetPayload() []byte { return i.Payload }
|
||||
func (i In[I]) GetType() PT { return i.T }
|
||||
|
||||
type Out struct {
|
||||
Id string `json:"id,omitempty"` // string because omitempty won't work as intended with arrays
|
||||
T uint8 `json:"t"`
|
||||
Payload any `json:"p,omitempty"`
|
||||
}
|
||||
|
||||
func (o *Out) SetId(s string) { o.Id = s }
|
||||
func (o *Out) SetType(u uint8) { o.T = u }
|
||||
func (o *Out) SetPayload(a any) { o.Payload = a }
|
||||
func (o *Out) SetGetId(s fmt.Stringer) { o.Id = s.String() }
|
||||
func (o *Out) GetPayload() any { return o.Payload }
|
||||
|
||||
// Packet codes:
|
||||
//
|
||||
// x, 1xx - user codes
|
||||
// 2xx - worker codes
|
||||
// x, 1xx - user codes
|
||||
// 15x - webrtc data exchange codes
|
||||
// 2xx - worker codes
|
||||
const (
|
||||
CheckLatency PT = 3
|
||||
InitSession PT = 4
|
||||
|
|
@ -45,17 +74,21 @@ const (
|
|||
WebrtcAnswer PT = 102
|
||||
WebrtcIce PT = 103
|
||||
StartGame PT = 104
|
||||
ChangePlayer PT = 108
|
||||
QuitGame PT = 105
|
||||
SaveGame PT = 106
|
||||
LoadGame PT = 107
|
||||
ToggleMultitap PT = 109
|
||||
ChangePlayer PT = 108
|
||||
RecordGame PT = 110
|
||||
GetWorkerList PT = 111
|
||||
ErrNoFreeSlots PT = 112
|
||||
ResetGame PT = 113
|
||||
RegisterRoom PT = 201
|
||||
CloseRoom PT = 202
|
||||
IceCandidate = WebrtcIce
|
||||
TerminateSession PT = 204
|
||||
AppVideoChange PT = 150
|
||||
LibNewGameList PT = 205
|
||||
PrevSessions PT = 206
|
||||
)
|
||||
|
||||
func (p PT) String() string {
|
||||
|
|
@ -82,18 +115,26 @@ func (p PT) String() string {
|
|||
return "SaveGame"
|
||||
case LoadGame:
|
||||
return "LoadGame"
|
||||
case ToggleMultitap:
|
||||
return "ToggleMultitap"
|
||||
case RecordGame:
|
||||
return "RecordGame"
|
||||
case GetWorkerList:
|
||||
return "GetWorkerList"
|
||||
case ErrNoFreeSlots:
|
||||
return "NoFreeSlots"
|
||||
case ResetGame:
|
||||
return "ResetGame"
|
||||
case RegisterRoom:
|
||||
return "RegisterRoom"
|
||||
case CloseRoom:
|
||||
return "CloseRoom"
|
||||
case TerminateSession:
|
||||
return "TerminateSession"
|
||||
case AppVideoChange:
|
||||
return "AppVideoChange"
|
||||
case LibNewGameList:
|
||||
return "LibNewGameList"
|
||||
case PrevSessions:
|
||||
return "PrevSessions"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
|
|
@ -110,6 +151,27 @@ var (
|
|||
ErrMalformed = fmt.Errorf("malformed")
|
||||
)
|
||||
|
||||
var (
|
||||
EmptyPacket = Out{Payload: ""}
|
||||
ErrPacket = Out{Payload: "err"}
|
||||
OkPacket = Out{Payload: "ok"}
|
||||
)
|
||||
|
||||
func Do[I Id, T any](in In[I], fn func(T)) error {
|
||||
if dat := Unwrap[T](in.Payload); dat != nil {
|
||||
fn(*dat)
|
||||
return nil
|
||||
}
|
||||
return ErrMalformed
|
||||
}
|
||||
|
||||
func DoE[I Id, T any](in In[I], fn func(T) error) error {
|
||||
if dat := Unwrap[T](in.Payload); dat != nil {
|
||||
return fn(*dat)
|
||||
}
|
||||
return ErrMalformed
|
||||
}
|
||||
|
||||
func Unwrap[T any](data []byte) *T {
|
||||
out := new(T)
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
|
|
@ -125,27 +187,16 @@ func UnwrapChecked[T any](bytes []byte, err error) (*T, error) {
|
|||
return Unwrap[T](bytes), nil
|
||||
}
|
||||
|
||||
// ToBase64Json encodes data to a URL-encoded Base64+JSON string.
|
||||
func ToBase64Json(data any) (string, error) {
|
||||
if data == nil {
|
||||
return "", nil
|
||||
}
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
func Wrap(t any) ([]byte, error) { return json.Marshal(t) }
|
||||
|
||||
// FromBase64Json decodes data from a URL-encoded Base64+JSON string.
|
||||
func FromBase64Json(data string, obj any) error {
|
||||
b, err := base64.URLEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
return err
|
||||
const separator = "___"
|
||||
|
||||
func ExplodeDeepLink(link string) (string, string) {
|
||||
p := strings.SplitN(link, separator, 2)
|
||||
|
||||
if len(p) == 1 {
|
||||
return p[0], ""
|
||||
}
|
||||
err = json.Unmarshal(b, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
return p[0], p[1]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,42 @@
|
|||
package api
|
||||
|
||||
import "github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
|
||||
type (
|
||||
CloseRoomRequest string
|
||||
ConnectionRequest struct {
|
||||
CloseRoomRequest string
|
||||
ConnectionRequest[T Id] struct {
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Id string `json:"id,omitempty"`
|
||||
Id T `json:"id,omitempty"`
|
||||
IsHTTPS bool `json:"is_https,omitempty"`
|
||||
PingURL string `json:"ping_url,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
}
|
||||
GetWorkerListRequest struct{}
|
||||
GetWorkerListResponse struct {
|
||||
Servers []Server `json:"servers"`
|
||||
}
|
||||
RegisterRoomRequest string
|
||||
)
|
||||
|
||||
// Server contains a list of server groups.
|
||||
// Server is a separate machine that may contain
|
||||
// multiple sub-processes.
|
||||
type Server struct {
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Id network.Uid `json:"id,omitempty"`
|
||||
IsBusy bool `json:"is_busy,omitempty"`
|
||||
InGroup bool `json:"in_group,omitempty"`
|
||||
PingURL string `json:"ping_url"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Replicas uint32 `json:"replicas,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
}
|
||||
|
||||
type HasServerInfo interface {
|
||||
GetServerList() []Server
|
||||
}
|
||||
|
||||
const (
|
||||
DataQueryParam = "data"
|
||||
RoomIdQueryParam = "room_id"
|
||||
ZoneQueryParam = "zone"
|
||||
WorkerIdParam = "wid"
|
||||
)
|
||||
|
||||
// Server contains a list of server groups.
|
||||
// Server is a separate machine that may contain
|
||||
// multiple sub-processes.
|
||||
type Server struct {
|
||||
Addr string `json:"addr,omitempty"`
|
||||
Id Id `json:"id,omitempty"`
|
||||
IsBusy bool `json:"is_busy,omitempty"`
|
||||
InGroup bool `json:"in_group,omitempty"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
PingURL string `json:"ping_url"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Replicas uint32 `json:"replicas,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ type (
|
|||
RecordUser string `json:"record_user,omitempty"`
|
||||
PlayerIndex int `json:"player_index"`
|
||||
}
|
||||
GameStartUserResponse struct {
|
||||
RoomId string `json:"roomId"`
|
||||
Av *AppVideoInfo `json:"av"`
|
||||
KbMouse bool `json:"kb_mouse"`
|
||||
}
|
||||
IceServer struct {
|
||||
Urls string `json:"urls,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
|
|
@ -18,13 +23,14 @@ type (
|
|||
}
|
||||
InitSessionUserResponse struct {
|
||||
Ice []IceServer `json:"ice"`
|
||||
Games []string `json:"games"`
|
||||
Games []AppMeta `json:"games"`
|
||||
Wid string `json:"wid"`
|
||||
}
|
||||
AppMeta struct {
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Title string `json:"title"`
|
||||
System string `json:"system"`
|
||||
}
|
||||
WebrtcAnswerUserRequest string
|
||||
WebrtcUserIceCandidate string
|
||||
)
|
||||
|
||||
func InitSessionResult(ice []IceServer, games []string, wid string) (PT, InitSessionUserResponse) {
|
||||
return InitSession, InitSessionUserResponse{Ice: ice, Games: games, Wid: wid}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,38 @@
|
|||
package api
|
||||
|
||||
import "github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
|
||||
type GameInfo struct {
|
||||
Name string `json:"name"`
|
||||
Base string `json:"base"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type (
|
||||
ChangePlayerRequest = struct {
|
||||
ChangePlayerRequest struct {
|
||||
StatefulRoom
|
||||
Index int `json:"index"`
|
||||
}
|
||||
ChangePlayerResponse int
|
||||
GameQuitRequest struct {
|
||||
StatefulRoom
|
||||
}
|
||||
LoadGameRequest struct {
|
||||
StatefulRoom
|
||||
}
|
||||
LoadGameResponse string
|
||||
SaveGameRequest struct {
|
||||
StatefulRoom
|
||||
}
|
||||
SaveGameResponse string
|
||||
StartGameRequest struct {
|
||||
GameQuitRequest StatefulRoom
|
||||
LoadGameRequest StatefulRoom
|
||||
LoadGameResponse string
|
||||
ResetGameRequest StatefulRoom
|
||||
ResetGameResponse string
|
||||
SaveGameRequest StatefulRoom
|
||||
SaveGameResponse string
|
||||
StartGameRequest struct {
|
||||
StatefulRoom
|
||||
Record bool
|
||||
RecordUser string
|
||||
Game GameInfo `json:"game"`
|
||||
PlayerIndex int `json:"player_index"`
|
||||
Game string `json:"game"`
|
||||
PlayerIndex int `json:"player_index"`
|
||||
}
|
||||
GameInfo struct {
|
||||
Alias string `json:"alias"`
|
||||
Base string `json:"base"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
System string `json:"system"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
StartGameResponse struct {
|
||||
Room
|
||||
Record bool
|
||||
AV *AppVideoInfo `json:"av"`
|
||||
Record bool `json:"record"`
|
||||
KbMouse bool `json:"kb_mouse"`
|
||||
}
|
||||
RecordGameRequest struct {
|
||||
StatefulRoom
|
||||
|
|
@ -43,26 +40,31 @@ type (
|
|||
User string `json:"user"`
|
||||
}
|
||||
RecordGameResponse string
|
||||
TerminateSessionRequest struct {
|
||||
Stateful
|
||||
}
|
||||
ToggleMultitapRequest struct {
|
||||
StatefulRoom
|
||||
}
|
||||
WebrtcAnswerRequest struct {
|
||||
TerminateSessionRequest Stateful
|
||||
WebrtcAnswerRequest struct {
|
||||
Stateful
|
||||
Sdp string `json:"sdp"`
|
||||
}
|
||||
WebrtcIceCandidateRequest struct {
|
||||
Stateful
|
||||
Candidate string `json:"candidate"`
|
||||
}
|
||||
WebrtcInitRequest struct {
|
||||
Stateful
|
||||
Candidate string `json:"candidate"` // Base64-encoded ICE candidate
|
||||
}
|
||||
WebrtcInitRequest Stateful
|
||||
WebrtcInitResponse string
|
||||
)
|
||||
|
||||
func NewWebrtcIceCandidateRequest(id network.Uid, can string) (PT, any) {
|
||||
return WebrtcIce, WebrtcIceCandidateRequest{Stateful: Stateful{id}, Candidate: can}
|
||||
}
|
||||
AppVideoInfo struct {
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
S int `json:"s"`
|
||||
A float32 `json:"a"`
|
||||
}
|
||||
|
||||
LibGameListInfo struct {
|
||||
T int
|
||||
List []GameInfo
|
||||
}
|
||||
|
||||
PrevSessionInfo struct {
|
||||
List []string
|
||||
}
|
||||
)
|
||||
|
|
|
|||
172
pkg/com/com.go
172
pkg/com/com.go
|
|
@ -1,93 +1,121 @@
|
|||
package com
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
import "github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
)
|
||||
|
||||
type (
|
||||
In struct {
|
||||
Id network.Uid `json:"id,omitempty"`
|
||||
T api.PT `json:"t"`
|
||||
Payload json.RawMessage `json:"p,omitempty"`
|
||||
}
|
||||
Out struct {
|
||||
Id network.Uid `json:"id,omitempty"`
|
||||
T api.PT `json:"t"`
|
||||
Payload any `json:"p,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
EmptyPacket = Out{Payload: ""}
|
||||
ErrPacket = Out{Payload: "err"}
|
||||
OkPacket = Out{Payload: "ok"}
|
||||
)
|
||||
|
||||
type (
|
||||
NetClient interface {
|
||||
Close()
|
||||
Id() network.Uid
|
||||
}
|
||||
RegionalClient interface {
|
||||
In(region string) bool
|
||||
}
|
||||
)
|
||||
|
||||
type SocketClient struct {
|
||||
NetClient
|
||||
|
||||
id network.Uid
|
||||
wire *Client
|
||||
Tag string
|
||||
Log *logger.Logger
|
||||
type stringer interface {
|
||||
comparable
|
||||
String() string
|
||||
}
|
||||
|
||||
func New(conn *Client, tag string, id network.Uid, log *logger.Logger) SocketClient {
|
||||
l := log.Extend(log.With().Str("cid", id.Short()))
|
||||
dir := "→"
|
||||
type NetClient[K stringer] interface {
|
||||
Disconnect()
|
||||
Id() K
|
||||
}
|
||||
|
||||
type NetMap[K stringer, T NetClient[K]] struct{ Map[K, T] }
|
||||
|
||||
func NewNetMap[K stringer, T NetClient[K]]() NetMap[K, T] {
|
||||
return NetMap[K, T]{Map: Map[K, T]{m: make(map[K]T, 10)}}
|
||||
}
|
||||
|
||||
func (m *NetMap[K, T]) Add(client T) bool { return m.Put(client.Id(), client) }
|
||||
func (m *NetMap[K, T]) Empty() bool { return m.Map.Len() == 0 }
|
||||
func (m *NetMap[K, T]) Remove(client T) { m.Map.Remove(client.Id()) }
|
||||
func (m *NetMap[K, T]) RemoveL(client T) int { return m.Map.RemoveL(client.Id()) }
|
||||
func (m *NetMap[K, T]) Reset() { m.Map = Map[K, T]{m: make(map[K]T, 10)} }
|
||||
func (m *NetMap[K, T]) RemoveDisconnect(client T) { client.Disconnect(); m.Remove(client) }
|
||||
func (m *NetMap[K, T]) Find(id string) T {
|
||||
v, _ := m.Map.FindBy(func(v T) bool {
|
||||
return v.Id().String() == id
|
||||
})
|
||||
return v
|
||||
}
|
||||
|
||||
type SocketClient[T ~uint8, P Packet[T], X any, P2 Packet2[X]] struct {
|
||||
id Uid
|
||||
rpc *RPC[T, P]
|
||||
sock *Connection
|
||||
log *logger.Logger // a special logger for showing x -> y directions
|
||||
}
|
||||
|
||||
func NewConnection[T ~uint8, P Packet[T], X any, P2 Packet2[X]](conn *Connection, id Uid, log *logger.Logger) *SocketClient[T, P, X, P2] {
|
||||
if id.IsNil() {
|
||||
id = NewUid()
|
||||
}
|
||||
dir := logger.MarkOut
|
||||
if conn.IsServer() {
|
||||
dir = "←"
|
||||
dir = logger.MarkIn
|
||||
}
|
||||
l.Debug().Str("c", tag).Str("d", dir).Msg("Connect")
|
||||
return SocketClient{id: id, wire: conn, Tag: tag, Log: l}
|
||||
dirClLog := log.Extend(log.With().
|
||||
Str("cid", id.Short()).
|
||||
Str(logger.DirectionField, dir),
|
||||
)
|
||||
dirClLog.Debug().Msg("Connect")
|
||||
return &SocketClient[T, P, X, P2]{sock: conn, id: id, log: dirClLog}
|
||||
}
|
||||
|
||||
func (c *SocketClient) SetId(id network.Uid) { c.id = id }
|
||||
|
||||
func (c *SocketClient) OnPacket(fn func(p In) error) {
|
||||
logFn := func(p In) {
|
||||
c.Log.Debug().Str("c", c.Tag).Str("d", "←").Msgf("%s", p.T)
|
||||
if err := fn(p); err != nil {
|
||||
c.Log.Error().Err(err).Send()
|
||||
func (c *SocketClient[T, P, _, _]) ProcessPackets(fn func(in P) error) chan struct{} {
|
||||
c.rpc = NewRPC[T, P]()
|
||||
c.rpc.Handler = func(p P) {
|
||||
c.log.Debug().Str(logger.DirectionField, logger.MarkIn).Msgf("%v", p.GetType())
|
||||
if err := fn(p); err != nil { // 3rd handler
|
||||
c.log.Error().Err(err).Send()
|
||||
}
|
||||
}
|
||||
c.wire.OnPacket(logFn)
|
||||
c.sock.conn.SetMessageHandler(c.handleMessage) // 1st handler
|
||||
return c.sock.conn.Listen()
|
||||
}
|
||||
|
||||
func (c *SocketClient[T, P, X, P2]) SetErrorHandler(h func(error)) { c.sock.conn.SetErrorHandler(h) }
|
||||
|
||||
func (c *SocketClient[T, P, X, P2]) SetMaxMessageSize(s int64) { c.sock.conn.SetMaxMessageSize(s) }
|
||||
|
||||
func (c *SocketClient[_, _, _, _]) handleMessage(message []byte, err error) {
|
||||
if err != nil {
|
||||
c.log.Error().Err(err).Send()
|
||||
return
|
||||
}
|
||||
if err = c.rpc.handleMessage(message); err != nil { // 2nd handler
|
||||
c.log.Error().Err(err).Send()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SocketClient[_, P, X, P2]) Route(in P, out P2) {
|
||||
rq := P2(new(X))
|
||||
rq.SetId(in.GetId().String())
|
||||
rq.SetType(uint8(in.GetType()))
|
||||
rq.SetPayload(out.GetPayload())
|
||||
if err := c.rpc.Send(c.sock.conn, rq); err != nil {
|
||||
c.log.Error().Err(err).Msgf("message route fail")
|
||||
}
|
||||
}
|
||||
|
||||
// Send makes a blocking call.
|
||||
func (c *SocketClient) Send(t api.PT, data any) ([]byte, error) {
|
||||
c.Log.Debug().Str("c", c.Tag).Str("d", "→").Msgf("ᵇ%s", t)
|
||||
return c.wire.Call(t, data)
|
||||
func (c *SocketClient[T, P, X, P2]) Send(t T, data any) ([]byte, error) {
|
||||
c.log.Debug().Str(logger.DirectionField, logger.MarkOut).Msgf("ᵇ%v", t)
|
||||
rq := P2(new(X))
|
||||
rq.SetType(uint8(t))
|
||||
rq.SetPayload(data)
|
||||
return c.rpc.Call(c.sock.conn, rq)
|
||||
}
|
||||
|
||||
// Notify just sends a message and goes further.
|
||||
func (c *SocketClient) Notify(t api.PT, data any) {
|
||||
c.Log.Debug().Str("c", c.Tag).Str("d", "→").Msgf("%s", t)
|
||||
_ = c.wire.Send(t, data)
|
||||
func (c *SocketClient[T, P, X, P2]) Notify(t T, data any) {
|
||||
c.log.Debug().Str(logger.DirectionField, logger.MarkOut).Msgf("%v", t)
|
||||
rq := P2(new(X))
|
||||
rq.SetType(uint8(t))
|
||||
rq.SetPayload(data)
|
||||
if err := c.rpc.Send(c.sock.conn, rq); err != nil {
|
||||
c.log.Error().Err(err).Msgf("notify fail")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SocketClient) Close() {
|
||||
c.wire.Close()
|
||||
c.Log.Debug().Str("c", c.Tag).Str("d", "x").Msg("Close")
|
||||
func (c *SocketClient[_, _, _, _]) Disconnect() {
|
||||
c.sock.conn.Close()
|
||||
c.rpc.Cleanup()
|
||||
c.log.Debug().Str(logger.DirectionField, logger.MarkCross).Msg("Close")
|
||||
}
|
||||
|
||||
func (c *SocketClient) Id() network.Uid { return c.id }
|
||||
func (c *SocketClient) Listen() { c.ProcessMessages(); <-c.Done() }
|
||||
func (c *SocketClient) ProcessMessages() { c.wire.Listen() }
|
||||
func (c *SocketClient) Route(in In, out Out) { _ = c.wire.Route(in, out) }
|
||||
func (c *SocketClient) String() string { return c.Tag + ":" + string(c.Id()) }
|
||||
func (c *SocketClient) Done() chan struct{} { return c.wire.Wait() }
|
||||
func (c *SocketClient[_, _, _, _]) Id() Uid { return c.id }
|
||||
func (c *SocketClient[_, _, _, _]) String() string { return c.Id().String() }
|
||||
|
|
|
|||
177
pkg/com/map.go
177
pkg/com/map.go
|
|
@ -1,98 +1,127 @@
|
|||
package com
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"sync"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
)
|
||||
|
||||
// NetMap defines a thread-safe NetClient list.
|
||||
type NetMap[T NetClient] struct {
|
||||
m map[string]T
|
||||
mu sync.Mutex
|
||||
// Map defines a concurrent-safe map structure.
|
||||
// Keep in mind that the underlying map structure will grow indefinitely.
|
||||
type Map[K comparable, V any] struct {
|
||||
m map[K]V
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ErrNotFound is returned by NetMap when some value is not present.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
func (m *Map[K, _]) Len() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.m)
|
||||
}
|
||||
|
||||
func NewNetMap[T NetClient]() NetMap[T] { return NetMap[T]{m: make(map[string]T, 10)} }
|
||||
func (m *Map[K, _]) Has(key K) bool {
|
||||
m.mu.RLock()
|
||||
_, ok := m.m[key]
|
||||
m.mu.RUnlock()
|
||||
return ok
|
||||
}
|
||||
|
||||
// Add adds a new NetClient value with its id value as the key.
|
||||
func (m *NetMap[T]) Add(client T) { m.Put(string(client.Id()), client) }
|
||||
// Get returns the value and exists flag (standard map comma-ok idiom).
|
||||
func (m *Map[K, V]) Get(key K) (V, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
val, ok := m.m[key]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Put adds a new NetClient value with a custom key value.
|
||||
func (m *NetMap[T]) Put(key string, client T) {
|
||||
func (m *Map[K, V]) Find(key K) V {
|
||||
v, _ := m.Get(key)
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Map[K, V]) String() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return fmt.Sprintf("%v", m.m)
|
||||
}
|
||||
|
||||
// FindBy searches for the first value satisfying the predicate.
|
||||
// Note: This holds a Read Lock during iteration.
|
||||
func (m *Map[K, V]) FindBy(predicate func(v V) bool) (V, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, v := range m.m {
|
||||
if predicate(v) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// Put sets the value and returns true if the key already existed.
|
||||
func (m *Map[K, V]) Put(key K, v V) bool {
|
||||
m.mu.Lock()
|
||||
m.m[key] = client
|
||||
m.mu.Unlock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.m == nil {
|
||||
m.m = make(map[K]V)
|
||||
}
|
||||
|
||||
_, exists := m.m[key]
|
||||
m.m[key] = v
|
||||
return exists
|
||||
}
|
||||
|
||||
// Remove removes NetClient from the map if present.
|
||||
func (m *NetMap[T]) Remove(client T) { m.RemoveByKey(string(client.Id())) }
|
||||
|
||||
// RemoveByKey removes NetClient from the map by a specified key value.
|
||||
func (m *NetMap[T]) RemoveByKey(key string) {
|
||||
func (m *Map[K, V]) Remove(key K) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, key)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveAll removes all occurrences of specified NetClient.
|
||||
func (m *NetMap[T]) RemoveAll(client T) {
|
||||
// Pop returns the value and removes it from the map.
|
||||
// Returns zero value if not found.
|
||||
func (m *Map[K, V]) Pop(key K) V {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for k, c := range m.m {
|
||||
if c.Id() == client.Id() {
|
||||
delete(m.m, k)
|
||||
|
||||
val, ok := m.m[key]
|
||||
if ok {
|
||||
delete(m.m, key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// RemoveL removes the key and returns the new length of the map.
|
||||
func (m *Map[K, _]) RemoveL(key K) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.m, key)
|
||||
return len(m.m)
|
||||
}
|
||||
|
||||
// Clear empties the map.
|
||||
func (m *Map[K, V]) Clear() {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[K]V)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Values returns an iterator for values only.
|
||||
//
|
||||
// Usage: for k, v := range m.Values() { ... }
|
||||
//
|
||||
// Warning: This holds a Read Lock (RLock) during iteration.
|
||||
// Do not call Put/Remove on this map inside the loop (Deadlock).
|
||||
func (m *Map[K, V]) Values() iter.Seq[V] {
|
||||
return func(yield func(V) bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, v := range m.m {
|
||||
if !yield(v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NetMap[T]) IsEmpty() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.m) == 0
|
||||
}
|
||||
|
||||
// List returns the current NetClient map.
|
||||
func (m *NetMap[T]) List() map[string]T { return m.m }
|
||||
|
||||
func (m *NetMap[T]) Has(id network.Uid) bool {
|
||||
_, err := m.Find(string(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Find searches the first NetClient by a specified key value.
|
||||
func (m *NetMap[T]) Find(key string) (client T, err error) {
|
||||
if key == "" {
|
||||
return client, ErrNotFound
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if c, ok := m.m[key]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return client, ErrNotFound
|
||||
}
|
||||
|
||||
// FindBy searches the first NetClient with the provided predicate function.
|
||||
func (m *NetMap[T]) FindBy(fn func(c T) bool) (client T, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, w := range m.m {
|
||||
if fn(w) {
|
||||
return w, nil
|
||||
}
|
||||
}
|
||||
return client, ErrNotFound
|
||||
}
|
||||
|
||||
// ForEach processes every NetClient with the provided callback function.
|
||||
func (m *NetMap[T]) ForEach(fn func(c T)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, w := range m.m {
|
||||
fn(w)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,63 @@
|
|||
package com
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
)
|
||||
func TestMap_Base(t *testing.T) {
|
||||
// map map
|
||||
m := Map[int, int]{m: make(map[int]int)}
|
||||
|
||||
type testClient struct {
|
||||
NetClient
|
||||
id int
|
||||
c int32
|
||||
}
|
||||
|
||||
func (t *testClient) Id() network.Uid { return network.Uid(fmt.Sprintf("%v", t.id)) }
|
||||
func (t *testClient) change(n int) { atomic.AddInt32(&t.c, int32(n)) }
|
||||
|
||||
func TestPointerValue(t *testing.T) {
|
||||
m := NewNetMap[*testClient]()
|
||||
c := testClient{id: 1}
|
||||
m.Add(&c)
|
||||
fc, _ := m.FindBy(func(c *testClient) bool { return c.id == 1 })
|
||||
c.change(100)
|
||||
fc2, _ := m.Find(fc.Id().String())
|
||||
|
||||
expected := c.c == fc.c && c.c == fc2.c
|
||||
if !expected {
|
||||
t.Errorf("not expected change, o: %v != %v != %v", c.c, fc.c, fc2.c)
|
||||
if m.Len() > 0 {
|
||||
t.Errorf("should be empty, %v %v", m.Len(), m.m)
|
||||
}
|
||||
k := 0
|
||||
m.Put(k, 0)
|
||||
if m.Len() == 0 {
|
||||
t.Errorf("should not be empty, %v", m.m)
|
||||
}
|
||||
if !m.Has(k) {
|
||||
t.Errorf("should have the key %v, %v", k, m.m)
|
||||
}
|
||||
v, ok := m.Get(k)
|
||||
if v != 0 && !ok {
|
||||
t.Errorf("should have the key %v and ok, %v %v", k, ok, m.m)
|
||||
}
|
||||
_, ok = m.Get(k + 1)
|
||||
if ok {
|
||||
t.Errorf("should not find anything, %v %v", ok, m.m)
|
||||
}
|
||||
m.Put(1, 1)
|
||||
v, ok = m.FindBy(func(v int) bool { return v == 1 })
|
||||
if v != 1 && !ok {
|
||||
t.Errorf("should have the key %v and ok, %v %v", 1, ok, m.m)
|
||||
}
|
||||
sum := 0
|
||||
for v := range m.Values() {
|
||||
sum += v
|
||||
}
|
||||
if sum != 1 {
|
||||
t.Errorf("shoud have exact sum of 1, but have %v", sum)
|
||||
}
|
||||
m.Remove(1)
|
||||
if !m.Has(0) || m.Len() > 1 {
|
||||
t.Errorf("should remove only one element, but has %v", m.m)
|
||||
}
|
||||
m.Put(3, 3)
|
||||
v = m.Pop(3)
|
||||
if v != 3 {
|
||||
t.Errorf("should have value %v, but has %v %v", 3, v, m.m)
|
||||
}
|
||||
m.Remove(3)
|
||||
m.Remove(0)
|
||||
if m.Len() != 0 {
|
||||
t.Errorf("should be completely empty, but %v", m.m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_Concurrency(t *testing.T) {
|
||||
m := Map[int, int]{m: make(map[int]int)}
|
||||
for i := range 100 {
|
||||
go m.Put(i, i)
|
||||
go m.Has(i)
|
||||
go m.Pop(i)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
263
pkg/com/net.go
263
pkg/com/net.go
|
|
@ -2,186 +2,177 @@ package com
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network/websocket"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/network/websocket"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type Uid struct {
|
||||
xid.ID
|
||||
}
|
||||
|
||||
var NilUid = Uid{xid.NilID()}
|
||||
|
||||
func NewUid() Uid { return Uid{xid.New()} }
|
||||
|
||||
func UidFromString(id string) (Uid, error) {
|
||||
x, err := xid.FromString(id)
|
||||
if err != nil {
|
||||
return NilUid, err
|
||||
}
|
||||
return Uid{x}, nil
|
||||
}
|
||||
|
||||
func (u Uid) Short() string { return u.String()[:3] + "." + u.String()[len(u.String())-3:] }
|
||||
|
||||
type HasCallId interface {
|
||||
SetGetId(fmt.Stringer)
|
||||
}
|
||||
|
||||
type Writer interface {
|
||||
Write([]byte)
|
||||
}
|
||||
|
||||
type Packet[T ~uint8] interface {
|
||||
GetId() Uid
|
||||
GetType() T
|
||||
GetPayload() []byte
|
||||
}
|
||||
|
||||
type Packet2[T any] interface {
|
||||
SetId(string)
|
||||
SetType(uint8)
|
||||
SetPayload(any)
|
||||
SetGetId(fmt.Stringer)
|
||||
GetPayload() any
|
||||
*T // non-interface type constraint element
|
||||
}
|
||||
|
||||
type Transport interface {
|
||||
SetMessageHandler(func([]byte, error))
|
||||
}
|
||||
|
||||
type RPC[T ~uint8, P Packet[T]] struct {
|
||||
CallTimeout time.Duration
|
||||
Handler func(P)
|
||||
Transport Transport
|
||||
|
||||
calls Map[Uid, *request]
|
||||
}
|
||||
|
||||
type request struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
response []byte
|
||||
}
|
||||
|
||||
const DefaultCallTimeout = 10 * time.Second
|
||||
|
||||
var errCanceled = errors.New("canceled")
|
||||
var errTimeout = errors.New("timeout")
|
||||
|
||||
type (
|
||||
Connector struct {
|
||||
tag string
|
||||
wu *websocket.Upgrader
|
||||
}
|
||||
Client struct {
|
||||
conn *websocket.WS
|
||||
queue map[network.Uid]*call
|
||||
onPacket func(packet In)
|
||||
mu sync.Mutex
|
||||
websocket.Client
|
||||
}
|
||||
call struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
Response In
|
||||
Server struct {
|
||||
websocket.Server
|
||||
}
|
||||
Connection struct {
|
||||
conn *websocket.Connection
|
||||
}
|
||||
Option = func(c *Connector)
|
||||
)
|
||||
|
||||
var (
|
||||
errConnClosed = errors.New("connection closed")
|
||||
errTimeout = errors.New("timeout")
|
||||
)
|
||||
var outPool = sync.Pool{New: func() any { o := Out{}; return &o }}
|
||||
func (c *Client) Connect(addr url.URL) (*Connection, error) { return connect(c.Client.Connect(addr)) }
|
||||
|
||||
func WithOrigin(url string) Option { return func(c *Connector) { c.wu = websocket.NewUpgrader(url) } }
|
||||
func WithTag(tag string) Option { return func(c *Connector) { c.tag = tag } }
|
||||
func (s *Server) Origin(host string) { s.Upgrader = websocket.NewUpgrader(host) }
|
||||
|
||||
const callTimeout = 5 * time.Second
|
||||
|
||||
func NewConnector(opts ...Option) *Connector {
|
||||
c := &Connector{}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
if c.wu == nil {
|
||||
c.wu = &websocket.DefaultUpgrader
|
||||
}
|
||||
return c
|
||||
func (s *Server) Connect(w http.ResponseWriter, r *http.Request) (*Connection, error) {
|
||||
return connect(s.Server.Connect(w, r, nil))
|
||||
}
|
||||
|
||||
func (co *Connector) NewClientServer(w http.ResponseWriter, r *http.Request, log *logger.Logger) (*SocketClient, error) {
|
||||
ws, err := co.wu.Upgrade(w, r, nil)
|
||||
func (c *Connection) IsServer() bool { return c.conn.IsServer() }
|
||||
|
||||
func (c *Connection) SetMaxReadSize(s int64) { c.conn.SetMaxMessageSize(s) }
|
||||
|
||||
func connect(conn *websocket.Connection, err error) (*Connection, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := connect(websocket.NewServerWithConn(ws, log))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := New(conn, co.tag, network.NewUid(), log)
|
||||
return &c, nil
|
||||
return &Connection{conn: conn}, nil
|
||||
}
|
||||
|
||||
func (co *Connector) NewClient(address url.URL, log *logger.Logger) (*Client, error) {
|
||||
return connect(websocket.NewClient(address, log))
|
||||
func NewRPC[T ~uint8, P Packet[T]]() *RPC[T, P] {
|
||||
return &RPC[T, P]{calls: Map[Uid, *request]{m: make(map[Uid]*request, 10)}}
|
||||
}
|
||||
|
||||
func connect(conn *websocket.WS, err error) (*Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &Client{conn: conn, queue: make(map[network.Uid]*call, 1)}
|
||||
client.conn.OnMessage = client.handleMessage
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) IsServer() bool { return c.conn.IsServer() }
|
||||
|
||||
func (c *Client) OnPacket(fn func(packet In)) { c.mu.Lock(); c.onPacket = fn; c.mu.Unlock() }
|
||||
|
||||
func (c *Client) Listen() { c.mu.Lock(); c.conn.Listen(); c.mu.Unlock() }
|
||||
|
||||
func (c *Client) Close() {
|
||||
// !to handle error
|
||||
c.conn.Close()
|
||||
c.drain(errConnClosed)
|
||||
}
|
||||
|
||||
func (c *Client) Call(type_ api.PT, payload any) ([]byte, error) {
|
||||
// !to expose channel instead of results
|
||||
rq := outPool.Get().(*Out)
|
||||
rq.Id, rq.T, rq.Payload = network.NewUid(), type_, payload
|
||||
r, err := json.Marshal(rq)
|
||||
outPool.Put(rq)
|
||||
if err != nil {
|
||||
//delete(c.queue, id)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
task := &call{done: make(chan struct{})}
|
||||
c.mu.Lock()
|
||||
c.queue[rq.Id] = task
|
||||
c.conn.Write(r)
|
||||
c.mu.Unlock()
|
||||
select {
|
||||
case <-task.done:
|
||||
case <-time.After(callTimeout):
|
||||
task.err = errTimeout
|
||||
}
|
||||
return task.Response.Payload, task.err
|
||||
}
|
||||
|
||||
func (c *Client) Send(type_ api.PT, pl any) error {
|
||||
rq := outPool.Get().(*Out)
|
||||
rq.Id, rq.T, rq.Payload = "", type_, pl
|
||||
defer outPool.Put(rq)
|
||||
return c.SendPacket(rq)
|
||||
}
|
||||
|
||||
func (c *Client) Route(p In, pl Out) error {
|
||||
rq := outPool.Get().(*Out)
|
||||
rq.Id, rq.T, rq.Payload = p.Id, p.T, pl.Payload
|
||||
defer outPool.Put(rq)
|
||||
return c.SendPacket(rq)
|
||||
}
|
||||
|
||||
func (c *Client) SendPacket(packet *Out) error {
|
||||
func (t *RPC[_, _]) Send(w Writer, packet any) error {
|
||||
r, err := json.Marshal(packet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.conn.Write(r)
|
||||
c.mu.Unlock()
|
||||
w.Write(r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Wait() chan struct{} { return c.conn.Done }
|
||||
func (t *RPC[_, _]) Call(w Writer, rq HasCallId) ([]byte, error) {
|
||||
id := NewUid()
|
||||
// set new request id for the external request structure as string
|
||||
rq.SetGetId(id)
|
||||
|
||||
func (c *Client) handleMessage(message []byte, err error) {
|
||||
r, err := json.Marshal(rq)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res In
|
||||
if err = json.Unmarshal(message, &res); err != nil {
|
||||
return
|
||||
task := &request{done: make(chan struct{})}
|
||||
t.calls.Put(id, task)
|
||||
w.Write(r)
|
||||
select {
|
||||
case <-task.done:
|
||||
case <-time.After(t.callTimeout()):
|
||||
task.err = errTimeout
|
||||
}
|
||||
return task.response, task.err
|
||||
}
|
||||
|
||||
// empty id implies that we won't track (wait) the response
|
||||
if !res.Id.Empty() {
|
||||
if task := c.pop(res.Id); task != nil {
|
||||
task.Response = res
|
||||
close(task.done)
|
||||
return
|
||||
func (t *RPC[_, P]) handleMessage(message []byte) error {
|
||||
res := *new(P)
|
||||
if err := json.Unmarshal(message, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
// if we have an id, then unblock blocking call with that id
|
||||
id := res.GetId()
|
||||
if id != NilUid {
|
||||
if blocked := t.calls.Pop(id); blocked != nil {
|
||||
blocked.response = res.GetPayload()
|
||||
close(blocked.done)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.onPacket(res)
|
||||
if t.Handler != nil {
|
||||
t.Handler(res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pop extracts and removes a task from the queue by its id.
|
||||
func (c *Client) pop(id network.Uid) *call {
|
||||
c.mu.Lock()
|
||||
task := c.queue[id]
|
||||
delete(c.queue, id)
|
||||
c.mu.Unlock()
|
||||
return task
|
||||
func (t *RPC[_, _]) callTimeout() time.Duration {
|
||||
if t.CallTimeout > 0 {
|
||||
return t.CallTimeout
|
||||
}
|
||||
return DefaultCallTimeout
|
||||
}
|
||||
|
||||
// drain cancels all what's left in the task queue.
|
||||
func (c *Client) drain(err error) {
|
||||
c.mu.Lock()
|
||||
for _, task := range c.queue {
|
||||
func (t *RPC[_, _]) Cleanup() {
|
||||
// drain cancels all what's left in the task queue.
|
||||
for task := range t.calls.Values() {
|
||||
if task.err == nil {
|
||||
task.err = err
|
||||
task.err = errCanceled
|
||||
}
|
||||
close(task.done)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,41 @@ package com
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network/websocket"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/network/websocket"
|
||||
)
|
||||
|
||||
var log = logger.Default()
|
||||
|
||||
func TestPackets(t *testing.T) {
|
||||
r, err := json.Marshal(Out{Payload: "asd"})
|
||||
if err != nil {
|
||||
t.Fatalf("can't marshal packet")
|
||||
}
|
||||
|
||||
t.Logf("PACKET: %v", string(r))
|
||||
type TestIn struct {
|
||||
Id Uid
|
||||
T uint8
|
||||
Payload json.RawMessage
|
||||
}
|
||||
|
||||
func (i TestIn) GetId() Uid { return i.Id }
|
||||
func (i TestIn) GetType() uint8 { return i.T }
|
||||
func (i TestIn) GetPayload() []byte { return i.Payload }
|
||||
|
||||
type TestOut struct {
|
||||
Id string
|
||||
T uint8
|
||||
Payload any
|
||||
}
|
||||
|
||||
func (o *TestOut) SetId(s string) { o.Id = s }
|
||||
func (o *TestOut) SetType(u uint8) { o.T = u }
|
||||
func (o *TestOut) SetPayload(a any) { o.Payload = a }
|
||||
func (o *TestOut) SetGetId(stringer fmt.Stringer) { o.Id = stringer.String() }
|
||||
func (o *TestOut) GetPayload() any { return o.Payload }
|
||||
|
||||
func TestWebsocket(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
|
|
@ -38,108 +50,100 @@ func TestWebsocket(t *testing.T) {
|
|||
}
|
||||
|
||||
func testWebsocket(t *testing.T) {
|
||||
// setup
|
||||
// socket handler
|
||||
var socket *websocket.WS
|
||||
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.DefaultUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("no socket, %v", err)
|
||||
}
|
||||
sock, err := websocket.NewServerWithConn(conn, log)
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't init socket server")
|
||||
}
|
||||
socket = sock
|
||||
socket.OnMessage = func(message []byte, err error) {
|
||||
// echo response
|
||||
socket.Write(message)
|
||||
}
|
||||
socket.Listen()
|
||||
})
|
||||
// http handler
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
wg.Done()
|
||||
if err := http.ListenAndServe(":8080", nil); err != nil {
|
||||
t.Errorf("no server")
|
||||
return
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
port, err := getFreePort()
|
||||
if err != nil {
|
||||
t.Logf("couldn't get any free port")
|
||||
t.Skip()
|
||||
}
|
||||
addr := fmt.Sprintf(":%v", port)
|
||||
|
||||
client := newClient(t, url.URL{Scheme: "ws", Host: "localhost:8080", Path: "/ws"})
|
||||
client.Listen()
|
||||
server := newServer(addr, t)
|
||||
client := newClient(t, url.URL{Scheme: "ws", Host: "localhost" + addr, Path: "/ws"})
|
||||
clDone := client.ProcessPackets(func(in TestIn) error { return nil })
|
||||
|
||||
if server.conn == nil {
|
||||
t.Fatalf("couldn't make new socket")
|
||||
}
|
||||
|
||||
calls := []struct {
|
||||
typ api.PT
|
||||
payload any
|
||||
packet TestOut
|
||||
concurrent bool
|
||||
value any
|
||||
}{
|
||||
{typ: 10, payload: "test", value: "test", concurrent: true},
|
||||
{typ: 10, payload: "test2", value: "test2"},
|
||||
{typ: 11, payload: "test3", value: "test3"},
|
||||
{typ: 99, payload: "", value: ""},
|
||||
{typ: 0},
|
||||
{typ: 12, payload: 123, value: 123},
|
||||
{typ: 10, payload: false, value: false},
|
||||
{typ: 10, payload: true, value: true},
|
||||
{typ: 11, payload: []string{"test", "test", "test"}, value: []string{"test", "test", "test"}},
|
||||
{typ: 22, payload: []string{}, value: []string{}},
|
||||
{packet: TestOut{T: 10, Payload: "test"}, value: "test", concurrent: true},
|
||||
{packet: TestOut{T: 10, Payload: "test2"}, value: "test2"},
|
||||
{packet: TestOut{T: 11, Payload: "test3"}, value: "test3"},
|
||||
{packet: TestOut{T: 99, Payload: ""}, value: ""},
|
||||
{packet: TestOut{T: 0}},
|
||||
{packet: TestOut{T: 12, Payload: 123}, value: 123},
|
||||
{packet: TestOut{T: 10, Payload: false}, value: false},
|
||||
{packet: TestOut{T: 10, Payload: true}, value: true},
|
||||
{packet: TestOut{T: 11, Payload: []string{"test", "test", "test"}}, value: []string{"test", "test", "test"}},
|
||||
{packet: TestOut{T: 22, Payload: []string{}}, value: []string{}},
|
||||
}
|
||||
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
n := 42 * 2 * 2
|
||||
const n = 42
|
||||
var wait sync.WaitGroup
|
||||
wait.Add(n * len(calls))
|
||||
|
||||
// test
|
||||
for _, call := range calls {
|
||||
for i := 0; i < n; i++ {
|
||||
if call.concurrent {
|
||||
call := call
|
||||
if call.concurrent {
|
||||
for range n {
|
||||
packet := call.packet
|
||||
go func() {
|
||||
w := rand.Intn(600-100) + 100
|
||||
time.Sleep(time.Duration(w) * time.Millisecond)
|
||||
vv, err := client.Call(call.typ, call.payload)
|
||||
checkCall(t, vv, err, call.value)
|
||||
wait.Done()
|
||||
defer wait.Done()
|
||||
time.Sleep(time.Duration(rand.IntN(200-100)+100) * time.Millisecond)
|
||||
vv, err := client.rpc.Call(client.sock.conn, &packet)
|
||||
err = checkCall(vv, err, call.value)
|
||||
if err != nil {
|
||||
t.Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
vv, err := client.Call(call.typ, call.payload)
|
||||
checkCall(t, vv, err, call.value)
|
||||
wait.Done()
|
||||
}
|
||||
} else {
|
||||
for range n {
|
||||
packet := call.packet
|
||||
vv, err := client.rpc.Call(client.sock.conn, &packet)
|
||||
err = checkCall(vv, err, call.value)
|
||||
if err != nil {
|
||||
wait.Done()
|
||||
t.Fatalf("%v", err)
|
||||
} else {
|
||||
wait.Done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wait.Wait()
|
||||
|
||||
client.Close()
|
||||
|
||||
<-socket.Done
|
||||
<-client.conn.Done
|
||||
client.sock.conn.Close()
|
||||
client.rpc.Cleanup()
|
||||
<-clDone
|
||||
server.conn.Close()
|
||||
<-server.done
|
||||
}
|
||||
|
||||
func newClient(t *testing.T, addr url.URL) *Client {
|
||||
conn, err := NewConnector().NewClient(addr, log)
|
||||
func newClient(t *testing.T, addr url.URL) *SocketClient[uint8, TestIn, TestOut, *TestOut] {
|
||||
connector := Client{}
|
||||
conn, err := connector.Connect(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("error: couldn't connect to %v because of %v", addr.String(), err)
|
||||
}
|
||||
return conn
|
||||
rpc := new(RPC[uint8, TestIn])
|
||||
rpc.calls = Map[Uid, *request]{m: make(map[Uid]*request, 10)}
|
||||
return &SocketClient[uint8, TestIn, TestOut, *TestOut]{sock: conn, log: logger.Default(), rpc: rpc}
|
||||
}
|
||||
|
||||
func checkCall(t *testing.T, v []byte, err error, need any) {
|
||||
func checkCall(v []byte, err error, need any) error {
|
||||
if err != nil {
|
||||
t.Fatalf("should be no error but %v", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
var value any
|
||||
if v != nil {
|
||||
if err = json.Unmarshal(v, &value); err != nil {
|
||||
t.Fatalf("can't unmarshal %v", v)
|
||||
return fmt.Errorf("can't unmarshal %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +156,8 @@ func checkCall(t *testing.T, v []byte, err error, need any) {
|
|||
nice = value == need.(bool)
|
||||
case float64:
|
||||
nice = value == float64(need.(int))
|
||||
case string:
|
||||
nice = value == need.(string)
|
||||
case []any:
|
||||
// let's assume that's strings
|
||||
vv := value.([]any)
|
||||
|
|
@ -166,6 +172,54 @@ func checkCall(t *testing.T, v []byte, err error, need any) {
|
|||
}
|
||||
|
||||
if !nice {
|
||||
t.Fatalf("expected %v is not expected %v", need, v)
|
||||
return fmt.Errorf("expected %v, but got %v", need, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type serverHandler struct {
|
||||
conn *websocket.Connection // ws server reference made dynamically on HTTP request
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (s *serverHandler) serve(t *testing.T) func(w http.ResponseWriter, r *http.Request) {
|
||||
connector := Server{}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sock, err := connector.Server.Connect(w, r, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("couldn't init socket server")
|
||||
}
|
||||
s.conn = sock
|
||||
s.conn.SetMessageHandler(func(m []byte, err error) { s.conn.Write(m) }) // echo
|
||||
s.done = s.conn.Listen()
|
||||
}
|
||||
}
|
||||
|
||||
func newServer(addr string, t *testing.T) *serverHandler {
|
||||
var wg sync.WaitGroup
|
||||
handler := serverHandler{}
|
||||
http.HandleFunc("/ws", handler.serve(t))
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
wg.Done()
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
t.Errorf("no server, %v", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
return &handler
|
||||
}
|
||||
|
||||
func getFreePort() (port int, err error) {
|
||||
var a *net.TCPAddr
|
||||
var l *net.TCPListener
|
||||
if a, err = net.ResolveTCPAddr("tcp", ":0"); err == nil {
|
||||
if l, err = net.ListenTCP("tcp", a); err == nil {
|
||||
defer func() { _ = l.Close() }()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
446
pkg/config/config.yaml
Normal file
446
pkg/config/config.yaml
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
# The main config file
|
||||
|
||||
# Note.
|
||||
# Be aware that when this configuration is being overwritten
|
||||
# by another configuration, any empty nested part
|
||||
# in the further configurations will reset (empty out) all the values.
|
||||
# For example:
|
||||
# the main config second config result
|
||||
# ... ... ...
|
||||
# list: list: list:
|
||||
# gba: gba: gba:
|
||||
# lib: mgba_libretro lib: ""
|
||||
# roms: [ "gba", "gbc" ] roms: []
|
||||
# ... ...
|
||||
#
|
||||
# So do not leave empty nested keys.
|
||||
|
||||
# for the compatibility purposes
|
||||
version: 3
|
||||
|
||||
# new decentralized library of games
|
||||
library:
|
||||
# optional alias file for overriding game names from the basePath path
|
||||
aliasFile: alias.txt
|
||||
# root folder for the library (where games are stored)
|
||||
basePath: assets/games
|
||||
# a list of ignored words in the ROM filenames
|
||||
ignored:
|
||||
- neogeo
|
||||
- pgm
|
||||
# DOSBox filesystem state
|
||||
- .pure
|
||||
# an explicit list of supported file extensions
|
||||
# which overrides Libretro emulator ROMs configs
|
||||
supported:
|
||||
# print some additional info
|
||||
verbose: true
|
||||
# enable library directory live reload
|
||||
# (experimental)
|
||||
watchMode: false
|
||||
|
||||
coordinator:
|
||||
# debugging switch
|
||||
# - shows debug logs
|
||||
# - allows selecting worker instances
|
||||
debug: false
|
||||
# selects free workers:
|
||||
# - empty value (default, any free)
|
||||
# - ping (with the lowest ping)
|
||||
selector:
|
||||
monitoring:
|
||||
port: 6601
|
||||
# enable Go profiler HTTP server
|
||||
profilingEnabled: false
|
||||
metricEnabled: false
|
||||
urlPrefix: /coordinator
|
||||
# a custom Origins for incoming Websocket connections:
|
||||
# "" -- checks same origin policy
|
||||
# "*" -- allows all
|
||||
# "your address" -- checks for that address
|
||||
origin:
|
||||
userWs:
|
||||
workerWs:
|
||||
# max websocket message size in bytes
|
||||
maxWsSize: 32000000
|
||||
# HTTP(S) server config
|
||||
server:
|
||||
address: :8000
|
||||
cacheControl: "max-age=259200, must-revalidate"
|
||||
frameOptions: ""
|
||||
https: false
|
||||
# Letsencrypt or self cert config
|
||||
tls:
|
||||
address: :443
|
||||
# allowed host name
|
||||
domain:
|
||||
# if both are set then will use certs
|
||||
# and Letsencryt instead
|
||||
httpsCert:
|
||||
httpsKey:
|
||||
analytics:
|
||||
inject: false
|
||||
gtag:
|
||||
|
||||
worker:
|
||||
# show more logs
|
||||
debug: false
|
||||
library:
|
||||
# root folder for the library (where games are stored)
|
||||
basePath: assets/games
|
||||
network:
|
||||
# a coordinator address to connect to
|
||||
coordinatorAddress: localhost:8000
|
||||
# where to connect
|
||||
endpoint: /wso
|
||||
# ping endpoint
|
||||
pingEndpoint: /echo
|
||||
# set public ping address (IP or hostname)
|
||||
publicAddress:
|
||||
# make coordinator connection secure (wss)
|
||||
secure: false
|
||||
# ISO Alpha-2 country code to group workers by zones
|
||||
zone:
|
||||
monitoring:
|
||||
# monitoring server port
|
||||
port: 6602
|
||||
profilingEnabled: false
|
||||
# monitoring server URL prefix
|
||||
metricEnabled: false
|
||||
urlPrefix: /worker
|
||||
server:
|
||||
address: :9000
|
||||
https: false
|
||||
tls:
|
||||
address: :444
|
||||
# LetsEncrypt config
|
||||
# allowed host name
|
||||
domain:
|
||||
# Own certs config
|
||||
httpsCert:
|
||||
httpsKey:
|
||||
# optional server tag
|
||||
tag:
|
||||
|
||||
emulator:
|
||||
# set the total number of threads for the image processing
|
||||
# (removed)
|
||||
threads: 0
|
||||
|
||||
# enable autosave for emulator states if set to a non-zero value of seconds
|
||||
autosaveSec: 0
|
||||
|
||||
# save directory for emulator states
|
||||
# special tag {user} will be replaced with current user's home dir
|
||||
storage: "{user}/.cr/save"
|
||||
|
||||
# path for storing emulator generated files
|
||||
localPath: "./libretro"
|
||||
|
||||
# checks if the system supports running an emulator at startup
|
||||
failFast: true
|
||||
|
||||
# do not send late video frames
|
||||
skipLateFrames: false
|
||||
|
||||
# log dropped frames (temp)
|
||||
logDroppedFrames: false
|
||||
|
||||
libretro:
|
||||
# use zip compression for emulator save states
|
||||
saveCompression: true
|
||||
# Sets a limiter function for some spammy core callbacks.
|
||||
# 0 - disabled, otherwise -- time in milliseconds for ignoring repeated calls except the last.
|
||||
debounceMs: 0
|
||||
# Allow duplicate frames
|
||||
dup: true
|
||||
# Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX
|
||||
logLevel: 1
|
||||
cores:
|
||||
paths:
|
||||
libs: assets/cores
|
||||
# Config params for Libretro cores repository,
|
||||
# available types are:
|
||||
# - buildbot (the default Libretro nightly repository)
|
||||
# - github (GitHub raw repository with a similar structure to buildbot)
|
||||
# - raw (just a link to a zip file extracted as is)
|
||||
repo:
|
||||
# enable auto-download for the list of cores (list->lib)
|
||||
sync: true
|
||||
# external cross-process mutex lock
|
||||
extLock: "{user}/.cr/cloud-game.lock"
|
||||
map:
|
||||
darwin:
|
||||
amd64:
|
||||
arch: x86_64
|
||||
ext: .dylib
|
||||
os: osx
|
||||
vendor: apple
|
||||
arm64:
|
||||
arch: arm64
|
||||
ext: .dylib
|
||||
os: osx
|
||||
vendor: apple
|
||||
linux:
|
||||
amd64:
|
||||
arch: x86_64
|
||||
ext: .so
|
||||
os: linux
|
||||
arm:
|
||||
arch: armv7-neon-hf
|
||||
ext: .so
|
||||
os: linux
|
||||
windows:
|
||||
amd64:
|
||||
arch: x86_64
|
||||
ext: .dll
|
||||
os: windows
|
||||
main:
|
||||
type: buildbot
|
||||
url: https://buildbot.libretro.com/nightly
|
||||
# if repo has file compression
|
||||
compression: zip
|
||||
# a secondary repo to use i.e. for not found in the main cores
|
||||
secondary:
|
||||
type: github
|
||||
url: https://github.com/sergystepanov/libretro-spiegel/raw/main
|
||||
compression: zip
|
||||
# Libretro core configuration
|
||||
#
|
||||
# The emulator selection will happen in this order:
|
||||
# - based on the folder name in the folder param
|
||||
# - based on the folder name (core name) in the list (i.e. nes, snes)
|
||||
# - based on the rom names in the roms param
|
||||
#
|
||||
# Available config params:
|
||||
# - altRepo (bool) prioritize secondary repo as the download source
|
||||
# - lib (string)
|
||||
# - roms ([]string)
|
||||
# - scale (int) scales the output video frames by this factor.
|
||||
# - folder (string)
|
||||
# By default emulator selection is based on the folder named as cores
|
||||
# in the list (i.e. nes, snes) but if you specify folder param,
|
||||
# then it will try to load the ROM file from that folder first.
|
||||
# - width (int) -- broken
|
||||
# - height (int) -- broken
|
||||
# - ratio (float)
|
||||
# - isGlAllowed (bool)
|
||||
# - usesLibCo (bool)
|
||||
# - hasMultitap (bool) -- (removed)
|
||||
# - coreAspectRatio (bool) -- (deprecated) correct the aspect ratio on the client with the info from the core.
|
||||
# - hid (map[int][]int)
|
||||
# A list of device IDs to bind to the input ports.
|
||||
# Can be seen in human readable form in the console when worker.debug is enabled.
|
||||
# Some cores allow binding multiple devices to a single port (DosBox), but typically,
|
||||
# you should bind just one device to one port.
|
||||
# - kbMouseSupport (bool) -- (temp) a flag if the core needs the keyboard and mouse on the client
|
||||
# - nonBlockingSave (bool) -- write save file in a non-blocking way, needed for huge save files
|
||||
# - vfr (bool)
|
||||
# (experimental)
|
||||
# Enable variable frame rate only for cores that can't produce a constant frame rate.
|
||||
# By default, we assume that cores output frames at a constant rate which equals
|
||||
# their tick rate (1/system FPS), but OpenGL cores like N64 may have significant
|
||||
# frame rendering time inconsistencies. In general, VFR for CFR cores leads to
|
||||
# noticeable video stutter (with the current frame rendering time calculations).
|
||||
# - options ([]string) a list of Libretro core options for tweaking.
|
||||
# All keys of the options should be in the double quotes in order to preserve upper-case symbols.
|
||||
# - options4rom (rom[[]string])
|
||||
# A list of core options to override for a specific core depending on the current ROM name.
|
||||
# - hacks ([]string) a list of hacks.
|
||||
# Available:
|
||||
# - skip_hw_context_destroy -- don't destroy OpenGL context during Libretro core deinit.
|
||||
# May help with crashes, for example, with PPSSPP.
|
||||
# - skip_same_thread_save -- skip thread lock save (used with PPSSPP).
|
||||
# - uniqueSaveDir (bool) -- needed only for cores (like DosBox) that persist their state into one shared file.
|
||||
# This will allow for concurrent reading and saving of current states.
|
||||
# - saveStateFs (string) -- the name of the file that will be initially copied into the save folder.
|
||||
# All * symbols will be replaced to the name of the ROM.
|
||||
list:
|
||||
gba:
|
||||
lib: mgba_libretro
|
||||
roms: [ "gba", "gbc" ]
|
||||
options:
|
||||
mgba_audio_low_pass_filter: disabled
|
||||
mgba_audio_low_pass_range: 50
|
||||
pcsx:
|
||||
lib: pcsx_rearmed_libretro
|
||||
roms: [ "cue", "chd" ]
|
||||
# example of folder override
|
||||
folder: psx
|
||||
# see: https://github.com/libretro/pcsx_rearmed/blob/master/frontend/libretro_core_options.h
|
||||
options:
|
||||
"pcsx_rearmed_show_bios_bootlogo": enabled
|
||||
"pcsx_rearmed_drc": enabled
|
||||
"pcsx_rearmed_display_internal_fps": disabled
|
||||
# MAME core requires additional manual setup, please read:
|
||||
# https://docs.libretro.com/library/fbneo/
|
||||
mame:
|
||||
lib: fbneo_libretro
|
||||
folder: mame
|
||||
roms: [ "zip" ]
|
||||
nes:
|
||||
lib: nestopia_libretro
|
||||
roms: [ "nes" ]
|
||||
options:
|
||||
nestopia_aspect: "uncorrected"
|
||||
snes:
|
||||
lib: snes9x_libretro
|
||||
roms: [ "smc", "sfc", "swc", "fig", "bs" ]
|
||||
hid:
|
||||
# set the 2nd port to RETRO_DEVICE_JOYPAD_MULTITAP ((1<<8) | 1) as SNES9x requires it
|
||||
# in order to support up to 5-player games
|
||||
# see: https://nintendo.fandom.com/wiki/Super_Multitap
|
||||
1: 257
|
||||
n64:
|
||||
lib: mupen64plus_next_libretro
|
||||
roms: [ "n64", "v64", "z64" ]
|
||||
isGlAllowed: true
|
||||
usesLibCo: true
|
||||
vfr: true
|
||||
# see: https://github.com/libretro/mupen64plus-libretro-nx/blob/master/libretro/libretro_core_options.h
|
||||
options:
|
||||
"mupen64plus-169screensize": 640x360
|
||||
"mupen64plus-43screensize": 320x240
|
||||
"mupen64plus-EnableCopyColorToRDRAM": Off
|
||||
"mupen64plus-EnableCopyDepthToRDRAM": Off
|
||||
"mupen64plus-EnableEnhancedTextureStorage": True
|
||||
"mupen64plus-EnableFBEmulation": True
|
||||
"mupen64plus-EnableLegacyBlending": True
|
||||
"mupen64plus-FrameDuping": True
|
||||
"mupen64plus-MaxTxCacheSize": 8000
|
||||
"mupen64plus-ThreadedRenderer": False
|
||||
"mupen64plus-cpucore": dynamic_recompiler
|
||||
"mupen64plus-pak1": memory
|
||||
"mupen64plus-rdp-plugin": gliden64
|
||||
"mupen64plus-rsp-plugin": hle
|
||||
"mupen64plus-astick-sensitivity": 100
|
||||
dos:
|
||||
lib: dosbox_pure_libretro
|
||||
roms: [ "zip", "cue" ]
|
||||
folder: dos
|
||||
kbMouseSupport: true
|
||||
nonBlockingSave: true
|
||||
saveStateFs: "*.pure.zip"
|
||||
hid:
|
||||
0: [ 257, 513 ]
|
||||
1: [ 257, 513 ]
|
||||
2: [ 257, 513 ]
|
||||
3: [ 257, 513 ]
|
||||
options:
|
||||
"dosbox_pure_conf": "outside"
|
||||
"dosbox_pure_force60fps": "true"
|
||||
|
||||
encoder:
|
||||
audio:
|
||||
# audio frame duration needed for WebRTC (Opus)
|
||||
# most of the emulators have ~1400 samples per a video frame,
|
||||
# so we keep the frame buffer roughly half of that size or 2 RTC packets per frame
|
||||
# (deprecated) due to frames
|
||||
frame: 10
|
||||
# dynamic frames for Opus encoder
|
||||
frames:
|
||||
- 10
|
||||
- 5
|
||||
# speex (2), linear (1) or nearest neighbour (0) audio resampler
|
||||
# linear should sound slightly better than 0
|
||||
resampler: 2
|
||||
video:
|
||||
# h264, vpx (vp8) or vp9
|
||||
codec: h264
|
||||
# Threaded encoder if supported, 0 - auto, 1 - nope, >1 - multi-threaded
|
||||
threads: 0
|
||||
# see: https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||
h264:
|
||||
# crf, cbr
|
||||
mode: crf
|
||||
# Constant Rate Factor (CRF) 0-51 (default: 23)
|
||||
crf: 23
|
||||
# Rate control options
|
||||
# set the maximum bitrate
|
||||
maxRate: 0
|
||||
# set the expected client buffer size
|
||||
bufSize: 0
|
||||
# ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
|
||||
preset: superfast
|
||||
# baseline, main, high, high10, high422, high444
|
||||
profile: baseline
|
||||
# film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency
|
||||
tune: zerolatency
|
||||
# 0-3
|
||||
logLevel: 0
|
||||
# see: https://www.webmproject.org/docs/encoder-parameters
|
||||
vpx:
|
||||
# target bitrate (KBit/s)
|
||||
bitrate: 1200
|
||||
# force keyframe interval
|
||||
keyframeInterval: 5
|
||||
|
||||
# game recording
|
||||
# (experimental)
|
||||
# recording allows export RAW a/v streams of games
|
||||
# by default, it will export audio as WAV files,
|
||||
# video as a list of PNG-encoded images, and
|
||||
# one additional FFMPEG concat demux file
|
||||
recording:
|
||||
enabled: false
|
||||
# name contains the name of the recording dir (or zip)
|
||||
# format:
|
||||
# %date:go_time_format% -- refer: https://go.dev/src/time/format.go
|
||||
# %user% -- user name who started the recording
|
||||
# %game% -- game name (game ROM name)
|
||||
# %rand:len% -- a random string of given length
|
||||
# as example: 20210101101010_yeE_user1_badApple
|
||||
name: "%date:20060102150405%_%rand:3%_%user%_%game%"
|
||||
# zip and remove recording dir on completion
|
||||
zip: true
|
||||
# save directory
|
||||
folder: ./recording
|
||||
|
||||
# cloud storage options
|
||||
# it is mandatory to use a cloud storage when running
|
||||
# a distributed multi-server configuration in order to
|
||||
# share save states between nodes (resume games on a different worker)
|
||||
storage:
|
||||
# cloud storage provider:
|
||||
# - empty (No op storage stub)
|
||||
# - s3 (S3 API compatible object storage)
|
||||
provider:
|
||||
s3Endpoint:
|
||||
s3BucketName:
|
||||
s3AccessKeyId:
|
||||
s3SecretAccessKey:
|
||||
|
||||
webrtc:
|
||||
# turn off default Pion interceptors (see: https://github.com/pion/interceptor)
|
||||
# (performance)
|
||||
disableDefaultInterceptors: false
|
||||
# indicates the role of the DTLS transport (see: https://github.com/pion/webrtc/blob/master/dtlsrole.go)
|
||||
# (debug)
|
||||
# - (default)
|
||||
# - 1 (auto)
|
||||
# - 2 (client)
|
||||
# - 3 (server)
|
||||
dtlsRole:
|
||||
# a list of STUN/TURN servers to use
|
||||
iceServers:
|
||||
- urls: stun:stun.l.google.com:19302
|
||||
# configures whether the ice agent should be a lite agent (true/false)
|
||||
# (performance)
|
||||
# don't use iceServers when enabled
|
||||
iceLite: false
|
||||
# ICE configuration
|
||||
# by default, ICE ports are random and unlimited
|
||||
# alternatives:
|
||||
# 1. instead of random unlimited port range for
|
||||
# WebRTC connections, these params limit port range of ICE connections
|
||||
icePorts:
|
||||
min:
|
||||
max:
|
||||
# 2. select a single port to forward all ICE connections there
|
||||
singlePort:
|
||||
# override ICE candidate IP, see: https://github.com/pion/webrtc/issues/835,
|
||||
# can be used for Docker bridged network internal IP override
|
||||
iceIpMap:
|
||||
# set additional log level for WebRTC separately
|
||||
# -1 - trace, 6 - nothing, ..., debug - 0
|
||||
logLevel: 6
|
||||
52
pkg/config/coordinator.go
Normal file
52
pkg/config/coordinator.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package config
|
||||
|
||||
import "flag"
|
||||
|
||||
type CoordinatorConfig struct {
|
||||
Coordinator Coordinator
|
||||
Emulator Emulator
|
||||
Library Library
|
||||
Recording Recording
|
||||
Version Version
|
||||
Webrtc Webrtc
|
||||
}
|
||||
|
||||
type Coordinator struct {
|
||||
Analytics Analytics
|
||||
Debug bool
|
||||
Library Library
|
||||
MaxWsSize int64
|
||||
Monitoring Monitoring
|
||||
Origin struct {
|
||||
UserWs string
|
||||
WorkerWs string
|
||||
}
|
||||
Selector string
|
||||
Server Server
|
||||
}
|
||||
|
||||
// Analytics is optional Google Analytics
|
||||
type Analytics struct {
|
||||
Inject bool
|
||||
Gtag string
|
||||
}
|
||||
|
||||
const SelectByPing = "ping"
|
||||
|
||||
// allows custom config path
|
||||
var coordinatorConfigPath string
|
||||
|
||||
func NewCoordinatorConfig() (conf CoordinatorConfig, paths []string) {
|
||||
paths, err := LoadConfig(&conf, coordinatorConfigPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *CoordinatorConfig) ParseFlags() {
|
||||
c.Coordinator.Server.WithFlags()
|
||||
flag.IntVar(&c.Coordinator.Monitoring.Port, "monitoring.port", c.Coordinator.Monitoring.Port, "Monitoring server port")
|
||||
flag.StringVar(&coordinatorConfigPath, "c-conf", coordinatorConfigPath, "Set custom configuration file path")
|
||||
flag.Parse()
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/emulator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/monitoring"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/shared"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/games"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Coordinator Coordinator
|
||||
Emulator emulator.Emulator
|
||||
Recording shared.Recording
|
||||
Version shared.Version
|
||||
Webrtc webrtc.Webrtc
|
||||
}
|
||||
|
||||
type Coordinator struct {
|
||||
Analytics Analytics
|
||||
Debug bool
|
||||
Library games.Config
|
||||
Monitoring monitoring.Config
|
||||
Origin struct {
|
||||
UserWs string
|
||||
WorkerWs string
|
||||
}
|
||||
Selector string
|
||||
Server shared.Server
|
||||
}
|
||||
|
||||
// Analytics is optional Google Analytics
|
||||
type Analytics struct {
|
||||
Inject bool
|
||||
Gtag string
|
||||
}
|
||||
|
||||
const (
|
||||
SelectAny = "any"
|
||||
SelectByPing = "ping"
|
||||
)
|
||||
|
||||
// allows custom config path
|
||||
var configPath string
|
||||
|
||||
func NewConfig() (conf Config) {
|
||||
err := config.LoadConfig(&conf, configPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
conf.Webrtc.AddIceServersEnv()
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Config) ParseFlags() {
|
||||
c.Coordinator.Server.WithFlags()
|
||||
flag.IntVar(&c.Coordinator.Monitoring.Port, "monitoring.port", c.Coordinator.Monitoring.Port, "Monitoring server port")
|
||||
flag.StringVar(&configPath, "c-conf", configPath, "Set custom configuration file path")
|
||||
flag.Parse()
|
||||
}
|
||||
154
pkg/config/emulator.go
Normal file
154
pkg/config/emulator.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Emulator struct {
|
||||
FailFast bool
|
||||
Threads int
|
||||
Storage string
|
||||
LocalPath string
|
||||
Libretro LibretroConfig
|
||||
AutosaveSec int
|
||||
SkipLateFrames bool
|
||||
LogDroppedFrames bool
|
||||
}
|
||||
|
||||
type LibretroConfig struct {
|
||||
Cores struct {
|
||||
Paths struct {
|
||||
Libs string
|
||||
}
|
||||
Repo LibretroRemoteRepo
|
||||
List map[string]LibretroCoreConfig
|
||||
}
|
||||
DebounceMs int
|
||||
Dup bool
|
||||
SaveCompression bool
|
||||
LogLevel int
|
||||
}
|
||||
|
||||
type LibretroRemoteRepo struct {
|
||||
Sync bool
|
||||
ExtLock string
|
||||
Map map[string]map[string]LibretroRepoMapInfo
|
||||
Main LibretroRepoConfig
|
||||
Secondary LibretroRepoConfig
|
||||
}
|
||||
|
||||
// LibretroRepoMapInfo contains Libretro core lib platform info.
|
||||
// And the cores are just C-compiled libraries.
|
||||
// See: https://buildbot.libretro.com/nightly.
|
||||
type LibretroRepoMapInfo struct {
|
||||
Arch string // bottom: x86_64, x86, ...
|
||||
Ext string // platform dependent library file extension (dot-prefixed)
|
||||
Os string // middle: windows, ios, ...
|
||||
Vendor string // top level: apple, nintendo, ...
|
||||
}
|
||||
|
||||
type LibretroRepoConfig struct {
|
||||
Type string
|
||||
Url string
|
||||
Compression string
|
||||
}
|
||||
|
||||
// Guess tries to map OS + CPU architecture to the corresponding remote URL path.
|
||||
// See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63.
|
||||
func (lrp LibretroRemoteRepo) Guess() (LibretroRepoMapInfo, error) {
|
||||
if os, ok := lrp.Map[runtime.GOOS]; ok {
|
||||
if arch, ok2 := os[runtime.GOARCH]; ok2 {
|
||||
return arch, nil
|
||||
}
|
||||
}
|
||||
return LibretroRepoMapInfo{},
|
||||
errors.New("core mapping not found for " + runtime.GOOS + ":" + runtime.GOARCH)
|
||||
}
|
||||
|
||||
type LibretroCoreConfig struct {
|
||||
AltRepo bool
|
||||
AutoGlContext bool // hack: keep it here to pass it down the emulator
|
||||
CoreAspectRatio bool
|
||||
Folder string
|
||||
Hacks []string
|
||||
Height int
|
||||
Hid map[int][]int
|
||||
IsGlAllowed bool
|
||||
KbMouseSupport bool
|
||||
Lib string
|
||||
NonBlockingSave bool
|
||||
Options map[string]string
|
||||
Options4rom map[string]map[string]string // <(^_^)>
|
||||
Roms []string
|
||||
SaveStateFs string
|
||||
Scale float64
|
||||
UniqueSaveDir bool
|
||||
UsesLibCo bool
|
||||
VFR bool
|
||||
Width int
|
||||
}
|
||||
|
||||
type CoreInfo struct {
|
||||
Id string
|
||||
Name string
|
||||
AltRepo bool
|
||||
}
|
||||
|
||||
// GetLibretroCoreConfig returns a core config with expanded paths.
|
||||
func (e Emulator) GetLibretroCoreConfig(emulator string) LibretroCoreConfig {
|
||||
cores := e.Libretro.Cores
|
||||
conf := cores.List[emulator]
|
||||
conf.Lib = path.Join(cores.Paths.Libs, conf.Lib)
|
||||
return conf
|
||||
}
|
||||
|
||||
// GetEmulator tries to find a suitable emulator.
|
||||
// !to remove quadratic complexity
|
||||
func (e Emulator) GetEmulator(rom string, path string) string {
|
||||
found := ""
|
||||
for emu, core := range e.Libretro.Cores.List {
|
||||
for _, romName := range core.Roms {
|
||||
if rom == romName {
|
||||
found = emu
|
||||
if p := strings.SplitN(filepath.ToSlash(path), "/", 2); len(p) > 1 {
|
||||
folder := p[0]
|
||||
if (folder != "" && folder == core.Folder) || folder == emu {
|
||||
return emu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (e Emulator) GetSupportedExtensions() []string {
|
||||
var extensions []string
|
||||
for _, core := range e.Libretro.Cores.List {
|
||||
extensions = append(extensions, core.Roms...)
|
||||
}
|
||||
return extensions
|
||||
}
|
||||
|
||||
func (e Emulator) SessionStoragePath() string {
|
||||
return e.Storage
|
||||
}
|
||||
|
||||
func (l *LibretroConfig) GetCores() (cores []CoreInfo) {
|
||||
for k, core := range l.Cores.List {
|
||||
cores = append(cores, CoreInfo{Id: k, Name: core.Lib, AltRepo: core.AltRepo})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (l *LibretroConfig) GetCoresStorePath() string {
|
||||
pth, err := filepath.Abs(l.Cores.Paths.Libs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return pth
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
package emulator
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Emulator struct {
|
||||
Scale int
|
||||
Threads int
|
||||
AspectRatio AspectRatio
|
||||
Storage string
|
||||
LocalPath string
|
||||
Libretro LibretroConfig
|
||||
AutosaveSec int
|
||||
}
|
||||
|
||||
type AspectRatio struct {
|
||||
Keep bool
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
func (a AspectRatio) ResizeToAspect(ratio float64, sw int, sh int) (dw int, dh int) {
|
||||
// ratio is always > 0
|
||||
dw = int(math.Round(float64(sh)*ratio/2) * 2)
|
||||
dh = sh
|
||||
if dw > sw {
|
||||
dw = sw
|
||||
dh = int(math.Round(float64(sw)/ratio/2) * 2)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type LibretroConfig struct {
|
||||
Cores struct {
|
||||
Paths struct {
|
||||
Libs string
|
||||
Configs string
|
||||
}
|
||||
Repo struct {
|
||||
Sync bool
|
||||
ExtLock string
|
||||
Main LibretroRepoConfig
|
||||
Secondary LibretroRepoConfig
|
||||
}
|
||||
List map[string]LibretroCoreConfig
|
||||
}
|
||||
SaveCompression bool
|
||||
LogLevel int
|
||||
}
|
||||
|
||||
type LibretroRepoConfig struct {
|
||||
Type string
|
||||
Url string
|
||||
Compression string
|
||||
}
|
||||
|
||||
type LibretroCoreConfig struct {
|
||||
Lib string
|
||||
Config string
|
||||
Roms []string
|
||||
Folder string
|
||||
Width int
|
||||
Height int
|
||||
IsGlAllowed bool
|
||||
UsesLibCo bool
|
||||
HasMultitap bool
|
||||
AltRepo bool
|
||||
|
||||
// hack: keep it here to pass it down the emulator
|
||||
AutoGlContext bool
|
||||
}
|
||||
|
||||
type CoreInfo struct {
|
||||
Name string
|
||||
AltRepo bool
|
||||
}
|
||||
|
||||
// GetLibretroCoreConfig returns a core config with expanded paths.
|
||||
func (e Emulator) GetLibretroCoreConfig(emulator string) LibretroCoreConfig {
|
||||
cores := e.Libretro.Cores
|
||||
conf := cores.List[emulator]
|
||||
conf.Lib = path.Join(cores.Paths.Libs, conf.Lib)
|
||||
if conf.Config != "" {
|
||||
conf.Config = path.Join(cores.Paths.Configs, conf.Config)
|
||||
}
|
||||
return conf
|
||||
}
|
||||
|
||||
// GetEmulator tries to find a suitable emulator.
|
||||
// !to remove quadratic complexity
|
||||
func (e Emulator) GetEmulator(rom string, path string) string {
|
||||
found := ""
|
||||
for emu, core := range e.Libretro.Cores.List {
|
||||
for _, romName := range core.Roms {
|
||||
if rom == romName {
|
||||
found = emu
|
||||
if p := strings.SplitN(filepath.ToSlash(path), "/", 2); len(p) > 1 {
|
||||
folder := p[0]
|
||||
if (folder != "" && folder == core.Folder) || folder == emu {
|
||||
return emu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func (e Emulator) GetSupportedExtensions() []string {
|
||||
var extensions []string
|
||||
for _, core := range e.Libretro.Cores.List {
|
||||
extensions = append(extensions, core.Roms...)
|
||||
}
|
||||
return extensions
|
||||
}
|
||||
|
||||
func (l *LibretroConfig) GetCores() (cores []CoreInfo) {
|
||||
for _, core := range l.Cores.List {
|
||||
cores = append(cores, CoreInfo{Name: core.Lib, AltRepo: core.AltRepo})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (l *LibretroConfig) GetCoresStorePath() string {
|
||||
pth, err := filepath.Abs(l.Cores.Paths.Libs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return pth
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package emulator
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
|
|
@ -29,20 +29,17 @@ func TestGetEmulator(t *testing.T) {
|
|||
},
|
||||
{
|
||||
rom: "nes",
|
||||
path: "test/game.nes",
|
||||
path: "test2/game.nes",
|
||||
config: map[string]LibretroCoreConfig{
|
||||
"snes": {Roms: []string{"nes"}},
|
||||
"snes": {Roms: []string{"snes"}},
|
||||
"nes": {Roms: []string{"nes"}},
|
||||
},
|
||||
emulator: "nes",
|
||||
},
|
||||
}
|
||||
|
||||
emu := Emulator{
|
||||
Libretro: LibretroConfig{},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
emu := Emulator{Libretro: LibretroConfig{}}
|
||||
emu.Libretro.Cores.List = test.config
|
||||
em := emu.GetEmulator(test.rom, test.path)
|
||||
if test.emulator != em {
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package encoder
|
||||
|
||||
type Encoder struct {
|
||||
Audio Audio
|
||||
Video Video
|
||||
}
|
||||
|
||||
type Audio struct {
|
||||
Frame int
|
||||
}
|
||||
|
||||
type Video struct {
|
||||
Codec string
|
||||
Concurrency int
|
||||
H264 struct {
|
||||
Crf uint8
|
||||
Preset string
|
||||
Profile string
|
||||
Tune string
|
||||
LogLevel int
|
||||
}
|
||||
Vpx struct {
|
||||
Bitrate uint
|
||||
KeyframeInterval uint
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,163 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/kkyr/fig"
|
||||
"github.com/knadh/koanf/maps"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const EnvPrefix = "CLOUD_GAME"
|
||||
const EnvPrefix = "CLOUD_GAME_"
|
||||
|
||||
var (
|
||||
//go:embed config.yaml
|
||||
conf embed.FS
|
||||
)
|
||||
|
||||
type Kv = map[string]any
|
||||
type Bytes []byte
|
||||
|
||||
func (b *Bytes) ReadBytes() ([]byte, error) { return *b, nil }
|
||||
func (b *Bytes) Read() (Kv, error) { return nil, nil }
|
||||
|
||||
type File string
|
||||
|
||||
func (f *File) ReadBytes() ([]byte, error) { return os.ReadFile(string(*f)) }
|
||||
func (f *File) Read() (Kv, error) { return nil, nil }
|
||||
|
||||
type YAML struct{}
|
||||
|
||||
func (p *YAML) Marshal(Kv) ([]byte, error) { return nil, nil }
|
||||
func (p *YAML) Unmarshal(b []byte) (Kv, error) {
|
||||
var out Kv
|
||||
klw := keysToLower(b)
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(klw))
|
||||
if err := decoder.Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// keysToLower iterates YAML bytes and tries to lower the keys.
|
||||
// Used for merging with environment vars which are lowered as well.
|
||||
func keysToLower(in []byte) []byte {
|
||||
l, r, ignore := 0, 0, false
|
||||
for i, b := range in {
|
||||
switch b {
|
||||
case '#': // skip comments
|
||||
ignore = true
|
||||
case ':': // lower left chunk before the next : symbol
|
||||
if ignore {
|
||||
continue
|
||||
}
|
||||
r = i
|
||||
ignore = true
|
||||
for j := l; j <= r; j++ {
|
||||
c := in[j]
|
||||
// we skip the line with the first explicit " string symbol
|
||||
if c == '"' {
|
||||
break
|
||||
}
|
||||
if 'A' <= c && c <= 'Z' {
|
||||
in[j] += 'a' - 'A'
|
||||
}
|
||||
}
|
||||
case '\n':
|
||||
l = i
|
||||
ignore = false
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
type Env string
|
||||
|
||||
func (e *Env) ReadBytes() ([]byte, error) { return nil, nil }
|
||||
func (e *Env) Read() (Kv, error) {
|
||||
var keys []string
|
||||
for _, k := range os.Environ() {
|
||||
if strings.HasPrefix(k, string(*e)) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
mp := make(Kv)
|
||||
for _, k := range keys {
|
||||
parts := strings.SplitN(k, "=", 2)
|
||||
if parts == nil {
|
||||
continue
|
||||
}
|
||||
n := strings.ToLower(strings.TrimPrefix(parts[0], string(*e)))
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
// convert VAR_VAR to VAR.VAR or if we need to preserve _
|
||||
// i.e. VAR_VAR__KEY_HAS_SLASHES to VAR.VAR.KEY_HAS_SLASHES
|
||||
// with the result: VAR: { VAR: { KEY_HAS_SLASHES: '' } } }
|
||||
x := strings.Index(n, "__")
|
||||
var key string
|
||||
if x == -1 {
|
||||
key = strings.Replace(n, "_", ".", -1)
|
||||
} else {
|
||||
key = strings.Replace(n[:x+1], "_", ".", -1) + n[x+2:]
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
mp[key] = parts[1]
|
||||
}
|
||||
}
|
||||
return maps.Unflatten(mp, "."), nil
|
||||
}
|
||||
|
||||
// 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 any, path string) error {
|
||||
dirs := []string{path}
|
||||
if path == "" {
|
||||
dirs = append(dirs, ".", "configs", "../../../configs")
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, home+"/.cr")
|
||||
func LoadConfig(config any, path string) (loaded []string, err error) {
|
||||
dirs := []string{".", "configs", "../../../configs"}
|
||||
if path != "" {
|
||||
dirs = append([]string{path}, dirs...)
|
||||
}
|
||||
|
||||
homeDir := ""
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
homeDir = home + "/.cr"
|
||||
dirs = append(dirs, homeDir)
|
||||
}
|
||||
|
||||
k := koanf.New("_") // move to global scope if configs become dynamic
|
||||
defer k.Delete("")
|
||||
data, err := conf.ReadFile("config.yaml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conf := Bytes(data)
|
||||
if err := k.Load(&conf, &YAML{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded = append(loaded, "default")
|
||||
|
||||
for _, dir := range dirs {
|
||||
path := filepath.Join(filepath.Clean(dir), "config.yaml")
|
||||
f := File(path)
|
||||
if _, err := os.Stat(string(f)); !os.IsNotExist(err) {
|
||||
if err := k.Load(&f, &YAML{}); err != nil {
|
||||
return loaded, err
|
||||
}
|
||||
loaded = append(loaded, path)
|
||||
}
|
||||
}
|
||||
if err := fig.Load(config, fig.Dirs(dirs...), fig.UseEnv(EnvPrefix)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadConfigEnv(config any) error {
|
||||
return fig.Load(config, fig.IgnoreFile(), fig.UseEnv(EnvPrefix))
|
||||
env := Env(EnvPrefix)
|
||||
if err := k.Load(&env, nil); err != nil {
|
||||
return loaded, err
|
||||
}
|
||||
|
||||
if err := k.Unmarshal("", config); err != nil {
|
||||
return loaded, err
|
||||
}
|
||||
|
||||
return loaded, nil
|
||||
}
|
||||
|
|
|
|||
63
pkg/config/loader_test.go
Normal file
63
pkg/config/loader_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigEnv(t *testing.T) {
|
||||
var out WorkerConfig
|
||||
|
||||
_ = os.Setenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[0]", "10")
|
||||
_ = os.Setenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[1]", "5")
|
||||
defer func() { _ = os.Unsetenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[0]") }()
|
||||
defer func() { _ = os.Unsetenv("CLOUD_GAME_ENCODER_AUDIO_FRAMES[1]") }()
|
||||
|
||||
_ = os.Setenv("CLOUD_GAME_EMULATOR_LIBRETRO_CORES_LIST_PCSX_OPTIONS__PCSX_REARMED_DRC", "x")
|
||||
defer func() {
|
||||
_ = os.Unsetenv("CLOUD_GAME_EMULATOR_LIBRETRO_CORES_LIST_PCSX_OPTIONS__PCSX_REARMED_DRC")
|
||||
}()
|
||||
|
||||
_, err := LoadConfig(&out, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, x := range []float32{10, 5} {
|
||||
if out.Encoder.Audio.Frames[i] != x {
|
||||
t.Errorf("%v is not [10, 5]", out.Encoder.Audio.Frames)
|
||||
t.Failed()
|
||||
}
|
||||
}
|
||||
|
||||
v := out.Emulator.Libretro.Cores.List["pcsx"].Options["pcsx_rearmed_drc"]
|
||||
if v != "x" {
|
||||
t.Errorf("%v is not x", v)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_keysToLower(t *testing.T) {
|
||||
type args struct {
|
||||
in []byte
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want []byte
|
||||
}{
|
||||
{name: "empty", args: args{in: []byte{}}, want: []byte{}},
|
||||
{name: "case", args: args{
|
||||
in: []byte("KEY:1\n#Comment with:\n KeY123_NamE: 1\n\n\n\nAAA:123\n \"KeyKey\":2\n"),
|
||||
},
|
||||
want: []byte("key:1\n#Comment with:\n key123_name: 1\n\n\n\naaa:123\n \"KeyKey\":2\n"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := keysToLower(tt.args.in); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("keysToLower() = %v, want %v", string(got), string(tt.want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package monitoring
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
URLPrefix string
|
||||
MetricEnabled bool `json:"metric_enabled"`
|
||||
ProfilingEnabled bool `json:"profiling_enabled"`
|
||||
}
|
||||
|
||||
func (c *Config) IsEnabled() bool { return c.MetricEnabled || c.ProfilingEnabled }
|
||||
66
pkg/config/shared.go
Normal file
66
pkg/config/shared.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package config
|
||||
|
||||
import "flag"
|
||||
|
||||
type Version int
|
||||
|
||||
type Library struct {
|
||||
// filename of the alias' file
|
||||
AliasFile string
|
||||
// some directory which is going to be
|
||||
// the root folder for the library
|
||||
BasePath string
|
||||
// a list of supported file extensions
|
||||
Supported []string
|
||||
// a list of ignored words in the files
|
||||
Ignored []string
|
||||
// print some additional info
|
||||
Verbose bool
|
||||
// enable directory changes watch
|
||||
WatchMode bool
|
||||
}
|
||||
|
||||
func (l Library) GetSupportedExtensions() []string { return l.Supported }
|
||||
|
||||
type Monitoring struct {
|
||||
Port int
|
||||
URLPrefix string
|
||||
MetricEnabled bool `json:"metric_enabled"`
|
||||
ProfilingEnabled bool `json:"profiling_enabled"`
|
||||
}
|
||||
|
||||
func (c *Monitoring) IsEnabled() bool { return c.MetricEnabled || c.ProfilingEnabled }
|
||||
|
||||
type Server struct {
|
||||
Address string
|
||||
CacheControl string
|
||||
FrameOptions string
|
||||
Https bool
|
||||
Tls struct {
|
||||
Address string
|
||||
Domain string
|
||||
HttpsKey string
|
||||
HttpsCert string
|
||||
}
|
||||
}
|
||||
|
||||
type Recording struct {
|
||||
Enabled bool
|
||||
Name string
|
||||
Folder string
|
||||
Zip bool
|
||||
}
|
||||
|
||||
func (s *Server) WithFlags() {
|
||||
flag.StringVar(&s.Address, "address", s.Address, "HTTP server address (host:port)")
|
||||
flag.StringVar(&s.Tls.Address, "httpsAddress", s.Tls.Address, "HTTPS server address (host:port)")
|
||||
flag.StringVar(&s.Tls.HttpsKey, "httpsKey", s.Tls.HttpsKey, "HTTPS key")
|
||||
flag.StringVar(&s.Tls.HttpsCert, "httpsCert", s.Tls.HttpsCert, "HTTPS chain")
|
||||
}
|
||||
|
||||
func (s *Server) GetAddr() string {
|
||||
if s.Https {
|
||||
return s.Tls.Address
|
||||
}
|
||||
return s.Address
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package shared
|
||||
|
||||
import "flag"
|
||||
|
||||
type Version int
|
||||
|
||||
type Server struct {
|
||||
Address string
|
||||
Https bool
|
||||
Tls struct {
|
||||
Address string
|
||||
Domain string
|
||||
HttpsKey string
|
||||
HttpsCert string
|
||||
}
|
||||
}
|
||||
|
||||
type Recording struct {
|
||||
Enabled bool
|
||||
CompressLevel int
|
||||
Name string
|
||||
Folder string
|
||||
Zip bool
|
||||
}
|
||||
|
||||
func (s *Server) WithFlags() {
|
||||
flag.StringVar(&s.Address, "address", s.Address, "HTTP server address (host:port)")
|
||||
flag.StringVar(&s.Tls.Address, "httpsAddress", s.Tls.Address, "HTTPS server address (host:port)")
|
||||
flag.StringVar(&s.Tls.HttpsKey, "httpsKey", s.Tls.HttpsKey, "HTTPS key")
|
||||
flag.StringVar(&s.Tls.HttpsCert, "httpsCert", s.Tls.HttpsCert, "HTTPS chain")
|
||||
}
|
||||
|
||||
func (s *Server) GetAddr() string {
|
||||
if s.Https {
|
||||
return s.Tls.Address
|
||||
}
|
||||
return s.Address
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package storage
|
||||
|
||||
type Storage struct {
|
||||
Provider string
|
||||
Key string
|
||||
}
|
||||
|
|
@ -1,11 +1,4 @@
|
|||
package webrtc
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config"
|
||||
)
|
||||
package config
|
||||
|
||||
type Webrtc struct {
|
||||
DisableDefaultInterceptors bool
|
||||
|
|
@ -31,23 +24,3 @@ func (w *Webrtc) HasDtlsRole() bool { return w.DtlsRole > 0 }
|
|||
func (w *Webrtc) HasPortRange() bool { return w.IcePorts.Min > 0 && w.IcePorts.Max > 0 }
|
||||
func (w *Webrtc) HasSinglePort() bool { return w.SinglePort > 0 }
|
||||
func (w *Webrtc) HasIceIpMap() bool { return w.IceIpMap != "" }
|
||||
|
||||
func (w *Webrtc) AddIceServersEnv() {
|
||||
cfg := Webrtc{IceServers: []IceServer{{}, {}, {}, {}, {}}}
|
||||
_ = config.LoadConfigEnv(&cfg)
|
||||
for i, ice := range cfg.IceServers {
|
||||
if ice.Urls == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(ice.Urls, "turn:") || strings.HasPrefix(ice.Urls, "turns:") {
|
||||
if ice.Username == "" || ice.Credential == "" {
|
||||
log.Fatalf("TURN or TURNS servers should have both username and credential: %+v", ice)
|
||||
}
|
||||
}
|
||||
if i > len(w.IceServers)-1 {
|
||||
w.IceServers = append(w.IceServers, ice)
|
||||
} else {
|
||||
w.IceServers[i] = ice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package worker
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
|
@ -8,29 +8,31 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/emulator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/encoder"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/monitoring"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/shared"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/storage"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/os"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Encoder encoder.Encoder
|
||||
Emulator emulator.Emulator
|
||||
Recording shared.Recording
|
||||
Storage storage.Storage
|
||||
type WorkerConfig struct {
|
||||
Encoder Encoder
|
||||
Emulator Emulator
|
||||
Library Library
|
||||
Recording Recording
|
||||
Storage Storage
|
||||
Worker Worker
|
||||
Webrtc webrtc.Webrtc
|
||||
Version shared.Version
|
||||
Webrtc Webrtc
|
||||
Version Version
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
Provider string
|
||||
S3Endpoint string
|
||||
S3BucketName string
|
||||
S3AccessKeyId string
|
||||
S3SecretAccessKey string
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
Debug bool
|
||||
Monitoring monitoring.Config
|
||||
Monitoring Monitoring
|
||||
Network struct {
|
||||
CoordinatorAddress string
|
||||
Endpoint string
|
||||
|
|
@ -39,15 +41,44 @@ type Worker struct {
|
|||
Secure bool
|
||||
Zone string
|
||||
}
|
||||
Server shared.Server
|
||||
Server Server
|
||||
Tag string
|
||||
}
|
||||
|
||||
// allows custom config path
|
||||
var configPath string
|
||||
type Encoder struct {
|
||||
Audio Audio
|
||||
Video Video
|
||||
}
|
||||
|
||||
func NewConfig() (conf Config) {
|
||||
err := config.LoadConfig(&conf, configPath)
|
||||
type Audio struct {
|
||||
Frames []float32
|
||||
Resampler int
|
||||
}
|
||||
|
||||
type Video struct {
|
||||
Codec string
|
||||
Threads int
|
||||
H264 struct {
|
||||
Mode string
|
||||
Crf uint8
|
||||
MaxRate int
|
||||
BufSize int
|
||||
LogLevel int32
|
||||
Preset string
|
||||
Profile string
|
||||
Tune string
|
||||
}
|
||||
Vpx struct {
|
||||
Bitrate uint
|
||||
KeyframeInterval uint
|
||||
}
|
||||
}
|
||||
|
||||
// allows custom config path
|
||||
var workerConfigPath string
|
||||
|
||||
func NewWorkerConfig() (conf WorkerConfig, paths []string) {
|
||||
paths, err := LoadConfig(&conf, workerConfigPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
@ -59,17 +90,17 @@ func NewConfig() (conf Config) {
|
|||
// 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() {
|
||||
func (c *WorkerConfig) ParseFlags() {
|
||||
c.Worker.Server.WithFlags()
|
||||
flag.IntVar(&c.Worker.Monitoring.Port, "monitoring.port", c.Worker.Monitoring.Port, "Monitoring server port")
|
||||
flag.StringVar(&c.Worker.Network.CoordinatorAddress, "coordinatorhost", c.Worker.Network.CoordinatorAddress, "Worker URL to connect")
|
||||
flag.StringVar(&c.Worker.Network.Zone, "zone", c.Worker.Network.Zone, "Worker network zone (us, eu, etc.)")
|
||||
flag.StringVar(&configPath, "w-conf", configPath, "Set custom configuration file path")
|
||||
flag.StringVar(&workerConfigPath, "w-conf", workerConfigPath, "Set custom configuration file path")
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
// expandSpecialTags replaces all the special tags in the config.
|
||||
func (c *Config) expandSpecialTags() {
|
||||
func (c *WorkerConfig) expandSpecialTags() {
|
||||
tag := "{user}"
|
||||
for _, dir := range []*string{&c.Emulator.Storage, &c.Emulator.Libretro.Cores.Repo.ExtLock} {
|
||||
if *dir == "" || !strings.Contains(*dir, tag) {
|
||||
|
|
@ -85,12 +116,10 @@ func (c *Config) expandSpecialTags() {
|
|||
}
|
||||
|
||||
// fixValues tries to fix some values otherwise hard to set externally.
|
||||
func (c *Config) fixValues() {
|
||||
func (c *WorkerConfig) fixValues() {
|
||||
// with ICE lite we clear ICE servers
|
||||
if !c.Webrtc.IceLite {
|
||||
c.Webrtc.AddIceServersEnv()
|
||||
} else {
|
||||
c.Webrtc.IceServers = []webrtc.IceServer{}
|
||||
if c.Webrtc.IceLite {
|
||||
c.Webrtc.IceServers = []IceServer{}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
func (h *Hub) findWorkerByRoom(id string, region string) *Worker {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
// if there is zone param, we need to ensure the worker in that zone,
|
||||
// if not we consider the room is missing
|
||||
w, _ := h.workers.FindBy(func(w *Worker) bool { return w.RoomId == id && w.In(region) })
|
||||
return w
|
||||
}
|
||||
|
||||
func (h *Hub) getAvailableWorkers(region string) []*Worker {
|
||||
var workers []*Worker
|
||||
h.workers.ForEach(func(w *Worker) {
|
||||
if w.HasSlot() && w.In(region) {
|
||||
workers = append(workers, w)
|
||||
}
|
||||
})
|
||||
return workers
|
||||
}
|
||||
|
||||
func (h *Hub) find1stFreeWorker(region string) *Worker {
|
||||
workers := h.getAvailableWorkers(region)
|
||||
if len(workers) > 0 {
|
||||
return workers[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findFastestWorker returns the best server for a session.
|
||||
// All workers addresses are sent to user and user will ping to get latency.
|
||||
// !to rewrite
|
||||
func (h *Hub) findFastestWorker(region string, fn func(addresses []string) (map[string]int64, error)) *Worker {
|
||||
workers := h.getAvailableWorkers(region)
|
||||
if len(workers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addresses []string
|
||||
group := map[string][]struct{}{}
|
||||
for _, w := range workers {
|
||||
if _, ok := group[w.PingServer]; !ok {
|
||||
addresses = append(addresses, w.PingServer)
|
||||
}
|
||||
group[w.PingServer] = append(group[w.PingServer], struct{}{})
|
||||
}
|
||||
|
||||
latencies, err := fn(addresses)
|
||||
if len(latencies) == 0 || err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
workers = h.getAvailableWorkers(region)
|
||||
if len(workers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bestWorker *Worker
|
||||
var minLatency int64 = 1<<31 - 1
|
||||
// get a worker with the lowest latency
|
||||
for addr, ping := range latencies {
|
||||
if ping < minLatency {
|
||||
for _, w := range workers {
|
||||
if w.PingServer == addr {
|
||||
bestWorker = w
|
||||
}
|
||||
}
|
||||
minLatency = ping
|
||||
}
|
||||
}
|
||||
return bestWorker
|
||||
}
|
||||
|
||||
func (h *Hub) findWorkerById(workerId string, useAllWorkers bool) *Worker {
|
||||
// when we select one particular worker
|
||||
if workerId != "" {
|
||||
if xid_, err := xid.FromString(workerId); err == nil {
|
||||
if useAllWorkers {
|
||||
for _, w := range h.getAvailableWorkers("") {
|
||||
if xid_.String() == w.Id().String() {
|
||||
return w
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, w := range h.getAvailableWorkers("") {
|
||||
xid__, err := xid.FromString(workerId)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(xid_.Machine(), xid__.Machine()) {
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,83 +1,119 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/shared"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/games"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/monitoring"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network/httpx"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/service"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/monitoring"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/network/httpx"
|
||||
)
|
||||
|
||||
func New(conf coordinator.Config, log *logger.Logger) (services service.Group) {
|
||||
lib := games.NewLibWhitelisted(conf.Coordinator.Library, conf.Emulator, log)
|
||||
lib.Scan()
|
||||
hub := NewHub(conf, lib, log)
|
||||
type Coordinator struct {
|
||||
hub *Hub
|
||||
services [2]interface {
|
||||
Run()
|
||||
Stop() error
|
||||
}
|
||||
}
|
||||
|
||||
func New(conf config.CoordinatorConfig, log *logger.Logger) (*Coordinator, error) {
|
||||
coordinator := &Coordinator{hub: NewHub(conf, log)}
|
||||
h, err := NewHTTPServer(conf, log, func(mux *httpx.Mux) *httpx.Mux {
|
||||
mux.HandleFunc("/ws", hub.handleUserConnection)
|
||||
mux.HandleFunc("/wso", hub.handleWorkerConnection)
|
||||
mux.HandleFunc("/ws", coordinator.hub.handleUserConnection())
|
||||
mux.HandleFunc("/wso", coordinator.hub.handleWorkerConnection())
|
||||
return mux
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("http server init fail")
|
||||
return
|
||||
return nil, fmt.Errorf("http init fail: %w", err)
|
||||
}
|
||||
services.Add(hub, h)
|
||||
coordinator.services[0] = h
|
||||
if conf.Coordinator.Monitoring.IsEnabled() {
|
||||
services.Add(monitoring.New(conf.Coordinator.Monitoring, h.GetHost(), log))
|
||||
coordinator.services[1] = monitoring.New(conf.Coordinator.Monitoring, h.GetHost(), log)
|
||||
}
|
||||
return
|
||||
return coordinator, nil
|
||||
}
|
||||
|
||||
func NewHTTPServer(conf coordinator.Config, log *logger.Logger, fnMux func(*httpx.Mux) *httpx.Mux) (*httpx.Server, error) {
|
||||
func (c *Coordinator) Start() {
|
||||
for _, s := range c.services {
|
||||
if s != nil {
|
||||
s.Run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) Stop() error {
|
||||
var err error
|
||||
for _, s := range c.services {
|
||||
if s != nil {
|
||||
err0 := s.Stop()
|
||||
err = errors.Join(err, err0)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func NewHTTPServer(conf config.CoordinatorConfig, log *logger.Logger, fnMux func(*httpx.Mux) *httpx.Mux) (*httpx.Server, error) {
|
||||
return httpx.NewServer(
|
||||
conf.Coordinator.Server.GetAddr(),
|
||||
func(s *httpx.Server) httpx.Handler {
|
||||
return fnMux(s.Mux().
|
||||
Handle("/", index(conf, log)).
|
||||
Static("/static/", "./web"))
|
||||
},
|
||||
func(s *httpx.Server) httpx.Handler { return fnMux(s.Mux().Handle("/", index(conf, log))) },
|
||||
httpx.WithServerConfig(conf.Coordinator.Server),
|
||||
httpx.WithLogger(log),
|
||||
)
|
||||
}
|
||||
|
||||
func index(conf coordinator.Config, log *logger.Logger) httpx.Handler {
|
||||
func index(conf config.CoordinatorConfig, log *logger.Logger) httpx.Handler {
|
||||
const indexHTML = "./web/index.html"
|
||||
|
||||
indexTpl := template.Must(template.ParseFiles(indexHTML))
|
||||
|
||||
// render index page with some tpl values
|
||||
tplData := struct {
|
||||
Analytics config.Analytics
|
||||
Recording config.Recording
|
||||
}{conf.Coordinator.Analytics, conf.Recording}
|
||||
|
||||
handler := func(tpl *template.Template, w httpx.ResponseWriter, r *httpx.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
// render index page with some tpl values
|
||||
tplData := struct {
|
||||
Analytics coordinator.Analytics
|
||||
Recording shared.Recording
|
||||
}{conf.Coordinator.Analytics, conf.Recording}
|
||||
if err := tpl.Execute(w, tplData); err != nil {
|
||||
log.Fatal().Err(err).Msg("error with the analytics template file")
|
||||
log.Error().Err(err).Msg("error with the analytics template file")
|
||||
}
|
||||
}
|
||||
|
||||
h := httpx.FileServer("./web")
|
||||
|
||||
if conf.Coordinator.Debug {
|
||||
log.Info().Msgf("Using auto-reloading index.html")
|
||||
return httpx.HandlerFunc(func(w httpx.ResponseWriter, r *httpx.Request) {
|
||||
tpl, _ := template.ParseFiles(indexHTML)
|
||||
handler(tpl, w, r)
|
||||
if conf.Coordinator.Server.CacheControl != "" {
|
||||
w.Header().Add("Cache-Control", conf.Coordinator.Server.CacheControl)
|
||||
}
|
||||
if conf.Coordinator.Server.FrameOptions != "" {
|
||||
w.Header().Add("X-Frame-Options", conf.Coordinator.Server.FrameOptions)
|
||||
}
|
||||
if r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") {
|
||||
tpl := template.Must(template.ParseFiles(indexHTML))
|
||||
handler(tpl, w, r)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
indexTpl, err := template.ParseFiles(indexHTML)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("error with the HTML index page")
|
||||
}
|
||||
|
||||
return httpx.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handler(indexTpl, writer, request)
|
||||
return httpx.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if conf.Coordinator.Server.CacheControl != "" {
|
||||
w.Header().Add("Cache-Control", conf.Coordinator.Server.CacheControl)
|
||||
}
|
||||
if conf.Coordinator.Server.FrameOptions != "" {
|
||||
w.Header().Add("X-Frame-Options", conf.Coordinator.Server.FrameOptions)
|
||||
}
|
||||
if r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") {
|
||||
handler(indexTpl, w, r)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,162 +1,337 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/com"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/games"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/service"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/com"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
type Hub struct {
|
||||
service.Service
|
||||
type Connection interface {
|
||||
Disconnect()
|
||||
Id() com.Uid
|
||||
ProcessPackets(func(api.In[com.Uid]) error) chan struct{}
|
||||
|
||||
conf coordinator.Config
|
||||
launcher games.Launcher
|
||||
users com.NetMap[*User]
|
||||
workers com.NetMap[*Worker]
|
||||
log *logger.Logger
|
||||
|
||||
wConn, uConn *com.Connector
|
||||
Send(api.PT, any) ([]byte, error)
|
||||
Notify(api.PT, any)
|
||||
}
|
||||
|
||||
func NewHub(conf coordinator.Config, lib games.GameLibrary, log *logger.Logger) *Hub {
|
||||
type Hub struct {
|
||||
conf config.CoordinatorConfig
|
||||
log *logger.Logger
|
||||
users com.NetMap[com.Uid, *User]
|
||||
workers com.NetMap[com.Uid, *Worker]
|
||||
}
|
||||
|
||||
func NewHub(conf config.CoordinatorConfig, log *logger.Logger) *Hub {
|
||||
return &Hub{
|
||||
conf: conf,
|
||||
users: com.NewNetMap[*User](),
|
||||
workers: com.NewNetMap[*Worker](),
|
||||
launcher: games.NewGameLauncher(lib),
|
||||
log: log,
|
||||
wConn: com.NewConnector(
|
||||
com.WithOrigin(conf.Coordinator.Origin.WorkerWs),
|
||||
com.WithTag("w"),
|
||||
),
|
||||
uConn: com.NewConnector(
|
||||
com.WithOrigin(conf.Coordinator.Origin.UserWs),
|
||||
com.WithTag("u"),
|
||||
),
|
||||
conf: conf,
|
||||
users: com.NewNetMap[com.Uid, *User](),
|
||||
workers: com.NewNetMap[com.Uid, *Worker](),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserConnection handles all connections from user/frontend.
|
||||
func (h *Hub) handleUserConnection(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Debug().Str("c", "u").Str("d", "←").Msgf("Handshake %v", r.Host)
|
||||
conn, err := h.uConn.NewClientServer(w, r, h.log)
|
||||
if err != nil {
|
||||
h.log.Error().Err(err).Msg("couldn't init user connection")
|
||||
}
|
||||
usr := NewUserConnection(conn)
|
||||
defer func() {
|
||||
if usr != nil {
|
||||
usr.Disconnect()
|
||||
h.users.Remove(usr)
|
||||
func (h *Hub) handleUserConnection() http.HandlerFunc {
|
||||
var connector com.Server
|
||||
connector.Origin(h.conf.Coordinator.Origin.UserWs)
|
||||
|
||||
log := h.log.Extend(h.log.With().
|
||||
Str(logger.ClientField, "u").
|
||||
Str(logger.DirectionField, logger.MarkIn),
|
||||
)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Debug().Msgf("Handshake %v", r.Host)
|
||||
|
||||
conn, err := connector.Connect(w, r)
|
||||
if err != nil {
|
||||
h.log.Error().Err(err).Msg("user connection fail")
|
||||
return
|
||||
}
|
||||
}()
|
||||
usr.HandleRequests(h, h.launcher, h.conf)
|
||||
|
||||
q := r.URL.Query()
|
||||
roomId := q.Get(api.RoomIdQueryParam)
|
||||
zone := q.Get(api.ZoneQueryParam)
|
||||
wid := q.Get(api.WorkerIdParam)
|
||||
user := NewUser(conn, log)
|
||||
defer h.users.RemoveDisconnect(user)
|
||||
done := user.HandleRequests(h, h.conf)
|
||||
params := r.URL.Query()
|
||||
|
||||
usr.Log.Info().Msg("Search available workers")
|
||||
var wkr *Worker
|
||||
if wkr = h.findWorkerByRoom(roomId, zone); wkr != nil {
|
||||
usr.Log.Info().Str("room", roomId).Msg("An existing worker has been found")
|
||||
} else if wkr = h.findWorkerById(wid, h.conf.Coordinator.Debug); wkr != nil {
|
||||
usr.Log.Info().Msgf("Worker with id: %v has been found", wid)
|
||||
} else if h.conf.Coordinator.Selector == "" || h.conf.Coordinator.Selector == coordinator.SelectAny {
|
||||
usr.Log.Debug().Msgf("Searching any free worker...")
|
||||
if wkr = h.find1stFreeWorker(zone); wkr != nil {
|
||||
usr.Log.Info().Msgf("Found next free worker")
|
||||
worker := h.findWorkerFor(user, params, h.log.Extend(h.log.With().Str("cid", user.Id().Short())))
|
||||
if worker == nil {
|
||||
user.Notify(api.ErrNoFreeSlots, "")
|
||||
h.log.Info().Msg("no free workers")
|
||||
return
|
||||
}
|
||||
} else if h.conf.Coordinator.Selector == coordinator.SelectByPing {
|
||||
usr.Log.Debug().Msgf("Searching fastest free worker...")
|
||||
if wkr = h.findFastestWorker(zone,
|
||||
func(servers []string) (map[string]int64, error) { return usr.CheckLatency(servers) }); wkr != nil {
|
||||
usr.Log.Info().Msg("The fastest worker has been found")
|
||||
|
||||
// Link the user to the selected worker. Slot reservation is handled later
|
||||
// on game start; this keeps connections lightweight and lets deep-link
|
||||
// joins share a worker without consuming its single game slot.
|
||||
user.w = worker
|
||||
|
||||
h.users.Add(user)
|
||||
|
||||
apps := worker.AppNames()
|
||||
list := make([]api.AppMeta, len(apps))
|
||||
for i := range apps {
|
||||
list[i] = api.AppMeta{Alias: apps[i].Alias, Title: apps[i].Name, System: apps[i].System}
|
||||
}
|
||||
}
|
||||
|
||||
if wkr == nil {
|
||||
usr.Log.Warn().Msg("no free workers")
|
||||
return
|
||||
user.InitSession(worker.Id().String(), h.conf.Webrtc.IceServers, list)
|
||||
log.Info().Str(logger.DirectionField, logger.MarkPlus).Msgf("user %s", user.Id())
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
usr.SetWorker(wkr)
|
||||
h.users.Add(usr)
|
||||
usr.InitSession(wkr.Id().String(), h.conf.Webrtc.IceServers, h.launcher.GetAppNames())
|
||||
<-usr.Done()
|
||||
func RequestToHandshake(data string) (*api.ConnectionRequest[com.Uid], error) {
|
||||
if data == "" {
|
||||
return nil, api.ErrMalformed
|
||||
}
|
||||
handshake, err := api.UnwrapChecked[api.ConnectionRequest[com.Uid]](base64.URLEncoding.DecodeString(data))
|
||||
if err != nil || handshake == nil {
|
||||
return nil, fmt.Errorf("%w (%v)", err, handshake)
|
||||
}
|
||||
return handshake, nil
|
||||
}
|
||||
|
||||
// handleWorkerConnection handles all connections from a new worker to coordinator.
|
||||
func (h *Hub) handleWorkerConnection(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Debug().Str("c", "w").Str("d", "←").Msgf("Handshake %v", r.Host)
|
||||
func (h *Hub) handleWorkerConnection() http.HandlerFunc {
|
||||
var connector com.Server
|
||||
connector.Origin(h.conf.Coordinator.Origin.WorkerWs)
|
||||
|
||||
data := r.URL.Query().Get(api.DataQueryParam)
|
||||
handshake, err := GetConnectionRequest(data)
|
||||
if err != nil || handshake == nil {
|
||||
h.log.Error().Err(err).Msg("got a malformed request")
|
||||
return
|
||||
}
|
||||
log := h.log.Extend(h.log.With().
|
||||
Str(logger.ClientField, "w").
|
||||
Str(logger.DirectionField, logger.MarkIn),
|
||||
)
|
||||
|
||||
if handshake.PingURL == "" {
|
||||
h.log.Warn().Msg("Ping address is not set")
|
||||
}
|
||||
h.log.Debug().Msgf("WS max message size: %vb", h.conf.Coordinator.MaxWsSize)
|
||||
|
||||
if h.conf.Coordinator.Server.Https && !handshake.IsHTTPS {
|
||||
h.log.Warn().Msg("Unsecure connection. The worker may not work properly without HTTPS on its side!")
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Debug().Msgf("Handshake %v", r.Host)
|
||||
|
||||
conn, err := h.wConn.NewClientServer(w, r, h.log)
|
||||
if err != nil {
|
||||
h.log.Error().Err(err).Msg("couldn't init worker connection")
|
||||
return
|
||||
}
|
||||
|
||||
worker := &Worker{
|
||||
SocketClient: *conn,
|
||||
Addr: handshake.Addr,
|
||||
PingServer: handshake.PingURL,
|
||||
Port: handshake.Port,
|
||||
Tag: handshake.Tag,
|
||||
Zone: handshake.Zone,
|
||||
}
|
||||
// we duplicate uid from the handshake
|
||||
hid := network.Uid(handshake.Id)
|
||||
if !(handshake.Id == "" || !network.ValidUid(hid)) {
|
||||
conn.SetId(hid)
|
||||
worker.Log.Debug().Msgf("connection id has been changed to %s", hid)
|
||||
}
|
||||
defer func() {
|
||||
if worker != nil {
|
||||
worker.Disconnect()
|
||||
h.workers.Remove(worker)
|
||||
handshake, err := RequestToHandshake(r.URL.Query().Get(api.DataQueryParam))
|
||||
if err != nil {
|
||||
h.log.Error().Err(err).Msg("handshake fail")
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
h.log.Info().Msgf("New worker / addr: %v, port: %v, zone: %v, ping addr: %v, tag: %v",
|
||||
worker.Addr, worker.Port, worker.Zone, worker.PingServer, worker.Tag)
|
||||
worker.HandleRequests(&h.users)
|
||||
h.workers.Add(worker)
|
||||
worker.Listen()
|
||||
if handshake.PingURL == "" {
|
||||
h.log.Warn().Msg("Ping address is not set")
|
||||
}
|
||||
|
||||
if h.conf.Coordinator.Server.Https && !handshake.IsHTTPS {
|
||||
h.log.Warn().Msg("Unsecure worker connection. Unsecure to secure may be bad.")
|
||||
}
|
||||
|
||||
// set connection uid from the handshake
|
||||
if handshake.Id != com.NilUid {
|
||||
h.log.Debug().Msgf("Worker uid will be set to %v", handshake.Id)
|
||||
}
|
||||
|
||||
conn, err := connector.Connect(w, r)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("worker connection fail")
|
||||
return
|
||||
}
|
||||
conn.SetMaxReadSize(h.conf.Coordinator.MaxWsSize)
|
||||
|
||||
worker := NewWorker(conn, *handshake, log)
|
||||
defer h.workers.RemoveDisconnect(worker)
|
||||
done := worker.HandleRequests(&h.users)
|
||||
h.workers.Add(worker)
|
||||
log.Info().
|
||||
Str(logger.DirectionField, logger.MarkPlus).
|
||||
Msgf("worker %s", worker.PrintInfo())
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) GetServerList() (r []api.Server) {
|
||||
for _, w := range h.workers.List() {
|
||||
r = append(r, api.Server{
|
||||
debug := h.conf.Coordinator.Debug
|
||||
for w := range h.workers.Values() {
|
||||
server := api.Server{
|
||||
Addr: w.Addr,
|
||||
Id: w.Id(),
|
||||
IsBusy: !w.HasSlot(),
|
||||
Machine: string(w.Id().Machine()),
|
||||
PingURL: w.PingServer,
|
||||
Port: w.Port,
|
||||
Tag: w.Tag,
|
||||
Zone: w.Zone,
|
||||
})
|
||||
}
|
||||
if debug {
|
||||
server.Room = w.RoomId
|
||||
}
|
||||
r = append(r, server)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// findWorkerFor searches a free worker for the user depending on
|
||||
// various conditions.
|
||||
func (h *Hub) findWorkerFor(usr *User, q url.Values, log *logger.Logger) *Worker {
|
||||
log.Debug().Msg("Search available workers")
|
||||
roomIdRaw := q.Get(api.RoomIdQueryParam)
|
||||
sessionId, deepRoomId := api.ExplodeDeepLink(roomIdRaw)
|
||||
roomId := roomIdRaw
|
||||
if deepRoomId != "" {
|
||||
roomId = deepRoomId
|
||||
}
|
||||
zone := q.Get(api.ZoneQueryParam)
|
||||
wid := q.Get(api.WorkerIdParam)
|
||||
|
||||
var worker *Worker
|
||||
|
||||
if wid != "" {
|
||||
if worker = h.findWorkerById(wid, h.conf.Coordinator.Debug); worker != nil {
|
||||
log.Debug().Msgf("Worker with id: %v has been found", wid)
|
||||
return worker
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if worker = h.findWorkerByRoom(roomIdRaw, roomId, zone); worker != nil {
|
||||
log.Debug().Str("room", roomId).Msg("An existing worker has been found")
|
||||
} else if worker = h.findWorkerByPreviousRoom(sessionId); worker != nil {
|
||||
log.Debug().Msgf("Worker %v with the previous room: %v is found", wid, roomId)
|
||||
} else {
|
||||
switch h.conf.Coordinator.Selector {
|
||||
case config.SelectByPing:
|
||||
log.Debug().Msgf("Searching fastest free worker...")
|
||||
if worker = h.findFastestWorker(zone,
|
||||
func(servers []string) (map[string]int64, error) { return usr.CheckLatency(servers) }); worker != nil {
|
||||
log.Debug().Msg("The fastest worker has been found")
|
||||
}
|
||||
default:
|
||||
log.Debug().Msgf("Searching any free worker...")
|
||||
if worker = h.find1stFreeWorker(zone); worker != nil {
|
||||
log.Debug().Msgf("Found next free worker")
|
||||
}
|
||||
}
|
||||
}
|
||||
return worker
|
||||
}
|
||||
|
||||
func (h *Hub) findWorkerByPreviousRoom(id string) *Worker {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
w, _ := h.workers.FindBy(func(w *Worker) bool {
|
||||
// session and room id are the same
|
||||
return w.HadSession(id) && w.HasSlot()
|
||||
})
|
||||
return w
|
||||
}
|
||||
|
||||
func (h *Hub) findWorkerByRoom(id string, deepId string, region string) *Worker {
|
||||
if id == "" && deepId == "" {
|
||||
return nil
|
||||
}
|
||||
// if there is zone param, we need to ensure the worker in that zone,
|
||||
// if not we consider the room is missing
|
||||
w, _ := h.workers.FindBy(func(w *Worker) bool {
|
||||
matchId := w.RoomId == id
|
||||
if !matchId && deepId != "" {
|
||||
matchId = w.RoomId == deepId
|
||||
}
|
||||
return matchId && w.In(region)
|
||||
})
|
||||
return w
|
||||
}
|
||||
|
||||
func (h *Hub) getAvailableWorkers(region string) []*Worker {
|
||||
var workers []*Worker
|
||||
for w := range h.workers.Values() {
|
||||
if w.HasSlot() && w.In(region) {
|
||||
workers = append(workers, w)
|
||||
}
|
||||
}
|
||||
return workers
|
||||
}
|
||||
|
||||
func (h *Hub) find1stFreeWorker(region string) *Worker {
|
||||
workers := h.getAvailableWorkers(region)
|
||||
if len(workers) > 0 {
|
||||
return workers[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findFastestWorker returns the best server for a session.
|
||||
// All workers addresses are sent to user and user will ping to get latency.
|
||||
// !to rewrite
|
||||
func (h *Hub) findFastestWorker(region string, fn func(addresses []string) (map[string]int64, error)) *Worker {
|
||||
workers := h.getAvailableWorkers(region)
|
||||
if len(workers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var addresses []string
|
||||
group := map[string][]struct{}{}
|
||||
for _, w := range workers {
|
||||
if _, ok := group[w.PingServer]; !ok {
|
||||
addresses = append(addresses, w.PingServer)
|
||||
}
|
||||
group[w.PingServer] = append(group[w.PingServer], struct{}{})
|
||||
}
|
||||
|
||||
latencies, err := fn(addresses)
|
||||
if len(latencies) == 0 || err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
workers = h.getAvailableWorkers(region)
|
||||
if len(workers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bestWorker *Worker
|
||||
var minLatency int64 = 1<<31 - 1
|
||||
// get a worker with the lowest latency
|
||||
for addr, ping := range latencies {
|
||||
if ping < minLatency {
|
||||
for _, w := range workers {
|
||||
if w.PingServer == addr {
|
||||
bestWorker = w
|
||||
}
|
||||
}
|
||||
minLatency = ping
|
||||
}
|
||||
}
|
||||
return bestWorker
|
||||
}
|
||||
|
||||
func (h *Hub) findWorkerById(id string, useAllWorkers bool) *Worker {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
uid, err := com.UidFromString(id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, w := range h.getAvailableWorkers("") {
|
||||
if w.Id() == com.NilUid {
|
||||
continue
|
||||
}
|
||||
if useAllWorkers {
|
||||
if uid == w.Id() {
|
||||
return w
|
||||
}
|
||||
} else {
|
||||
// select any worker on the same machine when workers are grouped on the client
|
||||
if bytes.Equal(uid.Machine(), w.Id().Machine()) {
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,81 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/com"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/games"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/com"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
com.SocketClient
|
||||
w *Worker // linked worker
|
||||
Connection
|
||||
w *Worker // linked worker
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
// NewUserConnection supposed to be a bidirectional one.
|
||||
func NewUserConnection(conn *com.SocketClient) *User { return &User{SocketClient: *conn} }
|
||||
type HasServerInfo interface {
|
||||
GetServerList() []api.Server
|
||||
}
|
||||
|
||||
func (u *User) SetWorker(w *Worker) { u.w = w; u.w.Reserve() }
|
||||
|
||||
func (u *User) Disconnect() {
|
||||
u.SocketClient.Close()
|
||||
if u.w != nil {
|
||||
u.w.UnReserve()
|
||||
u.w.TerminateSession(u.Id())
|
||||
func NewUser(sock *com.Connection, log *logger.Logger) *User {
|
||||
conn := com.NewConnection[api.PT, api.In[com.Uid], api.Out, *api.Out](sock, com.NewUid(), log)
|
||||
return &User{
|
||||
Connection: conn,
|
||||
log: log.Extend(log.With().
|
||||
Str(logger.ClientField, logger.MarkNone).
|
||||
Str(logger.DirectionField, logger.MarkNone).
|
||||
Str("cid", conn.Id().Short())),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) HandleRequests(info api.HasServerInfo, launcher games.Launcher, conf coordinator.Config) {
|
||||
u.ProcessMessages()
|
||||
u.OnPacket(func(x com.In) error {
|
||||
// !to use proper channels
|
||||
func (u *User) Bind(w *Worker) bool {
|
||||
u.w = w
|
||||
// Binding only links the worker; slot reservation is handled lazily on
|
||||
// game start to avoid blocking deep-link joins or parallel connections
|
||||
// that haven't started a game yet.
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *User) Disconnect() {
|
||||
u.Connection.Disconnect()
|
||||
if u.w != nil {
|
||||
u.w.TerminateSession(u.Id().String())
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) HandleRequests(info HasServerInfo, conf config.CoordinatorConfig) chan struct{} {
|
||||
return u.ProcessPackets(func(x api.In[com.Uid]) (err error) {
|
||||
switch x.T {
|
||||
case api.WebrtcInit:
|
||||
if u.w != nil {
|
||||
u.HandleWebrtcInit()
|
||||
}
|
||||
case api.WebrtcAnswer:
|
||||
rq := api.Unwrap[api.WebrtcAnswerUserRequest](x.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
u.HandleWebrtcAnswer(*rq)
|
||||
err = api.Do(x, u.HandleWebrtcAnswer)
|
||||
case api.WebrtcIce:
|
||||
rq := api.Unwrap[api.WebrtcUserIceCandidate](x.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
u.HandleWebrtcIceCandidate(*rq)
|
||||
err = api.Do(x, u.HandleWebrtcIceCandidate)
|
||||
case api.StartGame:
|
||||
rq := api.Unwrap[api.GameStartUserRequest](x.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
u.HandleStartGame(*rq, launcher, conf)
|
||||
err = api.Do(x, func(d api.GameStartUserRequest) { u.HandleStartGame(d, conf) })
|
||||
case api.QuitGame:
|
||||
rq := api.Unwrap[api.GameQuitRequest](x.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
u.HandleQuitGame(*rq)
|
||||
err = api.Do(x, u.HandleQuitGame)
|
||||
case api.SaveGame:
|
||||
return u.HandleSaveGame()
|
||||
err = u.HandleSaveGame()
|
||||
case api.LoadGame:
|
||||
return u.HandleLoadGame()
|
||||
err = u.HandleLoadGame()
|
||||
case api.ChangePlayer:
|
||||
rq := api.Unwrap[api.ChangePlayerUserRequest](x.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
u.HandleChangePlayer(*rq)
|
||||
case api.ToggleMultitap:
|
||||
u.HandleToggleMultitap()
|
||||
err = api.Do(x, u.HandleChangePlayer)
|
||||
case api.ResetGame:
|
||||
err = api.Do(x, u.HandleResetGame)
|
||||
case api.RecordGame:
|
||||
if !conf.Recording.Enabled {
|
||||
return api.ErrForbidden
|
||||
}
|
||||
rq := api.Unwrap[api.RecordGameRequest](x.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
u.HandleRecordGame(*rq)
|
||||
err = api.Do(x, u.HandleRecordGame)
|
||||
case api.GetWorkerList:
|
||||
u.handleGetWorkerList(conf.Coordinator.Debug, info)
|
||||
default:
|
||||
u.Log.Warn().Msgf("Unknown packet: %+v", x)
|
||||
u.log.Warn().Msgf("Unknown packet: %+v", x)
|
||||
}
|
||||
return nil
|
||||
return
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,28 +3,27 @@ package coordinator
|
|||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
)
|
||||
|
||||
// CheckLatency sends a list of server addresses to the user
|
||||
// and waits get back this list with tested ping times for each server.
|
||||
func (u *User) CheckLatency(req api.CheckLatencyUserResponse) (api.CheckLatencyUserRequest, error) {
|
||||
data, err := u.Send(api.CheckLatency, req)
|
||||
if err != nil || data == nil {
|
||||
return nil, err
|
||||
}
|
||||
dat := api.Unwrap[api.CheckLatencyUserRequest](data)
|
||||
dat, err := api.UnwrapChecked[api.CheckLatencyUserRequest](u.Send(api.CheckLatency, req))
|
||||
if dat == nil {
|
||||
return api.CheckLatencyUserRequest{}, err
|
||||
}
|
||||
return *dat, err
|
||||
return *dat, nil
|
||||
}
|
||||
|
||||
// InitSession signals the user that the app is ready to go.
|
||||
func (u *User) InitSession(wid string, ice []webrtc.IceServer, games []string) {
|
||||
// don't do this at home
|
||||
u.Notify(api.InitSessionResult(*(*[]api.IceServer)(unsafe.Pointer(&ice)), games, wid))
|
||||
func (u *User) InitSession(wid string, ice []config.IceServer, games []api.AppMeta) {
|
||||
u.Notify(api.InitSession, api.InitSessionUserResponse{
|
||||
Ice: *(*[]api.IceServer)(unsafe.Pointer(&ice)), // don't do this at home
|
||||
Games: games,
|
||||
Wid: wid,
|
||||
})
|
||||
}
|
||||
|
||||
// SendWebrtcOffer sends SDP offer back to the user.
|
||||
|
|
@ -34,4 +33,6 @@ func (u *User) SendWebrtcOffer(sdp string) { u.Notify(api.WebrtcOffer, sdp) }
|
|||
func (u *User) SendWebrtcIceCandidate(candidate string) { u.Notify(api.WebrtcIce, candidate) }
|
||||
|
||||
// StartGame signals the user that everything is ready to start a game.
|
||||
func (u *User) StartGame() { u.Notify(api.StartGame, u.w.RoomId) }
|
||||
func (u *User) StartGame(av *api.AppVideoInfo, kbMouse bool) {
|
||||
u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av, KbMouse: kbMouse})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,60 +2,91 @@ package coordinator
|
|||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/coordinator"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/games"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
)
|
||||
|
||||
func (u *User) HandleWebrtcInit() {
|
||||
resp, err := u.w.WebrtcInit(u.Id())
|
||||
uid := u.Id().String()
|
||||
resp, err := u.w.WebrtcInit(uid)
|
||||
if err != nil || resp == nil || *resp == api.EMPTY {
|
||||
u.Log.Error().Err(err).Msg("malformed WebRTC init response")
|
||||
u.log.Error().Err(err).Msg("malformed WebRTC init response")
|
||||
return
|
||||
}
|
||||
u.SendWebrtcOffer(string(*resp))
|
||||
}
|
||||
|
||||
func (u *User) HandleWebrtcAnswer(rq api.WebrtcAnswerUserRequest) {
|
||||
u.w.WebrtcAnswer(u.Id(), string(rq))
|
||||
u.w.WebrtcAnswer(u.Id().String(), string(rq))
|
||||
}
|
||||
|
||||
func (u *User) HandleWebrtcIceCandidate(rq api.WebrtcUserIceCandidate) {
|
||||
u.w.WebrtcIceCandidate(u.Id(), string(rq))
|
||||
u.w.WebrtcIceCandidate(u.Id().String(), string(rq))
|
||||
}
|
||||
|
||||
func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launcher, conf coordinator.Config) {
|
||||
// +injects game data into the original game request
|
||||
// the name of the game either in the `room id` field or
|
||||
// it's in the initial request
|
||||
game := rq.GameName
|
||||
if rq.RoomId != "" {
|
||||
name := launcher.ExtractAppNameFromUrl(rq.RoomId)
|
||||
if name == "" {
|
||||
u.Log.Warn().Msg("couldn't decode game name from the room id")
|
||||
func (u *User) HandleStartGame(rq api.GameStartUserRequest, conf config.CoordinatorConfig) {
|
||||
// Worker slot / room gating:
|
||||
// - If the worker is BUSY (no free slot), we must not create another room.
|
||||
// * If the worker has already reported a room id, only allow requests
|
||||
// for that same room (deep-link joins / reloads).
|
||||
// * If the worker hasn't reported a room yet, deny any new StartGame to
|
||||
// avoid racing concurrent room creation on the worker.
|
||||
// * When the user is starting a NEW game (empty room id), we give the
|
||||
// worker a short grace period to close the previous room and free the
|
||||
// slot before rejecting with "no slots".
|
||||
// - If the worker is FREE, reserve the slot lazily before starting the
|
||||
// game; the room id (if any) comes from the request / worker.
|
||||
|
||||
// Grace period: when there's no room id in the request (new game) but the
|
||||
// worker still appears busy, wait a bit for the previous room to close.
|
||||
if rq.RoomId == "" && !u.w.HasSlot() {
|
||||
const waitTotal = 3 * time.Second
|
||||
const step = 100 * time.Millisecond
|
||||
waited := time.Duration(0)
|
||||
for waited < waitTotal {
|
||||
if u.w.HasSlot() {
|
||||
break
|
||||
}
|
||||
time.Sleep(step)
|
||||
waited += step
|
||||
}
|
||||
}
|
||||
|
||||
busy := !u.w.HasSlot()
|
||||
if busy {
|
||||
if u.w.RoomId == "" {
|
||||
u.Notify(api.ErrNoFreeSlots, "")
|
||||
return
|
||||
}
|
||||
if rq.RoomId == "" {
|
||||
// No room id but worker is busy -> assume user wants to continue
|
||||
// the existing room instead of starting a parallel game.
|
||||
rq.RoomId = u.w.RoomId
|
||||
} else if rq.RoomId != u.w.RoomId {
|
||||
u.Notify(api.ErrNoFreeSlots, "")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Worker is free: try to reserve the single slot for this new room.
|
||||
if !u.w.TryReserve() {
|
||||
u.Notify(api.ErrNoFreeSlots, "")
|
||||
return
|
||||
}
|
||||
game = name
|
||||
}
|
||||
|
||||
gameInfo, err := launcher.FindAppByName(game)
|
||||
if err != nil {
|
||||
u.Log.Error().Err(err).Str("game", game).Msg("couldn't find game info")
|
||||
return
|
||||
}
|
||||
|
||||
startGameResp, err := u.w.StartGame(u.Id(), gameInfo, rq)
|
||||
startGameResp, err := u.w.StartGame(u.Id().String(), rq)
|
||||
if err != nil || startGameResp == nil {
|
||||
u.Log.Error().Err(err).Msg("malformed game start response")
|
||||
u.log.Error().Err(err).Msg("malformed game start response")
|
||||
return
|
||||
}
|
||||
if startGameResp.Rid == "" {
|
||||
u.Log.Error().Msg("there is no room")
|
||||
u.log.Error().Msg("there is no room")
|
||||
return
|
||||
}
|
||||
u.Log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker")
|
||||
u.StartGame()
|
||||
u.log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker")
|
||||
u.StartGame(startGameResp.AV, startGameResp.KbMouse)
|
||||
|
||||
// send back recording status
|
||||
if conf.Recording.Enabled && rq.Record {
|
||||
|
|
@ -64,22 +95,36 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launc
|
|||
}
|
||||
|
||||
func (u *User) HandleQuitGame(rq api.GameQuitRequest) {
|
||||
if rq.Room.Rid == u.w.RoomId {
|
||||
u.w.QuitGame(u.Id())
|
||||
if rq.Rid == u.w.RoomId {
|
||||
u.w.QuitGame(u.Id().String())
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) HandleResetGame(rq api.ResetGameRequest) {
|
||||
if rq.Rid != u.w.RoomId {
|
||||
return
|
||||
}
|
||||
u.w.ResetGame(u.Id().String())
|
||||
}
|
||||
|
||||
func (u *User) HandleSaveGame() error {
|
||||
resp, err := u.w.SaveGame(u.Id())
|
||||
resp, err := u.w.SaveGame(u.Id().String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *resp == api.OK {
|
||||
if id, _ := api.ExplodeDeepLink(u.w.RoomId); id != "" {
|
||||
u.w.AddSession(id)
|
||||
}
|
||||
}
|
||||
|
||||
u.Notify(api.SaveGame, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) HandleLoadGame() error {
|
||||
resp, err := u.w.LoadGame(u.Id())
|
||||
resp, err := u.w.LoadGame(u.Id().String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -88,52 +133,52 @@ func (u *User) HandleLoadGame() error {
|
|||
}
|
||||
|
||||
func (u *User) HandleChangePlayer(rq api.ChangePlayerUserRequest) {
|
||||
resp, err := u.w.ChangePlayer(u.Id(), int(rq))
|
||||
resp, err := u.w.ChangePlayer(u.Id().String(), int(rq))
|
||||
// !to make it a little less convoluted
|
||||
if err != nil || resp == nil || *resp == -1 {
|
||||
u.Log.Error().Err(err).Msg("player switch failed for some reason")
|
||||
u.log.Error().Err(err).Msgf("player select fail, req: %v", rq)
|
||||
return
|
||||
}
|
||||
u.Notify(api.ChangePlayer, rq)
|
||||
}
|
||||
|
||||
func (u *User) HandleToggleMultitap() { u.w.ToggleMultitap(u.Id()) }
|
||||
|
||||
func (u *User) HandleRecordGame(rq api.RecordGameRequest) {
|
||||
if u.w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
u.Log.Debug().Msgf("??? room: %v, rec: %v user: %v", u.w.RoomId, rq.Active, rq.User)
|
||||
u.log.Debug().Msgf("??? room: %v, rec: %v user: %v", u.w.RoomId, rq.Active, rq.User)
|
||||
|
||||
if u.w.RoomId == "" {
|
||||
u.Log.Error().Msg("Recording in the empty room is not allowed!")
|
||||
u.log.Error().Msg("Recording in the empty room is not allowed!")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := u.w.RecordGame(u.Id(), rq.Active, rq.User)
|
||||
resp, err := u.w.RecordGame(u.Id().String(), rq.Active, rq.User)
|
||||
if err != nil {
|
||||
u.Log.Error().Err(err).Msg("malformed game record request")
|
||||
u.log.Error().Err(err).Msg("malformed game record request")
|
||||
return
|
||||
}
|
||||
u.Notify(api.RecordGame, resp)
|
||||
}
|
||||
|
||||
func (u *User) handleGetWorkerList(debug bool, info api.HasServerInfo) {
|
||||
func (u *User) handleGetWorkerList(debug bool, info HasServerInfo) {
|
||||
response := api.GetWorkerListResponse{}
|
||||
servers := info.GetServerList()
|
||||
|
||||
if debug {
|
||||
response.Servers = servers
|
||||
} else {
|
||||
// not sure if []byte to string always reversible :/
|
||||
unique := map[string]*api.Server{}
|
||||
for _, s := range servers {
|
||||
mid := s.Id.Machine()
|
||||
mid := s.Machine
|
||||
if _, ok := unique[mid]; !ok {
|
||||
unique[mid] = &api.Server{Addr: s.Addr, PingURL: s.PingURL, Id: s.Id, InGroup: true}
|
||||
}
|
||||
unique[mid].Replicas++
|
||||
v := unique[mid]
|
||||
if v != nil {
|
||||
v.Replicas++
|
||||
}
|
||||
}
|
||||
for _, v := range unique {
|
||||
response.Servers = append(response.Servers, *v)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/com"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/com"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
com.SocketClient
|
||||
com.RegionalClient
|
||||
AppLibrary
|
||||
Connection
|
||||
RegionalClient
|
||||
Session
|
||||
slotted
|
||||
|
||||
Addr string
|
||||
|
|
@ -18,38 +23,111 @@ type Worker struct {
|
|||
RoomId string // room reference
|
||||
Tag string
|
||||
Zone string
|
||||
|
||||
Lib []api.GameInfo
|
||||
Sessions map[string]struct{}
|
||||
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func (w *Worker) HandleRequests(users *com.NetMap[*User]) {
|
||||
// !to make a proper multithreading abstraction
|
||||
w.OnPacket(func(p com.In) error {
|
||||
type RegionalClient interface {
|
||||
In(region string) bool
|
||||
}
|
||||
|
||||
type HasUserRegistry interface {
|
||||
Find(id string) *User
|
||||
}
|
||||
|
||||
type AppLibrary interface {
|
||||
SetLib([]api.GameInfo)
|
||||
AppNames() []api.GameInfo
|
||||
}
|
||||
|
||||
type Session interface {
|
||||
AddSession(id string)
|
||||
// HadSession is true when an old session is found
|
||||
HadSession(id string) bool
|
||||
SetSessions(map[string]struct{})
|
||||
}
|
||||
|
||||
type AppMeta struct {
|
||||
Alias string
|
||||
Base string
|
||||
Name string
|
||||
Path string
|
||||
System string
|
||||
Type string
|
||||
}
|
||||
|
||||
func NewWorker(sock *com.Connection, handshake api.ConnectionRequest[com.Uid], log *logger.Logger) *Worker {
|
||||
conn := com.NewConnection[api.PT, api.In[com.Uid], api.Out, *api.Out](sock, handshake.Id, log)
|
||||
return &Worker{
|
||||
Connection: conn,
|
||||
Addr: handshake.Addr,
|
||||
PingServer: handshake.PingURL,
|
||||
Port: handshake.Port,
|
||||
Tag: handshake.Tag,
|
||||
Zone: handshake.Zone,
|
||||
log: log.Extend(log.With().
|
||||
Str(logger.ClientField, logger.MarkNone).
|
||||
Str(logger.DirectionField, logger.MarkNone).
|
||||
Str("cid", conn.Id().Short())),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) HandleRequests(users HasUserRegistry) chan struct{} {
|
||||
return w.ProcessPackets(func(p api.In[com.Uid]) (err error) {
|
||||
switch p.T {
|
||||
case api.RegisterRoom:
|
||||
rq := api.Unwrap[api.RegisterRoomRequest](p.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
w.Log.Info().Msgf("set room [%v] = %v", w.Id(), *rq)
|
||||
w.HandleRegisterRoom(*rq)
|
||||
err = api.Do(p, func(d api.RegisterRoomRequest) {
|
||||
w.log.Info().Msgf("set room [%v] = %v", w.Id(), d)
|
||||
w.HandleRegisterRoom(d)
|
||||
})
|
||||
case api.CloseRoom:
|
||||
rq := api.Unwrap[api.CloseRoomRequest](p.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
w.HandleCloseRoom(*rq)
|
||||
err = api.Do(p, w.HandleCloseRoom)
|
||||
case api.IceCandidate:
|
||||
rq := api.Unwrap[api.WebrtcIceCandidateRequest](p.Payload)
|
||||
if rq == nil {
|
||||
return api.ErrMalformed
|
||||
}
|
||||
w.HandleIceCandidate(*rq, users)
|
||||
err = api.DoE(p, func(d api.WebrtcIceCandidateRequest) error {
|
||||
return w.HandleIceCandidate(d, users)
|
||||
})
|
||||
case api.LibNewGameList:
|
||||
err = api.DoE(p, w.HandleLibGameList)
|
||||
case api.PrevSessions:
|
||||
err = api.DoE(p, w.HandlePrevSessionList)
|
||||
default:
|
||||
w.Log.Warn().Msgf("Unknown packet: %+v", p)
|
||||
w.log.Warn().Msgf("Unknown packet: %+v", p)
|
||||
}
|
||||
return nil
|
||||
if err != nil && !errors.Is(err, api.ErrMalformed) {
|
||||
w.log.Error().Err(err).Send()
|
||||
err = api.ErrMalformed
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Worker) SetLib(list []api.GameInfo) { w.Lib = list }
|
||||
|
||||
func (w *Worker) AppNames() []api.GameInfo {
|
||||
return w.Lib
|
||||
}
|
||||
|
||||
func (w *Worker) AddSession(id string) {
|
||||
// sessions can be uninitialized until the coordinator pushes them to the worker
|
||||
if w.Sessions == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.Sessions[id] = struct{}{}
|
||||
}
|
||||
|
||||
func (w *Worker) HadSession(id string) bool {
|
||||
_, ok := w.Sessions[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (w *Worker) SetSessions(sessions map[string]struct{}) {
|
||||
w.Sessions = sessions
|
||||
}
|
||||
|
||||
// In say whether some worker from this region (zone).
|
||||
// Empty region always returns true.
|
||||
func (w *Worker) In(region string) bool { return region == "" || region == w.Zone }
|
||||
|
|
@ -62,20 +140,52 @@ type slotted int32
|
|||
// there are no players in the room (worker).
|
||||
func (s *slotted) HasSlot() bool { return atomic.LoadInt32((*int32)(s)) == 0 }
|
||||
|
||||
// Reserve increments user counter of the worker.
|
||||
func (s *slotted) Reserve() { atomic.AddInt32((*int32)(s), 1) }
|
||||
// TryReserve reserves the slot only when it's free.
|
||||
func (s *slotted) TryReserve() bool {
|
||||
for {
|
||||
current := atomic.LoadInt32((*int32)(s))
|
||||
if current != 0 {
|
||||
return false
|
||||
}
|
||||
if atomic.CompareAndSwapInt32((*int32)(s), 0, 1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UnReserve decrements user counter of the worker.
|
||||
func (s *slotted) UnReserve() {
|
||||
if atomic.AddInt32((*int32)(s), -1) < 0 {
|
||||
atomic.StoreInt32((*int32)(s), 0)
|
||||
for {
|
||||
current := atomic.LoadInt32((*int32)(s))
|
||||
if current <= 0 {
|
||||
// reset to zero
|
||||
if current < 0 {
|
||||
if atomic.CompareAndSwapInt32((*int32)(s), current, 0) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Regular decrement for positive values
|
||||
newVal := current - 1
|
||||
if atomic.CompareAndSwapInt32((*int32)(s), current, newVal) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *slotted) FreeSlots() { atomic.StoreInt32((*int32)(s), 0) }
|
||||
|
||||
func (w *Worker) Disconnect() {
|
||||
w.SocketClient.Close()
|
||||
w.Connection.Disconnect()
|
||||
w.RoomId = ""
|
||||
w.FreeSlots()
|
||||
}
|
||||
|
||||
func (w *Worker) PrintInfo() string {
|
||||
return fmt.Sprintf("id: %v, addr: %v, port: %v, zone: %v, ping addr: %v, tag: %v",
|
||||
w.Id(), w.Addr, w.Port, w.Zone, w.PingServer, w.Tag)
|
||||
}
|
||||
|
|
|
|||
193
pkg/coordinator/worker_test.go
Normal file
193
pkg/coordinator/worker_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSlotted(t *testing.T) {
|
||||
t.Run("UnReserve", func(t *testing.T) {
|
||||
t.Run("BasicDecrement", testUnReserveBasic)
|
||||
t.Run("PreventUnderflow", testUnReserveUnderflow)
|
||||
t.Run("ConcurrentDecrement", testUnReserveConcurrent)
|
||||
})
|
||||
|
||||
t.Run("TryReserve", func(t *testing.T) {
|
||||
t.Run("SuccessWhenZero", testTryReserveSuccess)
|
||||
t.Run("FailWhenNonZero", testTryReserveFailure)
|
||||
t.Run("ConcurrentReservations", testTryReserveConcurrent)
|
||||
})
|
||||
|
||||
t.Run("Integration", func(t *testing.T) {
|
||||
t.Run("ReserveUnreserveFlow", testReserveUnreserveFlow)
|
||||
t.Run("FreeSlots", testFreeSlots)
|
||||
t.Run("HasSlot", testHasSlot)
|
||||
})
|
||||
}
|
||||
|
||||
func testUnReserveBasic(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
// Initial state
|
||||
if atomic.LoadInt32((*int32)(&s)) != 0 {
|
||||
t.Fatal("initial state not zero")
|
||||
}
|
||||
|
||||
// Test normal decrement
|
||||
s.TryReserve() // 0 -> 1
|
||||
s.UnReserve()
|
||||
if atomic.LoadInt32((*int32)(&s)) != 0 {
|
||||
t.Error("failed to decrement to zero")
|
||||
}
|
||||
|
||||
// Test multiple decrements
|
||||
s.TryReserve() // 0 -> 1
|
||||
s.TryReserve() // 1 -> 2
|
||||
s.UnReserve()
|
||||
s.UnReserve()
|
||||
if atomic.LoadInt32((*int32)(&s)) != 0 {
|
||||
t.Error("failed to decrement multiple times")
|
||||
}
|
||||
}
|
||||
|
||||
func testUnReserveUnderflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
t.Run("PreventNewUnderflow", func(t *testing.T) {
|
||||
s.UnReserve() // Start at 0
|
||||
if atomic.LoadInt32((*int32)(&s)) != 0 {
|
||||
t.Error("should remain at 0 when unreserving from 0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FixExistingNegative", func(t *testing.T) {
|
||||
atomic.StoreInt32((*int32)(&s), -5)
|
||||
s.UnReserve()
|
||||
if current := atomic.LoadInt32((*int32)(&s)); current != 0 {
|
||||
t.Errorf("should fix negative value to 0, got %d", current)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testUnReserveConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var s slotted
|
||||
const workers = 100
|
||||
var wg sync.WaitGroup
|
||||
|
||||
atomic.StoreInt32((*int32)(&s), int32(workers))
|
||||
wg.Add(workers)
|
||||
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
s.UnReserve()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if current := atomic.LoadInt32((*int32)(&s)); current != 0 {
|
||||
t.Errorf("unexpected final value: %d (want 0)", current)
|
||||
}
|
||||
}
|
||||
|
||||
func testTryReserveSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
if !s.TryReserve() {
|
||||
t.Error("should succeed when zero")
|
||||
}
|
||||
if atomic.LoadInt32((*int32)(&s)) != 1 {
|
||||
t.Error("failed to increment")
|
||||
}
|
||||
}
|
||||
|
||||
func testTryReserveFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
atomic.StoreInt32((*int32)(&s), 1)
|
||||
if s.TryReserve() {
|
||||
t.Error("should fail when non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
func testTryReserveConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
const workers = 100
|
||||
var success int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if s.TryReserve() {
|
||||
atomic.AddInt32(&success, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if success != 1 {
|
||||
t.Errorf("unexpected success count: %d (want 1)", success)
|
||||
}
|
||||
if atomic.LoadInt32((*int32)(&s)) != 1 {
|
||||
t.Error("counter not properly incremented")
|
||||
}
|
||||
}
|
||||
|
||||
func testReserveUnreserveFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
// Successful reservation
|
||||
if !s.TryReserve() {
|
||||
t.Fatal("failed initial reservation")
|
||||
}
|
||||
|
||||
// Second reservation should fail
|
||||
if s.TryReserve() {
|
||||
t.Error("unexpected successful second reservation")
|
||||
}
|
||||
|
||||
// Unreserve and try again
|
||||
s.UnReserve()
|
||||
if !s.TryReserve() {
|
||||
t.Error("failed reservation after unreserve")
|
||||
}
|
||||
}
|
||||
|
||||
func testFreeSlots(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
// Set to arbitrary value
|
||||
atomic.StoreInt32((*int32)(&s), 5)
|
||||
s.FreeSlots()
|
||||
if atomic.LoadInt32((*int32)(&s)) != 0 {
|
||||
t.Error("FreeSlots failed to reset counter")
|
||||
}
|
||||
}
|
||||
|
||||
func testHasSlot(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s slotted
|
||||
|
||||
if !s.HasSlot() {
|
||||
t.Error("should have slot when zero")
|
||||
}
|
||||
|
||||
s.TryReserve()
|
||||
if s.HasSlot() {
|
||||
t.Error("shouldn't have slot when reserved")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +1,68 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/games"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network"
|
||||
)
|
||||
import "github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
|
||||
func (w *Worker) WebrtcInit(id network.Uid) (*api.WebrtcInitResponse, error) {
|
||||
func (w *Worker) WebrtcInit(id string) (*api.WebrtcInitResponse, error) {
|
||||
return api.UnwrapChecked[api.WebrtcInitResponse](
|
||||
w.Send(api.WebrtcInit, api.WebrtcInitRequest{Stateful: api.Stateful{Id: id}}))
|
||||
w.Send(api.WebrtcInit, api.WebrtcInitRequest{Id: id}))
|
||||
}
|
||||
|
||||
func (w *Worker) WebrtcAnswer(id network.Uid, sdp string) {
|
||||
w.Notify(api.WebrtcAnswer, api.WebrtcAnswerRequest{Stateful: api.Stateful{Id: id}, Sdp: sdp})
|
||||
func (w *Worker) WebrtcAnswer(id string, sdp string) {
|
||||
w.Notify(api.WebrtcAnswer,
|
||||
api.WebrtcAnswerRequest{Stateful: api.Stateful{Id: id}, Sdp: sdp})
|
||||
}
|
||||
|
||||
func (w *Worker) WebrtcIceCandidate(id network.Uid, can string) {
|
||||
w.Notify(api.NewWebrtcIceCandidateRequest(id, can))
|
||||
func (w *Worker) WebrtcIceCandidate(id string, candidate string) {
|
||||
w.Notify(api.WebrtcIce,
|
||||
api.WebrtcIceCandidateRequest{Stateful: api.Stateful{Id: id}, Candidate: candidate})
|
||||
}
|
||||
|
||||
func (w *Worker) StartGame(id network.Uid, app games.AppMeta, req api.GameStartUserRequest) (*api.StartGameResponse, error) {
|
||||
return api.UnwrapChecked[api.StartGameResponse](w.Send(api.StartGame, api.StartGameRequest{
|
||||
StatefulRoom: api.StateRoom(id, req.RoomId),
|
||||
Game: api.GameInfo{Name: app.Name, Base: app.Base, Path: app.Path, Type: app.Type},
|
||||
PlayerIndex: req.PlayerIndex,
|
||||
Record: req.Record,
|
||||
RecordUser: req.RecordUser,
|
||||
}))
|
||||
func (w *Worker) StartGame(id string, req api.GameStartUserRequest) (*api.StartGameResponse, error) {
|
||||
return api.UnwrapChecked[api.StartGameResponse](
|
||||
w.Send(api.StartGame, api.StartGameRequest{
|
||||
StatefulRoom: api.StatefulRoom{Id: id, Rid: req.RoomId},
|
||||
Game: req.GameName,
|
||||
PlayerIndex: req.PlayerIndex,
|
||||
Record: req.Record,
|
||||
RecordUser: req.RecordUser,
|
||||
}))
|
||||
}
|
||||
|
||||
func (w *Worker) QuitGame(id network.Uid) {
|
||||
w.Notify(api.QuitGame, api.GameQuitRequest{StatefulRoom: api.StateRoom(id, w.RoomId)})
|
||||
func (w *Worker) QuitGame(id string) {
|
||||
w.Notify(api.QuitGame, api.GameQuitRequest{Id: id, Rid: w.RoomId})
|
||||
}
|
||||
|
||||
func (w *Worker) SaveGame(id network.Uid) (*api.SaveGameResponse, error) {
|
||||
func (w *Worker) SaveGame(id string) (*api.SaveGameResponse, error) {
|
||||
return api.UnwrapChecked[api.SaveGameResponse](
|
||||
w.Send(api.SaveGame, api.SaveGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId)}))
|
||||
w.Send(api.SaveGame, api.SaveGameRequest{Id: id, Rid: w.RoomId}))
|
||||
}
|
||||
|
||||
func (w *Worker) LoadGame(id network.Uid) (*api.LoadGameResponse, error) {
|
||||
func (w *Worker) LoadGame(id string) (*api.LoadGameResponse, error) {
|
||||
return api.UnwrapChecked[api.LoadGameResponse](
|
||||
w.Send(api.LoadGame, api.LoadGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId)}))
|
||||
w.Send(api.LoadGame, api.LoadGameRequest{Id: id, Rid: w.RoomId}))
|
||||
}
|
||||
|
||||
func (w *Worker) ChangePlayer(id network.Uid, index int) (*api.ChangePlayerResponse, error) {
|
||||
func (w *Worker) ChangePlayer(id string, index int) (*api.ChangePlayerResponse, error) {
|
||||
return api.UnwrapChecked[api.ChangePlayerResponse](
|
||||
w.Send(api.ChangePlayer, api.ChangePlayerRequest{StatefulRoom: api.StateRoom(id, w.RoomId), Index: index}))
|
||||
w.Send(api.ChangePlayer, api.ChangePlayerRequest{
|
||||
StatefulRoom: api.StatefulRoom{Id: id, Rid: w.RoomId},
|
||||
Index: index,
|
||||
}))
|
||||
}
|
||||
|
||||
func (w *Worker) ToggleMultitap(id network.Uid) {
|
||||
_, _ = w.Send(api.ToggleMultitap, api.ToggleMultitapRequest{StatefulRoom: api.StateRoom(id, w.RoomId)})
|
||||
func (w *Worker) ResetGame(id string) {
|
||||
w.Notify(api.ResetGame, api.ResetGameRequest{Id: id, Rid: w.RoomId})
|
||||
}
|
||||
|
||||
func (w *Worker) RecordGame(id network.Uid, rec bool, recUser string) (*api.RecordGameResponse, error) {
|
||||
func (w *Worker) RecordGame(id string, rec bool, recUser string) (*api.RecordGameResponse, error) {
|
||||
return api.UnwrapChecked[api.RecordGameResponse](
|
||||
w.Send(api.RecordGame, api.RecordGameRequest{StatefulRoom: api.StateRoom(id, w.RoomId), Active: rec, User: recUser}))
|
||||
w.Send(api.RecordGame, api.RecordGameRequest{
|
||||
StatefulRoom: api.StatefulRoom{Id: id, Rid: w.RoomId},
|
||||
Active: rec,
|
||||
User: recUser,
|
||||
}))
|
||||
}
|
||||
|
||||
func (w *Worker) TerminateSession(id network.Uid) {
|
||||
_, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest{Stateful: api.Stateful{Id: id}})
|
||||
func (w *Worker) TerminateSession(id string) {
|
||||
_, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest{Id: id})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,39 @@
|
|||
package coordinator
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/api"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/com"
|
||||
)
|
||||
|
||||
func GetConnectionRequest(data string) (*api.ConnectionRequest, error) {
|
||||
if data == "" {
|
||||
return nil, fmt.Errorf("no data")
|
||||
}
|
||||
return api.UnwrapChecked[api.ConnectionRequest](base64.URLEncoding.DecodeString(data))
|
||||
}
|
||||
import "github.com/giongto35/cloud-game/v3/pkg/api"
|
||||
|
||||
func (w *Worker) HandleRegisterRoom(rq api.RegisterRoomRequest) { w.RoomId = string(rq) }
|
||||
|
||||
func (w *Worker) HandleCloseRoom(rq api.CloseRoomRequest) {
|
||||
if string(rq) == w.RoomId {
|
||||
w.RoomId = ""
|
||||
w.FreeSlots()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) HandleIceCandidate(rq api.WebrtcIceCandidateRequest, users *com.NetMap[*User]) {
|
||||
if usr, err := users.Find(string(rq.Id)); err == nil {
|
||||
func (w *Worker) HandleIceCandidate(rq api.WebrtcIceCandidateRequest, users HasUserRegistry) error {
|
||||
if usr := users.Find(rq.Id); usr != nil {
|
||||
usr.SendWebrtcIceCandidate(rq.Candidate)
|
||||
} else {
|
||||
w.Log.Warn().Str("id", rq.Id.String()).Msg("unknown session")
|
||||
w.log.Warn().Str("id", rq.Id).Msg("unknown session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleLibGameList(inf api.LibGameListInfo) error {
|
||||
w.SetLib(inf.List)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandlePrevSessionList(sess api.PrevSessionInfo) error {
|
||||
if len(sess.List) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := make(map[string]struct{})
|
||||
for _, v := range sess.List {
|
||||
m[v] = struct{}{}
|
||||
}
|
||||
w.SetSessions(m)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
56
pkg/encoder/color/bgra/bgra.go
Normal file
56
pkg/encoder/color/bgra/bgra.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package bgra
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
type BGRA struct {
|
||||
image.RGBA
|
||||
}
|
||||
|
||||
var BGRAModel = color.ModelFunc(func(c color.Color) color.Color {
|
||||
if _, ok := c.(BGRAColor); ok {
|
||||
return c
|
||||
}
|
||||
r, g, b, a := c.RGBA()
|
||||
return BGRAColor{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)}
|
||||
})
|
||||
|
||||
// BGRAColor represents a BGRA color.
|
||||
type BGRAColor struct {
|
||||
R, G, B, A uint8
|
||||
}
|
||||
|
||||
func (c BGRAColor) RGBA() (r, g, b, a uint32) {
|
||||
r = uint32(c.B)
|
||||
r |= r << 8
|
||||
g = uint32(c.G)
|
||||
g |= g << 8
|
||||
b = uint32(c.R)
|
||||
b |= b << 8
|
||||
a = uint32(255) //uint32(c.A)
|
||||
a |= a << 8
|
||||
return
|
||||
}
|
||||
|
||||
func NewBGRA(r image.Rectangle) *BGRA {
|
||||
return &BGRA{*image.NewRGBA(r)}
|
||||
}
|
||||
|
||||
func (p *BGRA) ColorModel() color.Model { return BGRAModel }
|
||||
func (p *BGRA) At(x, y int) color.Color {
|
||||
i := p.PixOffset(x, y)
|
||||
s := p.Pix[i : i+4 : i+4]
|
||||
return BGRAColor{s[0], s[1], s[2], s[3]}
|
||||
}
|
||||
|
||||
func (p *BGRA) Set(x, y int, c color.Color) {
|
||||
i := p.PixOffset(x, y)
|
||||
c1 := BGRAModel.Convert(c).(BGRAColor)
|
||||
s := p.Pix[i : i+4 : i+4]
|
||||
s[0] = c1.R
|
||||
s[1] = c1.G
|
||||
s[2] = c1.B
|
||||
s[3] = 255
|
||||
}
|
||||
62
pkg/encoder/color/rgb565/rgb565.go
Normal file
62
pkg/encoder/color/rgb565/rgb565.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package rgb565
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
// RGB565 is an in-memory image whose At method returns RGB565 values.
|
||||
type RGB565 struct {
|
||||
// Pix holds the image's pixels, as RGB565 values in big-endian format. The pixel at
|
||||
// (x, y) starts at Pix[(y-p.Rect.Min.Y)*p.Stride + (x-p.Rect.Min.X)*2].
|
||||
Pix []uint8
|
||||
// Stride is the Pix stride (in bytes) between vertically adjacent pixels.
|
||||
Stride int
|
||||
// Rect is the image's bounds.
|
||||
Rect image.Rectangle
|
||||
}
|
||||
|
||||
// Model is the model for RGB565 colors.
|
||||
var Model = color.ModelFunc(func(c color.Color) color.Color {
|
||||
//if _, ok := c.(Color); ok {
|
||||
// return c
|
||||
//}
|
||||
r, g, b, _ := c.RGBA()
|
||||
return Color(uint16((r<<8)&rMask | (g<<3)&gMask | (b>>3)&bMask))
|
||||
})
|
||||
|
||||
const (
|
||||
rMask = 0b1111100000000000
|
||||
gMask = 0b0000011111100000
|
||||
bMask = 0b0000000000011111
|
||||
)
|
||||
|
||||
// Color represents an RGB565 color.
|
||||
type Color uint16
|
||||
|
||||
func (c Color) RGBA() (r, g, b, a uint32) {
|
||||
return uint32(math.Round(float64(c&rMask>>11)*255.0/31.0)) << 8,
|
||||
uint32(math.Round(float64(c&gMask>>5)*255.0/63.0)) << 8,
|
||||
uint32(math.Round(float64(c&bMask)*255.0/31.0)) << 8,
|
||||
0xffff
|
||||
}
|
||||
|
||||
func NewRGB565(r image.Rectangle) *RGB565 {
|
||||
return &RGB565{Pix: make([]uint8, r.Dx()*r.Dy()<<1), Stride: r.Dx() << 1, Rect: r}
|
||||
}
|
||||
|
||||
func (p *RGB565) Bounds() image.Rectangle { return p.Rect }
|
||||
func (p *RGB565) ColorModel() color.Model { return Model }
|
||||
func (p *RGB565) PixOffset(x, y int) int { return (x-p.Rect.Min.X)<<1 + (y-p.Rect.Min.Y)*p.Stride }
|
||||
|
||||
func (p *RGB565) At(x, y int) color.Color {
|
||||
i := p.PixOffset(x, y)
|
||||
return Color(binary.LittleEndian.Uint16(p.Pix[i : i+2]))
|
||||
}
|
||||
|
||||
func (p *RGB565) Set(x, y int, c color.Color) {
|
||||
i := p.PixOffset(x, y)
|
||||
binary.LittleEndian.PutUint16(p.Pix[i:i+2], uint16(Model.Convert(c).(Color)))
|
||||
}
|
||||
24
pkg/encoder/color/rgba/rgba.go
Normal file
24
pkg/encoder/color/rgba/rgba.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package rgba
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
)
|
||||
|
||||
func ToRGBA(img image.Image, flipped bool) *image.RGBA {
|
||||
bounds := img.Bounds()
|
||||
sw, sh := bounds.Dx(), bounds.Dy()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, sw, sh))
|
||||
for y := range sh {
|
||||
yy := y
|
||||
if flipped {
|
||||
yy = sh - y
|
||||
}
|
||||
for x := range sw {
|
||||
px := img.At(x, y)
|
||||
rgba := color.RGBAModel.Convert(px).(color.RGBA)
|
||||
dst.Set(x, yy, rgba)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
146
pkg/encoder/encoder.go
Normal file
146
pkg/encoder/encoder.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package encoder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/encoder/h264"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/encoder/vpx"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/encoder/yuv"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
type (
|
||||
InFrame yuv.RawFrame
|
||||
OutFrame []byte
|
||||
Encoder interface {
|
||||
Encode([]byte) []byte
|
||||
IntraRefresh()
|
||||
Info() string
|
||||
SetFlip(bool)
|
||||
Shutdown() error
|
||||
}
|
||||
)
|
||||
|
||||
type Video struct {
|
||||
codec Encoder
|
||||
log *logger.Logger
|
||||
stopped atomic.Bool
|
||||
y yuv.Conv
|
||||
pf yuv.PixFmt
|
||||
rot uint
|
||||
}
|
||||
|
||||
type VideoCodec string
|
||||
|
||||
const (
|
||||
H264 VideoCodec = "h264"
|
||||
VP8 VideoCodec = "vp8"
|
||||
VP9 VideoCodec = "vp9"
|
||||
VPX VideoCodec = "vpx"
|
||||
)
|
||||
|
||||
// NewVideoEncoder returns new video encoder.
|
||||
// By default, it waits for RGBA images on the input channel,
|
||||
// converts them into YUV I420 format,
|
||||
// encodes with provided video encoder, and
|
||||
// puts the result into the output channel.
|
||||
func NewVideoEncoder(w, h, dw, dh int, scale float64, conf config.Video, log *logger.Logger) (*Video, error) {
|
||||
var enc Encoder
|
||||
var err error
|
||||
codec := VideoCodec(conf.Codec)
|
||||
switch codec {
|
||||
case H264:
|
||||
opts := h264.Options(conf.H264)
|
||||
enc, err = h264.NewEncoder(dw, dh, conf.Threads, &opts)
|
||||
case VP8, VP9, VPX:
|
||||
opts := vpx.Options(conf.Vpx)
|
||||
v := 8
|
||||
if codec == VP9 {
|
||||
v = 9
|
||||
}
|
||||
enc, err = vpx.NewEncoder(dw, dh, conf.Threads, v, &opts)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported codec: %v", conf.Codec)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if enc == nil {
|
||||
return nil, fmt.Errorf("no encoder")
|
||||
}
|
||||
|
||||
return &Video{codec: enc, y: yuv.NewYuvConv(w, h, scale), log: log}, nil
|
||||
}
|
||||
|
||||
func (v *Video) Encode(frame InFrame) OutFrame {
|
||||
if v.stopped.Load() {
|
||||
return nil
|
||||
}
|
||||
|
||||
yCbCr := v.y.Process(yuv.RawFrame(frame), v.rot, v.pf)
|
||||
//defer v.y.Put(&yCbCr)
|
||||
if bytes := v.codec.Encode(yCbCr); len(bytes) > 0 {
|
||||
return bytes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Video) Info() string {
|
||||
return fmt.Sprintf("%v, libyuv: %v", v.codec.Info(), v.y.Version())
|
||||
}
|
||||
|
||||
func (v *Video) SetPixFormat(f uint32) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch f {
|
||||
case 0:
|
||||
v.pf = yuv.PixFmt(yuv.FourccRgb0)
|
||||
case 1:
|
||||
v.pf = yuv.PixFmt(yuv.FourccArgb)
|
||||
case 2:
|
||||
v.pf = yuv.PixFmt(yuv.FourccRgbp)
|
||||
default:
|
||||
v.pf = yuv.PixFmt(yuv.FourccAbgr)
|
||||
}
|
||||
}
|
||||
|
||||
// SetRot sets the de-rotation angle of the frames.
|
||||
func (v *Video) SetRot(a uint) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if a > 0 {
|
||||
v.rot = (a + 180) % 360
|
||||
}
|
||||
}
|
||||
|
||||
// SetFlip tells the encoder to flip the frames vertically.
|
||||
func (v *Video) SetFlip(b bool) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
v.codec.SetFlip(b)
|
||||
}
|
||||
|
||||
func (v *Video) Stop() {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if v.stopped.Swap(true) {
|
||||
return
|
||||
}
|
||||
v.rot = 0
|
||||
|
||||
defer func() { v.codec = nil }()
|
||||
if err := v.codec.Shutdown(); err != nil {
|
||||
if v.log != nil {
|
||||
v.log.Error().Err(err).Msg("failed to close the encoder")
|
||||
}
|
||||
}
|
||||
}
|
||||
206
pkg/encoder/h264/x264.go
Normal file
206
pkg/encoder/h264/x264.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package h264
|
||||
|
||||
/*
|
||||
// See: [x264](https://www.videolan.org/developers/x264.html)
|
||||
#cgo !st pkg-config: x264
|
||||
#cgo st LDFLAGS: -l:libx264.a
|
||||
|
||||
#include "stdint.h"
|
||||
#include "x264.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct
|
||||
{
|
||||
x264_t *h;
|
||||
x264_nal_t *nal; // array of NALs
|
||||
int i_nal; // number of NALs
|
||||
int y; // Y size
|
||||
int uv; // U or V size
|
||||
x264_picture_t pic;
|
||||
x264_picture_t pic_out;
|
||||
} h264;
|
||||
|
||||
h264 *h264_new(x264_param_t *param)
|
||||
{
|
||||
h264 tmp;
|
||||
x264_picture_t pic;
|
||||
|
||||
tmp.h = x264_encoder_open(param);
|
||||
if (!tmp.h)
|
||||
return NULL;
|
||||
|
||||
x264_picture_init(&pic);
|
||||
pic.img.i_csp = param->i_csp;
|
||||
pic.img.i_plane = 3;
|
||||
pic.img.i_stride[0] = param->i_width;
|
||||
pic.img.i_stride[1] = param->i_width >> 1;
|
||||
pic.img.i_stride[2] = param->i_width >> 1;
|
||||
tmp.pic = pic;
|
||||
|
||||
// crashes during x264_picture_clean :/
|
||||
//if (x264_picture_alloc(&pic, param->i_csp, param->i_width, param->i_height) < 0)
|
||||
// return NULL;
|
||||
|
||||
tmp.y = param->i_width * param->i_height;
|
||||
tmp.uv = tmp.y >> 2;
|
||||
|
||||
h264 *h = malloc(sizeof(h264));
|
||||
*h = tmp;
|
||||
return h;
|
||||
}
|
||||
|
||||
int h264_encode(h264 *h, uint8_t *yuv)
|
||||
{
|
||||
h->pic.img.plane[0] = yuv;
|
||||
h->pic.img.plane[1] = h->pic.img.plane[0] + h->y;
|
||||
h->pic.img.plane[2] = h->pic.img.plane[1] + h->uv;
|
||||
h->pic.i_pts += 1;
|
||||
return x264_encoder_encode(h->h, &h->nal, &h->i_nal, &h->pic, &h->pic_out);
|
||||
}
|
||||
|
||||
void h264_destroy(h264 *h)
|
||||
{
|
||||
if (h == NULL) return;
|
||||
x264_encoder_close(h->h);
|
||||
free(h);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type H264 struct {
|
||||
h *C.h264
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Mode string
|
||||
// Constant Rate Factor (CRF)
|
||||
// This method allows the encoder to attempt to achieve a certain output quality for the whole file
|
||||
// when output file size is of less importance.
|
||||
// The range of the CRF scale is 0–51, where 0 is lossless, 23 is the default, and 51 is the worst quality possible.
|
||||
Crf uint8
|
||||
// vbv-maxrate
|
||||
MaxRate int
|
||||
// vbv-bufsize
|
||||
BufSize int
|
||||
LogLevel int32
|
||||
// ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo.
|
||||
Preset string
|
||||
// baseline, main, high, high10, high422, high444.
|
||||
Profile string
|
||||
// film, animation, grain, stillimage, psnr, ssim, fastdecode, zerolatency.
|
||||
Tune string
|
||||
}
|
||||
|
||||
func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) {
|
||||
ver := Version()
|
||||
|
||||
if ver < 150 {
|
||||
return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", ver)
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &Options{
|
||||
Mode: "crf",
|
||||
Crf: 23,
|
||||
Tune: "zerolatency",
|
||||
Preset: "superfast",
|
||||
Profile: "baseline",
|
||||
}
|
||||
}
|
||||
|
||||
param := C.x264_param_t{}
|
||||
|
||||
if opts.Preset != "" && opts.Tune != "" {
|
||||
preset := C.CString(opts.Preset)
|
||||
tune := C.CString(opts.Tune)
|
||||
defer C.free(unsafe.Pointer(preset))
|
||||
defer C.free(unsafe.Pointer(tune))
|
||||
if C.x264_param_default_preset(¶m, preset, tune) < 0 {
|
||||
return nil, fmt.Errorf("x264: invalid preset/tune name")
|
||||
}
|
||||
} else {
|
||||
C.x264_param_default(¶m)
|
||||
}
|
||||
|
||||
if opts.Profile != "" {
|
||||
profile := C.CString(opts.Profile)
|
||||
defer C.free(unsafe.Pointer(profile))
|
||||
if C.x264_param_apply_profile(¶m, profile) < 0 {
|
||||
return nil, fmt.Errorf("x264: invalid profile name")
|
||||
}
|
||||
}
|
||||
|
||||
param.i_bitdepth = 8
|
||||
if ver > 155 {
|
||||
param.i_csp = C.X264_CSP_I420
|
||||
} else {
|
||||
param.i_csp = 1
|
||||
}
|
||||
param.i_width = C.int(w)
|
||||
param.i_height = C.int(h)
|
||||
param.i_log_level = C.int(opts.LogLevel)
|
||||
param.i_keyint_max = 120
|
||||
param.i_sync_lookahead = 0
|
||||
param.i_threads = C.int(th)
|
||||
if th != 1 {
|
||||
param.b_sliced_threads = 1
|
||||
}
|
||||
|
||||
param.rc.i_rc_method = C.X264_RC_CRF
|
||||
param.rc.f_rf_constant = C.float(opts.Crf)
|
||||
|
||||
if strings.ToLower(opts.Mode) == "cbr" {
|
||||
param.rc.i_rc_method = C.X264_RC_ABR
|
||||
param.i_nal_hrd = C.X264_NAL_HRD_CBR
|
||||
}
|
||||
|
||||
if opts.MaxRate > 0 {
|
||||
param.rc.i_bitrate = C.int(opts.MaxRate)
|
||||
param.rc.i_vbv_max_bitrate = C.int(opts.MaxRate)
|
||||
}
|
||||
if opts.BufSize > 0 {
|
||||
param.rc.i_vbv_buffer_size = C.int(opts.BufSize)
|
||||
}
|
||||
|
||||
h264 := C.h264_new(¶m)
|
||||
if h264 == nil {
|
||||
return nil, fmt.Errorf("x264: cannot open the encoder")
|
||||
}
|
||||
return &H264{h264}, nil
|
||||
}
|
||||
|
||||
func (e *H264) Encode(yuv []byte) []byte {
|
||||
bytes := C.h264_encode(e.h, (*C.uchar)(unsafe.SliceData(yuv)))
|
||||
// we merge multiple NALs stored in **nal into a single byte stream
|
||||
// ret contains the total size of NALs in bytes, i.e. each e.nal[...].p_payload * i_payload
|
||||
return unsafe.Slice((*byte)(e.h.nal.p_payload), bytes)
|
||||
}
|
||||
|
||||
func (e *H264) IntraRefresh() {
|
||||
// !to implement
|
||||
}
|
||||
|
||||
func (e *H264) Info() string { return fmt.Sprintf("x264: v%v", Version()) }
|
||||
|
||||
func (e *H264) SetFlip(b bool) {
|
||||
if b {
|
||||
(*e.h).pic.img.i_csp |= C.X264_CSP_VFLIP
|
||||
} else {
|
||||
(*e.h).pic.img.i_csp &= ^C.X264_CSP_VFLIP
|
||||
}
|
||||
}
|
||||
|
||||
func (e *H264) Shutdown() error {
|
||||
if e.h != nil {
|
||||
C.h264_destroy(e.h)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Version() int { return int(C.X264_BUILD) }
|
||||
29
pkg/encoder/h264/x264_test.go
Normal file
29
pkg/encoder/h264/x264_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package h264
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestH264Encode(t *testing.T) {
|
||||
h264, err := NewEncoder(120, 120, 0, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
data := make([]byte, 120*120*1.5)
|
||||
h264.Encode(data)
|
||||
if err := h264.Shutdown(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func Benchmark(b *testing.B) {
|
||||
w, h := 1920, 1080
|
||||
h264, err := NewEncoder(w, h, 0, nil)
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
return
|
||||
}
|
||||
data := make([]byte, int(float64(w)*float64(h)*1.5))
|
||||
for b.Loop() {
|
||||
h264.Encode(data)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package opus
|
|||
|
||||
/*
|
||||
#cgo pkg-config: opus
|
||||
#cgo st LDFLAGS: -l:libopus.a
|
||||
|
||||
#include <opus.h>
|
||||
|
||||
|
|
@ -2,6 +2,7 @@ package vpx
|
|||
|
||||
/*
|
||||
#cgo pkg-config: vpx
|
||||
#cgo st LDFLAGS: -l:libvpx.a
|
||||
|
||||
#include "vpx/vpx_encoder.h"
|
||||
#include "vpx/vpx_image.h"
|
||||
|
|
@ -11,6 +12,7 @@ package vpx
|
|||
#include <string.h>
|
||||
|
||||
#define VP8_FOURCC 0x30385056
|
||||
#define VP9_FOURCC 0x30395056
|
||||
|
||||
typedef struct VpxInterface {
|
||||
const char *const name;
|
||||
|
|
@ -41,7 +43,10 @@ FrameBuffer get_frame_buffer(vpx_codec_ctx_t *codec, vpx_codec_iter_t *iter) {
|
|||
return fb;
|
||||
}
|
||||
|
||||
const VpxInterface vpx_encoders[] = {{ "vp8", VP8_FOURCC, &vpx_codec_vp8_cx }};
|
||||
const VpxInterface vpx_encoders[] = {
|
||||
{ "vp8", VP8_FOURCC, &vpx_codec_vp8_cx },
|
||||
{ "vp9", VP9_FOURCC, &vpx_codec_vp9_cx },
|
||||
};
|
||||
|
||||
int vpx_img_plane_width(const vpx_image_t *img, int plane) {
|
||||
if (plane > 0 && img->x_chroma_shift > 0)
|
||||
|
|
@ -83,31 +88,40 @@ type Vpx struct {
|
|||
image C.vpx_image_t
|
||||
codecCtx C.vpx_codec_ctx_t
|
||||
kfi C.int
|
||||
flipped bool
|
||||
v int
|
||||
}
|
||||
|
||||
func (vpx *Vpx) SetFlip(b bool) { vpx.flipped = b }
|
||||
|
||||
type Options struct {
|
||||
// Target bandwidth to use for this stream, in kilobits per second.
|
||||
Bitrate uint
|
||||
// Force keyframe interval.
|
||||
KeyframeInt uint
|
||||
KeyframeInterval uint
|
||||
}
|
||||
|
||||
func NewEncoder(w, h int, opts *Options) (*Vpx, error) {
|
||||
encoder := &C.vpx_encoders[0]
|
||||
func NewEncoder(w, h int, th int, version int, opts *Options) (*Vpx, error) {
|
||||
idx := 0
|
||||
if version == 9 {
|
||||
idx = 1
|
||||
}
|
||||
encoder := &C.vpx_encoders[idx]
|
||||
if encoder == nil {
|
||||
return nil, fmt.Errorf("couldn't get the encoder")
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &Options{
|
||||
Bitrate: 1200,
|
||||
KeyframeInt: 5,
|
||||
Bitrate: 1200,
|
||||
KeyframeInterval: 5,
|
||||
}
|
||||
}
|
||||
|
||||
vpx := Vpx{
|
||||
frameCount: C.int(0),
|
||||
kfi: C.int(opts.KeyframeInt),
|
||||
kfi: C.int(opts.KeyframeInterval),
|
||||
v: version,
|
||||
}
|
||||
|
||||
if C.vpx_img_alloc(&vpx.image, C.VPX_IMG_FMT_I420, C.uint(w), C.uint(h), 1) == nil {
|
||||
|
|
@ -121,8 +135,12 @@ func NewEncoder(w, h int, opts *Options) (*Vpx, error) {
|
|||
|
||||
cfg.g_w = C.uint(w)
|
||||
cfg.g_h = C.uint(h)
|
||||
if th != 0 {
|
||||
cfg.g_threads = C.uint(th)
|
||||
}
|
||||
cfg.g_lag_in_frames = 0
|
||||
cfg.rc_target_bitrate = C.uint(opts.Bitrate)
|
||||
cfg.g_error_resilient = 1
|
||||
cfg.g_error_resilient = C.VPX_ERROR_RESILIENT_DEFAULT
|
||||
|
||||
if C.call_vpx_codec_enc_init(&vpx.codecCtx, encoder, &cfg) != 0 {
|
||||
return nil, fmt.Errorf("failed to initialize encoder")
|
||||
|
|
@ -131,14 +149,13 @@ func NewEncoder(w, h int, opts *Options) (*Vpx, error) {
|
|||
return &vpx, nil
|
||||
}
|
||||
|
||||
func (vpx *Vpx) LoadBuf(yuv []byte) {
|
||||
C.vpx_img_read(&vpx.image, unsafe.Pointer(&yuv[0]))
|
||||
}
|
||||
|
||||
// Encode encodes yuv image with the VPX8 encoder.
|
||||
// see: https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c
|
||||
func (vpx *Vpx) Encode() []byte {
|
||||
var iter C.vpx_codec_iter_t
|
||||
func (vpx *Vpx) Encode(yuv []byte) []byte {
|
||||
C.vpx_img_read(&vpx.image, unsafe.Pointer(&yuv[0]))
|
||||
if vpx.flipped {
|
||||
C.vpx_img_flip(&vpx.image)
|
||||
}
|
||||
|
||||
var flags C.int
|
||||
if vpx.kfi > 0 && vpx.frameCount%vpx.kfi == 0 {
|
||||
|
|
@ -149,6 +166,7 @@ func (vpx *Vpx) Encode() []byte {
|
|||
}
|
||||
vpx.frameCount++
|
||||
|
||||
var iter C.vpx_codec_iter_t
|
||||
fb := C.get_frame_buffer(&vpx.codecCtx, &iter)
|
||||
if fb.ptr == nil {
|
||||
return []byte{}
|
||||
|
|
@ -156,14 +174,19 @@ func (vpx *Vpx) Encode() []byte {
|
|||
return C.GoBytes(fb.ptr, fb.size)
|
||||
}
|
||||
|
||||
func (vpx *Vpx) Info() string {
|
||||
return fmt.Sprintf("vpx (%v): %v", vpx.v, C.GoString(C.vpx_codec_version_str()))
|
||||
}
|
||||
|
||||
func (vpx *Vpx) IntraRefresh() {
|
||||
// !to implement
|
||||
}
|
||||
|
||||
func (vpx *Vpx) Shutdown() error {
|
||||
if &vpx.image != nil {
|
||||
C.vpx_img_free(&vpx.image)
|
||||
}
|
||||
//if &vpx.image != nil {
|
||||
C.vpx_img_free(&vpx.image)
|
||||
//}
|
||||
C.vpx_codec_destroy(&vpx.codecCtx)
|
||||
vpx.flipped = false
|
||||
return nil
|
||||
}
|
||||
274
pkg/encoder/yuv/libyuv/libyuv.go
Normal file
274
pkg/encoder/yuv/libyuv/libyuv.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// Package libyuv contains the wrapper for: https://chromium.googlesource.com/libyuv/libyuv.
|
||||
// MacOS libs are from: https://packages.macports.org/libyuv/.
|
||||
package libyuv
|
||||
|
||||
/*
|
||||
#cgo !darwin,!st LDFLAGS: -lyuv
|
||||
#cgo !darwin,st LDFLAGS: -l:libyuv.a -l:libjpeg.a -l:libstdc++.a -static-libgcc
|
||||
|
||||
#cgo darwin CFLAGS: -DINCLUDE_LIBYUV_VERSION_H_
|
||||
#cgo darwin LDFLAGS: -L${SRCDIR} -lstdc++
|
||||
#cgo darwin,amd64 LDFLAGS: -lyuv_darwin_x86_64 -ljpeg -lstdc++
|
||||
#cgo darwin,arm64 LDFLAGS: -lyuv_darwin_arm64 -ljpeg -lstdc++
|
||||
|
||||
#include <stdint.h> // for uintptr_t and C99 types
|
||||
#include <stdlib.h>
|
||||
|
||||
#if !defined(LIBYUV_API)
|
||||
#define LIBYUV_API
|
||||
#endif // LIBYUV_API
|
||||
|
||||
#ifndef INCLUDE_LIBYUV_VERSION_H_
|
||||
#include "libyuv/version.h"
|
||||
#else
|
||||
#define LIBYUV_VERSION 1874 // darwin static libs version
|
||||
#endif // INCLUDE_LIBYUV_VERSION_H_
|
||||
|
||||
// Supported rotation.
|
||||
typedef enum RotationMode {
|
||||
kRotate0 = 0, // No rotation.
|
||||
kRotate90 = 90, // Rotate 90 degrees clockwise.
|
||||
kRotate180 = 180, // Rotate 180 degrees.
|
||||
kRotate270 = 270, // Rotate 270 degrees clockwise.
|
||||
} RotationModeEnum;
|
||||
|
||||
// RGB16 (RGBP fourcc) little endian to I420.
|
||||
LIBYUV_API
|
||||
int RGB565ToI420(const uint8_t* src_rgb565, int src_stride_rgb565, uint8_t* dst_y, int dst_stride_y,
|
||||
uint8_t* dst_u, int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height);
|
||||
|
||||
// Rotate I420 frame.
|
||||
LIBYUV_API
|
||||
int I420Rotate(const uint8_t* src_y, int src_stride_y, const uint8_t* src_u, int src_stride_u,
|
||||
const uint8_t* src_v, int src_stride_v, uint8_t* dst_y, int dst_stride_y, uint8_t* dst_u,
|
||||
int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height, enum RotationMode mode);
|
||||
|
||||
// RGB15 (RGBO fourcc) little endian to I420.
|
||||
LIBYUV_API
|
||||
int ARGB1555ToI420(const uint8_t* src_argb1555, int src_stride_argb1555, uint8_t* dst_y, int dst_stride_y,
|
||||
uint8_t* dst_u, int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height);
|
||||
|
||||
// ABGR little endian (rgba in memory) to I420.
|
||||
LIBYUV_API
|
||||
int ABGRToI420(const uint8_t* src_abgr, int src_stride_abgr, uint8_t* dst_y, int dst_stride_y, uint8_t* dst_u,
|
||||
int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height);
|
||||
|
||||
// ARGB little endian (bgra in memory) to I420.
|
||||
LIBYUV_API
|
||||
int ARGBToI420(const uint8_t* src_argb, int src_stride_argb, uint8_t* dst_y, int dst_stride_y, uint8_t* dst_u,
|
||||
int dst_stride_u, uint8_t* dst_v, int dst_stride_v, int width, int height);
|
||||
|
||||
|
||||
void ConvertToI420Custom(const uint8_t* sample,
|
||||
uint8_t* dst_y,
|
||||
int dst_stride_y,
|
||||
uint8_t* dst_u,
|
||||
int dst_stride_u,
|
||||
uint8_t* dst_v,
|
||||
int dst_stride_v,
|
||||
int src_width,
|
||||
int src_height,
|
||||
int crop_width,
|
||||
int crop_height,
|
||||
uint32_t fourcc);
|
||||
|
||||
#ifdef __cplusplus
|
||||
namespace libyuv {
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define FOURCC(a, b, c, d) \
|
||||
(((uint32_t)(a)) | ((uint32_t)(b) << 8) | ((uint32_t)(c) << 16) | ((uint32_t)(d) << 24))
|
||||
|
||||
enum FourCC {
|
||||
FOURCC_I420 = FOURCC('I', '4', '2', '0'),
|
||||
FOURCC_ARGB = FOURCC('A', 'R', 'G', 'B'),
|
||||
FOURCC_ABGR = FOURCC('A', 'B', 'G', 'R'),
|
||||
FOURCC_RGBO = FOURCC('R', 'G', 'B', 'O'),
|
||||
FOURCC_RGBP = FOURCC('R', 'G', 'B', 'P'), // rgb565 LE.
|
||||
FOURCC_ANY = -1,
|
||||
};
|
||||
|
||||
inline void ConvertToI420Custom(const uint8_t* sample,
|
||||
uint8_t* dst_y,
|
||||
int dst_stride_y,
|
||||
uint8_t* dst_u,
|
||||
int dst_stride_u,
|
||||
uint8_t* dst_v,
|
||||
int dst_stride_v,
|
||||
int src_width,
|
||||
int src_height,
|
||||
int crop_width,
|
||||
int crop_height,
|
||||
uint32_t fourcc) {
|
||||
const int stride = src_width << 1;
|
||||
|
||||
switch (fourcc) {
|
||||
case FOURCC_RGBP:
|
||||
RGB565ToI420(sample, stride, dst_y, dst_stride_y, dst_u,
|
||||
dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height);
|
||||
break;
|
||||
case FOURCC_RGBO:
|
||||
ARGB1555ToI420(sample, stride, dst_y, dst_stride_y, dst_u,
|
||||
dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height);
|
||||
break;
|
||||
case FOURCC_ARGB:
|
||||
ARGBToI420(sample, stride << 1, dst_y, dst_stride_y, dst_u,
|
||||
dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height);
|
||||
break;
|
||||
case FOURCC_ABGR:
|
||||
ABGRToI420(sample, stride << 1, dst_y, dst_stride_y, dst_u,
|
||||
dst_stride_u, dst_v, dst_stride_v, crop_width, crop_height);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void rotateI420(const uint8_t* sample,
|
||||
uint8_t* dst_y,
|
||||
int dst_stride_y,
|
||||
uint8_t* dst_u,
|
||||
int dst_stride_u,
|
||||
uint8_t* dst_v,
|
||||
int dst_stride_v,
|
||||
int src_width,
|
||||
int src_height,
|
||||
int crop_width,
|
||||
int crop_height,
|
||||
enum RotationMode rotation,
|
||||
uint32_t fourcc) {
|
||||
|
||||
uint8_t* tmp_y = dst_y;
|
||||
uint8_t* tmp_u = dst_u;
|
||||
uint8_t* tmp_v = dst_v;
|
||||
int tmp_y_stride = dst_stride_y;
|
||||
int tmp_u_stride = dst_stride_u;
|
||||
int tmp_v_stride = dst_stride_v;
|
||||
|
||||
uint8_t* rotate_buffer = NULL;
|
||||
|
||||
int y_size = crop_width * crop_height;
|
||||
int uv_size = y_size >> 1;
|
||||
rotate_buffer = (uint8_t*)malloc(y_size + y_size);
|
||||
if (!rotate_buffer) {
|
||||
return;
|
||||
}
|
||||
dst_y = rotate_buffer;
|
||||
dst_u = dst_y + y_size;
|
||||
dst_v = dst_u + uv_size;
|
||||
dst_stride_y = crop_width;
|
||||
dst_stride_u = dst_stride_v = crop_width >> 1;
|
||||
ConvertToI420Custom(sample, dst_y, dst_stride_y, dst_u, dst_stride_u, dst_v, dst_stride_v,
|
||||
src_width, src_height, crop_width, crop_height, fourcc);
|
||||
I420Rotate(dst_y, dst_stride_y, dst_u, dst_stride_u, dst_v,
|
||||
dst_stride_v, tmp_y, tmp_y_stride, tmp_u, tmp_u_stride,
|
||||
tmp_v, tmp_v_stride, crop_width, crop_height, rotation);
|
||||
free(rotate_buffer);
|
||||
}
|
||||
|
||||
// Supported filtering.
|
||||
typedef enum FilterMode {
|
||||
kFilterNone = 0, // Point sample; Fastest.
|
||||
kFilterLinear = 1, // Filter horizontally only.
|
||||
kFilterBilinear = 2, // Faster than box, but lower quality scaling down.
|
||||
kFilterBox = 3 // Highest quality.
|
||||
} FilterModeEnum;
|
||||
|
||||
LIBYUV_API
|
||||
int I420Scale(const uint8_t *src_y, int src_stride_y, const uint8_t *src_u, int src_stride_u,
|
||||
const uint8_t *src_v, int src_stride_v, int src_width, int src_height, uint8_t *dst_y,
|
||||
int dst_stride_y, uint8_t *dst_u, int dst_stride_u, uint8_t *dst_v, int dst_stride_v,
|
||||
int dst_width, int dst_height, enum FilterMode filtering);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
} // namespace libyuv
|
||||
#endif
|
||||
*/
|
||||
import "C"
|
||||
import "fmt"
|
||||
|
||||
const FourccRgbp uint32 = C.FOURCC_RGBP
|
||||
const FourccArgb uint32 = C.FOURCC_ARGB
|
||||
const FourccAbgr uint32 = C.FOURCC_ABGR
|
||||
const FourccRgb0 uint32 = C.FOURCC_RGBO
|
||||
|
||||
func Y420(src []byte, dst []byte, _, h, stride int, dw, dh int, rot uint, pix uint32, cx, cy int) {
|
||||
cw := (dw + 1) / 2
|
||||
ch := (dh + 1) / 2
|
||||
i0 := dw * dh
|
||||
i1 := i0 + cw*ch
|
||||
yStride := dw
|
||||
cStride := cw
|
||||
|
||||
if rot == 0 {
|
||||
C.ConvertToI420Custom(
|
||||
(*C.uchar)(&src[0]),
|
||||
(*C.uchar)(&dst[0]),
|
||||
C.int(yStride),
|
||||
(*C.uchar)(&dst[i0]),
|
||||
C.int(cStride),
|
||||
(*C.uchar)(&dst[i1]),
|
||||
C.int(cStride),
|
||||
C.int(stride),
|
||||
C.int(h),
|
||||
C.int(cx),
|
||||
C.int(cy),
|
||||
C.uint32_t(pix))
|
||||
} else {
|
||||
C.rotateI420(
|
||||
(*C.uchar)(&src[0]),
|
||||
(*C.uchar)(&dst[0]),
|
||||
C.int(yStride),
|
||||
(*C.uchar)(&dst[i0]),
|
||||
C.int(cStride),
|
||||
(*C.uchar)(&dst[i1]),
|
||||
C.int(cStride),
|
||||
C.int(stride),
|
||||
C.int(h),
|
||||
C.int(cx),
|
||||
C.int(cy),
|
||||
C.enum_RotationMode(rot),
|
||||
C.uint32_t(pix))
|
||||
}
|
||||
}
|
||||
|
||||
func Y420Scale(src []byte, dst []byte, w, h int, dw, dh int) {
|
||||
srcWidthUV, dstWidthUV := (w+1)>>1, (dw+1)>>1
|
||||
srcHeightUV, dstHeightUV := (h+1)>>1, (dh+1)>>1
|
||||
|
||||
srcYPlaneSize, dstYPlaneSize := w*h, dw*dh
|
||||
srcUVPlaneSize, dstUVPlaneSize := srcWidthUV*srcHeightUV, dstWidthUV*dstHeightUV
|
||||
|
||||
srcStrideY, dstStrideY := w, dw
|
||||
srcStrideU, dstStrideU := srcWidthUV, dstWidthUV
|
||||
srcStrideV, dstStrideV := srcWidthUV, dstWidthUV
|
||||
|
||||
srcY := (*C.uchar)(&src[0])
|
||||
srcU := (*C.uchar)(&src[srcYPlaneSize])
|
||||
srcV := (*C.uchar)(&src[srcYPlaneSize+srcUVPlaneSize])
|
||||
|
||||
dstY := (*C.uchar)(&dst[0])
|
||||
dstU := (*C.uchar)(&dst[dstYPlaneSize])
|
||||
dstV := (*C.uchar)(&dst[dstYPlaneSize+dstUVPlaneSize])
|
||||
|
||||
C.I420Scale(
|
||||
srcY,
|
||||
C.int(srcStrideY),
|
||||
srcU,
|
||||
C.int(srcStrideU),
|
||||
srcV,
|
||||
C.int(srcStrideV),
|
||||
C.int(w),
|
||||
C.int(h),
|
||||
dstY,
|
||||
C.int(dstStrideY),
|
||||
dstU,
|
||||
C.int(dstStrideU),
|
||||
dstV,
|
||||
C.int(dstStrideV),
|
||||
C.int(dw),
|
||||
C.int(dh),
|
||||
C.enum_FilterMode(C.kFilterNone))
|
||||
}
|
||||
|
||||
func Version() string { return fmt.Sprintf("%v", int(C.LIBYUV_VERSION)) }
|
||||
BIN
pkg/encoder/yuv/libyuv/libyuv_darwin_arm64.a
Normal file
BIN
pkg/encoder/yuv/libyuv/libyuv_darwin_arm64.a
Normal file
Binary file not shown.
BIN
pkg/encoder/yuv/libyuv/libyuv_darwin_x86_64.a
Normal file
BIN
pkg/encoder/yuv/libyuv/libyuv_darwin_x86_64.a
Normal file
Binary file not shown.
92
pkg/encoder/yuv/yuv.go
Normal file
92
pkg/encoder/yuv/yuv.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package yuv
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/giongto35/cloud-game/v3/pkg/encoder/yuv/libyuv"
|
||||
)
|
||||
|
||||
type Conv struct {
|
||||
w, h int
|
||||
sw, sh int
|
||||
scale float64
|
||||
frame []byte
|
||||
frameSc []byte
|
||||
}
|
||||
|
||||
type RawFrame struct {
|
||||
Data []byte
|
||||
Stride int
|
||||
W, H int
|
||||
}
|
||||
|
||||
type PixFmt uint32
|
||||
|
||||
const FourccRgbp = libyuv.FourccRgbp
|
||||
const FourccArgb = libyuv.FourccArgb
|
||||
const FourccAbgr = libyuv.FourccAbgr
|
||||
const FourccRgb0 = libyuv.FourccRgb0
|
||||
|
||||
func NewYuvConv(w, h int, scale float64) Conv {
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
|
||||
sw, sh := round(w, scale), round(h, scale)
|
||||
conv := Conv{w: w, h: h, sw: sw, sh: sh, scale: scale}
|
||||
bufSize := int(float64(w) * float64(h) * 1.5)
|
||||
|
||||
if scale == 1 {
|
||||
conv.frame = make([]byte, bufSize)
|
||||
} else {
|
||||
bufSizeSc := int(float64(sw) * float64(sh) * 1.5)
|
||||
// [original frame][scaled frame ]
|
||||
frames := make([]byte, bufSize+bufSizeSc)
|
||||
conv.frame = frames[:bufSize]
|
||||
conv.frameSc = frames[bufSize:]
|
||||
}
|
||||
|
||||
return conv
|
||||
}
|
||||
|
||||
// Process converts an image to YUV I420 format inside the internal buffer.
|
||||
func (c *Conv) Process(frame RawFrame, rot uint, pf PixFmt) []byte {
|
||||
cx, cy := c.w, c.h // crop
|
||||
if rot == 90 || rot == 270 {
|
||||
cx, cy = cy, cx
|
||||
}
|
||||
|
||||
var stride int
|
||||
switch pf {
|
||||
case PixFmt(libyuv.FourccRgbp), PixFmt(libyuv.FourccRgb0):
|
||||
stride = frame.Stride >> 1
|
||||
default:
|
||||
stride = frame.Stride >> 2
|
||||
}
|
||||
|
||||
libyuv.Y420(frame.Data, c.frame, frame.W, frame.H, stride, c.w, c.h, rot, uint32(pf), cx, cy)
|
||||
|
||||
if c.scale > 1 {
|
||||
libyuv.Y420Scale(c.frame, c.frameSc, c.w, c.h, c.sw, c.sh)
|
||||
return c.frameSc
|
||||
}
|
||||
|
||||
return c.frame
|
||||
}
|
||||
|
||||
func (c *Conv) Version() string { return libyuv.Version() }
|
||||
func round(x int, scale float64) int { return (int(float64(x)*scale) + 1) & ^1 }
|
||||
|
||||
func ToYCbCr(bytes []byte, w, h int) *image.YCbCr {
|
||||
cw, ch := (w+1)/2, (h+1)/2
|
||||
|
||||
i0 := w*h + 0*cw*ch
|
||||
i1 := w*h + 1*cw*ch
|
||||
i2 := w*h + 2*cw*ch
|
||||
|
||||
yuv := image.NewYCbCr(image.Rect(0, 0, w, h), image.YCbCrSubsampleRatio420)
|
||||
yuv.Y = bytes[:i0:i0]
|
||||
yuv.Cb = bytes[i0:i1:i1]
|
||||
yuv.Cr = bytes[i1:i2:i2]
|
||||
return yuv
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,18 +1,25 @@
|
|||
package games
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Launcher interface {
|
||||
FindAppByName(name string) (AppMeta, error)
|
||||
ExtractAppNameFromUrl(name string) string
|
||||
GetAppNames() []string
|
||||
GetAppNames() []AppMeta
|
||||
}
|
||||
|
||||
type AppMeta struct {
|
||||
Name string
|
||||
Type string
|
||||
Base string
|
||||
Path string
|
||||
Alias string
|
||||
Base string
|
||||
Name string
|
||||
Path string
|
||||
System string
|
||||
Type string
|
||||
}
|
||||
|
||||
type GameLauncher struct {
|
||||
|
|
@ -26,17 +33,32 @@ func (gl GameLauncher) FindAppByName(name string) (AppMeta, error) {
|
|||
if game.Path == "" {
|
||||
return AppMeta{}, fmt.Errorf("couldn't find game info for the game %v", name)
|
||||
}
|
||||
return AppMeta{Name: game.Name, Base: game.Base, Type: game.Type, Path: game.Path}, nil
|
||||
return AppMeta(game), nil
|
||||
}
|
||||
|
||||
func (gl GameLauncher) ExtractAppNameFromUrl(name string) string {
|
||||
return GetGameNameFromRoomID(name)
|
||||
}
|
||||
func (gl GameLauncher) ExtractAppNameFromUrl(name string) string { return ExtractGame(name) }
|
||||
|
||||
func (gl GameLauncher) GetAppNames() []string {
|
||||
var gameList []string
|
||||
func (gl GameLauncher) GetAppNames() (apps []AppMeta) {
|
||||
for _, game := range gl.lib.GetAll() {
|
||||
gameList = append(gameList, game.Name)
|
||||
apps = append(apps, AppMeta{Alias: game.Alias, Name: game.Name, System: game.System})
|
||||
}
|
||||
return gameList
|
||||
return
|
||||
}
|
||||
|
||||
const separator = "___"
|
||||
|
||||
// ExtractGame parses game room link returning the name of the game "encoded" there.
|
||||
func ExtractGame(roomID string) string {
|
||||
parts := strings.Split(roomID, separator)
|
||||
if len(parts) > 1 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GenerateRoomID generate a unique room ID containing 16 digits.
|
||||
// RoomID contains random number + gameName
|
||||
// Next time when we only get roomID, we can launch game based on gameName
|
||||
func GenerateRoomID(title string) string {
|
||||
return strconv.FormatInt(rand.Int64(), 16) + separator + title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,30 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
// Config is an external configuration
|
||||
type Config struct {
|
||||
// some directory which is going to be
|
||||
// the root folder for the library
|
||||
BasePath string
|
||||
// a list of supported file extensions
|
||||
Supported []string
|
||||
// a list of ignored words in the files
|
||||
Ignored []string
|
||||
// print some additional info
|
||||
Verbose bool
|
||||
// enable directory changes watch
|
||||
WatchMode bool
|
||||
}
|
||||
|
||||
// libConf is an optimized internal library configuration
|
||||
type libConf struct {
|
||||
path string
|
||||
supported map[string]bool
|
||||
ignored map[string]bool
|
||||
verbose bool
|
||||
watchMode bool
|
||||
aliasFile string
|
||||
path string
|
||||
supported map[string]struct{}
|
||||
ignored []string
|
||||
verbose bool
|
||||
watchMode bool
|
||||
sessionPath string
|
||||
}
|
||||
|
||||
type library struct {
|
||||
|
|
@ -51,9 +40,13 @@ type library struct {
|
|||
games map[string]GameMetadata
|
||||
log *logger.Logger
|
||||
|
||||
// to restrict parallel execution
|
||||
// or throttling
|
||||
// !CAS would be better
|
||||
// ids of saved games to find closed sessions
|
||||
sessions []string
|
||||
|
||||
emuConf WithEmulatorInfo
|
||||
|
||||
// to restrict parallel execution or throttling
|
||||
// for file watch mode
|
||||
mu sync.Mutex
|
||||
isScanning bool
|
||||
isScanningDelayed bool
|
||||
|
|
@ -62,31 +55,33 @@ type library struct {
|
|||
type GameLibrary interface {
|
||||
GetAll() []GameMetadata
|
||||
FindGameByName(name string) GameMetadata
|
||||
Sessions() []string
|
||||
Scan()
|
||||
}
|
||||
|
||||
type FileExtensionWhitelist interface {
|
||||
type WithEmulatorInfo interface {
|
||||
GetSupportedExtensions() []string
|
||||
GetEmulator(rom string, path string) string
|
||||
SessionStoragePath() string
|
||||
}
|
||||
|
||||
type GameMetadata struct {
|
||||
uid string
|
||||
// the display name of the game
|
||||
Name string
|
||||
// the game file extension (e.g. nes, n64)
|
||||
Type string
|
||||
Base string
|
||||
// the game path relative to the library base path
|
||||
Path string
|
||||
Alias string
|
||||
Base string
|
||||
Name string // the display name of the game
|
||||
Path string // the game path relative to the library base path
|
||||
System string
|
||||
Type string // the game file extension (e.g. nes, n64)
|
||||
}
|
||||
|
||||
func (g GameMetadata) FullPath() string { return filepath.Join(g.Base, g.Path) }
|
||||
func (g GameMetadata) FullPath(base string) string {
|
||||
if base == "" {
|
||||
return filepath.Join(g.Base, g.Path)
|
||||
}
|
||||
return filepath.Join(base, g.Path)
|
||||
}
|
||||
|
||||
func (c Config) GetSupportedExtensions() []string { return c.Supported }
|
||||
|
||||
func NewLib(conf Config, log *logger.Logger) GameLibrary { return NewLibWhitelisted(conf, conf, log) }
|
||||
|
||||
func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist, log *logger.Logger) GameLibrary {
|
||||
func NewLib(conf config.Library, emu WithEmulatorInfo, log *logger.Logger) GameLibrary {
|
||||
hasSource := true
|
||||
dir, err := filepath.Abs(conf.BasePath)
|
||||
if err != nil {
|
||||
|
|
@ -95,21 +90,24 @@ func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist, log *logger.L
|
|||
}
|
||||
|
||||
if len(conf.Supported) == 0 {
|
||||
conf.Supported = filter.GetSupportedExtensions()
|
||||
conf.Supported = emu.GetSupportedExtensions()
|
||||
}
|
||||
|
||||
library := &library{
|
||||
config: libConf{
|
||||
path: dir,
|
||||
supported: toMap(conf.Supported),
|
||||
ignored: toMap(conf.Ignored),
|
||||
verbose: conf.Verbose,
|
||||
watchMode: conf.WatchMode,
|
||||
aliasFile: conf.AliasFile,
|
||||
path: dir,
|
||||
supported: toMap(conf.Supported),
|
||||
ignored: conf.Ignored,
|
||||
verbose: conf.Verbose,
|
||||
watchMode: conf.WatchMode,
|
||||
sessionPath: emu.SessionStoragePath(),
|
||||
},
|
||||
mu: sync.Mutex{},
|
||||
games: map[string]GameMetadata{},
|
||||
hasSource: hasSource,
|
||||
log: log,
|
||||
emuConf: emu,
|
||||
}
|
||||
|
||||
if conf.WatchMode && hasSource {
|
||||
|
|
@ -119,6 +117,10 @@ func NewLibWhitelisted(conf Config, filter FileExtensionWhitelist, log *logger.L
|
|||
return library
|
||||
}
|
||||
|
||||
func (lib *library) Sessions() []string {
|
||||
return lib.sessions
|
||||
}
|
||||
|
||||
func (lib *library) GetAll() []GameMetadata {
|
||||
var res []GameMetadata
|
||||
for _, value := range lib.games {
|
||||
|
|
@ -137,6 +139,39 @@ func (lib *library) FindGameByName(name string) GameMetadata {
|
|||
return game
|
||||
}
|
||||
|
||||
func (lib *library) AliasFileMaybe() map[string]string {
|
||||
if lib.config.aliasFile == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
path := filepath.Join(lib.config.path, lib.config.aliasFile)
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
lib.log.Error().Msgf("couldn't open alias file, %v", err)
|
||||
return nil
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
aliases := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if id, alias, found := strings.Cut(scanner.Text(), "="); found {
|
||||
aliases[id] = alias
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
lib.log.Error().Msgf("alias file read error, %v", err)
|
||||
}
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
func (lib *library) Scan() {
|
||||
if !lib.hasSource {
|
||||
lib.log.Info().Msg("Lib scan... skipped (no source)")
|
||||
|
|
@ -156,33 +191,78 @@ func (lib *library) Scan() {
|
|||
|
||||
lib.log.Debug().Msg("Lib scan... started")
|
||||
|
||||
// game name aliases
|
||||
aliases := lib.AliasFileMaybe()
|
||||
|
||||
if aliases != nil {
|
||||
lib.log.Debug().Msgf("Lib game alises found")
|
||||
lib.log.Debug().Msgf(">>> %v", aliases)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
var games []GameMetadata
|
||||
dir := lib.config.path
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info != nil && !info.IsDir() && lib.isFileExtensionSupported(path) {
|
||||
meta := getMetadata(path, dir)
|
||||
meta.uid = hash(path)
|
||||
if info == nil || info.IsDir() || !lib.isExtAllowed(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !lib.config.ignored[meta.Name] {
|
||||
games = append(games, meta)
|
||||
meta := metadata(path, dir)
|
||||
meta.System = lib.emuConf.GetEmulator(meta.Type, meta.Path)
|
||||
|
||||
if aliases != nil {
|
||||
if k, ok := aliases[meta.Name]; ok {
|
||||
meta.Alias = k
|
||||
}
|
||||
}
|
||||
|
||||
ignored := false
|
||||
for _, k := range lib.config.ignored {
|
||||
if meta.Name == k {
|
||||
ignored = true
|
||||
break
|
||||
}
|
||||
|
||||
if len(k) > 0 && k[0] == '.' && strings.Contains(meta.Name, k) {
|
||||
ignored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ignored {
|
||||
games = append(games, meta)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
lib.log.Error().Err(err).Str("dir", dir).Msgf("Lib scan error")
|
||||
lib.log.Error().Err(err).Str("dir", dir).Msgf("Lib scan... failed")
|
||||
return
|
||||
}
|
||||
|
||||
if len(games) > 0 {
|
||||
lib.set(games)
|
||||
}
|
||||
|
||||
var sessions []string
|
||||
dir = lib.config.sessionPath
|
||||
err = filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info != nil && !info.IsDir() {
|
||||
sessions = append(sessions, info.Name())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
lib.sessions = sessions
|
||||
|
||||
lib.lastScanDuration = time.Since(start)
|
||||
if lib.config.verbose {
|
||||
lib.dumpLibrary()
|
||||
|
|
@ -247,23 +327,24 @@ func (lib *library) set(games []GameMetadata) {
|
|||
lib.games = res
|
||||
}
|
||||
|
||||
func (lib *library) isFileExtensionSupported(path string) bool {
|
||||
ext := filepath.Ext(path)
|
||||
func (lib *library) isExtAllowed(path string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == "" {
|
||||
return false
|
||||
}
|
||||
return lib.config.supported[ext[1:]]
|
||||
_, ok := lib.config.supported[ext[1:]]
|
||||
return ok
|
||||
}
|
||||
|
||||
// getMetadata returns game info from a path
|
||||
func getMetadata(path string, basePath string) GameMetadata {
|
||||
// metadata returns game info from a path
|
||||
func metadata(path string, basePath string) GameMetadata {
|
||||
name := filepath.Base(path)
|
||||
ext := filepath.Ext(name)
|
||||
relPath, _ := filepath.Rel(basePath, path)
|
||||
|
||||
return GameMetadata{
|
||||
Name: strings.TrimSuffix(name, ext),
|
||||
Type: ext[1:],
|
||||
Type: strings.ToLower(ext[1:]),
|
||||
Path: relPath,
|
||||
}
|
||||
}
|
||||
|
|
@ -271,8 +352,21 @@ func getMetadata(path string, basePath string) GameMetadata {
|
|||
// dumpLibrary printouts the current library snapshot of games
|
||||
func (lib *library) dumpLibrary() {
|
||||
var gameList strings.Builder
|
||||
for _, game := range lib.games {
|
||||
gameList.WriteString(" " + game.Name + " (" + game.Path + ")" + "\n")
|
||||
|
||||
// oof
|
||||
keys := make([]string, 0, len(lib.games))
|
||||
for k := range lib.games {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
game := lib.games[k]
|
||||
alias := game.Alias
|
||||
if alias != "" {
|
||||
alias = fmt.Sprintf("[%s] ", game.Alias)
|
||||
}
|
||||
gameList.WriteString(fmt.Sprintf(" %7s %s %s(%s)\n", game.System, game.Name, alias, game.Path))
|
||||
}
|
||||
|
||||
lib.log.Debug().Msgf("Lib dump\n"+
|
||||
|
|
@ -281,25 +375,15 @@ func (lib *library) dumpLibrary() {
|
|||
"--------------------------------------------\n"+
|
||||
"%v"+
|
||||
"--------------------------------------------\n"+
|
||||
"--- ROMs: %03d %26s ---\n"+
|
||||
"--- ROMs: %03d --- Saves: %04d %10s ---\n"+
|
||||
"--------------------------------------------",
|
||||
gameList.String(), len(lib.games), lib.lastScanDuration)
|
||||
gameList.String(), len(lib.games), len(lib.sessions), lib.lastScanDuration)
|
||||
}
|
||||
|
||||
// hash makes an MD5 hash of the string
|
||||
func hash(str string) string {
|
||||
h := md5.New()
|
||||
_, err := io.WriteString(h, str)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
func toMap(list []string) map[string]bool {
|
||||
res := make(map[string]bool)
|
||||
func toMap(list []string) map[string]struct{} {
|
||||
res := make(map[string]struct{}, len(list))
|
||||
for _, s := range list {
|
||||
res[s] = true
|
||||
res[s] = struct{}{}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,56 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
func TestLibraryScan(t *testing.T) {
|
||||
tests := []struct {
|
||||
directory string
|
||||
expected []string
|
||||
expected []struct {
|
||||
name string
|
||||
system string
|
||||
}
|
||||
}{
|
||||
{
|
||||
directory: "../../assets/games",
|
||||
expected: []string{
|
||||
"Super Mario Bros", "Sushi The Cat", "anguna",
|
||||
expected: []struct {
|
||||
name string
|
||||
system string
|
||||
}{
|
||||
{name: "Alwa's Awakening (Demo)", system: "nes"},
|
||||
{name: "Sushi The Cat", system: "gba"},
|
||||
{name: "anguna", system: "gba"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
l := logger.NewConsole(false, "w", true)
|
||||
emuConf := config.Emulator{Libretro: config.LibretroConfig{}}
|
||||
emuConf.Libretro.Cores.List = map[string]config.LibretroCoreConfig{
|
||||
"nes": {Roms: []string{"nes"}},
|
||||
"gba": {Roms: []string{"gba"}},
|
||||
}
|
||||
|
||||
l := logger.NewConsole(false, "w", false)
|
||||
for _, test := range tests {
|
||||
library := NewLib(Config{
|
||||
library := NewLib(config.Library{
|
||||
BasePath: test.directory,
|
||||
Supported: []string{"gba", "zip", "nes"},
|
||||
Ignored: []string{"neogeo", "pgm"},
|
||||
}, l)
|
||||
}, emuConf, l)
|
||||
library.Scan()
|
||||
games := library.GetAll()
|
||||
|
||||
list := _map(games, func(meta GameMetadata) string {
|
||||
return meta.Name
|
||||
})
|
||||
|
||||
// ^2 complexity (;
|
||||
all := true
|
||||
for _, expect := range test.expected {
|
||||
found := false
|
||||
for _, game := range list {
|
||||
if game == expect {
|
||||
for _, game := range games {
|
||||
if game.Name == expect.name && (expect.system != "" && expect.system == game.System) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
|
@ -46,15 +58,67 @@ func TestLibraryScan(t *testing.T) {
|
|||
all = all && found
|
||||
}
|
||||
if !all {
|
||||
t.Errorf("Test fail for dir %v with %v != %v", test.directory, list, test.expected)
|
||||
t.Errorf("Test fail for dir %v with %v != %v", test.directory, games, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func _map(vs []GameMetadata, f func(info GameMetadata) string) []string {
|
||||
vsm := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
vsm[i] = f(v)
|
||||
func TestAliasFileMaybe(t *testing.T) {
|
||||
lib := &library{
|
||||
config: libConf{
|
||||
aliasFile: "alias",
|
||||
path: os.TempDir(),
|
||||
},
|
||||
log: logger.NewConsole(false, "w", false),
|
||||
}
|
||||
|
||||
contents := "a=b\nc=d\n"
|
||||
|
||||
path := filepath.Join(lib.config.path, lib.config.aliasFile)
|
||||
if err := os.WriteFile(path, []byte(contents), 0644); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
want := map[string]string{}
|
||||
want["a"] = "b"
|
||||
want["c"] = "d"
|
||||
|
||||
aliases := lib.AliasFileMaybe()
|
||||
|
||||
if !reflect.DeepEqual(aliases, want) {
|
||||
t.Errorf("AliasFileMaybe() = %v, want %v", aliases, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasFileMaybeNot(t *testing.T) {
|
||||
lib := &library{
|
||||
config: libConf{
|
||||
path: os.TempDir(),
|
||||
},
|
||||
log: logger.NewConsole(false, "w", false),
|
||||
}
|
||||
|
||||
aliases := lib.AliasFileMaybe()
|
||||
if aliases != nil {
|
||||
t.Errorf("should be nil, but %v", aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func Benchmark(b *testing.B) {
|
||||
log := logger.Default()
|
||||
logger.SetGlobalLevel(logger.Disabled)
|
||||
library := NewLib(config.Library{
|
||||
BasePath: "../../assets/games",
|
||||
Supported: []string{"gba", "zip", "nes"},
|
||||
}, config.Emulator{}, log)
|
||||
|
||||
for b.Loop() {
|
||||
library.Scan()
|
||||
_ = library.GetAll()
|
||||
}
|
||||
return vsm
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
package games
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const separator = "___"
|
||||
|
||||
// GetGameNameFromRoomID parse roomID to get roomID and gameName.
|
||||
func GetGameNameFromRoomID(roomID string) string {
|
||||
parts := strings.Split(roomID, separator)
|
||||
if len(parts) > 1 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GenerateRoomID generate a unique room ID containing 16 digits.
|
||||
func GenerateRoomID(gameName string) string {
|
||||
// RoomID contains random number + gameName
|
||||
// Next time when we only get roomID, we can launch game based on gameName
|
||||
roomID := strconv.FormatInt(rand.Int63(), 16) + separator + gameName
|
||||
return roomID
|
||||
}
|
||||
|
|
@ -27,6 +27,16 @@ const (
|
|||
// Values less than TraceLevel are handled as numbers.
|
||||
)
|
||||
|
||||
const (
|
||||
ClientField = "c"
|
||||
DirectionField = "d"
|
||||
MarkNone = " "
|
||||
MarkIn = "←"
|
||||
MarkOut = "→"
|
||||
MarkPlus = "+"
|
||||
MarkCross = "x"
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case TraceLevel:
|
||||
|
|
@ -81,12 +91,12 @@ func NewConsole(isDebug bool, tag string, noColor bool) *Logger {
|
|||
zerolog.LevelFieldName,
|
||||
zerolog.CallerFieldName,
|
||||
"s",
|
||||
"d",
|
||||
"c",
|
||||
DirectionField,
|
||||
ClientField,
|
||||
"m",
|
||||
zerolog.MessageFieldName,
|
||||
},
|
||||
FieldsExclude: []string{"s", "c", "d", "m", "pid"},
|
||||
FieldsExclude: []string{"s", ClientField, DirectionField, "m", "pid"},
|
||||
}
|
||||
|
||||
if output.NoColor {
|
||||
|
|
@ -103,8 +113,8 @@ func NewConsole(isDebug bool, tag string, noColor bool) *Logger {
|
|||
Str("pid", fmt.Sprintf("%4x", pid)).
|
||||
Str("s", tag).
|
||||
Str("m", "").
|
||||
Str("d", " ").
|
||||
Str("c", " ").
|
||||
Str(DirectionField, MarkNone).
|
||||
Str(ClientField, MarkNone).
|
||||
// Str("tag", tag). use when a file writer
|
||||
Timestamp().Logger()
|
||||
return &Logger{logger: &logger}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,23 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/monitoring"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network/httpx"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/network/httpx"
|
||||
)
|
||||
|
||||
const debugEndpoint = "/debug/pprof"
|
||||
const metricsEndpoint = "/metrics"
|
||||
|
||||
type Monitoring struct {
|
||||
conf monitoring.Config
|
||||
conf config.Monitoring
|
||||
server *httpx.Server
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
// New creates new monitoring service.
|
||||
// The tag param specifies owner label for logs.
|
||||
func New(conf monitoring.Config, baseAddr string, log *logger.Logger) *Monitoring {
|
||||
func New(conf config.Monitoring, baseAddr string, log *logger.Logger) *Monitoring {
|
||||
serv, err := httpx.NewServer(
|
||||
net.JoinHostPort(baseAddr, strconv.Itoa(conf.Port)),
|
||||
func(s *httpx.Server) httpx.Handler {
|
||||
|
|
@ -52,6 +52,7 @@ func New(conf monitoring.Config, baseAddr string, log *logger.Logger) *Monitorin
|
|||
return h
|
||||
},
|
||||
httpx.WithPortRoll(true),
|
||||
httpx.HttpsRedirect(false),
|
||||
httpx.WithLogger(log),
|
||||
)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package network
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
|
@ -12,15 +13,17 @@ func (a *Address) Port() (int, error) {
|
|||
if len(string(*a)) == 0 {
|
||||
return 0, errors.New("no address")
|
||||
}
|
||||
parts := strings.Split(string(*a), ":")
|
||||
var port string
|
||||
if len(parts) == 1 {
|
||||
port = parts[0]
|
||||
} else {
|
||||
port = parts[len(parts)-1]
|
||||
addr := replaceAllExceptLast(string(*a), ":", "_")
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if val, err := strconv.Atoi(port); err == nil {
|
||||
return val, nil
|
||||
}
|
||||
return 0, errors.New("port is not a number")
|
||||
}
|
||||
|
||||
func replaceAllExceptLast(s, c, x string) string {
|
||||
return strings.Replace(s, c, x, strings.Count(s, c)-1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network/socket"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/network/socket"
|
||||
)
|
||||
|
||||
const listenAttempts = 42
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ func TestListenerCreation(t *testing.T) {
|
|||
random bool
|
||||
error bool
|
||||
}{
|
||||
{addr: ":80", port: "80"},
|
||||
{addr: ":", random: true},
|
||||
{addr: ":0", random: true},
|
||||
{addr: "", random: true},
|
||||
|
|
@ -38,14 +37,14 @@ func TestListenerCreation(t *testing.T) {
|
|||
continue
|
||||
}
|
||||
|
||||
defer func() { _ = ls.Close() }()
|
||||
|
||||
addr := ls.Addr().(*net.TCPAddr)
|
||||
port := ls.GetPort()
|
||||
|
||||
hasPort := port > 0
|
||||
isPortSame := strings.HasSuffix(addr.String(), ":"+test.port)
|
||||
|
||||
_ = ls.Close()
|
||||
|
||||
if test.random {
|
||||
if !hasPort {
|
||||
t.Errorf("expected a random port, got %v", port)
|
||||
|
|
@ -64,7 +63,7 @@ func TestFailOnPortInUse(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
defer a.Close()
|
||||
defer func() { _ = a.Close() }()
|
||||
_, err = NewListener(":3333", false)
|
||||
if err == nil {
|
||||
t.Errorf("expected busy port error, but got none")
|
||||
|
|
@ -76,10 +75,10 @@ func TestListenerPortRoll(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
defer a.Close()
|
||||
defer func() { _ = a.Close() }()
|
||||
b, err := NewListener("127.0.0.1:3333", true)
|
||||
if err != nil {
|
||||
t.Errorf("expected no port error, but got %v", err)
|
||||
}
|
||||
b.Close()
|
||||
_ = b.Close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package httpx
|
|||
import (
|
||||
"time"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/config/shared"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
)
|
||||
|
||||
type (
|
||||
|
|
@ -47,7 +47,7 @@ func HttpsRedirect(redirect bool) Option {
|
|||
|
||||
func WithPortRoll(roll bool) Option { return func(opts *Options) { opts.PortRoll = roll } }
|
||||
func WithZone(zone string) Option { return func(opts *Options) { opts.Zone = zone } }
|
||||
func WithServerConfig(conf shared.Server) Option {
|
||||
func WithServerConfig(conf config.Server) Option {
|
||||
return func(opts *Options) {
|
||||
opts.Https = conf.Https
|
||||
opts.HttpsCert = conf.Tls.HttpsCert
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
|
|
@ -53,14 +54,9 @@ func (m *Mux) HandleFunc(pattern string, handler func(ResponseWriter, *Request))
|
|||
m.ServeMux.HandleFunc(m.prefix+pattern, handler)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Mux) ServeHTTP(w ResponseWriter, r *Request) { m.ServeMux.ServeHTTP(w, r) }
|
||||
|
||||
func NotFound(w ResponseWriter) { http.Error(w, "404 page not found", http.StatusNotFound) }
|
||||
|
||||
func (m *Mux) Static(prefix string, path string) *Mux {
|
||||
return m.Handle(m.prefix+prefix, http.StripPrefix(prefix, http.FileServer(http.Dir(path))))
|
||||
}
|
||||
|
||||
func NewServer(address string, handler func(*Server) Handler, options ...Option) (*Server, error) {
|
||||
opts := &Options{
|
||||
Https: false,
|
||||
|
|
@ -124,12 +120,12 @@ func (s *Server) run() {
|
|||
s.log.Debug().Msgf("Starting %s server on %s", protocol, s.Addr)
|
||||
|
||||
if s.opts.Https && s.opts.HttpsRedirect {
|
||||
rdr, err := s.redirection()
|
||||
if err != nil {
|
||||
if rdr, err := s.redirection(); err == nil {
|
||||
s.redirect = rdr
|
||||
go s.redirect.Run()
|
||||
} else {
|
||||
s.log.Error().Err(err).Msg("couldn't init redirection server")
|
||||
}
|
||||
s.redirect = rdr
|
||||
go s.redirect.Run()
|
||||
}
|
||||
|
||||
var err error
|
||||
|
|
@ -138,13 +134,12 @@ func (s *Server) run() {
|
|||
} else {
|
||||
err = s.Serve(*s.listener)
|
||||
}
|
||||
switch err {
|
||||
case http.ErrServerClosed:
|
||||
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Debug().Msgf("%s server was closed", protocol)
|
||||
return
|
||||
default:
|
||||
s.log.Error().Err(err)
|
||||
}
|
||||
s.log.Error().Err(err)
|
||||
}
|
||||
|
||||
func (s *Server) Stop() error {
|
||||
|
|
@ -170,6 +165,7 @@ func (s *Server) redirection() (*Server, error) {
|
|||
address = s.opts.HttpsDomain
|
||||
}
|
||||
addr := buildAddress(address, s.opts.Zone, *s.listener)
|
||||
s.log.Info().Str("addr", addr).Msg("Start HTTPS redirect server")
|
||||
|
||||
srv, err := NewServer(s.opts.HttpsRedirectAddress, func(serv *Server) Handler {
|
||||
h := NewServeMux("")
|
||||
|
|
@ -191,6 +187,7 @@ func (s *Server) redirection() (*Server, error) {
|
|||
},
|
||||
WithLogger(s.log),
|
||||
)
|
||||
s.log.Info().Str("addr", addr).Msg("Start HTTPS redirect server")
|
||||
return srv, err
|
||||
}
|
||||
|
||||
func FileServer(dir string) http.Handler { return http.FileServer(http.Dir(dir)) }
|
||||
|
|
|
|||
19
pkg/network/retry.go
Normal file
19
pkg/network/retry.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package network
|
||||
|
||||
import "time"
|
||||
|
||||
const retry = 10 * time.Second
|
||||
|
||||
type Retry struct {
|
||||
t time.Duration
|
||||
fail bool
|
||||
}
|
||||
|
||||
func NewRetry() Retry {
|
||||
return Retry{t: retry}
|
||||
}
|
||||
|
||||
func (r *Retry) Fail() *Retry { r.fail = true; time.Sleep(r.t); return r }
|
||||
func (r *Retry) Multiply(x int) { r.t *= time.Duration(x) }
|
||||
func (r *Retry) Success() { r.t = retry; r.fail = false }
|
||||
func (r *Retry) Time() time.Duration { return r.t }
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package network
|
||||
|
||||
import "github.com/rs/xid"
|
||||
|
||||
type Uid string
|
||||
|
||||
func NewUid() Uid { return Uid(xid.New().String()) }
|
||||
|
||||
func ValidUid(u Uid) bool {
|
||||
_, err := xid.FromString(string(u))
|
||||
return err == nil
|
||||
}
|
||||
func (u Uid) Empty() bool { return u == "" }
|
||||
func (u Uid) Short() string { return string(u)[:3] + "." + string(u)[len(u)-3:] }
|
||||
func (u Uid) String() string { return string(u) }
|
||||
func (u Uid) Machine() string {
|
||||
id, err := xid.FromString(string(u))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(id.Machine())
|
||||
}
|
||||
|
|
@ -4,12 +4,13 @@ import (
|
|||
"fmt"
|
||||
"net"
|
||||
|
||||
conf "github.com/giongto35/cloud-game/v2/pkg/config/webrtc"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v2/pkg/network/socket"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/config"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/logger"
|
||||
"github.com/giongto35/cloud-game/v3/pkg/network/socket"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/report"
|
||||
"github.com/pion/webrtc/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
type ApiFactory struct {
|
||||
|
|
@ -19,7 +20,7 @@ type ApiFactory struct {
|
|||
|
||||
type ModApiFun func(m *webrtc.MediaEngine, i *interceptor.Registry, s *webrtc.SettingEngine)
|
||||
|
||||
func NewApiFactory(conf conf.Webrtc, log *logger.Logger, mod ModApiFun) (api *ApiFactory, err error) {
|
||||
func NewApiFactory(conf config.Webrtc, log *logger.Logger, mod ModApiFun) (api *ApiFactory, err error) {
|
||||
m := &webrtc.MediaEngine{}
|
||||
if err = m.RegisterDefaultCodecs(); err != nil {
|
||||
return
|
||||
|
|
@ -72,6 +73,9 @@ func NewApiFactory(conf conf.Webrtc, log *logger.Logger, mod ModApiFun) (api *Ap
|
|||
log.Info().Msgf("The NAT mapping is active for %v", conf.IceIpMap)
|
||||
}
|
||||
|
||||
s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
|
||||
s.EnableSCTPZeroChecksum(true)
|
||||
|
||||
if mod != nil {
|
||||
mod(m, i, &s)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue