Compare commits

...

240 commits

Author SHA1 Message Date
sergystepanov
9e6efc2319 Update retropad input 2025-12-30 14:36:48 +03:00
sergystepanov
1d5bae0c62 Add analog triggers and pack axes into atomic int64
- Pack 4 analog axes (LX, LY, RX, RY) into single int64 for atomic access
- Pack L2/R2 analog triggers into single int32
- Reduce memory per port from 20 to 16 bytes
- Reduce atomic stores per SetInput from 5 to 3
- Add RETRO_DEVICE_INDEX_ANALOG_BUTTON support for analog trigger queries
- Fallback to digital (0/0x7FFF) for non-trigger analog button queries

Wire format: [BTN:2][LX:2][LY:2][RX:2][RY:2][L2:2][R2:2] (14 bytes)
2025-12-29 20:20:55 +03:00
sergystepanov
368bae8c07 Swap mutex to atomics in keyboard input 2025-12-28 21:25:33 +03:00
sergystepanov
58a19affcb Clean SDL/OpenGL functions 2025-12-27 01:58:14 +03:00
sergystepanov
8754a5edfa Tweak OpenGL framebuffer
Force alignment for GL ReadPixels and skip unbinding last framebuffer which makes it a bit faster.
2025-12-26 16:17:37 +03:00
sergystepanov
aeb41008c9 Remove room watchers 2025-12-24 21:25:03 +03:00
sergystepanov
059e19d790 Remove com.Uid from the API 2025-12-24 21:23:19 +03:00
sergystepanov
baa9bad6f8 Update dependencies 2025-12-22 15:38:17 +03:00
sergystepanov
94e13cb93b Clean api 2025-12-22 15:37:04 +03:00
sergystepanov
c800dd4bf9 Fix with go fix 2025-12-22 15:08:50 +03:00
sergystepanov
7c91d200e4 Update Go version to 1.26rc1 2025-12-17 23:12:50 +03:00
sergystepanov
d45daeab7a Tweak room join/creation logic 2025-12-15 18:42:41 +03:00
sergystepanov
b3ccea5f0e Refactor media buffer
Mostly cleanup.
2025-12-15 15:52:46 +03:00
sergystepanov
3178086dd7 Revert due to weird 32KHz mGBA issues
(fix later)
2025-12-14 22:29:27 +03:00
sergystepanov
1e4e5b3c65 Clean media buffer 2025-12-14 22:15:28 +03:00
sergystepanov
7c8e74716d Disable mGBA low-pass filter 2025-12-14 22:14:55 +03:00
sergystepanov
46a5799079 Fix media tests 2025-12-14 18:54:06 +03:00
sergystepanov
9feb788108 Make speexdsp statically linked 2025-12-14 17:01:04 +03:00
sergystepanov
e2f3e005ef Fix speex build libs 2025-12-14 16:31:13 +03:00
sergystepanov
9d54ea4c49 Add and use Speex audio resampler 2025-12-14 16:24:35 +03:00
sergystepanov
671e875f12 Add input cache for retropad, keyboard and mouse 2025-12-14 13:53:21 +03:00
sergystepanov
f708fce112 Revert "Try atomic-based locks in the same thread execution loop instead of a bunch of mutexes."
This reverts commit 460c466053.
2025-12-14 13:30:45 +03:00
sergystepanov
460c466053 Try atomic-based locks in the same thread execution loop instead of a bunch of mutexes. 2025-12-14 13:18:34 +03:00
sergystepanov
84ad0a4cac Add audio resampling option
You can now select between linear interpolation and nearest-neighbor resampling algorithms.
2025-12-13 23:56:38 +03:00
sergystepanov
129690e901 Fix map test 2025-11-22 22:21:05 +03:00
sergystepanov
9191861cab Use iterators in the custom map implementation 2025-11-22 22:09:38 +03:00
sergystepanov
c05e42f597 Cleanup nanoarch.go 2025-11-22 21:20:16 +03:00
sergystepanov
09a0c9c3f2 Revert "Add user input caching"
This reverts commit 859d0c8f1a.
2025-11-22 17:46:07 +03:00
sergystepanov
859d0c8f1a Add user input caching 2025-11-22 17:22:40 +03:00
sergystepanov
baaeaf43b1 Add config option for logging dropped frames 2025-11-22 12:32:56 +03:00
sergystepanov
76b376aef7 Add config option for skipping late video frames 2025-11-22 11:59:08 +03:00
sergystepanov
3df6a24a0a Skip video frames when they are late 2025-11-21 22:35:33 +03:00
sergystepanov
efa7a1d7b5 Update outdated Docker build 2025-11-21 20:44:27 +03:00
sergystepanov
5c6406c1e7 Implemented a busy loop for the emulation ticker.
This replaces the low-precision, OS-dependent time ticker with a CPU spin loop that performs continuous target frame time checks and corrections.
2025-11-21 20:13:27 +03:00
sergystepanov
3392251dda Update dependencies 2025-11-20 00:36:13 +03:00
sergystepanov
bbad4539b1 Update libretro.h 2025-11-20 00:33:03 +03:00
sergystepanov
6b0d7c0ce1 Update Go to 1.25.0 2025-08-15 14:51:20 +03:00
sergystepanov
e03fbadcaa Update dependencies
go: upgraded github.com/VictoriaMetrics/metrics v1.38.0 => v1.39.1
go: upgraded github.com/go-viper/mapstructure/v2 v2.3.0 => v2.4.0
go: upgraded github.com/klauspost/cpuid/v2 v2.2.10 => v2.3.0
go: upgraded github.com/knadh/koanf/v2 v2.2.1 => v2.2.2
go: upgraded github.com/minio/crc64nvme v1.0.2 => v1.1.0
go: upgraded github.com/minio/minio-go/v7 v7.0.94 => v7.0.95
go: upgraded github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c => v1.2.0
go: upgraded github.com/pion/logging v0.2.3 => v0.2.4
go: upgraded github.com/pion/rtp v1.8.19 => v1.8.21
go: upgraded github.com/pion/sdp/v3 v3.0.14 => v3.0.15
go: upgraded github.com/pion/turn/v4 v4.0.2 => v4.1.0
go: upgraded github.com/pion/webrtc/v4 v4.1.2 => v4.1.3
go: upgraded golang.org/x/crypto v0.39.0 => v0.41.0
go: upgraded golang.org/x/net v0.41.0 => v0.43.0
go: upgraded golang.org/x/sys v0.33.0 => v0.35.0
go: upgraded golang.org/x/text v0.26.0 => v0.28.0
2025-08-08 19:41:17 +03:00
sergystepanov
42b003db62 Verifies during startup if the system can run the emulator
This check can be disabled with the emulator.failFast = false config option. Right now it checks SDL2 video context creation.
2025-06-20 18:30:17 +03:00
sergystepanov
d8eed66a1d Update dependencies 2025-06-20 18:30:17 +03:00
sergystepanov
8083ba086b Use /usr/bin/env in the shebang line of shell scripts to ensure portability 2025-05-28 08:19:02 +03:00
sergystepanov
02210f1f8d Remove unnecessary C bridge functions 2025-05-18 12:46:32 +03:00
sergystepanov
817a19c757 Fix the circle-pad's roundness 2025-05-16 12:15:37 +03:00
sergystepanov
36da07f277 Use the actual state size when loading ROMs 2025-05-03 16:35:21 +03:00
sergystepanov
83056bbf4f Update dependencies 2025-05-03 15:57:41 +03:00
sergystepanov
37a4a80996 Use the save state size before each save/load call 2025-05-03 15:55:26 +03:00
sergystepanov
9d4256306e Add missing C function header for Go debugger 2025-05-02 10:34:22 +03:00
sergystepanov
ddfc9249ec Fix some user slot race conditions
In cases where HasSlot() and Reserve() operations are delayed, multiple users may incorrectly be granted a slot due to race conditions.
2025-05-02 10:06:23 +03:00
sergystepanov
a431b7050f Update dependencies 2025-04-17 09:11:26 +03:00
sergystepanov
debd4b23df Disable default static build
It is broken with SDL2 on Windows
2025-04-17 09:11:26 +03:00
sergystepanov
410610349b Switch to UCRT toolchain in MSYS2 2025-04-17 09:11:26 +03:00
sergystepanov
3ac7a559df Skip YUV test
It is broken on Windows
2025-04-17 09:11:26 +03:00
sergystepanov
7c878b1ee3 Update dependencies 2025-04-17 09:11:26 +03:00
sergystepanov
a1506d0f31 Add PGO with 1.24.0 2025-02-13 01:00:13 +03:00
sergystepanov
15ff2f3282 Update Go to 1.24.0 2025-02-12 14:17:43 +03:00
sergystepanov
ffb0abe4da Update dependencies 2025-01-18 19:41:21 +03:00
sergystepanov
3dbf4f9b19 Update dependencies 2025-01-11 16:53:52 +03:00
sergystepanov
b02cd5c4f0
It is time to update the copyright year 2025-01-04 10:41:51 +03:00
Sergey Stepanov
0c768bb3d6
Add some notes on recording in regards to ffconcat 2024-12-21 01:37:42 +03:00
Sergey Stepanov
f78bcf3e4b
Allow config for the remote Libretro core repos 2024-12-20 01:32:20 +03:00
Sergey Stepanov
535e725618
Panic when dlib functions are missing 2024-12-19 21:40:14 +03:00
Sergey Stepanov
4aaeda3fbb
Move some RETRO_ENVIRONMENT vars into C 2024-12-16 23:28:14 +03:00
Sergey Stepanov
600243c87d Update dependencies 2024-12-16 13:48:34 +03:00
Sergey Stepanov
82aebf6647 Fix Package 'libgl1-mesa-glx' has no installation candidate 2024-12-14 14:14:56 +03:00
Sergey Stepanov
89ae98b035 Why do we need samples
In an ideal scenario, the emulator generates a video frame and an audio chunk with its internal frame rate. For example, if the emulator runs a game at 60 FPS, it will produce 16 ms worth of audio and a video frame with each tick (or call of the run function). Then we need to send all this data to the user's browser, which becomes tricky with WebRTC audio.

The WebRTC standard supports only Opus-encoded audio for high-quality sound. The encoder and decoder (the audio player in the browser) have a limitation: they can only operate on fixed audio frames or predefined chunks of audio, which are 5, 10, 20, 40, or 60 ms in length.

Due to this limitation, we have to wait at least two ticks until the first whole audio chunk can be packed into predefined frames. If we have 16 ms of audio and one fixed buffer, we send 10 ms right away and have to wait for 4 ms to add to the remaining 6 ms. This will lead to a constant 6 ms delay between audio and video.

To mitigate this issue, we can set the smallest frame size as a buffer, i.e., 5 ms. This will decrease the latency to 1 ms, but we will send 3 packets of data in this manner for 16 ms.

A slightly better way is to create several buffers and dynamically select the next buffer so that the audio fits optimally, minimizing the number of network packets sent to users.

This frames thing essentially accomplishes that. In the options, we can select multiple (or one) Opus buffers to store audio and choose from. They should be defined from the largest to the smallest. And that's it.
2024-12-13 18:57:25 +03:00
sergystepanov
ed3b195b26
Dynamic audio buf
* Ugly audio buf

* Use dynamic Opus frames with config
2024-12-12 21:13:43 +03:00
Sergey Stepanov
f54089e072
Stretch samples a bit better with the GBA's 32768Hz 2024-12-07 00:47:27 +03:00
Sergey Stepanov
6bb82b2204 Allow 2.5ms Opus frame 2024-12-06 15:27:18 +03:00
Sergey Stepanov
d77d69a331 Remove pool from the audio stretcher 2024-12-05 13:50:39 +03:00
Sergey Stepanov
297ec9005c
Display video scaling info 2024-12-05 01:35:48 +03:00
Sergey Stepanov
5649d4410a
Remove pools from YUV conv 2024-12-05 01:11:02 +03:00
Sergey Stepanov
db32479c4e
Destroy rooms when the coordinator was lost 2024-12-05 01:10:16 +03:00
Sergey Stepanov
8fa53f4e32
Disable macos 2024-12-04 22:16:58 +03:00
Sergey Stepanov
a7acebc5d0
Try YUV without the mem pool 2024-12-04 22:09:51 +03:00
Sergey Stepanov
954bb23bb8
Add Reset with 0 key 2024-12-03 00:38:15 +03:00
Sergey Stepanov
7134782245
Enable frame duplication for Mupen64 2024-12-03 00:34:43 +03:00
Sergey Stepanov
5a42dc9857
Fail x2 on no coordinator connection 2024-12-01 20:26:29 +03:00
Sergey Stepanov
9caf45af78
Reset fail timer on success 2024-12-01 18:20:54 +03:00
Sergey Stepanov
56e3ce328e
Update Go to 1.23.3 2024-11-30 21:35:40 +03:00
Sergey Stepanov
6de1828ffe Wait user click when autoplay fails 2024-11-29 14:51:33 +03:00
Sergey Stepanov
b2e275a6cd
Don't crash the app on http2 garbage 2024-11-28 23:24:35 +03:00
Sergey Stepanov
45dba68b15
Faster CopyFile 2024-11-28 21:16:31 +03:00
Sergey Stepanov
31c670252c Update dependencies 2024-11-26 19:35:31 +03:00
Sergey Stepanov
1831e44eef Add new saveStateFs config param
Used when you need a copy of FS for new game sessions (i.e. DOSBox uniqueSaveDir=true).
2024-11-26 19:35:31 +03:00
Sergey Stepanov
71f5de3bf9 Update dependencies 2024-11-26 19:35:31 +03:00
Sergey Stepanov
88a0911f93
Avoid segfault with nil error handlers in ws 2024-11-17 22:27:34 +03:00
Sergey Stepanov
8686c4a6e5
Return workers by wids right away ;_; 2024-11-17 22:13:39 +03:00
Sergey Stepanov
68acb5d790
Show worker tags in manager 2024-11-17 21:24:08 +03:00
Sergey Stepanov
2c50ae2290
Allow one game per a direct worker 2024-11-17 20:29:45 +03:00
Sergey Stepanov
f09500f289
Check slots for direct workers 2024-11-17 19:55:14 +03:00
Sergey Stepanov
1147aeda14 Track all worker saves to resume old games
Move library config to the top level
2024-11-17 12:59:43 +03:00
Sergey Stepanov
7b57f73b26 Send worker lib 2024-11-17 12:59:43 +03:00
Sergey Stepanov
45cc9e8245 Move library config to the top level 2024-11-17 12:59:43 +03:00
Sergey Stepanov
795771e3d6 Add custom file lock 2024-11-17 12:59:43 +03:00
Sergey Stepanov
2ef1a93eaf
Add playsinline video attribute for Safari 2024-10-23 12:25:29 +03:00
Sergey Stepanov
6ccbea8bd9
Fix test 2024-10-18 22:20:34 +03:00
Sergey Stepanov
003eb5b995
Add CBR mode and max-rate, buf-size config options to x264 2024-10-18 21:59:41 +03:00
Sergey Stepanov
0ab6f58d36
Update dependencies 2024-10-18 21:56:54 +03:00
Sergey Stepanov
763f1e5d11
Update Ubuntu Docker container 2024-09-29 23:32:32 +03:00
Sergey Stepanov
16cf91f669
Update Ubuntu Docker container 2024-09-29 23:06:26 +03:00
Sergey Stepanov
0d8db25c3c
Change the video dimensions when playing 2024-09-28 00:30:50 +03:00
Sergey Stepanov
2084d0958b
Update dependencies 2024-09-24 22:59:11 +03:00
Sergey Stepanov
a67a077024
Fix env functions 2024-09-16 21:45:06 +03:00
Sergey Stepanov
f1ece58c7b
Add new aliasFile option
The option allows changing the alias file name.
2024-09-14 20:38:16 +03:00
Sergey Stepanov
fd34d5a972
Update dependencies 2024-09-14 20:38:11 +03:00
Sergey Stepanov
bdf3598367 Add game aliases
Allows different game names to be set in the alias.txt file [as name=alias] located in the games directory.
2024-08-31 22:29:31 +03:00
Sergey Stepanov
b9d35fa626
Fix save dir string freeing order 2024-08-27 14:30:49 +03:00
Sergey Stepanov
7da993a4c7 Add the uniqueSaveDir option
This option allows for the safe use of distinct filesystem snapshots of games with some cores (e.g., DosBox). Keep in mind that with this option enabled, game changes won't be saved (the unique save folder will be deleted on exit) until you explicitly call the save (or share) function. Thus, you will need files like dosbox.conf along with the games to use some default behaviors with each new game session.
2024-08-21 18:52:26 +03:00
Sergey Stepanov
ddb16f899f
Update dependencies 2024-08-17 18:13:46 +03:00
Sergey Stepanov
dea9926e4f
Update Go version to 1.23.0 2024-08-15 15:44:56 +03:00
Sergey Stepanov
61eb55f736
Update Go version to 1.22.6 2024-08-11 13:38:42 +03:00
Sergey Stepanov
e2521eea94
Update cr docker-compose for X11 2024-08-11 13:04:50 +03:00
Sergey Stepanov
8f859cd600
Reinit encoders with the mutexes 2024-08-11 12:18:41 +03:00
Sergey Stepanov
0232384fe2 Use mutex when switching media encoders 2024-08-11 11:50:54 +03:00
Sergey Stepanov
7873631613
Add skip_same_thread_save hack 2024-08-09 00:54:08 +03:00
Sergey Stepanov
466257d3be
Add NonBlockingSave option for background saving
This feature introduces a new configuration option, `NonBlockingSave`, which allows background saving for large files. With this param the saving process will not block the main thread with all network sockets.
By default, it's enabled for the DosBox core.
2024-08-07 20:31:14 +03:00
Sergey Stepanov
1ff7be38eb
Update x/image 2024-08-06 20:36:15 +03:00
Sergey Stepanov
c87b5cec65
Revert "Update dependencies"
This reverts commit 80afc18892.
2024-08-06 20:34:34 +03:00
Sergey Stepanov
80afc18892
Update dependencies 2024-08-06 20:01:01 +03:00
Sergey Stepanov
34a947ac6d
Update libretro.h 2024-08-06 19:57:45 +03:00
Sergey Stepanov
d855e56a2f Add a DOS game (Rogue) 2024-08-02 11:04:44 +03:00
Sergey Stepanov
7ee98c1b03 Add keyboard and mouse support
Keyboard and mouse controls will now work if you use the kbMouseSupport parameter in the config for Libretro cores. Be aware that capturing mouse and keyboard controls properly is only possible in fullscreen mode.

Note: In the case of DOSBox, a virtual filesystem handler is not yet implemented, thus each game state will be shared between all rooms (DOS game instances) of CloudRetro.
2024-08-02 11:04:44 +03:00
Sergey Stepanov
af8569a605
Update dependencies 2024-07-26 17:43:33 +03:00
sergystepanov
d6199c9598
Use ROM folders
Libretro cores are selected based on the file extensions of the ROMs. However, ROM file extensions are not unique across cores (e.g., .zip is used in both DosBox and MAME). To load a specific core correctly, it's necessary to place the corresponding ROMs in designated folders specified in the configuration. By default, you can use the keys from cores.list as the folder names, or you can specify your own custom folder names using the 'folder' parameter for each core.
2024-07-24 20:31:12 +03:00
Sergey Stepanov
ba7db72093
Increase buffers that use max dimensions from the Libretro geometry
Some cores may render frames bigger that reported max dimensions. This may help a bit.
2024-07-15 12:10:18 +03:00
Sergey Stepanov
83b040b39f
Change GL framebuffer if max geometry is different 2024-07-14 19:33:32 +03:00
Sergey Stepanov
d8a3e82f1e
Update dependencies 2024-07-14 19:31:44 +03:00
Sergey Stepanov
c40f9c9127 Resize GL buffer when geometry changes 2024-07-13 11:28:20 +03:00
sergystepanov
7e612458a0
Fix crash when loading games too early (also for mac) 2024-07-10 00:36:20 +03:00
Sergey Stepanov
72b791cc5e
Revert shutdown locks for now (deadlock on macOS) 2024-07-09 23:17:44 +03:00
Sergey Stepanov
e46b739311
Fix crash when loading games too early 2024-07-09 21:21:08 +03:00
Sergey Stepanov
daf6a20e1d
Update dependencies 2024-07-09 21:19:41 +03:00
sergystepanov
ba45936d77
Add another workaround for g0 stack in Go 1.22.5 (#460) 2024-07-05 22:56:28 +03:00
sergystepanov
ca64bd127e
Remove useless code from the libyuv wrapper 2024-05-26 02:34:00 +03:00
Sergey Stepanov
b93eb4911c
Fix conf loading for the emulator mocks 2024-05-21 22:26:20 +03:00
Sergey Stepanov
20e9449bb1
Fix for build.yml 2024-05-21 20:20:21 +03:00
sergystepanov
c2e9d67bcb
Update Windows software OpenGL Mesa drivers to 24.0.7 (#454)
Needed for Zink (Vulkan OpenGL) in the future.
2024-05-21 20:14:52 +03:00
Sergey Stepanov
3989a735ac
Update dependencies 2024-05-20 22:46:59 +03:00
Sergey Stepanov
ede15c4fe5
Make x264 glue code not mem-pinned 2024-05-19 18:55:37 +03:00
Sergey Stepanov
99976dd560
Add frame-options option 2024-05-13 19:29:13 +03:00
Sergey Stepanov
0500550fc0
Cleanup 2024-05-07 23:06:09 +03:00
Sergey Stepanov
c5c2578d0f
Switch Trace to Debug in Libretro logging 2024-05-07 23:05:03 +03:00
Sergey Stepanov
b843538fea
Update dependencies 2024-05-07 21:08:35 +03:00
Sergey Stepanov
9b56ffc87c
Fix macOS tests
Main thread locking hangs OpenGL emulators.
2024-05-07 21:05:12 +03:00
Sergey Stepanov
a4f0dbbca8
Add a health check in cloudretro.io 2024-05-07 19:11:55 +03:00
Sergey Stepanov
b530f7a6cf
Add curl to worker containers 2024-05-07 15:54:42 +03:00
sergystepanov
421e9115cc
Add new options4rom config param (#452)
* Add new options4rom config param

Allows changing core options depending on the ROM name.
2024-04-24 16:38:33 +03:00
Sergey Stepanov
b812887f6e
LibJPEG linking is broken on macOS 14 Sonoma :/ 2024-04-23 21:09:48 +03:00
Sergey Stepanov
b3f677d32f Use for range 2024-04-22 18:10:35 +03:00
Sergey Stepanov
b755bcd1bf Use <video> play listener instead of canplay 2024-04-22 18:10:35 +03:00
Sergey Stepanov
8d79680b81 Migrate to rand/v2 2024-04-22 18:10:35 +03:00
Sergey Stepanov
a013192bf8 Clean webrtc.js 2024-04-22 11:47:46 +03:00
Sergey Stepanov
dceb6f9993 Reuse retropad poll fn 2024-04-22 11:45:07 +03:00
Sergey Stepanov
d922e58278 Fix UI 2024-04-22 11:43:38 +03:00
Sergey Stepanov
8caad44ade Update dependencies 2024-04-22 11:34:56 +03:00
Sergey Stepanov
22d1bd7620 Add screen component 2024-04-07 00:20:47 +03:00
Sergey Stepanov
effa5c46c5
Update UA/PLT detection 2024-04-03 19:52:42 +03:00
Sergey Stepanov
cebbcdf256 Refactor WebRTC stats 2024-04-02 21:10:05 +03:00
Sergey Stepanov
f557d16997
Fix broken link 2024-03-31 22:08:32 +03:00
Sergey Stepanov
3e0fcfbfcf
Enable SCTP zero checksums 2024-03-31 21:31:18 +03:00
Sergey Stepanov
7377b4f15b
Update dependencies 2024-03-31 21:30:46 +03:00
Sergey Stepanov
ecbe7f6ad9
Remove unused ping stats module 2024-03-31 21:22:41 +03:00
Sergey Stepanov
084c14175e
Use AR correction in MAME 2024-03-22 00:17:22 +03:00
Sergey Stepanov
5da77a6b4f
Fix aspect ratio of PSX games in full-screen 2024-03-21 23:02:53 +03:00
Sergey Stepanov
84f55691eb
Check if dup frame didn't exist
FBNeo can return dup frame flag before its first frame.
2024-03-21 23:02:02 +03:00
Sergey Stepanov
4d5033f03c Allow duplicate frames
Some cores for performance reasons may return duplicate frames (i.e. previous frames) instead of rendering them again.
2024-03-21 16:10:09 +03:00
Sergey Stepanov
ff6c344a15 Update dependencies 2024-03-21 16:10:09 +03:00
Sergey Stepanov
104498dec0
Fix wrong import order of some modules 2024-03-18 13:45:01 +03:00
Sergey Stepanov
8654604b9b Fix index.html warnings 2024-03-17 22:09:43 +03:00
Sergey Stepanov
2bc64a3be8 Migrate from IIFE to modern ES modules
These modules should be supported by all contemporary browsers, and this transition should resolve most issues related to the explicit import order of the .js files.
2024-03-17 22:09:43 +03:00
Sergey Stepanov
2aaf37b766 Add Cache-Control for serving static files
Static files will be rechecked every 3 days instead of unlimited cache time. The Cache-Control header is mandatory in order to make browsers handle cache properly with Go's FileServer. The option can be modified in the server.CacheControl line of the config file.
2024-03-17 22:09:43 +03:00
Sergey Stepanov
47bd72e1cd
Fix broken options button 2024-03-16 13:15:05 +03:00
Sergey Stepanov
29eedee3ec
Fix keybindings for options 2024-03-15 14:38:30 +03:00
Sergey Stepanov
a349fdd0cf Add 'force full-screen' option 2024-03-14 12:24:39 +03:00
Sergey Stepanov
cf5248ec54 Fix missing gameList transition handler 2024-03-14 12:24:39 +03:00
Sergey Stepanov
4fc53e7220 Update options UI 2024-03-14 12:24:39 +03:00
Sergey Stepanov
f8392ab0be
Update dependencies 2024-03-08 18:46:46 +03:00
Sergey Stepanov
43d3f84993
Add support of the 0RGB1555 pixel format 2024-03-08 18:43:23 +03:00
Sergey Stepanov
72e846894e
Use case-insensitive sort for games 2024-03-07 23:18:42 +03:00
Sergey Stepanov
84d2261391
Don't stretch portrait games 2024-03-07 17:25:26 +03:00
Sergey Stepanov
608da9f64b
Track fullscreen for <video> 2024-03-05 22:07:12 +03:00
Sergey Stepanov
91ace06f8b
Replace the hasMultitap option with a more general solution
The new hid option enables users to map a specific Libretro device (or multiple devices) to the input ports. For instance, this allows users to map a Multitap controller with the snes9x core.
2024-03-05 21:34:37 +03:00
Sergey Stepanov
cdbb5e98f5
Clean 2024-03-02 16:46:08 +03:00
Sergey Stepanov
92e59672f9
Expose scale factor value 2024-03-02 16:38:53 +03:00
Sergey Stepanov
3568b7a12a
Update dependencies 2024-03-02 16:23:11 +03:00
Sergey Stepanov
4195b7f2dc
Disable load test for new mGBA 2024-02-29 00:53:04 +03:00
Sergey Stepanov
17fe1a938a
Fix serialize test for new mGBA
Consecutive retro_serialize calls won't return same states for mGBA anymore.
2024-02-29 00:38:41 +03:00
Sergey Stepanov
c699455b58
Hide video element controls in fullscreen 2024-02-25 12:51:37 +03:00
Sergey Stepanov
000bc4f661
Load apps after rendering 1 frame
This is mandatory for Mupen and DOSBox save states. Enabled for all emulators.
2024-02-25 12:33:03 +03:00
Sergey Stepanov
9308e1b388
Sort lib alphabetically in console 2024-02-20 21:59:36 +03:00
Sergey Stepanov
1452317d45
Scan ROM extensions case-insensitive 2024-02-20 21:39:49 +03:00
Sergey Stepanov
41bfe4f4d3
Fix WebRTC datachannels in FF 2024-02-20 21:29:57 +03:00
Sergey Stepanov
b79b4c405a
Get random free port in websocket tests 2024-02-17 21:25:14 +03:00
Sergey Stepanov
e7e281083f
Add ugly persistent volume option 2024-02-16 22:47:54 +03:00
Sergey Stepanov
3459c7e8d6 Add VP9 encoder option 2024-02-15 14:06:28 +03:00
Sergey Stepanov
6258f9a5e4
Add RTCP packet reader for output streams
Default interceptors need those.
2024-02-13 21:17:27 +03:00
Sergey Stepanov
61b4108dce
Disable save states tests for Nestopia
Savestates are broken in the Nestopia version 1ae59e3. Wait when new version (revert) is pushed into the nightly repo.
2024-02-13 19:37:23 +03:00
Sergey Stepanov
ce7aa1be62
Disable frame duplication by default
It breaks newer PCSX rearmed versions by pushing dozen of frames in bursts.

To implement a proper support later.
2024-02-13 18:50:38 +03:00
Sergey Stepanov
e2226e7492
Remove Encoder.LoadBuf interface method
There is no point in keeping it only for early YUV image pooling.
2024-02-12 11:23:52 +03:00
Sergey Stepanov
b903700077
Update dependencies 2024-02-11 15:30:45 +03:00
Sergey Stepanov
46067dec8f
Use half GOP size in h264 2024-02-11 15:01:27 +03:00
Sergey Stepanov
11295a28f6 Fix h264 lib for Go 1.22
Unwrapped X264_ C structs prevent crash on Go version 1.22.
2024-02-10 21:13:49 +03:00
Sergey Stepanov
4e241d0448 Swap x264-git dep to libx264 in Msys2/Arch 2024-02-10 21:13:49 +03:00
Sergey Stepanov
a6e56a208c Pin nano string options during Retro callbacks 2024-02-10 21:13:49 +03:00
Sergey Stepanov
ccb0f410ab Revert "Revert Go version back to 1.20"
This reverts commit 1a44b94c85.
2024-02-10 21:13:49 +03:00
Sergey Stepanov
1a44b94c85
Revert Go version back to 1.20
Go 1.22 crashes under Windows with h264 encoder.
2024-02-08 16:38:40 +03:00
Sergey Stepanov
53e55728db
Add PGO for 1.22 2024-02-07 15:15:41 +03:00
Sergey Stepanov
53a3624aef
Upload test frames separately 2024-02-07 12:48:59 +03:00
Sergey Stepanov
d6ceaad220
Enable overwrite for the upload-artifact action 2024-02-07 12:39:17 +03:00
Sergey Stepanov
e67b98d6fe
Update Github actions 2024-02-07 12:33:29 +03:00
Sergey Stepanov
a3f07057f4
Update dependencies 2024-02-07 12:23:14 +03:00
Sergey Stepanov
610e087bcd Update to Go 1.22.0 2024-02-07 11:55:40 +03:00
Sergey Stepanov
fca46f1a32 Update dependencies 2023-12-22 20:49:16 +03:00
sergystepanov
f7d12e65e5
Update README.md
Add some additional libyuv notes.
2023-12-22 16:09:59 +03:00
sergystepanov
27c9ce681b
Update README.md 2023-12-04 18:39:26 +03:00
Sergey Stepanov
1993950cd7
Downgrade requirements for an old Ubuntu 2023-12-04 18:20:05 +03:00
Sergey Stepanov
f475dbabb7
Update dependencies 2023-12-04 01:38:30 +03:00
Sergey Stepanov
b7b530fe60
Update libretro.h 2023-11-26 22:39:52 +03:00
Sergey Stepanov
4fbfa1d4e3
Fix possible NPEs 2023-11-26 22:39:46 +03:00
Sergey Stepanov
a77069a634
Add L2, R2, L3, R3 mappings to keyboard 2023-11-26 22:39:43 +03:00
Sergey Stepanov
aa10008d1b
Use default Pion webrtc interceptors
Slightly higher latency, but more stable in high ping situations.
2023-11-16 01:16:09 +03:00
Sergey Stepanov
e6e537d799 Add a generic S3 provider for cloud saves 2023-11-13 21:33:12 +03:00
Sergey Stepanov
2e91feb861 Add initial automatic aspect ratio change
Depending on the configuration param coreAspectRatio, video streams may have automatic aspect ratio correction in the browser with the value provided by the cores themselves.
2023-11-03 01:12:22 +03:00
Sergey Stepanov
d805ba8eb8 Update dependencies 2023-11-03 01:12:22 +03:00
Himaj333
ad07ad2014 Fixed README.md 2023-10-26 16:57:19 +03:00
Sergey Stepanov
5d65ff14d5
Fix test 2023-10-26 16:39:00 +03:00
guangwu
99ceb5d72c fix: typo 2023-10-26 16:34:51 +03:00
sergystepanov
7f2f1d70b1
Add configurable debouncer for spammy Libretro callbacks 2023-10-25 21:41:36 +03:00
Sergey Stepanov
07f40351fa
Show PCSX boot logo by default 2023-10-24 12:55:39 +03:00
Sergey Stepanov
6525106116
Update video encoders 2023-10-24 12:55:27 +03:00
Sergey Stepanov
3e116fcc52
Drop users when coordinator is lost 2023-10-21 18:45:38 +03:00
Sergey Stepanov
cb968d782a
Show rooms in the list 2023-10-21 02:37:44 +03:00
Sergey Stepanov
10507d9c53 Reorder shutdown functions 2023-10-21 00:34:15 +03:00
Sergey Stepanov
10c4cd9b7f Add start/stop frontend lock 2023-10-20 22:43:51 +03:00
Sergey Stepanov
38dc69e4a2 Show hanged rooms 2023-10-20 22:43:51 +03:00
Sergey Stepanov
377306dc80 Clean frontend tests 2023-10-20 22:43:51 +03:00
Sergey Stepanov
da7059dc79 Tame logs 2023-10-20 22:43:51 +03:00
Sergey Stepanov
9ec6541322 Update dependencies 2023-10-20 22:43:51 +03:00
Sergey Stepanov
e80e31da42 Use low pass filter with GBA 2023-10-20 22:43:51 +03:00
Sergey Stepanov
a69a934029 Remove unused csp param 2023-10-20 22:43:51 +03:00
160 changed files with 16134 additions and 9267 deletions

View file

@ -18,14 +18,14 @@ jobs:
build:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
os: [ ubuntu-latest, windows-latest ]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
- uses: actions/setup-go@v5
with:
go-version: 1.20.8
go-version: 'stable'
- name: Linux
if: matrix.os == 'ubuntu-latest'
@ -36,49 +36,53 @@ jobs:
sudo apt-get -qq install -y \
make pkg-config \
libvpx-dev libx264-dev libopus-dev libyuv-dev libjpeg-turbo8-dev \
libsdl2-dev libgl1-mesa-glx
libsdl2-dev libgl1 libglx-mesa0 libspeexdsp-dev
make build
xvfb-run --auto-servernum make test verify-cores
- name: macOS
if: matrix.os == 'macos-latest'
if: matrix.os == 'macos-12'
run: |
brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo
brew install libvpx x264 sdl2 speexdsp
make build test verify-cores
- uses: msys2/setup-msys2@v2
if: matrix.os == 'windows-latest'
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-x86_64-libyuv
mingw-w64-x86_64-libjpeg-turbo
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: 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
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: emulator-test-frames
name: emulator-test-frames-${{ matrix.os }}
path: _rendered/*.png

View file

@ -4,6 +4,7 @@ coordinator:
debug: true
server:
address:
frameOptions: SAMEORIGIN
https: true
tls:
domain: cloudretro.io
@ -21,19 +22,19 @@ worker:
https: true
tls:
address: :444
domain: cloudretro.io
# domain: cloudretro.io
emulator:
libretro:
logLevel: 1
cores:
list:
dos:
uniqueSaveDir: true
mame:
options:
"fbneo-diagnostic-input": "Hold Start"
nes:
scale: 2
pcsx:
altRepo: true
snes:
scale: 2

View file

@ -1,15 +1,30 @@
version: "3.9"
x-params:
&default-params
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
- seccomp=unconfined
logging:
driver: "journald"
x-worker: &worker
depends_on:
- coordinator
command: ./worker
volumes:
- ${APP_DIR:-/cloud-game}/cache:/usr/local/share/cloud-game/assets/cache
- ${APP_DIR:-/cloud-game}/cores:/usr/local/share/cloud-game/assets/cores
- ${APP_DIR:-/cloud-game}/games:/usr/local/share/cloud-game/assets/games
- ${APP_DIR:-/cloud-game}/libretro:/usr/local/share/cloud-game/libretro
- ${APP_DIR:-/cloud-game}/home:/root/.cr
- x11:/tmp/.X11-unix
healthcheck:
test: curl -f https://cloudretro.io/echo || exit 1
interval: 1m
timeout: 10s
retries: 3
start_period: 40s
start_interval: 5s
services:
@ -17,38 +32,62 @@ services:
<<: *default-params
command: ./coordinator
environment:
- CLOUD_GAME_COORDINATOR_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
- 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}/games:/usr/local/share/cloud-game/assets/games
- ${APP_DIR:-/cloud-game}/home:/root/.cr
worker:
<<: *default-params
depends_on:
- coordinator
deploy:
mode: replicated
replicas: 4
worker01:
<<: [ *default-params, *worker ]
environment:
- DISPLAY=:99
- MESA_GL_VERSION_OVERRIDE=4.5
- CLOUD_GAME_WORKER_LIBRARY_BASEPATH=/usr/local/share/cloud-game/assets/games
- 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
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
- 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" ]
command: [ ":99", "-screen", "0", "320x240x16" ]
volumes:
x11:

View file

@ -2,8 +2,8 @@ ARG BUILD_PATH=/tmp/cloud-game
ARG VERSION=master
# base build stage
FROM ubuntu:lunar AS build0
ARG GO=1.20.8
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 ./
@ -21,7 +21,7 @@ RUN apt-get -q update && apt-get -q install --no-install-recommends -y \
FROM build0 AS build_coordinator
ARG BUILD_PATH
ARG VERSION
ENV GIT_VERSION ${VERSION}
ENV GIT_VERSION=${VERSION}
WORKDIR ${BUILD_PATH}
@ -41,7 +41,7 @@ RUN ${BUILD_PATH}/scripts/version.sh ./web/index.html ${VERSION} && \
FROM build0 AS build_worker
ARG BUILD_PATH
ARG VERSION
ENV GIT_VERSION ${VERSION}
ENV GIT_VERSION=${VERSION}
WORKDIR ${BUILD_PATH}
@ -54,6 +54,7 @@ RUN apt-get -q update && apt-get -q install --no-install-recommends -y \
libyuv-dev \
libjpeg-turbo8-dev \
libx264-dev \
libspeexdsp-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
@ -73,9 +74,10 @@ 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:lunar AS worker
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 \

View file

@ -2,9 +2,9 @@ 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=static
GO_TAGS=
.PHONY: clean test

View file

@ -15,8 +15,7 @@ Discord: [Join Us](https://discord.gg/sXRQZa2zeP)
## Try it at **[cloudretro.io](https://cloudretro.io)**
Direct play an existing game: *
*[Pokemon Emerald](https://cloudretro.io/?id=1bd37d4b5dfda87c___Pokemon%20-%20Emerald%20Version%20(U))**
Direct play an existing game: **[Pokemon Emerald](https://cloudretro.io/?id=1bd37d4b5dfda87c___Pokemon%20-%20Emerald%20Version%20(U))**
## Introduction
@ -57,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 libyuv-dev libjpeg-turbo8-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 jpeg-turbo
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,libyuv,libjpeg-turbo}
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
@ -121,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)

View file

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

BIN
assets/games/dos/rogue.zip Normal file

Binary file not shown.

Binary file not shown.

View file

@ -1,4 +1,3 @@
version: '3'
services:
cloud-game:

86
go.mod
View file

@ -1,54 +1,62 @@
module github.com/giongto35/cloud-game/v3
go 1.20
go 1.25
require (
github.com/VictoriaMetrics/metrics v1.24.0
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.2
github.com/gofrs/flock v0.8.1
github.com/gorilla/websocket v1.5.0
github.com/knadh/koanf/maps v0.1.1
github.com/knadh/koanf/v2 v2.0.1
github.com/pion/ice/v3 v3.0.1
github.com/pion/interceptor v0.1.22
github.com/pion/logging v0.2.2
github.com/pion/webrtc/v4 v4.0.0-beta.5
github.com/rs/xid v1.5.0
github.com/rs/zerolog v1.31.0
github.com/veandco/go-sdl2 v0.4.35
golang.org/x/crypto v0.14.0
golang.org/x/image v0.13.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/davecgh/go-spew v1.1.1 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // 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/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pion/datachannel v1.5.5 // indirect
github.com/pion/dtls/v2 v2.2.7 // indirect
github.com/pion/mdns v0.0.9 // 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.8.2 // indirect
github.com/pion/sctp v1.8.9 // indirect
github.com/pion/sdp/v3 v3.0.6 // indirect
github.com/pion/srtp/v3 v3.0.0 // indirect
github.com/pion/stun/v2 v2.0.0 // indirect
github.com/pion/transport/v2 v2.2.4 // indirect
github.com/pion/transport/v3 v3.0.1 // indirect
github.com/pion/turn/v3 v3.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.8.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.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

323
go.sum
View file

@ -1,246 +1,133 @@
github.com/VictoriaMetrics/metrics v1.24.0 h1:ILavebReOjYctAGY5QU2F9X0MYvkcrG3aEn2RKa1Zkw=
github.com/VictoriaMetrics/metrics v1.24.0/go.mod h1:eFT25kvsTidQFHb6U0oa0rTrDRdz4xTYjpL8+UPohys=
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.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/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.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/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/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g=
github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus=
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.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-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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
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/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew8=
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/ice/v3 v3.0.1 h1:dwWGgIFDlYrKrCW13LihifuFabGw375hoU0347S9wNw=
github.com/pion/ice/v3 v3.0.1/go.mod h1:j4tfTlj4aSEQN9gP3IdliSHcUTWTu9tlOZL0c59MFXo=
github.com/pion/interceptor v0.1.22 h1:khhimAF0/VmGaIfeE+bA3X1jm0lD8C8HOGcU7vpWcPA=
github.com/pion/interceptor v0.1.22/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y=
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.8/go.mod h1:hYE72WX8WDveIhg7fmXgMKivD3Puklk0Ymzog0lSyaI=
github.com/pion/mdns v0.0.9 h1:7Ue5KZsqq8EuqStnpPWV33vYYEH0+skdDN5L7EiEsI4=
github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc=
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.10 h1:nkr3uj+8Sp97zyItdN60tE/S6vk4al5CPRR6Gejsdjc=
github.com/pion/rtcp v1.2.10/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I=
github.com/pion/rtp v1.8.1/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.2 h1:oKMM0K1/QYQ5b5qH+ikqDSZRipP5mIxPJcgcvw5sH0w=
github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.5/go.mod h1:SUFFfDpViyKejTAdwD1d/HQsCu+V/40cCs2nZIvC3s0=
github.com/pion/sctp v1.8.9 h1:TP5ZVxV5J7rz7uZmbyvnUvsn7EJ2x/5q9uhsTtXbI3g=
github.com/pion/sctp v1.8.9/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI=
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/v3 v3.0.0 h1:dH5nZUTxN+JDu4otle8Dfh5E/MHR6m8/aib7eD22QDc=
github.com/pion/srtp/v3 v3.0.0/go.mod h1:WxJGk0scShe0UdUidDgR0kDHywX7JN83JOYPkYiLdpM=
github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0=
github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ=
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.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8=
github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE=
github.com/pion/webrtc/v4 v4.0.0-beta.5 h1:mW4Z8I50IG2ATa9i6tgClGMTdvTUHrxfAefReI0V2QE=
github.com/pion/webrtc/v4 v4.0.0-beta.5/go.mod h1:epqb0qKpAf5GWPMeDmK1W9Za+dJqlDcx4iKp7+aem6I=
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.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.35 h1:NohzsfageDWGtCd9nf7Pc3sokMK/MOK+UA2QMJARWzQ=
github.com/veandco/go-sdl2 v0.4.35/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.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
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=

View file

@ -19,21 +19,22 @@ package api
import (
"encoding/json"
"fmt"
"strings"
)
type (
Id interface {
String() string
}
Stateful[T Id] struct {
Id T `json:"id"`
Stateful struct {
Id string `json:"id"`
}
Room struct {
Rid string `json:"room_id"` // room id
Rid string `json:"room_id"`
}
StatefulRoom[T Id] struct {
Stateful[T]
Room
StatefulRoom struct {
Id string `json:"id"`
Rid string `json:"room_id"`
}
PT uint8
)
@ -62,8 +63,9 @@ 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
@ -76,14 +78,17 @@ const (
SaveGame PT = 106
LoadGame PT = 107
ChangePlayer PT = 108
ToggleMultitap PT = 109
RecordGame PT = 110
GetWorkerList PT = 111
ErrNoFreeSlots PT = 112
ResetGame PT = 113
RegisterRoom PT = 201
CloseRoom PT = 202
IceCandidate = WebrtcIce
TerminateSession PT = 204
AppVideoChange PT = 150
LibNewGameList PT = 205
PrevSessions PT = 206
)
func (p PT) String() string {
@ -110,20 +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"
}
@ -146,6 +157,21 @@ var (
OkPacket = Out{Payload: "ok"}
)
func Do[I Id, T any](in In[I], fn func(T)) error {
if dat := Unwrap[T](in.Payload); dat != nil {
fn(*dat)
return nil
}
return ErrMalformed
}
func DoE[I Id, T any](in In[I], fn func(T) error) error {
if dat := Unwrap[T](in.Payload); dat != nil {
return fn(*dat)
}
return ErrMalformed
}
func Unwrap[T any](data []byte) *T {
out := new(T)
if err := json.Unmarshal(data, out); err != nil {
@ -160,3 +186,17 @@ func UnwrapChecked[T any](bytes []byte, err error) (*T, error) {
}
return Unwrap[T](bytes), nil
}
func Wrap(t any) ([]byte, error) { return json.Marshal(t) }
const separator = "___"
func ExplodeDeepLink(link string) (string, string) {
p := strings.SplitN(link, separator, 2)
if len(p) == 1 {
return p[0], ""
}
return p[0], p[1]
}

View file

@ -36,6 +36,7 @@ type Server struct {
PingURL string `json:"ping_url"`
Port string `json:"port,omitempty"`
Replicas uint32 `json:"replicas,omitempty"`
Room string `json:"room,omitempty"`
Tag string `json:"tag,omitempty"`
Zone string `json:"zone,omitempty"`
}

View file

@ -11,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"`
@ -22,6 +27,7 @@ type (
Wid string `json:"wid"`
}
AppMeta struct {
Alias string `json:"alias,omitempty"`
Title string `json:"title"`
System string `json:"system"`
}

View file

@ -1,30 +1,27 @@
package api
type (
ChangePlayerRequest[T Id] struct {
StatefulRoom[T]
ChangePlayerRequest struct {
StatefulRoom
Index int `json:"index"`
}
ChangePlayerResponse int
GameQuitRequest[T Id] struct {
StatefulRoom[T]
}
LoadGameRequest[T Id] struct {
StatefulRoom[T]
}
LoadGameResponse string
SaveGameRequest[T Id] struct {
StatefulRoom[T]
}
SaveGameResponse string
StartGameRequest[T Id] struct {
StatefulRoom[T]
ChangePlayerResponse int
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"`
@ -33,30 +30,41 @@ type (
}
StartGameResponse struct {
Room
Record bool
AV *AppVideoInfo `json:"av"`
Record bool `json:"record"`
KbMouse bool `json:"kb_mouse"`
}
RecordGameRequest[T Id] struct {
StatefulRoom[T]
RecordGameRequest struct {
StatefulRoom
Active bool `json:"active"`
User string `json:"user"`
}
RecordGameResponse string
TerminateSessionRequest[T Id] struct {
Stateful[T]
}
ToggleMultitapRequest[T Id] struct {
StatefulRoom[T]
}
WebrtcAnswerRequest[T Id] struct {
Stateful[T]
RecordGameResponse string
TerminateSessionRequest Stateful
WebrtcAnswerRequest struct {
Stateful
Sdp string `json:"sdp"`
}
WebrtcIceCandidateRequest[T Id] struct {
Stateful[T]
WebrtcIceCandidateRequest struct {
Stateful
Candidate string `json:"candidate"` // Base64-encoded ICE candidate
}
WebrtcInitRequest[T Id] struct {
Stateful[T]
}
WebrtcInitRequest Stateful
WebrtcInitResponse string
AppVideoInfo struct {
W int `json:"w"`
H int `json:"h"`
S int `json:"s"`
A float32 `json:"a"`
}
LibGameListInfo struct {
T int
List []GameInfo
}
PrevSessionInfo struct {
List []string
}
)

View file

@ -2,14 +2,19 @@ package com
import "github.com/giongto35/cloud-game/v3/pkg/logger"
type NetClient[K comparable] interface {
type stringer interface {
comparable
String() string
}
type NetClient[K stringer] interface {
Disconnect()
Id() K
}
type NetMap[K comparable, T NetClient[K]] struct{ Map[K, T] }
type NetMap[K stringer, T NetClient[K]] struct{ Map[K, T] }
func NewNetMap[K comparable, T NetClient[K]]() NetMap[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)}}
}
@ -19,6 +24,12 @@ 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
@ -55,6 +66,10 @@ func (c *SocketClient[T, P, _, _]) ProcessPackets(fn func(in P) error) chan stru
return c.sock.conn.Listen()
}
func (c *SocketClient[T, P, X, P2]) SetErrorHandler(h func(error)) { c.sock.conn.SetErrorHandler(h) }
func (c *SocketClient[T, P, X, P2]) SetMaxMessageSize(s int64) { c.sock.conn.SetMaxMessageSize(s) }
func (c *SocketClient[_, _, _, _]) handleMessage(message []byte, err error) {
if err != nil {
c.log.Error().Err(err).Send()

View file

@ -2,6 +2,7 @@ package com
import (
"fmt"
"iter"
"sync"
)
@ -9,72 +10,118 @@ import (
// Keep in mind that the underlying map structure will grow indefinitely.
type Map[K comparable, V any] struct {
m map[K]V
mu sync.Mutex
mu sync.RWMutex
}
func (m *Map[K, _]) Has(key K) bool { _, ok := m.Contains(key); return ok }
func (m *Map[_, _]) Len() int { m.mu.Lock(); defer m.mu.Unlock(); return len(m.m) }
func (m *Map[K, V]) Pop(key K) V {
m.mu.Lock()
v := m.m[key]
delete(m.m, key)
m.mu.Unlock()
return v
func (m *Map[K, _]) Len() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.m)
}
func (m *Map[K, V]) Put(key K, v V) bool {
m.mu.Lock()
func (m *Map[K, _]) Has(key K) bool {
m.mu.RLock()
_, ok := m.m[key]
m.m[key] = v
m.mu.Unlock()
m.mu.RUnlock()
return ok
}
func (m *Map[K, _]) Remove(key K) { m.mu.Lock(); delete(m.m, key); m.mu.Unlock() }
func (m *Map[K, _]) RemoveL(key K) int {
m.mu.Lock()
delete(m.m, key)
k := len(m.m)
m.mu.Unlock()
return k
}
func (m *Map[K, V]) String() string {
m.mu.Lock()
s := fmt.Sprintf("%v", m.m)
m.mu.Unlock()
return s
}
// Contains returns the first value found and a boolean flag if its found or not.
func (m *Map[K, V]) Contains(key K) (v V, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
if vv, ok := m.m[key]; ok {
return vv, true
}
return v, false
// Get returns the value and exists flag (standard map comma-ok idiom).
func (m *Map[K, V]) Get(key K) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
val, ok := m.m[key]
return val, ok
}
func (m *Map[K, V]) Find(key K) V {
v, _ := m.Contains(key)
v, _ := m.Get(key)
return v
}
// FindBy searches the first key-value with the provided predicate function.
func (m *Map[K, V]) FindBy(fn func(v V) bool) (v V, ok bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, vv := range m.m {
if fn(vv) {
return vv, true
}
}
return v, false
func (m *Map[K, V]) String() string {
m.mu.RLock()
defer m.mu.RUnlock()
return fmt.Sprintf("%v", m.m)
}
// ForEach processes every element with the provided callback function.
func (m *Map[K, V]) ForEach(fn func(v V)) {
// FindBy searches for the first value satisfying the predicate.
// Note: This holds a Read Lock during iteration.
func (m *Map[K, V]) FindBy(predicate func(v V) bool) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, v := range m.m {
if predicate(v) {
return v, true
}
}
var zero V
return zero, false
}
// Put sets the value and returns true if the key already existed.
func (m *Map[K, V]) Put(key K, v V) bool {
m.mu.Lock()
defer m.mu.Unlock()
for _, v := range m.m {
fn(v)
if m.m == nil {
m.m = make(map[K]V)
}
_, exists := m.m[key]
m.m[key] = v
return exists
}
func (m *Map[K, V]) Remove(key K) {
m.mu.Lock()
delete(m.m, key)
m.mu.Unlock()
}
// Pop returns the value and removes it from the map.
// Returns zero value if not found.
func (m *Map[K, V]) Pop(key K) V {
m.mu.Lock()
defer m.mu.Unlock()
val, ok := m.m[key]
if ok {
delete(m.m, key)
}
return val
}
// RemoveL removes the key and returns the new length of the map.
func (m *Map[K, _]) RemoveL(key K) int {
m.mu.Lock()
defer m.mu.Unlock()
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
}
}
}
}

View file

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

View file

@ -29,7 +29,6 @@ func UidFromString(id string) (Uid, error) {
}
func (u Uid) Short() string { return u.String()[:3] + "." + u.String()[len(u.String())-3:] }
func (u Uid) Id() string { return u.String() }
type HasCallId interface {
SetGetId(fmt.Stringer)
@ -72,7 +71,7 @@ type request struct {
response []byte
}
const DefaultCallTimeout = 7 * time.Second
const DefaultCallTimeout = 10 * time.Second
var errCanceled = errors.New("canceled")
var errTimeout = errors.New("timeout")
@ -97,7 +96,9 @@ func (s *Server) Connect(w http.ResponseWriter, r *http.Request) (*Connection, e
return connect(s.Server.Connect(w, r, nil))
}
func (c Connection) IsServer() bool { return c.conn.IsServer() }
func (c *Connection) 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 {
@ -168,10 +169,10 @@ func (t *RPC[_, _]) callTimeout() time.Duration {
func (t *RPC[_, _]) Cleanup() {
// drain cancels all what's left in the task queue.
t.calls.ForEach(func(task *request) {
for task := range t.calls.Values() {
if task.err == nil {
task.err = errCanceled
}
close(task.done)
})
}
}

View file

@ -3,7 +3,8 @@ package com
import (
"encoding/json"
"fmt"
"math/rand"
"math/rand/v2"
"net"
"net/http"
"net/url"
"sync"
@ -49,7 +50,13 @@ func TestWebsocket(t *testing.T) {
}
func testWebsocket(t *testing.T) {
addr := ":8989"
port, err := getFreePort()
if err != nil {
t.Logf("couldn't get any free port")
t.Skip()
}
addr := fmt.Sprintf(":%v", port)
server := newServer(addr, t)
client := newClient(t, url.URL{Scheme: "ws", Host: "localhost" + addr, Path: "/ws"})
clDone := client.ProcessPackets(func(in TestIn) error { return nil })
@ -81,14 +88,12 @@ func testWebsocket(t *testing.T) {
// test
for _, call := range calls {
call := call
if call.concurrent {
rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < n; i++ {
for range n {
packet := call.packet
go func() {
defer wait.Done()
time.Sleep(time.Duration(rand.Intn(200-100)+100) * time.Millisecond)
time.Sleep(time.Duration(rand.IntN(200-100)+100) * time.Millisecond)
vv, err := client.rpc.Call(client.sock.conn, &packet)
err = checkCall(vv, err, call.value)
if err != nil {
@ -98,7 +103,7 @@ func testWebsocket(t *testing.T) {
}()
}
} else {
for i := 0; i < n; i++ {
for range n {
packet := call.packet
vv, err := client.rpc.Call(client.sock.conn, &packet)
err = checkCall(vv, err, call.value)
@ -206,3 +211,15 @@ func newServer(addr string, t *testing.T) *serverHandler {
wg.Wait()
return &handler
}
func getFreePort() (port int, err error) {
var a *net.TCPAddr
var l *net.TCPListener
if a, err = net.ResolveTCPAddr("tcp", ":0"); err == nil {
if l, err = net.ListenTCP("tcp", a); err == nil {
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
}
return
}

View file

@ -18,6 +18,27 @@
# 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
@ -27,22 +48,6 @@ coordinator:
# - empty value (default, any free)
# - ping (with the lowest ping)
selector:
# games library
library:
# root folder for the library (where games are stored)
basePath: assets/games
# an explicit list of supported file extensions
# which overrides Libretro emulator ROMs configs
supported:
# a list of ignored words in the ROM filenames
ignored:
- neogeo
- pgm
# print some additional info
verbose: true
# enable library directory live reload
# (experimental)
watchMode: false
monitoring:
port: 6601
# enable Go profiler HTTP server
@ -56,9 +61,13 @@ coordinator:
origin:
userWs:
workerWs:
# max websocket message size in bytes
maxWsSize: 32000000
# HTTP(S) server config
server:
address: :8000
cacheControl: "max-age=259200, must-revalidate"
frameOptions: ""
https: false
# Letsencrypt or self cert config
tls:
@ -118,14 +127,6 @@ emulator:
# (removed)
threads: 0
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
@ -136,9 +137,23 @@ emulator:
# path for storing emulator generated files
localPath: "./libretro"
# checks if the system supports running an emulator at startup
failFast: true
# do not send late video frames
skipLateFrames: false
# log dropped frames (temp)
logDroppedFrames: false
libretro:
# use zip compression for emulator save states
saveCompression: true
# Sets a limiter function for some spammy core callbacks.
# 0 - disabled, otherwise -- time in milliseconds for ignoring repeated calls except the last.
debounceMs: 0
# Allow duplicate frames
dup: true
# Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX
logLevel: 1
cores:
@ -154,6 +169,32 @@ emulator:
sync: true
# external cross-process mutex lock
extLock: "{user}/.cr/cloud-game.lock"
map:
darwin:
amd64:
arch: x86_64
ext: .dylib
os: osx
vendor: apple
arm64:
arch: arm64
ext: .dylib
os: osx
vendor: apple
linux:
amd64:
arch: x86_64
ext: .so
os: linux
arm:
arch: armv7-neon-hf
ext: .so
os: linux
windows:
amd64:
arch: x86_64
ext: .dll
os: windows
main:
type: buildbot
url: https://buildbot.libretro.com/nightly
@ -185,7 +226,15 @@ emulator:
# - ratio (float)
# - isGlAllowed (bool)
# - usesLibCo (bool)
# - hasMultitap (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.
@ -195,14 +244,24 @@ emulator:
# 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" ]
@ -210,20 +269,28 @@ emulator:
folder: psx
# see: https://github.com/libretro/pcsx_rearmed/blob/master/frontend/libretro_core_options.h
options:
"pcsx_rearmed_show_bios_bootlogo": enabled
"pcsx_rearmed_drc": enabled
"pcsx_rearmed_display_internal_fps": disabled
# 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" ]
hasMultitap: true
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" ]
@ -239,7 +306,7 @@ emulator:
"mupen64plus-EnableEnhancedTextureStorage": True
"mupen64plus-EnableFBEmulation": True
"mupen64plus-EnableLegacyBlending": True
"mupen64plus-FrameDuping": False
"mupen64plus-FrameDuping": True
"mupen64plus-MaxTxCacheSize": 8000
"mupen64plus-ThreadedRenderer": False
"mupen64plus-cpucore": dynamic_recompiler
@ -247,20 +314,52 @@ emulator:
"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)
# 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: 26
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
@ -297,19 +396,24 @@ recording:
# save directory
folder: ./recording
# cloud storage options
# it is mandatory to use a cloud storage when running
# a distributed multi-server configuration in order to
# share save states between nodes (resume games on a different worker)
storage:
# cloud storage provider:
# - empty (No op storage stub)
# - oracle [Oracle Object Storage](https://www.oracle.com/cloud/storage/object-storage.html)
# - s3 (S3 API compatible object storage)
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:
s3Endpoint:
s3BucketName:
s3AccessKeyId:
s3SecretAccessKey:
webrtc:
# turn off default Pion interceptors (see: https://github.com/pion/interceptor)
# (performance)
disableDefaultInterceptors: true
disableDefaultInterceptors: false
# indicates the role of the DTLS transport (see: https://github.com/pion/webrtc/blob/master/dtlsrole.go)
# (debug)
# - (default)

View file

@ -5,6 +5,7 @@ import "flag"
type CoordinatorConfig struct {
Coordinator Coordinator
Emulator Emulator
Library Library
Recording Recording
Version Version
Webrtc Webrtc
@ -14,6 +15,7 @@ type Coordinator struct {
Analytics Analytics
Debug bool
Library Library
MaxWsSize int64
Monitoring Monitoring
Origin struct {
UserWs string

View file

@ -1,22 +1,22 @@
package config
import (
"errors"
"path"
"path/filepath"
"runtime"
"strings"
)
type Emulator struct {
Threads int
AspectRatio struct {
Keep bool
Width int
Height int
}
Storage string
LocalPath string
Libretro LibretroConfig
AutosaveSec int
FailFast bool
Threads int
Storage string
LocalPath string
Libretro LibretroConfig
AutosaveSec int
SkipLateFrames bool
LogDroppedFrames bool
}
type LibretroConfig struct {
@ -24,39 +24,72 @@ type LibretroConfig struct {
Paths struct {
Libs string
}
Repo struct {
Sync bool
ExtLock string
Main LibretroRepoConfig
Secondary LibretroRepoConfig
}
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
Folder string
Hacks []string
HasMultitap bool
Height int
IsGlAllowed bool
Lib string
Options map[string]string
Roms []string
Scale float64
UsesLibCo bool
VFR bool
Width int
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 {
@ -101,6 +134,10 @@ func (e Emulator) GetSupportedExtensions() []string {
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})

View file

@ -88,6 +88,9 @@ func (e *Env) Read() (Kv, error) {
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
@ -102,7 +105,9 @@ func (e *Env) Read() (Kv, error) {
} else {
key = strings.Replace(n[:x+1], "_", ".", -1) + n[x+2:]
}
mp[key] = parts[1]
if len(parts) > 1 {
mp[key] = parts[1]
}
}
return maps.Unflatten(mp, "."), nil
}

View file

@ -9,8 +9,10 @@ import (
func TestConfigEnv(t *testing.T) {
var out WorkerConfig
_ = os.Setenv("CLOUD_GAME_ENCODER_AUDIO_FRAME", "33")
defer func() { _ = os.Unsetenv("CLOUD_GAME_ENCODER_AUDIO_FRAME") }()
_ = 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() {
@ -22,8 +24,11 @@ func TestConfigEnv(t *testing.T) {
t.Fatal(err)
}
if out.Encoder.Audio.Frame != 33 {
t.Errorf("%v is not 33", out.Encoder.Audio.Frame)
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"]

View file

@ -5,6 +5,8 @@ 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
@ -30,9 +32,11 @@ type Monitoring struct {
func (c *Monitoring) IsEnabled() bool { return c.MetricEnabled || c.ProfilingEnabled }
type Server struct {
Address string
Https bool
Tls struct {
Address string
CacheControl string
FrameOptions string
Https bool
Tls struct {
Address string
Domain string
HttpsKey string

View file

@ -14,6 +14,7 @@ import (
type WorkerConfig struct {
Encoder Encoder
Emulator Emulator
Library Library
Recording Recording
Storage Storage
Worker Worker
@ -22,13 +23,15 @@ type WorkerConfig struct {
}
type Storage struct {
Provider string
Key string
Provider string
S3Endpoint string
S3BucketName string
S3AccessKeyId string
S3SecretAccessKey string
}
type Worker struct {
Debug bool
Library Library
Monitoring Monitoring
Network struct {
CoordinatorAddress string
@ -48,13 +51,18 @@ type Encoder struct {
}
type Audio struct {
Frame int
Frames []float32
Resampler int
}
type Video struct {
Codec string
H264 struct {
Codec string
Threads int
H264 struct {
Mode string
Crf uint8
MaxRate int
BufSize int
LogLevel int32
Preset string
Profile string

View file

@ -8,7 +8,6 @@ import (
"strings"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/games"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/monitoring"
"github.com/giongto35/cloud-game/v3/pkg/network/httpx"
@ -23,10 +22,7 @@ type Coordinator struct {
}
func New(conf config.CoordinatorConfig, log *logger.Logger) (*Coordinator, error) {
coordinator := &Coordinator{}
lib := games.NewLib(conf.Coordinator.Library, conf.Emulator, log)
lib.Scan()
coordinator.hub = NewHub(conf, lib, log)
coordinator := &Coordinator{hub: NewHub(conf, log)}
h, err := NewHTTPServer(conf, log, func(mux *httpx.Mux) *httpx.Mux {
mux.HandleFunc("/ws", coordinator.hub.handleUserConnection())
mux.HandleFunc("/wso", coordinator.hub.handleWorkerConnection())
@ -83,7 +79,7 @@ func index(conf config.CoordinatorConfig, log *logger.Logger) httpx.Handler {
handler := func(tpl *template.Template, w httpx.ResponseWriter, r *httpx.Request) {
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")
}
}
@ -92,6 +88,12 @@ func index(conf config.CoordinatorConfig, log *logger.Logger) httpx.Handler {
if conf.Coordinator.Debug {
log.Info().Msgf("Using auto-reloading index.html")
return httpx.HandlerFunc(func(w httpx.ResponseWriter, r *httpx.Request) {
if conf.Coordinator.Server.CacheControl != "" {
w.Header().Add("Cache-Control", conf.Coordinator.Server.CacheControl)
}
if conf.Coordinator.Server.FrameOptions != "" {
w.Header().Add("X-Frame-Options", conf.Coordinator.Server.FrameOptions)
}
if r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") {
tpl := template.Must(template.ParseFiles(indexHTML))
handler(tpl, w, r)
@ -102,6 +104,12 @@ func index(conf config.CoordinatorConfig, log *logger.Logger) httpx.Handler {
}
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

View file

@ -10,7 +10,6 @@ import (
"github.com/giongto35/cloud-game/v3/pkg/api"
"github.com/giongto35/cloud-game/v3/pkg/com"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/games"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
@ -24,20 +23,18 @@ type Connection interface {
}
type Hub struct {
conf config.CoordinatorConfig
launcher games.Launcher
log *logger.Logger
users com.NetMap[com.Uid, *User]
workers com.NetMap[com.Uid, *Worker]
conf config.CoordinatorConfig
log *logger.Logger
users com.NetMap[com.Uid, *User]
workers com.NetMap[com.Uid, *Worker]
}
func NewHub(conf config.CoordinatorConfig, lib games.GameLibrary, log *logger.Logger) *Hub {
func NewHub(conf config.CoordinatorConfig, log *logger.Logger) *Hub {
return &Hub{
conf: conf,
users: com.NewNetMap[com.Uid, *User](),
workers: com.NewNetMap[com.Uid, *Worker](),
launcher: games.NewGameLauncher(lib),
log: log,
conf: conf,
users: com.NewNetMap[com.Uid, *User](),
workers: com.NewNetMap[com.Uid, *Worker](),
log: log,
}
}
@ -62,21 +59,29 @@ func (h *Hub) handleUserConnection() http.HandlerFunc {
user := NewUser(conn, log)
defer h.users.RemoveDisconnect(user)
done := user.HandleRequests(h, h.launcher, h.conf)
done := user.HandleRequests(h, h.conf)
params := r.URL.Query()
worker := h.findWorkerFor(user, params, h.log.Extend(h.log.With().Str("cid", user.Id().Short())))
if worker == nil {
user.Notify(api.ErrNoFreeSlots, "")
h.log.Info().Msg("no free workers")
return
}
user.Bind(worker)
// 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 := h.launcher.GetAppNames()
apps := worker.AppNames()
list := make([]api.AppMeta, len(apps))
for i := range apps {
list[i] = api.AppMeta{Title: apps[i].Name, System: apps[i].System}
list[i] = api.AppMeta{Alias: apps[i].Alias, Title: apps[i].Name, System: apps[i].System}
}
user.InitSession(worker.Id().String(), h.conf.Webrtc.IceServers, list)
log.Info().Str(logger.DirectionField, logger.MarkPlus).Msgf("user %s", user.Id())
<-done
@ -104,6 +109,8 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc {
Str(logger.DirectionField, logger.MarkIn),
)
h.log.Debug().Msgf("WS max message size: %vb", h.conf.Coordinator.MaxWsSize)
return func(w http.ResponseWriter, r *http.Request) {
h.log.Debug().Msgf("Handshake %v", r.Host)
@ -131,6 +138,7 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc {
log.Error().Err(err).Msg("worker connection fail")
return
}
conn.SetMaxReadSize(h.conf.Coordinator.MaxWsSize)
worker := NewWorker(conn, *handshake, log)
defer h.workers.RemoveDisconnect(worker)
@ -144,8 +152,9 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc {
}
func (h *Hub) GetServerList() (r []api.Server) {
h.workers.ForEach(func(w *Worker) {
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(),
@ -154,8 +163,12 @@ func (h *Hub) GetServerList() (r []api.Server) {
Port: w.Port,
Tag: w.Tag,
Zone: w.Zone,
})
})
}
if debug {
server.Room = w.RoomId
}
r = append(r, server)
}
return
}
@ -163,15 +176,30 @@ func (h *Hub) GetServerList() (r []api.Server) {
// various conditions.
func (h *Hub) findWorkerFor(usr *User, q url.Values, log *logger.Logger) *Worker {
log.Debug().Msg("Search available workers")
roomId := q.Get(api.RoomIdQueryParam)
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 worker = h.findWorkerByRoom(roomId, zone); worker != nil {
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.findWorkerById(wid, h.conf.Coordinator.Debug); worker != nil {
log.Debug().Msgf("Worker with id: %v has been found", wid)
} 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:
@ -190,23 +218,40 @@ func (h *Hub) findWorkerFor(usr *User, q url.Values, log *logger.Logger) *Worker
return worker
}
func (h *Hub) findWorkerByRoom(id string, region string) *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 { return w.RoomId == id && w.In(region) })
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
h.workers.ForEach(func(w *Worker) {
for w := range h.workers.Values() {
if w.HasSlot() && w.In(region) {
workers = append(workers, w)
}
})
}
return workers
}

View file

@ -4,7 +4,6 @@ import (
"github.com/giongto35/cloud-game/v3/pkg/api"
"github.com/giongto35/cloud-game/v3/pkg/com"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/games"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
@ -29,77 +28,54 @@ func NewUser(sock *com.Connection, log *logger.Logger) *User {
}
}
func (u *User) Bind(w *Worker) {
func (u *User) Bind(w *Worker) bool {
u.w = w
u.w.Reserve()
// 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.UnReserve()
u.w.TerminateSession(u.Id())
u.w.TerminateSession(u.Id().String())
}
}
func (u *User) HandleRequests(info HasServerInfo, launcher games.Launcher, conf config.CoordinatorConfig) chan struct{} {
return u.ProcessPackets(func(x api.In[com.Uid]) error {
payload := x.GetPayload()
switch x.GetType() {
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](payload)
if rq == nil {
return api.ErrMalformed
}
u.HandleWebrtcAnswer(*rq)
err = api.Do(x, u.HandleWebrtcAnswer)
case api.WebrtcIce:
rq := api.Unwrap[api.WebrtcUserIceCandidate](payload)
if rq == nil {
return api.ErrMalformed
}
u.HandleWebrtcIceCandidate(*rq)
err = api.Do(x, u.HandleWebrtcIceCandidate)
case api.StartGame:
rq := api.Unwrap[api.GameStartUserRequest](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[com.Uid]](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](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[com.Uid]](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)
}
return nil
return
})
}

View file

@ -10,15 +10,11 @@ import (
// 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.
@ -37,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})
}

View file

@ -2,15 +2,15 @@ package coordinator
import (
"sort"
"time"
"github.com/giongto35/cloud-game/v3/pkg/api"
"github.com/giongto35/cloud-game/v3/pkg/com"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/games"
)
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")
return
@ -19,34 +19,64 @@ func (u *User) HandleWebrtcInit() {
}
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 config.CoordinatorConfig) {
// +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).Send()
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")
return
@ -56,7 +86,7 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launc
return
}
u.log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker")
u.StartGame()
u.StartGame(startGameResp.AV, startGameResp.KbMouse)
// send back recording status
if conf.Recording.Enabled && rq.Record {
@ -64,23 +94,37 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launc
}
}
func (u *User) HandleQuitGame(rq api.GameQuitRequest[com.Uid]) {
if rq.Room.Rid == u.w.RoomId {
u.w.QuitGame(u.Id())
func (u *User) HandleQuitGame(rq api.GameQuitRequest) {
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
}
@ -89,7 +133,7 @@ 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).Msgf("player select fail, req: %v", rq)
@ -98,9 +142,7 @@ func (u *User) HandleChangePlayer(rq api.ChangePlayerUserRequest) {
u.Notify(api.ChangePlayer, rq)
}
func (u *User) HandleToggleMultitap() { u.w.ToggleMultitap(u.Id()) }
func (u *User) HandleRecordGame(rq api.RecordGameRequest[com.Uid]) {
func (u *User) HandleRecordGame(rq api.RecordGameRequest) {
if u.w == nil {
return
}
@ -112,7 +154,7 @@ func (u *User) HandleRecordGame(rq api.RecordGameRequest[com.Uid]) {
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")
return
@ -127,14 +169,16 @@ func (u *User) handleGetWorkerList(debug bool, info HasServerInfo) {
if debug {
response.Servers = servers
} else {
// not sure if []byte to string always reversible :/
unique := map[string]*api.Server{}
for _, s := range servers {
mid := s.Machine
if _, ok := unique[mid]; !ok {
unique[mid] = &api.Server{Addr: s.Addr, PingURL: s.PingURL, Id: s.Id, InGroup: true}
}
unique[mid].Replicas++
v := unique[mid]
if v != nil {
v.Replicas++
}
}
for _, v := range unique {
response.Servers = append(response.Servers, *v)

View file

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

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

View file

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

View file

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

View file

@ -9,12 +9,12 @@ 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 := 0; y < sh; y++ {
for y := range sh {
yy := y
if flipped {
yy = sh - y
}
for x := 0; x < sw; x++ {
for x := range sw {
px := img.At(x, y)
rgba := color.RGBAModel.Convert(px).(color.RGBA)
dst.Set(x, yy, rgba)

View file

@ -2,9 +2,11 @@ package encoder
import (
"fmt"
"sync"
"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"
)
@ -13,9 +15,9 @@ type (
InFrame yuv.RawFrame
OutFrame []byte
Encoder interface {
LoadBuf(input []byte)
Encode() []byte
Encode([]byte) []byte
IntraRefresh()
Info() string
SetFlip(bool)
Shutdown() error
}
@ -28,7 +30,6 @@ type Video struct {
y yuv.Conv
pf yuv.PixFmt
rot uint
mu sync.Mutex
}
type VideoCodec string
@ -36,6 +37,8 @@ type VideoCodec string
const (
H264 VideoCodec = "h264"
VP8 VideoCodec = "vp8"
VP9 VideoCodec = "vp9"
VPX VideoCodec = "vpx"
)
// NewVideoEncoder returns new video encoder.
@ -43,31 +46,59 @@ const (
// converts them into YUV I420 format,
// encodes with provided video encoder, and
// puts the result into the output channel.
func NewVideoEncoder(codec Encoder, w, h int, scale float64, log *logger.Logger) *Video {
return &Video{codec: codec, y: yuv.NewYuvConv(w, h, scale), log: log}
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 {
v.mu.Lock()
defer v.mu.Unlock()
if v.stopped.Load() {
return nil
}
yCbCr := v.y.Process(yuv.RawFrame(frame), v.rot, v.pf)
v.codec.LoadBuf(yCbCr)
v.y.Put(&yCbCr)
if bytes := v.codec.Encode(); len(bytes) > 0 {
//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("libyuv: %v", v.y.Version()) }
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:
@ -77,29 +108,39 @@ func (v *Video) SetPixFormat(f uint32) {
}
}
// SetRot sets the rotation angle of the frames.
func (v *Video) SetRot(r uint) {
switch r {
// de-rotate
case 90:
v.rot = 270
case 270:
v.rot = 90
default:
v.rot = r
// 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) { v.codec.SetFlip(b) }
func (v *Video) SetFlip(b bool) {
if v == nil {
return
}
v.codec.SetFlip(b)
}
func (v *Video) Stop() {
v.stopped.Store(true)
v.mu.Lock()
defer v.mu.Unlock()
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 {
v.log.Error().Err(err).Msg("failed to close the encoder")
if v.log != nil {
v.log.Error().Err(err).Msg("failed to close the encoder")
}
}
}

View file

@ -1,528 +0,0 @@
// Package h264 implements cgo bindings for [x264](https://www.videolan.org/developers/x264.html) library.
package h264
/*
#cgo !st pkg-config: x264
#cgo st LDFLAGS: -l:libx264.a
#include "stdint.h"
#include "x264.h"
#include <stdlib.h>
*/
import "C"
import "unsafe"
const Build = C.X264_BUILD
// T is opaque handler for encoder
type T struct{}
// Nal is The data within the payload is already NAL-encapsulated; the ref_idc and type
// are merely in the struct for easy access by the calling application.
// All data returned in x264_nal_t, including the data in p_payload, is no longer
// valid after the next call to x264_encoder_encode. Thus, it must be used or copied
// before calling x264_encoder_encode or x264_encoder_headers again.
type Nal struct {
IRefIdc int32 /* nal_priority_e */
IType int32 /* nal_unit_type_e */
BLongStartcode int32
IFirstMb int32 /* If this NAL is a slice, the index of the first MB in the slice. */
ILastMb int32 /* If this NAL is a slice, the index of the last MB in the slice. */
/* Size of payload (including any padding) in bytes. */
IPayload int32
/* If param->b_annexb is set, Annex-B bytestream with startcode.
* Otherwise, startcode is replaced with a 4-byte size.
* This size is the size used in mp4/similar muxing; it is equal to i_payload-4 */
/* C.uint8_t */
PPayload unsafe.Pointer
/* Size of padding in bytes. */
IPadding int32
}
const RcCrf = 1
const (
CspI420 = 0x0002 // yuv 4:2:0 planar
CspVflip = 0x1000 /* the csp is vertically flipped */
// CspMask = 0x00ff /* */
// CspNone = 0x0000 /* Invalid mode */
// CspI400 = 0x0001 /* monochrome 4:0:0 */
//CspYv12 = 0x0003 /* yvu 4:2:0 planar */
//CspNv12 = 0x0004 /* yuv 4:2:0, with one y plane and one packed u+v */
//CspNv21 = 0x0005 /* yuv 4:2:0, with one y plane and one packed v+u */
//CspI422 = 0x0006 /* yuv 4:2:2 planar */
//CspYv16 = 0x0007 /* yvu 4:2:2 planar */
//CspNv16 = 0x0008 /* yuv 4:2:2, with one y plane and one packed u+v */
//CspYuyv = 0x0009 /* yuyv 4:2:2 packed */
//CspUyvy = 0x000a /* uyvy 4:2:2 packed */
//CspV210 = 0x000b /* 10-bit yuv 4:2:2 packed in 32 */
//CspI444 = 0x000c /* yuv 4:4:4 planar */
//CspYv24 = 0x000d /* yvu 4:4:4 planar */
//CspBgr = 0x000e /* packed bgr 24bits */
//CspBgra = 0x000f /* packed bgr 32bits */
//CspRgb = 0x0010 /* packed rgb 24bits */
//CspMax = 0x0011 /* end of list */
//CspHighDepth = 0x2000 /* the csp has a depth of 16 bits per pixel component */
)
type Zone struct {
IStart, IEnd int32 /* range of frame numbers */
BForceQp int32 /* whether to use qp vs bitrate factor */
IQp int32
FBitrateFactor float32
Param *Param
}
type Param struct {
/* CPU flags */
Cpu uint32
IThreads int32 /* encode multiple frames in parallel */
ILookaheadThreads int32 /* multiple threads for lookahead analysis */
BSlicedThreads int32 /* Whether to use slice-based threading. */
BDeterministic int32 /* whether to allow non-deterministic optimizations when threaded */
BCpuIndependent int32 /* force canonical behavior rather than cpu-dependent optimal algorithms */
ISyncLookahead int32 /* threaded lookahead buffer */
/* Video Properties */
IWidth int32
IHeight int32
ICsp int32 /* CSP of encoded bitstream */
IBitdepth int32
ILevelIdc int32
IFrameTotal int32 /* number of frames to encode if known, else 0 */
/* NAL HRD
* Uses Buffering and Picture Timing SEIs to signal HRD
* The HRD in H.264 was not designed with VFR in mind.
* It is therefore not recommended to use NAL HRD with VFR.
* Furthermore, reconfiguring the VBV (via x264_encoder_reconfig)
* will currently generate invalid HRD. */
INalHrd int32
Vui struct {
/* they will be reduced to be 0 < x <= 65535 and prime */
ISarHeight int32
ISarWidth int32
IOverscan int32 /* 0=undef, 1=no overscan, 2=overscan */
/* see h264 annex E for the values of the following */
IVidformat int32
BFullrange int32
IColorprim int32
ITransfer int32
IColmatrix int32
IChromaLoc int32 /* both top & bottom */
}
/* Bitstream parameters */
IFrameReference int32 /* Maximum number of reference frames */
IDpbSize int32 /* Force a DPB size larger than that implied by B-frames and reference frames.
* Useful in combination with interactive error resilience. */
IKeyintMax int32 /* Force an IDR keyframe at this interval */
IKeyintMin int32 /* Scenecuts closer together than this are coded as I, not IDR. */
IScenecutThreshold int32 /* how aggressively to insert extra I frames */
BIntraRefresh int32 /* Whether or not to use periodic intra refresh instead of IDR frames. */
IBframe int32 /* how many b-frame between 2 references pictures */
IBframeAdaptive int32
IBframeBias int32
IBframePyramid int32 /* Keep some B-frames as references: 0=off, 1=strict hierarchical, 2=normal */
BOpenGop int32
BBlurayCompat int32
IAvcintraClass int32
IAvcintraFlavor int32
BDeblockingFilter int32
IDeblockingFilterAlphac0 int32 /* [-6, 6] -6 light filter, 6 strong */
IDeblockingFilterBeta int32 /* [-6, 6] idem */
BCabac int32
ICabacInitIdc int32
BInterlaced int32
BConstrainedIntra int32
ICqmPreset int32
PszCqmFile *int8 /* filename (in UTF-8) of CQM file, JM format */
Cqm4iy [16]byte /* used only if i_cqm_preset == X264_CQM_CUSTOM */
Cqm4py [16]byte
Cqm4ic [16]byte
Cqm4pc [16]byte
Cqm8iy [64]byte
Cqm8py [64]byte
Cqm8ic [64]byte
Cqm8pc [64]byte
/* Log */
PfLog *[0]byte
PLogPrivate unsafe.Pointer
ILogLevel int32
BFullRecon int32 /* fully reconstruct frames, even when not necessary for encoding. Implied by psz_dump_yuv */
PszDumpYuv *int8 /* filename (in UTF-8) for reconstructed frames */
/* Encoder analyser parameters */
Analyse struct {
Intra uint32 /* intra partitions */
Inter uint32 /* inter partitions */
BTransform8x8 int32
IWeightedPred int32 /* weighting for P-frames */
BWeightedBipred int32 /* implicit weighting for B-frames */
IDirectMvPred int32 /* spatial vs temporal mv prediction */
IChromaQpOffset int32
IMeMethod int32 /* motion estimation algorithm to use (X264_ME_*) */
IMeRange int32 /* integer pixel motion estimation search range (from predicted mv) */
IMvRange int32 /* maximum length of a mv (in pixels). -1 = auto, based on level */
IMvRangeThread int32 /* minimum space between threads. -1 = auto, based on number of threads. */
ISubpelRefine int32 /* subpixel motion estimation quality */
BChromaMe int32 /* chroma ME for subpel and mode decision in P-frames */
BMixedReferences int32 /* allow each mb partition to have its own reference number */
ITrellis int32 /* trellis RD quantization */
BFastPskip int32 /* early SKIP detection on P-frames */
BDctDecimate int32 /* transform coefficient thresholding on P-frames */
INoiseReduction int32 /* adaptive pseudo-deadzone */
FPsyRd float32 /* Psy RD strength */
FPsyTrellis float32 /* Psy trellis strength */
BPsy int32 /* Toggle all psy optimizations */
BMbInfo int32 /* Use input mb_info data in x264_picture_t */
BMbInfoUpdate int32 /* Update the values in mb_info according to the results of encoding. */
/* the deadzone size that will be used in luma quantization */
ILumaDeadzone [2]int32
BPsnr int32 /* compute and print PSNR stats */
BSsim int32 /* compute and print SSIM stats */
}
/* Rate control parameters */
Rc struct {
IRcMethod int32 /* X264_RC_* */
IQpConstant int32 /* 0=lossless */
IQpMin int32 /* min allowed QP value */
IQpMax int32 /* max allowed QP value */
IQpStep int32 /* max QP step between frames */
IBitrate int32
FRfConstant float32 /* 1pass VBR, nominal QP */
FRfConstantMax float32 /* In CRF mode, maximum CRF as caused by VBV */
FRateTolerance float32
IVbvMaxBitrate int32
IVbvBufferSize int32
FVbvBufferInit float32 /* <=1: fraction of buffer_size. >1: kbit */
FIpFactor float32
FPbFactor float32
/* VBV filler: force CBR VBV and use filler bytes to ensure hard-CBR.
* Implied by NAL-HRD CBR. */
BFiller int32
IAqMode int32 /* psy adaptive QP. (X264_AQ_*) */
FAqStrength float32
BMbTree int32 /* Macroblock-tree ratecontrol. */
ILookahead int32
/* 2pass */
BStatWrite int32 /* Enable stat writing in psz_stat_out */
PszStatOut *int8 /* output filename (in UTF-8) of the 2pass stats file */
BStatRead int32 /* Read stat from psz_stat_in and use it */
PszStatIn *int8 /* input filename (in UTF-8) of the 2pass stats file */
/* 2pass params (same as ffmpeg ones) */
FQcompress float32 /* 0.0 => cbr, 1.0 => constant qp */
FQblur float32 /* temporally blur quants */
FComplexityBlur float32 /* temporally blur complexity */
Zones *Zone /* ratecontrol overrides */
IZones int32 /* number of zone_t's */
PszZones *int8 /* alternate method of specifying zones */
}
/* Cropping Rectangle parameters: added to those implicitly defined by
non-mod16 video resolutions. */
CropRect struct {
ILeft int32
ITop int32
IRight int32
IBottom int32
}
/* frame packing arrangement flag */
IFramePacking int32
/* alternative transfer SEI */
IAlternativeTransfer int32
/* Muxing parameters */
BAud int32 /* generate access unit delimiters */
BRepeatHeaders int32 /* put SPS/PPS before each keyframe */
BAnnexb int32 /* if set, place start codes (4 bytes) before NAL units,
* otherwise place size (4 bytes) before NAL units. */
ISpsId int32 /* SPS and PPS id number */
BVfrInput int32 /* VFR input. If 1, use timebase and timestamps for ratecontrol purposes.
* If 0, use fps only. */
BPulldown int32 /* use explicity set timebase for CFR */
IFpsNum uint32
IFpsDen uint32
ITimebaseNum uint32 /* Timebase numerator */
ITimebaseDen uint32 /* Timebase denominator */
BTff int32
/* Pulldown:
* The correct pic_struct must be passed with each input frame.
* The input timebase should be the timebase corresponding to the output framerate. This should be constant.
* e.g. for 3:2 pulldown timebase should be 1001/30000
* The PTS passed with each frame must be the PTS of the frame after pulldown is applied.
* Frame doubling and tripling require b_vfr_input set to zero (see H.264 Table D-1)
*
* Pulldown changes are not clearly defined in H.264. Therefore, it is the calling app's responsibility to manage this.
*/
BPicStruct int32
/* Fake Interlaced.
*
* Used only when b_interlaced=0. Setting this flag makes it possible to flag the stream as PAFF interlaced yet
* encode all frames progressively. It is useful for encoding 25p and 30p Blu-Ray streams.
*/
BFakeInterlaced int32
/* Don't optimize header parameters based on video content, e.g. ensure that splitting an input video, compressing
* each part, and stitching them back together will result in identical SPS/PPS. This is necessary for stitching
* with container formats that don't allow multiple SPS/PPS. */
BStitchable int32
BOpencl int32 /* use OpenCL when available */
IOpenclDevice int32 /* specify count of GPU devices to skip, for CLI users */
OpenclDeviceId unsafe.Pointer /* pass explicit cl_device_id as void*, for API users */
PszClbinFile *int8 /* filename (in UTF-8) of the compiled OpenCL kernel cache file */
/* Slicing parameters */
iSliceMaxSize int32 /* Max size per slice in bytes; includes estimated NAL overhead. */
iSliceMaxMbs int32 /* Max number of MBs per slice; overrides iSliceCount. */
iSliceMinMbs int32 /* Min number of MBs per slice */
iSliceCount int32 /* Number of slices per frame: forces rectangular slices. */
iSliceCountMax int32 /* Absolute cap on slices per frame; stops applying slice-max-size
* and slice-max-mbs if this is reached. */
ParamFree *func(arg unsafe.Pointer)
NaluProcess *func(H []T, Nal []Nal, Opaque unsafe.Pointer)
Opaque unsafe.Pointer
}
/****************************************************************************
* H.264 level restriction information
****************************************************************************/
type Level struct {
LevelIdc byte
Mbps int32 /* max macroblock processing rate (macroblocks/sec) */
FrameSize int32 /* max frame size (macroblocks) */
Dpb int32 /* max decoded picture buffer (mbs) */
Bitrate int32 /* max bitrate (kbit/sec) */
Cpb int32 /* max vbv buffer (kbit) */
MvRange uint16 /* max vertical mv component range (pixels) */
MvsPer2mb byte /* max mvs per 2 consecutive mbs. */
SliceRate byte /* ?? */
Mincr byte /* min compression ratio */
Bipred8x8 byte /* limit bipred to >=8x8 */
Direct8x8 byte /* limit b_direct to >=8x8 */
FrameOnly byte /* forbid interlacing */
}
type PicStruct int32
type Hrd struct {
CpbInitialArrivalTime float64
CpbFinalArrivalTime float64
CpbRemovalTime float64
DpbOutputTime float64
}
type SeiPayload struct {
PayloadSize int32
PayloadType int32
Payload *byte
}
type Sei struct {
NumPayloads int32
Payloads *SeiPayload
/* In: optional callback to free each payload AND x264_sei_payload_t when used. */
SeiFree *func(arg0 unsafe.Pointer)
}
type Image struct {
ICsp int32 /* Colorspace */
IPlane int32 /* Number of image planes */
IStride [4]int32 /* Strides for each plane */
Plane [4]uintptr /* Pointers to each plane */
}
type ImageProperties struct {
/* In: an array of quantizer offsets to be applied to this image during encoding.
* These are added on top of the decisions made by x264.
* Offsets can be fractional; they are added before QPs are rounded to integer.
* Adaptive quantization must be enabled to use this feature. Behavior if quant
* offsets differ between encoding passes is undefined. */
QuantOffsets *float32
/* In: optional callback to free quant_offsets when used.
* Useful if one wants to use a different quant_offset array for each frame. */
QuantOffsetsFree *func(arg0 unsafe.Pointer)
/* In: optional array of flags for each macroblock.
* Allows specifying additional information for the encoder such as which macroblocks
* remain unchanged. Usable flags are listed below.
* x264_param_t.analyse.b_mb_info must be set to use this, since x264 needs to track
* extra data internally to make full use of this information.
*
* Out: if b_mb_info_update is set, x264 will update this array as a result of encoding.
*
* For "MBINFO_CONSTANT", it will remove this flag on any macroblock whose decoded
* pixels have changed. This can be useful for e.g. noting which areas of the
* frame need to actually be blitted. Note: this intentionally ignores the effects
* of deblocking for the current frame, which should be fine unless one needs exact
* pixel-perfect accuracy.
*
* Results for MBINFO_CONSTANT are currently only set for P-frames, and are not
* guaranteed to enumerate all blocks which haven't changed. (There may be false
* negatives, but no false positives.)
*/
MbInfo *byte
/* In: optional callback to free mb_info when used. */
MbInfoFree *func(arg0 unsafe.Pointer)
/* Out: SSIM of the the frame luma (if x264_param_t.b_ssim is set) */
FSsim float64
/* Out: Average PSNR of the frame (if x264_param_t.b_psnr is set) */
FPsnrAvg float64
/* Out: PSNR of Y, U, and V (if x264_param_t.b_psnr is set) */
FPsnr [3]float64
/* Out: Average effective CRF of the encoded frame */
FCrfAvg float64
}
type Picture struct {
/* In: force picture type (if not auto)
* If x264 encoding parameters are violated in the forcing of picture types,
* x264 will correct the input picture type and log a warning.
* Out: type of the picture encoded */
IType int32
/* In: force quantizer for != X264_QP_AUTO */
IQpplus1 int32
/* In: pic_struct, for pulldown/doubling/etc...used only if b_pic_struct=1.
* use pic_struct_e for pic_struct inputs
* Out: pic_struct element associated with frame */
IPicStruct int32
/* Out: whether this frame is a keyframe. Important when using modes that result in
* SEI recovery points being used instead of IDR frames. */
BKeyframe int32
/* In: user pts, Out: pts of encoded picture (user)*/
IPts int64
/* Out: frame dts. When the pts of the first frame is close to zero,
* initial frames may have a negative dts which must be dealt with by any muxer */
IDts int64
/* In: custom encoding parameters to be set from this frame forwards
(in coded order, not display order). If NULL, continue using
parameters from the previous frame. Some parameters, such as
aspect ratio, can only be changed per-GOP due to the limitations
of H.264 itself; in this case, the caller must force an IDR frame
if it needs the changed parameter to apply immediately. */
Param *Param
/* In: raw image data */
/* Out: reconstructed image data. x264 may skip part of the reconstruction process,
e.g. deblocking, in frames where it isn't necessary. To force complete
reconstruction, at a small speed cost, set b_full_recon. */
Img Image
/* In: optional information to modify encoder decisions for this frame
* Out: information about the encoded frame */
Prop ImageProperties
/* Out: HRD timing information. Output only when i_nal_hrd is set. */
Hrdiming Hrd
/* In: arbitrary user SEI (e.g subtitles, AFDs) */
ExtraSei Sei
/* private user data. copied from input to output frames. */
Opaque unsafe.Pointer
}
func (t *T) cptr() *C.x264_t { return (*C.x264_t)(unsafe.Pointer(t)) }
func (n *Nal) cptr() *C.x264_nal_t { return (*C.x264_nal_t)(unsafe.Pointer(n)) }
func (p *Param) cptr() *C.x264_param_t { return (*C.x264_param_t)(unsafe.Pointer(p)) }
func (p *Picture) cptr() *C.x264_picture_t { return (*C.x264_picture_t)(unsafe.Pointer(p)) }
// ParamDefault - fill Param with default values and do CPU detection.
func ParamDefault(param *Param) { C.x264_param_default(param.cptr()) }
// ParamDefaultPreset - the same as ParamDefault, but also use the passed preset and tune to modify the default settings
// (either can be nil, which implies no preset or no tune, respectively).
//
// Currently available presets are, ordered from fastest to slowest:
// "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo".
//
// Currently available tunings are:
// "film", "animation", "grain", "stillimage", "psnr", "ssim", "fastdecode", "zerolatency".
//
// Returns 0 on success, negative on failure (e.g. invalid preset/tune name).
func ParamDefaultPreset(param *Param, preset string, tune string) int32 {
cpreset := C.CString(preset)
defer C.free(unsafe.Pointer(cpreset))
ctune := C.CString(tune)
defer C.free(unsafe.Pointer(ctune))
return (int32)(C.x264_param_default_preset(param.cptr(), cpreset, ctune))
}
// ParamApplyProfile - applies the restrictions of the given profile.
//
// Currently available profiles are, from most to least restrictive:
// "baseline", "main", "high", "high10", "high422", "high444".
// (can be nil, in which case the function will do nothing).
//
// Returns 0 on success, negative on failure (e.g. invalid profile name).
func ParamApplyProfile(param *Param, profile string) int32 {
cprofile := C.CString(profile)
defer C.free(unsafe.Pointer(cprofile))
return (int32)(C.x264_param_apply_profile(param.cptr(), cprofile))
}
// EncoderOpen - create a new encoder handler, all parameters from Param are copied.
func EncoderOpen(param *Param) *T {
ret := C.x264_encoder_open(param.cptr())
return *(**T)(unsafe.Pointer(&ret))
}
// EncoderEncode - encode one picture.
// Returns the number of bytes in the returned NALs, negative on error and zero if no NAL units returned.
func EncoderEncode(enc *T, ppNal []*Nal, piNal *int32, picIn *Picture, picOut *Picture) int32 {
cenc := enc.cptr()
cppNal := (**C.x264_nal_t)(unsafe.Pointer(&ppNal[0]))
cpiNal := (*C.int)(unsafe.Pointer(piNal))
cpicIn := picIn.cptr()
cpicOut := picOut.cptr()
return (int32)(C.x264_encoder_encode(cenc, cppNal, cpiNal, cpicIn, cpicOut))
}
// EncoderClose closes an encoder handler.
func EncoderClose(enc *T) { C.x264_encoder_close(enc.cptr()) }
// EncoderIntraRefresh - If an intra refresh is not in progress, begin one with the next P-frame.
// If an intra refresh is in progress, begin one as soon as the current one finishes.
// Requires that BIntraRefresh be set.
//
// Should not be called during an x264_encoder_encode.
//func EncoderIntraRefresh(enc *T) { C.x264_encoder_intra_refresh(enc.cptr()) }

View file

@ -1,29 +1,93 @@
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 {
ref *T
width int32
lumaSize int32
chromaSize int32
csp int32
nnals int32
nals []*Nal
in, out *Picture
h *C.h264
}
type Options struct {
Mode string
// Constant Rate Factor (CRF)
// This method allows the encoder to attempt to achieve a certain output quality for the whole file
// when output file size is of less importance.
// The range of the CRF scale is 051, where 0 is lossless, 23 is the default, and 51 is the worst quality possible.
Crf uint8
Crf uint8
// vbv-maxrate
MaxRate int
// vbv-bufsize
BufSize int
LogLevel int32
// ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo.
Preset string
@ -33,15 +97,16 @@ type Options struct {
Tune string
}
func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) {
libVersion := LibVersion()
func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) {
ver := Version()
if libVersion < 150 {
return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", libVersion)
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",
@ -49,94 +114,93 @@ func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) {
}
}
param := Param{}
param := C.x264_param_t{}
if opts.Preset != "" && opts.Tune != "" {
if ParamDefaultPreset(&param, opts.Preset, opts.Tune) < 0 {
preset := C.CString(opts.Preset)
tune := C.CString(opts.Tune)
defer C.free(unsafe.Pointer(preset))
defer C.free(unsafe.Pointer(tune))
if C.x264_param_default_preset(&param, preset, tune) < 0 {
return nil, fmt.Errorf("x264: invalid preset/tune name")
}
} else {
ParamDefault(&param)
C.x264_param_default(&param)
}
if opts.Profile != "" {
if ParamApplyProfile(&param, opts.Profile) < 0 {
profile := C.CString(opts.Profile)
defer C.free(unsafe.Pointer(profile))
if C.x264_param_apply_profile(&param, profile) < 0 {
return nil, fmt.Errorf("x264: invalid profile name")
}
}
// legacy encoder lacks of this param
param.IBitdepth = 8
if libVersion > 155 {
param.ICsp = CspI420
param.i_bitdepth = 8
if ver > 155 {
param.i_csp = C.X264_CSP_I420
} else {
param.ICsp = 1
param.i_csp = 1
}
param.IWidth = int32(w)
param.IHeight = int32(h)
param.ILogLevel = opts.LogLevel
param.ISyncLookahead = 0
param.IThreads = 1
param.Rc.IRcMethod = RcCrf
param.Rc.FRfConstant = float32(opts.Crf)
encoder = &H264{
csp: param.ICsp,
lumaSize: param.IWidth * param.IHeight,
chromaSize: param.IWidth * param.IHeight / 4,
nals: make([]*Nal, 1),
width: param.IWidth,
out: new(Picture),
in: &Picture{
Img: Image{
ICsp: param.ICsp,
IPlane: 3,
IStride: [4]int32{
0: param.IWidth,
1: param.IWidth >> 1,
2: param.IWidth >> 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
}
if encoder.ref = EncoderOpen(&param); encoder.ref == nil {
err = fmt.Errorf("x264: cannot open the encoder")
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
}
return
if opts.MaxRate > 0 {
param.rc.i_bitrate = C.int(opts.MaxRate)
param.rc.i_vbv_max_bitrate = C.int(opts.MaxRate)
}
if opts.BufSize > 0 {
param.rc.i_vbv_buffer_size = C.int(opts.BufSize)
}
h264 := C.h264_new(&param)
if h264 == nil {
return nil, fmt.Errorf("x264: cannot open the encoder")
}
return &H264{h264}, nil
}
func LibVersion() int { return int(Build) }
func (e *H264) LoadBuf(yuv []byte) {
e.in.Img.Plane[0] = uintptr(unsafe.Pointer(&yuv[0]))
e.in.Img.Plane[1] = uintptr(unsafe.Pointer(&yuv[e.lumaSize]))
e.in.Img.Plane[2] = uintptr(unsafe.Pointer(&yuv[e.lumaSize+e.chromaSize]))
}
func (e *H264) Encode() []byte {
e.in.IPts += 1
if ret := EncoderEncode(e.ref, e.nals, &e.nnals, e.in, e.out); ret > 0 {
return unsafe.Slice((*byte)(e.nals[0].PPayload), ret)
//return C.GoBytes(e.nals[0].PPayload, C.int(ret))
}
return []byte{}
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.in.Img.ICsp |= CspVflip
(*e.h).pic.img.i_csp |= C.X264_CSP_VFLIP
} else {
e.in.Img.ICsp &= ^CspVflip
(*e.h).pic.img.i_csp &= ^C.X264_CSP_VFLIP
}
}
func (e *H264) Shutdown() error {
EncoderClose(e.ref)
if e.h != nil {
C.h264_destroy(e.h)
}
return nil
}
func Version() int { return int(C.X264_BUILD) }

View file

@ -3,13 +3,13 @@ package h264
import "testing"
func TestH264Encode(t *testing.T) {
h264, err := NewEncoder(120, 120, nil)
h264, err := NewEncoder(120, 120, 0, nil)
if err != nil {
t.Error(err)
return
}
data := make([]byte, 120*120*1.5)
h264.LoadBuf(data)
h264.Encode()
h264.Encode(data)
if err := h264.Shutdown(); err != nil {
t.Error(err)
}
@ -17,13 +17,13 @@ func TestH264Encode(t *testing.T) {
func Benchmark(b *testing.B) {
w, h := 1920, 1080
h264, err := NewEncoder(w, h, nil)
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 i := 0; i < b.N; i++ {
h264.LoadBuf(data)
h264.Encode()
for b.Loop() {
h264.Encode(data)
}
}

View file

@ -12,6 +12,7 @@ package vpx
#include <string.h>
#define VP8_FOURCC 0x30385056
#define VP9_FOURCC 0x30395056
typedef struct VpxInterface {
const char *const name;
@ -42,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)
@ -85,6 +89,7 @@ type Vpx struct {
codecCtx C.vpx_codec_ctx_t
kfi C.int
flipped bool
v int
}
func (vpx *Vpx) SetFlip(b bool) { vpx.flipped = b }
@ -96,8 +101,12 @@ type Options struct {
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")
}
@ -112,6 +121,7 @@ func NewEncoder(w, h int, opts *Options) (*Vpx, error) {
vpx := Vpx{
frameCount: C.int(0),
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 {
@ -125,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")
@ -135,17 +149,13 @@ func NewEncoder(w, h int, opts *Options) (*Vpx, error) {
return &vpx, nil
}
func (vpx *Vpx) LoadBuf(yuv []byte) {
// Encode encodes yuv image with the VPX8 encoder.
// see: https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c
func (vpx *Vpx) Encode(yuv []byte) []byte {
C.vpx_img_read(&vpx.image, unsafe.Pointer(&yuv[0]))
if vpx.flipped {
C.vpx_img_flip(&vpx.image)
}
}
// Encode encodes yuv image with the VPX8 encoder.
// see: https://chromium.googlesource.com/webm/libvpx/+/master/examples/simple_encoder.c
func (vpx *Vpx) Encode() []byte {
var iter C.vpx_codec_iter_t
var flags C.int
if vpx.kfi > 0 && vpx.frameCount%vpx.kfi == 0 {
@ -156,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{}
@ -163,6 +174,10 @@ 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
}

View file

@ -1,5 +1,5 @@
// Package libyuv contains the wrapper for: https://chromium.googlesource.com/libyuv/libyuv.
// Libs are downloaded from: https://packages.macports.org/libyuv/.
// MacOS libs are from: https://packages.macports.org/libyuv/.
package libyuv
/*
@ -12,6 +12,7 @@ package libyuv
#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
@ -23,6 +24,54 @@ package libyuv
#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" {
@ -35,61 +84,100 @@ 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,
};
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;
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;
LIBYUV_API
int ConvertToI420(const uint8_t* sample,
size_t sample_size,
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 crop_x,
int crop_y,
int src_width,
int src_height,
int crop_width,
int crop_height,
enum RotationMode rotation,
uint32_t fourcc);
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.
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);
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"
@ -102,6 +190,7 @@ 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
@ -111,23 +200,36 @@ func Y420(src []byte, dst []byte, _, h, stride int, dw, dh int, rot uint, pix ui
yStride := dw
cStride := cw
C.ConvertToI420(
(*C.uchar)(&src[0]),
C.size_t(0),
(*C.uchar)(&dst[0]),
C.int(yStride),
(*C.uchar)(&dst[i0]),
C.int(cStride),
(*C.uchar)(&dst[i1]),
C.int(cStride),
C.int(0),
C.int(0),
C.int(stride),
C.int(h),
C.int(cx),
C.int(cy),
C.enum_RotationMode(rot),
C.uint32_t(pix))
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) {

View file

@ -2,16 +2,16 @@ package yuv
import (
"image"
"sync"
"github.com/giongto35/cloud-game/v3/pkg/encoder/yuv/libyuv"
)
type Conv struct {
w, h int
sw, sh int
scale float64
pool sync.Pool
w, h int
sw, sh int
scale float64
frame []byte
frameSc []byte
}
type RawFrame struct {
@ -25,45 +25,55 @@ 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)
bufSize := int(float64(sw) * float64(sh) * 1.5)
return Conv{
w: w, h: h, sw: sw, sh: sh, scale: scale,
pool: sync.Pool{New: func() any { b := make([]byte, bufSize); return &b }},
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 {
dx, dy := c.w, c.h // dest
cx, cy := c.w, c.h // crop
if rot == 90 || rot == 270 {
cx, cy = cy, cx
}
stride := frame.Stride >> 2
if pf == PixFmt(libyuv.FourccRgbp) {
var stride int
switch pf {
case PixFmt(libyuv.FourccRgbp), PixFmt(libyuv.FourccRgb0):
stride = frame.Stride >> 1
default:
stride = frame.Stride >> 2
}
buf := *c.pool.Get().(*[]byte)
libyuv.Y420(frame.Data, buf, frame.W, frame.H, stride, dx, dy, rot, uint32(pf), cx, cy)
libyuv.Y420(frame.Data, c.frame, frame.W, frame.H, stride, c.w, c.h, rot, uint32(pf), cx, cy)
if c.scale > 1 {
dstBuf := *c.pool.Get().(*[]byte)
libyuv.Y420Scale(buf, dstBuf, dx, dy, c.sw, c.sh)
c.pool.Put(&buf)
return dstBuf
libyuv.Y420Scale(c.frame, c.frameSc, c.w, c.h, c.sw, c.sh)
return c.frameSc
}
return buf
return c.frame
}
func (c *Conv) Put(x *[]byte) { c.pool.Put(x) }
func (c *Conv) Version() string { return libyuv.Version() }
func round(x int, scale float64) int { return (int(float64(x)*scale) + 1) & ^1 }

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@ package games
import (
"fmt"
"math/rand"
"math/rand/v2"
"strconv"
"strings"
)
@ -14,6 +14,7 @@ type Launcher interface {
}
type AppMeta struct {
Alias string
Base string
Name string
Path string
@ -39,7 +40,7 @@ func (gl GameLauncher) ExtractAppNameFromUrl(name string) string { return Extrac
func (gl GameLauncher) GetAppNames() (apps []AppMeta) {
for _, game := range gl.lib.GetAll() {
apps = append(apps, AppMeta{Name: game.Name, System: game.System})
apps = append(apps, AppMeta{Alias: game.Alias, Name: game.Name, System: game.System})
}
return
}
@ -59,5 +60,5 @@ func ExtractGame(roomID string) string {
// 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.Int63(), 16) + separator + title
return strconv.FormatInt(rand.Int64(), 16) + separator + title
}

View file

@ -1,9 +1,12 @@
package games
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@ -15,11 +18,13 @@ import (
// libConf is an optimized internal library configuration
type libConf struct {
path string
supported map[string]struct{}
ignored map[string]struct{}
verbose bool
watchMode bool
aliasFile string
path string
supported map[string]struct{}
ignored []string
verbose bool
watchMode bool
sessionPath string
}
type library struct {
@ -35,6 +40,9 @@ type library struct {
games map[string]GameMetadata
log *logger.Logger
// ids of saved games to find closed sessions
sessions []string
emuConf WithEmulatorInfo
// to restrict parallel execution or throttling
@ -47,15 +55,18 @@ type library struct {
type GameLibrary interface {
GetAll() []GameMetadata
FindGameByName(name string) GameMetadata
Sessions() []string
Scan()
}
type WithEmulatorInfo interface {
GetSupportedExtensions() []string
GetEmulator(rom string, path string) string
SessionStoragePath() string
}
type GameMetadata struct {
Alias string
Base string
Name string // the display name of the game
Path string // the game path relative to the library base path
@ -84,11 +95,13 @@ func NewLib(conf config.Library, emu WithEmulatorInfo, log *logger.Logger) GameL
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{},
@ -104,6 +117,10 @@ func NewLib(conf config.Library, emu WithEmulatorInfo, log *logger.Logger) GameL
return library
}
func (lib *library) Sessions() []string {
return lib.sessions
}
func (lib *library) GetAll() []GameMetadata {
var res []GameMetadata
for _, value := range lib.games {
@ -122,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)")
@ -141,6 +191,14 @@ 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
@ -149,15 +207,36 @@ func (lib *library) Scan() {
return err
}
if info != nil && !info.IsDir() && lib.isExtAllowed(path) {
meta := getMetadata(path, dir)
if info == nil || info.IsDir() || !lib.isExtAllowed(path) {
return nil
}
meta.System = lib.emuConf.GetEmulator(meta.Type, meta.Path)
meta := metadata(path, dir)
meta.System = lib.emuConf.GetEmulator(meta.Type, meta.Path)
if _, ok := lib.config.ignored[meta.Name]; !ok {
games = append(games, meta)
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
})
@ -170,6 +249,20 @@ func (lib *library) Scan() {
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()
@ -235,7 +328,7 @@ func (lib *library) set(games []GameMetadata) {
}
func (lib *library) isExtAllowed(path string) bool {
ext := filepath.Ext(path)
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return false
}
@ -243,15 +336,15 @@ func (lib *library) isExtAllowed(path string) bool {
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,
}
}
@ -259,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(fmt.Sprintf(" %5s %s (%s)\n", game.System, game.Name, game.Path))
// 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"+
@ -269,9 +375,9 @@ 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)
}
func toMap(list []string) map[string]struct{} {

View file

@ -1,6 +1,9 @@
package games
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/giongto35/cloud-game/v3/pkg/config"
@ -60,6 +63,52 @@ func TestLibraryScan(t *testing.T) {
}
}
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)
@ -68,7 +117,7 @@ func Benchmark(b *testing.B) {
Supported: []string{"gba", "zip", "nes"},
}, config.Emulator{}, log)
for i := 0; i < b.N; i++ {
for b.Loop() {
library.Scan()
_ = library.GetAll()
}

View file

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

View file

@ -120,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
@ -165,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("")
@ -186,7 +187,6 @@ func (s *Server) redirection() (*Server, error) {
},
WithLogger(s.log),
)
s.log.Info().Str("addr", addr).Msg("Start HTTPS redirect server")
return srv, err
}

19
pkg/network/retry.go Normal file
View 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 }

View file

@ -7,7 +7,7 @@ import (
"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/v3"
"github.com/pion/ice/v4"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/report"
"github.com/pion/webrtc/v4"
@ -74,6 +74,7 @@ func NewApiFactory(conf config.Webrtc, log *logger.Logger, mod ModApiFun) (api *
}
s.SetICEMulticastDNSMode(ice.MulticastDNSModeDisabled)
s.EnableSCTPZeroChecksum(true)
if mod != nil {
mod(m, i, &s)

View file

@ -32,48 +32,72 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp
if p.conn != nil && p.conn.ConnectionState() == webrtc.PeerConnectionStateConnected {
return
}
p.log.Info().Msg("WebRTC start")
p.log.Debug().Msg("WebRTC start")
if p.conn, err = p.api.NewPeer(); err != nil {
return "", err
return
}
p.conn.OnICECandidate(p.handleICECandidate(onICECandidate))
// plug in the [video] track (out)
video, err := newTrack("video", "game-video", vCodec)
video, err := newTrack("video", "video", vCodec)
if err != nil {
return "", err
}
if _, err = p.conn.AddTrack(video); err != nil {
vs, err := p.conn.AddTrack(video)
if err != nil {
return "", err
}
// Read incoming RTCP packets
go func() {
rtcpBuf := make([]byte, 1500)
for {
_, _, rtcpErr := vs.Read(rtcpBuf)
if rtcpErr != nil {
return
}
}
}()
p.v = video
p.log.Debug().Msgf("Added [%s] track", video.Codec().MimeType)
// plug in the [audio] track (out)
audio, err := newTrack("audio", "game-audio", aCodec)
audio, err := newTrack("audio", "audio", aCodec)
if err != nil {
return "", err
}
if _, err = p.conn.AddTrack(audio); err != nil {
as, err := p.conn.AddTrack(audio)
if err != nil {
return "", err
}
// Read incoming RTCP packets
go func() {
rtcpBuf := make([]byte, 1500)
for {
_, _, rtcpErr := as.Read(rtcpBuf)
if rtcpErr != nil {
return
}
}
}()
p.log.Debug().Msgf("Added [%s] track", audio.Codec().MimeType)
p.a = audio
// plug in the [input] data channel (in)
if err = p.addInputChannel("game-input"); err != nil {
err = p.AddChannel("data", func(data []byte) {
if len(data) == 0 || p.OnMessage == nil {
return
}
p.OnMessage(data)
})
if err != nil {
return "", err
}
p.log.Debug().Msg("Added [input/bytes] chan")
p.conn.OnICEConnectionStateChange(p.handleICEState(func() {
p.log.Info().Msg("Start streaming")
}))
p.conn.OnICEConnectionStateChange(p.handleICEState(func() { p.log.Info().Msg("Connected") }))
// Stream provider supposes to send offer
offer, err := p.conn.CreateOffer(nil)
if err != nil {
return "", err
}
p.log.Info().Msg("Created Offer")
p.log.Debug().Msg("Created Offer")
err = p.conn.SetLocalDescription(offer)
if err != nil {
@ -140,6 +164,8 @@ func newTrack(id string, label string, codec string) (*webrtc.TrackLocalStaticSa
mime = webrtc.MimeTypeH264
case "vpx", "vp8":
mime = webrtc.MimeTypeVP8
case "vp9":
mime = webrtc.MimeTypeVP9
}
}
if mime == "" {
@ -199,6 +225,19 @@ func (p *Peer) AddCandidate(candidate string, decoder Decoder) error {
return nil
}
func (p *Peer) AddChannel(label string, onMessage func([]byte)) error {
ch, err := p.addDataChannel(label)
if err != nil {
return err
}
if label == "data" {
p.d = ch
}
ch.OnMessage(func(m webrtc.DataChannelMessage) { onMessage(m.Data) })
p.log.Debug().Msgf("Added [%v] chan", label)
return nil
}
func (p *Peer) Disconnect() {
if p.conn == nil {
return
@ -210,28 +249,19 @@ func (p *Peer) Disconnect() {
p.log.Debug().Msg("WebRTC stop")
}
// addInputChannel creates a new WebRTC data channel for user input.
// addDataChannel creates new WebRTC data channel.
// Default params -- ordered: true, negotiated: false.
func (p *Peer) addInputChannel(label string) error {
func (p *Peer) addDataChannel(label string) (*webrtc.DataChannel, error) {
ch, err := p.conn.CreateDataChannel(label, nil)
if err != nil {
return err
return nil, err
}
ch.OnOpen(func() {
p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).Msg("Data channel [input] opened")
p.log.Debug().Uint16("id", *ch.ID()).Msgf("Data channel [%v] opened", ch.Label())
})
ch.OnError(p.logx)
ch.OnMessage(func(m webrtc.DataChannelMessage) {
if len(m.Data) == 0 {
return
}
if p.OnMessage != nil {
p.OnMessage(m.Data)
}
})
p.d = ch
ch.OnClose(func() { p.log.Debug().Msg("Data channel [input] has been closed") })
return nil
ch.OnClose(func() { p.log.Debug().Msgf("Data channel [%v] has been closed", ch.Label()) })
return ch, nil
}
func (p *Peer) logx(err error) { p.log.Error().Err(err) }

View file

@ -27,13 +27,15 @@ type Server struct {
}
type Connection struct {
alive bool
callback MessageHandler
conn deadlineConn
done chan struct{}
once sync.Once
pingPong bool
send chan []byte
alive bool
callback MessageHandler
conn deadlineConn
done chan struct{}
errorHandler ErrorHandler
once sync.Once
pingPong bool
send chan []byte
messSize int64
}
type deadlineConn struct {
@ -43,6 +45,7 @@ type deadlineConn struct {
}
type MessageHandler func([]byte, error)
type ErrorHandler func(err error)
type Upgrader struct {
websocket.Upgrader
@ -125,7 +128,12 @@ func (c *Connection) reader() {
c.close()
}()
c.conn.SetReadLimit(maxMessageSize)
var s int64 = maxMessageSize
if c.messSize > 0 {
s = c.messSize
}
c.conn.SetReadLimit(s)
_ = c.conn.SetReadDeadline(time.Now().Add(pongTime))
if c.pingPong {
c.conn.SetPongHandler(func(string) error { _ = c.conn.SetReadDeadline(time.Now().Add(pongTime)); return nil })
@ -145,6 +153,10 @@ func (c *Connection) reader() {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
if c.errorHandler != nil {
c.errorHandler(err)
}
} else {
c.callback(message, err)
}
break
@ -219,6 +231,10 @@ func (c *Connection) IsServer() bool { return c.pingPong }
func (c *Connection) SetMessageHandler(fn MessageHandler) { c.callback = fn }
func (c *Connection) SetErrorHandler(fn ErrorHandler) { c.errorHandler = fn }
func (c *Connection) SetMaxMessageSize(s int64) { c.messSize = s }
func (c *Connection) Listen() chan struct{} {
if c.alive {
return c.done

37
pkg/os/flock.go Normal file
View file

@ -0,0 +1,37 @@
package os
import (
"os"
"path/filepath"
"github.com/gofrs/flock"
)
type Flock struct {
f *flock.Flock
}
func NewFileLock(path string) (*Flock, error) {
if path == "" {
path = os.TempDir() + string(os.PathSeparator) + "cloud_game.lock"
}
if err := os.MkdirAll(filepath.Dir(path), 0770); err != nil {
return nil, err
} else {
f, err := os.Create(path)
defer func() { _ = f.Close() }()
if err != nil {
return nil, err
}
}
f := Flock{
f: flock.New(path),
}
return &f, nil
}
func (f *Flock) Lock() error { return f.f.Lock() }
func (f *Flock) Unlock() error { return f.f.Unlock() }

View file

@ -28,6 +28,10 @@ func CheckCreateDir(path string) error {
return nil
}
func MakeDirAll(path string) error {
return os.MkdirAll(path, os.ModeDir|os.ModePerm)
}
func ExpectTermination() chan struct{} {
signals := make(chan os.Signal, 1)
signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
@ -47,6 +51,37 @@ func GetUserHome() (string, error) {
return me.HomeDir, nil
}
func CopyFile(from string, to string) (err error) {
f, err := os.Open(from)
if err != nil {
return err
}
defer func() {
if err2 := f.Close(); err2 != nil {
err = errors.Join(err, err2)
}
}()
destFile, err := os.Create(to)
if err != nil {
return err
}
defer func() {
if err2 := destFile.Close(); err != nil {
err = errors.Join(err, err2)
}
}()
n, err := f.WriteTo(destFile)
if n == 0 {
return errors.New("nothing was written")
}
if err != nil {
return err
}
return nil
}
func WriteFile(name string, data []byte, perm os.FileMode) error {
return os.WriteFile(name, data, perm)
}
@ -84,3 +119,7 @@ func StatSize(path string) (int64, error) {
}
return fi.Size(), nil
}
func RemoveAll(path string) error {
return os.RemoveAll(path)
}

62
pkg/resampler/simple.go Normal file
View file

@ -0,0 +1,62 @@
package resampler
func Linear(dst, src []int16) {
nSrc, nDst := len(src), len(dst)
if nSrc < 2 || nDst < 2 {
return
}
srcPairs, dstPairs := nSrc>>1, nDst>>1
// replicate single pair input or output
if srcPairs == 1 || dstPairs == 1 {
for i := range dstPairs {
dst[i*2], dst[i*2+1] = src[0], src[1]
}
return
}
ratio := ((srcPairs - 1) << 16) / (dstPairs - 1)
lastSrc := nSrc - 2
// interpolate all pairs except the last
for i, pos := 0, 0; i < dstPairs-1; i, pos = i+1, pos+ratio {
idx := (pos >> 16) << 1
di := i << 1
frac := int32(pos & 0xFFFF)
l0, r0 := int32(src[idx]), int32(src[idx+1])
// L = L0 + (L1-L0)*frac
dst[di] = int16(l0 + ((int32(src[idx+2])-l0)*frac)>>16)
// R = R0 + (R1-R0)*frac
dst[di+1] = int16(r0 + ((int32(src[idx+3])-r0)*frac)>>16)
}
// last output pair = last input pair (avoids precision loss at the edge)
lastDst := (dstPairs - 1) << 1
dst[lastDst], dst[lastDst+1] = src[lastSrc], src[lastSrc+1]
}
func Nearest(dst, src []int16) {
nSrc, nDst := len(src), len(dst)
if nSrc < 2 || nDst < 2 {
return
}
srcPairs, dstPairs := nSrc>>1, nDst>>1
if srcPairs == 1 || dstPairs == 1 {
for i := range dstPairs {
dst[i*2], dst[i*2+1] = src[0], src[1]
}
return
}
ratio := (srcPairs << 16) / dstPairs
for i, pos := 0, 0; i < dstPairs; i, pos = i+1, pos+ratio {
si := (pos >> 16) << 1
di := i << 1
dst[di], dst[di+1] = src[si], src[si+1]
}
}

106
pkg/resampler/speex.go Normal file
View file

@ -0,0 +1,106 @@
package resampler
/*
#cgo pkg-config: speexdsp
#cgo st LDFLAGS: -l:libspeexdsp.a
#include <stdint.h>
#include "speex_resampler.h"
*/
import "C"
import (
"errors"
"unsafe"
)
// Quality
const (
QualityMax = 10
QualityMin = 0
QualityDefault = 4
QualityDesktop = 5
QualityVoid = 3
)
// Errors
const (
ErrorSuccess = iota
ErrorAllocFailed
ErrorBadState
ErrorInvalidArg
ErrorPtrOverlap
ErrorMaxError
)
type Resampler struct {
resampler *C.SpeexResamplerState
channels int
inRate int
outRate int
}
func Init(channels, inRate, outRate, quality int) (*Resampler, error) {
var err C.int
r := &Resampler{
channels: channels,
inRate: inRate,
outRate: outRate,
}
r.resampler = C.speex_resampler_init(
C.spx_uint32_t(channels),
C.spx_uint32_t(inRate),
C.spx_uint32_t(outRate),
C.int(quality),
&err,
)
if r.resampler == nil {
return nil, StrError(int(err))
}
C.speex_resampler_skip_zeros(r.resampler)
return r, nil
}
func (r *Resampler) Destroy() {
if r.resampler != nil {
C.speex_resampler_destroy(r.resampler)
r.resampler = nil
}
}
// Process performs resampling.
// Returns written samples count and error if any.
func (r *Resampler) Process(out, in []int16) (int, error) {
if len(in) == 0 || len(out) == 0 {
return 0, nil
}
inLen := C.spx_uint32_t(len(in) / r.channels)
outLen := C.spx_uint32_t(len(out) / r.channels)
res := C.speex_resampler_process_interleaved_int(
r.resampler,
(*C.spx_int16_t)(unsafe.Pointer(&in[0])),
&inLen,
(*C.spx_int16_t)(unsafe.Pointer(&out[0])),
&outLen,
)
if res != ErrorSuccess {
return 0, StrError(int(res))
}
return int(outLen) * r.channels, nil
}
func StrError(errorCode int) error {
cS := C.speex_resampler_strerror(C.int(errorCode))
if cS == nil {
return nil
}
return errors.New(C.GoString(cS))
}

View file

@ -0,0 +1,70 @@
#ifndef SPEEX_RESAMPLER_H
#define SPEEX_RESAMPLER_H
#define spx_int16_t short
#define spx_int32_t int
#define spx_uint16_t unsigned short
#define spx_uint32_t unsigned int
#define SPEEX_RESAMPLER_QUALITY_MAX 10
#define SPEEX_RESAMPLER_QUALITY_MIN 0
#define SPEEX_RESAMPLER_QUALITY_DEFAULT 4
#define SPEEX_RESAMPLER_QUALITY_VOIP 3
#define SPEEX_RESAMPLER_QUALITY_DESKTOP 5
enum {
RESAMPLER_ERR_SUCCESS = 0,
RESAMPLER_ERR_ALLOC_FAILED = 1,
RESAMPLER_ERR_BAD_STATE = 2,
RESAMPLER_ERR_INVALID_ARG = 3,
RESAMPLER_ERR_PTR_OVERLAP = 4,
RESAMPLER_ERR_MAX_ERROR
};
struct SpeexResamplerState_;
typedef struct SpeexResamplerState_ SpeexResamplerState;
/** Create a new resampler with integer input and output rates.
* @param nb_channels Number of channels to be processed
* @param in_rate Input sampling rate (integer number of Hz).
* @param out_rate Output sampling rate (integer number of Hz).
* @param quality Resampling quality between 0 and 10, where 0 has poor quality
* and 10 has very high quality.
* @return Newly created resampler state
* @retval NULL Error: not enough memory
*/
SpeexResamplerState *speex_resampler_init(spx_uint32_t nb_channels,
spx_uint32_t in_rate,
spx_uint32_t out_rate,
int quality,
int *err);
/** Destroy a resampler state.
* @param st Resampler state
*/
void speex_resampler_destroy(SpeexResamplerState *st);
/** Make sure that the first samples to go out of the resamplers don't have
* leading zeros. This is only useful before starting to use a newly created
* resampler. It is recommended to use that when resampling an audio file, as
* it will generate a file with the same length. For real-time processing,
* it is probably easier not to use this call (so that the output duration
* is the same for the first frame).
* @param st Resampler state
*/
int speex_resampler_skip_zeros(SpeexResamplerState *st);
/** Resample an interleaved int array. The input and output buffers must *not* overlap.
* @param st Resampler state
* @param in Input buffer
* @param in_len Number of input samples in the input buffer. Returns the number
* of samples processed. This is all per-channel.
* @param out Output buffer
* @param out_len Size of the output buffer. Returns the number of samples written.
* This is all per-channel.
*/
int speex_resampler_process_interleaved_int(SpeexResamplerState *st,
const spx_int16_t *in,
spx_uint32_t *in_len,
spx_int16_t *out,
spx_uint32_t *out_len);
const char *speex_resampler_strerror(int err);
#endif

View file

@ -2,14 +2,19 @@ package app
type App interface {
AudioSampleRate() int
AspectRatio() float32
AspectEnabled() bool
Init() error
ViewportSize() (int, int)
Scale() float64
Start()
Close()
SetAudioCb(func(Audio))
SetVideoCb(func(Video))
SendControl(port int, data []byte)
SetDataCb(func([]byte))
Input(port int, device byte, data []byte)
KbMouseSupport() bool
}
type Audio struct {

View file

@ -15,6 +15,12 @@ type Manager struct {
log *logger.Logger
}
const (
RetroPad = libretro.RetroPad
Keyboard = libretro.Keyboard
Mouse = libretro.Mouse
)
type ModName string
const Libretro ModName = "libretro"

View file

@ -14,9 +14,6 @@ type Caged struct {
base *Frontend // maintains the root for mad embedding
conf CagedConf
log *logger.Logger
w, h int
OnSysInfoChange func()
}
type CagedConf struct {
@ -34,6 +31,13 @@ func (c *Caged) Init() error {
if err := manager.CheckCores(c.conf.Emulator, c.log); err != nil {
c.log.Warn().Err(err).Msgf("a Libretro cores sync fail")
}
if c.conf.Emulator.FailFast {
if err := c.IsSupported(); err != nil {
return err
}
}
return nil
}
@ -41,26 +45,24 @@ func (c *Caged) ReloadFrontend() {
frontend, err := NewFrontend(c.conf.Emulator, c.log)
if err != nil {
c.log.Fatal().Err(err).Send()
return
}
c.Emulator = frontend
c.base = frontend
}
func (c *Caged) HandleOnSystemAvInfo(fn func()) {
c.base.SetOnAV(func() {
w, h := c.ViewportCalc()
c.SetViewport(w, h)
fn()
})
}
// VideoChangeCb adds a callback when video params are changed by the app.
func (c *Caged) VideoChangeCb(fn func()) { c.base.SetVideoChangeCb(fn) }
func (c *Caged) Load(game games.GameMetadata, path string) error {
if c.Emulator == nil {
return nil
}
c.Emulator.LoadCore(game.System)
if err := c.Emulator.LoadGame(game.FullPath(path)); err != nil {
return err
}
w, h := c.ViewportCalc()
c.SetViewport(w, h)
c.ViewportRecalculate()
return nil
}
@ -73,24 +75,28 @@ func (c *Caged) EnableRecording(nowait bool, user string, game string) {
}
func (c *Caged) EnableCloudStorage(uid string, storage cloud.Storage) {
if storage != nil {
wc, err := WithCloud(c.Emulator, uid, storage)
if err != nil {
c.log.Error().Err(err).Msgf("couldn't init %v", wc.HashPath())
} else {
c.log.Info().Msgf("cloud state %v has been initialized", wc.HashPath())
c.Emulator = wc
}
if storage == nil {
return
}
if wc, err := WithCloud(c.Emulator, uid, storage); err == nil {
c.Emulator = wc
c.log.Info().Msgf("cloud storage has been initialized")
} else {
c.log.Error().Err(err).Msgf("couldn't init cloud storage")
}
}
func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() }
func (c *Caged) Rotation() uint { return c.Emulator.Rotation() }
func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() }
func (c *Caged) ViewportSize() (int, int) { return c.Emulator.ViewportSize() }
func (c *Caged) Scale() float64 { return c.Emulator.Scale() }
func (c *Caged) SendControl(port int, data []byte) { c.base.Input(port, data) }
func (c *Caged) Start() { go c.Emulator.Start() }
func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v }
func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) }
func (c *Caged) Close() { c.Emulator.Close() }
func (c *Caged) AspectEnabled() bool { return c.base.nano.Aspect }
func (c *Caged) AspectRatio() float32 { return c.base.AspectRatio() }
func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() }
func (c *Caged) Rotation() uint { return c.Emulator.Rotation() }
func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() }
func (c *Caged) ViewportSize() (int, int) { return c.base.ViewportSize() }
func (c *Caged) Scale() float64 { return c.Emulator.Scale() }
func (c *Caged) Input(p int, d byte, data []byte) { c.base.Input(p, d, data) }
func (c *Caged) KbMouseSupport() bool { return c.base.KbMouseSupport() }
func (c *Caged) Start() { go c.Emulator.Start() }
func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v }
func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) }
func (c *Caged) Close() { c.Emulator.Close() }
func (c *Caged) IsSupported() error { return c.base.IsSupported() }

View file

@ -7,32 +7,37 @@ import (
type CloudFrontend struct {
Emulator
stateName string
stateLocalPath string
storage cloud.Storage // a cloud storage to store room state online
uid string
storage cloud.Storage // a cloud storage to store room state online
}
func WithCloud(fe Emulator, stateName string, storage cloud.Storage) (*CloudFrontend, error) {
r := &CloudFrontend{Emulator: fe, stateLocalPath: fe.HashPath(), stateName: stateName, storage: storage}
// WithCloud adds the ability to keep game states in the cloud storage like Amazon S3.
// It supports only one file of main save state.
func WithCloud(fe Emulator, uid string, storage cloud.Storage) (*CloudFrontend, error) {
r := &CloudFrontend{Emulator: fe, uid: uid, storage: storage}
// saveOnlineRoomToLocal save online room to local.
// !Supports only one file of main save state.
data, err := r.storage.Load(stateName)
if err != nil {
return nil, err
}
// save the data fetched from the cloud to a local directory
if data != nil {
if err := os.WriteFile(r.stateLocalPath, data, 0644); err != nil {
name := fe.SaveStateName()
if r.storage.Has(name) {
data, err := r.storage.Load(fe.SaveStateName())
if err != nil {
return nil, err
}
// save the data fetched from the cloud to a local directory
if data != nil {
if err := os.WriteFile(fe.HashPath(), data, 0644); err != nil {
return nil, err
}
}
}
return r, nil
}
// !to use emulator save/load calls instead of the storage
func (c *CloudFrontend) HasSave() bool {
_, err := c.storage.Load(c.stateName)
_, err := c.storage.Load(c.SaveStateName())
if err == nil {
return true
}
@ -43,8 +48,13 @@ func (c *CloudFrontend) SaveGameState() error {
if err := c.Emulator.SaveGameState(); err != nil {
return err
}
if err := c.storage.Save(c.stateName, c.stateLocalPath); err != nil {
path := c.Emulator.HashPath()
data, err := os.ReadFile(path)
if err != nil {
return err
}
return nil
return c.storage.Save(c.SaveStateName(), data, map[string]string{
"uid": c.uid,
"type": "cloudretro-main-save",
})
}

View file

@ -3,10 +3,10 @@ package libretro
import (
"errors"
"fmt"
"math"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
@ -14,12 +14,14 @@ import (
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/os"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/app"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/nanoarch"
)
type Emulator interface {
SetAudioCb(func(app.Audio))
SetVideoCb(func(app.Video))
SetDataCb(func([]byte))
LoadCore(name string)
LoadGame(path string) error
FPS() int
@ -30,70 +32,68 @@ type Emulator interface {
IsPortrait() bool
// Start is called after LoadGame
Start()
// SetViewport sets viewport size
SetViewport(width int, height int)
// ViewportCalc calculates the viewport size with the aspect ratio and scale
ViewportCalc() (nw int, nh int)
ViewportSize() (w, h int)
// ViewportRecalculate calculates output resolution with aspect and scale
ViewportRecalculate()
RestoreGameState() error
// SetSessionId sets distinct name for the game session (in order to save/load it later)
SetSessionId(name string)
SaveGameState() error
SaveStateName() string
// HashPath returns the path emulator will save state to
HashPath() string
// HasSave returns true if the current ROM was saved before
HasSave() bool
// Close will be called when the game is done
Close()
// ToggleMultitap toggles multitap controller.
ToggleMultitap()
// Input passes input to the emulator
Input(player int, data []byte)
Input(player int, device byte, data []byte)
// Scale returns set video scale factor
Scale() float64
Reset()
}
type Frontend struct {
conf config.Emulator
done chan struct{}
input InputState
log *logger.Logger
nano *nanoarch.Nanoarch
onAudio func(app.Audio)
onData func([]byte)
onVideo func(app.Video)
storage Storage
scale float64
th int // draw threads
vw, vh int // out frame size
mu sync.Mutex
// directives
// skipVideo used when new frame was too late
skipVideo bool
mu sync.Mutex
mui sync.Mutex
DisableCanvasPool bool
SaveOnClose bool
UniqueSaveDir bool
SaveStateFs string
}
// InputState stores full controller state.
// It consists of:
// - uint16 button values
// - int16 analog stick values
type (
InputState [maxPort]State
State struct {
keys uint32
axes [dpadAxes]int32
}
)
type Device byte
const (
maxPort = 4
dpadAxes = 4
RetroPad = Device(nanoarch.RetroPad)
Keyboard = Device(nanoarch.Keyboard)
Mouse = Device(nanoarch.Mouse)
)
var (
audioPool sync.Pool
noAudio = func(app.Audio) {}
noData = func([]byte) {}
noVideo = func(app.Video) {}
videoPool sync.Pool
lastFrame *app.Video
)
// NewFrontend implements Emulator interface for a Libretro frontend.
@ -111,8 +111,8 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
nano := nanoarch.NewNano(path)
log = log.Extend(log.With().Str("m", "Libretro"))
ll := log.Extend(log.Level(logger.Level(conf.Libretro.LogLevel)).With())
nano.SetLogger(ll)
level := logger.Level(conf.Libretro.LogLevel)
nano.SetLogger(log.Extend(log.Level(level).With()))
// Check if room is on local storage, if not, pull from GCS to local storage
log.Info().Msgf("Local storage path: %v", conf.Storage)
@ -129,36 +129,62 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
f := &Frontend{
conf: conf,
done: make(chan struct{}),
input: NewGameSessionInput(),
log: log,
onAudio: noAudio,
onData: noData,
onVideo: noVideo,
storage: store,
th: conf.Threads,
}
f.linkNano(nano)
if conf.Libretro.DebounceMs > 0 {
t := time.Duration(conf.Libretro.DebounceMs) * time.Millisecond
f.nano.SetVideoDebounce(t)
f.log.Debug().Msgf("set debounce time: %v", t)
}
return f, nil
}
func (f *Frontend) LoadCore(emu string) {
conf := f.conf.GetLibretroCoreConfig(emu)
libExt := ""
if ar, err := f.conf.Libretro.Cores.Repo.Guess(); err == nil {
libExt = ar.Ext
} else {
f.log.Warn().Err(err).Msg("system arch guesser failed")
}
meta := nanoarch.Metadata{
AutoGlContext: conf.AutoGlContext,
Hacks: conf.Hacks,
HasMultitap: conf.HasMultitap,
HasVFR: conf.VFR,
IsGlAllowed: conf.IsGlAllowed,
LibPath: conf.Lib,
Options: conf.Options,
UsesLibCo: conf.UsesLibCo,
AutoGlContext: conf.AutoGlContext,
FrameDup: f.conf.Libretro.Dup,
Hacks: conf.Hacks,
HasVFR: conf.VFR,
Hid: conf.Hid,
IsGlAllowed: conf.IsGlAllowed,
LibPath: conf.Lib,
Options: conf.Options,
Options4rom: conf.Options4rom,
UsesLibCo: conf.UsesLibCo,
CoreAspectRatio: conf.CoreAspectRatio,
KbMouseSupport: conf.KbMouseSupport,
LibExt: libExt,
}
f.mu.Lock()
f.SaveStateFs = conf.SaveStateFs
if conf.UniqueSaveDir {
f.UniqueSaveDir = true
f.nano.SetSaveDirSuffix(f.storage.MainPath())
f.log.Debug().Msgf("Using unique dir for saves: %v", f.storage.MainPath())
}
scale := 1.0
if conf.Scale > 1 {
scale = conf.Scale
f.log.Debug().Msgf("Scale: x%v", scale)
}
f.storage.SetNonBlocking(conf.NonBlockingSave)
f.scale = scale
f.nano.CoreLoad(meta)
f.mu.Unlock()
@ -178,7 +204,10 @@ func (f *Frontend) handleAudio(audio unsafe.Pointer, samples int) {
}
func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) {
// !to merge both pools
if f.conf.SkipLateFrames && f.skipVideo {
return
}
fr, _ := videoPool.Get().(*app.Video)
if fr == nil {
fr = new(app.Video)
@ -188,137 +217,230 @@ func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo)
fr.Frame.H = int(fi.H)
fr.Frame.Stride = int(fi.Stride)
fr.Duration = delta
lastFrame = fr
f.onVideo(*fr)
videoPool.Put(fr)
}
func (f *Frontend) handleDup() {
if lastFrame != nil {
f.onVideo(*lastFrame)
}
}
func (f *Frontend) Shutdown() {
f.mu.Lock()
f.nano.Shutdown()
f.SetAudioCb(noAudio)
f.SetVideoCb(noVideo)
lastFrame = nil
f.mu.Unlock()
f.log.Debug().Msgf("frontend closed")
f.log.Debug().Msgf("frontend shutdown done")
}
func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) {
f.nano = nano
if nano == nil {
return
}
f.nano.WaitReady() // start only when nano is available
f.nano.OnKeyPress = f.input.isKeyPressed
f.nano.OnDpad = f.input.isDpadTouched
f.nano.OnVideo = f.handleVideo
f.nano.OnAudio = f.handleAudio
f.nano.OnDup = f.handleDup
}
func (f *Frontend) SetOnAV(fn func()) { f.nano.OnSystemAvInfo = fn }
func (f *Frontend) SetVideoChangeCb(fn func()) {
if f.nano != nil {
f.nano.OnSystemAvInfo = fn
}
}
func (f *Frontend) Start() {
f.log.Debug().Msgf("Frontend start")
f.log.Debug().Msgf("frontend start")
if f.nano.Stopped.Load() {
f.log.Warn().Msgf("frontend stopped during the start")
f.mui.Lock()
defer f.mui.Unlock()
f.Shutdown()
return
}
// don't jump between threads
runtime.LockOSThread()
defer runtime.UnlockOSThread()
f.mui.Lock()
f.done = make(chan struct{})
f.nano.LastFrameTime = time.Now().UnixNano()
defer f.Shutdown()
defer func() {
// Save game on quit if it was saved before (shared or click-saved).
if f.SaveOnClose && f.HasSave() {
f.log.Debug().Msg("save on quit")
if err := f.Save(); err != nil {
f.log.Error().Err(err).Msg("save on quit failed")
}
}
f.mui.Unlock()
f.Shutdown()
}()
if f.HasSave() {
// advance 1 frame for Mupen save state
if f.nano.LibCo {
f.Tick()
}
// advance 1 frame for Mupen, DOSBox save states
// loading will work if autostart is selected for DOSBox apps
f.Tick()
if err := f.RestoreGameState(); err != nil {
f.log.Error().Err(err).Msg("couldn't load a save file")
}
}
ticker := time.NewTicker(time.Second / time.Duration(f.nano.VideoFramerate()))
defer ticker.Stop()
if f.conf.AutosaveSec > 0 {
// !to sync both for loops, can crash if the emulator starts later
go f.autosave(f.conf.AutosaveSec)
}
// The main loop of Libretro
// calculate the exact duration required for a frame (e.g., 16.666ms = 60 FPS)
targetFrameTime := time.Second / time.Duration(f.nano.VideoFramerate())
// stop sleeping and start spinning in the remaining 1ms
const spinThreshold = 1 * time.Millisecond
// how many frames will be considered not normal
const lateFramesThreshold = 3
lastFrameStart := time.Now()
for {
select {
case <-ticker.C:
f.Tick()
case <-f.done:
return
default:
// run one tick of the emulation
f.Tick()
elapsed := time.Since(lastFrameStart)
sleepTime := targetFrameTime - elapsed
if sleepTime > 0 {
// SLEEP
// if we have plenty of time, sleep to save CPU and
// wake up slightly before the target time
if sleepTime > spinThreshold {
time.Sleep(sleepTime - spinThreshold)
}
// SPIN
// if we are close to the target,
// burn CPU and check the clock with ns resolution
for time.Since(lastFrameStart) < targetFrameTime {
// CPU burn!
}
f.skipVideo = false
} else {
// lagging behind the target framerate so we don't sleep
if f.conf.LogDroppedFrames {
// !to make some stats counter instead
f.log.Debug().Msgf("[] Frame drop: %v", elapsed)
}
f.skipVideo = true
}
// timer reset
//
// adding targetFrameTime to the previous start
// prevents drift, if one frame was late,
// we try to catch up in the next frame
lastFrameStart = lastFrameStart.Add(targetFrameTime)
// if execution was paused or heavily delayed,
// reset lastFrameStart so we don't try to run
// a bunch of frames instantly to catch up
if time.Since(lastFrameStart) > targetFrameTime*lateFramesThreshold {
lastFrameStart = time.Now()
}
}
}
}
func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C }
func (f *Frontend) Rotation() uint { return f.nano.Rot }
func (f *Frontend) Flipped() bool { return f.nano.IsGL() }
func (f *Frontend) FrameSize() (int, int) { return f.nano.GeometryBase() }
func (f *Frontend) FPS() int { return f.nano.VideoFramerate() }
func (f *Frontend) HashPath() string { return f.storage.GetSavePath() }
func (f *Frontend) HasSave() bool { return os.Exists(f.HashPath()) }
func (f *Frontend) SRAMPath() string { return f.storage.GetSRAMPath() }
func (f *Frontend) AudioSampleRate() int { return f.nano.AudioSampleRate() }
func (f *Frontend) Input(player int, data []byte) { f.input.setInput(player, data) }
func (f *Frontend) LoadGame(path string) error { return f.nano.LoadGame(path) }
func (f *Frontend) RestoreGameState() error { return f.Load() }
func (f *Frontend) Scale() float64 { return f.scale }
func (f *Frontend) IsPortrait() bool { return f.nano.IsPortrait() }
func (f *Frontend) SaveGameState() error { return f.Save() }
func (f *Frontend) SetAudioCb(cb func(app.Audio)) { f.onAudio = cb }
func (f *Frontend) SetSessionId(name string) { f.storage.SetMainSaveName(name) }
func (f *Frontend) SetVideoCb(ff func(app.Video)) { f.onVideo = ff }
func (f *Frontend) SetViewport(width int, height int) {
f.mu.Lock()
f.vw, f.vh = width, height
f.mu.Unlock()
func (f *Frontend) LoadGame(path string) error {
if f.UniqueSaveDir {
f.copyFsMaybe(path)
}
return f.nano.LoadGame(path)
}
// Tick runs one emulation frame.
func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f.mu.Unlock() }
func (f *Frontend) ToggleMultitap() { f.nano.ToggleMultitap() }
func (f *Frontend) ViewportSize() (int, int) { return f.vw, f.vh }
func (f *Frontend) AspectRatio() float32 { return f.nano.AspectRatio() }
func (f *Frontend) AudioSampleRate() int { return f.nano.AudioSampleRate() }
func (f *Frontend) FPS() int { return f.nano.VideoFramerate() }
func (f *Frontend) Flipped() bool { return f.nano.IsGL() }
func (f *Frontend) FrameSize() (int, int) { return f.nano.BaseWidth(), f.nano.BaseHeight() }
func (f *Frontend) HasSave() bool { return os.Exists(f.HashPath()) }
func (f *Frontend) HashPath() string { return f.storage.GetSavePath() }
func (f *Frontend) IsPortrait() bool { return f.nano.IsPortrait() }
func (f *Frontend) KbMouseSupport() bool { return f.nano.KbMouseSupport() }
func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C }
func (f *Frontend) Reset() { f.mu.Lock(); defer f.mu.Unlock(); f.nano.Reset() }
func (f *Frontend) RestoreGameState() error { return f.Load() }
func (f *Frontend) Rotation() uint { return f.nano.Rot }
func (f *Frontend) SRAMPath() string { return f.storage.GetSRAMPath() }
func (f *Frontend) SaveGameState() error { return f.Save() }
func (f *Frontend) SaveStateName() string { return filepath.Base(f.HashPath()) }
func (f *Frontend) Scale() float64 { return f.scale }
func (f *Frontend) SetAudioCb(cb func(app.Audio)) { f.onAudio = cb }
func (f *Frontend) SetSessionId(name string) { f.storage.SetMainSaveName(name) }
func (f *Frontend) SetDataCb(cb func([]byte)) { f.onData = cb }
func (f *Frontend) SetVideoCb(ff func(app.Video)) { f.onVideo = ff }
func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f.mu.Unlock() }
func (f *Frontend) ViewportRecalculate() { f.mu.Lock(); f.vw, f.vh = f.ViewportCalc(); f.mu.Unlock() }
func (f *Frontend) ViewportSize() (int, int) { return f.vw, f.vh }
func (f *Frontend) Input(port int, device byte, data []byte) {
switch Device(device) {
case RetroPad:
f.nano.InputRetropad(port, data)
case Keyboard:
f.nano.InputKeyboard(port, data)
case Mouse:
f.nano.InputMouse(port, data)
}
}
func (f *Frontend) ViewportCalc() (nw int, nh int) {
w, h := f.FrameSize()
f.log.Debug().Msgf("Viewport source size: %dx%d", w, h)
aspect, aw, ah := f.conf.AspectRatio.Keep, f.conf.AspectRatio.Width, f.conf.AspectRatio.Height
// calc the aspect ratio
if aspect && aw > 0 && ah > 0 {
ratio := float64(w) / float64(ah)
nw = int(math.Round(float64(ah)*ratio/2) * 2)
nh = ah
if nw > aw {
nw = aw
nh = int(math.Round(float64(aw)/ratio/2) * 2)
}
f.log.Debug().Msgf("Viewport aspect change: %dx%d (%f) -> %dx%d", aw, ah, ratio, nw, nh)
} else {
nw, nh = w, h
}
nw, nh = w, h
if f.IsPortrait() {
nw, nh = nh, nw
f.log.Debug().Msgf("Set portrait mode")
}
f.log.Info().Msgf("Viewport final size: %dx%d", nw, nh)
f.log.Debug().Msgf("viewport: %dx%d -> %dx%d", w, h, nw, nh)
return
}
func (f *Frontend) Close() {
f.log.Debug().Msgf("frontend close called")
f.log.Debug().Msgf("frontend close")
close(f.done)
// Save game on quit if it was saved before (shared or click-saved).
if f.SaveOnClose && f.HasSave() {
f.log.Debug().Msg("Save on quit")
if err := f.Save(); err != nil {
f.log.Error().Err(err).Msg("save on quit failed")
f.mui.Lock()
f.nano.Close()
if f.UniqueSaveDir && !f.HasSave() {
if err := f.nano.DeleteSaveDir(); err != nil {
f.log.Error().Msgf("couldn't delete save dir: %v", err)
}
}
close(f.done)
f.nano.Close()
f.UniqueSaveDir = false
f.SaveStateFs = ""
f.mui.Unlock()
f.log.Debug().Msgf("frontend closed")
}
// Save writes the current state to the filesystem.
@ -367,6 +489,10 @@ func (f *Frontend) Load() error {
return nil
}
func (f *Frontend) IsSupported() error {
return graphics.TryInit()
}
func (f *Frontend) autosave(periodSec int) {
f.log.Info().Msgf("Autosave every [%vs]", periodSec)
ticker := time.NewTicker(time.Duration(periodSec) * time.Second)
@ -389,23 +515,31 @@ func (f *Frontend) autosave(periodSec int) {
}
}
func NewGameSessionInput() InputState { return [maxPort]State{} }
func (f *Frontend) copyFsMaybe(path string) {
if f.SaveStateFs == "" {
return
}
// setInput sets input state for some player in a game session.
func (s *InputState) setInput(player int, data []byte) {
atomic.StoreUint32(&s[player].keys, uint32(uint16(data[1])<<8+uint16(data[0])))
for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ {
axis := i<<1 + 2
atomic.StoreInt32(&s[player].axes[i], int32(data[axis+1])<<8+int32(data[axis]))
fileName := f.SaveStateFs
hasPlaceholder := strings.HasPrefix(f.SaveStateFs, "*")
if hasPlaceholder {
game := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
fileName = strings.Replace(f.SaveStateFs, "*", game, 1)
}
fullPath := filepath.Join(f.nano.SaveDir(), fileName)
if os.Exists(fullPath) {
return
}
storePath := filepath.Dir(path)
fsPath := filepath.Join(storePath, fileName)
if os.Exists(fsPath) {
if err := os.CopyFile(fsPath, fullPath); err != nil {
f.log.Error().Err(err).Msgf("fs copy fail")
} else {
f.log.Debug().Msgf("copied fs %v to %v", fsPath, fullPath)
}
}
}
// isKeyPressed checks if some button is pressed by any player.
func (s *InputState) isKeyPressed(port uint, key int) int {
return int((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1)
}
// isDpadTouched checks if D-pad is used by any player.
func (s *InputState) isDpadTouched(port uint, axis uint) (shift int16) {
return int16(atomic.LoadInt32(&s[port].axes[axis]))
}

View file

@ -5,11 +5,12 @@ import (
"fmt"
"io"
"log"
"math/rand"
"math/rand/v2"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/logger"
@ -25,14 +26,17 @@ type TestFrontend struct {
*Frontend
corePath string
coreExt string
gamePath string
system string
}
type testRun struct {
room string
system string
rom string
emulationTicks int
name string
room string
system string
rom string
frames int
}
type game struct {
@ -41,9 +45,10 @@ type game struct {
}
var (
alwa = game{system: "nes", rom: "Alwa's Awakening (Demo).nes"}
sushi = game{system: "gba", rom: "Sushi The Cat.gba"}
angua = game{system: "gba", rom: "anguna.gba"}
alwa = game{system: "nes", rom: "nes/Alwa's Awakening (Demo).nes"}
sushi = game{system: "gba", rom: "gba/Sushi The Cat.gba"}
angua = game{system: "gba", rom: "gba/anguna.gba"}
rogue = game{system: "dos", rom: "dos/rogue.zip"}
)
// TestMain runs all tests in the main thread in macOS.
@ -66,15 +71,20 @@ func EmulatorMock(room string, system string) *TestFrontend {
conf.Emulator.Storage = expand("tests", "storage")
l := logger.Default()
l2 := l.Extend(l.Level(logger.ErrorLevel).With())
l2 := l.Extend(l.Level(logger.WarnLevel).With())
if err := manager.CheckCores(conf.Emulator, l); err != nil {
if err := manager.CheckCores(conf.Emulator, l2); err != nil {
panic(err)
}
nano := nanoarch.NewNano(conf.Emulator.LocalPath)
nano.SetLogger(l2)
arch, err := conf.Emulator.Libretro.Cores.Repo.Guess()
if err != nil {
panic(err)
}
// an emu
emu := &TestFrontend{
Frontend: &Frontend{
@ -83,14 +93,15 @@ func EmulatorMock(room string, system string) *TestFrontend {
Path: os.TempDir(),
MainSave: room,
},
input: NewGameSessionInput(),
done: make(chan struct{}),
th: conf.Emulator.Threads,
log: l2,
SaveOnClose: false,
},
corePath: expand(conf.Emulator.GetLibretroCoreConfig(system).Lib),
gamePath: expand(conf.Worker.Library.BasePath),
coreExt: arch.Ext,
gamePath: expand(conf.Library.BasePath),
system: system,
}
emu.linkNano(nano)
@ -111,23 +122,36 @@ func DefaultFrontend(room string, system string, rom string) *TestFrontend {
// loadRom loads a ROM into the emulator.
// The rom will be loaded from emulators' games path.
func (emu *TestFrontend) loadRom(game string) {
emu.nano.CoreLoad(nanoarch.Metadata{LibPath: emu.corePath})
gamePath := expand(emu.gamePath, game)
conf := emu.conf.GetLibretroCoreConfig(gamePath)
conf := emu.conf.GetLibretroCoreConfig(emu.system)
scale := 1.0
if conf.Scale > 1 {
scale = conf.Scale
}
emu.scale = scale
meta := nanoarch.Metadata{
AutoGlContext: conf.AutoGlContext,
//FrameDup: f.conf.Libretro.Dup,
Hacks: conf.Hacks,
HasVFR: conf.VFR,
Hid: conf.Hid,
IsGlAllowed: conf.IsGlAllowed,
LibPath: emu.corePath,
Options: conf.Options,
Options4rom: conf.Options4rom,
UsesLibCo: conf.UsesLibCo,
CoreAspectRatio: conf.CoreAspectRatio,
LibExt: emu.coreExt,
}
emu.nano.CoreLoad(meta)
gamePath := expand(emu.gamePath, game)
err := emu.nano.LoadGame(gamePath)
if err != nil {
log.Fatal(err)
}
w, h := emu.FrameSize()
emu.SetViewport(w, h)
emu.ViewportRecalculate()
}
// Shutdown closes the emulator and cleans its resources.
@ -138,22 +162,26 @@ func (emu *TestFrontend) Shutdown() {
emu.Frontend.Shutdown()
}
// dumpState returns the current emulator state and
// the latest saved state for its session.
// Locks the emulator.
func (emu *TestFrontend) dumpState() (string, string) {
// dumpState returns both current and previous emulator save state as MD5 hash string.
func (emu *TestFrontend) dumpState() (cur string, prev string) {
emu.mu.Lock()
bytes, _ := os.ReadFile(emu.HashPath())
lastStateHash := hash(bytes)
b, _ := os.ReadFile(emu.HashPath())
prev = hash(b)
emu.mu.Unlock()
emu.mu.Lock()
state, _ := nanoarch.SaveState()
b, _ = nanoarch.SaveState()
emu.mu.Unlock()
stateHash := hash(state)
cur = hash(b)
fmt.Printf("mem: %v, dat: %v\n", stateHash, lastStateHash)
return stateHash, lastStateHash
return
}
func (emu *TestFrontend) save() ([]byte, error) {
emu.mu.Lock()
defer emu.mu.Unlock()
return nanoarch.SaveState()
}
func BenchmarkEmulators(b *testing.B) {
@ -172,7 +200,7 @@ func BenchmarkEmulators(b *testing.B) {
for _, bench := range benchmarks {
b.Run(bench.name, func(b *testing.B) {
s := DefaultFrontend("bench_"+bench.system+"_performance", bench.system, bench.rom)
for i := 0; i < b.N; i++ {
for range b.N {
s.nano.Run()
}
s.Shutdown()
@ -180,36 +208,32 @@ func BenchmarkEmulators(b *testing.B) {
}
}
// Tests a successful emulator state save.
func TestSave(t *testing.T) {
func TestSavePersistence(t *testing.T) {
tests := []testRun{
{room: "test_save_ok_00", system: sushi.system, rom: sushi.rom, emulationTicks: 100},
{room: "test_save_ok_01", system: angua.system, rom: angua.rom, emulationTicks: 10},
{system: sushi.system, rom: sushi.rom, frames: 100},
{system: angua.system, rom: angua.rom, frames: 100},
{system: rogue.system, rom: rogue.rom, frames: 200},
}
for _, test := range tests {
t.Logf("Testing [%v] save with [%v]\n", test.system, test.rom)
t.Run(fmt.Sprintf("If saves persistent on %v - %v", test.system, test.rom), func(t *testing.T) {
front := DefaultFrontend(test.room, test.system, test.rom)
front := DefaultFrontend(test.room, test.system, test.rom)
for test.frames > 0 {
front.Tick()
test.frames--
}
for test.emulationTicks > 0 {
front.Tick()
test.emulationTicks--
}
for range 10 {
v, _ := front.save()
if v == nil || len(v) == 0 {
t.Errorf("couldn't persist the state")
t.Fail()
}
}
fmt.Printf("[%-14v] ", "before save")
_, _ = front.dumpState()
if err := front.Save(); err != nil {
t.Errorf("Save fail %v", err)
}
fmt.Printf("[%-14v] ", "after save")
snapshot1, snapshot2 := front.dumpState()
if snapshot1 != snapshot2 {
t.Errorf("It seems rom state save has failed: %v != %v", snapshot1, snapshot2)
}
front.Shutdown()
front.Shutdown()
})
}
}
@ -222,9 +246,9 @@ func TestSave(t *testing.T) {
// Compare states (a) and (b), should be =.
func TestLoad(t *testing.T) {
tests := []testRun{
{room: "test_load_00", system: alwa.system, rom: alwa.rom, emulationTicks: 100},
{room: "test_load_01", system: sushi.system, rom: sushi.rom, emulationTicks: 1000},
{room: "test_load_02", system: angua.system, rom: angua.rom, emulationTicks: 100},
{room: "test_load_00", system: alwa.system, rom: alwa.rom, frames: 100},
//{room: "test_load_01", system: sushi.system, rom: sushi.rom, frames: 1000},
//{room: "test_load_02", system: angua.system, rom: angua.rom, frames: 100},
}
for _, test := range tests {
@ -232,31 +256,26 @@ func TestLoad(t *testing.T) {
mock := DefaultFrontend(test.room, test.system, test.rom)
fmt.Printf("[%-14v] ", "initial")
mock.dumpState()
for ticks := test.emulationTicks; ticks > 0; ticks-- {
for ticks := test.frames; ticks > 0; ticks-- {
mock.Tick()
}
fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.emulationTicks))
mock.dumpState()
if err := mock.Save(); err != nil {
t.Errorf("Save fail %v", err)
}
fmt.Printf("[%-14v] ", "saved")
snapshot1, _ := mock.dumpState()
for ticks := test.emulationTicks; ticks > 0; ticks-- {
for ticks := test.frames; ticks > 0; ticks-- {
mock.Tick()
}
fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.emulationTicks))
mock.dumpState()
if err := mock.Load(); err != nil {
t.Errorf("Load fail %v", err)
}
fmt.Printf("[%-14v] ", "restored")
snapshot2, _ := mock.dumpState()
if snapshot1 != snapshot2 {
@ -273,11 +292,11 @@ func TestStateConcurrency(t *testing.T) {
seed int
}{
{
run: testRun{room: "test_concurrency_00", system: sushi.system, rom: sushi.rom, emulationTicks: 120},
run: testRun{room: "test_concurrency_00", system: alwa.system, rom: alwa.rom, frames: 120},
seed: 42,
},
{
run: testRun{room: "test_concurrency_01", system: angua.system, rom: angua.rom, emulationTicks: 300},
run: testRun{room: "test_concurrency_01", system: alwa.system, rom: alwa.rom, frames: 300},
seed: 42 + 42,
},
}
@ -304,15 +323,13 @@ func TestStateConcurrency(t *testing.T) {
_ = mock.Save()
for i := 0; i < test.run.emulationTicks; i++ {
for i := range test.run.frames {
qLock.Lock()
mock.Tick()
qLock.Unlock()
i := i
if lucky() && !lucky() {
ops.Add(1)
go func() {
ops.Go(func() {
qLock.Lock()
defer qLock.Unlock()
@ -323,20 +340,10 @@ func TestStateConcurrency(t *testing.T) {
_ = mock.Load()
snapshot2, _ := mock.dumpState()
// Bug or feature?
// When you load a state from the file
// without immediate preceding save,
// it won't be in the loaded state
// even without calling retro_run.
// But if you pause the threads with a debugger
// and run the code step by step, then it will work as expected.
// Possible background emulation?
if snapshot1 != snapshot2 {
t.Errorf("States are inconsistent %v != %v on tick %v\n", snapshot1, snapshot2, i+1)
}
ops.Done()
}()
})
}
}
@ -345,18 +352,16 @@ func TestStateConcurrency(t *testing.T) {
}
}
func TestConcurrentInput(t *testing.T) {
var wg sync.WaitGroup
state := NewGameSessionInput()
events := 1000
wg.Add(2 * events)
func TestStartStop(t *testing.T) {
f1 := DefaultFrontend("sushi", sushi.system, sushi.rom)
go f1.Start()
time.Sleep(1 * time.Second)
f1.Close()
for i := 0; i < events; i++ {
player := rand.Intn(maxPort)
go func() { state.setInput(player, []byte{0, 1}); wg.Done() }()
go func() { state.isKeyPressed(uint(player), 100); wg.Done() }()
}
wg.Wait()
f2 := DefaultFrontend("sushi", sushi.system, sushi.rom)
go f2.Start()
time.Sleep(100 * time.Millisecond)
f2.Close()
}
// expand joins a list of file path elements.
@ -369,4 +374,4 @@ func expand(p ...string) string {
func hash(bytes []byte) string { return fmt.Sprintf("%x", md5.Sum(bytes)) }
// lucky returns random boolean.
func lucky() bool { return rand.Intn(2) == 1 }
func lucky() bool { return rand.IntN(2) == 1 }

View file

@ -78,6 +78,7 @@ typedef void (APIENTRYP GPREADPIXELS)(GLint x, GLint y, GLsizei width, GLsizei h
typedef void (APIENTRYP GPRENDERBUFFERSTORAGE)(GLenum target, GLenum internalformat, GLsizei width, GLsizei height);
typedef void (APIENTRYP GPTEXIMAGE2D)(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void *pixels);
typedef void (APIENTRYP GPTEXPARAMETERI)(GLenum target, GLenum pname, GLint param);
typedef void (APIENTRYP GPPIXELSTOREI)(GLenum pname, GLint param);
static const GLubyte *getString(GPGETSTRING ptr, GLenum name) { return (*ptr)(name); }
static GLenum getError(GPGETERROR ptr) { return (*ptr)(); }
@ -113,6 +114,7 @@ static void deleteTextures(GPDELETETEXTURES ptr, GLsizei n, const GLuint *textur
static void readPixels(GPREADPIXELS ptr, GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void *pixels) {
(*ptr)(x, y, width, height, format, type, pixels);
}
static void pixelStorei(GPPIXELSTOREI ptr, GLenum pname, GLint param) { (*ptr)(pname, param); }
*/
import "C"
import (
@ -144,6 +146,8 @@ const (
UnsignedShort5551 = 0x8034
UnsignedShort565 = 0x8363
UnsignedInt8888Rev = 0x8367
PackAlignment = 0x0D05
)
var (
@ -165,6 +169,7 @@ var (
gpDeleteFramebuffers C.GPDELETEFRAMEBUFFERS
gpDeleteTextures C.GPDELETETEXTURES
gpReadPixels C.GPREADPIXELS
gpPixelStorei C.GPPIXELSTOREI
)
func InitWithProcAddrFunc(getProcAddr func(name string) unsafe.Pointer) error {
@ -205,6 +210,9 @@ func InitWithProcAddrFunc(getProcAddr func(name string) unsafe.Pointer) error {
if gpReadPixels == nil {
return errors.New("glReadPixels")
}
if gpPixelStorei = (C.GPPIXELSTOREI)(getProcAddr("glPixelStorei")); gpPixelStorei == nil {
return errors.New("glPixelStorei")
}
return nil
}
@ -257,6 +265,9 @@ func DeleteTextures(n int32, textures *uint32) {
func ReadPixels(x int32, y int32, width int32, height int32, format uint32, xtype uint32, pixels unsafe.Pointer) {
C.readPixels(gpReadPixels, (C.GLint)(x), (C.GLint)(y), (C.GLsizei)(width), (C.GLsizei)(height), (C.GLenum)(format), (C.GLenum)(xtype), pixels)
}
func PixelStorei(pname uint32, param int32) {
C.pixelStorei(gpPixelStorei, (C.GLenum)(pname), (C.GLint)(param))
}
func GetError() uint32 { return (uint32)(C.getError(gpGetError)) }

View file

@ -9,24 +9,6 @@ import (
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics/gl"
)
type (
offscreenSetup struct {
tex uint32
fbo uint32
rbo uint32
width int32
height int32
pixType uint32
pixFormat uint32
hasDepth bool
hasStencil bool
}
PixelFormat int
)
type Context int
const (
@ -37,11 +19,12 @@ const (
CtxOpenGlEs3
CtxOpenGlEsVersion
CtxVulkan
CtxUnknown = math.MaxInt32 - 1
CtxDummy = math.MaxInt32
)
type PixelFormat int
const (
UnsignedShort5551 PixelFormat = iota
UnsignedShort565
@ -49,99 +32,91 @@ const (
)
var (
opt = offscreenSetup{}
buf []byte
fbo, tex, rbo uint32
hasDepth bool
pixType, pixFormat uint32
buf []byte
bufPtr unsafe.Pointer
)
func initContext(getProcAddr func(name string) unsafe.Pointer) {
if err := gl.InitWithProcAddrFunc(getProcAddr); err != nil {
panic(err)
}
gl.PixelStorei(gl.PackAlignment, 1)
}
func initFramebuffer(w int, h int, hasDepth bool, hasStencil bool) error {
opt.width = int32(w)
opt.height = int32(h)
opt.hasDepth = hasDepth
opt.hasStencil = hasStencil
// texture init
gl.GenTextures(1, &opt.tex)
gl.BindTexture(gl.Texture2d, opt.tex)
func initFramebuffer(width, height int, depth, stencil bool) error {
w, h := int32(width), int32(height)
hasDepth = depth
gl.GenTextures(1, &tex)
gl.BindTexture(gl.Texture2d, tex)
gl.TexParameteri(gl.Texture2d, gl.TextureMinFilter, gl.NEAREST)
gl.TexParameteri(gl.Texture2d, gl.TextureMagFilter, gl.NEAREST)
gl.TexImage2D(gl.Texture2d, 0, gl.RGBA8, opt.width, opt.height, 0, opt.pixType, opt.pixFormat, nil)
gl.TexImage2D(gl.Texture2d, 0, gl.RGBA8, w, h, 0, pixType, pixFormat, nil)
gl.BindTexture(gl.Texture2d, 0)
// framebuffer init
gl.GenFramebuffers(1, &opt.fbo)
gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo)
gl.GenFramebuffers(1, &fbo)
gl.BindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.ColorAttachment0, gl.Texture2d, tex, 0)
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.ColorAttachment0, gl.Texture2d, opt.tex, 0)
// more buffers init
if opt.hasDepth {
gl.GenRenderbuffers(1, &opt.rbo)
gl.BindRenderbuffer(gl.RENDERBUFFER, opt.rbo)
if opt.hasStencil {
gl.RenderbufferStorage(gl.RENDERBUFFER, gl.Depth24Stencil8, opt.width, opt.height)
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DepthStencilAttachment, gl.RENDERBUFFER, opt.rbo)
} else {
gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DepthComponent24, opt.width, opt.height)
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DepthAttachment, gl.RENDERBUFFER, opt.rbo)
if depth {
gl.GenRenderbuffers(1, &rbo)
gl.BindRenderbuffer(gl.RENDERBUFFER, rbo)
format, attachment := uint32(gl.DepthComponent24), uint32(gl.DepthAttachment)
if stencil {
format, attachment = gl.Depth24Stencil8, gl.DepthStencilAttachment
}
gl.RenderbufferStorage(gl.RENDERBUFFER, format, w, h)
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, attachment, gl.RENDERBUFFER, rbo)
gl.BindRenderbuffer(gl.RENDERBUFFER, 0)
}
if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FramebufferComplete {
return fmt.Errorf("invalid framebuffer (0x%X)", status)
return fmt.Errorf("framebuffer incomplete: 0x%X", status)
}
return nil
}
func destroyFramebuffer() {
if opt.hasDepth {
gl.DeleteRenderbuffers(1, &opt.rbo)
if hasDepth {
gl.DeleteRenderbuffers(1, &rbo)
}
gl.DeleteFramebuffers(1, &opt.fbo)
gl.DeleteTextures(1, &opt.tex)
gl.DeleteFramebuffers(1, &fbo)
gl.DeleteTextures(1, &tex)
}
func ReadFramebuffer(bytes, w, h uint) []byte {
data := buf[:bytes]
gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo)
gl.ReadPixels(0, 0, int32(w), int32(h), opt.pixType, opt.pixFormat, unsafe.Pointer(&data[0]))
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
return data
func ReadFramebuffer(size, w, h uint) []byte {
gl.BindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.ReadPixels(0, 0, int32(w), int32(h), pixType, pixFormat, bufPtr)
return buf[:size]
}
func getFbo() uint32 { return opt.fbo }
func SetBuffer(size int) { buf = make([]byte, size) }
func SetBuffer(size int) {
buf = make([]byte, size)
bufPtr = unsafe.Pointer(&buf[0])
}
func SetPixelFormat(format PixelFormat) error {
switch format {
case UnsignedShort5551:
opt.pixFormat = gl.UnsignedShort5551
opt.pixType = gl.BGRA
pixFormat, pixType = gl.UnsignedShort5551, gl.BGRA
case UnsignedShort565:
opt.pixFormat = gl.UnsignedShort565
opt.pixType = gl.RGB
pixFormat, pixType = gl.UnsignedShort565, gl.RGB
case UnsignedInt8888Rev:
opt.pixFormat = gl.UnsignedInt8888Rev
opt.pixType = gl.BGRA
pixFormat, pixType = gl.UnsignedInt8888Rev, gl.BGRA
default:
return errors.New("unknown pixel format")
}
return nil
}
func GetGLVersionInfo() string { return get(gl.VERSION) }
func GetGLVendorInfo() string { return get(gl.VENDOR) }
func GetGLRendererInfo() string { return get(gl.RENDERER) }
func GetGLSLInfo() string { return get(gl.ShadingLanguageVersion) }
func GetGLError() uint32 { return gl.GetError() }
func GLInfo() (version, vendor, renderer, glsl string) {
return gl.GoStr(gl.GetString(gl.VERSION)),
gl.GoStr(gl.GetString(gl.VENDOR)),
gl.GoStr(gl.GetString(gl.RENDERER)),
gl.GoStr(gl.GetString(gl.ShadingLanguageVersion))
}
func get(name uint32) string { return gl.GoStr(gl.GetString(name)) }
func GlFbo() uint32 { return fbo }

View file

@ -4,21 +4,17 @@ import (
"fmt"
"unsafe"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/worker/thread"
"github.com/veandco/go-sdl2/sdl"
)
type SDL struct {
glWCtx sdl.GLContext
w *sdl.Window
log *logger.Logger
w *sdl.Window
ctx sdl.GLContext
}
type Config struct {
Ctx Context
W int
H int
W, H int
GLAutoContext bool
GLVersionMajor uint
GLVersionMinor uint
@ -26,114 +22,79 @@ type Config struct {
GLHasStencil bool
}
// NewSDLContext initializes SDL/OpenGL context.
// Uses main thread lock (see thread/mainthread).
func NewSDLContext(cfg Config, log *logger.Logger) (*SDL, error) {
log.Debug().Msg("[SDL/OpenGL] initialization...")
func NewSDLContext(cfg Config) (*SDL, error) {
if err := sdl.Init(sdl.INIT_VIDEO); err != nil {
return nil, fmt.Errorf("SDL initialization fail: %w", err)
return nil, fmt.Errorf("sdl: %w", err)
}
display := SDL{log: log}
if cfg.GLAutoContext {
log.Debug().Msgf("[OpenGL] CONTEXT_AUTO (type: %v v%v.%v)", cfg.Ctx, cfg.GLVersionMajor, cfg.GLVersionMinor)
} else {
switch cfg.Ctx {
case CtxOpenGlCore:
display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE)
log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_CORE")
case CtxOpenGlEs2:
display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_ES)
display.setAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, 3)
display.setAttribute(sdl.GL_CONTEXT_MINOR_VERSION, 0)
log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_ES 3.0")
case CtxOpenGl:
if cfg.GLVersionMajor >= 3 {
display.setAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_COMPATIBILITY)
}
log.Debug().Msgf("[OpenGL] CONTEXT_PROFILE_COMPATIBILITY")
default:
log.Error().Msgf("[OpenGL] Unsupported hw context: %v", cfg.Ctx)
if !cfg.GLAutoContext {
if err := setGLAttrs(cfg.Ctx); err != nil {
return nil, err
}
}
var err error
// In OSX 10.14+ window creation and context creation must happen in the main thread
thread.Main(func() { display.w, display.glWCtx, err = createWindow() })
w, err := sdl.CreateWindow("cloud-retro", sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, 1, 1, sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN)
if err != nil {
return nil, fmt.Errorf("window fail: %w", err)
return nil, fmt.Errorf("window: %w", err)
}
if err := display.BindContext(); err != nil {
return nil, fmt.Errorf("bind context fail: %w", err)
ctx, err := w.GLCreateContext()
if err != nil {
err1 := w.Destroy()
return nil, fmt.Errorf("gl context: %w, destroy err: %w", err, err1)
}
if err = w.GLMakeCurrent(ctx); err != nil {
return nil, fmt.Errorf("gl bind: %w", err)
}
initContext(sdl.GLGetProcAddress)
if err := initFramebuffer(cfg.W, cfg.H, cfg.GLHasDepth, cfg.GLHasStencil); err != nil {
return nil, fmt.Errorf("OpenGL initialization fail: %w", err)
if err = initFramebuffer(cfg.W, cfg.H, cfg.GLHasDepth, cfg.GLHasStencil); err != nil {
return nil, fmt.Errorf("fbo: %w", err)
}
return &SDL{w: w, ctx: ctx}, nil
}
func setGLAttrs(ctx Context) error {
set := sdl.GLSetAttribute
switch ctx {
case CtxOpenGlCore:
return set(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE)
case CtxOpenGlEs2:
for _, a := range [][2]int{
{sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_ES},
{sdl.GL_CONTEXT_MAJOR_VERSION, 3},
{sdl.GL_CONTEXT_MINOR_VERSION, 0},
} {
if err := set(sdl.GLattr(a[0]), a[1]); err != nil {
return err
}
}
return nil
case CtxOpenGl:
return set(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_COMPATIBILITY)
default:
return fmt.Errorf("unsupported gl context: %v", ctx)
}
return &display, nil
}
// Deinit destroys SDL/OpenGL context.
// Uses main thread lock (see thread/mainthread).
func (s *SDL) Deinit() error {
s.log.Debug().Msg("[SDL/OpenGL] shutdown...")
destroyFramebuffer()
var err error
// In OSX 10.14+ window deletion must happen in the main thread
thread.Main(func() {
err = s.destroyWindow()
})
if err != nil {
return fmt.Errorf("[SDL/OpenGL] deinit fail: %w", err)
}
sdl.GLDeleteContext(s.ctx)
err := s.w.Destroy()
sdl.Quit()
s.log.Debug().Msgf("[SDL/OpenGL] shutdown codes:(%v, %v)", sdl.GetError(), GetGLError())
return nil
return err
}
// createWindow creates a fake SDL window just for OpenGL initialization purposes.
func createWindow() (*sdl.Window, sdl.GLContext, error) {
w, err := sdl.CreateWindow(
"CloudRetro dummy window",
sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED,
1, 1,
sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN,
)
if err != nil {
return nil, nil, fmt.Errorf("window creation fail: %w", err)
}
glWCtx, err := w.GLCreateContext()
if err != nil {
return nil, nil, fmt.Errorf("window OpenGL context fail: %w", err)
}
return w, glWCtx, nil
}
func (s *SDL) BindContext() error { return s.w.GLMakeCurrent(s.ctx) }
func GlProcAddress(proc string) unsafe.Pointer { return sdl.GLGetProcAddress(proc) }
// destroyWindow destroys previously created SDL window.
func (s *SDL) destroyWindow() error {
if err := s.BindContext(); err != nil {
func TryInit() error {
if err := sdl.Init(sdl.INIT_VIDEO); err != nil {
return err
}
sdl.GLDeleteContext(s.glWCtx)
if err := s.w.Destroy(); err != nil {
return fmt.Errorf("window destroy fail: %w", err)
}
sdl.Quit()
return nil
}
// BindContext explicitly binds context to current thread.
func (s *SDL) BindContext() error { return s.w.GLMakeCurrent(s.glWCtx) }
// setAttribute tries to set a GL attribute or prints error.
func (s *SDL) setAttribute(attr sdl.GLattr, value int) {
if err := sdl.GLSetAttribute(attr, value); err != nil {
s.log.Error().Err(err).Msg("[SDL] attribute")
}
}
func GetGlFbo() uint32 { return getFbo() }
func GetGlProcAddress(proc string) unsafe.Pointer { return sdl.GLGetProcAddress(proc) }

View file

@ -47,11 +47,15 @@ func (d GrabDownloader) Request(dest string, urls ...Download) (ok []string, noo
r := resp.Request
if err := resp.Err(); err != nil {
d.log.Error().Err(err).Msgf("download [%s] %s has failed: %v", r.Label, r.URL(), err)
if resp.HTTPResponse.StatusCode == 404 {
if resp.HTTPResponse != nil && resp.HTTPResponse.StatusCode == 404 {
nook = append(nook, resp.Request.Label)
}
} else {
d.log.Info().Msgf("Downloaded [%v] [%s] -> %s", resp.HTTPResponse.Status, r.Label, resp.Filename)
status := ""
if resp.HTTPResponse != nil {
status = resp.HTTPResponse.Status
}
d.log.Info().Msgf("Downloaded [%v] [%s] -> %s", status, r.Label, resp.Filename)
ok = append(ok, resp.Filename)
}
}

View file

@ -1,64 +1,50 @@
package manager
import (
"os"
"path/filepath"
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch"
"github.com/gofrs/flock"
"github.com/giongto35/cloud-game/v3/pkg/os"
)
type Manager struct {
BasicManager
arch arch.Info
repo repo.Repository
altRepo repo.Repository
arch ArchInfo
repo Repository
altRepo Repository
client Downloader
fmu *flock.Flock
fmu *os.Flock
log *logger.Logger
}
func NewRemoteHttpManager(conf config.LibretroConfig, log *logger.Logger) Manager {
repoConf := conf.Cores.Repo.Main
altRepoConf := conf.Cores.Repo.Secondary
// used for synchronization of multiple process
fileLock := conf.Cores.Repo.ExtLock
if fileLock == "" {
fileLock = os.TempDir() + string(os.PathSeparator) + "cloud_game.lock"
}
log.Debug().Msgf("Using .lock file: %v", fileLock)
if err := os.MkdirAll(filepath.Dir(fileLock), 0770); err != nil {
log.Error().Err(err).Msgf("couldn't create lock")
} else {
f, err := os.Create(fileLock)
if err != nil {
log.Error().Err(err).Msgf("couldn't create lock")
}
_ = f.Close()
// used for synchronization of multiple process
flock, err := os.NewFileLock(conf.Cores.Repo.ExtLock)
if err != nil {
log.Error().Err(err).Msgf("couldn't make file lock")
}
ar, err := arch.Guess()
arch, err := conf.Cores.Repo.Guess()
if err != nil {
log.Error().Err(err).Msg("couldn't get Libretro core file extension")
}
m := Manager{
BasicManager: BasicManager{Conf: conf},
arch: ar,
arch: ArchInfo(arch),
client: NewDefaultDownloader(log),
fmu: flock.New(fileLock),
fmu: flock,
log: log,
}
if repoConf.Type != "" {
m.repo = repo.New(repoConf.Type, repoConf.Url, repoConf.Compression, "buildbot")
m.repo = NewRepo(repoConf.Type, repoConf.Url, repoConf.Compression, "buildbot")
}
if altRepoConf.Type != "" {
m.altRepo = repo.New(altRepoConf.Type, altRepoConf.Url, altRepoConf.Compression, "")
m.altRepo = NewRepo(altRepoConf.Type, altRepoConf.Url, altRepoConf.Compression, "")
}
return m
@ -71,8 +57,7 @@ func CheckCores(conf config.Emulator, log *logger.Logger) error {
log.Info().Msg("Starting Libretro cores sync...")
coreManager := NewRemoteHttpManager(conf.Libretro, log)
// make a dir for cores
dir := coreManager.Conf.GetCoresStorePath()
if err := os.MkdirAll(dir, os.ModeDir|os.ModePerm); err != nil {
if err := os.MakeDirAll(coreManager.Conf.GetCoresStorePath()); err != nil {
return err
}
if err := coreManager.Sync(); err != nil {
@ -94,7 +79,7 @@ func (m *Manager) Sync() error {
}
}()
installed, err := m.GetInstalled(m.arch.LibExt)
installed, err := m.GetInstalled(m.arch.Ext)
if err != nil {
return err
}
@ -105,9 +90,9 @@ func (m *Manager) Sync() error {
return nil
}
func (m *Manager) getCoreUrls(names []string, repo repo.Repository) (urls []Download) {
func (m *Manager) getCoreUrls(names []string, repo Repository) (urls []Download) {
for _, c := range names {
urls = append(urls, Download{Key: c, Address: repo.GetCoreUrl(c, m.arch)})
urls = append(urls, Download{Key: c, Address: repo.CoreUrl(c, m.arch)})
}
return
}
@ -150,7 +135,7 @@ func (m *Manager) download(cores []config.CoreInfo) (failed []string) {
return
}
func (m *Manager) down(cores []string, repo repo.Repository) (failed []string) {
func (m *Manager) down(cores []string, repo Repository) (failed []string) {
if len(cores) == 0 || repo == nil {
return
}

View file

@ -0,0 +1,65 @@
package manager
import "strings"
type ArchInfo struct {
Arch string
Ext string
Os string
Vendor string
}
type Data struct {
Url string
Compression string
}
type Repository interface {
CoreUrl(file string, info ArchInfo) (url string)
}
// Repo defines a simple zip file containing all the cores that will be extracted as is.
type Repo struct {
Address string
Compression string
}
func (r Repo) CoreUrl(_ string, _ ArchInfo) string { return r.Address }
type Buildbot struct{ Repo }
func (r Buildbot) CoreUrl(file string, info ArchInfo) string {
var sb strings.Builder
sb.WriteString(r.Address + "/")
if info.Vendor != "" {
sb.WriteString(info.Vendor + "/")
}
sb.WriteString(info.Os + "/" + info.Arch + "/latest/" + file + info.Ext)
if r.Compression != "" {
sb.WriteString("." + r.Compression)
}
return sb.String()
}
type Github struct{ Buildbot }
func (r Github) CoreUrl(file string, info ArchInfo) string {
return r.Buildbot.CoreUrl(file, info) + "?raw=true"
}
func NewRepo(kind string, url string, compression string, defaultRepo string) Repository {
var repository Repository
switch kind {
case "buildbot":
repository = Buildbot{Repo{Address: url, Compression: compression}}
case "github":
repository = Github{Buildbot{Repo{Address: url, Compression: compression}}}
case "raw":
repository = Repo{Address: url, Compression: "zip"}
default:
if defaultRepo != "" {
repository = NewRepo(defaultRepo, url, compression, "")
}
}
return repository
}

View file

@ -0,0 +1,61 @@
package manager
import "testing"
func TestCoreUrl(t *testing.T) {
testAddress := "https://test.me"
tests := []struct {
arch ArchInfo
compress string
f string
repo string
result string
}{
{
arch: ArchInfo{Arch: "x86_64", Ext: ".so", Os: "linux"},
f: "uber_core",
repo: "buildbot",
result: testAddress + "/" + "linux/x86_64/latest/uber_core.so",
},
{
arch: ArchInfo{Arch: "x86_64", Ext: ".so", Os: "linux"},
compress: "zip",
f: "uber_core",
repo: "buildbot",
result: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip",
},
{
arch: ArchInfo{Arch: "x86_64", Ext: ".dylib", Os: "osx", Vendor: "apple"},
f: "uber_core",
repo: "buildbot",
result: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib",
},
{
arch: ArchInfo{Os: "linux", Arch: "x86_64", Ext: ".so"},
f: "uber_core",
repo: "github",
result: testAddress + "/" + "linux/x86_64/latest/uber_core.so?raw=true",
},
{
arch: ArchInfo{Os: "linux", Arch: "x86_64", Ext: ".so"},
compress: "zip",
f: "uber_core",
repo: "github",
result: testAddress + "/" + "linux/x86_64/latest/uber_core.so.zip?raw=true",
},
{
arch: ArchInfo{Os: "osx", Arch: "x86_64", Vendor: "apple", Ext: ".dylib"},
f: "uber_core",
repo: "github",
result: testAddress + "/" + "apple/osx/x86_64/latest/uber_core.dylib?raw=true",
},
}
for _, test := range tests {
r := NewRepo(test.repo, testAddress, test.compress, "")
url := r.CoreUrl(test.f, test.arch)
if url != test.result {
t.Errorf("seems that expected link address is incorrect (%v) for file %s %+v", url, test.f, test.arch)
}
}
}

View file

@ -0,0 +1,167 @@
package nanoarch
import (
"encoding/binary"
"sync/atomic"
)
/*
#include <stdint.h>
#include "libretro.h"
void input_cache_set_port(unsigned port, uint32_t buttons,
int16_t lx, int16_t ly, int16_t rx, int16_t ry,
int16_t l2, int16_t r2);
void input_cache_set_keyboard_key(unsigned id, uint8_t pressed);
void input_cache_set_mouse(int16_t dx, int16_t dy, uint8_t buttons);
void input_cache_clear(void);
*/
import "C"
const (
maxPort = 4
numAxes = 4
RetrokLast = int(C.RETROK_LAST)
)
type Device byte
const (
RetroPad Device = iota
Keyboard
Mouse
)
const (
MouseMove = iota
MouseButton
)
type MouseBtnState int32
const (
MouseLeft MouseBtnState = 1 << iota
MouseRight
MouseMiddle
)
// InputState stores controller state for all ports.
// - uint16 button bitmask
// - int16 analog axes x4 (left stick, right stick)
// - int16 analog triggers x2 (L2, R2)
type InputState [maxPort]struct {
keys uint32 // lower 16 bits used
axes int64 // packed: [LX:16][LY:16][RX:16][RY:16]
triggers int32 // packed: [L2:16][R2:16]
}
// SetInput sets input state for a player.
//
// [BTN:2][LX:2][LY:2][RX:2][RY:2][L2:2][R2:2]
func (s *InputState) SetInput(port int, data []byte) {
if len(data) < 2 {
return
}
// Buttons
atomic.StoreUint32(&s[port].keys, uint32(binary.LittleEndian.Uint16(data)))
// Axes - pack into int64
var packedAxes int64
for i := 0; i < numAxes && i*2+3 < len(data); i++ {
axis := int64(int16(binary.LittleEndian.Uint16(data[i*2+2:])))
packedAxes |= (axis & 0xFFFF) << (i * 16)
}
atomic.StoreInt64(&s[port].axes, packedAxes)
// Analog triggers L2, R2 - pack into int32
if len(data) >= 14 {
l2 := int32(int16(binary.LittleEndian.Uint16(data[10:])))
r2 := int32(int16(binary.LittleEndian.Uint16(data[12:])))
atomic.StoreInt32(&s[port].triggers, (l2&0xFFFF)|((r2&0xFFFF)<<16))
}
}
// SyncToCache syncs input state to C-side cache before Run().
func (s *InputState) SyncToCache() {
for p := uint(0); p < maxPort; p++ {
keys := atomic.LoadUint32(&s[p].keys)
axes := atomic.LoadInt64(&s[p].axes)
triggers := atomic.LoadInt32(&s[p].triggers)
C.input_cache_set_port(C.uint(p), C.uint32_t(keys),
C.int16_t(axes),
C.int16_t(axes>>16),
C.int16_t(axes>>32),
C.int16_t(axes>>48),
C.int16_t(triggers),
C.int16_t(triggers>>16))
}
}
// KeyboardState tracks keys of the keyboard.
type KeyboardState struct {
keys [6]atomic.Uint64 // 342 keys packed into 6 uint64s (384 bits)
mod atomic.Uint32
}
// SetKey sets keyboard state.
//
// [KEY:4][P:1][MOD:2]
//
// KEY - Libretro key code, P - pressed (0/1), MOD - modifier bitmask
func (ks *KeyboardState) SetKey(data []byte) (pressed bool, key uint, mod uint16) {
if len(data) != 7 {
return
}
key = uint(binary.BigEndian.Uint32(data))
mod = binary.BigEndian.Uint16(data[5:])
pressed = data[4] == 1
idx, bit := key/64, uint64(1)<<(key%64)
if pressed {
ks.keys[idx].Or(bit)
} else {
ks.keys[idx].And(^bit)
}
ks.mod.Store(uint32(mod))
return
}
// SyncToCache syncs keyboard state to C-side cache.
func (ks *KeyboardState) SyncToCache() {
for id := 0; id < RetrokLast; id++ {
pressed := (ks.keys[id/64].Load() >> (id % 64)) & 1
C.input_cache_set_keyboard_key(C.uint(id), C.uint8_t(pressed))
}
}
// MouseState tracks mouse delta and buttons.
type MouseState struct {
dx, dy atomic.Int32
buttons atomic.Int32
}
// ShiftPos adds relative mouse movement.
//
// [dx:2][dy:2]
func (ms *MouseState) ShiftPos(data []byte) {
if len(data) != 4 {
return
}
ms.dx.Add(int32(int16(binary.BigEndian.Uint16(data[:2]))))
ms.dy.Add(int32(int16(binary.BigEndian.Uint16(data[2:]))))
}
func (ms *MouseState) SetButtons(b byte) { ms.buttons.Store(int32(b)) }
func (ms *MouseState) Buttons() (l, r, m bool) {
b := MouseBtnState(ms.buttons.Load())
return b&MouseLeft != 0, b&MouseRight != 0, b&MouseMiddle != 0
}
// SyncToCache syncs mouse state to C-side cache, consuming deltas.
func (ms *MouseState) SyncToCache() {
C.input_cache_set_mouse(C.int16_t(ms.dx.Swap(0)), C.int16_t(ms.dy.Swap(0)), C.uint8_t(ms.buttons.Load()))
}

View file

@ -0,0 +1,514 @@
package nanoarch
import (
"encoding/binary"
"math/rand"
"sync"
"testing"
)
func TestInputState_SetInput(t *testing.T) {
tests := []struct {
name string
port int
data []byte
keys uint32
axes [4]int16
triggers [2]int16
}{
{
name: "buttons only",
port: 0,
data: []byte{0xFF, 0x01},
keys: 0x01FF,
},
{
name: "buttons and axes",
port: 1,
data: []byte{0x03, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x00, 0x80, 0xFF, 0x7F},
keys: 0x0003,
axes: [4]int16{10000, -10000, -32768, 32767},
},
{
name: "partial axes",
port: 2,
data: []byte{0x01, 0x00, 0x64, 0x00},
keys: 0x0001,
axes: [4]int16{100, 0, 0, 0},
},
{
name: "max port",
port: 3,
data: []byte{0xFF, 0xFF},
keys: 0xFFFF,
},
{
name: "full input with triggers",
port: 0,
data: []byte{
0x03, 0x00, // buttons
0x10, 0x27, // LX: 10000
0xF0, 0xD8, // LY: -10000
0x00, 0x80, // RX: -32768
0xFF, 0x7F, // RY: 32767
0xFF, 0x3F, // L2: 16383
0xFF, 0x7F, // R2: 32767
},
keys: 0x0003,
axes: [4]int16{10000, -10000, -32768, 32767},
triggers: [2]int16{16383, 32767},
},
{
name: "axes without triggers",
port: 1,
data: []byte{
0x01, 0x00,
0x64, 0x00, // LX: 100
0xC8, 0x00, // LY: 200
0x2C, 0x01, // RX: 300
0x90, 0x01, // RY: 400
},
keys: 0x0001,
axes: [4]int16{100, 200, 300, 400},
},
{
name: "zero triggers",
port: 2,
data: []byte{
0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, // L2: 0
0x00, 0x00, // R2: 0
},
keys: 0x0000,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
state := InputState{}
state.SetInput(test.port, test.data)
if state[test.port].keys != test.keys {
t.Errorf("keys: got %v, want %v", state[test.port].keys, test.keys)
}
// Check axes from packed int64
axes := state[test.port].axes
for i, want := range test.axes {
got := int16(axes >> (i * 16))
if got != want {
t.Errorf("axes[%d]: got %v, want %v", i, got, want)
}
}
// Check triggers from packed int32
triggers := state[test.port].triggers
l2 := int16(triggers)
r2 := int16(triggers >> 16)
if l2 != test.triggers[0] {
t.Errorf("L2: got %v, want %v", l2, test.triggers[0])
}
if r2 != test.triggers[1] {
t.Errorf("R2: got %v, want %v", r2, test.triggers[1])
}
})
}
}
func TestInputState_AxisExtraction(t *testing.T) {
state := InputState{}
data := []byte{
0x00, 0x00, // buttons
0x01, 0x00, // LX: 1
0x02, 0x00, // LY: 2
0x03, 0x00, // RX: 3
0x04, 0x00, // RY: 4
0x05, 0x00, // L2: 5
0x06, 0x00, // R2: 6
}
state.SetInput(0, data)
axes := state[0].axes
expected := []int16{1, 2, 3, 4}
for i, want := range expected {
got := int16(axes >> (i * 16))
if got != want {
t.Errorf("axis[%d]: got %v, want %v", i, got, want)
}
}
triggers := state[0].triggers
if got := int16(triggers); got != 5 {
t.Errorf("L2: got %v, want 5", got)
}
if got := int16(triggers >> 16); got != 6 {
t.Errorf("R2: got %v, want 6", got)
}
}
func TestInputState_NegativeAxes(t *testing.T) {
state := InputState{}
data := []byte{
0x00, 0x00, // buttons
0x00, 0x80, // LX: -32768
0xFF, 0xFF, // LY: -1
0x01, 0x80, // RX: -32767
0xFE, 0xFF, // RY: -2
}
state.SetInput(0, data)
axes := state[0].axes
expected := []int16{-32768, -1, -32767, -2}
for i, want := range expected {
got := int16(axes >> (i * 16))
if got != want {
t.Errorf("axis[%d]: got %v, want %v", i, got, want)
}
}
}
func TestInputState_Concurrent(t *testing.T) {
var wg sync.WaitGroup
state := InputState{}
events := 1000
wg.Add(events)
for range events {
player := rand.Intn(maxPort)
go func() {
// Full 14-byte input
state.SetInput(player, []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0})
wg.Done()
}()
}
wg.Wait()
}
func TestKeyboardState_SetKey(t *testing.T) {
tests := []struct {
name string
data []byte
pressed bool
key uint
mod uint16
}{
{
name: "key pressed",
data: []byte{0, 0, 0, 42, 1, 0, 3},
pressed: true,
key: 42,
mod: 3,
},
{
name: "key released",
data: []byte{0, 0, 0, 100, 0, 0, 0},
pressed: false,
key: 100,
mod: 0,
},
{
name: "high key code",
data: []byte{0, 0, 1, 50, 1, 0xFF, 0xFF},
pressed: true,
key: 306,
mod: 0xFFFF,
},
{
name: "invalid length",
data: []byte{0, 0, 0},
pressed: false,
key: 0,
mod: 0,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ks := KeyboardState{}
pressed, key, mod := ks.SetKey(test.data)
if pressed != test.pressed {
t.Errorf("pressed: got %v, want %v", pressed, test.pressed)
}
if key != test.key {
t.Errorf("key: got %v, want %v", key, test.key)
}
if mod != test.mod {
t.Errorf("mod: got %v, want %v", mod, test.mod)
}
})
}
}
func TestKeyboardState_IsPressed(t *testing.T) {
ks := KeyboardState{}
// Initially not pressed
if ks.keys[0].Load() != 0 {
t.Error("key should not be pressed initially")
}
// Press key
ks.SetKey([]byte{0, 0, 0, 42, 1, 0, 0})
if (ks.keys[42/64].Load()>>(42%64))&1 != 1 {
t.Error("key should be pressed")
}
// Release key
ks.SetKey([]byte{0, 0, 0, 42, 0, 0, 0})
if (ks.keys[42/64].Load()>>(42%64))&1 != 0 {
t.Error("key should be released")
}
}
func TestKeyboardState_MultipleBits(t *testing.T) {
ks := KeyboardState{}
// Press keys in different uint64 slots
keys := []uint{0, 63, 64, 127, 128, 200, 300, 341}
for _, k := range keys {
data := make([]byte, 7)
binary.BigEndian.PutUint32(data, uint32(k))
data[4] = 1
ks.SetKey(data)
}
// Check all pressed
for _, k := range keys {
if (ks.keys[k/64].Load()>>(k%64))&1 != 1 {
t.Errorf("key %d should be pressed", k)
}
}
// Release some
for _, k := range []uint{0, 128, 341} {
data := make([]byte, 7)
binary.BigEndian.PutUint32(data, uint32(k))
data[4] = 0
ks.SetKey(data)
}
// Check states
expected := map[uint]uint64{
0: 0, 63: 1, 64: 1, 127: 1, 128: 0, 200: 1, 300: 1, 341: 0,
}
for k, want := range expected {
got := (ks.keys[k/64].Load() >> (k % 64)) & 1
if got != want {
t.Errorf("key %d: got %v, want %v", k, got, want)
}
}
}
func TestKeyboardState_Concurrent(t *testing.T) {
var wg sync.WaitGroup
ks := KeyboardState{}
events := 1000
wg.Add(events * 2)
for range events {
key := uint(rand.Intn(RetrokLast))
go func() {
data := make([]byte, 7)
binary.BigEndian.PutUint32(data, uint32(key))
data[4] = byte(rand.Intn(2))
ks.SetKey(data)
wg.Done()
}()
go func() {
_ = (ks.keys[key/64].Load() >> (key % 64)) & 1
wg.Done()
}()
}
wg.Wait()
}
func TestMouseState_ShiftPos(t *testing.T) {
tests := []struct {
name string
dx int16
dy int16
rx int16
ry int16
b func(dx, dy int16) []byte
}{
{
name: "positive values",
dx: 100,
dy: 200,
rx: 100,
ry: 200,
b: func(dx, dy int16) []byte {
data := make([]byte, 4)
binary.BigEndian.PutUint16(data, uint16(dx))
binary.BigEndian.PutUint16(data[2:], uint16(dy))
return data
},
},
{
name: "negative values",
dx: -10123,
dy: 5678,
rx: -10123,
ry: 5678,
b: func(dx, dy int16) []byte {
data := make([]byte, 4)
binary.BigEndian.PutUint16(data, uint16(dx))
binary.BigEndian.PutUint16(data[2:], uint16(dy))
return data
},
},
{
name: "wrong endian",
dx: -1234,
dy: 5678,
rx: 12027,
ry: 11798,
b: func(dx, dy int16) []byte {
data := make([]byte, 4)
binary.LittleEndian.PutUint16(data, uint16(dx))
binary.LittleEndian.PutUint16(data[2:], uint16(dy))
return data
},
},
{
name: "max values",
dx: 32767,
dy: -32768,
rx: 32767,
ry: -32768,
b: func(dx, dy int16) []byte {
data := make([]byte, 4)
binary.BigEndian.PutUint16(data, uint16(dx))
binary.BigEndian.PutUint16(data[2:], uint16(dy))
return data
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ms := MouseState{}
ms.ShiftPos(test.b(test.dx, test.dy))
x, y := int16(ms.dx.Swap(0)), int16(ms.dy.Swap(0))
if x != test.rx || y != test.ry {
t.Errorf("got (%v, %v), want (%v, %v)", x, y, test.rx, test.ry)
}
if ms.dx.Load() != 0 || ms.dy.Load() != 0 {
t.Error("coordinates weren't cleared")
}
})
}
}
func TestMouseState_ShiftPosAccumulates(t *testing.T) {
ms := MouseState{}
data := make([]byte, 4)
binary.BigEndian.PutUint16(data, uint16(10))
binary.BigEndian.PutUint16(data[2:], uint16(20))
ms.ShiftPos(data)
ms.ShiftPos(data)
ms.ShiftPos(data)
if got := ms.dx.Load(); got != 30 {
t.Errorf("dx: got %v, want 30", got)
}
if got := ms.dy.Load(); got != 60 {
t.Errorf("dy: got %v, want 60", got)
}
}
func TestMouseState_ShiftPosInvalidLength(t *testing.T) {
ms := MouseState{}
ms.ShiftPos([]byte{1, 2, 3})
ms.ShiftPos([]byte{1, 2, 3, 4, 5})
if ms.dx.Load() != 0 || ms.dy.Load() != 0 {
t.Error("invalid data should be ignored")
}
}
func TestMouseState_Buttons(t *testing.T) {
tests := []struct {
name string
data byte
l bool
r bool
m bool
}{
{name: "none", data: 0},
{name: "left", data: 1, l: true},
{name: "right", data: 2, r: true},
{name: "middle", data: 4, m: true},
{name: "left+right", data: 3, l: true, r: true},
{name: "all", data: 7, l: true, r: true, m: true},
{name: "left+middle", data: 5, l: true, m: true},
}
ms := MouseState{}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ms.SetButtons(test.data)
l, r, m := ms.Buttons()
if l != test.l || r != test.r || m != test.m {
t.Errorf("got (%v, %v, %v), want (%v, %v, %v)", l, r, m, test.l, test.r, test.m)
}
})
}
}
func TestMouseState_Concurrent(t *testing.T) {
var wg sync.WaitGroup
ms := MouseState{}
events := 1000
wg.Add(events * 3)
for range events {
go func() {
data := make([]byte, 4)
binary.BigEndian.PutUint16(data, uint16(rand.Int31n(100)-50))
binary.BigEndian.PutUint16(data[2:], uint16(rand.Int31n(100)-50))
ms.ShiftPos(data)
wg.Done()
}()
go func() {
ms.SetButtons(byte(rand.Intn(8)))
wg.Done()
}()
go func() {
ms.Buttons()
wg.Done()
}()
}
wg.Wait()
}
func TestConstants(t *testing.T) {
// MouseBtnState
if MouseLeft != 1 || MouseRight != 2 || MouseMiddle != 4 {
t.Error("invalid MouseBtnState constants")
}
// Device
if RetroPad != 0 || Keyboard != 1 || Mouse != 2 {
t.Error("invalid Device constants")
}
// Mouse events
if MouseMove != 0 || MouseButton != 1 {
t.Error("invalid mouse event constants")
}
// Limits
if maxPort != 4 || numAxes != 4 || RetrokLast != 342 {
t.Error("invalid limit constants")
}
}

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,11 @@ import "C"
func loadFunction(handle unsafe.Pointer, name string) unsafe.Pointer {
cs := C.CString(name)
defer C.free(unsafe.Pointer(cs))
return C.dlsym(handle, cs)
ptr := C.dlsym(handle, cs)
if ptr == nil {
panic("lib function not found: " + name)
}
return ptr
}
func loadLib(filepath string) (handle unsafe.Pointer, err error) {

View file

@ -3,15 +3,18 @@
#include <stdbool.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#define RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB (3 | 0x800000)
int initialized = 0;
typedef struct {
int type;
void* fn;
void* arg1;
void* arg2;
void* result;
int type;
void* fn;
void* arg1;
void* arg2;
void* result;
} call_def_t;
call_def_t call;
@ -24,6 +27,57 @@ enum call_type {
void *same_thread_with_args(void *f, int type, ...);
// Input State Cache
#define INPUT_MAX_PORTS 4
#define INPUT_MAX_KEYS 512
typedef struct {
uint32_t buttons[INPUT_MAX_PORTS];
int16_t analog[INPUT_MAX_PORTS][4]; // LX, LY, RX, RY
int16_t triggers[INPUT_MAX_PORTS][2]; // L2, R2
uint8_t keyboard[INPUT_MAX_KEYS];
int16_t mouse_x;
int16_t mouse_y;
uint8_t mouse_buttons;
} input_cache_t;
static input_cache_t input_cache = {0};
// Update entire port state at once
void input_cache_set_port(unsigned port, uint32_t buttons,
int16_t lx, int16_t ly, int16_t rx, int16_t ry,
int16_t l2, int16_t r2) {
if (port < INPUT_MAX_PORTS) {
input_cache.buttons[port] = buttons;
input_cache.analog[port][0] = lx;
input_cache.analog[port][1] = ly;
input_cache.analog[port][2] = rx;
input_cache.analog[port][3] = ry;
input_cache.triggers[port][0] = l2;
input_cache.triggers[port][1] = r2;
}
}
// Keyboard update
void input_cache_set_keyboard_key(unsigned id, uint8_t pressed) {
if (id < INPUT_MAX_KEYS) {
input_cache.keyboard[id] = pressed;
}
}
// Mouse update
void input_cache_set_mouse(int16_t dx, int16_t dy, uint8_t buttons) {
input_cache.mouse_x = dx;
input_cache.mouse_y = dy;
input_cache.mouse_buttons = buttons;
}
void input_cache_clear(void) {
memset(&input_cache, 0, sizeof(input_cache));
}
void core_log_cgo(enum retro_log_level level, const char *fmt, ...) {
char msg[2048] = {0};
va_list va;
@ -34,14 +88,12 @@ void core_log_cgo(enum retro_log_level level, const char *fmt, ...) {
coreLog(level, msg);
}
void bridge_retro_init(void *f) {
core_log_cgo(RETRO_LOG_DEBUG, "Initialization...\n");
void bridge_call(void *f) {
((void (*)(void)) f)();
}
void bridge_retro_deinit(void *f) {
core_log_cgo(RETRO_LOG_DEBUG, "Deinitialiazation...\n");
((void (*)(void)) f)();
void bridge_set_callback(void *f, void *callback) {
((void (*)(void *))f)(callback);
}
unsigned bridge_retro_api_version(void *f) {
@ -60,40 +112,14 @@ bool bridge_retro_set_environment(void *f, void *callback) {
return ((bool (*)(retro_environment_t)) f)((retro_environment_t) callback);
}
void bridge_retro_set_video_refresh(void *f, void *callback) {
((bool (*)(retro_video_refresh_t)) f)((retro_video_refresh_t) callback);
}
void bridge_retro_set_input_poll(void *f, void *callback) {
((bool (*)(retro_input_poll_t)) f)((retro_input_poll_t) callback);
}
void bridge_retro_set_input_state(void *f, void *callback) {
((bool (*)(retro_input_state_t)) f)((retro_input_state_t) callback);
}
void bridge_retro_set_audio_sample(void *f, void *callback) {
((bool (*)(retro_audio_sample_t)) f)((retro_audio_sample_t) callback);
}
void bridge_retro_set_audio_sample_batch(void *f, void *callback) {
((bool (*)(retro_audio_sample_batch_t)) f)((retro_audio_sample_batch_t) callback);
((int16_t (*)(retro_input_state_t)) f)((retro_input_state_t) callback);
}
bool bridge_retro_load_game(void *f, struct retro_game_info *gi) {
core_log_cgo(RETRO_LOG_DEBUG, "Loading the game...\n");
return ((bool (*)(struct retro_game_info *)) f)(gi);
}
void bridge_retro_unload_game(void *f) {
core_log_cgo(RETRO_LOG_DEBUG, "Unloading the game...\n");
((void (*)(void)) f)();
}
void bridge_retro_run(void *f) {
((void (*)(void)) f)();
}
size_t bridge_retro_get_memory_size(void *f, unsigned id) {
return ((size_t (*)(unsigned)) f)(id);
}
@ -123,12 +149,41 @@ static bool clear_all_thread_waits_cb(unsigned v, void *data) {
return true;
}
void bridge_clear_all_thread_waits_cb(void *data) {
*(retro_environment_t *)data = clear_all_thread_waits_cb;
void bridge_retro_keyboard_callback(void *cb, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers) {
(*(retro_keyboard_event_t *) cb)(down, keycode, character, keyModifiers);
}
bool core_environment_cgo(unsigned cmd, void *data) {
bool coreEnvironment(unsigned, void *);
switch (cmd)
{
case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE:
return false;
break;
case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE:
return false;
break;
case RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB:
*(retro_environment_t *)data = clear_all_thread_waits_cb;
return true;
break;
case RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS:
*(unsigned *)data = 4;
core_log_cgo(RETRO_LOG_DEBUG, "Set max users: %d\n", 4);
return true;
break;
case RETRO_ENVIRONMENT_GET_INPUT_BITMASKS:
return false;
case RETRO_ENVIRONMENT_SHUTDOWN:
return false;
break;
case RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT:
if (data != NULL) *(int *)data = RETRO_SAVESTATE_CONTEXT_NORMAL;
return true;
break;
}
return coreEnvironment(cmd, data);
}
@ -138,18 +193,77 @@ void core_video_refresh_cgo(void *data, unsigned width, unsigned height, size_t
}
void core_input_poll_cgo() {
void coreInputPoll();
coreInputPoll();
}
int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, unsigned id) {
int16_t coreInputState(unsigned, unsigned, unsigned, unsigned);
return coreInputState(port, device, index, id);
}
if (port >= INPUT_MAX_PORTS) {
return 0;
}
void core_audio_sample_cgo(int16_t left, int16_t right) {
void coreAudioSample(int16_t, int16_t);
coreAudioSample(left, right);
switch (device) {
case RETRO_DEVICE_JOYPAD:
return (int16_t)((input_cache.buttons[port] >> id) & 1);
case RETRO_DEVICE_ANALOG:
switch (index) {
case RETRO_DEVICE_INDEX_ANALOG_LEFT:
// id: RETRO_DEVICE_ID_ANALOG_X=0, RETRO_DEVICE_ID_ANALOG_Y=1
if (id <= RETRO_DEVICE_ID_ANALOG_Y) {
return input_cache.analog[port][id];
}
break;
case RETRO_DEVICE_INDEX_ANALOG_RIGHT:
// id: RETRO_DEVICE_ID_ANALOG_X=0, RETRO_DEVICE_ID_ANALOG_Y=1
if (id <= RETRO_DEVICE_ID_ANALOG_Y) {
return input_cache.analog[port][2 + id];
}
break;
case RETRO_DEVICE_INDEX_ANALOG_BUTTON:
// Any button can be queried as analog
// id = RETRO_DEVICE_ID_JOYPAD_* (0-15)
// For now, only L2/R2 have analog values
switch (id) {
case RETRO_DEVICE_ID_JOYPAD_L2:
return input_cache.triggers[port][0];
case RETRO_DEVICE_ID_JOYPAD_R2:
return input_cache.triggers[port][1];
default:
// Other buttons: return digital as 0 or 0x7fff
return ((input_cache.buttons[port] >> id) & 1) ? 0x7FFF : 0;
}
break;
}
break;
case RETRO_DEVICE_KEYBOARD:
if (id < INPUT_MAX_KEYS) {
return input_cache.keyboard[id] ? 1 : 0;
}
break;
case RETRO_DEVICE_MOUSE:
switch (id) {
case RETRO_DEVICE_ID_MOUSE_X: {
int16_t x = input_cache.mouse_x;
input_cache.mouse_x = 0;
return x;
}
case RETRO_DEVICE_ID_MOUSE_Y: {
int16_t y = input_cache.mouse_y;
input_cache.mouse_y = 0;
return y;
}
case RETRO_DEVICE_ID_MOUSE_LEFT:
return (input_cache.mouse_buttons & 0x01) ? 1 : 0;
case RETRO_DEVICE_ID_MOUSE_RIGHT:
return (input_cache.mouse_buttons & 0x02) ? 1 : 0;
case RETRO_DEVICE_ID_MOUSE_MIDDLE:
return (input_cache.mouse_buttons & 0x04) ? 1 : 0;
}
break;
}
return 0;
}
size_t core_audio_sample_batch_cgo(const int16_t *data, size_t frames) {
@ -157,6 +271,11 @@ size_t core_audio_sample_batch_cgo(const int16_t *data, size_t frames) {
return coreAudioSampleBatch(data, frames);
}
void core_audio_sample_cgo(int16_t left, int16_t right) {
int16_t frame[2] = { left, right };
core_audio_sample_batch_cgo(frame, 1);
}
uintptr_t core_get_current_framebuffer_cgo() {
uintptr_t coreGetCurrentFramebuffer();
return coreGetCurrentFramebuffer();
@ -231,6 +350,7 @@ void *run_loop(void *unused) {
mutex_destroy(&done_mutex);
pthread_detach(thread);
core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop stop\n");
pthread_exit(NULL);
}
void same_thread_stop() {

View file

@ -3,8 +3,11 @@ package nanoarch
import (
"errors"
"fmt"
"maps"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
@ -12,7 +15,6 @@ import (
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/giongto35/cloud-game/v3/pkg/os"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch"
"github.com/giongto35/cloud-game/v3/pkg/worker/thread"
)
@ -20,18 +22,9 @@ import (
#include "libretro.h"
#include "nanoarch.h"
#include <stdlib.h>
#define RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB (3 | 0x800000)
*/
import "C"
const lastKey = int(C.RETRO_DEVICE_ID_JOYPAD_R3)
const KeyPressed = 1
const KeyReleased = 0
const MaxPort int = 4
var (
RGBA5551 = PixFmt{C: 0, BPP: 2} // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha
RGBA8888Rev = PixFmt{C: 1, BPP: 4} // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha
@ -40,20 +33,26 @@ var (
type Nanoarch struct {
Handlers
keyboard KeyboardState
mouse MouseState
retropad InputState
keyboardCb *C.struct_retro_keyboard_callback
LastFrameTime int64
LibCo bool
multitap struct {
supported bool
enabled bool
value C.unsigned
meta Metadata
options map[string]string
options4rom map[string]map[string]string
reserved chan struct{} // limits concurrent use
Rot uint
serializeSize C.size_t
Stopped atomic.Bool
sys struct {
av C.struct_retro_system_av_info
i C.struct_retro_system_info
api C.unsigned
}
options *map[string]string
reserved chan struct{} // limits concurrent use
Rot uint
serializeSize C.size_t
stopped atomic.Bool
sysAvInfo C.struct_retro_system_av_info
sysInfo C.struct_retro_system_info
tickTime int64
cSaveDirectory *C.char
cSystemDirectory *C.char
@ -67,16 +66,18 @@ type Nanoarch struct {
PixFmt PixFmt
}
vfr bool
Aspect bool
sdlCtx *graphics.SDL
hackSkipHwContextDestroy bool
hackSkipSameThreadSave bool
limiter func(func())
log *logger.Logger
}
type Handlers struct {
OnDpad func(port uint, axis uint) (shift int16)
OnKeyPress func(port uint, key int) int
OnAudio func(ptr unsafe.Pointer, frames int)
OnVideo func(data []byte, delta int32, fi FrameInfo)
OnDup func()
OnSystemAvInfo func()
}
@ -87,14 +88,19 @@ type FrameInfo struct {
}
type Metadata struct {
LibPath string // the full path to some emulator lib
IsGlAllowed bool
UsesLibCo bool
AutoGlContext bool
HasMultitap bool
HasVFR bool
Options map[string]string
Hacks []string
FrameDup bool
LibPath string // the full path to some emulator lib
IsGlAllowed bool
UsesLibCo bool
AutoGlContext bool
HasVFR bool
Options map[string]string
Options4rom map[string]map[string]string
Hacks []string
Hid map[int][]int
CoreAspectRatio bool
KbMouseSupport bool
LibExt string
}
type PixFmt struct {
@ -118,12 +124,12 @@ func (p PixFmt) String() string {
// Nan0 is a global link for C callbacks to Go
var Nan0 = Nanoarch{
reserved: make(chan struct{}, 1), // this thing forbids concurrent use of the emulator
stopped: atomic.Bool{},
Stopped: atomic.Bool{},
limiter: func(fn func()) { fn() },
Handlers: Handlers{
OnDpad: func(uint, uint) int16 { return 0 },
OnKeyPress: func(uint, int) int { return 0 },
OnAudio: func(unsafe.Pointer, int) {},
OnVideo: func([]byte, int32, FrameInfo) {},
OnAudio: func(unsafe.Pointer, int) {},
OnVideo: func([]byte, int32, FrameInfo) {},
OnDup: func() {},
},
}
@ -139,55 +145,76 @@ func NewNano(localPath string) *Nanoarch {
return nano
}
func (n *Nanoarch) AudioSampleRate() int { return int(n.sysAvInfo.timing.sample_rate) }
func (n *Nanoarch) VideoFramerate() int { return int(n.sysAvInfo.timing.fps) }
func (n *Nanoarch) IsPortrait() bool { return n.Rot == 90 || n.Rot == 270 }
func (n *Nanoarch) GeometryBase() (int, int) {
return int(n.sysAvInfo.geometry.base_width), int(n.sysAvInfo.geometry.base_height)
func (n *Nanoarch) AspectRatio() float32 { return float32(n.sys.av.geometry.aspect_ratio) }
func (n *Nanoarch) AudioSampleRate() int { return int(n.sys.av.timing.sample_rate) }
func (n *Nanoarch) VideoFramerate() int { return int(n.sys.av.timing.fps) }
func (n *Nanoarch) IsPortrait() bool { return 90 == n.Rot%180 }
func (n *Nanoarch) KbMouseSupport() bool { return n.meta.KbMouseSupport }
func (n *Nanoarch) BaseWidth() int { return int(n.sys.av.geometry.base_width) }
func (n *Nanoarch) BaseHeight() int { return int(n.sys.av.geometry.base_height) }
func (n *Nanoarch) WaitReady() { <-n.reserved }
func (n *Nanoarch) Close() { n.Stopped.Store(true); n.reserved <- struct{}{} }
func (n *Nanoarch) SetLogger(log *logger.Logger) { n.log = log }
func (n *Nanoarch) SetVideoDebounce(t time.Duration) { n.limiter = NewLimit(t) }
func (n *Nanoarch) SaveDir() string { return C.GoString(n.cSaveDirectory) }
func (n *Nanoarch) SetSaveDirSuffix(sx string) {
dir := C.GoString(n.cSaveDirectory) + "/" + sx
err := os.CheckCreateDir(dir)
if err != nil {
n.log.Error().Msgf("couldn't create %v, %v", dir, err)
}
if n.cSaveDirectory != nil {
C.free(unsafe.Pointer(n.cSaveDirectory))
}
n.cSaveDirectory = C.CString(dir)
}
func (n *Nanoarch) GeometryMax() (int, int) {
return int(n.sysAvInfo.geometry.max_width), int(n.sysAvInfo.geometry.max_height)
func (n *Nanoarch) DeleteSaveDir() error {
if n.cSaveDirectory == nil {
return nil
}
dir := C.GoString(n.cSaveDirectory)
return os.RemoveAll(dir)
}
func (n *Nanoarch) WaitReady() { <-n.reserved }
func (n *Nanoarch) Close() { n.stopped.Store(true); n.reserved <- struct{}{} }
func (n *Nanoarch) SetLogger(log *logger.Logger) { n.log = log }
func (n *Nanoarch) CoreLoad(meta Metadata) {
var err error
n.meta = meta
n.LibCo = meta.UsesLibCo
n.vfr = meta.HasVFR
n.Aspect = meta.CoreAspectRatio
n.Video.gl.autoCtx = meta.AutoGlContext
n.Video.gl.enabled = meta.IsGlAllowed
thread.SwitchGraphics(n.Video.gl.enabled)
// hacks
Nan0.hackSkipHwContextDestroy = meta.HasHack("skip_hw_context_destroy")
Nan0.hackSkipSameThreadSave = meta.HasHack("skip_same_thread_save")
n.options = &meta.Options
// reset controllers
n.retropad = InputState{}
n.keyboardCb = nil
n.keyboard = KeyboardState{}
n.mouse = MouseState{}
n.multitap.supported = meta.HasMultitap
n.multitap.enabled = false
n.multitap.value = 0
n.options = maps.Clone(meta.Options)
n.options4rom = meta.Options4rom
filePath := meta.LibPath
if ar, err := arch.Guess(); err == nil {
filePath = filePath + ar.LibExt
} else {
n.log.Warn().Err(err).Msg("system arch guesser failed")
}
coreLib, err = loadLib(filePath)
corePath := meta.LibPath + meta.LibExt
coreLib, err = loadLib(corePath)
// fallback to sequential lib loader (first successfully loaded)
if err != nil {
n.log.Error().Err(err).Msgf("load fail: %v", filePath)
coreLib, err = loadLibRollingRollingRolling(filePath)
n.log.Error().Err(err).Msgf("load fail: %v", corePath)
coreLib, err = loadLibRollingRollingRolling(corePath)
if err != nil {
n.log.Fatal().Err(err).Msgf("core load: %s", filePath)
n.log.Fatal().Err(err).Msgf("core load: %s", corePath)
}
}
retroInit = loadFunction(coreLib, "retro_init")
retroDeinit = loadFunction(coreLib, "retro_deinit")
//retroAPIVersion = loadFunction(coreLib, "retro_api_version")
retroAPIVersion = loadFunction(coreLib, "retro_api_version")
retroGetSystemInfo = loadFunction(coreLib, "retro_get_system_info")
retroGetSystemAVInfo = loadFunction(coreLib, "retro_get_system_av_info")
retroSetEnvironment = loadFunction(coreLib, "retro_set_environment")
@ -196,6 +223,7 @@ func (n *Nanoarch) CoreLoad(meta Metadata) {
retroSetInputState = loadFunction(coreLib, "retro_set_input_state")
retroSetAudioSample = loadFunction(coreLib, "retro_set_audio_sample")
retroSetAudioSampleBatch = loadFunction(coreLib, "retro_set_audio_sample_batch")
retroReset = loadFunction(coreLib, "retro_reset")
retroRun = loadFunction(coreLib, "retro_run")
retroLoadGame = loadFunction(coreLib, "retro_load_game")
retroUnloadGame = loadFunction(coreLib, "retro_unload_game")
@ -207,28 +235,30 @@ func (n *Nanoarch) CoreLoad(meta Metadata) {
retroGetMemoryData = loadFunction(coreLib, "retro_get_memory_data")
C.bridge_retro_set_environment(retroSetEnvironment, C.core_environment_cgo)
C.bridge_retro_set_video_refresh(retroSetVideoRefresh, C.core_video_refresh_cgo)
C.bridge_retro_set_input_poll(retroSetInputPoll, C.core_input_poll_cgo)
C.bridge_retro_set_input_state(retroSetInputState, C.core_input_state_cgo)
C.bridge_retro_set_audio_sample(retroSetAudioSample, C.core_audio_sample_cgo)
C.bridge_retro_set_audio_sample_batch(retroSetAudioSampleBatch, C.core_audio_sample_batch_cgo)
C.bridge_set_callback(retroSetVideoRefresh, C.core_video_refresh_cgo)
C.bridge_set_callback(retroSetInputPoll, C.core_input_poll_cgo)
C.bridge_set_callback(retroSetAudioSample, C.core_audio_sample_cgo)
C.bridge_set_callback(retroSetAudioSampleBatch, C.core_audio_sample_batch_cgo)
if n.LibCo {
C.same_thread(retroInit)
} else {
C.bridge_retro_init(retroInit)
C.bridge_call(retroInit)
}
C.bridge_retro_get_system_info(retroGetSystemInfo, &n.sysInfo)
n.log.Debug().Msgf("System >>> %s (%s) [%s] nfp: %v",
C.GoString(n.sysInfo.library_name), C.GoString(n.sysInfo.library_version),
C.GoString(n.sysInfo.valid_extensions), bool(n.sysInfo.need_fullpath))
n.sys.api = C.bridge_retro_api_version(retroAPIVersion)
C.bridge_retro_get_system_info(retroGetSystemInfo, &n.sys.i)
n.log.Info().Msgf("System >>> %v (%v) [%v] nfp: %v, api: %v",
C.GoString(n.sys.i.library_name), C.GoString(n.sys.i.library_version),
C.GoString(n.sys.i.valid_extensions), bool(n.sys.i.need_fullpath),
uint(n.sys.api))
}
func (n *Nanoarch) LoadGame(path string) error {
game := C.struct_retro_game_info{}
big := bool(n.sysInfo.need_fullpath) // big ROMs are loaded by cores later
big := bool(n.sys.i.need_fullpath) // big ROMs are loaded by cores later
if big {
size, err := os.StatSize(path)
if err != nil {
@ -252,70 +282,74 @@ func (n *Nanoarch) LoadGame(path string) error {
n.log.Debug().Msgf("ROM - big: %v, size: %v", big, byteCountBinary(int64(game.size)))
// maybe some custom options
if n.options4rom != nil {
romName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if _, ok := n.options4rom[romName]; ok {
for k, v := range n.options4rom[romName] {
n.options[k] = v
n.log.Debug().Msgf("Replace: %v=%v", k, v)
}
}
}
if ok := C.bridge_retro_load_game(retroLoadGame, &game); !ok {
return fmt.Errorf("core failed to load ROM: %v", path)
}
C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &n.sysAvInfo)
var av C.struct_retro_system_av_info
C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &av)
n.log.Info().Msgf("System A/V >>> %vx%v (%vx%v), [%vfps], AR [%v], audio [%vHz]",
n.sysAvInfo.geometry.base_width, n.sysAvInfo.geometry.base_height,
n.sysAvInfo.geometry.max_width, n.sysAvInfo.geometry.max_height,
n.sysAvInfo.timing.fps, n.sysAvInfo.geometry.aspect_ratio, n.sysAvInfo.timing.sample_rate,
av.geometry.base_width, av.geometry.base_height,
av.geometry.max_width, av.geometry.max_height,
av.timing.fps, av.geometry.aspect_ratio, av.timing.sample_rate,
)
if isGeometryDifferent(av.geometry) {
geometryChange(av.geometry)
}
n.sys.av = av
n.serializeSize = C.bridge_retro_serialize_size(retroSerializeSize)
n.log.Info().Msgf("Save file size: %v", byteCountBinary(int64(n.serializeSize)))
Nan0.tickTime = int64(time.Second / time.Duration(n.sysAvInfo.timing.fps))
Nan0.tickTime = int64(time.Second / time.Duration(n.sys.av.timing.fps))
if n.vfr {
n.log.Info().Msgf("variable framerate (VFR) is enabled")
}
n.stopped.Store(false)
n.Stopped.Store(false)
if n.Video.gl.enabled {
//setRotation(image.F180) // flip Y coordinates of OpenGL
bufS := uint(n.sysAvInfo.geometry.max_width*n.sysAvInfo.geometry.max_height) * n.Video.PixFmt.BPP
graphics.SetBuffer(int(bufS))
n.log.Info().Msgf("Set buffer: %v", byteCountBinary(int64(bufS)))
if n.LibCo {
C.same_thread(C.init_video_cgo)
C.same_thread(unsafe.Pointer(Nan0.Video.hw.context_reset))
} else {
runtime.LockOSThread()
initVideo()
C.bridge_context_reset(Nan0.Video.hw.context_reset)
runtime.UnlockOSThread()
}
}
// set default controller types on all ports
for i := 0; i < MaxPort; i++ {
// needed for nestopia
for i := range maxPort {
C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD)
}
// map custom devices to ports
for k, v := range n.meta.Hid {
for _, device := range v {
C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(k), C.unsigned(device))
n.log.Debug().Msgf("set custom port-device: %v:%v", k, device)
}
}
n.LastFrameTime = time.Now().UnixNano()
return nil
}
// ToggleMultitap toggles multitap controller for cores.
//
// Official SNES games only support a single multitap device
// Most require it to be plugged in player 2 port and Snes9X requires it
// to be "plugged" after the game is loaded.
// Control this from the browser since player 2 will stop working in some games
// if multitap is "plugged" in.
func (n *Nanoarch) ToggleMultitap() {
if !n.multitap.supported || n.multitap.value == 0 {
return
}
mt := n.multitap.value
if n.multitap.enabled {
mt = C.RETRO_DEVICE_JOYPAD
}
C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, 1, mt)
n.multitap.enabled = !n.multitap.enabled
}
func (n *Nanoarch) Shutdown() {
if n.LibCo {
thread.Main(func() {
@ -336,8 +370,8 @@ func (n *Nanoarch) Shutdown() {
}
})
}
C.bridge_retro_unload_game(retroUnloadGame)
C.bridge_retro_deinit(retroDeinit)
C.bridge_call(retroUnloadGame)
C.bridge_call(retroDeinit)
if n.Video.gl.enabled {
thread.Main(func() {
deinitVideo()
@ -347,35 +381,77 @@ func (n *Nanoarch) Shutdown() {
}
setRotation(0)
Nan0.sys.av = C.struct_retro_system_av_info{}
if err := closeLib(coreLib); err != nil {
n.log.Error().Err(err).Msg("lib close failed")
}
n.options = nil
n.options4rom = nil
C.free(unsafe.Pointer(n.cUserName))
C.free(unsafe.Pointer(n.cSaveDirectory))
C.free(unsafe.Pointer(n.cSystemDirectory))
}
func (n *Nanoarch) Reset() {
C.bridge_call(retroReset)
}
func (n *Nanoarch) syncInputToCache() {
n.retropad.SyncToCache()
if n.keyboardCb != nil {
n.keyboard.SyncToCache()
}
n.mouse.SyncToCache()
}
func (n *Nanoarch) Run() {
n.syncInputToCache()
if n.LibCo {
C.same_thread(retroRun)
} else {
if n.Video.gl.enabled {
// running inside a go routine, lock the thread to make sure the OpenGL context stays current
runtime.LockOSThread()
if err := n.sdlCtx.BindContext(); err != nil {
n.log.Error().Err(err).Msg("ctx bind fail")
}
}
C.bridge_retro_run(retroRun)
C.bridge_call(retroRun)
if n.Video.gl.enabled {
runtime.UnlockOSThread()
}
}
}
func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled }
func (n *Nanoarch) IsStopped() bool { return n.stopped.Load() }
func (n *Nanoarch) IsSupported() error { return graphics.TryInit() }
func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled }
func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() }
func (n *Nanoarch) InputRetropad(port int, data []byte) { n.retropad.SetInput(port, data) }
func (n *Nanoarch) InputKeyboard(_ int, data []byte) {
if n.keyboardCb == nil {
return
}
// we should preserve the state of pressed buttons for the input poll function (each retro_run)
// and explicitly call the retro_keyboard_callback function when a keyboard event happens
pressed, key, mod := n.keyboard.SetKey(data)
C.bridge_retro_keyboard_callback(unsafe.Pointer(n.keyboardCb), C.bool(pressed),
C.unsigned(key), C.uint32_t(0), C.uint16_t(mod))
}
func (n *Nanoarch) InputMouse(_ int, data []byte) {
if len(data) == 0 {
return
}
t := data[0]
state := data[1:]
switch t {
case MouseMove:
n.mouse.ShiftPos(state)
case MouseButton:
n.mouse.SetButtons(state[0])
}
}
func videoSetPixelFormat(format uint32) (C.bool, error) {
switch format {
@ -384,8 +460,6 @@ func videoSetPixelFormat(format uint32) (C.bool, error) {
if err := graphics.SetPixelFormat(graphics.UnsignedShort5551); err != nil {
return false, fmt.Errorf("unknown pixel format %v", Nan0.Video.PixFmt)
}
// format is not implemented
return false, fmt.Errorf("unsupported pixel type %v converter", format)
case C.RETRO_PIXEL_FORMAT_XRGB8888:
Nan0.Video.PixFmt = RGBA8888Rev
if err := graphics.SetPixelFormat(graphics.UnsignedInt8888Rev); err != nil {
@ -412,13 +486,11 @@ func setRotation(rot uint) {
func printOpenGLDriverInfo() {
var openGLInfo strings.Builder
openGLInfo.Grow(128)
openGLInfo.WriteString(fmt.Sprintf("\n[OpenGL] Version: %v\n", graphics.GetGLVersionInfo()))
openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Vendor: %v\n", graphics.GetGLVendorInfo()))
// This string is often the name of the GPU.
// In the case of Mesa3d, it would be i.e "Gallium 0.4 on NVA8".
// It might even say "Direct3D" if the Windows Direct3D wrapper is being used.
openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Renderer: %v\n", graphics.GetGLRendererInfo()))
openGLInfo.WriteString(fmt.Sprintf("[OpenGL] GLSL Version: %v", graphics.GetGLSLInfo()))
version, vendor, renderrer, glsl := graphics.GLInfo()
openGLInfo.WriteString(fmt.Sprintf("\n[OpenGL] Version: %v\n", version))
openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Vendor: %v\n", vendor))
openGLInfo.WriteString(fmt.Sprintf("[OpenGL] Renderer: %v\n", renderrer))
openGLInfo.WriteString(fmt.Sprintf("[OpenGL] GLSL Version: %v", glsl))
Nan0.log.Debug().Msg(openGLInfo.String())
}
@ -437,32 +509,42 @@ const (
// SaveState returns emulator internal state.
func SaveState() (State, error) {
data := make([]byte, uint(Nan0.serializeSize))
size := C.bridge_retro_serialize_size(retroSerializeSize)
data := make([]byte, uint(size))
rez := false
if Nan0.LibCo {
rez = *(*bool)(C.same_thread_with_args2(retroSerialize, C.int(CallSerialize), unsafe.Pointer(&data[0]), unsafe.Pointer(&Nan0.serializeSize)))
if Nan0.LibCo && !Nan0.hackSkipSameThreadSave {
rez = *(*bool)(C.same_thread_with_args2(retroSerialize, C.int(CallSerialize), unsafe.Pointer(&data[0]), unsafe.Pointer(&size)))
} else {
rez = bool(C.bridge_retro_serialize(retroSerialize, unsafe.Pointer(&data[0]), Nan0.serializeSize))
rez = bool(C.bridge_retro_serialize(retroSerialize, unsafe.Pointer(&data[0]), size))
}
if !rez {
return nil, errors.New("retro_serialize failed")
}
return data, nil
}
// RestoreSaveState restores emulator internal state.
func RestoreSaveState(st State) error {
if len(st) > 0 {
rez := false
if Nan0.LibCo {
rez = *(*bool)(C.same_thread_with_args2(retroUnserialize, C.int(CallUnserialize), unsafe.Pointer(&st[0]), unsafe.Pointer(&Nan0.serializeSize)))
} else {
rez = bool(C.bridge_retro_unserialize(retroUnserialize, unsafe.Pointer(&st[0]), Nan0.serializeSize))
}
if !rez {
return errors.New("retro_unserialize failed")
}
if len(st) <= 0 {
return errors.New("empty load state")
}
size := C.size_t(len(st))
rez := false
if Nan0.LibCo {
rez = *(*bool)(C.same_thread_with_args2(retroUnserialize, C.int(CallUnserialize), unsafe.Pointer(&st[0]), unsafe.Pointer(&size)))
} else {
rez = bool(C.bridge_retro_unserialize(retroUnserialize, unsafe.Pointer(&st[0]), size))
}
if !rez {
return errors.New("retro_unserialize failed")
}
return nil
}
@ -485,19 +567,19 @@ func RestoreSaveRAM(st State) {
}
}
// getMemorySize returns memory region size.
func getMemorySize(id C.uint) uint {
// memorySize returns memory region size.
func memorySize(id C.uint) uint {
return uint(C.bridge_retro_get_memory_size(retroGetMemorySize, id))
}
// getMemoryData returns a pointer to memory data.
func getMemoryData(id C.uint) unsafe.Pointer {
// memoryData returns a pointer to memory data.
func memoryData(id C.uint) unsafe.Pointer {
return C.bridge_retro_get_memory_data(retroGetMemoryData, id)
}
// ptSaveRam return SRAM memory pointer if core supports it or nil.
func ptSaveRAM() *mem {
ptr, size := getMemoryData(C.RETRO_MEMORY_SAVE_RAM), getMemorySize(C.RETRO_MEMORY_SAVE_RAM)
ptr, size := memoryData(C.RETRO_MEMORY_SAVE_RAM), memorySize(C.RETRO_MEMORY_SAVE_RAM)
if ptr == nil || size == 0 {
return nil
}
@ -527,13 +609,14 @@ func (m Metadata) HasHack(h string) bool {
}
var (
//retroAPIVersion unsafe.Pointer
retroAPIVersion unsafe.Pointer
retroDeinit unsafe.Pointer
retroGetSystemAVInfo unsafe.Pointer
retroGetSystemInfo unsafe.Pointer
coreLib unsafe.Pointer
retroInit unsafe.Pointer
retroLoadGame unsafe.Pointer
retroReset unsafe.Pointer
retroRun unsafe.Pointer
retroSetAudioSample unsafe.Pointer
retroSetAudioSampleBatch unsafe.Pointer
@ -552,8 +635,7 @@ var (
//export coreVideoRefresh
func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) {
if Nan0.stopped.Load() {
Nan0.log.Warn().Msgf(">>> skip video")
if Nan0.Stopped.Load() {
return
}
@ -562,24 +644,24 @@ func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) {
// (and proper frame display time, for example: 1->1/60=16.6ms, 2->10ms, 3->23ms, 4->16.6ms)
// this is useful only for cores with variable framerate, for the fixed framerate cores this adds stutter
// !to find docs on Libretro refresh sync and frame times
t := time.Now().UnixNano()
dt := Nan0.tickTime
// override frame rendering with dynamic frame times
if Nan0.vfr {
t := time.Now().UnixNano()
dt = t - Nan0.LastFrameTime
Nan0.LastFrameTime = t
}
Nan0.LastFrameTime = t
// some cores can return nothing
// !to add duplicate if can dup
// when the core returns a duplicate frame
if data == nil {
Nan0.Handlers.OnDup()
return
}
// calculate real frame width in pixels from packed data (realWidth >= width)
// some cores or games output zero pitch, i.e. N64 Mupen
bpp := Nan0.Video.PixFmt.BPP
if packed == 0 {
packed = width * Nan0.Video.PixFmt.BPP
packed = width * bpp
}
// calculate space for the video frame
bytes := packed * height
@ -600,48 +682,9 @@ func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) {
Nan0.Handlers.OnVideo(data_, int32(dt), FrameInfo{W: width, H: height, Stride: packed})
}
//export coreInputPoll
func coreInputPoll() {}
//export coreInputState
func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t {
if uint(port) >= uint(MaxPort) {
return KeyReleased
}
if device == C.RETRO_DEVICE_ANALOG {
if index > C.RETRO_DEVICE_INDEX_ANALOG_RIGHT || id > C.RETRO_DEVICE_ID_ANALOG_Y {
return 0
}
axis := index*2 + id
value := Nan0.Handlers.OnDpad(uint(port), uint(axis))
if value != 0 {
return (C.int16_t)(value)
}
}
key := int(id)
if key > lastKey || index > 0 || device != C.RETRO_DEVICE_JOYPAD {
return KeyReleased
}
if Nan0.Handlers.OnKeyPress(uint(port), key) == KeyPressed {
return KeyPressed
}
return KeyReleased
}
//export coreAudioSample
func coreAudioSample(l, r C.int16_t) {
frame := []C.int16_t{l, r}
coreAudioSampleBatch(unsafe.Pointer(&frame), 1)
}
//export coreAudioSampleBatch
func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t {
if Nan0.stopped.Load() {
if Nan0.log.GetLevel() < logger.InfoLevel {
Nan0.log.Warn().Msgf(">>> skip %v audio frames", frames)
}
if Nan0.Stopped.Load() {
return frames
}
Nan0.Handlers.OnAudio(data, int(frames)<<1)
@ -669,44 +712,40 @@ func coreLog(level C.enum_retro_log_level, msg *C.char) {
}
//export coreGetCurrentFramebuffer
func coreGetCurrentFramebuffer() C.uintptr_t { return (C.uintptr_t)(graphics.GetGlFbo()) }
func coreGetCurrentFramebuffer() C.uintptr_t { return (C.uintptr_t)(graphics.GlFbo()) }
//export coreGetProcAddress
func coreGetProcAddress(sym *C.char) C.retro_proc_address_t {
return (C.retro_proc_address_t)(graphics.GetGlProcAddress(C.GoString(sym)))
return (C.retro_proc_address_t)(graphics.GlProcAddress(C.GoString(sym)))
}
//export coreEnvironment
func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool {
// spammy
switch cmd {
case C.RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE:
return false
case C.RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE:
return false
}
// see core_environment_cgo
switch cmd {
case C.RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO:
Nan0.log.Debug().Msgf("retro_set_system_av_info")
av := *(*C.struct_retro_system_av_info)(data)
Nan0.log.Info().Msgf(">>> SET SYS AV INFO: %v", av)
Nan0.sysAvInfo = av
go func() {
if Nan0.OnSystemAvInfo != nil {
Nan0.OnSystemAvInfo()
}
}()
if isGeometryDifferent(av.geometry) {
geometryChange(av.geometry)
}
return true
case C.RETRO_ENVIRONMENT_SET_GEOMETRY:
Nan0.log.Debug().Msgf("retro_set_geometry")
geom := *(*C.struct_retro_game_geometry)(data)
Nan0.log.Info().Msgf(">>> GEOMETRY: %v", geom)
if isGeometryDifferent(geom) {
geometryChange(geom)
}
return true
case C.RETRO_ENVIRONMENT_SET_ROTATION:
setRotation((*(*uint)(data) % 4) * 90)
return true
case C.RETRO_ENVIRONMENT_GET_CAN_DUPE:
*(*C.bool)(data) = C.bool(true)
return true
dup := C.bool(Nan0.meta.FrameDup)
*(*C.bool)(data) = dup
return dup
case C.RETRO_ENVIRONMENT_GET_USERNAME:
*(**C.char)(data) = Nan0.cUserName
return true
@ -735,22 +774,22 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool {
return true
}
return false
case C.RETRO_ENVIRONMENT_SHUTDOWN:
//window.SetShouldClose(true)
return false
case C.RETRO_ENVIRONMENT_GET_VARIABLE:
if (*Nan0.options) == nil {
if Nan0.options == nil {
return false
}
rv := (*C.struct_retro_variable)(data)
key := C.GoString(rv.key)
if v, ok := (*Nan0.options)[key]; ok {
if v, ok := Nan0.options[key]; ok {
// make Go strings null-terminated copies ;_;
(*Nan0.options)[key] = v + "\x00"
Nan0.options[key] = v + "\x00"
ptr := unsafe.Pointer(unsafe.StringData(Nan0.options[key]))
var p runtime.Pinner
p.Pin(ptr)
defer p.Unpin()
// cast to C string and set the value
// we hope the string won't be collected while C needs it
rv.value = (*C.char)(unsafe.Pointer(unsafe.StringData((*Nan0.options)[key])))
Nan0.log.Debug().Msgf("Set %s=%v", key, v)
rv.value = (*C.char)(ptr)
Nan0.log.Debug().Msgf("Set %v=%v", key, v)
return true
}
return false
@ -763,30 +802,33 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool {
}
return false
case C.RETRO_ENVIRONMENT_SET_CONTROLLER_INFO:
// !to rewrite
if !Nan0.multitap.supported {
if Nan0.log.GetLevel() > logger.DebugLevel {
return false
}
info := (*[100]C.struct_retro_controller_info)(data)
var i C.unsigned
for i = 0; unsafe.Pointer(info[i].types) != nil; i++ {
var j C.unsigned
types := (*[100]C.struct_retro_controller_description)(unsafe.Pointer(info[i].types))
for j = 0; j < info[i].num_types; j++ {
if C.GoString(types[j].desc) == "Multitap" {
Nan0.multitap.value = types[j].id
return true
}
info := (*[64]C.struct_retro_controller_info)(data)
for c, controller := range info {
tp := unsafe.Pointer(controller.types)
if tp == nil {
break
}
cInfo := strings.Builder{}
cInfo.WriteString(fmt.Sprintf("Controller [%v] ", c))
cd := (*[32]C.struct_retro_controller_description)(tp)
delim := ", "
n := int(controller.num_types)
for i := range n {
if i == n-1 {
delim = ""
}
cInfo.WriteString(fmt.Sprintf("%v: %v%s", cd[i].id, C.GoString(cd[i].desc), delim))
}
//Nan0.log.Debug().Msgf("%v", cInfo.String())
}
return false
case C.RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB:
C.bridge_clear_all_thread_waits_cb(data)
return true
case C.RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT:
if ctx := (*C.int)(data); ctx != nil {
*ctx = C.RETRO_SAVESTATE_CONTEXT_NORMAL
}
case C.RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK:
Nan0.log.Debug().Msgf("Keyboard event callback was set")
Nan0.keyboardCb = (*C.struct_retro_keyboard_callback)(data)
return true
}
return false
@ -816,22 +858,23 @@ func initVideo() {
context = graphics.CtxUnknown
}
sdl, err := graphics.NewSDLContext(graphics.Config{
Ctx: context,
W: int(Nan0.sysAvInfo.geometry.max_width),
H: int(Nan0.sysAvInfo.geometry.max_height),
GLAutoContext: Nan0.Video.gl.autoCtx,
GLVersionMajor: uint(Nan0.Video.hw.version_major),
GLVersionMinor: uint(Nan0.Video.hw.version_minor),
GLHasDepth: bool(Nan0.Video.hw.depth),
GLHasStencil: bool(Nan0.Video.hw.stencil),
}, Nan0.log)
if err != nil {
panic(err)
}
Nan0.sdlCtx = sdl
thread.Main(func() {
var err error
Nan0.sdlCtx, err = graphics.NewSDLContext(graphics.Config{
Ctx: context,
W: int(Nan0.sys.av.geometry.max_width),
H: int(Nan0.sys.av.geometry.max_height),
GLAutoContext: Nan0.Video.gl.autoCtx,
GLVersionMajor: uint(Nan0.Video.hw.version_major),
GLVersionMinor: uint(Nan0.Video.hw.version_minor),
GLHasDepth: bool(Nan0.Video.hw.depth),
GLHasStencil: bool(Nan0.Video.hw.stencil),
})
if err != nil {
panic(err)
}
})
C.bridge_context_reset(Nan0.Video.hw.context_reset)
if Nan0.log.GetLevel() < logger.InfoLevel {
printOpenGLDriverInfo()
}
@ -842,10 +885,59 @@ func deinitVideo() {
if !Nan0.hackSkipHwContextDestroy {
C.bridge_context_reset(Nan0.Video.hw.context_destroy)
}
if err := Nan0.sdlCtx.Deinit(); err != nil {
Nan0.log.Error().Err(err).Msg("deinit fail")
}
thread.Main(func() {
if err := Nan0.sdlCtx.Deinit(); err != nil {
Nan0.log.Error().Err(err).Msg("deinit fail")
}
})
Nan0.Video.gl.enabled = false
Nan0.Video.gl.autoCtx = false
Nan0.hackSkipHwContextDestroy = false
Nan0.hackSkipSameThreadSave = false
thread.SwitchGraphics(false)
}
type limit struct {
d time.Duration
t *time.Timer
mu sync.Mutex
}
func NewLimit(d time.Duration) func(f func()) {
l := &limit{d: d}
return func(f func()) { l.push(f) }
}
func (d *limit) push(f func()) {
d.mu.Lock()
defer d.mu.Unlock()
if d.t != nil {
d.t.Stop()
}
d.t = time.AfterFunc(d.d, f)
}
func geometryChange(geom C.struct_retro_game_geometry) {
Nan0.limiter(func() {
old := Nan0.sys.av.geometry
Nan0.sys.av.geometry = geom
if Nan0.Video.gl.enabled && (old.max_width != geom.max_width || old.max_height != geom.max_height) {
// (for LRPS2) makes the max height bigger increasing SDL2 and OpenGL buffers slightly
Nan0.sys.av.geometry.max_height = C.unsigned(float32(Nan0.sys.av.geometry.max_height) * 1.5)
bufS := uint(geom.max_width*Nan0.sys.av.geometry.max_height) * Nan0.Video.PixFmt.BPP
graphics.SetBuffer(int(bufS))
Nan0.log.Debug().Msgf("OpenGL frame buffer: %v", bufS)
}
if Nan0.OnSystemAvInfo != nil {
Nan0.log.Debug().Msgf(">>> geometry change %v -> %v", old, geom)
go Nan0.OnSystemAvInfo()
}
})
}
func isGeometryDifferent(geom C.struct_retro_game_geometry) bool {
return Nan0.sys.av.geometry.base_width != geom.base_width ||
Nan0.sys.av.geometry.base_height != geom.base_height
}

View file

@ -1,8 +1,10 @@
#ifndef FRONTEND_H__
#define FRONTEND_H__
void bridge_call(void *f);
void bridge_set_callback(void *f, void *callback);
bool bridge_retro_load_game(void *f, struct retro_game_info *gi);
void bridge_retro_unload_game(void *f);
bool bridge_retro_serialize(void *f, void *data, size_t size);
size_t bridge_retro_serialize_size(void *f);
bool bridge_retro_unserialize(void *f, void *data, size_t size);
@ -11,18 +13,11 @@ unsigned bridge_retro_api_version(void *f);
size_t bridge_retro_get_memory_size(void *f, unsigned id);
void *bridge_retro_get_memory_data(void *f, unsigned id);
void bridge_context_reset(retro_hw_context_reset_t f);
void bridge_retro_deinit(void *f);
void bridge_retro_get_system_av_info(void *f, struct retro_system_av_info *si);
void bridge_retro_get_system_info(void *f, struct retro_system_info *si);
void bridge_retro_init(void *f);
void bridge_retro_run(void *f);
void bridge_retro_set_audio_sample(void *f, void *callback);
void bridge_retro_set_audio_sample_batch(void *f, void *callback);
void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device);
void bridge_retro_set_input_poll(void *f, void *callback);
void bridge_retro_set_input_state(void *f, void *callback);
void bridge_retro_set_video_refresh(void *f, void *callback);
void bridge_clear_all_thread_waits_cb(void *f);
void bridge_retro_keyboard_callback(void *f, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers);
bool core_environment_cgo(unsigned cmd, void *data);
int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, unsigned id);

View file

@ -0,0 +1,22 @@
package nanoarch
import (
"sync/atomic"
"testing"
"time"
)
func TestLimit(t *testing.T) {
c := atomic.Int32{}
lim := NewLimit(50 * time.Millisecond)
for range 10 {
lim(func() {
c.Add(1)
})
}
if c.Load() > 1 {
t.Errorf("should be just 1")
}
}

View file

@ -15,17 +15,6 @@ type RecordingFrontend struct {
}
func WithRecording(fe Emulator, rec bool, user string, game string, conf config.Recording, log *logger.Logger) *RecordingFrontend {
pix := ""
switch fe.PixFormat() {
case 0:
pix = "rgb1555"
case 1:
pix = "brga"
case 2:
pix = "rgb565"
}
rr := &RecordingFrontend{Emulator: fe, rec: recorder.NewRecording(
recorder.Meta{UserName: user},
log,
@ -36,7 +25,6 @@ func WithRecording(fe Emulator, rec bool, user string, game string, conf config.
Zip: conf.Zip,
Vsync: true,
Flip: fe.Flipped(),
Pix: pix,
})}
rr.ToggleRecording(rec, user)
return rr
@ -70,6 +58,7 @@ func (r *RecordingFrontend) LoadGame(path string) error {
}
r.rec.SetFramerate(float64(r.Emulator.FPS()))
r.rec.SetAudioFrequency(r.Emulator.AudioSampleRate())
r.rec.SetPixFormat(r.Emulator.PixFormat())
return nil
}

View file

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

View file

@ -1,34 +0,0 @@
package buildbot
import (
"strings"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/raw"
)
type RepoBuildbot struct {
raw.Repo
}
func NewBuildbotRepo(address string, compression string) RepoBuildbot {
return RepoBuildbot{
Repo: raw.Repo{
Address: address,
Compression: compression,
},
}
}
func (r RepoBuildbot) GetCoreUrl(file string, info arch.Info) string {
var sb strings.Builder
sb.WriteString(r.Address + "/")
if info.Vendor != "" {
sb.WriteString(info.Vendor + "/")
}
sb.WriteString(info.Os + "/" + info.Arch + "/latest/" + file + info.LibExt)
if r.Compression != "" {
sb.WriteString("." + r.Compression)
}
return sb.String()
}

View file

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

View file

@ -1,18 +0,0 @@
package github
import (
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/buildbot"
)
type RepoGithub struct {
buildbot.RepoBuildbot
}
func NewGithubRepo(address string, compression string) RepoGithub {
return RepoGithub{RepoBuildbot: buildbot.NewBuildbotRepo(address, compression)}
}
func (r RepoGithub) GetCoreUrl(file string, info arch.Info) string {
return r.RepoBuildbot.GetCoreUrl(file, info) + "?raw=true"
}

View file

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

View file

@ -1,14 +0,0 @@
package raw
import "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch"
type Repo struct {
Address string
Compression string
}
// NewRawRepo defines a simple zip file containing
// all the cores that will be extracted as is.
func NewRawRepo(address string) Repo { return Repo{Address: address, Compression: "zip"} }
func (r Repo) GetCoreUrl(_ string, _ arch.Info) string { return r.Address }

View file

@ -1,36 +0,0 @@
package repo
import (
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/buildbot"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/github"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/raw"
)
type (
Data struct {
Url string
Compression string
}
Repository interface {
GetCoreUrl(file string, info arch.Info) (url string)
}
)
func New(kind string, url string, compression string, defaultRepo string) Repository {
var repository Repository
switch kind {
case "raw":
repository = raw.NewRawRepo(url)
case "github":
repository = github.NewGithubRepo(url, compression)
case "buildbot":
repository = buildbot.NewBuildbotRepo(url, compression)
default:
if defaultRepo != "" {
repository = New(defaultRepo, url, compression, "")
}
}
return repository
}

View file

@ -10,9 +10,11 @@ import (
type (
Storage interface {
MainPath() string
GetSavePath() string
GetSRAMPath() string
SetMainSaveName(name string)
SetNonBlocking(v bool)
Load(path string) ([]byte, error)
Save(path string, data []byte) error
}
@ -24,17 +26,27 @@ type (
// needed for Google Cloud save/restore which
// doesn't support multiple files
MainSave string
NonBlock bool
}
ZipStorage struct {
Storage
}
)
func (s *StateStorage) SetMainSaveName(name string) { s.MainSave = name }
func (s *StateStorage) GetSavePath() string { return filepath.Join(s.Path, s.MainSave+".dat") }
func (s *StateStorage) GetSRAMPath() string { return filepath.Join(s.Path, s.MainSave+".srm") }
func (s *StateStorage) Load(path string) ([]byte, error) { return os.ReadFile(path) }
func (s *StateStorage) Save(path string, dat []byte) error { return os.WriteFile(path, dat, 0644) }
func (s *StateStorage) MainPath() string { return s.MainSave }
func (s *StateStorage) SetMainSaveName(name string) { s.MainSave = name }
func (s *StateStorage) SetNonBlocking(v bool) { s.NonBlock = v }
func (s *StateStorage) GetSavePath() string { return filepath.Join(s.Path, s.MainSave+".dat") }
func (s *StateStorage) GetSRAMPath() string { return filepath.Join(s.Path, s.MainSave+".srm") }
func (s *StateStorage) Load(path string) ([]byte, error) { return os.ReadFile(path) }
func (s *StateStorage) Save(path string, dat []byte) error {
if s.NonBlock {
go func() { _ = os.WriteFile(path, dat, 0644) }()
return nil
}
return os.WriteFile(path, dat, 0644)
}
func (z *ZipStorage) GetSavePath() string { return z.Storage.GetSavePath() + zip.Ext }
func (z *ZipStorage) GetSRAMPath() string { return z.Storage.GetSRAMPath() + zip.Ext }

View file

@ -1,128 +0,0 @@
package cloud
import (
"bytes"
"crypto/md5"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/giongto35/cloud-game/v3/pkg/os"
)
// !to replace all with unified s3 api
type Storage interface {
Save(name string, localPath string) (err error)
Load(name string) (data []byte, err error)
}
type OracleDataStorageClient struct {
accessURL string
client *http.Client
}
func Store(provider, key string) (Storage, error) {
var st Storage
var err error
switch provider {
case "oracle":
st, err = NewOracleDataStorageClient(key)
case "coordinator":
default:
}
return st, err
}
// NewOracleDataStorageClient returns either a new Oracle Data Storage
// client or some error in case of failure.
// Oracle infrastructure access is based on pre-authenticated requests,
// see: https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/usingpreauthenticatedrequests.htm
//
// It follows broken Google Cloud Storage client design.
func NewOracleDataStorageClient(accessURL string) (*OracleDataStorageClient, error) {
if accessURL == "" {
return nil, errors.New("pre-authenticated request was not specified")
}
return &OracleDataStorageClient{
accessURL: accessURL,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
func (s *OracleDataStorageClient) Save(name string, localPath string) (err error) {
if s == nil {
return nil
}
dat, err := os.ReadFile(localPath)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", s.accessURL+name, bytes.NewBuffer(dat))
if err != nil {
return err
}
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 200 {
return errors.New(resp.Status)
}
dstMD5 := resp.Header.Get("Opc-Content-Md5")
srcMD5 := base64.StdEncoding.EncodeToString(md5Hash(dat))
if dstMD5 != srcMD5 {
return fmt.Errorf("MD5 mismatch %v != %v", srcMD5, dstMD5)
}
return nil
}
func (s *OracleDataStorageClient) Load(name string) (data []byte, err error) {
if s == nil {
return nil, errors.New("cloud storage was not initialized")
}
res, err := s.client.Get(s.accessURL + name)
if err != nil {
return nil, err
}
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode != 200 {
return nil, errors.New(res.Status)
}
dat, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
dstMD5 := res.Header.Get("Content-Md5")
srcMD5 := base64.StdEncoding.EncodeToString(md5Hash(dat))
if dstMD5 != srcMD5 {
return nil, fmt.Errorf("MD5 mismatch %v != %v", srcMD5, dstMD5)
}
return dat, nil
}
func md5Hash(data []byte) []byte {
hash := md5.New()
hash.Write(data)
return hash.Sum(nil)
}

View file

@ -1,54 +0,0 @@
package cloud
import (
"io"
"net/http"
"os"
"strings"
"testing"
)
type rtFunc func(req *http.Request) *http.Response
func (f rtFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil }
func newTestClient(fn rtFunc) *http.Client {
return &http.Client{
Transport: fn,
}
}
func TestOracleSave(t *testing.T) {
client, _ := NewOracleDataStorageClient("test-url/")
client.client = newTestClient(func(req *http.Request) *http.Response {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("")),
Header: map[string][]string{
"Opc-Content-Md5": {"CY9rzUYh03PK3k6DJie09g=="},
},
}
})
tempFile, err := os.CreateTemp("", "oracle_test.file")
if err != nil {
t.Errorf("%v", err)
}
defer func() {
_ = tempFile.Close()
err := os.Remove(tempFile.Name())
if err != nil {
t.Errorf("%v", err)
}
}()
_, err = tempFile.WriteString("test")
if err != nil {
return
}
err = client.Save("oracle_test.file", tempFile.Name())
if err != nil {
t.Errorf("can't save, err: %v", err)
}
}

91
pkg/worker/cloud/s3.go Normal file
View file

@ -0,0 +1,91 @@
package cloud
import (
"bytes"
"context"
"errors"
"io"
"github.com/giongto35/cloud-game/v3/pkg/logger"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/rs/zerolog/log"
)
type S3Client struct {
c *minio.Client
bucket string
log *logger.Logger
}
func NewS3Client(endpoint, bucket, key, secret string, log *logger.Logger) (*S3Client, error) {
s3Client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(key, secret, ""),
Secure: true,
})
if err != nil {
return nil, err
}
exists, err := s3Client.BucketExists(context.Background(), bucket)
if err != nil {
return nil, err
}
if !exists {
return nil, errors.New("bucket doesn't exist")
}
return &S3Client{bucket: bucket, c: s3Client, log: log}, nil
}
func (s *S3Client) SetBucket(bucket string) { s.bucket = bucket }
func (s *S3Client) Save(name string, data []byte, meta map[string]string) error {
if s == nil || s.c == nil {
return errors.New("s3 client was not initialised")
}
r := bytes.NewReader(data)
opts := minio.PutObjectOptions{
ContentType: "application/octet-stream",
SendContentMd5: true,
}
if meta != nil {
opts.UserMetadata = meta
}
info, err := s.c.PutObject(context.Background(), s.bucket, name, r, int64(len(data)), opts)
if err != nil {
return err
}
s.log.Debug().Msgf("Uploaded: %v", info)
return nil
}
func (s *S3Client) Load(name string) (data []byte, err error) {
if s == nil || s.c == nil {
return nil, errors.New("s3 client was not initialised")
}
r, err := s.c.GetObject(context.Background(), s.bucket, name, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
defer func() { err = errors.Join(err, r.Close()) }()
stats, err := r.Stat()
log.Debug().Msgf("Downloaded: %v", stats)
dat, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return dat, nil
}
func (s *S3Client) Has(name string) bool {
if s == nil || s.c == nil {
return false
}
_, err := s.c.StatObject(context.Background(), s.bucket, name, minio.GetObjectOptions{})
return err == nil
}

View file

@ -0,0 +1,55 @@
package cloud
import (
"crypto/rand"
"testing"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
func TestS3(t *testing.T) {
t.Skip()
name := "test"
s3, err := NewS3Client(
"s3.tebi.io",
"cloudretro-001",
"",
"",
logger.Default(),
)
if err != nil {
t.Error(err)
}
buf := make([]byte, 1024*4)
// then we can call rand.Read.
_, err = rand.Read(buf)
if err != nil {
t.Error(err)
}
err = s3.Save(name, buf, map[string]string{"id": "test"})
if err != nil {
t.Error(err)
}
exists := s3.Has(name)
if !exists {
t.Errorf("don't exist, but shuld")
}
ne := s3.Has(name + "123213")
if ne {
t.Errorf("exists, but shouldn't")
}
dat, err := s3.Load(name)
if err != nil {
t.Error(err)
}
if len(dat) == 0 {
t.Errorf("should be something")
}
}

24
pkg/worker/cloud/store.go Normal file
View file

@ -0,0 +1,24 @@
package cloud
import (
"github.com/giongto35/cloud-game/v3/pkg/config"
"github.com/giongto35/cloud-game/v3/pkg/logger"
)
type Storage interface {
Save(name string, data []byte, tags map[string]string) (err error)
Load(name string) (data []byte, err error)
Has(name string) bool
}
func Store(conf config.Storage, log *logger.Logger) (Storage, error) {
var st Storage
var err error
switch conf.Provider {
case "s3":
st, err = NewS3Client(conf.S3Endpoint, conf.S3BucketName, conf.S3AccessKeyId, conf.S3SecretAccessKey, log)
case "coordinator":
default:
}
return st, err
}

View file

@ -14,6 +14,7 @@ type Connection interface {
Disconnect()
Id() com.Uid
ProcessPackets(func(api.In[com.Uid]) error) chan struct{}
SetErrorHandler(func(error))
Send(api.PT, any) ([]byte, error)
Notify(api.PT, any)
@ -66,84 +67,41 @@ func (c *coordinator) HandleRequests(w *Worker) chan struct{} {
if err != nil {
c.log.Panic().Err(err).Msg("WebRTC API creation has been failed")
}
skipped := api.Out{}
return c.ProcessPackets(func(x api.In[com.Uid]) (err error) {
var out api.Out
switch x.T {
case api.WebrtcInit:
if dat := api.Unwrap[api.WebrtcInitRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
out = c.HandleWebrtcInit(*dat, w, ap)
}
case api.WebrtcAnswer:
dat := api.Unwrap[api.WebrtcAnswerRequest[com.Uid]](x.Payload)
if dat == nil {
return api.ErrMalformed
}
c.HandleWebrtcAnswer(*dat, w)
case api.WebrtcIce:
dat := api.Unwrap[api.WebrtcIceCandidateRequest[com.Uid]](x.Payload)
if dat == nil {
return api.ErrMalformed
}
c.HandleWebrtcIceCandidate(*dat, w)
err = api.Do(x, func(d api.WebrtcInitRequest) { out = c.HandleWebrtcInit(d, w, ap) })
case api.StartGame:
if dat := api.Unwrap[api.StartGameRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
out = c.HandleGameStart(*dat, w)
}
case api.TerminateSession:
dat := api.Unwrap[api.TerminateSessionRequest[com.Uid]](x.Payload)
if dat == nil {
return api.ErrMalformed
}
c.HandleTerminateSession(*dat, w)
case api.QuitGame:
dat := api.Unwrap[api.GameQuitRequest[com.Uid]](x.Payload)
if dat == nil {
return api.ErrMalformed
}
c.HandleQuitGame(*dat, w)
err = api.Do(x, func(d api.StartGameRequest) { out = c.HandleGameStart(d, w) })
case api.SaveGame:
if dat := api.Unwrap[api.SaveGameRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
out = c.HandleSaveGame(*dat, w)
}
err = api.Do(x, func(d api.SaveGameRequest) { out = c.HandleSaveGame(d, w) })
case api.LoadGame:
if dat := api.Unwrap[api.LoadGameRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
out = c.HandleLoadGame(*dat, w)
}
err = api.Do(x, func(d api.LoadGameRequest) { out = c.HandleLoadGame(d, w) })
case api.ChangePlayer:
if dat := api.Unwrap[api.ChangePlayerRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
out = c.HandleChangePlayer(*dat, w)
}
case api.ToggleMultitap:
if dat := api.Unwrap[api.ToggleMultitapRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
c.HandleToggleMultitap(*dat, w)
}
err = api.Do(x, func(d api.ChangePlayerRequest) { out = c.HandleChangePlayer(d, w) })
case api.RecordGame:
if dat := api.Unwrap[api.RecordGameRequest[com.Uid]](x.Payload); dat == nil {
err, out = api.ErrMalformed, api.EmptyPacket
} else {
out = c.HandleRecordGame(*dat, w)
}
err = api.Do(x, func(d api.RecordGameRequest) { out = c.HandleRecordGame(d, w) })
case api.WebrtcAnswer:
err = api.Do(x, func(d api.WebrtcAnswerRequest) { c.HandleWebrtcAnswer(d, w) })
case api.WebrtcIce:
err = api.Do(x, func(d api.WebrtcIceCandidateRequest) { c.HandleWebrtcIceCandidate(d, w) })
case api.TerminateSession:
err = api.Do(x, func(d api.TerminateSessionRequest) { c.HandleTerminateSession(d, w) })
case api.QuitGame:
err = api.Do(x, func(d api.GameQuitRequest) { c.HandleQuitGame(d, w) })
case api.ResetGame:
err = api.Do(x, func(d api.ResetGameRequest) { c.HandleResetGame(d, w) })
default:
c.log.Warn().Msgf("unhandled packet type %v", x.T)
}
if out != skipped {
if out != (api.Out{}) {
w.cord.Route(x, &out)
}
return err
return
})
}
@ -151,6 +109,34 @@ func (c *coordinator) RegisterRoom(id string) { c.Notify(api.RegisterRoom, id) }
// CloseRoom sends a signal to coordinator which will remove that room from its list.
func (c *coordinator) CloseRoom(id string) { c.Notify(api.CloseRoom, id) }
func (c *coordinator) IceCandidate(candidate string, sessionId com.Uid) {
c.Notify(api.WebrtcIce, api.WebrtcIceCandidateRequest[com.Uid]{Stateful: api.Stateful[com.Uid]{Id: sessionId}, Candidate: candidate})
func (c *coordinator) IceCandidate(candidate string, sessionId string) {
c.Notify(api.WebrtcIce, api.WebrtcIceCandidateRequest{
Stateful: api.Stateful{Id: sessionId},
Candidate: candidate,
})
}
func (c *coordinator) SendLibrary(w *Worker) {
g := w.lib.GetAll()
var gg = make([]api.GameInfo, len(g))
for i, g := range g {
gg[i] = api.GameInfo(g)
}
c.Notify(api.LibNewGameList, api.LibGameListInfo{T: 1, List: gg})
}
func (c *coordinator) SendPrevSessions(w *Worker) {
sessions := w.lib.Sessions()
// extract ids from save states, i.e. sessions
var ids []string
for _, id := range sessions {
x, _ := api.ExplodeDeepLink(id)
ids = append(ids, x)
}
c.Notify(api.PrevSessions, api.PrevSessionInfo{List: ids})
}

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