From a69a934029185333405b570f10a0445c41f27526 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 19 Oct 2023 21:36:02 +0300 Subject: [PATCH 001/240] Remove unused csp param --- pkg/encoder/h264/x264.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go index ee7cb097..ae8634cb 100644 --- a/pkg/encoder/h264/x264.go +++ b/pkg/encoder/h264/x264.go @@ -11,7 +11,6 @@ type H264 struct { width int32 lumaSize int32 chromaSize int32 - csp int32 nnals int32 nals []*Nal @@ -82,7 +81,6 @@ func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) { 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), From e80e31da4283abe80ad0cb754d8ab24bfe2faad1 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 19 Oct 2023 22:26:34 +0300 Subject: [PATCH 002/240] Use low pass filter with GBA --- pkg/config/config.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 95dc4e4d..344aa10e 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -203,6 +203,9 @@ emulator: gba: lib: mgba_libretro roms: [ "gba", "gbc" ] + options: + mgba_audio_low_pass_filter: enabled + mgba_audio_low_pass_range: 40 pcsx: lib: pcsx_rearmed_libretro roms: [ "cue", "chd" ] From 9ec6541322fbd41838f4e26fe1285f3192f39fb6 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 20 Oct 2023 13:33:56 +0300 Subject: [PATCH 003/240] Update dependencies --- go.mod | 8 ++++---- go.sum | 14 ++++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index a5880268..82368bb6 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,10 @@ require ( 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/ice/v3 v3.0.2 + github.com/pion/interceptor v0.1.24 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.0-beta.5 + github.com/pion/webrtc/v4 v4.0.0-beta.6 github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.31.0 github.com/veandco/go-sdl2 v0.4.35 @@ -27,7 +27,7 @@ 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/mattn/go-isatty v0.0.20 // 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 diff --git a/go.sum b/go.sum index 62291f5c..fed062e6 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,9 @@ 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/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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/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= @@ -67,10 +68,11 @@ github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew 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/ice/v3 v3.0.2 h1:dNQnKsjLvOWz+PaI4tw1VnLYTp9adihC1HIASFGajmI= +github.com/pion/ice/v3 v3.0.2/go.mod h1:q3BDzTsxbqP0ySMSHrFuw2MYGUx/AC3WQfRGC5F/0Is= +github.com/pion/interceptor v0.1.24 h1:lN4ua3yUAJCgNKQKcZIM52wFjBgjN0r7shLj91PkJ0c= +github.com/pion/interceptor v0.1.24/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= @@ -101,8 +103,8 @@ github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouAN 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/webrtc/v4 v4.0.0-beta.6 h1:swTwlzDY+1zDtW7ogXjNwlUY0xW733UUIAUMNUTCkPw= +github.com/pion/webrtc/v4 v4.0.0-beta.6/go.mod h1:UcyD8jIeTkFqfYJqoHT9qwUSmrtacKaXxgOEujOdhZ8= 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= From da7059dc792b97111672171491fb79f8af03f8b3 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 20 Oct 2023 17:44:39 +0300 Subject: [PATCH 004/240] Tame logs --- pkg/worker/coordinatorhandlers.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 9fee6460..047e7d3a 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -162,7 +162,6 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest[com.Uid], w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { w.router.Remove(user) - c.log.Debug().Msgf(">>> users: %v", w.router.Users()) user.Disconnect() } } @@ -171,7 +170,6 @@ func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest[com. func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest[com.Uid], w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { w.router.Remove(user) - c.log.Debug().Msgf(">>> users: %v", w.router.Users()) } } From 377306dc80d8b3413fe6e2a1b49bfbc423572647 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 20 Oct 2023 17:50:48 +0300 Subject: [PATCH 005/240] Clean frontend tests --- pkg/worker/caged/libretro/frontend_test.go | 87 ++++++++++------------ 1 file changed, 41 insertions(+), 46 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index 60a08dee..0fa88f10 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -29,10 +29,11 @@ type TestFrontend struct { } type testRun struct { - room string - system string - rom string - emulationTicks int + name string + room string + system string + rom string + frames int } type game struct { @@ -138,22 +139,19 @@ 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 BenchmarkEmulators(b *testing.B) { @@ -180,36 +178,33 @@ 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}, } 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-- - } + _, _ = front.dumpState() + if err := front.Save(); err != nil { + t.Error(err) + } - 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() + hash1, hash2 := front.dumpState() + if hash1 != hash2 { + t.Errorf("It seems that the previous state is diffrent: %v != %v", hash1, hash2) + } - if snapshot1 != snapshot2 { - t.Errorf("It seems rom state save has failed: %v != %v", snapshot1, snapshot2) - } - - front.Shutdown() + front.Shutdown() + }) } } @@ -222,9 +217,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 { @@ -235,10 +230,10 @@ func TestLoad(t *testing.T) { 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)) + fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.frames)) mock.dumpState() if err := mock.Save(); err != nil { @@ -247,10 +242,10 @@ func TestLoad(t *testing.T) { 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)) + fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.frames)) mock.dumpState() if err := mock.Load(); err != nil { @@ -273,11 +268,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: sushi.system, rom: sushi.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: angua.system, rom: angua.rom, frames: 300}, seed: 42 + 42, }, } @@ -304,7 +299,7 @@ func TestStateConcurrency(t *testing.T) { _ = mock.Save() - for i := 0; i < test.run.emulationTicks; i++ { + for i := 0; i < test.run.frames; i++ { qLock.Lock() mock.Tick() qLock.Unlock() From 38dc69e4a2b88da3367a57dfc01da33a037cc0f4 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 20 Oct 2023 20:19:04 +0300 Subject: [PATCH 006/240] Show hanged rooms --- pkg/worker/coordinatorhandlers.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 047e7d3a..970f023d 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -79,7 +79,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke } user.Index = rq.PlayerIndex - r := w.router.FindRoom(rq.Rid) + r := w.router.FindRoom(rq.Room.Rid) if r == nil { // new room uid := rq.Room.Rid @@ -89,10 +89,13 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke game := games.GameMetadata(rq.Game) r = room.NewRoom[*room.GameSession](uid, nil, w.router.Users(), nil) - r.HandleClose = func() { c.CloseRoom(uid) } + r.HandleClose = func() { + c.CloseRoom(uid) + c.log.Debug().Msgf("room close request %v sent") + } if other := w.router.Room(); other != nil { - c.log.Error().Msgf("concurrent room creation: %v", uid) + c.log.Error().Msgf("concurrent room creation: %v / %v", uid, w.router.Room().Id()) return api.EmptyPacket } From 10c4cd9b7f313589ce6e35ac60de45639d89d768 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 20 Oct 2023 18:50:04 +0300 Subject: [PATCH 007/240] Add start/stop frontend lock --- pkg/worker/caged/libretro/frontend.go | 50 +++++++++++----------- pkg/worker/caged/libretro/frontend_test.go | 17 +++++++- pkg/worker/coordinatorhandlers.go | 2 +- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 052756a6..80392ded 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -66,7 +66,8 @@ type Frontend struct { th int // draw threads vw, vh int // out frame size - mu sync.Mutex + mu sync.Mutex + mui sync.Mutex DisableCanvasPool bool SaveOnClose bool @@ -198,7 +199,7 @@ func (f *Frontend) Shutdown() { f.SetAudioCb(noAudio) f.SetVideoCb(noVideo) f.mu.Unlock() - f.log.Debug().Msgf("frontend closed") + f.log.Debug().Msgf("frontend shutdown done") } func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) { @@ -214,11 +215,14 @@ func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) { func (f *Frontend) SetOnAV(fn func()) { f.nano.OnSystemAvInfo = fn } func (f *Frontend) Start() { - f.log.Debug().Msgf("Frontend start") + f.log.Debug().Msgf("frontend start") f.done = make(chan struct{}) f.nano.LastFrameTime = time.Now().UnixNano() + + f.mui.Lock() defer f.Shutdown() + defer f.mui.Unlock() if f.HasSave() { // advance 1 frame for Mupen save state @@ -248,34 +252,28 @@ func (f *Frontend) Start() { } } -func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C } -func (f *Frontend) Rotation() uint { return f.nano.Rot } +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.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) HashPath() string { return f.storage.GetSavePath() } 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) LoadGame(path string) error { return f.nano.LoadGame(path) } +func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C } +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) 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) 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() -} - -// 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) SetViewport(w, h int) { f.mu.Lock(); f.vw, f.vh = w, h; f.mu.Unlock() } +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) ViewportCalc() (nw int, nh int) { w, h := f.FrameSize() @@ -307,8 +305,11 @@ func (f *Frontend) ViewportCalc() (nw int, nh int) { } func (f *Frontend) Close() { - f.log.Debug().Msgf("frontend close called") + f.log.Debug().Msgf("frontend close") + close(f.done) + f.mui.Lock() + defer f.mui.Unlock() // 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") @@ -316,9 +317,8 @@ func (f *Frontend) Close() { f.log.Error().Err(err).Msg("save on quit failed") } } - - close(f.done) f.nano.Close() + f.log.Debug().Msgf("frontend closed") } // Save writes the current state to the filesystem. diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index 0fa88f10..7c13cb76 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "sync" "testing" + "time" "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/logger" @@ -67,9 +68,9 @@ 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) } @@ -354,6 +355,18 @@ func TestConcurrentInput(t *testing.T) { wg.Wait() } +func TestStartStop(t *testing.T) { + f1 := DefaultFrontend("sushi", sushi.system, sushi.rom) + go f1.Start() + time.Sleep(1 * time.Second) + f1.Close() + + f2 := DefaultFrontend("sushi", sushi.system, sushi.rom) + go f2.Start() + time.Sleep(100 * time.Millisecond) + f2.Close() +} + // expand joins a list of file path elements. func expand(p ...string) string { ph, _ := filepath.Abs(filepath.FromSlash(filepath.Join(p...))) diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 970f023d..7384c8c6 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -91,7 +91,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke r = room.NewRoom[*room.GameSession](uid, nil, w.router.Users(), nil) r.HandleClose = func() { c.CloseRoom(uid) - c.log.Debug().Msgf("room close request %v sent") + c.log.Debug().Msgf("room close request %v sent", uid) } if other := w.router.Room(); other != nil { From 10507d9c53599367dd375c5177c10aef93e7e373 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 21 Oct 2023 00:07:10 +0300 Subject: [PATCH 008/240] Reorder shutdown functions --- pkg/worker/caged/libretro/frontend.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 80392ded..462a10d2 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -217,11 +217,20 @@ func (f *Frontend) SetOnAV(fn func()) { f.nano.OnSystemAvInfo = fn } func (f *Frontend) Start() { f.log.Debug().Msgf("frontend start") + f.mui.Lock() f.done = make(chan struct{}) f.nano.LastFrameTime = time.Now().UnixNano() - f.mui.Lock() - 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.Shutdown() + }() defer f.mui.Unlock() if f.HasSave() { @@ -306,17 +315,11 @@ func (f *Frontend) ViewportCalc() (nw int, nh int) { func (f *Frontend) Close() { f.log.Debug().Msgf("frontend close") + close(f.done) f.mui.Lock() defer f.mui.Unlock() - // 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.nano.Close() f.log.Debug().Msgf("frontend closed") } From cb968d782a6b31c82a1eab32e36463dd929e2400 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 21 Oct 2023 02:37:44 +0300 Subject: [PATCH 009/240] Show rooms in the list --- pkg/api/coordinator.go | 1 + pkg/coordinator/hub.go | 9 +++++++-- web/js/workerManager.js | 13 ++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/pkg/api/coordinator.go b/pkg/api/coordinator.go index 6c79bc8b..9cdf22b7 100644 --- a/pkg/api/coordinator.go +++ b/pkg/api/coordinator.go @@ -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"` } diff --git a/pkg/coordinator/hub.go b/pkg/coordinator/hub.go index 8d19fb4f..8b57df74 100644 --- a/pkg/coordinator/hub.go +++ b/pkg/coordinator/hub.go @@ -144,8 +144,9 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc { } func (h *Hub) GetServerList() (r []api.Server) { + debug := h.conf.Coordinator.Debug h.workers.ForEach(func(w *Worker) { - r = append(r, api.Server{ + server := api.Server{ Addr: w.Addr, Id: w.Id(), IsBusy: !w.HasSlot(), @@ -154,7 +155,11 @@ 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 } diff --git a/web/js/workerManager.js b/web/js/workerManager.js index d8fe4d18..56f463e4 100644 --- a/web/js/workerManager.js +++ b/web/js/workerManager.js @@ -37,7 +37,7 @@ const workerManager = (() => { }, 'is_busy': { caption: 'State', - renderer: (data) => data?.is_busy === true ? 'R' : '' + renderer: renderStateEl }, 'use': { caption: 'Use', @@ -112,6 +112,17 @@ const workerManager = (() => { }) } + function renderStateEl(server) { + const state = server?.is_busy === true ? 'R' : '' + if (server.room) { + return gui.create('a', (el) => { + el.innerText = state; + el.href = "/?id="+server.room; + }) + } + return state + } + panel.toggle(false); trigger.addEventListener('click', () => { From 3e116fcc52d3f2dd01e0a9d828e54f4986577341 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 21 Oct 2023 18:45:38 +0300 Subject: [PATCH 010/240] Drop users when coordinator is lost --- pkg/worker/coordinatorhandlers.go | 2 + pkg/worker/room/room.go | 12 ++++- pkg/worker/room/room_test.go | 28 ------------ pkg/worker/room/router_test.go | 74 +++++++++++++++++++++++++++++++ pkg/worker/worker.go | 5 ++- 5 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 pkg/worker/room/router_test.go diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 7384c8c6..737c7bd9 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -165,6 +165,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest[com.Uid], w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { w.router.Remove(user) + c.log.Debug().Msgf(">>> users: %v", w.router.Users()) user.Disconnect() } } @@ -173,6 +174,7 @@ func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest[com. func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest[com.Uid], w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { w.router.Remove(user) + c.log.Debug().Msgf(">>> users: %v", w.router.Users()) } } diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index 51978532..0f111456 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -119,7 +119,7 @@ func (r *Router[T]) FindRoom(id string) *Room[T] { func (r *Router[T]) Remove(user T) { if left := r.users.RemoveL(user); left == 0 { r.Close() - r.SetRoom(nil) + r.SetRoom(nil) // !to remove } } @@ -130,6 +130,16 @@ func (r *Router[T]) Room() *Room[T] { r.mu.Lock(); defer r.mu.Unlock() func (r *Router[T]) SetRoom(room *Room[T]) { r.mu.Lock(); r.room = room; r.mu.Unlock() } func (r *Router[T]) HasRoom() bool { r.mu.Lock(); defer r.mu.Unlock(); return r.room != nil } func (r *Router[T]) Users() SessionManager[T] { return r.users } +func (r *Router[T]) Reset() { + r.mu.Lock() + if r.room != nil { + r.room.Close() + r.room = nil + } + r.users.ForEach(func(u T) { u.Disconnect() }) + r.users.Reset() + r.mu.Unlock() +} type AppSession struct { Uid diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go index ed67e48c..e41004b9 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room/room_test.go @@ -269,34 +269,6 @@ func BenchmarkRoom(b *testing.B) { } } -type tSession struct{} - -func (t tSession) SendAudio([]byte, int32) {} -func (t tSession) SendVideo([]byte, int32) {} -func (t tSession) SendData([]byte) {} -func (t tSession) Disconnect() {} -func (t tSession) Id() string { return "1" } - -func TestRouter(t *testing.T) { - u := com.NewNetMap[string, *tSession]() - router := Router[*tSession]{users: &u} - - var r *Room[*tSession] - - router.SetRoom(&Room[*tSession]{id: "test001"}) - room := router.FindRoom("test001") - if room == nil { - t.Errorf("no room, but should be") - } - router.SetRoom(r) - room = router.FindRoom("x") - if room != nil { - t.Errorf("a room, but should not be") - } - router.SetRoom(nil) - router.Close() -} - // expand joins a list of file path elements. func expand(p ...string) string { ph, _ := filepath.Abs(filepath.FromSlash(filepath.Join(p...))) diff --git a/pkg/worker/room/router_test.go b/pkg/worker/room/router_test.go new file mode 100644 index 00000000..f404073c --- /dev/null +++ b/pkg/worker/room/router_test.go @@ -0,0 +1,74 @@ +package room + +import ( + "testing" + + "github.com/giongto35/cloud-game/v3/pkg/com" +) + +type tSession struct { + id string + connected bool +} + +func (t *tSession) SendAudio([]byte, int32) {} +func (t *tSession) SendVideo([]byte, int32) {} +func (t *tSession) SendData([]byte) {} +func (t *tSession) Connect() { t.connected = true } +func (t *tSession) Disconnect() { t.connected = false } +func (t *tSession) Id() string { return t.id } + +type lookMap struct { + com.NetMap[string, *tSession] + prev com.NetMap[string, *tSession] // we could use pointers in the original :3 +} + +func (l *lookMap) Reset() { + l.prev = com.NewNetMap[string, *tSession]() + l.Map.ForEach(func(s *tSession) { l.prev.Add(s) }) + l.NetMap.Reset() +} + +func TestRouter(t *testing.T) { + router := newTestRouter() + + var r *Room[*tSession] + + router.SetRoom(&Room[*tSession]{id: "test001"}) + room := router.FindRoom("test001") + if room == nil { + t.Errorf("no room, but should be") + } + router.SetRoom(r) + room = router.FindRoom("x") + if room != nil { + t.Errorf("a room, but should not be") + } + router.SetRoom(nil) + router.Close() +} + +func TestRouterReset(t *testing.T) { + u := lookMap{NetMap: com.NewNetMap[string, *tSession]()} + router := Router[*tSession]{users: &u} + + router.AddUser(&tSession{id: "1", connected: true}) + router.AddUser(&tSession{id: "2", connected: false}) + router.AddUser(&tSession{id: "3", connected: true}) + + router.Reset() + + disconnected := true + u.prev.ForEach(func(u *tSession) { disconnected = disconnected && !u.connected }) + if !disconnected { + t.Errorf("not all users were disconnected, but should") + } + if !router.Users().Empty() { + t.Errorf("has users after reset, but should not") + } +} + +func newTestRouter() *Router[*tSession] { + u := com.NewNetMap[string, *tSession]() + return &Router[*tSession]{users: &u} +} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 04e3ae27..6ff68e03 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -69,7 +69,7 @@ func New(conf config.WorkerConfig, log *logger.Logger) (*Worker, error) { return worker, nil } -func (w *Worker) Reset() { w.router.Close() } +func (w *Worker) Reset() { w.router.Reset() } func (w *Worker) Start(done chan struct{}) { for _, s := range w.services { @@ -77,6 +77,9 @@ func (w *Worker) Start(done chan struct{}) { s.Run() } } + + // !to restore alive worker info when coordinator connection was lost + go func() { remoteAddr := w.conf.Worker.Network.CoordinatorAddress defer func() { From 65251061160c384e3eae7b992e965cdeb4cccac5 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 24 Oct 2023 02:46:57 +0300 Subject: [PATCH 011/240] Update video encoders --- pkg/config/config.yaml | 2 + pkg/config/worker.go | 5 ++- pkg/encoder/encoder.go | 54 +++++++++++++++---------- pkg/encoder/h264/libx264.go | 11 +++-- pkg/encoder/h264/x264.go | 73 +++++++++++++++------------------- pkg/encoder/h264/x264_test.go | 4 +- pkg/encoder/vpx/libvpx.go | 2 + pkg/worker/media/media.go | 29 +++----------- pkg/worker/media/media_test.go | 36 +++++++++++++---- 9 files changed, 116 insertions(+), 100 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 344aa10e..cbfe0ab5 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -260,6 +260,8 @@ encoder: video: # h264, vpx (VP8) 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: # Constant Rate Factor (CRF) 0-51 (default: 23) diff --git a/pkg/config/worker.go b/pkg/config/worker.go index ab6af2cc..c0c39adf 100644 --- a/pkg/config/worker.go +++ b/pkg/config/worker.go @@ -52,8 +52,9 @@ type Audio struct { } type Video struct { - Codec string - H264 struct { + Codec string + Threads int + H264 struct { Crf uint8 LogLevel int32 Preset string diff --git a/pkg/encoder/encoder.go b/pkg/encoder/encoder.go index 60e960d0..a9f9003c 100644 --- a/pkg/encoder/encoder.go +++ b/pkg/encoder/encoder.go @@ -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" ) @@ -16,6 +18,7 @@ type ( LoadBuf(input []byte) Encode() []byte IntraRefresh() + Info() string SetFlip(bool) Shutdown() error } @@ -28,7 +31,6 @@ type Video struct { y yuv.Conv pf yuv.PixFmt rot uint - mu sync.Mutex } type VideoCodec string @@ -36,6 +38,7 @@ type VideoCodec string const ( H264 VideoCodec = "h264" VP8 VideoCodec = "vp8" + VPX VideoCodec = "vpx" ) // NewVideoEncoder returns new video encoder. @@ -43,13 +46,27 @@ 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 + switch VideoCodec(conf.Codec) { + case H264: + opts := h264.Options(conf.H264) + enc, err = h264.NewEncoder(dw, dh, conf.Threads, &opts) + case VP8, VPX: + opts := vpx.Options(conf.Vpx) + enc, err = vpx.NewEncoder(dw, dh, &opts) + default: + err = fmt.Errorf("unsupported codec: %v", conf.Codec) + } + if err != nil { + return nil, err + } + + 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 } @@ -64,7 +81,9 @@ func (v *Video) Encode(frame InFrame) OutFrame { 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) { switch f { @@ -77,16 +96,10 @@ 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 a > 0 { + v.rot = (a + 180) % 360 } } @@ -94,11 +107,12 @@ func (v *Video) SetRot(r uint) { func (v *Video) SetFlip(b bool) { v.codec.SetFlip(b) } func (v *Video) Stop() { - v.stopped.Store(true) - v.mu.Lock() - defer v.mu.Unlock() + 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") } diff --git a/pkg/encoder/h264/libx264.go b/pkg/encoder/h264/libx264.go index 0539b437..6be21eb6 100644 --- a/pkg/encoder/h264/libx264.go +++ b/pkg/encoder/h264/libx264.go @@ -8,6 +8,11 @@ package h264 #include "stdint.h" #include "x264.h" #include + +static int x264_encode( x264_t *h, uintptr_t pp_nal, int *pi_nal, x264_picture_t *pic_in, x264_picture_t *pic_out ) { + return x264_encoder_encode(h, (x264_nal_t **)pp_nal, pi_nal, pic_in, pic_out); +} + */ import "C" import "unsafe" @@ -505,16 +510,16 @@ func EncoderOpen(param *Param) *T { // 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 { +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])) + cppNal := C.uintptr_t(uintptr(unsafe.Pointer(ppNal))) cpiNal := (*C.int)(unsafe.Pointer(piNal)) cpicIn := picIn.cptr() cpicOut := picOut.cptr() - return (int32)(C.x264_encoder_encode(cenc, cppNal, cpiNal, cpicIn, cpicOut)) + return (int32)(C.x264_encode(cenc, cppNal, cpiNal, cpicIn, cpicOut)) } // EncoderClose closes an encoder handler. diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go index ae8634cb..8b10ce58 100644 --- a/pkg/encoder/h264/x264.go +++ b/pkg/encoder/h264/x264.go @@ -8,12 +8,10 @@ import ( type H264 struct { ref *T - width int32 - lumaSize int32 - chromaSize int32 - nnals int32 - nals []*Nal - + pnals *Nal // array of NALs + nnals int32 // number of NALs + y int32 // Y size + uv int32 // U or V size in, out *Picture } @@ -32,11 +30,11 @@ type Options struct { Tune string } -func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) { +func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { libVersion := LibVersion() - if libVersion < 150 { - return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", libVersion) + if libVersion < 156 { + return nil, fmt.Errorf("x264: the library version should be newer than v155, you have got version %v", libVersion) } if opts == nil { @@ -63,39 +61,28 @@ func NewEncoder(w, h int, opts *Options) (encoder *H264, err error) { } } - // legacy encoder lacks of this param - param.IBitdepth = 8 + ww, hh := int32(w), int32(h) - if libVersion > 155 { - param.ICsp = CspI420 - } else { - param.ICsp = 1 - } - param.IWidth = int32(w) - param.IHeight = int32(h) + param.IBitdepth = 8 + param.ICsp = CspI420 + param.IWidth = ww + param.IHeight = hh param.ILogLevel = opts.LogLevel param.ISyncLookahead = 0 - param.IThreads = 1 - + param.IThreads = int32(th) + if th != 1 { + param.BSlicedThreads = 1 + } param.Rc.IRcMethod = RcCrf param.Rc.FRfConstant = float32(opts.Crf) encoder = &H264{ - lumaSize: param.IWidth * param.IHeight, - chromaSize: param.IWidth * param.IHeight / 4, - nals: make([]*Nal, 1), - width: param.IWidth, - out: new(Picture), + y: ww * hh, + uv: ww * hh / 4, + pnals: new(Nal), + 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, - }, - }, + Img: Image{ICsp: param.ICsp, IPlane: 3, IStride: [4]int32{0: ww, 1: ww >> 1, 2: ww >> 1}}, }, } @@ -109,23 +96,27 @@ 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])) + e.in.Img.Plane[1] = uintptr(unsafe.Pointer(&yuv[e.y])) + e.in.Img.Plane[2] = uintptr(unsafe.Pointer(&yuv[e.y+e.uv])) } -func (e *H264) Encode() []byte { +func (e *H264) Encode() (b []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)) + bytes := EncoderEncode(e.ref, &e.pnals, &e.nnals, e.in, e.out) + if bytes > 0 { + // we merge multiple NALs stored in **pnals into a single byte stream + // ret contains the total size of NALs in bytes, i.e. each e.pnals[...].PPayload * IPayload + b = unsafe.Slice((*byte)(e.pnals.PPayload), bytes) } - return []byte{} + return } func (e *H264) IntraRefresh() { // !to implement } +func (e *H264) Info() string { return fmt.Sprintf("x264: v%v", LibVersion()) } + func (e *H264) SetFlip(b bool) { if b { e.in.Img.ICsp |= CspVflip diff --git a/pkg/encoder/h264/x264_test.go b/pkg/encoder/h264/x264_test.go index e819ba18..b13c0bc4 100644 --- a/pkg/encoder/h264/x264_test.go +++ b/pkg/encoder/h264/x264_test.go @@ -3,7 +3,7 @@ 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) } @@ -17,7 +17,7 @@ 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) } diff --git a/pkg/encoder/vpx/libvpx.go b/pkg/encoder/vpx/libvpx.go index ca423e0d..81988b9c 100644 --- a/pkg/encoder/vpx/libvpx.go +++ b/pkg/encoder/vpx/libvpx.go @@ -163,6 +163,8 @@ func (vpx *Vpx) Encode() []byte { return C.GoBytes(fb.ptr, fb.size) } +func (vpx *Vpx) Info() string { return fmt.Sprintf("vpx: %v", C.GoString(C.vpx_codec_version_str())) } + func (vpx *Vpx) IntraRefresh() { // !to implement } diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index 7c9a242e..588259ac 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -8,9 +8,7 @@ import ( "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/encoder" - "github.com/giongto35/cloud-game/v3/pkg/encoder/h264" "github.com/giongto35/cloud-game/v3/pkg/encoder/opus" - "github.com/giongto35/cloud-game/v3/pkg/encoder/vpx" "github.com/giongto35/cloud-game/v3/pkg/logger" "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" ) @@ -145,6 +143,7 @@ func (wmp *WebrtcMediaPipe) Init() error { if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil { return err } + wmp.log.Debug().Msgf("%v", wmp.v.Info()) return nil } @@ -175,29 +174,11 @@ func (wmp *WebrtcMediaPipe) encodeAudio(pcm samples) { wmp.onAudio(data) } -func (wmp *WebrtcMediaPipe) initVideo(w, h int, scale float64, conf config.Video) error { - var enc encoder.Encoder - var err error - +func (wmp *WebrtcMediaPipe) initVideo(w, h int, scale float64, conf config.Video) (err error) { sw, sh := round(w, scale), round(h, scale) - - wmp.log.Debug().Msgf("Scale: %vx%v -> %vx%v", w, h, sw, sh) - - wmp.log.Info().Msgf("Video codec: %v", conf.Codec) - if conf.Codec == string(encoder.H264) { - wmp.log.Debug().Msgf("x264: build v%v", h264.LibVersion()) - opts := h264.Options(conf.H264) - enc, err = h264.NewEncoder(sw, sh, &opts) - } else { - opts := vpx.Options(conf.Vpx) - enc, err = vpx.NewEncoder(sw, sh, &opts) - } - if err != nil { - return fmt.Errorf("couldn't create a video encoder: %w", err) - } - wmp.v = encoder.NewVideoEncoder(enc, w, h, scale, wmp.log) - wmp.log.Debug().Msgf("%v", wmp.v.Info()) - return nil + wmp.v, err = encoder.NewVideoEncoder(w, h, sw, sh, scale, conf, wmp.log) + wmp.log.Debug().Msgf("media scale: %vx%v -> %vx%v", w, h, sw, sh) + return err } func round(x int, scale float64) int { return (int(float64(x)*scale) + 1) & ^1 } diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index e99522ef..93568cc2 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -6,9 +6,8 @@ import ( "reflect" "testing" + "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/encoder" - "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/logger" ) @@ -38,15 +37,36 @@ func BenchmarkH264(b *testing.B) { run(1920, 1080, encoder.H264, b.N, nil, nil, func BenchmarkVP8(b *testing.B) { run(1920, 1080, encoder.VP8, b.N, nil, nil, b) } func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RGBA, backend testing.TB) { - var enc encoder.Encoder - if cod == encoder.H264 { - enc, _ = h264.NewEncoder(w, h, nil) - } else { - enc, _ = vpx.NewEncoder(w, h, nil) + conf := config.Video{ + Codec: string(cod), + Threads: 0, + H264: struct { + Crf uint8 + LogLevel int32 + Preset string + Profile string + Tune string + }{ + Crf: 30, + LogLevel: 0, + Preset: "ultrafast", + Profile: "baseline", + Tune: "zerolatency", + }, + Vpx: struct { + Bitrate uint + KeyframeInterval uint + }{ + Bitrate: 1000, + KeyframeInterval: 5, + }, } logger.SetGlobalLevel(logger.Disabled) - ve := encoder.NewVideoEncoder(enc, w, h, 1, l) + ve, err := encoder.NewVideoEncoder(w, h, w, h, 1, conf, l) + if err != nil { + backend.Error(err) + } defer ve.Stop() if a == nil { From 07f40351fad89ea27e7c21b44a48e6e530daca05 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 24 Oct 2023 12:41:40 +0300 Subject: [PATCH 012/240] Show PCSX boot logo by default --- pkg/config/config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index cbfe0ab5..eeec4122 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -213,6 +213,7 @@ 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: From 7f2f1d70b11f48c38d63166bc7bad0e1f1e7987b Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 25 Oct 2023 21:41:36 +0300 Subject: [PATCH 013/240] Add configurable debouncer for spammy Libretro callbacks --- pkg/config/config.yaml | 3 + pkg/config/emulator.go | 1 + pkg/worker/caged/libretro/caged.go | 14 +- pkg/worker/caged/libretro/frontend.go | 39 +++--- pkg/worker/caged/libretro/frontend_test.go | 8 +- .../caged/libretro/nanoarch/nanoarch.go | 121 +++++++++++------- .../caged/libretro/nanoarch/nanoarch_test.go | 22 ++++ pkg/worker/coordinatorhandlers.go | 9 +- 8 files changed, 131 insertions(+), 86 deletions(-) create mode 100644 pkg/worker/caged/libretro/nanoarch/nanoarch_test.go diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index eeec4122..3ffc0891 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -139,6 +139,9 @@ emulator: 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 # Libretro cores logging level: DEBUG = 0, INFO, WARN, ERROR, DUMMY = INT_MAX logLevel: 1 cores: diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index f7d71fc3..84e57ed8 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -32,6 +32,7 @@ type LibretroConfig struct { } List map[string]LibretroCoreConfig } + DebounceMs int SaveCompression bool LogLevel int } diff --git a/pkg/worker/caged/libretro/caged.go b/pkg/worker/caged/libretro/caged.go index 2260b4c0..20f95847 100644 --- a/pkg/worker/caged/libretro/caged.go +++ b/pkg/worker/caged/libretro/caged.go @@ -46,21 +46,15 @@ func (c *Caged) ReloadFrontend() { 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 { 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 } @@ -87,7 +81,7 @@ func (c *Caged) EnableCloudStorage(uid string, storage 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) ViewportSize() (int, int) { return c.base.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() } diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 462a10d2..e33f6b0f 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -30,11 +30,8 @@ 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) @@ -112,8 +109,13 @@ 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) + if level == logger.DebugLevel { + level = logger.TraceLevel + nano.SetLogger(log.Extend(log.Level(level).With())) + } else { + nano.SetLogger(log) + } // Check if room is on local storage, if not, pull from GCS to local storage log.Info().Msgf("Local storage path: %v", conf.Storage) @@ -139,6 +141,12 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) { } 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 } @@ -212,7 +220,7 @@ func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) { f.nano.OnAudio = f.handleAudio } -func (f *Frontend) SetOnAV(fn func()) { f.nano.OnSystemAvInfo = fn } +func (f *Frontend) SetVideoChangeCb(fn func()) { f.nano.OnSystemAvInfo = fn } func (f *Frontend) Start() { f.log.Debug().Msgf("frontend start") @@ -264,7 +272,7 @@ func (f *Frontend) Start() { 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.GeometryBase() } +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) Input(player int, data []byte) { f.input.setInput(player, data) } @@ -279,14 +287,14 @@ 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) SetVideoCb(ff func(app.Video)) { f.onVideo = ff } -func (f *Frontend) SetViewport(w, h int) { f.mu.Lock(); f.vw, f.vh = w, h; f.mu.Unlock() } func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f.mu.Unlock() } func (f *Frontend) ToggleMultitap() { f.nano.ToggleMultitap() } +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) ViewportCalc() (nw int, nh int) { w, h := f.FrameSize() - f.log.Debug().Msgf("Viewport source size: %dx%d", w, h) + nw, nh = w, h aspect, aw, ah := f.conf.AspectRatio.Keep, f.conf.AspectRatio.Width, f.conf.AspectRatio.Height // calc the aspect ratio @@ -298,29 +306,24 @@ func (f *Frontend) ViewportCalc() (nw int, nh int) { 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 } 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") - close(f.done) f.mui.Lock() - defer f.mui.Unlock() f.nano.Close() + f.mui.Unlock() f.log.Debug().Msgf("frontend closed") } diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index 7c13cb76..ab64042c 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -128,8 +128,7 @@ func (emu *TestFrontend) loadRom(game string) { if err != nil { log.Fatal(err) } - w, h := emu.FrameSize() - emu.SetViewport(w, h) + emu.ViewportRecalculate() } // Shutdown closes the emulator and cleans its resources. @@ -228,31 +227,26 @@ func TestLoad(t *testing.T) { mock := DefaultFrontend(test.room, test.system, test.rom) - fmt.Printf("[%-14v] ", "initial") mock.dumpState() for ticks := test.frames; ticks > 0; ticks-- { mock.Tick() } - fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.frames)) mock.dumpState() if err := mock.Save(); err != nil { t.Errorf("Save fail %v", err) } - fmt.Printf("[%-14v] ", "saved") snapshot1, _ := mock.dumpState() for ticks := test.frames; ticks > 0; ticks-- { mock.Tick() } - fmt.Printf("[%-14v] ", fmt.Sprintf("emulated %d", test.frames)) mock.dumpState() if err := mock.Load(); err != nil { t.Errorf("Load fail %v", err) } - fmt.Printf("[%-14v] ", "restored") snapshot2, _ := mock.dumpState() if snapshot1 != snapshot2 { diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 1b661201..4b412a3c 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -5,6 +5,7 @@ import ( "fmt" "runtime" "strings" + "sync" "sync/atomic" "time" "unsafe" @@ -47,13 +48,15 @@ type Nanoarch struct { enabled bool value 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 + options *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 + } tickTime int64 cSaveDirectory *C.char cSystemDirectory *C.char @@ -69,6 +72,7 @@ type Nanoarch struct { vfr bool sdlCtx *graphics.SDL hackSkipHwContextDestroy bool + limiter func(func()) log *logger.Logger } @@ -119,6 +123,7 @@ func (p PixFmt) String() string { var Nan0 = Nanoarch{ reserved: make(chan struct{}, 1), // this thing forbids concurrent use of the emulator stopped: atomic.Bool{}, + limiter: func(fn func()) { fn() }, Handlers: Handlers{ OnDpad: func(uint, uint) int16 { return 0 }, OnKeyPress: func(uint, int) int { return 0 }, @@ -139,18 +144,15 @@ 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) GeometryMax() (int, int) { - return int(n.sysAvInfo.geometry.max_width), int(n.sysAvInfo.geometry.max_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) 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) 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) CoreLoad(meta Metadata) { var err error @@ -219,16 +221,16 @@ func (n *Nanoarch) CoreLoad(meta Metadata) { C.bridge_retro_init(retroInit) } - C.bridge_retro_get_system_info(retroGetSystemInfo, &n.sysInfo) + C.bridge_retro_get_system_info(retroGetSystemInfo, &n.sys.i) 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)) + 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)) } 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 { @@ -256,17 +258,17 @@ func (n *Nanoarch) LoadGame(path string) error { return fmt.Errorf("core failed to load ROM: %v", path) } - C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &n.sysAvInfo) + C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &n.sys.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, + n.sys.av.geometry.base_width, n.sys.av.geometry.base_height, + n.sys.av.geometry.max_width, n.sys.av.geometry.max_height, + n.sys.av.timing.fps, n.sys.av.geometry.aspect_ratio, n.sys.av.timing.sample_rate, ) 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") } @@ -274,10 +276,9 @@ func (n *Nanoarch) LoadGame(path string) error { 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 + bufS := uint(n.sys.av.geometry.max_width*n.sys.av.geometry.max_height) * n.Video.PixFmt.BPP graphics.SetBuffer(int(bufS)) - n.log.Info().Msgf("Set buffer: %v", byteCountBinary(int64(bufS))) + n.log.Debug().Msgf("Set buffer: %v", byteCountBinary(int64(bufS))) if n.LibCo { C.same_thread(C.init_video_cgo) } else { @@ -414,9 +415,6 @@ func printOpenGLDriverInfo() { 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())) Nan0.log.Debug().Msg(openGLInfo.String()) @@ -655,7 +653,7 @@ func coreLog(level C.enum_retro_log_level, msg *C.char) { switch level { // with debug level cores have too much logs case C.RETRO_LOG_DEBUG: - Nan0.log.Debug().MsgFunc(func() string { return m(msg) }) + Nan0.log.Trace().MsgFunc(func() string { return m(msg) }) case C.RETRO_LOG_INFO: Nan0.log.Info().MsgFunc(func() string { return m(msg) }) case C.RETRO_LOG_WARN: @@ -688,18 +686,27 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { switch cmd { case C.RETRO_ENVIRONMENT_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() - } - }() + Nan0.sys.av = *(*C.struct_retro_system_av_info)(data) + Nan0.log.Debug().Msgf(">>> system av change: %v", Nan0.sys.av) + if Nan0.OnSystemAvInfo != nil { + go Nan0.OnSystemAvInfo() + } return true case C.RETRO_ENVIRONMENT_SET_GEOMETRY: geom := *(*C.struct_retro_game_geometry)(data) - Nan0.log.Info().Msgf(">>> GEOMETRY: %v", geom) + Nan0.log.Debug().Msgf(">>> geometry change: %v", geom) + // some cores are eager to change resolution too many times + // in a small period of time, thus we have some debouncer here + Nan0.limiter(func() { + lw := Nan0.sys.av.geometry.base_width + lh := Nan0.sys.av.geometry.base_height + if lw != geom.base_width || lh != geom.base_height { + Nan0.sys.av.geometry = geom + if Nan0.OnSystemAvInfo != nil { + go Nan0.OnSystemAvInfo() + } + } + }) return true case C.RETRO_ENVIRONMENT_SET_ROTATION: setRotation((*(*uint)(data) % 4) * 90) @@ -739,7 +746,7 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { //window.SetShouldClose(true) return false case C.RETRO_ENVIRONMENT_GET_VARIABLE: - if (*Nan0.options) == nil { + if Nan0.options == nil || *Nan0.options == nil { return false } rv := (*C.struct_retro_variable)(data) @@ -818,8 +825,8 @@ func initVideo() { sdl, err := graphics.NewSDLContext(graphics.Config{ Ctx: context, - W: int(Nan0.sysAvInfo.geometry.max_width), - H: int(Nan0.sysAvInfo.geometry.max_height), + 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), @@ -849,3 +856,23 @@ func deinitVideo() { Nan0.Video.gl.autoCtx = false Nan0.hackSkipHwContextDestroy = 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) +} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch_test.go b/pkg/worker/caged/libretro/nanoarch/nanoarch_test.go new file mode 100644 index 00000000..48f1152a --- /dev/null +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch_test.go @@ -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 i := 0; i < 10; i++ { + lim(func() { + c.Add(1) + }) + } + + if c.Load() > 1 { + t.Errorf("should be just 1") + } +} diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 737c7bd9..e675d33d 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -140,12 +140,13 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke m.SetPixFmt(app.PixFormat()) m.SetRot(app.Rotation()) - app.HandleOnSystemAvInfo(func() { + // recreate the video encoder + app.VideoChangeCb(func() { + app.ViewportRecalculate() m.VideoW, m.VideoH = app.ViewportSize() m.VideoScale = app.Scale() - err := m.Reinit() - if err != nil { - c.log.Error().Err(err).Msgf("av reinit fail") + if err := m.Reinit(); err != nil { + c.log.Error().Err(err).Msgf("reinit fail") } }) From 99ceb5d72cb54eb9b615d03befacc0eaddc8b8a2 Mon Sep 17 00:00:00 2001 From: guangwu Date: Thu, 26 Oct 2023 20:10:56 +0800 Subject: [PATCH 014/240] fix: typo --- pkg/encoder/yuv/yuv_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/encoder/yuv/yuv_test.go b/pkg/encoder/yuv/yuv_test.go index 3f07aa69..2575d486 100644 --- a/pkg/encoder/yuv/yuv_test.go +++ b/pkg/encoder/yuv/yuv_test.go @@ -119,7 +119,7 @@ func TestYuvPredefined(t *testing.T) { t.Logf("%v", v) if len(a) != len(should) { - t.Fatalf("diffrent size a: %v, o: %v", len(a), len(should)) + t.Fatalf("different size a: %v, o: %v", len(a), len(should)) } for i := 0; i < len(a); i++ { From 5d65ff14d554be98929b096075a78ab0d6f30d32 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 26 Oct 2023 16:39:00 +0300 Subject: [PATCH 015/240] Fix test --- pkg/worker/caged/libretro/frontend_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index ab64042c..50dd78c6 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -200,7 +200,7 @@ func TestSavePersistence(t *testing.T) { hash1, hash2 := front.dumpState() if hash1 != hash2 { - t.Errorf("It seems that the previous state is diffrent: %v != %v", hash1, hash2) + t.Errorf("%v != %v", hash1, hash2) } front.Shutdown() From ad07ad2014f89871618719500c2284e01a24e2eb Mon Sep 17 00:00:00 2001 From: Himaj333 <78681144+HimajPatil@users.noreply.github.com> Date: Thu, 26 Oct 2023 19:22:06 +0530 Subject: [PATCH 016/240] Fixed README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index db4e23cf..ae86ce7e 100644 --- a/README.md +++ b/README.md @@ -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 From d805ba8eb83d0a509c18b250628bc78938cee79b Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 1 Nov 2023 01:25:29 +0300 Subject: [PATCH 017/240] Update dependencies --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 82368bb6..e11342a3 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/VictoriaMetrics/metrics v1.24.0 github.com/cavaliergopher/grab/v3 v3.0.1 - github.com/fsnotify/fsnotify v1.6.0 + github.com/fsnotify/fsnotify v1.7.0 github.com/goccy/go-json v0.10.2 github.com/gofrs/flock v0.8.1 github.com/gorilla/websocket v1.5.0 @@ -25,7 +25,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/uuid v1.3.1 // indirect + github.com/google/uuid v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index fed062e6..1eb82e82 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ 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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 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= @@ -29,8 +29,9 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw 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/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -184,7 +185,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 2e91feb861de737fd4f28b08051097cd84c9dddb Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 1 Nov 2023 01:21:58 +0300 Subject: [PATCH 018/240] 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. --- pkg/api/api.go | 10 +- pkg/api/user.go | 4 + pkg/api/worker.go | 7 ++ pkg/config/config.yaml | 12 +-- pkg/config/emulator.go | 34 +++---- pkg/coordinator/userapi.go | 4 +- pkg/coordinator/userhandlers.go | 2 +- pkg/network/webrtc/webrtc.go | 27 +++--- pkg/worker/caged/app/app.go | 3 + pkg/worker/caged/libretro/caged.go | 5 +- pkg/worker/caged/libretro/frontend.go | 43 ++++----- .../caged/libretro/nanoarch/nanoarch.go | 91 +++++++++++-------- pkg/worker/coordinatorhandlers.go | 44 ++++++--- pkg/worker/media/media.go | 8 ++ pkg/worker/room/room.go | 1 + web/css/main.css | 29 +++--- web/index.html | 34 +++---- web/js/api/api.js | 13 ++- web/js/controller.js | 14 ++- web/js/event/event.js | 2 + web/js/network/webrtc.js | 27 +++--- web/js/stream/stream.js | 49 +++++++++- 22 files changed, 296 insertions(+), 167 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 6a8e96f6..f85daa8d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -62,8 +62,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 @@ -84,6 +85,7 @@ const ( CloseRoom PT = 202 IceCandidate = WebrtcIce TerminateSession PT = 204 + AppVideoChange PT = 150 ) func (p PT) String() string { @@ -124,6 +126,8 @@ func (p PT) String() string { return "CloseRoom" case TerminateSession: return "TerminateSession" + case AppVideoChange: + return "AppVideoChange" default: return "Unknown" } @@ -160,3 +164,5 @@ 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) } diff --git a/pkg/api/user.go b/pkg/api/user.go index 84d8ee62..aef4305d 100644 --- a/pkg/api/user.go +++ b/pkg/api/user.go @@ -11,6 +11,10 @@ type ( RecordUser string `json:"record_user,omitempty"` PlayerIndex int `json:"player_index"` } + GameStartUserResponse struct { + RoomId string `json:"roomId"` + Av *AppVideoInfo `json:"av"` + } IceServer struct { Urls string `json:"urls,omitempty"` Username string `json:"username,omitempty"` diff --git a/pkg/api/worker.go b/pkg/api/worker.go index b206c5c1..045fa429 100644 --- a/pkg/api/worker.go +++ b/pkg/api/worker.go @@ -33,6 +33,7 @@ type ( } StartGameResponse struct { Room + AV *AppVideoInfo `json:"av"` Record bool } RecordGameRequest[T Id] struct { @@ -59,4 +60,10 @@ type ( Stateful[T] } WebrtcInitResponse string + + AppVideoInfo struct { + W int `json:"w"` + H int `json:"h"` + A float32 `json:"a"` + } ) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 3ffc0891..13104af5 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -118,14 +118,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 @@ -189,6 +181,7 @@ emulator: # - isGlAllowed (bool) # - usesLibCo (bool) # - hasMultitap (bool) + # - coreAspectRatio (bool) -- correct the aspect ratio on the client with the info from the core. # - vfr (bool) # (experimental) # Enable variable frame rate only for cores that can't produce a constant frame rate. @@ -210,6 +203,7 @@ emulator: mgba_audio_low_pass_filter: enabled mgba_audio_low_pass_range: 40 pcsx: + coreAspectRatio: true lib: pcsx_rearmed_libretro roms: [ "cue", "chd" ] # example of folder override @@ -227,6 +221,8 @@ emulator: nes: lib: nestopia_libretro roms: [ "nes" ] + options: + nestopia_aspect: "uncorrected" snes: lib: snes9x_libretro roms: [ "smc", "sfc", "swc", "fig", "bs" ] diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index 84e57ed8..da8f5b2e 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -8,11 +8,6 @@ import ( type Emulator struct { Threads int - AspectRatio struct { - Keep bool - Width int - Height int - } Storage string LocalPath string Libretro LibretroConfig @@ -44,20 +39,21 @@ type LibretroRepoConfig struct { } 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 + HasMultitap bool + Height int + IsGlAllowed bool + Lib string + Options map[string]string + Roms []string + Scale float64 + UsesLibCo bool + VFR bool + Width int } type CoreInfo struct { diff --git a/pkg/coordinator/userapi.go b/pkg/coordinator/userapi.go index 4f922d9a..ed1ebcea 100644 --- a/pkg/coordinator/userapi.go +++ b/pkg/coordinator/userapi.go @@ -37,4 +37,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) { + u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av}) +} diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go index 426fd74f..81b6bf4f 100644 --- a/pkg/coordinator/userhandlers.go +++ b/pkg/coordinator/userhandlers.go @@ -56,7 +56,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) // send back recording status if conf.Recording.Enabled && rq.Record { diff --git a/pkg/network/webrtc/webrtc.go b/pkg/network/webrtc/webrtc.go index ed0c3ca6..f94d7915 100644 --- a/pkg/network/webrtc/webrtc.go +++ b/pkg/network/webrtc/webrtc.go @@ -32,13 +32,13 @@ 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 } @@ -49,7 +49,7 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp 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 } @@ -59,21 +59,19 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp 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 { + // plug in the [data] channel (in and out) + if err = p.addDataChannel("data"); err != nil { return "", err } - p.log.Debug().Msg("Added [input/bytes] chan") + p.log.Debug().Msg("Added [data] 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 { @@ -210,15 +208,16 @@ func (p *Peer) Disconnect() { p.log.Debug().Msg("WebRTC stop") } -// addInputChannel creates a new WebRTC data channel for user input. +// addDataChannel creates a new WebRTC data channel for user input. // Default params -- ordered: true, negotiated: false. -func (p *Peer) addInputChannel(label string) error { +func (p *Peer) addDataChannel(label string) error { ch, err := p.conn.CreateDataChannel(label, nil) if err != nil { return err } ch.OnOpen(func() { - p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).Msg("Data channel [input] opened") + p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()). + Msg("Data channel [input] opened") }) ch.OnError(p.logx) ch.OnMessage(func(m webrtc.DataChannelMessage) { diff --git a/pkg/worker/caged/app/app.go b/pkg/worker/caged/app/app.go index a1917b4d..fcf34fd9 100644 --- a/pkg/worker/caged/app/app.go +++ b/pkg/worker/caged/app/app.go @@ -2,6 +2,8 @@ package app type App interface { AudioSampleRate() int + AspectRatio() float32 + AspectEnabled() bool Init() error ViewportSize() (int, int) Start() @@ -9,6 +11,7 @@ type App interface { SetAudioCb(func(Audio)) SetVideoCb(func(Video)) + SetDataCb(func([]byte)) SendControl(port int, data []byte) } diff --git a/pkg/worker/caged/libretro/caged.go b/pkg/worker/caged/libretro/caged.go index 20f95847..f71017bf 100644 --- a/pkg/worker/caged/libretro/caged.go +++ b/pkg/worker/caged/libretro/caged.go @@ -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 { @@ -78,6 +75,8 @@ func (c *Caged) EnableCloudStorage(uid string, storage cloud.Storage) { } } +func (c *Caged) AspectEnabled() bool { return c.base.nano.Aspect } +func (c *Caged) AspectRatio() float32 { return c.base.AspectRatio() } func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() } func (c *Caged) Rotation() uint { return c.Emulator.Rotation() } func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() } diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index e33f6b0f..6a729cb3 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -3,7 +3,6 @@ package libretro import ( "errors" "fmt" - "math" "path/filepath" "sync" "sync/atomic" @@ -20,6 +19,7 @@ import ( type Emulator interface { SetAudioCb(func(app.Audio)) SetVideoCb(func(app.Video)) + SetDataCb(func([]byte)) LoadCore(name string) LoadGame(path string) error FPS() int @@ -57,6 +57,7 @@ type Frontend struct { log *logger.Logger nano *nanoarch.Nanoarch onAudio func(app.Audio) + onData func([]byte) onVideo func(app.Video) storage Storage scale float64 @@ -90,6 +91,7 @@ const ( var ( audioPool sync.Pool noAudio = func(app.Audio) {} + noData = func([]byte) {} noVideo = func(app.Video) {} videoPool sync.Pool ) @@ -135,6 +137,7 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) { input: NewGameSessionInput(), log: log, onAudio: noAudio, + onData: noData, onVideo: noVideo, storage: store, th: conf.Threads, @@ -153,14 +156,15 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) { func (f *Frontend) LoadCore(emu string) { conf := f.conf.GetLibretroCoreConfig(emu) 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, + Hacks: conf.Hacks, + HasMultitap: conf.HasMultitap, + HasVFR: conf.VFR, + IsGlAllowed: conf.IsGlAllowed, + LibPath: conf.Lib, + Options: conf.Options, + UsesLibCo: conf.UsesLibCo, + CoreAspectRatio: conf.CoreAspectRatio, } f.mu.Lock() scale := 1.0 @@ -224,6 +228,13 @@ func (f *Frontend) SetVideoChangeCb(fn func()) { f.nano.OnSystemAvInfo = fn } func (f *Frontend) Start() { f.log.Debug().Msgf("frontend start") + if f.nano.Stopped.Load() { + f.log.Warn().Msgf("frontend stopped during the start") + f.mui.Lock() + defer f.mui.Unlock() + f.Shutdown() + return + } f.mui.Lock() f.done = make(chan struct{}) @@ -269,6 +280,7 @@ func (f *Frontend) Start() { } } +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() } @@ -286,6 +298,7 @@ func (f *Frontend) SaveGameState() error { return f.Save() } 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) ToggleMultitap() { f.nano.ToggleMultitap() } @@ -296,18 +309,6 @@ func (f *Frontend) ViewportCalc() (nw int, nh int) { w, h := f.FrameSize() nw, nh = 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) - } - } - if f.IsPortrait() { nw, nh = nh, nw } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 4b412a3c..cd4d3649 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -52,7 +52,7 @@ type Nanoarch struct { reserved chan struct{} // limits concurrent use Rot uint serializeSize C.size_t - stopped atomic.Bool + Stopped atomic.Bool sys struct { av C.struct_retro_system_av_info i C.struct_retro_system_info @@ -70,6 +70,7 @@ type Nanoarch struct { PixFmt PixFmt } vfr bool + Aspect bool sdlCtx *graphics.SDL hackSkipHwContextDestroy bool limiter func(func()) @@ -91,14 +92,15 @@ 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 + 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 + CoreAspectRatio bool } type PixFmt struct { @@ -122,7 +124,7 @@ 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 }, @@ -144,13 +146,14 @@ func NewNano(localPath string) *Nanoarch { return nano } +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) 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) 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) } @@ -158,6 +161,7 @@ func (n *Nanoarch) CoreLoad(meta Metadata) { var err error n.LibCo = meta.UsesLibCo n.vfr = meta.HasVFR + n.Aspect = meta.CoreAspectRatio n.Video.gl.autoCtx = meta.AutoGlContext n.Video.gl.enabled = meta.IsGlAllowed @@ -258,12 +262,17 @@ func (n *Nanoarch) LoadGame(path string) error { return fmt.Errorf("core failed to load ROM: %v", path) } - C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &n.sys.av) + 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.sys.av.geometry.base_width, n.sys.av.geometry.base_height, - n.sys.av.geometry.max_width, n.sys.av.geometry.max_height, - n.sys.av.timing.fps, n.sys.av.geometry.aspect_ratio, n.sys.av.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))) @@ -273,7 +282,7 @@ func (n *Nanoarch) LoadGame(path string) error { n.log.Info().Msgf("variable framerate (VFR) is enabled") } - n.stopped.Store(false) + n.Stopped.Store(false) if n.Video.gl.enabled { bufS := uint(n.sys.av.geometry.max_width*n.sys.av.geometry.max_height) * n.Video.PixFmt.BPP @@ -348,6 +357,7 @@ 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") } @@ -376,7 +386,7 @@ func (n *Nanoarch) Run() { } func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled } -func (n *Nanoarch) IsStopped() bool { return n.stopped.Load() } +func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() } func videoSetPixelFormat(format uint32) (C.bool, error) { switch format { @@ -550,7 +560,7 @@ var ( //export coreVideoRefresh func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { - if Nan0.stopped.Load() { + if Nan0.Stopped.Load() { Nan0.log.Warn().Msgf(">>> skip video") return } @@ -636,7 +646,7 @@ func coreAudioSample(l, r C.int16_t) { //export coreAudioSampleBatch func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t { - if Nan0.stopped.Load() { + if Nan0.Stopped.Load() { if Nan0.log.GetLevel() < logger.InfoLevel { Nan0.log.Warn().Msgf(">>> skip %v audio frames", frames) } @@ -686,27 +696,18 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { switch cmd { case C.RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO: - Nan0.sys.av = *(*C.struct_retro_system_av_info)(data) - Nan0.log.Debug().Msgf(">>> system av change: %v", Nan0.sys.av) - if Nan0.OnSystemAvInfo != nil { - go Nan0.OnSystemAvInfo() + Nan0.log.Debug().Msgf("retro_set_system_av_info") + av := *(*C.struct_retro_system_av_info)(data) + 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.Debug().Msgf(">>> geometry change: %v", geom) - // some cores are eager to change resolution too many times - // in a small period of time, thus we have some debouncer here - Nan0.limiter(func() { - lw := Nan0.sys.av.geometry.base_width - lh := Nan0.sys.av.geometry.base_height - if lw != geom.base_width || lh != geom.base_height { - Nan0.sys.av.geometry = geom - if Nan0.OnSystemAvInfo != nil { - go Nan0.OnSystemAvInfo() - } - } - }) + if isGeometryDifferent(geom) { + geometryChange(geom) + } return true case C.RETRO_ENVIRONMENT_SET_ROTATION: setRotation((*(*uint)(data) % 4) * 90) @@ -876,3 +877,21 @@ func (d *limit) push(f func()) { } 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.OnSystemAvInfo != nil { + Nan0.log.Debug().Msgf(">>> geometry change %v -> %v", old, geom) + if Nan0.Aspect { + 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 +} diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index e675d33d..d5d9dd8e 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -112,6 +112,31 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke r.SetApp(app) + m := media.NewWebRtcMediaPipe(w.conf.Encoder.Audio, w.conf.Encoder.Video, w.log) + + // recreate the video encoder + app.VideoChangeCb(func() { + app.ViewportRecalculate() + m.VideoW, m.VideoH = app.ViewportSize() + m.VideoScale = app.Scale() + + if m.IsInitialized() { + if err := m.Reinit(); err != nil { + c.log.Error().Err(err).Msgf("reinit fail") + } + } + + data, err := api.Wrap(api.Out{T: uint8(api.AppVideoChange), Payload: api.AppVideoInfo{ + W: m.VideoW, + H: m.VideoH, + A: app.AspectRatio(), + }}) + if err != nil { + c.log.Error().Err(err).Msgf("wrap") + } + r.Send(data) + }) + w.log.Info().Msgf("Starting the game: %v", rq.Game.Name) if err := app.Load(game, w.conf.Worker.Library.BasePath); err != nil { c.log.Error().Err(err).Msgf("couldn't load the game %v", game) @@ -120,7 +145,6 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke return api.EmptyPacket } - m := media.NewWebRtcMediaPipe(w.conf.Encoder.Audio, w.conf.Encoder.Video, w.log) m.AudioSrcHz = app.AudioSampleRate() m.AudioFrame = w.conf.Encoder.Audio.Frame m.VideoW, m.VideoH = app.ViewportSize() @@ -140,16 +164,6 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke m.SetPixFmt(app.PixFormat()) m.SetRot(app.Rotation()) - // recreate the video encoder - app.VideoChangeCb(func() { - app.ViewportRecalculate() - m.VideoW, m.VideoH = app.ViewportSize() - m.VideoScale = app.Scale() - if err := m.Reinit(); err != nil { - c.log.Error().Err(err).Msgf("reinit fail") - } - }) - r.BindAppMedia() r.StartApp() } @@ -159,7 +173,13 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke c.RegisterRoom(r.Id()) - return api.Out{Payload: api.StartGameResponse{Room: api.Room{Rid: r.Id()}, Record: w.conf.Recording.Enabled}} + response := api.StartGameResponse{Room: api.Room{Rid: r.Id()}, Record: w.conf.Recording.Enabled} + if r.App().AspectEnabled() { + ww, hh := r.App().ViewportSize() + response.AV = &api.AppVideoInfo{W: ww, H: hh, A: r.App().AspectRatio()} + } + + return api.Out{Payload: response} } // HandleTerminateSession handles cases when a user has been disconnected from the websocket of coordinator. diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index 588259ac..70758b1c 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -115,6 +115,8 @@ type WebrtcMediaPipe struct { VideoW, VideoH int VideoScale float64 + initialized bool + // keep the old settings for reinit oldPf uint32 oldRot uint @@ -144,6 +146,7 @@ func (wmp *WebrtcMediaPipe) Init() error { return err } wmp.log.Debug().Msgf("%v", wmp.v.Info()) + wmp.initialized = true return nil } @@ -188,6 +191,10 @@ func (wmp *WebrtcMediaPipe) ProcessVideo(v app.Video) []byte { } func (wmp *WebrtcMediaPipe) Reinit() error { + if !wmp.initialized { + return nil + } + wmp.v.Stop() if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil { return err @@ -199,6 +206,7 @@ func (wmp *WebrtcMediaPipe) Reinit() error { return nil } +func (wmp *WebrtcMediaPipe) IsInitialized() bool { return wmp.initialized } func (wmp *WebrtcMediaPipe) SetPixFmt(f uint32) { wmp.oldPf = f; wmp.v.SetPixFormat(f) } func (wmp *WebrtcMediaPipe) SetVideoFlip(b bool) { wmp.oldFlip = b; wmp.v.SetFlip(b) } func (wmp *WebrtcMediaPipe) SetRot(r uint) { wmp.oldRot = r; wmp.v.SetRot(r) } diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index 0f111456..c52f091d 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -81,6 +81,7 @@ func (r *Room[T]) Id() string { return r.id } func (r *Room[T]) SetApp(app app.App) { r.app = app } func (r *Room[T]) SetMedia(m MediaPipe) { r.media = m } func (r *Room[T]) StartApp() { r.app.Start() } +func (r *Room[T]) Send(data []byte) { r.users.ForEach(func(u T) { u.SendData(data) }) } func (r *Room[T]) Close() { if r == nil || r.closed { diff --git a/web/css/main.css b/web/css/main.css index d106b1be..5efbf875 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -22,7 +22,7 @@ body { display: flex; overflow: hidden; - width: 556px; + width: 640px; height: 286px; position: absolute; @@ -65,6 +65,11 @@ body { background-size: 100% 100%; } +#controls-right { + position: absolute; + left: 70px; +} + #circle-pad-holder { display: block; @@ -83,11 +88,10 @@ body { } #guide-txt { - color: #bababa; + color: #979797; font-size: 8px; top: 269px; - left: 30px; - width: 1000px; + left: 68px; position: absolute; user-select: none; @@ -166,7 +170,7 @@ body { align-items: center; justify-content: center; - width: 256px; + width: 320px; height: 240px; position: absolute; top: 23px; @@ -416,11 +420,11 @@ body { } .game-screen { - width: 100%; - height: 102%; /* lol */ - background-color: #222222; position: absolute; - display: flex; + object-fit: contain; + width: inherit; + height: inherit; + background-color: #222222; } #menu-screen { @@ -428,7 +432,7 @@ body { display: block; overflow: hidden; - width: 256px; + width: 320px; height: 240px; background-image: url('/img/screen_background5.png'); @@ -444,6 +448,7 @@ body { height: 36px; background-color: #FFCF9E; opacity: 0.75; + mix-blend-mode: lighten; top: 50%; left: 0; @@ -459,7 +464,7 @@ body { top: 102px; /* 240px - 36 / 2 */ left: 0; - z-index: 1; + /*z-index: 1;*/ } @@ -481,7 +486,7 @@ body { left: 15px; top: 7px; - width: 226px; + width: 288px; height: 25px; } diff --git a/web/index.html b/web/index.html index 4f44140f..e082abde 100644 --- a/web/index.html +++ b/web/index.html @@ -47,26 +47,28 @@
-
Arrows(move),ZXCVAS(game ABXYLR),1/2(1st/2nd player),Shift/Enter/K/L(select/start/save/load),F(fullscreen),share(copy - sharelink to clipboard) +
+ Arrows (move), ZXCVAS (game ABXYLR), 1/2 (1st/2nd player), Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy shared link to the clipboard)
- -
player choice
-
-
-
-
-
-
-
-
+
+ + +
+
+ +
+
+
+
+
+
@@ -125,16 +127,16 @@ - + - + - + - + diff --git a/web/js/api/api.js b/web/js/api/api.js index bbb21318..20de8312 100644 --- a/web/js/api/api.js +++ b/web/js/api/api.js @@ -21,6 +21,8 @@ const api = (() => { GAME_RECORDING: 110, GET_WORKER_LIST: 111, GAME_ERROR_NO_FREE_SLOTS: 112, + + APP_VIDEO_CHANGE: 150, }); const packet = (type, payload, id) => { @@ -31,18 +33,21 @@ const api = (() => { socket.send(packet); }; + const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b)) + return Object.freeze({ endpoint: endpoints, + decode: (b) => JSON.parse(decodeBytes(b)), server: - Object.freeze({ + { initWebrtc: () => packet(endpoints.INIT_WEBRTC), sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))), sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))), latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id), getWorkerList: () => packet(endpoints.GET_WORKER_LIST), - }), + }, game: - Object.freeze({ + { load: () => packet(endpoints.GAME_LOAD), save: () => packet(endpoints.GAME_SAVE), setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), @@ -60,6 +65,6 @@ const api = (() => { user: userName, }), quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), - }) + } }) })(socket); diff --git a/web/js/controller.js b/web/js/controller.js index 5ae5443c..edd0e61e 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -153,8 +153,8 @@ const saveGame = utils.debounce(() => api.game.save(), 1000); const loadGame = utils.debounce(() => api.game.load(), 1000); - const onMessage = (message) => { - const {id, t, p: payload} = message; + const onMessage = (m) => { + const {id, t, p: payload} = m; switch (t) { case api.endpoint.INIT: event.pub(WEBRTC_NEW_CONNECTION, payload); @@ -166,7 +166,10 @@ event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); break; case api.endpoint.GAME_START: - event.pub(GAME_ROOM_AVAILABLE, {roomId: payload}); + if (payload.av) { + event.pub(APP_VIDEO_CHANGED, payload.av) + } + event.pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId}); break; case api.endpoint.GAME_SAVE: event.pub(GAME_SAVED); @@ -189,6 +192,9 @@ case api.endpoint.GAME_ERROR_NO_FREE_SLOTS: event.pub(GAME_ERROR_NO_FREE_SLOTS); break; + case api.endpoint.APP_VIDEO_CHANGE: + event.pub(APP_VIDEO_CHANGED, {...payload}) + break; } } @@ -429,6 +435,7 @@ event.sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500)); event.sub(WEBRTC_NEW_CONNECTION, (data) => { workerManager.whoami(data.wid); + webrtc.onData = (x) => onMessage(api.decode(x.data)) webrtc.start(data.ice); api.server.initWebrtc() gameList.set(data.games); @@ -438,7 +445,6 @@ event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el())); event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)); event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); - // event.sub(MEDIA_STREAM_READY, () => rtcp.start()); event.sub(WEBRTC_CONNECTION_READY, onConnectionReady); event.sub(WEBRTC_CONNECTION_CLOSED, () => { input.poll.disable(); diff --git a/web/js/event/event.js b/web/js/event/event.js index 9ad45a4b..3c412d20 100644 --- a/web/js/event/event.js +++ b/web/js/event/event.js @@ -101,3 +101,5 @@ const SETTINGS_CLOSED = 'settingsClosed'; const RECORDING_TOGGLED = 'recordingToggle' const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' + +const APP_VIDEO_CHANGED = 'appVideoChanged' diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js index 99c5432a..8d72c8fd 100644 --- a/web/js/network/webrtc.js +++ b/web/js/network/webrtc.js @@ -12,16 +12,16 @@ */ const webrtc = (() => { let connection; - let inputChannel; + let dataChannel; let mediaStream; - let candidates = Array(); + let candidates = []; let isAnswered = false; let isFlushing = false; let connected = false; let inputReady = false; - let onMessage; + let onData; const start = (iceservers) => { log.info('[rtc] <- ICE servers', iceservers); @@ -31,16 +31,16 @@ const webrtc = (() => { connection.ondatachannel = e => { log.debug('[rtc] ondatachannel', e.channel.label) - inputChannel = e.channel; - inputChannel.onopen = () => { + dataChannel = e.channel; + dataChannel.onopen = () => { log.info('[rtc] the input channel has been opened'); inputReady = true; event.pub(WEBRTC_CONNECTION_READY) }; - if (onMessage) { - inputChannel.onmessage = onMessage; + if (onData) { + dataChannel.onmessage = onData; } - inputChannel.onclose = () => log.info('[rtc] the input channel has been closed'); + dataChannel.onclose = () => log.info('[rtc] the input channel has been closed'); } connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; connection.onicegatheringstatechange = ice.onIceStateChange; @@ -62,9 +62,9 @@ const webrtc = (() => { connection.close(); connection = null; } - if (inputChannel) { - inputChannel.close(); - inputChannel = null; + if (dataChannel) { + dataChannel.close(); + dataChannel = null; } candidates = Array(); log.info('[rtc] WebRTC has been closed'); @@ -173,10 +173,13 @@ const webrtc = (() => { // return false // } // }, - input: (data) => inputChannel.send(data), + input: (data) => dataChannel.send(data), isConnected: () => connected, isInputReady: () => inputReady, getConnection: () => connection, stop, + set onData(fn) { + onData = fn + } } })(event, log); diff --git a/web/js/stream/stream.js b/web/js/stream/stream.js index 64e65a16..9ab3dd49 100644 --- a/web/js/stream/stream.js +++ b/web/js/stream/stream.js @@ -16,6 +16,9 @@ const stream = (() => { state = { screen: screen, timerId: null, + w: 0, + h: 0, + aspect: 4/3 }; const mute = (mute) => screen.muted = mute @@ -82,6 +85,25 @@ const stream = (() => { useCustomScreen(options.mirrorMode === 'mirror'); }, false); + screen.addEventListener('fullscreenchange', () => { + const fullscreen = document.fullscreenElement + + + screen.style.padding = '0' + if (fullscreen) { + const dw = (window.innerWidth - fullscreen.clientHeight * state.aspect) / 2 + screen.style.padding = `0 ${dw}px` + // chrome bug + setTimeout(() => { + const dw = (window.innerHeight - fullscreen.clientHeight * state.aspect) / 2 + screen.style.padding = `0 ${dw}px` + }, 1) + } + + // !to flipped + + }) + const useCustomScreen = (use) => { if (use) { if (screen.paused || screen.ended) return; @@ -95,13 +117,15 @@ const stream = (() => { canvas.setAttribute('width', screen.videoWidth); canvas.setAttribute('height', screen.videoHeight); canvas.style['image-rendering'] = 'pixelated'; + canvas.style.width = '100%' + canvas.style.height = '100%' canvas.classList.add('game-screen'); // stretch depending on the video orientation // portrait -- vertically, landscape -- horizontally const isPortrait = screen.videoWidth < screen.videoHeight; canvas.style.width = isPortrait ? 'auto' : canvas.style.width; - canvas.style.height = isPortrait ? canvas.style.height : 'auto'; + // canvas.style.height = isPortrait ? canvas.style.height : 'auto'; let surface = canvas.getContext('2d'); screen.parentNode.insertBefore(canvas, screen.nextSibling); @@ -135,6 +159,27 @@ const stream = (() => { } }); + + const fit = 'contain' + + event.sub(APP_VIDEO_CHANGED, (payload) => { + const {w, h, a} = payload + + state.aspect = a + + const a2 = w / h + + const attr = a.toFixed(6) !== a2.toFixed(6) ? 'fill' : fit + state.screen.style['object-fit'] = attr + + state.h = payload.h + state.w = Math.floor(payload.h * payload.a) + // payload.a > 0 && (state.aspect = payload.a) + state.screen.setAttribute('width', payload.w) + state.screen.setAttribute('height', payload.h) + state.screen.style.aspectRatio = state.aspect + }) + return { audio: {mute}, video: {toggleFullscreen, el: getVideoEl}, @@ -144,4 +189,4 @@ const stream = (() => { init } } -)(env, gui, log, opts, settings); +)(env, event, gui, log, opts, settings); From e6e537d7991453168b58668c2c498445a7d7ea58 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 13 Nov 2023 18:46:41 +0300 Subject: [PATCH 019/240] Add a generic S3 provider for cloud saves --- go.mod | 29 ++++-- go.sum | 67 +++++++++----- pkg/config/config.yaml | 13 ++- pkg/config/worker.go | 7 +- pkg/worker/caged/libretro/caged.go | 16 ++-- pkg/worker/caged/libretro/cloud.go | 44 +++++---- pkg/worker/caged/libretro/frontend.go | 2 + pkg/worker/cloud/cloudstore.go | 128 -------------------------- pkg/worker/cloud/cloudstore_test.go | 54 ----------- pkg/worker/cloud/s3.go | 91 ++++++++++++++++++ pkg/worker/cloud/s3_test.go | 55 +++++++++++ pkg/worker/cloud/store.go | 24 +++++ pkg/worker/worker.go | 2 +- 13 files changed, 288 insertions(+), 244 deletions(-) delete mode 100644 pkg/worker/cloud/cloudstore.go delete mode 100644 pkg/worker/cloud/cloudstore_test.go create mode 100644 pkg/worker/cloud/s3.go create mode 100644 pkg/worker/cloud/s3_test.go create mode 100644 pkg/worker/cloud/store.go diff --git a/go.mod b/go.mod index e11342a3..df9b8594 100644 --- a/go.mod +++ b/go.mod @@ -11,44 +11,55 @@ require ( 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/minio/minio-go/v7 v7.0.63 github.com/pion/ice/v3 v3.0.2 - github.com/pion/interceptor v0.1.24 + github.com/pion/interceptor v0.1.25 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.0-beta.6 + github.com/pion/webrtc/v4 v4.0.0-beta.7 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/crypto v0.15.0 golang.org/x/image v0.13.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/sha256-simd v1.0.1 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 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/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/rtcp v1.2.12 // indirect + github.com/pion/rtp v1.8.3 // 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/srtp/v3 v3.0.1 // 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/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.8.4 // 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 + golang.org/x/net v0.18.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index 1eb82e82..fa96e072 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -29,12 +31,20 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 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= @@ -50,12 +60,23 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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/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.63 h1:GbZ2oCvaUdgT5640WJOpyDhhDxvknAJU2/T3yurwcbQ= +github.com/minio/minio-go/v7 v7.0.63/go.mod h1:Q6X7Qjb7WMhvG65qKf4gUgA5XaiSox74kR1uAEjxRS4= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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= @@ -69,30 +90,29 @@ github.com/pion/datachannel v1.5.5 h1:10ef4kwdjije+M9d7Xm9im2Y3O6A6ccQb0zcqZcJew 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/go.mod h1:j4tfTlj4aSEQN9gP3IdliSHcUTWTu9tlOZL0c59MFXo= github.com/pion/ice/v3 v3.0.2 h1:dNQnKsjLvOWz+PaI4tw1VnLYTp9adihC1HIASFGajmI= github.com/pion/ice/v3 v3.0.2/go.mod h1:q3BDzTsxbqP0ySMSHrFuw2MYGUx/AC3WQfRGC5F/0Is= -github.com/pion/interceptor v0.1.24 h1:lN4ua3yUAJCgNKQKcZIM52wFjBgjN0r7shLj91PkJ0c= -github.com/pion/interceptor v0.1.24/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y= +github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= +github.com/pion/interceptor v0.1.25/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/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/rtcp v1.2.12 h1:bKWiX93XKgDZENEXCijvHRU/wRifm6JV5DGcH6twtSM= +github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8= +github.com/pion/rtp v1.8.3/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/srtp/v3 v3.0.1 h1:AkIQRIZ+3tAOJMQ7G301xtrD1vekQbNeRO7eY1K8ZHk= +github.com/pion/srtp/v3 v3.0.1/go.mod h1:3R3a1qIOIxBkVTLGFjafKK6/fJoTdQDhcC67HOyMbJ8= 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= @@ -104,8 +124,8 @@ github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouAN 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.6 h1:swTwlzDY+1zDtW7ogXjNwlUY0xW733UUIAUMNUTCkPw= -github.com/pion/webrtc/v4 v4.0.0-beta.6/go.mod h1:UcyD8jIeTkFqfYJqoHT9qwUSmrtacKaXxgOEujOdhZ8= +github.com/pion/webrtc/v4 v4.0.0-beta.7 h1:OGCl69njLUKzT0ozJEon18W1LqH0GtuxG9Qx+qtxBdg= +github.com/pion/webrtc/v4 v4.0.0-beta.7/go.mod h1:/zWz+1e1qrjaIKYZG/mOfPrntiHOhnd3vGz2Fdo85Ys= 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= @@ -114,10 +134,14 @@ 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -137,11 +161,11 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U 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/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= 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= @@ -159,12 +183,11 @@ 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/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= 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= @@ -183,6 +206,7 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w 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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -191,18 +215,17 @@ 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/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= @@ -213,10 +236,10 @@ 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/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= @@ -238,6 +261,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 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= diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 13104af5..117cfbbb 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -302,14 +302,19 @@ recording: # save directory folder: ./recording +# cloud storage options +# it is mandatory to use a cloud storage when running +# a distributed multi-server configuration in order to +# share save states between nodes (resume games on a different worker) storage: # cloud storage provider: # - empty (No op storage stub) - # - 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) diff --git a/pkg/config/worker.go b/pkg/config/worker.go index c0c39adf..5fd4ef23 100644 --- a/pkg/config/worker.go +++ b/pkg/config/worker.go @@ -22,8 +22,11 @@ type WorkerConfig struct { } type Storage struct { - Provider string - Key string + Provider string + S3Endpoint string + S3BucketName string + S3AccessKeyId string + S3SecretAccessKey string } type Worker struct { diff --git a/pkg/worker/caged/libretro/caged.go b/pkg/worker/caged/libretro/caged.go index f71017bf..a0b5f633 100644 --- a/pkg/worker/caged/libretro/caged.go +++ b/pkg/worker/caged/libretro/caged.go @@ -64,14 +64,14 @@ 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") } } diff --git a/pkg/worker/caged/libretro/cloud.go b/pkg/worker/caged/libretro/cloud.go index caaa0335..67f8da14 100644 --- a/pkg/worker/caged/libretro/cloud.go +++ b/pkg/worker/caged/libretro/cloud.go @@ -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", + }) } diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 6a729cb3..dc8316d5 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -36,6 +36,7 @@ type Emulator interface { // 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 @@ -295,6 +296,7 @@ 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) } diff --git a/pkg/worker/cloud/cloudstore.go b/pkg/worker/cloud/cloudstore.go deleted file mode 100644 index 2413a7a5..00000000 --- a/pkg/worker/cloud/cloudstore.go +++ /dev/null @@ -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) -} diff --git a/pkg/worker/cloud/cloudstore_test.go b/pkg/worker/cloud/cloudstore_test.go deleted file mode 100644 index 228d4d29..00000000 --- a/pkg/worker/cloud/cloudstore_test.go +++ /dev/null @@ -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) - } -} diff --git a/pkg/worker/cloud/s3.go b/pkg/worker/cloud/s3.go new file mode 100644 index 00000000..bc5227f7 --- /dev/null +++ b/pkg/worker/cloud/s3.go @@ -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 +} diff --git a/pkg/worker/cloud/s3_test.go b/pkg/worker/cloud/s3_test.go new file mode 100644 index 00000000..9701cd9c --- /dev/null +++ b/pkg/worker/cloud/s3_test.go @@ -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") + } +} diff --git a/pkg/worker/cloud/store.go b/pkg/worker/cloud/store.go new file mode 100644 index 00000000..538983cf --- /dev/null +++ b/pkg/worker/cloud/store.go @@ -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 +} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 6ff68e03..14818728 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -59,7 +59,7 @@ func New(conf config.WorkerConfig, log *logger.Logger) (*Worker, error) { if conf.Worker.Monitoring.IsEnabled() { worker.services[1] = monitoring.New(conf.Worker.Monitoring, h.GetHost(), log) } - st, err := cloud.Store(conf.Storage.Provider, conf.Storage.Key) + st, err := cloud.Store(conf.Storage, log) if err != nil { log.Warn().Err(err).Msgf("cloud storage fail, using no storage") } From aa10008d1b611dfa4c0e2db58886f4eb696f0d77 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 16 Nov 2023 01:16:09 +0300 Subject: [PATCH 020/240] Use default Pion webrtc interceptors Slightly higher latency, but more stable in high ping situations. --- pkg/config/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 117cfbbb..c532fbe1 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -319,7 +319,7 @@ storage: 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) From a77069a63489b7d62a9a0cdc09039bc493aada9a Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 23 Nov 2023 01:36:14 +0300 Subject: [PATCH 021/240] Add L2, R2, L3, R3 mappings to keyboard --- web/css/main.css | 2 +- web/index.html | 8 ++++---- web/js/input/keyboard.js | 4 ++++ web/js/settings/settings.js | 12 ++++++++---- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/web/css/main.css b/web/css/main.css index 5efbf875..b47047fb 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -91,7 +91,7 @@ body { color: #979797; font-size: 8px; top: 269px; - left: 68px; + left: 30px; position: absolute; user-select: none; diff --git a/web/index.html b/web/index.html index e082abde..a912d443 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,7 @@ - + Cloud Retro @@ -48,7 +48,7 @@
- Arrows (move), ZXCVAS (game ABXYLR), 1/2 (1st/2nd player), Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy shared link to the clipboard) + Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player), Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the clipboard)
@@ -123,7 +123,7 @@ - + @@ -137,7 +137,7 @@ - + diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index 91b9f548..3a09ba9e 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -16,6 +16,10 @@ const keyboard = (() => { KeyV: KEY.Y, KeyA: KEY.L, KeyS: KEY.R, + Semicolon: KEY.L2, + Quote: KEY.R2, + Period: KEY.L3, + Slash: KEY.R3, Enter: KEY.START, ShiftLeft: KEY.SELECT, // non-game diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js index e3be8e81..81b38c0c 100644 --- a/web/js/settings/settings.js +++ b/web/js/settings/settings.js @@ -15,7 +15,7 @@ */ const settings = (() => { // internal structure version - const revision = 1.1; + const revision = 1.2; // default settings // keep them for revert to defaults option @@ -123,6 +123,7 @@ const settings = (() => { return { get, + clear: () => localStorage.removeItem(root), set, remove, save, @@ -164,12 +165,15 @@ const settings = (() => { } const init = () => { + // try to load settings from the localStorage with fallback to null-object provider = localStorageProvider(store) || voidProvider(store); provider.loadSettings(); - if (revision > store.settings._version) { - // !to handle this with migrations - log.warn(`Your settings are in older format (v${store.settings._version})`); + const lastRev = (store.settings || {_version: 0})._version + + if (revision > lastRev) { + log.warn(`Your settings are in older format (v${lastRev}) and will be reset to (v${revision})!`); + _reset(); } } From 4fbfa1d4e3af43b528c532969206f13be97b0be9 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 23 Nov 2023 18:49:45 +0300 Subject: [PATCH 022/240] Fix possible NPEs --- pkg/config/loader.go | 7 +++++- pkg/coordinator/userhandlers.go | 6 +++-- pkg/encoder/encoder.go | 26 ++++++++++++++++++-- pkg/encoder/h264/x264_test.go | 2 ++ pkg/network/address.go | 15 ++++++----- pkg/network/httpx/server.go | 10 ++++---- pkg/worker/caged/libretro/caged.go | 4 +++ pkg/worker/caged/libretro/frontend.go | 9 ++++++- pkg/worker/caged/libretro/graphics/opengl.go | 2 +- pkg/worker/caged/libretro/manager/grab.go | 8 ++++-- pkg/worker/media/media.go | 13 +++++++++- pkg/worker/media/media_test.go | 1 + pkg/worker/recorder/recorder.go | 10 ++++++-- pkg/worker/room/room_test.go | 21 +++++++--------- 14 files changed, 99 insertions(+), 35 deletions(-) diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 99ae6e7c..a2fb6bd8 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -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 } diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go index 81b6bf4f..d754109d 100644 --- a/pkg/coordinator/userhandlers.go +++ b/pkg/coordinator/userhandlers.go @@ -127,14 +127,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) diff --git a/pkg/encoder/encoder.go b/pkg/encoder/encoder.go index a9f9003c..5378e47c 100644 --- a/pkg/encoder/encoder.go +++ b/pkg/encoder/encoder.go @@ -62,6 +62,9 @@ func NewVideoEncoder(w, h, dw, dh int, scale float64, conf config.Video, log *lo 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 } @@ -86,6 +89,10 @@ func (v *Video) Info() string { } func (v *Video) SetPixFormat(f uint32) { + if v == nil { + return + } + switch f { case 1: v.pf = yuv.PixFmt(yuv.FourccArgb) @@ -98,15 +105,28 @@ func (v *Video) SetPixFormat(f uint32) { // 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() { + if v == nil { + return + } + if v.stopped.Swap(true) { return } @@ -114,6 +134,8 @@ func (v *Video) Stop() { 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") + } } } diff --git a/pkg/encoder/h264/x264_test.go b/pkg/encoder/h264/x264_test.go index b13c0bc4..09f22ae3 100644 --- a/pkg/encoder/h264/x264_test.go +++ b/pkg/encoder/h264/x264_test.go @@ -6,6 +6,7 @@ func TestH264Encode(t *testing.T) { h264, err := NewEncoder(120, 120, 0, nil) if err != nil { t.Error(err) + return } data := make([]byte, 120*120*1.5) h264.LoadBuf(data) @@ -20,6 +21,7 @@ func Benchmark(b *testing.B) { 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++ { diff --git a/pkg/network/address.go b/pkg/network/address.go index 5fcaca49..318c9cd5 100644 --- a/pkg/network/address.go +++ b/pkg/network/address.go @@ -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) +} diff --git a/pkg/network/httpx/server.go b/pkg/network/httpx/server.go index eb6e8c1e..5486c05c 100644 --- a/pkg/network/httpx/server.go +++ b/pkg/network/httpx/server.go @@ -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 } diff --git a/pkg/worker/caged/libretro/caged.go b/pkg/worker/caged/libretro/caged.go index a0b5f633..dea9bf5c 100644 --- a/pkg/worker/caged/libretro/caged.go +++ b/pkg/worker/caged/libretro/caged.go @@ -38,6 +38,7 @@ 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 @@ -47,6 +48,9 @@ func (c *Caged) ReloadFrontend() { 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 diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index dc8316d5..0386b0a8 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -217,6 +217,9 @@ func (f *Frontend) Shutdown() { 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 @@ -225,7 +228,11 @@ func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) { f.nano.OnAudio = f.handleAudio } -func (f *Frontend) SetVideoChangeCb(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") diff --git a/pkg/worker/caged/libretro/graphics/opengl.go b/pkg/worker/caged/libretro/graphics/opengl.go index ea8b8c09..99eef10d 100644 --- a/pkg/worker/caged/libretro/graphics/opengl.go +++ b/pkg/worker/caged/libretro/graphics/opengl.go @@ -50,7 +50,7 @@ const ( var ( opt = offscreenSetup{} - buf []byte + buf = make([]byte, 1024*1024) ) func initContext(getProcAddr func(name string) unsafe.Pointer) { diff --git a/pkg/worker/caged/libretro/manager/grab.go b/pkg/worker/caged/libretro/manager/grab.go index a2ece967..f7d40a05 100644 --- a/pkg/worker/caged/libretro/manager/grab.go +++ b/pkg/worker/caged/libretro/manager/grab.go @@ -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) } } diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index 70758b1c..39c48442 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -145,6 +145,10 @@ func (wmp *WebrtcMediaPipe) Init() error { if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil { return err } + if wmp.v == nil || wmp.a == nil { + return fmt.Errorf("could intit the encoders, v=%v a=%v", wmp.v != nil, wmp.a != nil) + } + wmp.log.Debug().Msgf("%v", wmp.v.Info()) wmp.initialized = true return nil @@ -179,7 +183,14 @@ func (wmp *WebrtcMediaPipe) encodeAudio(pcm samples) { func (wmp *WebrtcMediaPipe) initVideo(w, h int, scale float64, conf config.Video) (err error) { sw, sh := round(w, scale), round(h, scale) - wmp.v, err = encoder.NewVideoEncoder(w, h, sw, sh, scale, conf, wmp.log) + enc, err := encoder.NewVideoEncoder(w, h, sw, sh, scale, conf, wmp.log) + if err != nil { + return err + } + if enc == nil { + return fmt.Errorf("broken video encoder init") + } + wmp.v = enc wmp.log.Debug().Msgf("media scale: %vx%v -> %vx%v", w, h, sw, sh) return err } diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 93568cc2..8db75e6c 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -66,6 +66,7 @@ func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RG ve, err := encoder.NewVideoEncoder(w, h, w, h, 1, conf, l) if err != nil { backend.Error(err) + return } defer ve.Stop() diff --git a/pkg/worker/recorder/recorder.go b/pkg/worker/recorder/recorder.go index 4c3d1207..d0c1165b 100644 --- a/pkg/worker/recorder/recorder.go +++ b/pkg/worker/recorder/recorder.go @@ -98,11 +98,13 @@ func (r *Recording) Start() { audio, err := newWavStream(path, r.opts) if err != nil { r.log.Fatal().Err(err) + return } r.audio = audio video, err := newRawStream(path) if err != nil { r.log.Fatal().Err(err) + return } r.video = video } @@ -111,8 +113,12 @@ func (r *Recording) Stop() (err error) { r.Lock() defer r.Unlock() r.enabled = false - err = r.audio.Close() - err = r.video.Close() + if r.audio != nil { + err = r.audio.Close() + } + if r.video != nil { + err = r.video.Close() + } path := filepath.Join(r.dir, r.saveDir) // FFMPEG diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go index e41004b9..c1459e0a 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room/room_test.go @@ -139,18 +139,15 @@ func TestAll(t *testing.T) { if renderFrames { rect := image.Rect(0, 0, frame.W, frame.H) var src image.Image - if test.color == 1 { - src1 := bgra.NewBGRA(rect) - src1.Pix = frame.Data - src1.Stride = frame.Stride - src = src1 - } else { - if test.color == 2 { - src1 := rgb565.NewRGB565(rect) - src1.Pix = frame.Data - src1.Stride = frame.Stride - src = src1 - } + src1 := bgra.NewBGRA(rect) + src1.Pix = frame.Data + src1.Stride = frame.Stride + src = src1 + if test.color == 2 { + src2 := rgb565.NewRGB565(rect) + src2.Pix = frame.Data + src2.Stride = frame.Stride + src = src2 } dst := rgba.ToRGBA(src, flip) tag := fmt.Sprintf("%v-%v-0x%08x", runtime.GOOS, test.game.Type, crc32.Checksum(frame.Data, crc32q)) From b7b530fe603cb56fad3afcf7d52134613f96a8d4 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 25 Nov 2023 03:09:29 +0300 Subject: [PATCH 023/240] Update libretro.h --- pkg/worker/caged/libretro/nanoarch/libretro.h | 484 +++++++++++++++++- 1 file changed, 473 insertions(+), 11 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/libretro.h b/pkg/worker/caged/libretro/nanoarch/libretro.h index e1020c9a..14c05991 100644 --- a/pkg/worker/caged/libretro/nanoarch/libretro.h +++ b/pkg/worker/caged/libretro/nanoarch/libretro.h @@ -291,6 +291,7 @@ enum retro_language RETRO_LANGUAGE_CATALAN = 29, RETRO_LANGUAGE_BRITISH_ENGLISH = 30, RETRO_LANGUAGE_HUNGARIAN = 31, + RETRO_LANGUAGE_BELARUSIAN = 32, RETRO_LANGUAGE_LAST, /* Ensure sizeof(enum) == sizeof(int) */ @@ -928,8 +929,6 @@ enum retro_mod * anything else. * It is recommended to expose all relevant pointers through * retro_get_memory_* as well. - * - * Can be called from retro_init and retro_load_game. */ #define RETRO_ENVIRONMENT_SET_GEOMETRY 37 /* const struct retro_game_geometry * -- @@ -1793,6 +1792,66 @@ enum retro_mod * this environment call to query support. */ +#define RETRO_ENVIRONMENT_GET_JIT_CAPABLE 74 + /* bool * -- + * Result is set to true if the frontend has already verified JIT can be + * used, mainly for use iOS/tvOS. On other platforms the result is true. + */ + +#define RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE (75 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_microphone_interface * -- + * Returns an interface that can be used to receive input from the microphone driver. + * + * Returns true if microphone support is available, + * even if no microphones are plugged in. + * Returns false if mic support is disabled or unavailable. + * + * This callback can be invoked at any time, + * even before the microphone driver is ready. + */ + + /* Environment 76 was an obsolete version of RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE. + * It was not used by any known core at the time, and was removed from the API. */ + +#define RETRO_ENVIRONMENT_GET_DEVICE_POWER (77 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /* struct retro_device_power * -- + * Returns the device's current power state as reported by the frontend. + * This is useful for emulating the battery level in handheld consoles, + * or for reducing power consumption when on battery power. + * + * The return value indicates whether the frontend can provide this information, + * even if the parameter is NULL. + * + * If the frontend does not support this functionality, + * then the provided argument will remain unchanged. + * + * Note that this environment call describes the power state for the entire device, + * not for individual peripherals like controllers. + */ + +#define RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE 78 + /* const struct retro_netpacket_callback * -- + * When set, a core gains control over network packets sent and + * received during a multiplayer session. This can be used to + * emulate multiplayer games that were originally played on two + * or more separate consoles or computers connected together. + * + * The frontend will take care of connecting players together, + * and the core only needs to send the actual data as needed for + * the emulation, while handshake and connection management happen + * in the background. + * + * When two or more players are connected and this interface has + * been set, time manipulation features (such as pausing, slow motion, + * fast forward, rewinding, save state loading, etc.) are disabled to + * avoid interrupting communication. + * + * Should be set in either retro_init or retro_load_game, but not both. + * + * When not set, a frontend may use state serialization-based + * multiplayer, where a deterministic core supporting multiple + * input devices does not need to take any action on its own. + */ /* VFS functionality */ @@ -1968,13 +2027,13 @@ struct retro_vfs_interface_info enum retro_hw_render_interface_type { - RETRO_HW_RENDER_INTERFACE_VULKAN = 0, - RETRO_HW_RENDER_INTERFACE_D3D9 = 1, - RETRO_HW_RENDER_INTERFACE_D3D10 = 2, - RETRO_HW_RENDER_INTERFACE_D3D11 = 3, - RETRO_HW_RENDER_INTERFACE_D3D12 = 4, + RETRO_HW_RENDER_INTERFACE_VULKAN = 0, + RETRO_HW_RENDER_INTERFACE_D3D9 = 1, + RETRO_HW_RENDER_INTERFACE_D3D10 = 2, + RETRO_HW_RENDER_INTERFACE_D3D11 = 3, + RETRO_HW_RENDER_INTERFACE_D3D12 = 4, RETRO_HW_RENDER_INTERFACE_GSKIT_PS2 = 5, - RETRO_HW_RENDER_INTERFACE_DUMMY = INT_MAX + RETRO_HW_RENDER_INTERFACE_DUMMY = INT_MAX }; /* Base struct. All retro_hw_render_interface_* types @@ -2750,9 +2809,17 @@ enum retro_hw_context_type /* Vulkan, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE. */ RETRO_HW_CONTEXT_VULKAN = 6, - /* Direct3D, set version_major to select the type of interface - * returned by RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ - RETRO_HW_CONTEXT_DIRECT3D = 7, + /* Direct3D11, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ + RETRO_HW_CONTEXT_D3D11 = 7, + + /* Direct3D10, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ + RETRO_HW_CONTEXT_D3D10 = 8, + + /* Direct3D12, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ + RETRO_HW_CONTEXT_D3D12 = 9, + + /* Direct3D9, see RETRO_ENVIRONMENT_GET_HW_RENDER_INTERFACE */ + RETRO_HW_CONTEXT_D3D9 = 10, RETRO_HW_CONTEXT_DUMMY = INT_MAX }; @@ -3007,6 +3074,118 @@ struct retro_disk_control_ext_callback retro_get_image_label_t get_image_label; /* Optional - may be NULL */ }; +/* Definitions for RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE. + * A core can set it if sending and receiving custom network packets + * during a multiplayer session is desired. + */ + +/* Netpacket flags for retro_netpacket_send_t */ +#define RETRO_NETPACKET_UNRELIABLE 0 /* Packet to be sent unreliable, depending on network quality it might not arrive. */ +#define RETRO_NETPACKET_RELIABLE (1 << 0) /* Reliable packets are guaranteed to arrive at the target in the order they were sent. */ +#define RETRO_NETPACKET_UNSEQUENCED (1 << 1) /* Packet will not be sequenced with other packets and may arrive out of order. Cannot be set on reliable packets. */ +#define RETRO_NETPACKET_FLUSH_HINT (1 << 2) /* Request the packet and any previously buffered ones to be sent immediately */ + +/* Broadcast client_id for retro_netpacket_send_t */ +#define RETRO_NETPACKET_BROADCAST 0xFFFF + +/* Used by the core to send a packet to one or all connected players. + * A single packet sent via this interface can contain up to 64 KB of data. + * + * The client_id RETRO_NETPACKET_BROADCAST sends the packet as a broadcast to + * all connected players. This is supported from the host as well as clients. +* Otherwise, the argument indicates the player to send the packet to. + * + * A frontend must support sending reliable packets (RETRO_NETPACKET_RELIABLE). + * Unreliable packets might not be supported by the frontend, but the flags can + * still be specified. Reliable transmission will be used instead. + * + * Calling this with the flag RETRO_NETPACKET_FLUSH_HINT will send off the + * packet and any previously buffered ones immediately and without blocking. + * To only flush previously queued packets, buf or len can be passed as NULL/0. + * + * This function is not guaranteed to be thread-safe and must be called during + * retro_run or any of the netpacket callbacks passed with this interface. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_send_t)(int flags, const void* buf, size_t len, uint16_t client_id); + +/* Optionally read any incoming packets without waiting for the end of the + * frame. While polling, retro_netpacket_receive_t and retro_netpacket_stop_t + * can be called. The core can perform this in a loop to do a blocking read, + * i.e., wait for incoming data, but needs to handle stop getting called and + * also give up after a short while to avoid freezing on a connection problem. + * It is a good idea to manually flush outgoing packets before calling this. + * + * This function is not guaranteed to be thread-safe and must be called during + * retro_run or any of the netpacket callbacks passed with this interface. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_poll_receive_t)(); + +/* Called by the frontend to signify that a multiplayer session has started. + * If client_id is 0 the local player is the host of the session and at this + * point no other player has connected yet. + * + * If client_id is > 0 the local player is a client connected to a host and + * at this point is already fully connected to the host. + * + * The core must store the function pointer send_fn and use it whenever it + * wants to send a packet. Optionally poll_receive_fn can be stored and used + * when regular receiving between frames is not enough. These function pointers + * remain valid until the frontend calls retro_netpacket_stop_t. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_start_t)(uint16_t client_id, retro_netpacket_send_t send_fn, retro_netpacket_poll_receive_t poll_receive_fn); + +/* Called by the frontend when a new packet arrives which has been sent from + * another player with retro_netpacket_send_t. The client_id argument indicates + * who has sent the packet. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_receive_t)(const void* buf, size_t len, uint16_t client_id); + +/* Called by the frontend when the multiplayer session has ended. + * Once this gets called the function pointers passed to + * retro_netpacket_start_t will not be valid anymore. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_stop_t)(void); + +/* Called by the frontend every frame (between calls to retro_run while + * updating the state of the multiplayer session. + * This is a good place for the core to call retro_netpacket_send_t from. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_poll_t)(void); + +/* Called by the frontend when a new player connects to the hosted session. + * This is only called on the host side, not for clients connected to the host. + * If this function returns false, the newly connected player gets dropped. + * This can be used for example to limit the number of players. + */ +typedef bool (RETRO_CALLCONV *retro_netpacket_connected_t)(uint16_t client_id); + +/* Called by the frontend when a player leaves or disconnects from the hosted session. + * This is only called on the host side, not for clients connected to the host. + */ +typedef void (RETRO_CALLCONV *retro_netpacket_disconnected_t)(uint16_t client_id); + +/** + * A callback interface for giving a core the ability to send and receive custom + * network packets during a multiplayer session between two or more instances + * of a libretro frontend. + * + * Normally during connection handshake the frontend will compare library_version + * used by both parties and show a warning if there is a difference. When the core + * supplies protocol_version, the frontend will check against this instead. + * + * @see RETRO_ENVIRONMENT_SET_NETPACKET_INTERFACE + */ +struct retro_netpacket_callback +{ + retro_netpacket_start_t start; + retro_netpacket_receive_t receive; + retro_netpacket_stop_t stop; /* Optional - may be NULL */ + retro_netpacket_poll_t poll; /* Optional - may be NULL */ + retro_netpacket_connected_t connected; /* Optional - may be NULL */ + retro_netpacket_disconnected_t disconnected; /* Optional - may be NULL */ + const char* protocol_version; /* Optional - if not NULL will be used instead of core version to decide if communication is compatible */ +}; + enum retro_pixel_format { /* 0RGB1555, native endian. @@ -3809,6 +3988,289 @@ struct retro_throttle_state float rate; }; +/** + * Opaque handle to a microphone that's been opened for use. + * The underlying object is accessed or created with \c retro_microphone_interface_t. + */ +typedef struct retro_microphone retro_microphone_t; + +/** + * Parameters for configuring a microphone. + * Some of these might not be honored, + * depending on the available hardware and driver configuration. + */ +typedef struct retro_microphone_params +{ + /** + * The desired sample rate of the microphone's input, in Hz. + * The microphone's input will be resampled, + * so cores can ask for whichever frequency they need. + * + * If zero, some reasonable default will be provided by the frontend + * (usually from its config file). + * + * @see retro_get_mic_rate_t + */ + unsigned rate; +} retro_microphone_params_t; + +/** + * @copydoc retro_microphone_interface::open_mic + */ +typedef retro_microphone_t *(RETRO_CALLCONV *retro_open_mic_t)(const retro_microphone_params_t *params); + +/** + * @copydoc retro_microphone_interface::close_mic + */ +typedef void (RETRO_CALLCONV *retro_close_mic_t)(retro_microphone_t *microphone); + +/** + * @copydoc retro_microphone_interface::get_params + */ +typedef bool (RETRO_CALLCONV *retro_get_mic_params_t)(const retro_microphone_t *microphone, retro_microphone_params_t *params); + +/** + * @copydoc retro_microphone_interface::set_mic_state + */ +typedef bool (RETRO_CALLCONV *retro_set_mic_state_t)(retro_microphone_t *microphone, bool state); + +/** + * @copydoc retro_microphone_interface::get_mic_state + */ +typedef bool (RETRO_CALLCONV *retro_get_mic_state_t)(const retro_microphone_t *microphone); + +/** + * @copydoc retro_microphone_interface::read_mic + */ +typedef int (RETRO_CALLCONV *retro_read_mic_t)(retro_microphone_t *microphone, int16_t* samples, size_t num_samples); + +/** + * The current version of the microphone interface. + * Will be incremented whenever \c retro_microphone_interface or \c retro_microphone_params_t + * receive new fields. + * + * Frontends using cores built against older mic interface versions + * should not access fields introduced in newer versions. + */ +#define RETRO_MICROPHONE_INTERFACE_VERSION 1 + +/** + * An interface for querying the microphone and accessing data read from it. + * + * @see RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE + */ +struct retro_microphone_interface +{ + /** + * The version of this microphone interface. + * Set by the core to request a particular version, + * and set by the frontend to indicate the returned version. + * 0 indicates that the interface is invalid or uninitialized. + */ + unsigned interface_version; + + /** + * Initializes a new microphone. + * Assuming that microphone support is enabled and provided by the frontend, + * cores may call this function whenever necessary. + * A microphone could be opened throughout a core's lifetime, + * or it could wait until a microphone is plugged in to the emulated device. + * + * The returned handle will be valid until it's freed, + * even if the audio driver is reinitialized. + * + * This function is not guaranteed to be thread-safe. + * + * @param args[in] Parameters used to create the microphone. + * May be \c NULL, in which case the default value of each parameter will be used. + * + * @returns Pointer to the newly-opened microphone, + * or \c NULL if one couldn't be opened. + * This likely means that no microphone is plugged in and recognized, + * or the maximum number of supported microphones has been reached. + * + * @note Microphones are \em inactive by default; + * to begin capturing audio, call \c set_mic_state. + * @see retro_microphone_params_t + */ + retro_open_mic_t open_mic; + + /** + * Closes a microphone that was initialized with \c open_mic. + * Calling this function will stop all microphone activity + * and free up the resources that it allocated. + * Afterwards, the handle is invalid and must not be used. + * + * A frontend may close opened microphones when unloading content, + * but this behavior is not guaranteed. + * Cores should close their microphones when exiting, just to be safe. + * + * @param microphone Pointer to the microphone that was allocated by \c open_mic. + * If \c NULL, this function does nothing. + * + * @note The handle might be reused if another microphone is opened later. + */ + retro_close_mic_t close_mic; + + /** + * Returns the configured parameters of this microphone. + * These may differ from what was requested depending on + * the driver and device configuration. + * + * Cores should check these values before they start fetching samples. + * + * Will not change after the mic was opened. + * + * @param microphone[in] Opaque handle to the microphone + * whose parameters will be retrieved. + * @param params[out] The parameters object that the + * microphone's parameters will be copied to. + * + * @return \c true if the parameters were retrieved, + * \c false if there was an error. + */ + retro_get_mic_params_t get_params; + + /** + * Enables or disables the given microphone. + * Microphones are disabled by default + * and must be explicitly enabled before they can be used. + * Disabled microphones will not process incoming audio samples, + * and will therefore have minimal impact on overall performance. + * Cores may enable microphones throughout their lifetime, + * or only for periods where they're needed. + * + * Cores that accept microphone input should be able to operate without it; + * we suggest substituting silence in this case. + * + * @param microphone Opaque handle to the microphone + * whose state will be adjusted. + * This will have been provided by \c open_mic. + * @param state \c true if the microphone should receive audio input, + * \c false if it should be idle. + * @returns \c true if the microphone's state was successfully set, + * \c false if \c microphone is invalid + * or if there was an error. + */ + retro_set_mic_state_t set_mic_state; + + /** + * Queries the active state of a microphone at the given index. + * Will return whether the microphone is enabled, + * even if the driver is paused. + * + * @param microphone Opaque handle to the microphone + * whose state will be queried. + * @return \c true if the provided \c microphone is valid and active, + * \c false if not or if there was an error. + */ + retro_get_mic_state_t get_mic_state; + + /** + * Retrieves the input processed by the microphone since the last call. + * \em Must be called every frame unless \c microphone is disabled, + * similar to how \c retro_audio_sample_batch_t works. + * + * @param[in] microphone Opaque handle to the microphone + * whose recent input will be retrieved. + * @param[out] samples The buffer that will be used to store the microphone's data. + * Microphone input is in mono (i.e. one number per sample). + * Should be large enough to accommodate the expected number of samples per frame; + * for example, a 44.1kHz sample rate at 60 FPS would require space for 735 samples. + * @param[in] num_samples The size of the data buffer in samples (\em not bytes). + * Microphone input is in mono, so a "frame" and a "sample" are equivalent in length here. + * + * @return The number of samples that were copied into \c samples. + * If \c microphone is pending driver initialization, + * this function will copy silence of the requested length into \c samples. + * + * Will return -1 if the microphone is disabled, + * the audio driver is paused, + * or there was an error. + */ + retro_read_mic_t read_mic; +}; + +/** + * Describes how a device is being powered. + * @see RETRO_ENVIRONMENT_GET_DEVICE_POWER + */ +enum retro_power_state +{ + /** + * Indicates that the frontend cannot report its power state at this time, + * most likely due to a lack of support. + * + * \c RETRO_ENVIRONMENT_GET_DEVICE_POWER will not return this value; + * instead, the environment callback will return \c false. + */ + RETRO_POWERSTATE_UNKNOWN = 0, + + /** + * Indicates that the device is running on its battery. + * Usually applies to portable devices such as handhelds, laptops, and smartphones. + */ + RETRO_POWERSTATE_DISCHARGING, + + /** + * Indicates that the device's battery is currently charging. + */ + RETRO_POWERSTATE_CHARGING, + + /** + * Indicates that the device is connected to a power source + * and that its battery has finished charging. + */ + RETRO_POWERSTATE_CHARGED, + + /** + * Indicates that the device is connected to a power source + * and that it does not have a battery. + * This usually suggests a desktop computer or a non-portable game console. + */ + RETRO_POWERSTATE_PLUGGED_IN +}; + +/** + * Indicates that an estimate is not available for the battery level or time remaining, + * even if the actual power state is known. + */ +#define RETRO_POWERSTATE_NO_ESTIMATE (-1) + +/** + * Describes the power state of the device running the frontend. + * @see RETRO_ENVIRONMENT_GET_DEVICE_POWER + */ +struct retro_device_power +{ + /** + * The current state of the frontend's power usage. + */ + enum retro_power_state state; + + /** + * A rough estimate of the amount of time remaining (in seconds) + * before the device powers off. + * This value depends on a variety of factors, + * so it is not guaranteed to be accurate. + * + * Will be set to \c RETRO_POWERSTATE_NO_ESTIMATE if \c state does not equal \c RETRO_POWERSTATE_DISCHARGING. + * May still be set to \c RETRO_POWERSTATE_NO_ESTIMATE if the frontend is unable to provide an estimate. + */ + int seconds; + + /** + * The approximate percentage of battery charge, + * ranging from 0 to 100 (inclusive). + * The device may power off before this reaches 0. + * + * The user might have configured their device + * to stop charging before the battery is full, + * so do not assume that this will be 100 in the \c RETRO_POWERSTATE_CHARGED state. + */ + int8_t percent; +}; + /* Callbacks */ /* Environment callback. Gives implementations a way of performing From f475dbabb7d3cfe381ee1f45467c3f7e87a0263b Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 4 Dec 2023 01:38:30 +0300 Subject: [PATCH 024/240] Update dependencies --- go.mod | 18 +++++++++--------- go.sum | 35 ++++++++++++++++++----------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index df9b8594..d20298fe 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module github.com/giongto35/cloud-game/v3 go 1.20 require ( - github.com/VictoriaMetrics/metrics v1.24.0 + github.com/VictoriaMetrics/metrics v1.25.3 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.7.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/gorilla/websocket v1.5.1 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.0.1 - github.com/minio/minio-go/v7 v7.0.63 + github.com/minio/minio-go/v7 v7.0.65 github.com/pion/ice/v3 v3.0.2 github.com/pion/interceptor v0.1.25 github.com/pion/logging v0.2.2 github.com/pion/webrtc/v4 v4.0.0-beta.7 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.15.0 + github.com/veandco/go-sdl2 v0.4.36 + golang.org/x/crypto v0.16.0 golang.org/x/image v0.13.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,7 +29,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.2 // indirect + github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -41,7 +41,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pion/datachannel v1.5.5 // indirect - github.com/pion/dtls/v2 v2.2.7 // indirect + github.com/pion/dtls/v2 v2.2.8 // indirect github.com/pion/mdns v0.0.9 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.12 // indirect @@ -58,8 +58,8 @@ require ( github.com/stretchr/testify v1.8.4 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect - golang.org/x/net v0.18.0 // indirect - golang.org/x/sys v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index fa96e072..2397f02d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -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.25.3 h1:Zcxyj8JbAB6CQU51Er3D7RBRupcP55DevVQi9cFqo2Q= +github.com/VictoriaMetrics/metrics v1.25.3/go.mod h1:ZKmlI+QN6b9LUC0OiHNp2LiGQGlBy4U1re6Slooln1o= 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= @@ -35,13 +35,13 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= -github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -62,8 +62,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.63 h1:GbZ2oCvaUdgT5640WJOpyDhhDxvknAJU2/T3yurwcbQ= -github.com/minio/minio-go/v7 v7.0.63/go.mod h1:Q6X7Qjb7WMhvG65qKf4gUgA5XaiSox74kR1uAEjxRS4= +github.com/minio/minio-go/v7 v7.0.65 h1:sOlB8T3nQK+TApTpuN3k4WD5KasvZIE3vVFzyyCa0go= +github.com/minio/minio-go/v7 v7.0.65/go.mod h1:R4WVUR6ZTedlCcGwZRauLMIKjgyaWxhs4Mqi/OMPmEc= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -88,8 +88,9 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y 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/dtls/v2 v2.2.8 h1:BUroldfiIbV9jSnC6cKOMnyiORRWrWWpV11JUyEu5OA= +github.com/pion/dtls/v2 v2.2.8/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/ice/v3 v3.0.2 h1:dNQnKsjLvOWz+PaI4tw1VnLYTp9adihC1HIASFGajmI= github.com/pion/ice/v3 v3.0.2/go.mod h1:q3BDzTsxbqP0ySMSHrFuw2MYGUx/AC3WQfRGC5F/0Is= github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= @@ -152,8 +153,8 @@ github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G 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/veandco/go-sdl2 v0.4.36 h1:Ltydev536rRQodmIrTWFZ3dRp5A+/6t5CYvbi4Kvia0= +github.com/veandco/go-sdl2 v0.4.36/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= @@ -164,8 +165,8 @@ golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE 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/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 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= @@ -186,8 +187,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 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= @@ -218,8 +219,8 @@ golang.org/x/sys v0.8.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= From 1993950cd7e098638d63b6fb5bd712ca75db5b76 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 4 Dec 2023 18:20:05 +0300 Subject: [PATCH 025/240] Downgrade requirements for an old Ubuntu --- README.md | 3 +++ pkg/encoder/h264/x264.go | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae86ce7e..943eb664 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ a better sense of performance. , [libopus](http://opus-codec.org/), [pkg-config](https://www.freedesktop.org/wiki/Software/pkg-config/) , [sdl2](https://wiki.libsdl.org/Installation) +(If you need to use the app on an older version of Ubuntu that does not have libyuv, you can add a custom apt repository: +`add sudo add-apt-repository ppa:savoury1/graphics`) + ``` # Ubuntu / Windows (WSL2) apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libyuv-dev libjpeg-turbo8-dev diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go index 8b10ce58..c55f1502 100644 --- a/pkg/encoder/h264/x264.go +++ b/pkg/encoder/h264/x264.go @@ -33,8 +33,8 @@ type Options struct { func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { libVersion := LibVersion() - if libVersion < 156 { - return nil, fmt.Errorf("x264: the library version should be newer than v155, you have got version %v", libVersion) + if libVersion < 150 { + return nil, fmt.Errorf("x264: the library version should be newer than v150, you have got version %v", libVersion) } if opts == nil { @@ -64,7 +64,11 @@ func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { ww, hh := int32(w), int32(h) param.IBitdepth = 8 - param.ICsp = CspI420 + if libVersion > 155 { + param.ICsp = CspI420 + } else { + param.ICsp = 1 + } param.IWidth = ww param.IHeight = hh param.ILogLevel = opts.LogLevel From 27c9ce681b40bb4604a2e8da818dcc9f6c119e2b Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 4 Dec 2023 18:39:26 +0300 Subject: [PATCH 026/240] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 943eb664..6ba65056 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,6 @@ a better sense of performance. , [libopus](http://opus-codec.org/), [pkg-config](https://www.freedesktop.org/wiki/Software/pkg-config/) , [sdl2](https://wiki.libsdl.org/Installation) -(If you need to use the app on an older version of Ubuntu that does not have libyuv, you can add a custom apt repository: -`add sudo add-apt-repository ppa:savoury1/graphics`) - ``` # Ubuntu / Windows (WSL2) apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libyuv-dev libjpeg-turbo8-dev @@ -72,6 +69,9 @@ brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo pacman -Sy --noconfirm --needed git make mingw-w64-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,x264-git,SDL2,libyuv,libjpeg-turbo} ``` +(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 From f7d12e65e51536f46a428bb839953d4cf36d0cbc Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 22 Dec 2023 16:09:59 +0300 Subject: [PATCH 027/240] Update README.md Add some additional libyuv notes. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ba65056..42c1d157 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ 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) @@ -69,6 +69,8 @@ brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo pacman -Sy --noconfirm --needed git make mingw-w64-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,x264-git,SDL2,libyuv,libjpeg-turbo} ``` +(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`) From fca46f1a32ed6583c918a5e6cbb01e3585d03091 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 22 Dec 2023 20:35:48 +0300 Subject: [PATCH 028/240] Update dependencies --- go.mod | 10 +++++----- go.sum | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index d20298fe..a2d212db 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/giongto35/cloud-game/v3 go 1.20 require ( - github.com/VictoriaMetrics/metrics v1.25.3 + github.com/VictoriaMetrics/metrics v1.29.1 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.7.0 github.com/goccy/go-json v0.10.2 @@ -11,7 +11,7 @@ require ( github.com/gorilla/websocket v1.5.1 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.0.1 - github.com/minio/minio-go/v7 v7.0.65 + github.com/minio/minio-go/v7 v7.0.66 github.com/pion/ice/v3 v3.0.2 github.com/pion/interceptor v0.1.25 github.com/pion/logging v0.2.2 @@ -19,7 +19,7 @@ require ( github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.31.0 github.com/veandco/go-sdl2 v0.4.36 - golang.org/x/crypto v0.16.0 + golang.org/x/crypto v0.17.0 golang.org/x/image v0.13.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -27,7 +27,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.4.0 // indirect + github.com/google/uuid v1.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect @@ -44,7 +44,7 @@ require ( github.com/pion/dtls/v2 v2.2.8 // indirect github.com/pion/mdns v0.0.9 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.12 // indirect + github.com/pion/rtcp v1.2.13 // indirect github.com/pion/rtp v1.8.3 // indirect github.com/pion/sctp v1.8.9 // indirect github.com/pion/sdp/v3 v3.0.6 // indirect diff --git a/go.sum b/go.sum index 2397f02d..d1fe4710 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.25.3 h1:Zcxyj8JbAB6CQU51Er3D7RBRupcP55DevVQi9cFqo2Q= -github.com/VictoriaMetrics/metrics v1.25.3/go.mod h1:ZKmlI+QN6b9LUC0OiHNp2LiGQGlBy4U1re6Slooln1o= +github.com/VictoriaMetrics/metrics v1.29.1 h1:yTORfGeO1T0C6P/tEeT4Mf7rBU5TUu3kjmHvmlaoeO8= +github.com/VictoriaMetrics/metrics v1.29.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= 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= @@ -33,8 +33,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -62,8 +62,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.65 h1:sOlB8T3nQK+TApTpuN3k4WD5KasvZIE3vVFzyyCa0go= -github.com/minio/minio-go/v7 v7.0.65/go.mod h1:R4WVUR6ZTedlCcGwZRauLMIKjgyaWxhs4Mqi/OMPmEc= +github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= +github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -102,8 +102,9 @@ github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecI 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/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= -github.com/pion/rtcp v1.2.12 h1:bKWiX93XKgDZENEXCijvHRU/wRifm6JV5DGcH6twtSM= github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtcp v1.2.13 h1:+EQijuisKwm/8VBs8nWllr0bIndR7Lf7cZG200mpbNo= +github.com/pion/rtcp v1.2.13/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8= github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= @@ -165,8 +166,8 @@ golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE 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/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 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= From 610e087bcd5bb3a6cad43925744478eedf4177c4 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 7 Feb 2024 11:44:27 +0300 Subject: [PATCH 029/240] Update to Go 1.22.0 --- .github/workflows/build.yml | 2 +- Dockerfile | 2 +- cmd/worker/default.pgo | Bin 49125 -> 0 bytes 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 cmd/worker/default.pgo diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5fccc156..164398e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-go@v4 with: - go-version: 1.20.8 + go-version: 1.22.0 - name: Linux if: matrix.os == 'ubuntu-latest' diff --git a/Dockerfile b/Dockerfile index 0d696da1..abae4a67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:lunar AS build0 -ARG GO=1.20.8 +ARG GO=1.22.0 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ diff --git a/cmd/worker/default.pgo b/cmd/worker/default.pgo deleted file mode 100644 index 35a67035847acc971e1a31dbe34b4c0848e08869..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49125 zcmZs?byQnh*FAh&3bfFo#T|-Uf#O!6K=C5MHMqN5TM7h%l;ReOdvLb`!CiuDaDtTL z{>#13eZTQMV|@85BWH|#&RJ`&IpvaCBhgHzi%m-ZKOSn3YZV9tGENv9N&2btbI#A4A8%gdJY!%32{V9-ii%Pg zwtq_iGSF_ZF*n9@Ga$LrX;-PrppwE*@X)B_UQ7{PxE zgKn&YE_Q+*NP!UcWQ#ruprtI@{`p`VZEwZ!C$wVHtjXp&Cdk?c?s<)czE20jyY!!MWPk>nSyjql;|&4a7x4=`E=oP2i-)z(o-Ts**O89L&zEdAX( z-}#L7$lE%TR-X6av$#d(0lxn*r1B`SyfCfsfXsi?_zXn9EUs9R+$I~R@dhoc16ZM# za+wsBEL9Vvyfy9(Zo*<78I5~kl60)LG385;)fw-*8UZiWZQvW(^jq7|x>8?kAh-h( zUtVO!`vi2@i+dTM+v_V%d}@LrAVE-LKRiSvrR ziU)|3;gF!eHuwWsa(C`fPD4e`kfl>CE`xo7$!4m^JzMKBJoZha+w_a1G0cPHhON1$ z58FiB=GPY@#{FQwmVjWpjeM4E#@2+29qK|BieCe(@q|Oy7{;A~e^gq3=)C&w`6F-t zq51Ay z8rRD4hd?#er4gi$`08n|Kx{Gt`IYg?@n0%GYGaRg&^WPah8l2xhun=*^i}#psV@9} zV@`^9#kwt7+_2x)Q?_K%P$-wioD$x>ao*&S@8MP@Bc!JTMJ%WJj4@@gY^3>?eSNYg zI%04|wvt;6w~2(Y=jMqF-BKmE2e{lGJrn*T=Db02CA-2dcIcX+)S78>A%xiZ)6IUj zPqOlUC2`|iV6L;`1bK(iG2nc%c!$$5bbNAq_awMKms(ISW-s7WL`05BY~Nc;Zs@A7 zl}0Zo3W}Da#`VA13 z-EZYmzIE0E5Ifm4JiT}AZwi%b+UF_*m{!xB81?wdxZhR$;9MfJ(nnJxboOzWx&1(- zDtvzFmo!psW#C@++8)Y3mgbzVZ#(nUKD(~}kK8*Aj(V}~?@pXmesDsvZiOF7`eZY_ z_DzYlmU`@Ei8E~M6duvvyLAi#z4KnUZIauU17?p2Zel2t&0oFC_q%8{v_~JS`BVPX zJ`-Sbex@I{SC`F}v2q>MrL)!7qa6XTD{mMqbF&Wtm^s$6T``qTmQW_zjv_eRoOS%|}CFyPb5! z*zeumdV!8^TSbQs4FKy`Ye6)F4g2NdGqh?}<`&W^{JK?Ry(bz6br*WKT!+{9mrGHHf`ps>1~b}0i~HOwRNV4Q zJnqm(+07#oqYtRcyezv-GUmq+^Af8Rvqq?GwcvK>@*fYLWX_n@OLzEK_|9yMZTiP{ z*u;O;kf?&_H&&uvnh)7@-sNZ8R_)j46lclKvSnXdA!W8`KCVlaF;uv4{~9?FLmOsS1d+opS@Y2mgY{#n2sV0~eB8?}9>v!hd^&-V* zd+Yu<3VT8Bw^Js$w-)6m;jAt6Z18TStm>zx z6`I{q5M8ET6KjISR7rEyi8ttq4}A)28>WWU^;@+PM;!UBY@$?V=upbUEs%b7ptk0K z=t9xoW#{T|?KzOjx{>_uR4+b9!O)?5m`Rm2ggZNDf0*(h>2eltzO&%#)qcMnZ{|IM zUT>XzuiW0?G|uJ+<-7Y|v2|Z!(#X6@}qmS_turn zh1o4AaGLt+NzbC&0Z$8it)$as2TxSoi=NhRS(Udh;#3!$8#|q9F;6}fS;K|SWl11B zyx9ZPYwbDn5{?aX)(6_RWr3p-&JFV<2jB}ve=hq5@zcHPz@ClS_^p&uucPz=w1M~G zMqL&2;)2WK5AMtDX1FC!B6yFUwUC+DM}6;h?)i zJbx+98J$c&Og)EtsJiGMVS0HfWEGk5dyd-YErXY8LWJ5u;e`@1b@@>p*|dCo{tNpy z|9&vY(LY&X*~_|Qs^%T42n&TF48dxDJeUVk*WZZpW$MKgcFn6EVCAQ5_}skdNktC3 zZ;L{Vgy3X%Tp8OS^cd#940c~49)O}?@gX^dy8Z~N#Q=^~+ACU-SFoeJC=;tSmNFmsAIgWSrOel3(_+ivd zct?-RJ|;+7(Zy~3&>R!=^1@Ivd|sqaR2G|xDmhwvJ(T;Ay^}j~JPddcN>Env+EpBw z+F;eeBEN@`p#9T>VW#Cpej-b`o0wpEkb3kaxwGI+kCnW`NzsDcAO@guZ|#`*sz;#K zPIRWFd$BGzuf)?!#C@L2xWMgFBay&)GQ`yepR6TK^-;-kVEQC!I~qgVyeV3cI7w~DM2!5_ouyc9nx?gLFlN#3?1pan zeltje!L$XDgm3l7^reV%G3gHfD5{fZe3Cs_S#%Qe-acO}P3YUZsw?ojC0rhhCBCbk zeks`lDPS<@%m?wH#hFR}I@!dM89sT9!C+35zzc7vnB`gJo~-PD zs?|7-iQP*-+)Bzr<@y4lF`e;c(o%x-Y)gW)Pnl+TgM*{xsVgIb4dy`8cxRUYl`=%4 zjSWlLasjCY-qPfwgOCAlxv|ytznv53);_EHJAq{N4V3v5ci2t63DkdlA=!+BLIDjw3+K!#0#V$KM#CrWU)OMf?{=lW%42etHx-W*BKLgSvj65n zg+XBVH85TW{vmbl0Ngg~MIscvWzg!`7r}>NL==?NEa%KzZnLL171#|BVn-tV*yW6N z0^90=tLUq;tBhF!SO%oE9VQ`PG<9q7%k>wq6MkT z=PR;+OP7En28s70xcH*c+eg>y8eRe14bn7XaFgg*~AMe(TO-#U;fd zYbg1wAdswtt#25Wy~;5GRQl%b6Lssc(oGH%Rg=u^!W^jo$f+%SC2~67PkCdz_hxCr|B`kfItb1U4KEDtzls2;@W9!icpztw1}WBpo(AokC#Cv2`uc*wn?h zr|2+n%Jq-gC|0*Fij}*wZ_jKVEz7pB5C9lX?K*i$bBs^7sQgBLqwwa;_eB{$C;xi{ zj$H?M>p1(YorKVGDTMB~ob!j@kc^a}KXcu5nKIO`QirHJM>3mp-goZ8 zihQ9vUt&xw%(jU@?D#r#R=L}7jF#>L{uJNx&(U0%L)*Y@F<1cXDa;cTx~n5A6xNf{ z^iPQ;amv^VBQUsQzIU_3xMv>zW!dEw zzlnV77azi*73n?4vP&GrWBL`D)>uoLpJ#MHW&{JbY8)Xo_zBJ0#p{5teh_*InShw9 zr>}w{#80vEE2V>6s0*;-e%?hczgO1W&ul;S?@I^RqpI&9y_U(P(a5;%GN617s_Jvt??iJn`wESPX-#oOE#ZZPOzebzBi0$ z#KPqCXUQnlG2iCbJthwLUhBm&JWI)u4kp^leurW}-3bxO-_pZ|-%`Qq?k|9>2tODv z%nkMmM5KExm*B)Zci4qeFzGV6GBPaONWokWY=kz<5wtkbPsLJxj^q86@+Ky#f_~N)bdo|V$mzEk<9sbFW^yE20 z9cI4!rBB`O>B$=)1A-OC1haxofjB|dcn0`m^bi@b*tsyDCnxj>Mwl0D2&B_$bP35I zz4b&Jo0AV3@8X8~5e3pN$7Iij;oNl?Kz#m+ZsgJOS?z3!KwfbU2T+{gG*1{M{GE=g zzfS15;xbtRzg>}@yg+Ey?x*UAXk+;IHI9Wpl0c!5+@mbnM?$g;?*!V z{`p6lz$qc;+d1!Wc}%+G^gmCcssH#s@$g$Sjh+A>VaL6xx*8P$unBzeRWCRic z`GKzxRxnQEsAOR2L16t-SdwFMWyRrp&pME(>l?3Z4?!RmP#i%G!>9l}*;{14R5+#x zyPeoa6Jo{9sg?nSw+YEHo!uusSHt@(i?R?hVn;nFSK2;{^_$pkO!QgN_8Xvi1)Uh& z`gwZgd%lYi+k|>rNat`li#XT^=R^!hfj~dY1GBV!n~U)wJHaYxjuh}V-L?nK@|?bc zrKemIBOGC(AaDHR?Co0+ZsiMH^@WHMYP0RWlxqdGE^UC!JLVCK@hSUKM%;PSCAb{m} zn+^>AD<&X4<5EPvE>GFz;jSV_C02S|^d!9d*X_*DZ2RyCPDr(|t^f%SF8iQ706oQ* z3P=aU2lD)^RSNT6G>A-{%_XyB;k?Z8&CT19>=LICAuf5p1ZsRyMNoxRMP7yB%7-9^ zmCsc>vMe9U-bdHzVJ*^FSnG!U~=_E6zHdCq9DQK`6$?b0~nL;wA~i!gMvE32pXi+`gGe6gS%Ie8AU1Z{!- zF+VqVTH^h@BJ%y#o2;U(y}Pk6?cC z!#lx4n86r|_E9W896)&jb6I*+#W}t;rw*C`g{smC&vmBTR^j=Y=ZoAY&a7XTd73tpMRne@Bn+9ejI*0gy3xnCk21G`iF{PE|gC5peek|lc-E%d z773#tGD1_W8Ahd)dFGby{_Y7-UXuu^BD04gT@V!3mEzCRLS=`ghGmCkhGV;n;kS3yYtm(Yp5nU{^=)F1*0qms0Pp4pTC2ddGy8EHUEClil~y5>_3%{ z`>T_IDBl)@U0H{%-uyfttezIr_{^0Rh*6>+Hi`1=AUzN}o{pbul(H_-?s3-H9X$mY zDrXqdNX&!DgW^{nnkrh_8FaB*enl`I7+OOw~Dc})Usk> zK9ncV5DG9km?lUCG-9*)k2*;?$cp_(oD3=k0_j|M@NT19CwLz;e$J|O%Uhmk)M9dB z{tga=`T#mO`;1?C+*dkohKJqiMT(BM+s|>&s{3_=H8;NnNes_4B&8Sa{HtLTdHyVI z|G0?1J>B#jX}%g>7I52|B@kuiP8bpFuvw59y3jw)0fy?1Km#DYHd5aR!r;IDGDaM{ zK>IY}z}>6FBQG07UuoTPxlPI};|_oS_*4WJKK6e+i|2}u0Kz=b(ORG7E46*({WQp% zuye{PPL^!e^iRzH&OXt8v>JhSP8h(rbR)lC`$~bSLpK_I=0DKxkrk0Lh`^+| z+ZpzBH#gntq}on)B>Zz;Xx0A@z+s#q1N;U$r@ki(D56O9 zrazKiLiM$}v5lBjPh2ZVP*eJbnm{|3$Cg?eBC7XdAfM+am48zomAc6q)vw$lBq_ob zMrf>@;A-Zjaei+C`I!)1@{Q#3#`CjgQR#~+vXgf}Yy=rhoASPfMSQ16U1PasjQv<` z*Z=Elj{o*~copU2kClCjSnj`@K4tTS1Jy9=74@qA#jQh9KxUPkGecQ|Y{~w{F;>b_#L?k>GLD8%sO< z-Q0N`J3I8ajSLB~R{ZQO1yO-!@hTWr-nT~hV4n!2xnky{JR9ALe85@{Zr<(Nhgu@! z>1?1s1B<0~z-8=`H+#Uh8yW@)ses6W{yJJMBMga2>vlm7}4qRB4?+5Qa~Y=V8C7bYp6O)^iw zvbqjDK$JLg2XRRnrhShazdTWplysP_3C;Zv`ONJSu^6G6oBvZvlm|vY65~JLI&u;B z%M8nNyf+Nj1?hg%jdJ9Yvi+~7=0EyKuT7`Tyf(9Z*)?=)ZX>?_#BM&S5#5y?AprZY zO#F@gW0Zxc|IED^m=S=RWcz;Uf5Oi|EhQG|^YWyT1Z~orzVognU@R9d zpaw+r%rlaiD_znJ_{X#>vaR7hG$&6GGB8z`JP0+bp_Pyg2)p{PwqFWYk%xcx1=?3q z4BGz>@Yt>b2uj#%SQF?JZ-=3P9@RL$Hzb8|x~RLjz7?N4ee0L;lJf=EE30S8=<6Y| zaTQWg@!4Bdu(-*resA6olTE(*4GR|br1t(^RKwsCtkkYHPlPtds~V<_LkoGsI;@Sp zYHE1#7x_w(xeg`+a{Slo%Hesz$S(kt?B`&^rp>XlY0rbj4emhf494t&aOaY#@!Q@p zRq$5O2LwMvry z+Vc7V{@&Il&}B`xCAJ+%-Nt@=$}_7eo<&FX6CHIB!F=WFh5ne}thjZy#408ywqNl5 zqmeMRr1RTf-cv&%6h1FjJI$LOZOA(4=ElO?`v%4hS0258jR>yX*7-y!Kd*@l!g#~7 z)w@<;YFWvqOLg{U=9wVogg3JSTTpkQ3u<_iv^KL=Z+cu2O}=w@+Bpqz?sLSIsH?2^ z-fW}_Nd*&H0#o1^$-4L3q?{Yx7`Jh#%}VyU*i+fP%M!w(NHRHqjm}s&h$SpZZ7d)~JR2Rq) zvLCCqDJmb*_q8v?$XTzq8T-)uKEm`^?W9l1wOS{HsXs0n&1v?y-_=NvHt@)aXVu>g zQM|V1=k+76!1P!!>PC5Ydu`8D>9=wv|L%F?*rvaA$j(=jUA`t1{C7g`0|kD@+6JyV zz&K2PXm*uxUde_9m%U6=YwL^Q=nSi8}yp`5!3#2 z7;68sh5GI*jsC-@c25SOUxbMg?8_r-#B>bt;xHoI{@kVdxYlx;@~8kdlwwR>tx3=3n5OUR>T^4&KNExF2Xnc zNSQ$^Z|C}0p!S`?Ius+VM~c-tyu?3~BMdA|x6aSS_)c*8@{t&&**l@z;TVKajC|!+ z`Jv;Fs4Xe_Lbl$gwvbmiuPHF{4e|6z+4c`d!P01Hl|l*B`UDHkRJ+E7eM9(4GKJ+G z2gfFSt3PtVP0Z7=^8+UFSr*?ggyb$vcxeNNq=#huTN|`fvr|rm*=wJ=$rTI?j+fv` z^q@AO9js>$q{@a-bKryVnYDf6``tlxdue-_ww32ce2MPTj;&+45D7x&G0EL|5!Em^ znM1LgrmEIYK}EFFQ&3mlX=+`P{Tl&avm%yneNm|WODLMUxAN5LV9I`XqI#CR3}be? znS(O=8#HTQ6{5Y$^ve_Qcb$9K0uArUIh zs4cXee6fuOHu6#7 z1OvLxx=Z8RGTAyk#&v7Lf|u7?QPd0^M9{WmQ9xn%n6?UBcEN5gAvNq%XVKY%;nBj^ z;sL0Mi~d}wVcW({sI2uAm|uS%cUj0x^L!e3B+{kb64oTO{%Pb+v)&$CKHA%HU31mz z9<|LBb1-QcRz%}^4}4xH+4fVt=mpf|xxV=9YqFA{zH@N)w7%J=k}UUo>B*tZDe~}Q z1*pl(+`;4#^6*j`uX~^nkq+ru>2XWYdSSCXlY7RE!YnspcQ4+a9BseUDdYJkJ;jzo z5$WRirD(!PEbir5$_gtUK~>mP4rUnPH2%nogepYp_D?^8m&@3Px4ZAy>WJ}ND_u(kpa`38^A0#fvw(=Rdk+&+r~5XnVyjtBuJeN z+!VwUxsV?1Q+pFe!7Mjb%V}l=Cr#{J!Uw)ITFfeG@hvO)-4sJwNR5knUyizt{uyy$ zA)Zt>9q8;`y<+{c(1`$?iXDCz zBVyeYbb@1a%u;7)&|3O18%=mTxhHDikzP;)Ur1xsT5VT}9rfSWL9Ikh)ln$}Ue($2 z^H?dokO`CgWZ+>g@iR|f^G?RIYbS<)Mx$qeu=z@UKX8_qrgQ+xDWSgVagShV7P-tL z4lD`kJ_pAZ-}mrDn`qE92A#YZ$?!AWu-+F$tmueO^BykS&aaWMjS-Vy~K-=blj;*oQ z5cg~rka|>GF`0=>P3y>3^e7CBHm)Uqup$Z0To_VD9WylW@5wNes>tYw?7PuzmK3`4*DkIl(*j{S-|FLDXBPAkZ=3odMY8cmS=y;- z!D#{jiU%^l@bU!0+UAyR!%4EV9l3zmncjgi1R3K7WnAgmDe?gBzp}vr_mqz80=~iO zf%B+)sEt`soNyNdGw(@QtP9pnj@TZ+(^X4n3l&AuBOM-5rs0sJ;P_m<5qqfuQI z#hiW#L7`YP>v7RzEsZQ?70}dtcRXHC zW|UtZko|M2O)h=TdyGc~h(GlNp;qs&5z z)$k*O=b94gNf`9wG>Wc8OQeQ6WE2L%I!X(WTbnIfS$1;!v2a5xjJKIhxiFfkQU|{0 z!8s4Gx}L0(ipKgYzH_u$?uTz|#^mz1?jga7Dh;n5LT{3gB*AjRV=o`_Zy=~UW+XxR z+fH=$BcVs!Hrq~92EpYbo}Q0t%ah0*lgWZz?LU~PCtzn&;xQOlEo$mU)x@kvOYO(g z)e_*c^@#?3(GHs)~WB4knRvK!36k2+NKRxdBMs(y3pC)@81#97Y zklcwoN?LCpN)~r7y^0L}NjBxQYx^*^FAC*sFMQ}d2M6cLz#BXe9VxOmS-bOL49&xr z00DOyMAqKQmrAF;juQ0Yx}J_=8WeQ=JuX+UGm)zRaqoMC$*z;s7wtbZ`wgfE3>brNSs@r#!>DmpgC`D#AwAQqMekhx&5|IjzTb8UA1K(zH7Qu0ZPCcwZEV| zXjm}mIcO+X1Krw7@Ks)}Gi2Q6Y4%^)OdXp>D4-U#t`+CB8>wi15;wSO=nFfk*X4?M zC#ST{J!7en>Sxg;5!b7g+r(5Q=Au1Qwx)RJl=Mq(aCJ04bW zib$@b?l4Q?KpuxxiELpWpWi}4iRNVLg7EY!5Mz|aW!iJoj6pI4224Ya3Rh?E8>4dI z@+;6wxo{Dc1}@8e_M?wgQ4^=pR!=S8o@+uGlJ+jX>yGZuXNc=xyA@o%&FZ(<&pFyw zmJ<#~dMXlY`ZuPtRFiTR>6Xd|pledpLKBr4n-$AUwLZlcqL-#L?>9cF`Y|!==-a$n zor;CJ0<68VfR0wD6<;p_=+h?>ONB)&UupVE2-0fviRioPj>Xg|w;f`@$mQgdso^h# zmG7Yxc=lvQ4*coFCU-_P5oQHr8=?cw}9T}q=?F+!}6$lughOX zhggfGjMg8Lf7ptQdC4gm43j!q?MGf*Q$-CXL*Zf0X8R;dpNlMOdEjpxEbUN5qFTNM zI=q1i6wKIgwvbZ!1?Uz!Sc`bzfR>|-odic#C*TVra{Nj;ToKV&ag|d#`>{=U zo7&txJgHr7bp9-Na0^+t_LruLpPu-D&G8|-{<2iT<@#qEiv$n4>O_IPDzP3LZ8*8n zE}rH{^C!aG8?kt|l;Ro*od_#AeqpAq=bB_9Tkmoc#5N@5Q`4ZdyWDt32Av*Ygx@ZUQZWLDNI(59}|j9(8Ql@hV2eqcx6t$ z*0Ww98{uk%!rvi@v?GS1LU!3Y(aEl9ZzK7P2_lgkz-=Od4q17l zekpc_C7Y(V6=rU&SG-$Hv5sGV1&S^LiUAqc`^QF%P`)(phltV1-su=KD1jhpqLsuH zD@jT-*)f}Iy!s?Z{0_Y)zn{P|(4(qcv$P2|P1B^fWToZi-!?iq>OoorqmoOkOZObK zEWoOjT(V549nCW|E74o@j#Ym+$`sjR)1){H^U9iNN3-fgnzAW*AEU{=0;{lim)qY5 z_OtA0#f`dU?)?>*vuok8n{hHgq3k$r-=k8HbZeN^*mHIxpdkkIsIN3;ZyK3?HjD3_ zawDh{(HEsEn69$VrCH~<=!mTK`d^m<;vTK5 zm}x1(!qIBT9G$v_i#|ooSdyzE+uIhol+s5RG@x)Hg>{Z=KX>yRUSY>zQ;zgOz)G@V zR!T2ib{0Sn*8J0B^G3hOe2^$Zq??I0bri1FqFA<>(9r8ZqhOfD`LPV4<y==RdAq=iqwyx#BYLVFsr*jT(nlZRuz$@ z*?7aQ6A_(ZOxd1WH@amM*UhAgX@6`}e%hP{7}&evihr9`YBKSKu<)f?8-px=;i-|V zMpN1(;7|DS=c4u3D;*lMNxdUnZK+dG&xyt=pC}4E10nHfxhuFI=E7jHQ_Nljx?$Vo z$WpJZrp8#E)O&}WX`itZE_UgH0MR{q=nv1} zqb-kFce6a0glcBX$kUdT(tx(2gB>mM5#ZNbw2UL7^n9MoQepieDODY;XlKs#{Tu33nC8vKFG+gOCZHKY=Dr zZIc?|GmpCY{b{|eq+uR)EiuMxlDvlCqzQ2zD$lCu4#1zb!RuV@md)xv0}PGEDK^nn zPiN!>L-Ug;n7Q2SR+j@!q!0v!TkZDhC#>MN@Z(>jh&&0+a+rw%RBTWgN({uH6fx_lll3E`0 zr3{867e8Io!Lp70A*YGpr<#9l!rTGqb3LU5Nq}AvzM8k?1JEv0I-{CisnN-G>{&i z<8Gh=Ysy680O&_@qFymsV(N&V?Q&Pz>|oSW}M|*Bopbk-)$lX>CG( zey*7qG7=kj6fpSV&)79Z&@blUZZ_KWYZAVZiLbGoGtn z0VV>T6^ zau#w(*ol$74Q}UJxW3V{!pkwI@gg48Hu^@>Y9i;~ovT> z*Y2KQ7bSUnzO#!F=KGpF>P5C^DGj-vC$2557#9Q2am{DLuRzcbe zT@PpTv$P{BL0WQ-l5qb&ZNyY<26n2yjgZq4U@VjLDPK_?15e!JhTR7^lu%4(WI+vA zH$khaCu6OtIj3CGM!jukg;kZZx(LcluZ5_T?J2fOZyJPq1PTcQ)n)I+GbcMe4Yvtr z8x?0?iQ7#?7EXEW81C$=zwc8aUS{JM*IrrMkM&%#n25~Xe$lT2eb7c-*jhCz6x|`7 z1*^T!s4Y7EIpCIF+A*{KLpBvt`K}#OY~q$EWI0jS!&J=%USbu{^Qh}QF!65mE75td zI?QI;;?n8s+xtF{Q?^SOcPo(W&KYEnFqm3RyyR4D)2^clSY6~)FfcW6&Y_(%lB2TaX?2W19rF6oXsW(P z6~Duy^NPuMi%q`yiR(npVKnm@NNj80*^rS-F99APZ5`MPNQ3@d&PkZo5!n*f!L2ng zuD7{Xi2w7o)U$bzoj70Y9V<5qIo3A-FmG3d=O893C1}5 z3>}mDY?B5j*>By!NltuKzde*3C-{y-Wjzy_7zZ?Dx1|Siyjnl_T22HCO|;eTs!MGS zoQpX6%oV_SHVTUc4$of$fN3f%+EPjdiyk?*ewJ;JTLpnSPC*9#ZVeN!J}{YpxUa2K zc|0e-Mg@P;O>JqmaP!`P01?yJESr^Hi3T?hpCg;3z8$1T&uC1;YX0nvq~GFVvAAo! zN_qK&HEZct5yR@e3!bTBcaA#u*3u7=DuV?B~#X0_HZN>a!HN}X5l8>fl zN7Xxv3G?^UX7Lhl@)lknNfhq6Yo@7y{UNEn5$FPp=VGoZudy8$NNVIs=4-|Mo^r#d ztgBQ!g^c$EMo|BCLCr%1GC8B7bTTELa@Flw<=WmDDpB23BA;lQe~tO!urMwj>$K}z zJ6sUbK;L;P=n-WxHD4pHM)uKS!Qud>hA=61rS zvCE*$Z|oh}7N{~^370?jSM5!AU+d3HE8Qnj;57*lt@6A8bJSFC&nG0>g?U%yD|$F@ zFP>GF?WBP|d{JQxAL-3>+KZ2#ZyfRnL^}8_ls{xGeP*dGUOvSF5y%QKdYe!hQSW1X zs8UH5w~qVFQnlx&u$lPOefg7}=G7VJp{Nqaxw(jLb@AFJ=W>KnIaCSEo4${e&Ow%R zqb5i@=r~xUHl>)P$F#6ws<|O@+Hc#GFEjqRsIs`~7W$jSBhNz9@r>$=NSlvyH7hHt zU!hL=E>ompI!@2oAX_>PG^Q32n?vlntr zoL(>6jT`vaoSmlQqT6ZFtcK%4ZvA}ip~G3+hK%E;SNg)X{AI?ih-J7+jcWS1eQ17( z=X-#?rm>s_Y0_KoygA#c`SwdcS75TrP#Z~0i=(0lfoE|~vyrF3$g#u{v+dToz+uRD zn^+Z2Nap(pjP&}INb>tGL*Ds!>^D%QAF6ZKnxoaA&~291)dE7Q?Jl4Ee6FA}^Ec@W zEhew~Q}X`)21&VXCQ}X^dj*mafUoctf&fXAV7OU2d6M_CgRWib%4+PK$7QR6o@23_ zxP(2&`TGh6dB;Jv*cH#RS*dHD^qGt(h!kWu@w!pPbNS0|Y_lukn~L?AkMWU^Eh>g> zKb(yGADoDlILA{U(i1CM-04%r~`Zjc`qBRG5!Qv1j$|7Epu0DFM$6 zURL3xlF9n|x!Q*Ug@HP)-1_qO(8vCyPJ9-bd6;LEfG`ckC&GJ5*HS6Tv4iGM!=X^Ft;7R2ib7#IHz2IX?|?(3mkOQIyw8cEmd7+0d~nRuB=WW7dWp>CYGX49e9k@fQrb!22yVXJY+a!bAM2E+}U9(%4Z z;>1mx>Xt+vf&8C4Qrxm>W~TAI4VL-|H@9eS`|*>w4) zN!e_v;~`gx*ET(06TP?EGg^m84qeSJGw%sl>Ze?v*lKkJ%~Ku9ITc;am*|vf4!v3( zeeRNDZj>BKy=NXoZnM+_^X$!`j>qOmu-#JMasy(9Lsc`{b;1ryeMJXF4&80Iq|4M= zUB{rA4y`|6Qg&Kur3s20y1&On(L(_w;;+4wjmfJ(kMWQDmw^xt^UpAkrLq z^3pu1Zm`rZy8*Glq5TJ<+uA?4ablrEj}L^M$XjhnEOO|0w>NZZZ&>V5{gvoT5!$wN zhfY1J^ah+u9BSW`VJ>VewOhO5Qiobknv{K(nr1GG9BMvpQudpafLP|x?3>GU#~iTK zpXoTrp%>9jWjS*bM5d!Sm1SHg3O}3RTEaY$%yOvqmGOf^mMWO+CWsY|vS{zq*9B91 za;rG#);LuCd`9i`o^oq-xxyU{-f}q( zz3hZ?xei^Ko!nDSi9ClMO$VFu9h#8~DFqHSWkSk2hn8nX-%`mkS4R$2ERUuvH!+Yy zw^N{P#oESet1fzNEOBT@Hs~&ODEARqz22c6b0ANdL%Z6c{pGs9S1(Ln=jrba4sD$Z z7F1{p!fEM+9t$=))UaoUw@)@Xv^nMFB~PA8hi)$e3#uGig*{O1(D|oeca1}{T0EKq zVzWbcqwo4I(udP64!xMwq!SKzZRJ}X%0#`}9J>7g^lo=(>QS(GheM~Z4Ydxny)aAj z5ldau8{AHZE;pH!qn4Vb^Xzgcxhv(ErDp1s-Fg8sE9Y^m>n(#ihn^Re*jPu{^*s-w_GLmI&`=sdJ#U)Ja2O-<0Y89-=UQ= zlf8p_Ko4r;{Ab=tbkL!U3>b<-4lT@rl*0}kc?!CZI8;)W)b1J5qYjm(K&fL6Rig_Z zcWB;H$aBJ>c`v~6PHLCRo(2=P(V>S}?vz6>aN;#NbZ9E1G&@w32c=Fsbn`T{qs5_h zN1^^Rjy@$xv$McI0%{c?sUUH}@3~g+8X!9krlAp3vzCNQ~cBt-_IaW7W zYKQiaW=oyX@!%DQo_83@X-jR;DOdFfEz>D2mYT2ibvSf%hq2;}rB(#ofVk$+()`F2 zD;VM?i0j&k&R0##^ElBBhpxQ>Q*SzSx_j`=1L0c^)yx9pZ#(qx7L>Z<(9?a8a@V0J z*yMW-%{*_K+-h~b+H~KcZQadSqzCbVqa4b6v}$Q`Pq~K<d^g%9p07knRbVJOTZnTJ2ZO^Sn$H3^95kRONX)=9$xg6 zdgaiudEk7n9co5BVIS3WyFs2_L#F!Z39g>geDq`u=$Y=L{jb3JlYENPRowMv_-NTf z(3I?>%?;)O(^*T^m=MWFDcuh`*7@87G0UeM+MNl#G}}j&=+JX~v?Cc>lH#M+4UlK9 zj|!eb-_FxkzFG=>JKsm^yo**qr243Jr3v|WGV{(-nvYtZLqit$=rp?RLLV(khfZ4L zqeoAnlNRfyt$O)liKl7lJ}N%`)H`fTd{lTF$}QDqpYL4n-7GVF^!j{hx<}J8A3Z>u zmiuTu+LY;|WL&kfe9EFD8FPy~lX8WRuBJfeuJqBWz2I)Ed{nut#k-DY`)GR-)VA73 zudiUZfnA-$zfTPR;a&OFn8o20gOQ zM>8{FaVhlCg`1$K$VX|fp^u7n|G&T~S>mIP7SLVlqYcfFvff8$AD#7%ZJCb>?m=zk zKH7K#Qa1Rg<}B1-q3f^5`ZxM$2JTUte6(;X^kk*>w~S-2z5Z6^qf0m`s&%fTIN@r1 zbU*W%*YeFi+VL7%x5Y;lsi(aju+>M&I8C?tXdUjY+kNz+zSl=h)1cfw zT`mnrWxtQsVoY?vN3(IOI_RTYX&5*8XexT;VIL)9Bz43`l?Oo2Q6C-XK8u-$KgWEu zEj{`;Y^sTyeAJF{%LyO7#31&hk8(;K3Ww$ zE&hj&ntW6~3tG|Qql3%gAaF)|-=5XreXTxv@*L_q>!T;#L4kP>=A4gS)vot?-+3Q( zUhD8KMPTI@Fc>0Z=uTl8!%W;uwN z-HVY&tfuEA!Qz%;y9DRM3>N3|M*12ldI`MdV#;%%mPSpL^s#)*Ei*;6CgA>VP96mX*mcXRY zyCiL*|u z?WEC=x8CMN6QA|%P880tO3pcvwXn0T^q&NpoY)@SZy%8kr`(H@@sk(qktQb|@d?=F zL}%-Sy$=_hSmj{rq7&s@N4uREcof4xlM|hJJ1J{*M7iX|di9pQ=3XaexCegOiE%!Q zt~fEsDOa7S=bmIAnU?y)3=mv%VseU=x=#9^wzy$;5^gwA_l8CJ`ki<^zaz+Rg#%)M zKI^^1KD+6}Yp%$*oM<}4499H}_h@#jUEFt^=;7*c*NIav7@PN~y@|#od&uLy6Mb7u zWgj@v_Kb1x(24eoD|Ycda^eLara>n*d7xy-iE{3!4?D5RgSsP5^zzu+s1w!P0F613 z&u#L!6PZh_XOF38a~D`x{fQHKci49mPK->jzD_zZ%2(JaCysNoIqk&YWk#8CVq=O? zo;uOb-Th}y9OmBctP`mm^E`KAwA9Ld8PjBflaP`V%ZGNdVMg~7QgUMQ_RjuLoOJ@u ziFR(Z7M*B+&XJN6_c&sI>BMy&SXgpm#dhFF7q6Unxf^Nt=kUkHvJ+Vki~s+}MaMPy z^b=tzR-DMcYi(~l*5qlTtU8f-$D%yZWGhkDoG47Of-Do79Q=4#7q6Wd*txi(o;tMd zME&fM55aRlyrJQIa`@pf+se2>wC%CcQ#RTr(faFRTWz#0q7B|%Y_ZX{iS}mvXqAl? zEu@Ckvo-c|`-pb0t<_H3PqfLE+s!sw4ACw<7-+K5Vu{w+v1F&k5v{oUuKnA1qRlt3 zb`ywpB)iPcUn0@ApGVvKmL%jJER-`B`hdU`-7jMKt<#k3|bTA)hcIs}GFcr;32 zb7{=3d0hhcQkYgKW76E5yO%z`NmR|!0PbJJeasI{CN zVlLX4z~h6grg4EgT>g&*qIXX&T8Av22%LX0Vwd)WKpb}wCk1kNJbX&vHOE8K0@>3X zR|(Yd*xys)dhBU}-7-BRTII_Py9Jvi+VwORrGGAPf?Mr5fnz*cHZO24k;Q5jXrAob z%D2yxMS*9j%<@1%m6vy(*#QBw{&34XL1d1***I`xQ z%I;+!pCVW#u#YSFYk}0)Pwd08E>QNA3HXh`EAH-X2t;qQdNu`$ct~wa;9UFCW831h zO-zrju!#~Kg_ZKy!}y9MQmG_ecg|VLQVw}N-TZZew__*9MM+KU9<<$ z;-ipokxlA^D2#A7Bryv02~X@H{-h{e<%sD(6c+d-PmaRy?gnzH9+n~{3b|ZTsZn@w zoh1*XMPaj>P2hCmy5=NP%#0|E^KGZhD7@mna#j=uc+aw@ItMg30xlwWt zt~_9Bm=}dEUVDBNo^7+b3h29(lkB^~D6C8~J`YBrfX5|@qR`F(Mlp@>Py)-PITVHR zL3tGJ@r|z|Q5fWg_GlD3M_HMQDD30= z+{cLP(KN>O@hG%&ta~B~eH_SCMxl(GzN#oRb1+aHg%loDsG-(Yub0?cI~j$m%dAu_ zmFnR<)kWb#=^6u0QJ9No^R6LE?!`i8qdh#(7=_Xy_FYpH&hc5-Y;l!PWN+_O6b86E z-V%kXLiSxN>CVD5)1A{%SbxT|o}y5^JDOJf>}(W9;#l2nQRsMZf^FhOVTSAOxhTwb zvc>Lv6drMZqazB5Wupu>Md8S9T=9?+)7;R^(qq7Z%So}J@MQ7E=o;}gA682a$GJt;0np^674u0)}d`$t!4Zl}Lw%XMEA zQm^vOyC|&MEs0lLk3z}0_B}h*+@K+CohY*pX@3+>UthBi-~cr?eTn5$-HgJKO~%qK zDs|-H1G`baO|+F;kL^zR9inX{7S-EmcZv4+&80dU?HTSET{fKD2*=OyvL81-ZpR&`2takS`+G)c?t3H%yceY1}7Bf34cljy4ve5X~*jB6aOS{_*3s?!F)uEov#XtLV!ptY03pf4M}Z zs<|zE6@?6LiI%DUinwU|tXYXd+k@0e`^H`rs$UJ;@ywcK`BoU{dmV-P3D%$WD8%1m zuiD8HJegNjsBU)Yup|{@YQA;rR^FE>v@az0ZZo1FXCI zT~bBiP`$mT7#FJSh7Kauh4!~T@>5M(mr_k@vX{!nxzP7uCv-FM2?A6uq-U{VO>p7W z{&BX$=fW|bRGQ>Max~k|JK#e3+w<$sG3Q@V@)X+uW_N7E9prWp2skSsdZtHFL8BL_ssISDqG$4E~GwV6S2XC+Bk-|8tJ>u zVdf7txp3+x!zs-!bdE9EopPb~Hlwt-(7>mCs|%wXJ)S1{wrtzvtBW%(bnYgM`v_dQ zu+YzAQZA(Ncv`y)M_%sa>ehT4fh!lbc|_p63jT;oGjREWn zB%+%$Ohgx5*lJ=(q?<|&Hg4N7Mi0>zRub$+{gMk=Ws@u_Uq?vFio;S5jqdE!FHbRG*%x$uC8`6fx4^;cNXeaeLao`^VY*%^*i zW-PhnGr2r|Sy1s+7h7yBH1lfbY3d7_cz6jHNJ|bx-rH*<}^26 z@(rzYHzqIenRD-Q4q+(_U{QK1{#d@JIh8z+xEwYOE|#+j5y_Af{wURwsYvD+DTx^jjRM#O@$lv z+=)3xg73&;PRwyPlFL}Q?1UR<(v}!lqVJMxC+q`Oa*U7qC9I%Drns~b=FdUo2<(6F`-s{HG9@hWMRBB;@+0!d-ByebV)r~BE)UA)Cn>op1 z+}GTw;_KUWH(FoD+3nyBHwy0YjXXD2c^rPgjiW^@;(ODL)&z#DZ@ICUvU4K)xpnKM z8?hI5ZdvMjFRY6@Zrs`N)L+x0bJvZ#PuZZ|bK@xAcfU^p$!k4q7svxQ%6L%Yp=Fl& zj?p7G9*i=4I_O3TPlp_GBa0`54qJj;NVFUM5vskyejY#*~=#&JGT=cv2mtxQqp-MBJ1W@li*jar@tvq%g) z>0!Y0g&T*t625e!<4mJn<(Axt8DcfPqMFY3GLXJZTqR#%rf9{DrN`{cRr>P$GBZVM zZd^S4#GU~4no3={&dlh#8>8Itym6yFecc|&+Mw@dpQqW^j!idC@b9+V=;3S5Ht~MA zmO+bX4`Miw+~-005(CNmJyJs>hw?EV3}o@`JSx@O#BxI7Jm{L?(8+_L3v6O1cu>u^ zQW8Blzz-27d2n-tLnjZ-+R7sPWJvZPfuCha@u0Me)t%}=I!}K}^Prc5t8@<bP8_$nE)WMjsQIr`n~OZ?NU@z>3y5O+zIBPYK8HMLyUp4?>_L1!3*nRy zzjt}yw$y{`{2WCY)xMCBYsW6-9z5srI6|f7U$VJ#)Pu}Krfn5e>QpkT>zD^^)mApp zye2bDLR219?q0lJ{SF~24<2zxv66Uye3RM#Di3-;vc2cQO-`xtpqx8XCq3xqOM9&c zi(LQeJb1_#wt5fN9`o~g9=zbLT%!klJg1?_gSMnnyZvhRVDl-f=adIa7g%3gNNVS^ zo9#xu)q|WGwz+$nN{zR$rT&Zumw1lSSq}<$sIAR|S3EhOooedmdrs#(DB-a1ya)C5 zV|EMILEm)_Gt1cNK@88v=%P{+`%~@OdclKB+@4+Zpo?d6b$hVH^U``eNald+k_T%e z%m(y&(A&;N?y?7UC5)ph#L;9w)7`5cwDZyI^B|r(71umyOyTGAJb0bUuA#W$K`cMU z(eJ@3w=M%7^z!ZXn;ukl@f|+W_k~<`n(4L&`K$bdo(HjIJI|yf?IkSbK`zh8y63^G z(@aM9J!ooRaQA@+=eaF?=)om^PVA8fInm5t8uTEYuh&B!T(t-BbTRBfY>{;x>w+f# zF8r<-@!;t5LNauV+S^O1`!x9{VJSvEIG8?Y2c=^k+}h7ZZrmgHV4WWUcvo6lJy%d&+m=H~2Jc!g*UQ^WS%=2fEAWH;GqYeb8#x>#eQy(Zd? zV;lC}+;vMrU2F&X4bjS5GVQz98$@e)Fl*oS-6Yz>tVkam7`3?F)0$w z@M3%SadeOv9d65@Kc+|Dni>!WD&mk|G z_%8ZkFP`xu+a+F%jxp!5)QgP;o@(yJ%_5#^P72n4m;tyWUi5V{qkPnhXI&F4K;=c^ z2J_I4dGWeo=gPxxCoJW~aKp|MU=2z*AWl$Y``eQ3Xrj`K6po6jyy*GJp*=6^xD~DO zVl0Mf_erXIW#NRq?piO(Rv4nJqf(`r3c#cZa(g1eX)pFSFi-Q07w5Rrob{rX2U6O+sNtxk-HQTF zIp;+_zt`uyWjQzw@9^U3Np@_m(~B6s0(W^)%i|;$yjUAxl#5=}@;$*5%;xa3983j40ti;)>7waZ>i@twpgG_ub&QtXO+)r;XSwtdv+#T?%ny5_|`zL|a9 zi~al_hZ|m;ism;RcyTxJhQ0j(YCoMv5pH^Mh>!m*FXniP;cb#Y@?wkKo4aFK{o8E6 z^Dfb@KW4Oh^zCTvY5VBi_o9h=eGj}i%rDD%=tay&Zi@0^l$(=5FE%qz+D+Gx7sY%b z81~{Om)(dL^>K_c>cvxj`P`USI8Be^3<@xIqB$chR zEKg(7i#+>IbU;i|sbU^Cnf4-Wg>6aAc=2NF!`A@=;wgQ0av;}U`7Vf z?{hC!6KkxC*fsga9hPFwi?m|v6}cr%UZM4D-it>2<&6QcK&_?mWc5WanyQ-YgYd$O zv#U&jUeb4itFd;)U-IGs4@bVDQsq+m? zUup8hp0E_Jy*O85Jv6Ya$@D#8U95XCz4NYg`W`}1G@c6&SQvf7i)emAWRqB`-Oq%u z<;62TXScn`;LBLFPpTNtWwr0~;Tgy5`+ZVFNjzKZVtlyFH(_IaDCc%F&WA3(Ld5%! z%I$c94@*2Gp6J5@AA}_0vgA&$eXHUC(O#cnb2!m!z1oL7WnYo4uia+(1+6UoupMM4*GEQQki`&6!|ce%Z`~9Q`d&3GuTB@ zKD2N+aoC60QMOc-_>jUWr9Pa?J!WsM%!jfqCdhIU+S4v3v?D&u@m-&zKAh*nR6$&x z%U~|pF&}CtS?$NERLAQwd#1q&qQ#H06A+ce#ZVS=u&PMp>j{N+MX2^+jbAEML#0OF z@GGNyNEu`N*ZPphb0F%7i`4OEI~Vmt8!uwplMO!99j~?rup6n=Lw?U`lMmB8^Q75_ z+x#lCQ$92vtFVKl79WnDIBp-GRv+#UzqSX3PE%{wZnC_SGd`?w2y&K6oxaCp+D4L@ z=h=GgK9m;UU~tNZYcqU@&xdJ#i$jMG!#oh&Np&YK)Y%vOF5)4*CXNNBd>BpOiRV7V z+XHr=5Zyi;Ew>Jxt!VP(cZK(gRZZUcE^C+MzIFYk4=>VLC6|0yKXcSB%3dG#A7W)L z`%uddbX@V_+}3`(xUc#!&u=^H^Pw+m*^V2p`A|5?2J*TOm(!T|Z&257WixBkPa^7H z%&?1SfXbB(x7(+~O)6KzZ^gRh!xMfn$ZaZB*UK>O9UsQ;GGutyheCcX_nr@@Il8&; z!(;BKKJcNMEAK-esyP07VhJ2{viN(V{T;n(YjQG&N6D>x4SkGqi z93wd$xz2FnIMIrD(#B&SN_Yay6Cc|7dA123vbc*o=|c?1^iw|E>SE5~v=22LqRdc# zXHOnt_elA$J-=a(S3dJ0x`6d%miR6|$kJV&TVu}CNauVg<~h*w^ksi_iM_`QKJ?gM zJCI`0hxUqi2CsZb!ke%7+-9rm*b86@CC}h4@~IXVI5cAEGZY z%~&J8S4x-{@!E%nxvY2VRM(h&OFkgp(3h)&%(QO!&|*J41hMHu`eo}$tu;;7%J5$C zT9X$gp)4O_4_W`LYx1>=P?irv`4;7kCQlJ1TF22ui?X4~E+=6u9TV|8Pjs~rWxtLw zo~jz7V`h+1Vs%{R8WN{V6~{MNB1t^a%CE8`00}xa$JoYyqK?bl2q)?2ILHq3AJ7rK z`!J9m4v1u`x-*(7WQvZBN9=S#DwTS9mL<}s>4@Gv2VuSQnXY4=!f zlPya}HAfuTI%@gKo1^2{_^4eyb9KDpkTOq~d-2?U=}SQ56W`DKFWH4zK(yBbg?5!G z)CplEFtB}4M=amQDALiuo&RDTojfJt5Y^txH;NDIxY;{qj}n&XxbT3D)Mvy;_V zMx2$TGS13%EOT>sM8|_;9Bb*=;y0vMP)!GNlGud^Iu1T%5cN2fis2j9Cv>D$TF;1X zYI2=?wn`m`D=f;ECacLqtkUHkoVvxYL(uV>Zw=MxSZp7&EA2@gWqkB&bxiT>zd9Y; zIRp05uh;RQg6Ev;NZ=u-Mjg-ZGc9hiWchfX-4r(KnB|#lr*w=rv$|V!yyOc~tB%ue zm}7m~nxZ^>bVf((DJI>s#K8l8xkMXDZZw5$pS0_^|B+LDI*#&lNauCb@gw9NI_4HQ z!lL#n_@VYL9Syn9?C9_U@t&W<+>(nrs>XQgIhD#>WJs__$EgQQR+n^KfIK|`F*L57@Cx>t7SnOu~=%;>kBruCN zpkt+p=bh`Q8)h-&TRJ8#Fk5z8N76L=>W+?km8`Y9Gy)IrvVhAy9m70id0)rNNmkPX z9ko19_o0q@ezx|Jj&gpL<)DtFD%SpxrEvvGcHl5f61bVhE({&farr3oAV=xTcAk_n zrlYyRR{y(VTu1KBQZ>9sY-=(U-YcRVa#0Yf($Vr{=K=N0pCnYJV|@2@wJrytDjgeX zJBewj*89OaX8E!5DIFu+Kc3cc^!CoXJJvhIGdhy^5tOGo9`NJ;&vcXyF>^3WVn|Ca zw=2SP9nl|2JJ&H=$nF4{*D=fQ0AA2BdX=@msAG`lJiO4+HTI@-{q=`9dSI~`$os{5vEWZ z#L<4fwX~_@!F9&>mSrqv+wBYQHqqi658D^sXel+^-DI)qeG+HJc@`CwI(?hvUc^Y8 zxyZ4U#41OuaT0fUpdwyM6Ojm)iZ`z(}>p3kFBLkJm&3XNVFwX+857EiF@1tWl5ajzG${Y z+kJLDV2;GCX4cp&0vXA&e1`M>wG2@ zNwo0nmtu+Wmuw5|kVInP&Z|s+Mi@$BbnP(9L6^wyWsts9B4hWV@?QHxEjYvFPIvfkQmKoxxJMV8~n26Dv8o( zYy-MlVrBO*{V#Xx{yxDcYCVC^{AP(xo|AP-B5RUao)(D)9z|}ISmV2jrzJZ1GJS?x zi{VesoRxUo$1*+IXc*@Qn2+C1U(Rh@vupD?iPIBoIX^E^!}o$ZBp&mFSe;bUb$$}L zOQM?R@?4N8UjPUi~lElVMer8YNGT%eGEYZyO|E@@^?cP6h z%{sIvQO(t_Poj-StFK8s`v`QBIL&WGz9Dgj$9VcBo~E-T{Q-&1sVnxt=1m%rj%Bu$ za!aCsA3wbA4>&> z`*DL`TN&fWB2TM{_2b^PZTm!u^GglMH<=2@`?1CE{z{-y+qEo&km$$gM{YatV~KB^ z9`IwC2Lh7)=;11y;zufXcvAhC<{5Np)P6}8i-M*TZT$H$`*hFnBZud^W>TrE(@ery zepK)*|7x=M z{2uFb5w+IwlpVAz_9LHTszZJZ^H)d?({~N)tj8sO9NM%VZLe45pc3|pQa=iZKX{<@ z=fVL|MjCaqrpAs=%c-Uj?!q4NBaL6GbkuK!8p`dv5*0+dlEO^RF+W-=nLLkEsbzkk z`vmdPmBO6#N9C^df6*IN&tt%(} zc)9zI;ui@p`BCtZm-GBM&EMdwCx$levm}fLKZg16HTtoSI~+}Zxd+QUy3_1Oc^Z#7 z`O!SWrbCM#0>i#)JHj+VV^J71UhwXmc z;MWSCquw50WKi$CAAREte09*kRX$^?+380c&%NvNBZ))A3w~tqgDe;Qh~W?Ibo;S* z*?NrNfGWGJ4Lm<4IgGsIM_Dhcr`L}=M>t^e_)&erI&g8oA!oiX zEX7Sf>UST8t9mya5VxqN`h^DjMMt;&7`(}Ny+gbfahLY49|QHQkN5nDyUEV*-uGkV z607L})zojlnJyq6TIQ8643GS{a)f<3NTn8vyX{7J$d8Uv7Qq|#qw6u>#`9yECy$K! zF~Sc5jrsBXI-`vHvBrI{$9|M`u`*BmxNwo_^aSzPw$8GHCjF@9+A-xvMLg@anzR*AOBc4Y-XZ=Y2NQRUjaXe-_M|_@&V~fzdA0_;d!-5}G z1&s4WKaOWI&R-CheThZ(@q9_OCyuc3T=HYme$nVB#4A6hx~+@NlO6Kk!h1!EL!MCH z9v6M`URa7{KazJJ=S#I-h@r+hGMU9*^`nPpw66K_kWbmyew^ecYu%5`CRXN+A2IoC zA8W&pjP)JB`A6Y^*tDQQ0fQ!6ejMF>Ew5Y+Lu~tTaGE`unCg&+qX=9DFxO+vzBGr- zmtkG(3t(aA&Cx`1KlTTZ(ZcwO3E`p*-RuBv9Ak;R zIRT8F=ddb3KxgO4SQS8Q(av`5uqzx8g(Q(9`8M0G&!K?^_DQxiaDHygE+G-@S$hp9Gz=X`yy3!wYiwmrq6K7dUAKwAUV zbS92@){Qih9X$+(GzD;*k7RQI>!;Z?JQYBGHj`6J09pKUr`7;UmsyzPbO3n`tmZQT z-JxTy9}JbQA9r3(QsO3E(l0L0k&pQV$FB_6Bg8kLTq84%f3D zUkPA?ZvkAT)_V9oWPJf7MYEc&1(0!(QLYE@w2$$9gKC=1WjfX$z}0i?yMX|T`C5K6 zfbn#G6G8w_D@N=K$?X6x+AhHgh&$9+<{kEKz}*0{d9?Ii07shh8Jr3rx|=ojAb_nL zMtK-OY9i~}BWi7bGxLxJ14y~Sz8eZ4ljocb2N2C+!-!RrJ$X+TqXBfzTPK*ZH5n!> z6+rb1X3xe0C@f)1>*D}AtE?+9^HljhL8$ zbO6yyA0$Gj?jaOKdR$OkVAt)Z)Q#3l{ODc)Px=`HvjL3omFjr_89aP27r;J#=4U>D zReltBfi#2%paSUT$0uI|kdwjMdKtjUYkcD_fIa%e@y)&E0A6rEXeEHhJp8p9 zz(NmGkTsIgBmVaL>j3gK3EMVB2l#{{K@lndMCHgE+=LpsXMoc!qCw5Z7A@NS2un^4N8e6GUZEu5}R6A^##Q zMQ#wWy9qhVi+1{z-GU4T-AcmGKh?=U&dhPAon~yB46vW1EgI&bJp&%AGK=+hkGl9I}ci$ATELU-s)0$AdU*e*pDF5YfBORGivFI4OwE zM>{Rm&{$Lj(UomM^m;}9XIK~2L99JI?g;M@>x%rT@VlZWh$FSu3733_Or$^lQl7Bx zj0&RR#cBGqQI%gIoD@X=H48ZxIOHR0uP%t1#4MuRR^_(!hF%ckyKlL*k>neKnB9$x zbP`XEL6knUc-p7P_rtnq3gYB$OTR@3DTwF{%kL?4$Y={81u+qAQ4Tuf1*)YbhzG}y zk>HCQRxPbTq}5s%(-k}9m&5Of(?JyFS_*T>A%8o(N5m@f%Y>4G7|XU&Jq|l$Kf+R+ z4I=5yPAp^o9l}UK9H_M79VHI==KBaE1#xIM0`izB=YqI%+xjYAl_h~N#Q7jzCRsI? zI%MIGhWCnnnw)+&>=PY9MDL8}mrq^v1~FT^^TOa42s#B(`gTgCY4VB%oq||>`;6@o2RWs> zxEe%#T|cq*4RWr1qA!T&nGcmv$w-pIW{V#KP|J z<-ZQ=qCbe}rVnbho@E&b;zqM&X(}8tktjEVsEN1q@0deAx1J;pV&sLTqj%M}bM1Bz zZ$_<_suYf~Xs}+B@NpBgDaA5HkgqJSrWs=GzG} z1u;-+QEC)<>|3G3o%5Pj?duV+DANVIL%>S8vC1GeN}DV_&WT5ApNNryZ@AI$~v{DO6B^F37# z><#N;K8W__ox{xE8`i}_5G$1*Tz%I~=6ErPm19NJ<64K@N0b*qOkA`M!PPnB3UT)` zh`X(pLVQ71Q<0@0QcJ9cx~#{q-xIHbSh{-nqmSwB5gDrd8o{R^8twPdyen3MNP0Wv z*;I#E4I=ifJ*u~ww`NHf!n#-sV&%mKb>^RB4=r%7gE(7cP2~oM%>Q6Y_wFS~6~ugw z6*zCSw)UiW6GTSAPAL624gN+DImMQ2wpID*ur4-(n2WcJ%&)vnY}^W>_F$crtER|% zBCLz;Ag;0(5~4#md)PKk?}&XNTzNZln;h~JZzu4dg{9aZ!cc}Kvu1~EAwv-p!ns}R z^0Ne|LWn(onRq?OU8rSBKzZ2dg+8y%q;XUGWWczfH6GCimJ@xRM!&2+q z5QYy~g!2yhV+5%}c$wKoVwzB8EU83(2=_1U+?sdqy|5GoAv_(}!3PD@!@>~8u1?Wc z9S*rbl!GA*-?b>WHCaI7E(+mp*#{TAju11&A(W0jp|(36@}~$(g^<~|Q;T)U+u;x{ zCBL074w-1hokGZdx)WvFh$1i*!q|zOHFlX+)Upsx?TRHaL})66(zDi-zu=G;thiGM z-MeAP^NujY(GZ@$vW(0{hiu#P*2tvp2}4wbFyC)wSamz(nZ0i_tVYO891EfKUMF>- z$02__EXDB0%G5v50ys}6!!A@prp`*;1CT%&5LLTFmEj6knL z{&84}>JW1F@8FbMB#D|3j-9l6aQOqAc`}69m7OIukt(hYA-loSyekg5K_adT;ohl> zRPkR)%Nwl^p|SY`iankpgGqTLh~8R^p2{skb2P`!r9#ws-8YN z7ed3Moyq;vVO^XL;bETD^Uujj>Ssp?rFJ9b6P+Q%f4E%hqAP^ek`JCFJ!HKF6T(80 zwK83E$O&gyii;s!-90sv>kdP7hmdjLzW{H#=n0{!X=hFO?yyf>3Sns9ZhH{DAw<8b zpii$m}{35 z5tgDaguK+9T;pZp_*w|DQ#%>T`F`zgULLCqB4~MYOzhl()lj4np z5OdO+gm)aWm+Bo2p}6Wppzxj;3*mh8e_@h8n1JIUw2xZWpjeY*B+3k(M5GW z3E{*6%LTbhW=0niA@ry2II+JJmSQr5iCr{(o&-7-LT2Fy`hJqSGaW+dO-sA_Rr#3a z$V>>^mp@1~JMAPi6+&j?j)uM$*2S|Bl1Dzc65$4MI~ziFZU)J*UU{48^E`yCV>_=E zpP^djLKy3-pla`tvg%?!gubd1M7U2*ImAK;i3^q-3sf5Zz2cf8Yqapk#bO9+w~DA} zye8iv!$Iq#?=AyNvI)Dvgrg zgYPj6^VcIT;ga8r?=`;9{Dp{1xTOix_-2)U6((MA2xo({O*WbNcp4qQR6e_YbD|me)(hgG2_S0UyQg#K>h@N!uWCX zh7xg$p!`Yvr14qvr9a{pA^G3%-;B?iSWJC~{5(Eye8K#e zh+BN8{3ZO7@xIx(H{urW$zR4V8}FO*dm}FKarr;-KaKxoemUY6pO9a~7mZ&r=cp<} z{wjXe_%-w2BX03Y`6YbG__Dd|j<`iw{yKi$__BG~8*z*8lD~o9Fn-gVcSPLcyX9B# z72`L}TrJ`j-y?quzh(TkIYU3bSN;xu$M|jYnig@1?~}ia-!=ZX`OS!1nDY1Vd&d7U zlc>q>m%oqSH~zqUH{uo%`Bi+?_(L;Zjkv`R$gkmR#vhthB)T7ze}q3W{@A=p?0-uB z3I4?RQ`3yN#1F|o!=D*{YK9{&@oD*g@qdj!wFL3Q^3U<-#-9P%JSM}MUyiuMkI4Ur z|7ZM#`F#&tZ}GRr zXUqd4;ub$4{|;vbD~nswCk zpOXKCe=@#ho+JVNwESoMv+*r6pMLyz`7ihv<6q5o`u=C+zv17Ee>ZRt|#OGv!f&|4#RLl}3;uD{jNeYsz-#PUA3-W-11J>_cHR2J! zB$E{+E5=WoUyXRh`!YpAiejWH=8s2w;+JKbf;6k{GWz&`%5(+k*6$i6;uZf(W+=!| zj7-J+bi^mVD6*wD`yy8z~oq{^+=Vv2c@n^DLLA~|!>k+T` zzp_C=1Bu`|vGM1!Q9&d9d__P1pKMalL^7BmdHscKR?w^%-)Y8qB0lk#@|1#8iqWE& z$4NwgC0iA=D#mHWY$a*`wLGKXjAEQs%)g9y#ox#_1#ODaPA&Vy-^z0e&MC%u#hj(K z{!Vr%=unKWo7dHdSA1P|D(F;yC-y6HSi!Jjj3}lv;uA57991x?7-Ne0=MkTXRphvWaqIW5MSLPo zk&hKTR*Waas!zl#azep`_4{WcK9QivNd=RNF{PNl7x59Im{u@tegB1sE|L^EqhQA3 zxAZ$Bx;UW7rwX1b#xun%77?9*lS-s3azVj@Vk|1=Q!0_6$QKG;D8@_0oQaC)B2$q|3YILw7B!ot$X5zp zS%fS{L>JkLTvo7b5gt(;If`6SuwoH%Nf~n$xvF4QG2S;Dq9Q(#r^q!0Yl`t&F~2wB z6Zwi*6;rj@rpu4ZYtO$W&f3kPaIU_mVzzC_?VflMs!i6 z$ZZANiV>}v_mzk)iWRv}#Xi;8uaZ*f;*cU^RK!?>1~uXnhZPyCB33oxRP);+x+qa( zyoz|$NKnnpJrSQMRb--yMAb-AO@BleWr{qY;(%2khN>)AWU`86)ksmz3Nm*`6q%|b zRW;I76A_;{s>pN|>8g>TS_)L5$V?TPs*$CdzZ}uUF-2yp$X1OU)hs8gbzG6TDsojL zPc^?B(ZvZx=Bvn8jRKVnk1i?|S*W5=rO|5+M08Q5$b%{lT7<-4L?_3)NJWuFctwO7 zMHZ_lwg@pFi|FL<9a3?~B9webL>IM+JgnldN;*qau&0II0>IZ_76+@|cQas&QO3LlK{7R^$m4CsdelPv?6O&)LMjUnh0kU zS*M~-rI}JoQ}?VQ>s8dN#{1?ZsaKmK8&ou?Mx$!}WW*=h71^Ys$@)!VJEzEI70s$~ zN;Qi~u;&%oqM}7LT2(WKYV1(tX%(kcMV?b}PBqS}<|iUPaZ!;SDmtwH|2EnCW6?sX;CF?gW`&Sg%tD;vmF01B0Ms#sikylh) zv3}1}jeUx|s^Y3@^r_}O;`y2)uc^4E8ecc7XqH}AWTPkj;#;=%_G;3}t^0tcG^n248(Zy{= z-cfN!HSVfrA1T)zMcz|!Po<^gYZ0HgtH}E*?yJUU%r3I3_Z0a+#RJv&8#9;0a9@!R zRXn8s3mp-!c%aBfDjw1QKN|6ghl(6jF-U)(r0O3ja!AFHY7DF9S0g?#sK^l&Bi8Rf ziulBkB1ct>s>YaVekP)eVMUIs7*~zQs+mHwdPI>=R6MZ=o5aAVA}3T#sK(^m8paek zrD94oK5L$HM|@&jk<%)s>Gz~R;uDV*Iiq5Rez%Y=K2hXT6;D;;nQH!O#3v>cIjdq; zHJ+>HUq^gmQjv2i=2YX4%^2#zlp^O<%+v2H#L%=N7gQ`*3{{e~ol)eXibZR_7ShW8 zRFN-KyikpgnV*aJ#4|;{RPj#Ffh zGn%aHOGUm>@kTYiWu73*wxq}n6&v*bK`Q@Bk((+u>HjL~)3PGBRBWlV9vmVbR}{Ie zVp}z$H8YD?T2L6uN4`iAx0ykP)T2`D>7C?tY*Y% z=8r{u;*BEXHNH6;K+|ebg^HR85%My!u2qr232Ni z$kfQj|7=7Tv8v3{kfo8K$RRG`RGFsY!fOEr{QzrQEq6B(*3(@>_-dO%&uRAsq_ za?LoRnd_v&S*kp$;iyI!qn4~fwkj($RA_{#<7upNRC!FpG0ixxnSVieL6s*ooUpq1 zFN7CVS*fAY8vXYpy2w{$m4+&-i7x76fhwytRBOi9&0{o+3RPL7p++FR%M-rI?brpEQ5VWl?@siNQm|H!C_T4YG~AqCe2JC z6)sU_vxa6;ry)||QdOSPa7r^;H1iV8yE0X_YG}0x!=z5-sywaXv}T;q%v1Eq5mlbm za8@(gG{W$@II7Ba4egq7PBWWmYFDW8yoU3d@o_T}@rh%q?9kAm8J(K>>4+|ltFlW& zmqrseZg0dZPN?#Nh6|eU-IkwIsmhBQE?VWkKjITrs_fR#tr_1({+mx!tFlK!kEJ=k z6!D1~RbJ9?i8?()|DRN4uZCXw-ABJ`Re4#%W%`{;zw19>~U9MN-_EG~=db z!LC!PyrtomX57}yDXOwXm3K7Uu?QP9GOen-tKqIiI7nhWt;%~E?pcH$8mcp@yszQD zW<1c$PepWbR+SGmJk*Rwn)!gfXjA2&hCz#vO7^NpDU_-uHiZH^OcBCTvg?qhB@Nrhy@i?Ij>>f znxK88s@GJxpkcwXN++qrbyY5EShNV%Fx*h(3k@$cq8){JO}743MS%6$&(bI@oI6(cp}z@_+*e97eLka%M(Vnc_f-!}yk&OSWfBm8lM-I*f0csU(MSRi-(R=Ad<^ zf(krVWx50D4kN>18R#df%yb~rVPrYXpC-tl%4`R+9mXG<+0@{qDsvpjq2Jx4ty8MZ zbs(314^kJVRhj2No`c}+HwZDPGT(uGhf&}#3#bcERaxjjp~E=nFk?xK&s16DK#{}v zthr1a%&M~3fnoEm|bLV=T%wiK&e&Ws}Wr+ zsIts~G6zlk<@X3OsIuIFa*I&+9zh0G9&zA^!#Jy$KM~QzOI03q;Hbl>aF|WR+Dytl*vPzUui4|2=J5X&AETg=t${Gi1EW#xs ztf}&(11Bv)o-5)LuT@#=K&_=9|0kl0bye0mP-p#4phn)PvfhDuhtc3LUy{AqP-UY7 zjTYe`wYsUwCI^}4SZm zJmbI_OE@F%5^T`qSqIKKXhLR_d}B1(=0Kan_=ed;LmsQicE|rm+ndL^Qj`nBm)qUB zb5)o^Kng?*h?$(-~40cX0pp698mu6p*T zflJjDitiF*`sDHX;h{Gu=x!C?EgIem4R2J?XH@(d(Qwa8 z^TR{uE9kQ-{;WXyLE&vs(C1YAInnTWXt+Q@_o(oC zeQNg0s^qnoDCm9_->+uBqUK%>s)K^Qs^YJz*$33znb6_w3i_Igza|=LpgJh%K@~r! zX1}iHCi25Wmn!HRD*lEFI^H#~GM6dnn=1aMpm`6_e5ZoGrQ&aig}4#E#^nlnNW~Aq ziN9GW4+{FWioY#7+^d7~prG%l_&aL$yK3&a`NM{;RM7WS{5>`MZ@H%d!MhdoeHDKn zo^OC{`yK^7tm21N&?TOL9rIoV{XoS(5Dk9=+wCd^J)+`AM8gHp<7x#xs^UjQ!&jik z|0w7&6+fnCf2c|w`x*uPNX0)=vrin7y97Y4RnU)B{A1B@B`}irDd;CE{)w7>{E%EW zKRon)1^raTKNTIm3%lk63VK|{kBf!}0q1oJ`k9JP2f}T?GQ?RLSgD?Cs1^rINzZ07b-ruC4->dld z@O&*i->jggRs1v@rYGQV-J+mBsQ3?R_K#}L&JPcLTtRfqAP|#mg{1-L*S2Z^S`h$Z0rsBVe=l_)-9=c6IXK45g4HUJ@SjSH*=(QStt!TJh=nx8eorYhhW%uX4 z4jt}N(AgS3TLU}skN*ftgo4h|@HwL4vS;Uqhd!gAb2WUfXm}KM)n^rSo`%m84S#@z z_?&`Xui@8g*}D$O?Ep1GL2uCT8?@|7?oQappI6WuHT*^`d%l)C3mWcK(3>>;Ced&v zh_Wvz=mHI2pk?2z<^C(L41G~J8WeP)hA-4$xvm9^;ywkvMZ<5=vTxOLhl1{)po=to zk?8PK;b&0L#Tvd?G@Jt{zM`N@G<=C@I2#(is-U-N_-z_+k{<%)2Nd*n4Zj`M$3at%k1^L>_s5et75+1-(zh?-LC-L&KvAdcTI>FB-0bhQ}220S$jZ zG~5r|`-cj;PQ%w}AY$GKoBBrzx?aQAYuOKKx!1x}eypGmY4}5;;SOl{iGps>@C{<1 zR|-8sK_AxehqdfSH0fr2TtPQ#_(lyhx(DDW{Y*h0)$m6(P}u%AC=v?#n1(;5WpC1Q zTl0qv{X#)EYxrg@d#%(PeyN~aG<=H~=6SjNp+mn?(8o3WaoB~Xm#!Fv9!f^OIF?OOJxLvo*mee$G&?$GcZ zfbbfSKu;;?QyTu1mR-wT04(iy3i`B$KP@-w3qXfZ(488-Q#5=P8lG0rT^hbiO!9h^ zA0GOHg6`Jv-J;<(X!xUoKBM8!h=zxN$Nfn`pVjbZMZ=q6-~3rYpVRQ?M8hXw_5Y%v zdo+BHmi@ezdjyv9uL`c?tLW<*{yIDg=kqx#`i6$TA=csNFv4?H^i2(aQv)Vl! zx5UT31$w+*MGtBCA$b^HoIh;n4J!J!hQBQ~%>M@}go?hS;qQoNAzRN^(RVfcU2(wP z0Some6@5>`-;6c!;gxkH1oqlZ&lG_8h%X6{!q)^1WS97ihiWwABl!T^M?&xtfC)l_{UoICt7Ye zKRk4aihio$pK94Nl-#9&@og%4T*HrR*<*8O!5H4IqMvE_XW}q@6?V}(RP=KV|6I#H zdPwd|uoRc7=ocFPg=qK?ptwv$ztr$AMFZI0?^MyRH2fa<752 z@g5aDrQxSE(2f3oP$E?HI}QI%oM*xBu2RwOHT-*l;dY!qZ0KqgJ+0xV#ZtZkbO;sw zLBoI0z#h9>=nyLUqlW(|AT9;Lb*+m2q~Sk_hWn1l4-dUhMSs@tpGCtJ$K($idcTVP zqT#=2*}rPJU7$m#=x-YSoA`eBgNAUOiq6R3GcwTdBn<0%6`h&EXJ%k^UoZR+Dmp8J z&&p)ia(@zj2o=31gI|-$uH>E&eh3x4HiKWA0T%ldG<;Y^ugl=qWq^ae1wO||RCIO* zpPc~{NdzfxRM9yZd`A0E0%MQ_O9 zH)OJJ%z({5Jan^)&d=cUGuby~a*qLxTU2yG249fLzBwZ!Mjuzvg&BNdCi|95?mIB7 zTUGSd41Q|{*5+0)S3jYmi!%743~;kofgYivi!=D*O!fxp>bp%vmt^oIne5v#BFu2R zir${VZ_i}kkr7Ay4i#OR!Ix&T`*Xj7S$#@Hmu2u}ne00=xtD#tuFl}AGui*i`o;A=A3d;UK6G%Ve{ zD!Mje8MfV5RP>Py{)kxncN?Hl zsOZKFzENzN%Rw?cprVgv@JBP*k7aT&udk`-rVPF*ljTy9JgB0ZGx+8V=%HY9eO*Pj zWbiGS?8h_05&sPp-I~F-X0o5i3au0;En}2WYK={h zNjggDggHGvx}`d0jZRH&shSfL->*N}#(*g+s%7cQ*d>Rz$ z4Uf8$1B%n2{Tst`xUaM#r}lr^kw9YqX=1>7=U z@)ChlAJxZ-Ms5q8ni$<;jTNgC#YtleElyR<(b0-EI!@^XG3aPzR~3sA z>o`olSezu&)lq{^5Nb_K6{pRyi3+hM>Gt2$%DC7OK~VSU zexNndH!b7kgA$UvH~wrXdtTDAYCfq8Vy|GfE2+I~KxzU9^TYx^w`TLDMnyMSz2Mo* zEz~VH&`Z=`p}sNRi8!Sq$Im_8*3S7XNWyO7vJS7&O}<;Io)5fgxt2fwY~Dy{h3EUb*_x%}Mh_ML<;QJ?xF z;dCUqnGMNBXiixf(+#~?gaOn-WI*_z`X;=S@?CIl=ZR11lv6)2PU!_aPpJ=wpPKUU z)u>B-!s$M@PPn;5tU1G0ALNB@JeM&6q8^#9vG?a5OmKrmT4AnLrY>yp2r8`K#n@W0` zdRHfo!^B2MO;FiXGmT@KErDa3w6e}AX94$Zx7G`z>;V{`S*>%uhckNcTlVL=GZaRDJw^!>iV9!(&Bb`oSODc%18u6 z2v=jq(JhExDG<$+_7sgHQeHIe^xkO84y_S$9k6#u8H+msNOC#iS?Y7UYMX@9kihhY zaw^0ClHCBUM34tsCv~G$b?n*_AD<_FK>HBoZ3IH-kgZFlvd(V+PV3aapUCFm#^Bpu$y=uwPq~Kr$M04Sk@w&2|8DUg0haBM1miPY$#?dI}A`vkkgwYJ)>WxKUf!vv9RZ0!nT zN(<0|&eWl1LX`+{gc^nMPXj8^fj}3kf*l{DebDm2%()fTa4ln|`*?|K*%sjxcv~Bo z)I37PMk|IEyoqE}J!0t}(vuQL(G`94NhAj95EKU9y95W2%R8 z*#TT|iOYy@F2P7nJy;{@TWjr#DVN{jGnZ4>8i}zEHnVDo0A@BZm=3>(t}ZZEH%{z9 z&}ULr-gOYn1DIWj6Q3KU0T<3SnMM7=k})~n+;?ln2OMH6((UqLuSebR z2&d}BDWJluZAkavj)OCTzD>|HJbUPBh{`DRJB8->Uq0wZhV3(=+pf)DX!2U3M1vr7d%BjyaZ=CI-{4ZW+j)RnL!K(VI3V|p z5J_1p7ce}b|2mLey_1R=ZH!NhPMKRsk&Ko*x*gS~TvUj;Tr0*7fMm0AL`tji!tUBg(%d~Z z@&c>}oYn&lhc;NXxw&MV_J2cF@f)r&cc6& zn@f>&@}F>n52}-O#W(ObO=8OBPc6g6fX8LYde z9-fM_=QDHOc8wFdpC;%M21;EaEEsb}bkBCvoMOWG%Wi-1)Ib%*iLBSy#j$e**XZV1CV|#sEmIZ6j7y7t-{&}73e_3 zNn#wvD-M*;5I`nA*MZwWAalgvq{!S#faQ;L6;B;q4~s+luEklLdLW=cJq{{g{AxRka)!QQ(dSry?Ckv`;E9y z+`wbLaOY2T<%03|t~b{7Tzy=uqq>a5# zAqJTBs9u~X1QJgf+JWR)caqYO;XWnxQkz{gI9Y)vqB_~ju!2s4l)t0F8$L}C-i%cU z=*nWn{Bl(wgb@^bjbLGM9)t^UWB51JTsEF{Cvl&9fxwi0(qJQ))Z@(87Z&H6lXlH; zbERafbjKjlE%f4+7OZXI-gX#)*Qeqbz#fB<&c&}s^sTmQg*GmHz;Gq@ZE9RGt%6*W za@$B}dwr8c-$fSN-g3hV8~{4t(=9rA zxNiOU$pC@GGO#mrgF(dWMnDgr_bIJ=JWXSXHGR<~vYiK0mSYvnu9F(ot3IhyW2_&i zvj6~5yh1jSGty32Xa*}J7}2-+WHtO2kq;rDbz<&s*gh@xvU;GKhz+-xOQi_dcur{C zoJMS!5jUd4pJ}-T!YEoLHm^ZH!1`|oae{H61~gln7vszaJ17aS$4=(62De?Brr2QtHh1iV*Nsa~;p=renE zM8+g8tNb@LdqdXDI`La)QZu*t#CG=>&xu=Rg1`=>#aT3-o7x`E^QhF9v3tp)R>S1- z)vQk~o5vt^vP?x1ipjinSheaU*f(b$ z08=Z`Eijj;)o^Hx$Rqx8xgM`ZUFc#FzpvAJovqMbN=DcvTAWR)zb$^%U5r>;8`Z?X zMVbbjh+YyN`iN}q_T;k)13p;@?3!NU#I=ZT&5AC@)W+FuF);iW&GW?ce)>Mwr^H+$ z6^9xVePeeS58-PpLQN>BfrJL(ISlGT7lfB-zNHwd&^%!>8SlidRXp20<~TylNr-q3 zjcEj$Xu>XKDjZ+QJkP4_S_=)=6|-;CV@5X}`;c0ay*4#9HDw&0&_zPU;rRAL?{m}B z8*ZKW!4h%$$Z!Gxk*28^M++Wl=C7tB-WMV6%i}UTKcZVyezlFw%N`yV(~~tW-C1_i zw=LL1sOW6$+3G?Ph`qQQv1UU4ENj4?GPVv(Wtpy*E$Z7Vq?vwoY+~DoJbo*%u$iEq zCNmS;JsBX3c3r~-U?Wx!Wy5JUUs@pUaxb0+{e0psmsz#i!goWQ$Aq*&bm_S~{4=62 z(17wZF_7piA=<*^{R-aZnwMZJ%ryAYBB`|mxBEpD?nc;}y|z;%aFhKvPG(J!xEt== zh}5&_I3fTA@P0pu+_9En6#%PE9*>^ z?dI_gD0O3_A+W35Vyo_=U8jC8OSMTS66Jow-Rn{bhZ2okw8X*mC2B5y79lrG3V)CEF}EFVo;VDtOwn2W7cWR5RqS>riTCw^1EPUo5 zN|QOdPWV#6C$6RE@(aYZM)WzCPmZN&N>NwYzs3yQ3BAvD`Iyn^V~0)+Zo|m|An45M zJ0(kkX@zhbZ;B=&>De6>qk^%#sDnM2+-sJs1pzZ{Au0M1XeJlZd`jO^ATeZmGByyA zRW!zEFAs8Qc5!~O8TDyqO>FF9Pl+g)+-nIR^$4POR-g&d4rA$E_Lm9<7SoPFph}c4 z&^qIE8x2g~7L~AJXt={rOX6W3>TEoJMA#(Ulz9!F{ zJImKXpMEG2?FYL-(KC$g1S;ag< zt+5(2hT96=W4Dh_i4{LBz*C#_vum!fHnz|0SzMUgYv9!SZ2%l*V1w+i9V%TWY3{D3 z^C$`@CxHZ(XR<*2A>2uWdiuMiY0U~3$U#mRH#Y6dZ699Mr}V%R@>&KrrWa%Tr{=R& z;b4r^QQ<_%*e50&U4cWXmoW1gr?M$_ zQq2>8IerW{^XkS6(+@#ptZ%hl;;$Q{-EJxH$hyObB`Xko$+vd+tZw}4fL9fSh{|R? zklvj3e!VW(2!h1;&D*IV|FjNijMLKK||?C`NnW>GT;{cZDd z!14fzKSKq7W1~K~lO;~!$ScRYI_=Cf$CRDiw`ch-67UkJ$_d|IMM&WTNv913=1wLB`rd<< znHzrmqr=ed!fB!ZU}2y1K-hI&J^#cUT&?_R$oVy8WJR2?>u zNWV)+oVj2pYYLp_#oV*btcF7^!pVpZ%yn7@gm}zOZEyaoNf!dgE5C%JD_W8zm&K_0Y zBm??1(yOu~Wc{joU8;BHg3!fCt-dc->MY+$VtIzmgt<23IL5IlF(Fp4+2Dnm9Y;WT zvJUbd-UWutLEVZ1<3ZhuO&8PwZN=!;f`hRYBVi8X`=5Ol#H`K!$l?J@4A^8qIvK!(+?LTMsre zA4G@tdm(wCY&MHDp+fl16ITJnh7iXU2G z{i?R(^t|*-^b{EYo#4%z!;B*;jjGMWT@=FORr*QLx4SF0 z&)hn7xp7k0;=g3CmbBeE@ybD+I4QY1E%BrVx1FG{#(e!%VX9>xwOVN*qzDoG7wf|R zZ*YWY*V8T`O}nPcp>wXu>b6^xuCUI) zS34AJ^%>Wa#{8gnfsOS_Cv8p8k|sb$@V{F*+ibI*Z8J4=Q8AlLi|HB1-L|-^l!!Wq zbxO1In~FTcYHdF9yc*H&h?BQgqRn=eqIHCk2B{)*81q6`$>B}g8bsOxack6gUN^&9 zy4E_bnH`jJtVSfumCC_-P-ku|XnopJ&2)H6Ij_(hk!&)>Minh7dG#*k&Fet3p*hVR zkp;?kQ4(+|Izspqr6PujuC>F$L96HTTf-9ZD6)|vBShbVEGF+9M0_RzexJ*2CrVar z@3BBl=39IH+1Zluyq4q@I!oHZ*4N~P_F z@rr{c(l!d=?DZN!N(g6D5hhXQ7e|;5j~!=}IHxgxxidnugZVR-?roFfNn63@ zq_RjV4qaULsPy$ELP(Uu`}jkWAfP^q zY*2|`Z>)ig>Rq4SejmW@#@dK(*?}h9orYmubkO6w=Le8e#71GHMnu8?OJpVWMhR{>uFaa$MqcR zz=djs2qI#TErB?<;W&xH#qc6)mx%yy+_T}DE(nlkC3sxoCQr{?Ax?wFY|Aq(!ij8r zeti4_zAUPD<Czyq-`iwIsYUbA3ZsISWCjCV+7TYUrre?N;TwCE|LIHII8}0agfaZpt z_ZgSEap>gH<9w%G)a~Omw_65Ygs~h}3~uH=fo`?M^9`OTA(vIp@=$e@8Fqxc8gAz$ zC;cWVZ=A#32^n()v&QCJsp%&+H2ia_9dK6jNgX(t&?9>!PAoM$x76U7B&Y?P@J6C+ zgsxGu!Bg^PS`?}D!L0VfUbj#)htzF(JbZm=# z; z`Ye#ii@HOjed8P5<8rPidBVt@ffYW=EsE$fmF%oYid;gn;ex$?*}9GeCSg<65Ov;u%ICKkFxP z6r$VUW6Qv&E|~+RI*|cS`R-X_E)AUA(555JxhvG?#i`N$7@fS`#%DaLDr&tL6BI4f zX1#cu4#}pl`-<(h@UhLtq?4$(w&BFWi5h*(ww$t`5`D#|)04l$2h|c|W zdBMyPiC8U|9ndE>yrXcf;CCKzTSB6VNUnHyT>86qBPv*>)MGB)fD?#rB4pYGtkgtX zJZ7*{iO)Cs^@w0SD6dUTwus6EXw@}N6iK_bTPyr`z+63--$ui`u3!~kDeTu25!wP3 z22w=wQ&)8Ij~3@};^&-ZnH#WXqEx6n_Y82R{HT*DakTqB5>y8x?trIusRS#+jnaMojFL#EdHnKZ}LVH4I^xpF+NPr#8FNvLm z+U!rycwuibG8HnNle%|HuqKqyL!=WlQVuMidG&ZZ5->OEZjn?S#Lc;UJ@AMt&tEc& z0$NKnEMMt$j-X>89n&Xt98b^};N(W@@v@f0SSeU6pRDSxD*{m-6MRVrzDdgqJGair z|CM|*?N*BpWT&i*>eKqDz*(^Co}+jC0BLukB_Mi$mv$`Bz&@S!{*jDUxhkeA^2gK5 z8$_9VsZlphXo?g0TX@8>d?8Rq^dNj}$ySNKYcowKj_l>~0j-k-#>8a@GJzs_!CWZB zi=DT}g>OK%wcj2$V_9jJ%54Kq94Gw4Z00P7?wsAj=Hu!^lDgNU0poW{0Ak;J|N}lbywws8u ztdeEwB`ggZP#!dUB_w6lUK8T3ncLLE`%`cBO+S|6)ovKyE|GSf!VfC&k&o|aZe}M* z#dNUSzq=&3>|!QXrS+XyGK@<|d)H1rW)Odyki-ia3c8{PHWLOt^TY~~i(HmZ>lCd$s}{$4I^ z;)v>xTqGNPT6L(&9hp1ia}jit)|hZmrrdXz1{>+}Edh}lb9_bL>J!b@Q21WzSg*pA+`}tONLEjrPJaJs8CIV_`8JHyb0M)eZQB* z7j{jVQ&U%h2bmJv5uJd4w$Z?h%!M>hQOCtj;s(0dZZTf8AzEqQ9ow!`ZFd`WDajMz zG3&n@0q62Er8vEsxD5X2<-rt_by<}xw{p&gy9(R Date: Wed, 7 Feb 2024 12:23:14 +0300 Subject: [PATCH 030/240] Update dependencies --- go.mod | 30 +++++++++++++++--------------- go.sum | 55 +++++++++++++++++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index a2d212db..23ed99de 100644 --- a/go.mod +++ b/go.mod @@ -1,52 +1,52 @@ module github.com/giongto35/cloud-game/v3 -go 1.20 +go 1.22 require ( - github.com/VictoriaMetrics/metrics v1.29.1 + github.com/VictoriaMetrics/metrics v1.31.0 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.7.0 github.com/goccy/go-json v0.10.2 github.com/gofrs/flock v0.8.1 github.com/gorilla/websocket v1.5.1 github.com/knadh/koanf/maps v0.1.1 - github.com/knadh/koanf/v2 v2.0.1 + github.com/knadh/koanf/v2 v2.1.0 github.com/minio/minio-go/v7 v7.0.66 github.com/pion/ice/v3 v3.0.2 github.com/pion/interceptor v0.1.25 github.com/pion/logging v0.2.2 github.com/pion/webrtc/v4 v4.0.0-beta.7 github.com/rs/xid v1.5.0 - github.com/rs/zerolog v1.31.0 - github.com/veandco/go-sdl2 v0.4.36 - golang.org/x/crypto v0.17.0 - golang.org/x/image v0.13.0 + github.com/rs/zerolog v1.32.0 + github.com/veandco/go-sdl2 v0.4.38 + golang.org/x/crypto v0.18.0 + golang.org/x/image v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.4 // indirect + github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/sha256-simd v1.0.1 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pion/datachannel v1.5.5 // indirect - github.com/pion/dtls/v2 v2.2.8 // indirect - github.com/pion/mdns v0.0.9 // indirect + github.com/pion/dtls/v2 v2.2.10 // indirect + github.com/pion/mdns v0.0.10 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.13 // indirect github.com/pion/rtp v1.8.3 // indirect - github.com/pion/sctp v1.8.9 // indirect + github.com/pion/sctp v1.8.10 // indirect github.com/pion/sdp/v3 v3.0.6 // indirect github.com/pion/srtp/v3 v3.0.1 // indirect github.com/pion/stun/v2 v2.0.0 // indirect @@ -58,8 +58,8 @@ require ( github.com/stretchr/testify v1.8.4 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index d1fe4710..dcbb94b2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.29.1 h1:yTORfGeO1T0C6P/tEeT4Mf7rBU5TUu3kjmHvmlaoeO8= -github.com/VictoriaMetrics/metrics v1.29.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.31.0 h1:X6+nBvAP0UB+GjR0Ht9hhQ3pjL1AN4b8dt9zFfzTsUo= +github.com/VictoriaMetrics/metrics v1.31.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= 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= @@ -13,6 +13,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -33,22 +35,22 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 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/knadh/koanf/v2 v2.1.0 h1:eh4QmHHBuU8BybfIJ8mB8K8gsGCD/AUQTdwGq/GzId8= +github.com/knadh/koanf/v2 v2.1.0/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= 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= @@ -68,8 +70,6 @@ github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dz github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -89,16 +89,17 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl 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/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= -github.com/pion/dtls/v2 v2.2.8 h1:BUroldfiIbV9jSnC6cKOMnyiORRWrWWpV11JUyEu5OA= -github.com/pion/dtls/v2 v2.2.8/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= +github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= github.com/pion/ice/v3 v3.0.2 h1:dNQnKsjLvOWz+PaI4tw1VnLYTp9adihC1HIASFGajmI= github.com/pion/ice/v3 v3.0.2/go.mod h1:q3BDzTsxbqP0ySMSHrFuw2MYGUx/AC3WQfRGC5F/0Is= github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= github.com/pion/interceptor v0.1.25/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.9 h1:7Ue5KZsqq8EuqStnpPWV33vYYEH0+skdDN5L7EiEsI4= github.com/pion/mdns v0.0.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc= +github.com/pion/mdns v0.0.10 h1:u9/12WL2NNgtGT2nNPXT6+A+xeOF0PkawM/S/wPMWQA= +github.com/pion/mdns v0.0.10/go.mod h1:Y1scL/8TT8KQ172UfxrE4j0c04NOY71bJS1aE1zvyGY= 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/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= @@ -109,8 +110,9 @@ github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8= github.com/pion/rtp v1.8.3/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/sctp v1.8.10 h1:FDPlkojWQ2hIjnvgFs+frHR33TZCxoRhV2HztZ07NnU= +github.com/pion/sctp v1.8.10/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.1 h1:AkIQRIZ+3tAOJMQ7G301xtrD1vekQbNeRO7eY1K8ZHk= @@ -133,8 +135,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -154,8 +156,8 @@ github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G 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.36 h1:Ltydev536rRQodmIrTWFZ3dRp5A+/6t5CYvbi4Kvia0= -github.com/veandco/go-sdl2 v0.4.36/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= +github.com/veandco/go-sdl2 v0.4.38 h1:lx8syOA2ccXlgViYkQe2Kn/4xt+p9mdd1Qc/yYMrmSo= +github.com/veandco/go-sdl2 v0.4.38/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= @@ -166,10 +168,10 @@ golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE 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/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -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/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= +golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= 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= @@ -188,8 +190,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 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= @@ -220,8 +222,8 @@ golang.org/x/sys v0.8.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= @@ -231,6 +233,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 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/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 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= From e67b98d6fe18f97080ffd1056cdfea7eeb023f17 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 7 Feb 2024 12:33:29 +0300 Subject: [PATCH 031/240] Update Github actions --- .github/workflows/build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 164398e7..c66984d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: 1.22.0 @@ -77,7 +77,7 @@ jobs: make build test verify-cores - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: emulator-test-frames From d6ceaad2205fcf9e8517ebb5212bb51b76c9403b Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 7 Feb 2024 12:39:17 +0300 Subject: [PATCH 032/240] Enable overwrite for the upload-artifact action --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c66984d4..159d4a10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,4 +81,5 @@ jobs: if: always() with: name: emulator-test-frames + overwrite: true path: _rendered/*.png From 53a3624aefd50cabb172d410d3dfc4998b1e2b28 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 7 Feb 2024 12:48:59 +0300 Subject: [PATCH 033/240] Upload test frames separately --- .github/workflows/build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 159d4a10..da4711d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,6 +80,5 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: emulator-test-frames - overwrite: true + name: emulator-test-frames-${{ matrix.os }} path: _rendered/*.png From 53e55728db17664c6f8e8d7b5a94690139aa6fb9 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 7 Feb 2024 15:15:41 +0300 Subject: [PATCH 034/240] Add PGO for 1.22 --- cmd/worker/default.pgo | Bin 0 -> 23472 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cmd/worker/default.pgo diff --git a/cmd/worker/default.pgo b/cmd/worker/default.pgo new file mode 100644 index 0000000000000000000000000000000000000000..cfc8cc4b008a8f0f3fe1fca1a3e9b7f4c4798bc2 GIT binary patch literal 23472 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 T|NU1000960!i!#AdHDbUWI4-` literal 0 HcmV?d00001 From 1a44b94c85c947b85737cb0be9bdfce5b6eac9fb Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 8 Feb 2024 16:38:40 +0300 Subject: [PATCH 035/240] Revert Go version back to 1.20 Go 1.22 crashes under Windows with h264 encoder. --- .github/workflows/build.yml | 2 +- Dockerfile | 2 +- cmd/worker/default.pgo | Bin 23472 -> 49125 bytes go.mod | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da4711d5..ba19d038 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: 1.22.0 + go-version: 1.20.14 - name: Linux if: matrix.os == 'ubuntu-latest' diff --git a/Dockerfile b/Dockerfile index abae4a67..1f33b6be 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:lunar AS build0 -ARG GO=1.22.0 +ARG GO=1.20.14 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ diff --git a/cmd/worker/default.pgo b/cmd/worker/default.pgo index cfc8cc4b008a8f0f3fe1fca1a3e9b7f4c4798bc2..35a67035847acc971e1a31dbe34b4c0848e08869 100644 GIT binary patch literal 49125 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 T|NU1000960!i!#AdHDbUWI4-` diff --git a/go.mod b/go.mod index 23ed99de..cfc02011 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/giongto35/cloud-game/v3 -go 1.22 +go 1.20 require ( github.com/VictoriaMetrics/metrics v1.31.0 From ccb0f410abc5526cd321d5b8cf3224ba7a5a1625 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 8 Feb 2024 16:39:37 +0300 Subject: [PATCH 036/240] Revert "Revert Go version back to 1.20" This reverts commit 1a44b94c85c947b85737cb0be9bdfce5b6eac9fb. --- .github/workflows/build.yml | 2 +- Dockerfile | 2 +- cmd/worker/default.pgo | Bin 49125 -> 23472 bytes go.mod | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ba19d038..da4711d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: 1.20.14 + go-version: 1.22.0 - name: Linux if: matrix.os == 'ubuntu-latest' diff --git a/Dockerfile b/Dockerfile index 1f33b6be..abae4a67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:lunar AS build0 -ARG GO=1.20.14 +ARG GO=1.22.0 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ diff --git a/cmd/worker/default.pgo b/cmd/worker/default.pgo index 35a67035847acc971e1a31dbe34b4c0848e08869..cfc8cc4b008a8f0f3fe1fca1a3e9b7f4c4798bc2 100644 GIT binary patch literal 23472 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 T|NU1000960!i!#AdHDbUWI4-` literal 49125 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 Date: Wed, 7 Feb 2024 19:45:57 +0300 Subject: [PATCH 037/240] Pin nano string options during Retro callbacks --- pkg/worker/caged/libretro/nanoarch/nanoarch.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index cd4d3649..a414cbcf 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -755,10 +755,13 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { if v, ok := (*Nan0.options)[key]; ok { // make Go strings null-terminated copies ;_; (*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 From 4e241d04486e690538032504dd8c4949a82d152d Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 8 Feb 2024 18:12:55 +0300 Subject: [PATCH 038/240] Swap x264-git dep to libx264 in Msys2/Arch --- .github/workflows/build.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da4711d5..c82893d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: 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-libx264 mingw-w64-x86_64-SDL2 mingw-w64-x86_64-libyuv mingw-w64-x86_64-libjpeg-turbo diff --git a/README.md b/README.md index 42c1d157..654dc6d6 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo # 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-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,SDL2,libyuv,libjpeg-turbo} ``` (You don't need to download libyuv on macOS) From 11295a28f6e58ac0b3eda9138cfa7966e1bd19d2 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 10 Feb 2024 16:22:45 +0300 Subject: [PATCH 039/240] Fix h264 lib for Go 1.22 Unwrapped X264_ C structs prevent crash on Go version 1.22. --- pkg/encoder/h264/libx264.go | 533 ------------------------------------ pkg/encoder/h264/x264.go | 135 +++++---- 2 files changed, 83 insertions(+), 585 deletions(-) delete mode 100644 pkg/encoder/h264/libx264.go diff --git a/pkg/encoder/h264/libx264.go b/pkg/encoder/h264/libx264.go deleted file mode 100644 index 6be21eb6..00000000 --- a/pkg/encoder/h264/libx264.go +++ /dev/null @@ -1,533 +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 - -static int x264_encode( x264_t *h, uintptr_t pp_nal, int *pi_nal, x264_picture_t *pic_in, x264_picture_t *pic_out ) { - return x264_encoder_encode(h, (x264_nal_t **)pp_nal, pi_nal, pic_in, pic_out); -} - -*/ -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.uintptr_t(uintptr(unsafe.Pointer(ppNal))) - cpiNal := (*C.int)(unsafe.Pointer(piNal)) - - cpicIn := picIn.cptr() - cpicOut := picOut.cptr() - - return (int32)(C.x264_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()) } diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go index c55f1502..74b805c4 100644 --- a/pkg/encoder/h264/x264.go +++ b/pkg/encoder/h264/x264.go @@ -1,18 +1,32 @@ 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 +*/ +import "C" + import ( "fmt" + "runtime" "unsafe" ) type H264 struct { - ref *T + ref *C.x264_t - pnals *Nal // array of NALs - nnals int32 // number of NALs - y int32 // Y size - uv int32 // U or V size - in, out *Picture + nal *C.x264_nal_t // array of NALs + cNal *C.int // number of NALs + y int // Y size + uv int // U or V size + in, out *C.x264_picture_t + + p runtime.Pinner } type Options struct { @@ -31,10 +45,10 @@ type Options struct { } func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { - libVersion := LibVersion() + 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 { @@ -46,90 +60,107 @@ func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { } } - param := Param{} + param := C.x264_param_t{} + if opts.Preset != "" && opts.Tune != "" { - if ParamDefaultPreset(¶m, 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(¶m, preset, tune) < 0 { return nil, fmt.Errorf("x264: invalid preset/tune name") } } else { - ParamDefault(¶m) + C.x264_param_default(¶m) } if opts.Profile != "" { - if ParamApplyProfile(¶m, opts.Profile) < 0 { + profile := C.CString(opts.Profile) + defer C.free(unsafe.Pointer(profile)) + if C.x264_param_apply_profile(¶m, profile) < 0 { return nil, fmt.Errorf("x264: invalid profile name") } } - ww, hh := int32(w), int32(h) - - 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 = ww - param.IHeight = hh - param.ILogLevel = opts.LogLevel - param.ISyncLookahead = 0 - param.IThreads = int32(th) + param.i_width = C.int(w) + param.i_height = C.int(h) + param.i_log_level = C.int(opts.LogLevel) + param.i_sync_lookahead = 0 + param.i_threads = C.int(th) if th != 1 { - param.BSlicedThreads = 1 + param.b_sliced_threads = 1 } - param.Rc.IRcMethod = RcCrf - param.Rc.FRfConstant = float32(opts.Crf) + param.rc.i_rc_method = C.X264_RC_CRF + param.rc.f_rf_constant = C.float(opts.Crf) encoder = &H264{ - y: ww * hh, - uv: ww * hh / 4, - pnals: new(Nal), - out: new(Picture), - in: &Picture{ - Img: Image{ICsp: param.ICsp, IPlane: 3, IStride: [4]int32{0: ww, 1: ww >> 1, 2: ww >> 1}}, + y: w * h, + uv: w * h / 4, + cNal: new(C.int), + nal: new(C.x264_nal_t), + out: new(C.x264_picture_t), + in: &C.x264_picture_t{ + img: C.x264_image_t{ + i_csp: param.i_csp, + i_plane: 3, + i_stride: [4]C.int{0: C.int(w), 1: C.int(w >> 1), 2: C.int(w >> 1)}, + }, }, + ref: C.x264_encoder_open(¶m), } - if encoder.ref = EncoderOpen(¶m); encoder.ref == nil { + if encoder.ref == nil { err = fmt.Errorf("x264: cannot open the encoder") } return } -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.y])) - e.in.Img.Plane[2] = uintptr(unsafe.Pointer(&yuv[e.y+e.uv])) + e.in.img.plane[0] = (*C.uchar)(unsafe.Pointer(&yuv[0])) + e.in.img.plane[1] = (*C.uchar)(unsafe.Pointer(&yuv[e.y])) + e.in.img.plane[2] = (*C.uchar)(unsafe.Pointer(&yuv[e.y+e.uv])) } -func (e *H264) Encode() (b []byte) { - e.in.IPts += 1 - bytes := EncoderEncode(e.ref, &e.pnals, &e.nnals, e.in, e.out) - if bytes > 0 { - // we merge multiple NALs stored in **pnals into a single byte stream - // ret contains the total size of NALs in bytes, i.e. each e.pnals[...].PPayload * IPayload - b = unsafe.Slice((*byte)(e.pnals.PPayload), bytes) - } - return +func (e *H264) Encode() []byte { + e.in.i_pts += 1 + + e.p.Pin(e.in.img.plane[0]) + e.p.Pin(e.in.img.plane[1]) + e.p.Pin(e.in.img.plane[2]) + + e.p.Pin(e.nal) + bytes := C.x264_encoder_encode(e.ref, &e.nal, e.cNal, e.in, e.out) + e.p.Unpin() + + // 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.nal.p_payload), bytes) } func (e *H264) IntraRefresh() { // !to implement } -func (e *H264) Info() string { return fmt.Sprintf("x264: v%v", LibVersion()) } +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.in.img.i_csp |= C.X264_CSP_VFLIP } else { - e.in.Img.ICsp &= ^CspVflip + e.in.img.i_csp &= ^C.X264_CSP_VFLIP } } func (e *H264) Shutdown() error { - EncoderClose(e.ref) + C.x264_encoder_close(e.ref) + e.p.Unpin() return nil } + +func Version() int { return int(C.X264_BUILD) } From 46067dec8f10a431863847e6299b7dcb0dbe49d6 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 11 Feb 2024 15:01:27 +0300 Subject: [PATCH 040/240] Use half GOP size in h264 --- pkg/encoder/h264/x264.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go index 74b805c4..6ca1f3aa 100644 --- a/pkg/encoder/h264/x264.go +++ b/pkg/encoder/h264/x264.go @@ -91,6 +91,7 @@ func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { 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 { From b903700077188eddaf29a732bef578715f92fff7 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 11 Feb 2024 15:30:45 +0300 Subject: [PATCH 041/240] Update dependencies --- go.mod | 16 ++++++++-------- go.sum | 40 ++++++++++++++++------------------------ 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index 23ed99de..1e5be76c 100644 --- a/go.mod +++ b/go.mod @@ -11,15 +11,15 @@ require ( github.com/gorilla/websocket v1.5.1 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.0 - github.com/minio/minio-go/v7 v7.0.66 - github.com/pion/ice/v3 v3.0.2 + github.com/minio/minio-go/v7 v7.0.67 + github.com/pion/ice/v3 v3.0.3 github.com/pion/interceptor v0.1.25 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.0-beta.7 + github.com/pion/webrtc/v4 v4.0.0-beta.9 github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.32.0 github.com/veandco/go-sdl2 v0.4.38 - golang.org/x/crypto v0.18.0 + golang.org/x/crypto v0.19.0 golang.org/x/image v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -42,11 +42,11 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pion/datachannel v1.5.5 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect - github.com/pion/mdns v0.0.10 // indirect + github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.13 // indirect github.com/pion/rtp v1.8.3 // indirect - github.com/pion/sctp v1.8.10 // indirect + github.com/pion/sctp v1.8.12 // indirect github.com/pion/sdp/v3 v3.0.6 // indirect github.com/pion/srtp/v3 v3.0.1 // indirect github.com/pion/stun/v2 v2.0.0 // indirect @@ -58,8 +58,8 @@ require ( github.com/stretchr/testify v1.8.4 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect - golang.org/x/net v0.20.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) diff --git a/go.sum b/go.sum index dcbb94b2..52a0de22 100644 --- a/go.sum +++ b/go.sum @@ -34,7 +34,6 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= @@ -64,8 +63,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= -github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs= +github.com/minio/minio-go/v7 v7.0.67 h1:BeBvZWAS+kRJm1vGTMJYVjKUNoo0FoEt/wUWdUtfmh8= +github.com/minio/minio-go/v7 v7.0.67/go.mod h1:+UXocnUeZ3wHvVh5s95gcrA4YjMIbccT6ubB+1m054A= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -91,15 +90,14 @@ github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= -github.com/pion/ice/v3 v3.0.2 h1:dNQnKsjLvOWz+PaI4tw1VnLYTp9adihC1HIASFGajmI= -github.com/pion/ice/v3 v3.0.2/go.mod h1:q3BDzTsxbqP0ySMSHrFuw2MYGUx/AC3WQfRGC5F/0Is= +github.com/pion/ice/v3 v3.0.3 h1:Mu5QkZ2pYmcjq9JETDcDR7F8UzjP1VHmcZmgU0yqsyk= +github.com/pion/ice/v3 v3.0.3/go.mod h1:YMLU7qbKfVjmEv7EoZPIVEI+kNYxWCdPK3VS0BU+U4Q= github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= github.com/pion/interceptor v0.1.25/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.9/go.mod h1:2JA5exfxwzXiCihmxpTKgFUpiQws2MnipoPK09vecIc= -github.com/pion/mdns v0.0.10 h1:u9/12WL2NNgtGT2nNPXT6+A+xeOF0PkawM/S/wPMWQA= -github.com/pion/mdns v0.0.10/go.mod h1:Y1scL/8TT8KQ172UfxrE4j0c04NOY71bJS1aE1zvyGY= +github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= +github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= 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/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= @@ -110,9 +108,8 @@ github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8= github.com/pion/rtp v1.8.3/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/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI= -github.com/pion/sctp v1.8.10 h1:FDPlkojWQ2hIjnvgFs+frHR33TZCxoRhV2HztZ07NnU= -github.com/pion/sctp v1.8.10/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI= +github.com/pion/sctp v1.8.12 h1:2VX50pedElH+is6FI+OKyRTeN5oy4mrk2HjnGa3UCmY= +github.com/pion/sctp v1.8.12/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.1 h1:AkIQRIZ+3tAOJMQ7G301xtrD1vekQbNeRO7eY1K8ZHk= @@ -128,8 +125,8 @@ github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouAN 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.7 h1:OGCl69njLUKzT0ozJEon18W1LqH0GtuxG9Qx+qtxBdg= -github.com/pion/webrtc/v4 v4.0.0-beta.7/go.mod h1:/zWz+1e1qrjaIKYZG/mOfPrntiHOhnd3vGz2Fdo85Ys= +github.com/pion/webrtc/v4 v4.0.0-beta.9 h1:xmTVa6aia4fzOSP4Ki/hB7dKKtcIqaPI6YSfGDa5JZE= +github.com/pion/webrtc/v4 v4.0.0-beta.9/go.mod h1:z/hdYIuZUz2MFSdPKf099qRVAyTJwvy2c0nwRItCgZI= 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= @@ -166,10 +163,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh 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.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/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -188,10 +184,9 @@ 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.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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= @@ -221,9 +216,9 @@ 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.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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= @@ -231,8 +226,6 @@ 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.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/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 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= @@ -242,7 +235,6 @@ 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.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From e2226e749229c2d5b6c9558ef73b94b02f4b6839 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 12 Feb 2024 11:23:52 +0300 Subject: [PATCH 042/240] Remove Encoder.LoadBuf interface method There is no point in keeping it only for early YUV image pooling. --- pkg/encoder/encoder.go | 9 +++------ pkg/encoder/h264/x264.go | 4 +--- pkg/encoder/h264/x264_test.go | 6 ++---- pkg/encoder/vpx/libvpx.go | 11 ++++------- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/pkg/encoder/encoder.go b/pkg/encoder/encoder.go index 5378e47c..b8ef829a 100644 --- a/pkg/encoder/encoder.go +++ b/pkg/encoder/encoder.go @@ -15,8 +15,7 @@ type ( InFrame yuv.RawFrame OutFrame []byte Encoder interface { - LoadBuf(input []byte) - Encode() []byte + Encode([]byte) []byte IntraRefresh() Info() string SetFlip(bool) @@ -75,10 +74,8 @@ func (v *Video) Encode(frame InFrame) OutFrame { } 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 diff --git a/pkg/encoder/h264/x264.go b/pkg/encoder/h264/x264.go index 6ca1f3aa..8e75f3ef 100644 --- a/pkg/encoder/h264/x264.go +++ b/pkg/encoder/h264/x264.go @@ -122,13 +122,11 @@ func NewEncoder(w, h int, th int, opts *Options) (encoder *H264, err error) { return } -func (e *H264) LoadBuf(yuv []byte) { +func (e *H264) Encode(yuv []byte) []byte { e.in.img.plane[0] = (*C.uchar)(unsafe.Pointer(&yuv[0])) e.in.img.plane[1] = (*C.uchar)(unsafe.Pointer(&yuv[e.y])) e.in.img.plane[2] = (*C.uchar)(unsafe.Pointer(&yuv[e.y+e.uv])) -} -func (e *H264) Encode() []byte { e.in.i_pts += 1 e.p.Pin(e.in.img.plane[0]) diff --git a/pkg/encoder/h264/x264_test.go b/pkg/encoder/h264/x264_test.go index 09f22ae3..0fe1bb43 100644 --- a/pkg/encoder/h264/x264_test.go +++ b/pkg/encoder/h264/x264_test.go @@ -9,8 +9,7 @@ func TestH264Encode(t *testing.T) { 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) } @@ -25,7 +24,6 @@ func Benchmark(b *testing.B) { } data := make([]byte, int(float64(w)*float64(h)*1.5)) for i := 0; i < b.N; i++ { - h264.LoadBuf(data) - h264.Encode() + h264.Encode(data) } } diff --git a/pkg/encoder/vpx/libvpx.go b/pkg/encoder/vpx/libvpx.go index 81988b9c..8de6ff40 100644 --- a/pkg/encoder/vpx/libvpx.go +++ b/pkg/encoder/vpx/libvpx.go @@ -135,17 +135,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 +152,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{} From ce7aa1be623b509e7dd46d1eda40c6f4247be275 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 13 Feb 2024 18:50:38 +0300 Subject: [PATCH 043/240] Disable frame duplication by default It breaks newer PCSX rearmed versions by pushing dozen of frames in bursts. To implement a proper support later. --- pkg/worker/caged/libretro/nanoarch/nanoarch.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index a414cbcf..86ca6bd9 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -713,8 +713,9 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { setRotation((*(*uint)(data) % 4) * 90) return true case C.RETRO_ENVIRONMENT_GET_CAN_DUPE: - *(*C.bool)(data) = C.bool(true) - return true + // !to implement frame dup (nil) some time later + *(*C.bool)(data) = C.bool(false) + return false case C.RETRO_ENVIRONMENT_GET_USERNAME: *(**C.char)(data) = Nan0.cUserName return true From 61b4108dcedbd19ccfdfbf6df1dc2a22155f4dd4 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 13 Feb 2024 19:37:23 +0300 Subject: [PATCH 044/240] 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. --- pkg/worker/caged/libretro/frontend_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index 50dd78c6..5b4e65ae 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -217,7 +217,7 @@ func TestSavePersistence(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, frames: 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}, } From 6258f9a5e49471309f7059095b4b456f698f25c9 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 13 Feb 2024 21:17:27 +0300 Subject: [PATCH 045/240] Add RTCP packet reader for output streams Default interceptors need those. --- pkg/network/webrtc/webrtc.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/pkg/network/webrtc/webrtc.go b/pkg/network/webrtc/webrtc.go index f94d7915..0f67ba7d 100644 --- a/pkg/network/webrtc/webrtc.go +++ b/pkg/network/webrtc/webrtc.go @@ -42,9 +42,20 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp 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) @@ -53,9 +64,20 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp 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 From 3459c7e8d606afef8c077bdc80cf40e2d2ac6fe6 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 15 Feb 2024 04:10:49 +0300 Subject: [PATCH 046/240] Add VP9 encoder option --- pkg/config/config.yaml | 2 +- pkg/encoder/encoder.go | 12 +++++++++--- pkg/encoder/vpx/libvpx.go | 26 +++++++++++++++++++++----- pkg/network/webrtc/webrtc.go | 2 ++ pkg/worker/room/room_test.go | 12 +++++++----- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index c532fbe1..27f1eaac 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -258,7 +258,7 @@ encoder: # so we keep the frame buffer roughly half of that size or 2 RTC packets per frame frame: 10 video: - # h264, vpx (VP8) + # h264, vpx (vp8) or vp9 codec: h264 # Threaded encoder if supported, 0 - auto, 1 - nope, >1 - multi-threaded threads: 0 diff --git a/pkg/encoder/encoder.go b/pkg/encoder/encoder.go index b8ef829a..a323290b 100644 --- a/pkg/encoder/encoder.go +++ b/pkg/encoder/encoder.go @@ -37,6 +37,7 @@ type VideoCodec string const ( H264 VideoCodec = "h264" VP8 VideoCodec = "vp8" + VP9 VideoCodec = "vp9" VPX VideoCodec = "vpx" ) @@ -48,13 +49,18 @@ const ( func NewVideoEncoder(w, h, dw, dh int, scale float64, conf config.Video, log *logger.Logger) (*Video, error) { var enc Encoder var err error - switch VideoCodec(conf.Codec) { + codec := VideoCodec(conf.Codec) + switch codec { case H264: opts := h264.Options(conf.H264) enc, err = h264.NewEncoder(dw, dh, conf.Threads, &opts) - case VP8, VPX: + case VP8, VP9, VPX: opts := vpx.Options(conf.Vpx) - enc, err = vpx.NewEncoder(dw, dh, &opts) + 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) } diff --git a/pkg/encoder/vpx/libvpx.go b/pkg/encoder/vpx/libvpx.go index 8de6ff40..c175c14f 100644 --- a/pkg/encoder/vpx/libvpx.go +++ b/pkg/encoder/vpx/libvpx.go @@ -12,6 +12,7 @@ package vpx #include #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") @@ -160,7 +174,9 @@ func (vpx *Vpx) Encode(yuv []byte) []byte { return C.GoBytes(fb.ptr, fb.size) } -func (vpx *Vpx) Info() string { return fmt.Sprintf("vpx: %v", C.GoString(C.vpx_codec_version_str())) } +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 diff --git a/pkg/network/webrtc/webrtc.go b/pkg/network/webrtc/webrtc.go index 0f67ba7d..25612d06 100644 --- a/pkg/network/webrtc/webrtc.go +++ b/pkg/network/webrtc/webrtc.go @@ -160,6 +160,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 == "" { diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go index c1459e0a..2be732d5 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room/room_test.go @@ -110,13 +110,15 @@ func TestMain(m *testing.M) { func TestRoom(t *testing.T) { tests := []testParams{ - {game: alwas, codecs: []codec{encoder.H264}, frames: 300}, + {game: alwas, codecs: []codec{encoder.H264, encoder.VP8, encoder.VP9}, frames: 300}, } for _, test := range tests { - room := room(conf{codec: test.codecs[0], game: test.game}) - room.WaitFrame(test.frames) - room.Close() + for _, codec := range test.codecs { + room := room(conf{codec: codec, game: test.game}) + room.WaitFrame(test.frames) + room.Close() + } } } @@ -245,7 +247,7 @@ func room(cfg conf) testRoom { func BenchmarkRoom(b *testing.B) { benches := []testParams{ // warm up - {system: "gba", game: sushi, codecs: []codec{encoder.VP8}, frames: 50}, + {system: "gba", game: sushi, codecs: []codec{encoder.VP8, encoder.VP9}, frames: 50}, {system: "gba", game: sushi, codecs: []codec{encoder.VP8, encoder.H264}, frames: 100}, {system: "nes", game: alwas, codecs: []codec{encoder.VP8, encoder.H264}, frames: 100}, } From e7e281083f94c49cd6e49237b9ae0f4178940960 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 16 Feb 2024 22:47:54 +0300 Subject: [PATCH 047/240] Add ugly persistent volume option --- web/index.html | 8 ++++---- web/js/gui/gui.js | 11 +++++++++++ web/js/settings/opts.js | 3 ++- web/js/settings/settings.js | 21 +++++++++++++++++++-- web/js/stream/stream.js | 1 + 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/web/index.html b/web/index.html index a912d443..bc673e5e 100644 --- a/web/index.html +++ b/web/index.html @@ -118,16 +118,16 @@ - + - - + + - + diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js index 0d14ebff..be5b603f 100644 --- a/web/js/gui/gui.js +++ b/web/js/gui/gui.js @@ -147,6 +147,16 @@ const gui = (() => { el.classList.remove('hidden'); } + const inputN = (key = '', cb = () => ({}), current = 0) => { + const el = _create(); + const input = _create('input'); + input.type = 'number'; + input.value = current; + input.onchange = event => cb(key, event.target.value); + el.append(input); + return el; + } + const hide = (el) => { el.classList.add('hidden'); } @@ -208,6 +218,7 @@ const gui = (() => { create: _create, fragment, hide, + inputN, panel, select, show, diff --git a/web/js/settings/opts.js b/web/js/settings/opts.js index bff7a098..b0b2b966 100644 --- a/web/js/settings/opts.js +++ b/web/js/settings/opts.js @@ -10,5 +10,6 @@ const opts = Object.freeze({ _VERSION: '_version', LOG_LEVEL: 'log.level', INPUT_KEYBOARD_MAP: 'input.keyboard.map', - MIRROR_SCREEN: 'mirror.screen' + MIRROR_SCREEN: 'mirror.screen', + VOLUME: 'volume' }); diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js index 81b38c0c..55dd89dd 100644 --- a/web/js/settings/settings.js +++ b/web/js/settings/settings.js @@ -15,7 +15,7 @@ */ const settings = (() => { // internal structure version - const revision = 1.2; + const revision = 1.3; // default settings // keep them for revert to defaults option @@ -347,6 +347,13 @@ settings._renderrer = (() => { // the main display data holder element const data = document.getElementById('settings-data'); + let sx, sy = 0; + + data.addEventListener("scroll", event => { + sx = data.scrollTop; + sy = data.scrollLeft; + }, {passive: true}); + // a fast way to clear data holder. const clearData = () => { while (data.firstChild) data.removeChild(data.firstChild) @@ -428,7 +435,11 @@ settings._renderrer = (() => { * @param newValue A new value to set. * @param oldValue An old value to use somehow if needed. */ - const onChange = (key, newValue, oldValue) => settings.set(key, newValue); + const onChange = (key, newValue, oldValue) => { + settings.set(key, newValue); + data.scrollTop = sx; + data.scrollLeft = sy; + } const onKeyBindingChange = (key, oldValue) => { clearData(); @@ -467,6 +478,12 @@ settings._renderrer = (() => { .add(gui.select(k, onChange, {values: ['mirror']}, value)) .build(); break; + case opts.VOLUME: + _option(data).withName('Volume (%)') + .add(gui.inputN(k, onChange, value)) + .restartNeeded() + .build() + break; default: _option(data).withName(k).add(value).build(); } diff --git a/web/js/stream/stream.js b/web/js/stream/stream.js index 9ab3dd49..5652b36e 100644 --- a/web/js/stream/stream.js +++ b/web/js/stream/stream.js @@ -149,6 +149,7 @@ const stream = (() => { const init = () => { options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none'); + options.volume = settings.loadOr(opts.VOLUME, 50) / 100; } event.sub(SETTINGS_CHANGED, () => { From b79b4c405afb41b4e2c0d05b9c578ea3bd4503eb Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 26 Nov 2023 23:49:13 +0300 Subject: [PATCH 048/240] Get random free port in websocket tests --- pkg/com/net_test.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg/com/net_test.go b/pkg/com/net_test.go index fa7d3130..572fb31e 100644 --- a/pkg/com/net_test.go +++ b/pkg/com/net_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math/rand" + "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 }) @@ -206,3 +213,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 +} From 41bfe4f4d3849508b4c848f760c6df9452cbf7f9 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 19 Feb 2024 18:27:38 +0300 Subject: [PATCH 049/240] Fix WebRTC datachannels in FF --- web/js/network/webrtc.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js index 8d72c8fd..26e04f59 100644 --- a/web/js/network/webrtc.js +++ b/web/js/network/webrtc.js @@ -31,6 +31,8 @@ const webrtc = (() => { connection.ondatachannel = e => { log.debug('[rtc] ondatachannel', e.channel.label) + e.channel.binaryType = "arraybuffer"; + dataChannel = e.channel; dataChannel.onopen = () => { log.info('[rtc] the input channel has been opened'); From 1452317d451600e81d3ee33b30e4e271b42ced09 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 20 Feb 2024 21:39:49 +0300 Subject: [PATCH 050/240] Scan ROM extensions case-insensitive --- pkg/games/library.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/games/library.go b/pkg/games/library.go index 0fde5023..e1338f8c 100644 --- a/pkg/games/library.go +++ b/pkg/games/library.go @@ -235,7 +235,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 } @@ -246,7 +246,7 @@ func (lib *library) isExtAllowed(path string) bool { // getMetadata returns game info from a path func getMetadata(path string, basePath string) GameMetadata { name := filepath.Base(path) - ext := filepath.Ext(name) + ext := strings.ToLower(filepath.Ext(name)) relPath, _ := filepath.Rel(basePath, path) return GameMetadata{ From 9308e1b3889f68ee1eb94a11b2a7212dd80ba126 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 20 Feb 2024 21:59:36 +0300 Subject: [PATCH 051/240] Sort lib alphabetically in console --- pkg/games/library.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/games/library.go b/pkg/games/library.go index e1338f8c..884f3852 100644 --- a/pkg/games/library.go +++ b/pkg/games/library.go @@ -4,6 +4,7 @@ import ( "fmt" "io/fs" "path/filepath" + "sort" "strings" "sync" "time" @@ -259,8 +260,17 @@ 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] + gameList.WriteString(fmt.Sprintf(" %7s %s (%s)\n", game.System, game.Name, game.Path)) } lib.log.Debug().Msgf("Lib dump\n"+ From 000bc4f661ee31151bb1cd81a2c328ca42df2abe Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 23 Feb 2024 21:06:55 +0300 Subject: [PATCH 052/240] Load apps after rendering 1 frame This is mandatory for Mupen and DOSBox save states. Enabled for all emulators. --- pkg/worker/caged/libretro/frontend.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 0386b0a8..ab6b8ed1 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -261,10 +261,9 @@ func (f *Frontend) Start() { defer f.mui.Unlock() 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") } From c699455b580e676cd6e1c056f22cbb7cc5004f75 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 25 Feb 2024 12:51:37 +0300 Subject: [PATCH 053/240] Hide video element controls in fullscreen --- web/css/main.css | 6 ++++++ web/index.html | 4 ++-- web/js/stream/stream.js | 23 ++++++++++++++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/web/css/main.css b/web/css/main.css index b47047fb..10d98e2e 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -3,6 +3,12 @@ src: url('/fonts/6809-Chargen.woff2'); } + +.no-media-controls::-webkit-media-controls { + display: none !important; +} + + html { /* force full size for Firefox */ width: 100%; diff --git a/web/index.html b/web/index.html index bc673e5e..a760c3fc 100644 --- a/web/index.html +++ b/web/index.html @@ -37,7 +37,7 @@ There is still audio because current audio flow is not from media but it is manually encoded (technical webRTC challenge). Later, when we can integrate audio to media, we can face the issue with mute again . https://developers.google.com/web/updates/2017/09/autoplay-policy-changes --> - +
+
Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player), Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the clipboard) @@ -94,21 +95,6 @@
{{end}}
- - + diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js index be5b603f..239affde 100644 --- a/web/js/gui/gui.js +++ b/web/js/gui/gui.js @@ -26,8 +26,7 @@ const gui = (() => { return el; } - const select = (key = '', callback = function () { - }, values = {values: [], labels: []}, current = '') => { + const select = (key = '', callback = () => ({}), values = {values: [], labels: []}, current = '') => { const el = _create(); const select = _create('select'); select.onchange = event => { @@ -45,6 +44,7 @@ const gui = (() => { const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => { const state = { + br: null, shown: false, loading: false, title: title, @@ -70,6 +70,10 @@ const gui = (() => { el.classList.add('panel__header__controls'); buttons.forEach((b => el.append(_create('span', (el) => { + if (Object.keys(b).length === 0) { + el.classList.add('panel__button_separator'); + return + } el.classList.add('panel__button'); if (b.cl) b.cl.forEach(class_ => el.classList.add(class_)); if (b.title) el.title = b.title; @@ -99,17 +103,19 @@ const gui = (() => { function toggle(show) { state.shown = show; - if (onToggle) { - onToggle(state.shown, _root) - } - if (state.shown) { - gui.show(_root); - } else { - gui.hide(_root); - } + + // hack not transparent jpeg corners :_; + show ? _root.parentElement.style.borderRadius = '0px' : + state.br ? _root.parentElement.style.borderRadius = state.br : + state.br = window.getComputedStyle(_root.parentElement).borderRadius + + onToggle && onToggle(state.shown, _root) + + state.shown ? gui.show(_root) : gui.hide(_root) } return { + contentEl: _content, isHidden: () => !state.shown, setContent, setLoad, diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js index 0e67fdd7..01da6f1c 100644 --- a/web/js/settings/settings.js +++ b/web/js/settings/settings.js @@ -15,7 +15,7 @@ */ const settings = (() => { // internal structure version - const revision = 1.4; + const revision = 1.5; // default settings // keep them for revert to defaults option @@ -46,17 +46,7 @@ const settings = (() => { const exportFileName = `cloud-game.settings.v${revision}.txt`; - // ui references - const ui = document.getElementById('app-settings'), - closeEl = document.getElementById('settings__controls__close'), - loadEl = document.getElementById('settings__controls__load'), - saveEl = document.getElementById('settings__controls__save'), - resetEl = document.getElementById('settings__controls__reset'); - - this._renderrer = this._renderrer || { - render: () => { - } - }; + let _renderer = {render: () => ({})}; const getStore = () => store.settings; @@ -64,8 +54,7 @@ const settings = (() => { * The NullObject provider if everything else fails. */ const voidProvider = (store_ = {settings: {}}) => { - const nil = () => { - } + const nil = () => ({}) return { get: key => store_.settings[key], @@ -107,7 +96,7 @@ const settings = (() => { const get = key => JSON.parse(localStorage.getItem(key)); - const set = (key, value) => save(); + const set = () => save(); const remove = () => save(); @@ -161,7 +150,6 @@ const settings = (() => { document.body.appendChild(el); el.click(); document.body.removeChild(el); - el = undefined; } const init = () => { @@ -256,13 +244,40 @@ const settings = (() => { provider.remove(key, subKey); } - const _render = () => settings._renderrer.render() + const panel = gui.panel(document.getElementById('settings'), '> OPTIONS', 'settings', null, [ + {caption: 'Export', handler: () => _export(), title: 'Save',}, + {caption: 'Import', handler: () => _fileReader.read(onFileLoad), title: 'Load',}, + { + caption: 'Reset', + handler: () => { + if (window.confirm("Are you sure want to reset your settings?")) { + _reset(); + event.pub(SETTINGS_CHANGED); + } + }, + title: 'Reset', + }, + {} + ], + (state) => { + if (state) return; - /** - * Settings modal window toggle handler. - * @returns {boolean} True in case if it's opened. - */ - const toggle = () => ui.classList.toggle('modal-visible') && !_render(); + event.pub(SETTINGS_CLOSED); + // to make sure it's disabled, but it's a tad verbose + event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); + }) + + panel.toggle(false); + + const _render = () => { + _renderer.data = panel.contentEl; + _renderer.render() + } + + const toggle = () => { + panel.toggle(true); + _render() + } function _getType(value) { if (value === undefined) return option.undefined @@ -273,15 +288,8 @@ const settings = (() => { else return option.undefined; } - /** - * File reader submodule (FileReader API). - * - * @type {{read: read}} Tries to read a file. - * @private - */ const _fileReader = (() => { - let callback_ = () => { - } + let callback_ = () => ({}) const el = document.createElement('input'); const reader = new FileReader(); @@ -309,21 +317,6 @@ const settings = (() => { event.sub(SETTINGS_CHANGED, _render); - // internal init section - closeEl.addEventListener('click', () => { - event.pub(SETTINGS_CLOSED); - // to make sure it's disabled, but it's a tad verbose - event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); - }); - saveEl.addEventListener('click', () => _export()); - loadEl.addEventListener('click', () => _fileReader.read(onFileLoad)); - resetEl.addEventListener('click', () => { - if (window.confirm("Are you sure want to reset your settings?")) { - _reset(); - event.pub(SETTINGS_CHANGED); - } - }); - return { init, loadOr, @@ -335,24 +328,31 @@ const settings = (() => { export: _export, ui: { toggle, + }, + set renderer(fn) { + _renderer = fn; } } })(document, event, JSON, localStorage, log, window); // hardcoded ui stuff -settings._renderrer = (() => { - // options to ignore (i.e. ignored = {'_version': 1}) - const ignored = {}; +settings.renderer = (() => { + // don't show these options (i.e. ignored = {'_version': 1}) + const ignored = {'_version': 1}; // the main display data holder element - const data = document.getElementById('settings-data'); + let data = null; - let sx, sy = 0; - - data.addEventListener("scroll", event => { - sx = data.scrollTop; - sy = data.scrollLeft; - }, {passive: true}); + const scrollState = ((sx = 0, sy = 0, el) => ({ + track(_el) { + el = _el + el.addEventListener("scroll", () => ({scrollTop: sx, scrollLeft: sy} = el), {passive: true}) + }, + restore() { + el.scrollTop = sx + el.scrollLeft = sy + } + }))() // a fast way to clear data holder. const clearData = () => { @@ -363,9 +363,11 @@ settings._renderrer = (() => { const wrapperEl = document.createElement('div'); wrapperEl.classList.add('settings__option'); + const titleEl = document.createElement('div'); + titleEl.classList.add('settings__option-title'); + wrapperEl.append(titleEl); + const nameEl = document.createElement('div'); - nameEl.classList.add('settings__option-name'); - wrapperEl.append(nameEl); const valueEl = document.createElement('div'); valueEl.classList.add('settings__option-value'); @@ -373,15 +375,23 @@ settings._renderrer = (() => { return { withName: function (name = '') { + if (name === '') return this; + nameEl.classList.add('settings__option-name'); nameEl.textContent = name; + titleEl.append(nameEl); return this; }, withClass: function (name = '') { wrapperEl.classList.add(name); return this; }, - readOnly: function () { - // reserved + withDescription(text = '') { + if (text === '') return this; + const descEl = document.createElement('div'); + descEl.classList.add('settings__option-desc'); + descEl.textContent = text; + titleEl.append(descEl); + return this; }, restartNeeded: function () { nameEl.classList.add('restart-needed-asterisk'); @@ -408,11 +418,7 @@ settings._renderrer = (() => { } } - // !to check leaks - if (handler) { - handler.unsub(); - handler = undefined; - } + handler?.unsub(); event.pub(KEYBOARD_TOGGLE_FILTER_MODE); event.pub(SETTINGS_CHANGED); @@ -433,12 +439,10 @@ settings._renderrer = (() => { * * @param key The name (id) of an option. * @param newValue A new value to set. - * @param oldValue An old value to use somehow if needed. */ - const onChange = (key, newValue, oldValue) => { + const onChange = (key, newValue) => { settings.set(key, newValue); - data.scrollTop = sx; - data.scrollLeft = sy; + scrollState.restore(data); } const onKeyBindingChange = (key, oldValue) => { @@ -457,7 +461,7 @@ settings._renderrer = (() => { const value = _settings[k]; switch (k) { case opts._VERSION: - _option(data).withName('Format version').add(value).build(); + _option(data).withName('Options format version').add(value).build(); break; case opts.LOG_LEVEL: _option(data).withName('Log level') @@ -474,8 +478,9 @@ settings._renderrer = (() => { .build(); break; case opts.MIRROR_SCREEN: - _option(data).withName('Video mirroring without smooth') - .add(gui.select(k, onChange, {values: ['mirror']}, value)) + _option(data).withName('Video mirroring') + .add(gui.select(k, onChange, {values: ['mirror'], labels: []}, value)) + .withDescription('Disables video image smoothing by rendering the video on a canvas (much more demanding on the CPU/GPU)') .build(); break; case opts.VOLUME: @@ -488,9 +493,25 @@ settings._renderrer = (() => { _option(data).withName(k).add(value).build(); } } + + data.append( + gui.create('br'), + gui.create('div', (el) => { + el.classList.add('settings__info', 'restart-needed-asterisk-b'); + el.innerText = ' -- applied after page reload' + }), + gui.create('div', (el) => { + el.classList.add('settings__info'); + el.innerText = `Options format version: ${_settings?._version}`; + }) + ); } return { render, + set data(el) { + data = el; + scrollState.track(el) + } } -})(document, log, opts, settings); +})(document, gui, log, opts, settings); diff --git a/web/js/workerManager.js b/web/js/workerManager.js index 56f463e4..3d119b2a 100644 --- a/web/js/workerManager.js +++ b/web/js/workerManager.js @@ -7,19 +7,13 @@ const workerManager = (() => { _class = 'server-list', trigger = document.getElementById('w'), panel = gui.panel(document.getElementById(id), 'WORKERS', 'server-list', null, [ - { - caption: '⟳', - cl: ['bold'], - handler: utils.debounce(handleReload, 1000), - title: 'Reload server data', - } - ], - // hack not transparent jpeg corners :_; - ((br) => (state, el) => { - state ? el.parentElement.style.borderRadius = '0px' : - br ? el.parentElement.style.borderRadius = br : - br = window.getComputedStyle(el.parentElement).borderRadius - })()), + { + caption: '⟳', + cl: ['bold'], + handler: utils.debounce(handleReload, 1000), + title: 'Reload server data', + } + ]), index = ((i = 1) => ({v: () => i++, r: () => i = 1}))(), // caption -- the field caption // renderer -- an arbitrary DOM output for the field @@ -117,7 +111,7 @@ const workerManager = (() => { if (server.room) { return gui.create('a', (el) => { el.innerText = state; - el.href = "/?id="+server.room; + el.href = "/?id=" + server.room; }) } return state From cf5248ec54697cbabe755bec09d7fc791c607be9 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 14 Mar 2024 00:36:53 +0300 Subject: [PATCH 066/240] Fix missing gameList transition handler --- web/index.html | 2 +- web/js/gameList.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 2953032d..e710272f 100644 --- a/web/index.html +++ b/web/index.html @@ -112,7 +112,7 @@ - + diff --git a/web/js/gameList.js b/web/js/gameList.js index 9b10b32a..74338a3e 100644 --- a/web/js/gameList.js +++ b/web/js/gameList.js @@ -88,7 +88,7 @@ const gameList = (() => { let onTransitionEnd = () => ({}) - rootEl.addEventListener('transitionend', () => onTransitionEnd()) + //rootEl.addEventListener('transitionend', () => onTransitionEnd()) let items = [] From a349fdd0cf99036b45d925e2a0805ddb8756cb25 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 14 Mar 2024 01:21:13 +0300 Subject: [PATCH 067/240] Add 'force full-screen' option --- web/css/ui.css | 12 ++++++++++-- web/index.html | 14 +++++++------- web/js/controller.js | 1 + web/js/env.js | 3 +-- web/js/gui/gui.js | 31 +++++++++++++++++++++++++++++++ web/js/settings/opts.js | 3 ++- web/js/settings/settings.js | 12 ++++++++++-- web/js/stream/stream.js | 16 +++++++++++++++- 8 files changed, 77 insertions(+), 15 deletions(-) diff --git a/web/css/ui.css b/web/css/ui.css index 1a2d05fd..ce3cfd27 100644 --- a/web/css/ui.css +++ b/web/css/ui.css @@ -74,10 +74,13 @@ .settings__option-value select, .settings__option-value input { font-family: '6809', monospace; - width: 6em; font-size: 90%; } +.settings__option-value input:not([type='checkbox']) { + width: 6em; +} + .settings__option-value option { font-size: 150%; } @@ -88,10 +91,15 @@ .keyboard-bindings .settings__option-value { display: grid; - grid-template-columns: 25% 25% auto auto; + grid-template-columns: 20% 20% 20% 20% auto; row-gap: 5px; } +.settings__option-checkbox label { + display: inline-flex; + align-items: center; +} + .binding-element { display: flex; flex-direction: column; diff --git a/web/index.html b/web/index.html index e710272f..068279c6 100644 --- a/web/index.html +++ b/web/index.html @@ -16,7 +16,7 @@ - + Cloud Retro @@ -102,18 +102,18 @@
- + - - - + + + - + @@ -122,7 +122,7 @@ - + diff --git a/web/js/controller.js b/web/js/controller.js index cd57844a..e85733a0 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -138,6 +138,7 @@ input.poll.disable(); gui.hide(menuScreen); stream.toggle(true); + stream.forceFullscreenMaybe(); gui.show(keyButtons[KEY.SAVE]); gui.show(keyButtons[KEY.LOAD]); // end clear diff --git a/web/js/env.js b/web/js/env.js index 0a6ddb80..a8f79ebd 100644 --- a/web/js/env.js +++ b/web/js/env.js @@ -108,8 +108,7 @@ const env = (() => { return { getOs: getOS, getBrowser: getBrowser, - // Check mobile type because different mobile can accept different video encoder. - isMobileDevice: () => (typeof window.orientation !== 'undefined') || (navigator.userAgent.indexOf('IEMobile') !== -1), + isMobileDevice: () => /Mobi|Android|iPhone/i.test(navigator.userAgent), display: () => ({ isPortrait: isPortrait, toggleFullscreen: toggleFullscreen, diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js index 239affde..b58ca0a3 100644 --- a/web/js/gui/gui.js +++ b/web/js/gui/gui.js @@ -42,6 +42,36 @@ const gui = (() => { return el; } + const checkbox = (id, cb = () => ({}), checked = false, label = '', cc = '') => { + const el = _create(); + cc !== '' && el.classList.add(cc); + + let parent = el; + + if (label) { + const _label = _create('label', (el) => { + el.setAttribute('htmlFor', id); + }) + _label.innerText = label; + el.append(_label) + parent = _label; + } + + const input = _create('input', (el) => { + el.setAttribute('id', id); + el.setAttribute('name', id); + el.setAttribute('type', 'checkbox'); + el.onclick = ((e) => { + checked = e.target.checked + cb(id, checked) + }) + checked && el.setAttribute('checked', ''); + }); + parent.prepend(input); + + return el; + } + const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => { const state = { br: null, @@ -221,6 +251,7 @@ const gui = (() => { fadeInOut, }, binding, + checkbox, create: _create, fragment, hide, diff --git a/web/js/settings/opts.js b/web/js/settings/opts.js index b0b2b966..5e9fac03 100644 --- a/web/js/settings/opts.js +++ b/web/js/settings/opts.js @@ -11,5 +11,6 @@ const opts = Object.freeze({ LOG_LEVEL: 'log.level', INPUT_KEYBOARD_MAP: 'input.keyboard.map', MIRROR_SCREEN: 'mirror.screen', - VOLUME: 'volume' + VOLUME: 'volume', + FORCE_FULLSCREEN: 'force.fullscreen' }); diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js index 01da6f1c..09e1cb7f 100644 --- a/web/js/settings/settings.js +++ b/web/js/settings/settings.js @@ -15,7 +15,7 @@ */ const settings = (() => { // internal structure version - const revision = 1.5; + const revision = 1.51; // default settings // keep them for revert to defaults option @@ -480,7 +480,7 @@ settings.renderer = (() => { case opts.MIRROR_SCREEN: _option(data).withName('Video mirroring') .add(gui.select(k, onChange, {values: ['mirror'], labels: []}, value)) - .withDescription('Disables video image smoothing by rendering the video on a canvas (much more demanding on the CPU/GPU)') + .withDescription('Disables video image smoothing by rendering the video on a canvas (much more demanding for browser)') .build(); break; case opts.VOLUME: @@ -489,6 +489,14 @@ settings.renderer = (() => { .restartNeeded() .build() break; + case opts.FORCE_FULLSCREEN: + _option(data).withName('Force fullscreen') + .withDescription( + 'Whether games should open in full-screen mode after starting up (excluding mobile devices)' + ) + .add(gui.checkbox(k, onChange, value, 'Enbabled', 'settings__option-checkbox')) + .build() + break; default: _option(data).withName(k).add(value).build(); } diff --git a/web/js/stream/stream.js b/web/js/stream/stream.js index c5dcf478..b0fb730d 100644 --- a/web/js/stream/stream.js +++ b/web/js/stream/stream.js @@ -12,6 +12,7 @@ const stream = (() => { poster: '/img/screen_loading.gif', mirrorMode: null, mirrorUpdateRate: 1 / 60, + forceFullscreen: true, }, state = { screen: screen, @@ -112,6 +113,12 @@ const stream = (() => { screen.classList.toggle('no-media-controls', make) } + const forceFullscreenMaybe = () => { + const touchMode = env.isMobileDevice(); + log.debug('touch check', touchMode) + !touchMode && options.forceFullscreen && toggleFullscreen(); + } + const useCustomScreen = (use) => { if (use) { if (screen.paused || screen.ended) return; @@ -158,14 +165,20 @@ const stream = (() => { const init = () => { options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none'); options.volume = settings.loadOr(opts.VOLUME, 50) / 100; + options.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false); } event.sub(SETTINGS_CHANGED, () => { - const newValue = settings.get()[opts.MIRROR_SCREEN]; + const s = settings.get(); + const newValue = s[opts.MIRROR_SCREEN]; if (newValue !== options.mirrorMode) { useCustomScreen(newValue === 'mirror'); options.mirrorMode = newValue; } + const newValue2 = s[opts.FORCE_FULLSCREEN]; + if (newValue2 !== options.forceFullscreen) { + options.forceFullscreen = newValue2; + } }); @@ -196,6 +209,7 @@ const stream = (() => { play: stream, toggle, useCustomScreen, + forceFullscreenMaybe, init } } From 29eedee3ec4ab177a37c31ef5ba86960accd5f6a Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 15 Mar 2024 14:38:30 +0300 Subject: [PATCH 068/240] Fix keybindings for options --- web/index.html | 8 ++++---- web/js/controller.js | 6 +----- web/js/event/event.js | 1 - web/js/gui/gui.js | 3 ++- web/js/settings/settings.js | 31 +++++++++++++++++++------------ 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/web/index.html b/web/index.html index 068279c6..8ca2df5f 100644 --- a/web/index.html +++ b/web/index.html @@ -104,12 +104,12 @@ - + - + - + @@ -122,7 +122,7 @@ - + diff --git a/web/js/controller.js b/web/js/controller.js index e85733a0..3b7d7dc5 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -303,8 +303,7 @@ name: 'settings', keyRelease: key => { if (key === KEY.SETTINGS) { - const isSettingsOpened = settings.ui.toggle(); - if (!isSettingsOpened) setState(lastState); + !settings.ui.toggle() && setState(lastState) } }, menuReady: showMenuScreen @@ -452,9 +451,6 @@ event.sub(KEY_PRESSED, onKeyPress); event.sub(KEY_RELEASED, onKeyRelease); event.sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); - event.sub(SETTINGS_CLOSED, () => { - state.keyRelease(KEY.SETTINGS); - }); event.sub(AXIS_CHANGED, onAxisChanged); event.sub(CONTROLLER_UPDATED, data => webrtc.input(data)); // recording diff --git a/web/js/event/event.js b/web/js/event/event.js index 3c412d20..301e731b 100644 --- a/web/js/event/event.js +++ b/web/js/event/event.js @@ -97,7 +97,6 @@ const STATS_TOGGLE = 'statsToggle'; const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; const SETTINGS_CHANGED = 'settingsChanged'; -const SETTINGS_CLOSED = 'settingsClosed'; const RECORDING_TOGGLED = 'recordingToggle' const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js index b58ca0a3..95b4ee13 100644 --- a/web/js/gui/gui.js +++ b/web/js/gui/gui.js @@ -131,7 +131,7 @@ const gui = (() => { _title.innerText = state.loading ? `${state.title}...` : state.title; } - function toggle(show) { + function toggle(show = true) { state.shown = show; // hack not transparent jpeg corners :_; @@ -142,6 +142,7 @@ const gui = (() => { onToggle && onToggle(state.shown, _root) state.shown ? gui.show(_root) : gui.hide(_root) + return state.shown; } return { diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js index 09e1cb7f..5fed80af 100644 --- a/web/js/settings/settings.js +++ b/web/js/settings/settings.js @@ -244,6 +244,13 @@ const settings = (() => { provider.remove(key, subKey); } + + const _render = () => { + _renderer.data = panel.contentEl; + _renderer.render() + } + + const panel = gui.panel(document.getElementById('settings'), '> OPTIONS', 'settings', null, [ {caption: 'Export', handler: () => _export(), title: 'Save',}, {caption: 'Import', handler: () => _fileReader.read(onFileLoad), title: 'Load',}, @@ -259,25 +266,25 @@ const settings = (() => { }, {} ], - (state) => { - if (state) return; + (show) => { + if (show) { + _render(); + return; + } - event.pub(SETTINGS_CLOSED); // to make sure it's disabled, but it's a tad verbose event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); }) panel.toggle(false); - const _render = () => { - _renderer.data = panel.contentEl; - _renderer.render() - } - - const toggle = () => { - panel.toggle(true); - _render() - } + const toggle = (() => { + let x = false; + return () => { + x = !x; + panel.toggle(x); + } + })(); function _getType(value) { if (value === undefined) return option.undefined From 47bd72e1cd03525112b7f22dc6e7ee761ee33353 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 16 Mar 2024 13:15:05 +0300 Subject: [PATCH 069/240] Fix broken options button --- web/index.html | 6 +++--- web/js/controller.js | 9 ++++----- web/js/gui/gui.js | 30 ++++++++++++++++-------------- web/js/settings/settings.js | 17 +++++------------ 4 files changed, 28 insertions(+), 34 deletions(-) diff --git a/web/index.html b/web/index.html index 8ca2df5f..2446207e 100644 --- a/web/index.html +++ b/web/index.html @@ -104,12 +104,12 @@ - + - + @@ -122,7 +122,7 @@ - + diff --git a/web/js/controller.js b/web/js/controller.js index 3b7d7dc5..d0e336e7 100644 --- a/web/js/controller.js +++ b/web/js/controller.js @@ -301,11 +301,10 @@ ..._default, _uber: true, name: 'settings', - keyRelease: key => { - if (key === KEY.SETTINGS) { - !settings.ui.toggle() && setState(lastState) - } - }, + keyRelease: (() => { + settings.ui.onToggle = (o) => !o && setState(lastState); + return (key) => key === KEY.SETTINGS && settings.ui.toggle() + })(), menuReady: showMenuScreen }, diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js index 95b4ee13..c63a4b89 100644 --- a/web/js/gui/gui.js +++ b/web/js/gui/gui.js @@ -74,14 +74,18 @@ const gui = (() => { const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => { const state = { - br: null, shown: false, loading: false, title: title, } + const tHandlers = []; + onToggle && tHandlers.push(onToggle); + const _root = root || _create('div'); _root.classList.add('panel'); + gui.hide(_root); + const header = _create('div', (el) => el.classList.add('panel__header')); const _content = _create('div', (el) => { if (cc) { @@ -131,23 +135,21 @@ const gui = (() => { _title.innerText = state.loading ? `${state.title}...` : state.title; } - function toggle(show = true) { - state.shown = show; - - // hack not transparent jpeg corners :_; - show ? _root.parentElement.style.borderRadius = '0px' : - state.br ? _root.parentElement.style.borderRadius = state.br : - state.br = window.getComputedStyle(_root.parentElement).borderRadius - - onToggle && onToggle(state.shown, _root) - - state.shown ? gui.show(_root) : gui.hide(_root) - return state.shown; - } + const toggle = (() => { + let br = window.getComputedStyle(_root.parentElement).borderRadius; + return (force) => { + state.shown = force !== undefined ? force : !state.shown; + // hack for not transparent jpeg corners :_; + _root.parentElement.style.borderRadius = state.shown ? '0px' : br; + tHandlers.forEach(h => h?.(state.shown, _root)); + state.shown ? gui.show(_root) : gui.hide(_root) + } + })() return { contentEl: _content, isHidden: () => !state.shown, + onToggle: (fn) => tHandlers.push(fn), setContent, setLoad, toggle, diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js index 5fed80af..76c20c33 100644 --- a/web/js/settings/settings.js +++ b/web/js/settings/settings.js @@ -276,16 +276,6 @@ const settings = (() => { event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); }) - panel.toggle(false); - - const toggle = (() => { - let x = false; - return () => { - x = !x; - panel.toggle(x); - } - })(); - function _getType(value) { if (value === undefined) return option.undefined else if (Array.isArray(value)) return option.list @@ -334,7 +324,10 @@ const settings = (() => { import: _import, export: _export, ui: { - toggle, + set onToggle(fn) { + panel.onToggle(fn); + }, + toggle: () => panel.toggle(), }, set renderer(fn) { _renderer = fn; @@ -501,7 +494,7 @@ settings.renderer = (() => { .withDescription( 'Whether games should open in full-screen mode after starting up (excluding mobile devices)' ) - .add(gui.checkbox(k, onChange, value, 'Enbabled', 'settings__option-checkbox')) + .add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox')) .build() break; default: From 2aaf37b7667da0f41fef0c61ff5454d295e6f09c Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 17 Mar 2024 16:35:58 +0300 Subject: [PATCH 070/240] 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. --- pkg/config/config.yaml | 1 + pkg/config/shared.go | 7 ++++--- pkg/coordinator/coordinator.go | 6 ++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 39b695ea..57cb39de 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -59,6 +59,7 @@ coordinator: # HTTP(S) server config server: address: :8000 + cacheControl: "max-age=259200, must-revalidate" https: false # Letsencrypt or self cert config tls: diff --git a/pkg/config/shared.go b/pkg/config/shared.go index 026b79d3..ae99d289 100644 --- a/pkg/config/shared.go +++ b/pkg/config/shared.go @@ -30,9 +30,10 @@ 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 + Https bool + Tls struct { Address string Domain string HttpsKey string diff --git a/pkg/coordinator/coordinator.go b/pkg/coordinator/coordinator.go index bdb21067..cb80b739 100644 --- a/pkg/coordinator/coordinator.go +++ b/pkg/coordinator/coordinator.go @@ -92,6 +92,9 @@ 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 r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") { tpl := template.Must(template.ParseFiles(indexHTML)) handler(tpl, w, r) @@ -102,6 +105,9 @@ 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 r.URL.Path == "/" || strings.HasSuffix(r.URL.Path, "/index.html") { handler(indexTpl, w, r) return From 2bc64a3be827993b9be4b9c6b23faedcb7fab7da Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 17 Mar 2024 17:24:00 +0300 Subject: [PATCH 071/240] 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. --- web/index.html | 47 ++- web/js/api.js | 70 +++++ web/js/api/api.js | 68 ---- web/js/app.js | 541 ++++++++++++++++++++++++++++++++ web/js/controller.js | 468 ---------------------------- web/js/env.js | 212 +++++++------ web/js/event.js | 100 ++++++ web/js/event/event.js | 104 ------- web/js/gameList.js | 458 +++++++++++++-------------- web/js/gui.js | 259 ++++++++++++++++ web/js/gui/gui.js | 267 ---------------- web/js/gui/message.js | 46 --- web/js/init.js | 26 -- web/js/input/input.js | 119 +------ web/js/input/joystick.js | 524 ++++++++++++++++--------------- web/js/input/keyboard.js | 261 ++++++++-------- web/js/input/keys.js | 66 ++-- web/js/input/retropad.js | 98 ++++++ web/js/input/touch.js | 604 ++++++++++++++++++------------------ web/js/log.js | 60 ++-- web/js/message.js | 44 +++ web/js/network/ajax.js | 43 ++- web/js/network/network.js | 3 + web/js/network/socket.js | 94 +++--- web/js/network/webrtc.js | 321 ++++++++++--------- web/js/recording.js | 112 +++---- web/js/room.js | 141 +++++---- web/js/settings.js | 537 ++++++++++++++++++++++++++++++++ web/js/settings/opts.js | 16 - web/js/settings/settings.js | 525 ------------------------------- web/js/stats.js | 440 ++++++++++++++++++++++++++ web/js/stats/stats.js | 433 -------------------------- web/js/stream.js | 222 +++++++++++++ web/js/stream/stream.js | 216 ------------- web/js/utils.js | 84 +++-- web/js/workerManager.js | 273 ++++++++-------- 36 files changed, 3984 insertions(+), 3918 deletions(-) create mode 100644 web/js/api.js delete mode 100644 web/js/api/api.js create mode 100644 web/js/app.js delete mode 100644 web/js/controller.js create mode 100644 web/js/event.js delete mode 100644 web/js/event/event.js create mode 100644 web/js/gui.js delete mode 100644 web/js/gui/gui.js delete mode 100644 web/js/gui/message.js delete mode 100644 web/js/init.js create mode 100644 web/js/input/retropad.js create mode 100644 web/js/message.js create mode 100644 web/js/network/network.js create mode 100644 web/js/settings.js delete mode 100644 web/js/settings/opts.js delete mode 100644 web/js/settings/settings.js create mode 100644 web/js/stats.js delete mode 100644 web/js/stats/stats.js create mode 100644 web/js/stream.js delete mode 100644 web/js/stream/stream.js diff --git a/web/index.html b/web/index.html index 2446207e..61f24f13 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,7 @@ - + Cloud Retro @@ -49,7 +49,9 @@
- Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player), Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the clipboard) + Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player), + Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the + clipboard)
@@ -102,32 +104,23 @@
- - - - - - - - - - - - - - - - - - - - - - - - + - + {{if .Analytics.Inject}} diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 00000000..6b93264b --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,70 @@ +import {log} from 'log'; + +const endpoints = { + LATENCY_CHECK: 3, + INIT: 4, + INIT_WEBRTC: 100, + OFFER: 101, + ANSWER: 102, + ICE_CANDIDATE: 103, + GAME_START: 104, + GAME_QUIT: 105, + GAME_SAVE: 106, + GAME_LOAD: 107, + GAME_SET_PLAYER_INDEX: 108, + GAME_RECORDING: 110, + GET_WORKER_LIST: 111, + GAME_ERROR_NO_FREE_SLOTS: 112, + + APP_VIDEO_CHANGE: 150, +} + +/** + * Server API. + * + * Requires the actual api.transport implementation. + */ +export const api = { + set transport(t) { + transport = t; + }, + endpoint: endpoints, + decode: (b) => JSON.parse(decodeBytes(b)), + server: { + initWebrtc: () => packet(endpoints.INIT_WEBRTC), + sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))), + sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))), + latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id), + getWorkerList: () => packet(endpoints.GET_WORKER_LIST), + }, + game: { + load: () => packet(endpoints.GAME_LOAD), + save: () => packet(endpoints.GAME_SAVE), + setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), + start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, { + game_name: game, + room_id: roomId, + player_index: player, + record: record, + record_user: recordUser, + }), + toggleRecording: (active = false, userName = '') => + packet(endpoints.GAME_RECORDING, {active: active, user: userName}), + quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), + } +} + +let transport = { + send: (packet) => { + log.warn('Default transport is used! Change it with the api.transport variable.', packet) + } +} + +const packet = (type, payload, id) => { + const packet = {t: type}; + if (id !== undefined) packet.id = id; + if (payload !== undefined) packet.p = payload; + transport.send(packet); +} + +const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b)) diff --git a/web/js/api/api.js b/web/js/api/api.js deleted file mode 100644 index ad9c1322..00000000 --- a/web/js/api/api.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Server API. - * - * @version 1 - * - */ -const api = (() => { - const endpoints = Object.freeze({ - LATENCY_CHECK: 3, - INIT: 4, - INIT_WEBRTC: 100, - OFFER: 101, - ANSWER: 102, - ICE_CANDIDATE: 103, - GAME_START: 104, - GAME_QUIT: 105, - GAME_SAVE: 106, - GAME_LOAD: 107, - GAME_SET_PLAYER_INDEX: 108, - GAME_RECORDING: 110, - GET_WORKER_LIST: 111, - GAME_ERROR_NO_FREE_SLOTS: 112, - - APP_VIDEO_CHANGE: 150, - }); - - const packet = (type, payload, id) => { - const packet = {t: type}; - if (id !== undefined) packet.id = id; - if (payload !== undefined) packet.p = payload; - - socket.send(packet); - }; - - const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b)) - - return Object.freeze({ - endpoint: endpoints, - decode: (b) => JSON.parse(decodeBytes(b)), - server: - { - initWebrtc: () => packet(endpoints.INIT_WEBRTC), - sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))), - sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))), - latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id), - getWorkerList: () => packet(endpoints.GET_WORKER_LIST), - }, - game: - { - load: () => packet(endpoints.GAME_LOAD), - save: () => packet(endpoints.GAME_SAVE), - setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), - start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, { - game_name: game, - room_id: roomId, - player_index: player, - record: record, - record_user: recordUser, - }), - toggleRecording: (active = false, userName = '') => - packet(endpoints.GAME_RECORDING, { - active: active, - user: userName, - }), - quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), - } - }) -})(socket); diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 00000000..5e951d26 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,541 @@ +import {api} from 'api'; +import { + pub, + sub, + APP_VIDEO_CHANGED, + AXIS_CHANGED, + CONTROLLER_UPDATED, + DPAD_TOGGLE, + GAME_ERROR_NO_FREE_SLOTS, + GAME_LOADED, + GAME_PLAYER_IDX, + GAME_PLAYER_IDX_SET, + GAME_ROOM_AVAILABLE, + GAME_SAVED, + GAMEPAD_CONNECTED, + GAMEPAD_DISCONNECTED, + HELP_OVERLAY_TOGGLED, + KEY_PRESSED, + KEY_RELEASED, + LATENCY_CHECK_REQUESTED, + MENU_HANDLER_ATTACHED, + MESSAGE, + RECORDING_STATUS_CHANGED, + RECORDING_TOGGLED, + SETTINGS_CHANGED, + STATS_TOGGLE, + WEBRTC_CONNECTION_CLOSED, + WEBRTC_CONNECTION_READY, + WEBRTC_ICE_CANDIDATE_FOUND, + WEBRTC_ICE_CANDIDATE_RECEIVED, + WEBRTC_ICE_CANDIDATES_FLUSH, + WEBRTC_NEW_CONNECTION, + WEBRTC_SDP_ANSWER, + WEBRTC_SDP_OFFER, + WORKER_LIST_FETCHED +} from 'event'; +import {gui} from 'gui'; +import {keyboard, KEY, joystick, retropad, touch} from 'input'; +import {log} from 'log'; +import {opts, settings} from 'settings'; +import {socket, webrtc} from 'network'; +import {debounce} from 'utils'; + +import {gameList} from './gameList.js?v=3'; +import {message} from './message.js?v=3'; +import {recording} from './recording.js?v=3'; +import {room} from './room.js?v=3'; +import {stats} from './stats.js?v=3'; +import {stream} from './stream.js?v=3'; +import {workerManager} from "./workerManager.js?v=3"; + +// application state +let state; +let lastState; + +// first user interaction +let interacted = false; + +const menuScreen = document.getElementById('menu-screen'); +const helpOverlay = document.getElementById('help-overlay'); +const playerIndex = document.getElementById('playeridx'); + +// keymap +const keyButtons = {}; +Object.keys(KEY).forEach(button => { + keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`); +}); + +/** + * State machine transition. + * @param newState A new state strictly from app.state.* + * @example + * setState(app.state.eden) + */ +const setState = (newState = app.state.eden) => { + if (newState === state) return; + + const prevState = state; + + // keep the current state intact for one of the "uber" states + if (state && state._uber) { + // if we are done with the uber state + if (lastState === newState) state = newState; + lastState = newState; + } else { + lastState = state + state = newState; + } + + if (log.level === log.DEBUG) { + const previous = prevState ? prevState.name : '???'; + const current = state ? state.name : '???'; + const kept = lastState ? lastState.name : '???'; + + log.debug(`[state] ${previous} -> ${current} [${kept}]`); + } +}; + +const onGameRoomAvailable = () => { + // room is ready +}; + +const onConnectionReady = () => { + // start a game right away or show the menu + if (room.getId()) { + startGame(); + } else { + state.menuReady(); + } +}; + +const onLatencyCheck = async (data) => { + message.show('Connecting to fastest server...'); + const servers = await workerManager.checkLatencies(data); + const latencies = Object.assign({}, ...servers); + log.info('[ping] <->', latencies); + api.server.latencyCheck(data.packetId, latencies); +}; + +const helpScreen = { + // don't call $ if holding the button + shown: false, + // use function () if you need "this" + show: function (show, event) { + if (this.shown === show) return; + + const isGameScreen = state === app.state.game + if (isGameScreen) { + stream.toggle(!show); + } else { + gui.toggle(menuScreen, !show); + } + + gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen); + gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen); + + gui.toggle(helpOverlay, show) + + this.shown = show; + + if (event) pub(HELP_OVERLAY_TOGGLED, {shown: show}); + } +}; + +const showMenuScreen = () => { + log.debug('[control] loading menu screen'); + + stream.toggle(false); + gui.hide(keyButtons[KEY.SAVE]); + gui.hide(keyButtons[KEY.LOAD]); + + gameList.show(); + gui.show(menuScreen); + + setState(app.state.menu); +}; + +const startGame = () => { + if (!webrtc.isConnected()) { + message.show('Game cannot load. Please refresh'); + return; + } + + if (!webrtc.isInputReady()) { + message.show('Game is not ready yet. Please wait'); + return; + } + + log.info('[control] game start'); + + setState(app.state.game); + + stream.play() + + api.game.start( + gameList.selected, + room.getId(), + recording.isActive(), + recording.getUser(), + +playerIndex.value - 1, + ); + + // clear menu screen + retropad.poll.disable(); + gui.hide(menuScreen); + stream.toggle(true); + stream.forceFullscreenMaybe(); + gui.show(keyButtons[KEY.SAVE]); + gui.show(keyButtons[KEY.LOAD]); + // end clear + retropad.poll.enable(); +}; + +const saveGame = debounce(() => api.game.save(), 1000); +const loadGame = debounce(() => api.game.load(), 1000); + +const onMessage = (m) => { + const {id, t, p: payload} = m; + switch (t) { + case api.endpoint.INIT: + pub(WEBRTC_NEW_CONNECTION, payload); + break; + case api.endpoint.OFFER: + pub(WEBRTC_SDP_OFFER, {sdp: payload}); + break; + case api.endpoint.ICE_CANDIDATE: + pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); + break; + case api.endpoint.GAME_START: + if (payload.av) { + pub(APP_VIDEO_CHANGED, payload.av) + } + pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId}); + break; + case api.endpoint.GAME_SAVE: + pub(GAME_SAVED); + break; + case api.endpoint.GAME_LOAD: + pub(GAME_LOADED); + break; + case api.endpoint.GAME_SET_PLAYER_INDEX: + pub(GAME_PLAYER_IDX_SET, payload); + break; + case api.endpoint.GET_WORKER_LIST: + pub(WORKER_LIST_FETCHED, payload); + break; + case api.endpoint.LATENCY_CHECK: + pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload}); + break; + case api.endpoint.GAME_RECORDING: + pub(RECORDING_STATUS_CHANGED, payload); + break; + case api.endpoint.GAME_ERROR_NO_FREE_SLOTS: + pub(GAME_ERROR_NO_FREE_SLOTS); + break; + case api.endpoint.APP_VIDEO_CHANGE: + pub(APP_VIDEO_CHANGED, {...payload}) + break; + } +} + +const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; + +// pre-state key press handler +const onKeyPress = (data) => { + const button = keyButtons[data.key]; + + if (_dpadArrowKeys.includes(data.key)) { + button.classList.add('dpad-pressed'); + } else { + if (button) button.classList.add('pressed'); + } + + if (state !== app.state.settings) { + if (KEY.HELP === data.key) helpScreen.show(true, event); + } + + state.keyPress(data.key); +}; + +// pre-state key release handler +const onKeyRelease = data => { + const button = keyButtons[data.key]; + + if (_dpadArrowKeys.includes(data.key)) { + button.classList.remove('dpad-pressed'); + } else { + if (button) button.classList.remove('pressed'); + } + + if (state !== app.state.settings) { + if (KEY.HELP === data.key) helpScreen.show(false, event); + } + + // maybe move it somewhere + if (!interacted) { + // unmute when there is user interaction + stream.audio.mute(false); + interacted = true; + } + + // change app state if settings + if (KEY.SETTINGS === data.key) setState(app.state.settings); + + state.keyRelease(data.key); +}; + +const updatePlayerIndex = (idx, not_game = false) => { + playerIndex.value = idx + 1; + !not_game && api.game.setPlayerIndex(idx); +}; + +// noop function for the state +const _nil = () => ({/*_*/}) + +const onAxisChanged = (data) => { + // maybe move it somewhere + if (!interacted) { + // unmute when there is user interaction + stream.audio.mute(false); + interacted = true; + } + + state.axisChanged(data.id, data.value); +}; + +const handleToggle = () => { + const toggle = document.getElementById('dpad-toggle'); + toggle.checked = !toggle.checked; + pub(DPAD_TOGGLE, {checked: toggle.checked}); +}; + +const handleRecording = (data) => { + const {recording, userName} = data; + api.game.toggleRecording(recording, userName); +} + +const handleRecordingStatus = (data) => { + if (data === 'ok') { + message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`) + if (recording.isActive()) { + recording.setIndicator(true) + } + } else { + message.show(`Recording failed ):`) + recording.setIndicator(false) + } + log.debug("recording is ", recording.isActive()) +} + +const _default = { + name: 'default', + axisChanged: _nil, + keyPress: _nil, + keyRelease: _nil, + menuReady: _nil, +} +const app = { + state: { + eden: { + ..._default, + name: 'eden', + menuReady: showMenuScreen + }, + + settings: { + ..._default, + _uber: true, + name: 'settings', + keyRelease: (() => { + settings.ui.onToggle = (o) => !o && setState(lastState); + return (key) => key === KEY.SETTINGS && settings.ui.toggle() + })(), + menuReady: showMenuScreen + }, + + menu: { + ..._default, + name: 'menu', + axisChanged: (id, val) => id === 1 && gameList.scroll(val < -.5 ? -1 : val > .5 ? 1 : 0), + keyPress: (key) => { + switch (key) { + case KEY.UP: + case KEY.DOWN: + gameList.scroll(key === KEY.UP ? -1 : 1) + break; + } + }, + keyRelease: (key) => { + switch (key) { + case KEY.UP: + case KEY.DOWN: + gameList.scroll(0); + break; + case KEY.JOIN: + case KEY.A: + case KEY.B: + case KEY.X: + case KEY.Y: + case KEY.START: + case KEY.SELECT: + startGame(); + break; + case KEY.QUIT: + message.show('You are already in menu screen!'); + break; + case KEY.LOAD: + message.show('Loading the game.'); + break; + case KEY.SAVE: + message.show('Saving the game.'); + break; + case KEY.STATS: + pub(STATS_TOGGLE); + break; + case KEY.SETTINGS: + break; + case KEY.DTOGGLE: + handleToggle(); + break; + } + }, + }, + + game: { + ..._default, + name: 'game', + axisChanged: (id, value) => retropad.setAxisChanged(id, value), + keyPress: key => retropad.setKeyState(key, true), + keyRelease: function (key) { + retropad.setKeyState(key, false); + + switch (key) { + case KEY.JOIN: // or SHARE + // save when click share + saveGame(); + room.copyToClipboard(); + message.show('Shared link copied to the clipboard!'); + break; + case KEY.SAVE: + saveGame(); + break; + case KEY.LOAD: + loadGame(); + break; + case KEY.FULL: + stream.video.toggleFullscreen(); + break; + case KEY.PAD1: + updatePlayerIndex(0); + break; + case KEY.PAD2: + updatePlayerIndex(1); + break; + case KEY.PAD3: + updatePlayerIndex(2); + break; + case KEY.PAD4: + updatePlayerIndex(3); + break; + case KEY.QUIT: + retropad.poll.disable(); + api.game.quit(room.getId()); + room.reset(); + window.location = window.location.pathname; + break; + case KEY.STATS: + pub(STATS_TOGGLE); + break; + case KEY.DTOGGLE: + handleToggle(); + break; + } + }, + } + } +}; + +// subscriptions +sub(MESSAGE, onMessage); + +sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2); +sub(GAME_SAVED, () => message.show('Saved')); +sub(GAME_LOADED, () => message.show('Loaded')); +sub(GAME_PLAYER_IDX, data => { + updatePlayerIndex(+data.index, state !== app.state.game); +}); +sub(GAME_PLAYER_IDX_SET, idx => { + if (!isNaN(+idx)) message.show(+idx + 1); +}); +sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500)); +sub(WEBRTC_NEW_CONNECTION, (data) => { + workerManager.whoami(data.wid); + webrtc.onData = (x) => onMessage(api.decode(x.data)) + webrtc.start(data.ice); + api.server.initWebrtc() + gameList.set(data.games); +}); +sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate)); +sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp)); +sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el())); +sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)); +sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); +sub(WEBRTC_CONNECTION_READY, onConnectionReady); +sub(WEBRTC_CONNECTION_CLOSED, () => { + retropad.poll.disable(); + webrtc.stop(); +}); +sub(LATENCY_CHECK_REQUESTED, onLatencyCheck); +sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected')); +sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected')); +// touch stuff +sub(MENU_HANDLER_ATTACHED, (data) => { + menuScreen.addEventListener(data.event, data.handler, {passive: true}); +}); +sub(KEY_PRESSED, onKeyPress); +sub(KEY_RELEASED, onKeyRelease); +sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); +sub(AXIS_CHANGED, onAxisChanged); +sub(CONTROLLER_UPDATED, data => webrtc.input(data)); +// recording +sub(RECORDING_TOGGLED, handleRecording); +sub(RECORDING_STATUS_CHANGED, handleRecordingStatus); + +sub(SETTINGS_CHANGED, () => { + const newValue = settings.get()[opts.LOG_LEVEL]; + if (newValue !== log.level) { + log.level = newValue; + } +}); + +// initial app state +setState(app.state.eden); + +settings.init(); + +(() => { + let lvl = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); + // migrate old log level options + // !to remove at some point + if (isNaN(lvl)) { + console.warn( + `The log value [${lvl}] is not supported! ` + + `The default value [debug] will be used instead.`); + settings.set(opts.LOG_LEVEL, `${log.DEFAULT}`) + lvl = log.DEFAULT + } + log.level = lvl +})(); + +keyboard.init(); +joystick.init(); +touch.init(); +stream.init(); + +let [roomId, zone] = room.loadMaybe(); +// find worker id if present +const wid = new URLSearchParams(document.location.search).get('wid'); +// if from URL -> start game immediately! +socket.init(roomId, wid, zone); +api.transport = socket; diff --git a/web/js/controller.js b/web/js/controller.js deleted file mode 100644 index d0e336e7..00000000 --- a/web/js/controller.js +++ /dev/null @@ -1,468 +0,0 @@ -/** - * App controller module. - * @version 1 - */ -(() => { - // application state - let state; - let lastState; - - // first user interaction - let interacted = false; - - const menuScreen = document.getElementById('menu-screen'); - const helpOverlay = document.getElementById('help-overlay'); - const playerIndex = document.getElementById('playeridx'); - - // keymap - const keyButtons = {}; - Object.keys(KEY).forEach(button => { - keyButtons[KEY[button]] = document.getElementById(`btn-${KEY[button]}`); - }); - - /** - * State machine transition. - * @param newState A new state strictly from app.state.* - * @example - * setState(app.state.eden) - */ - const setState = (newState = app.state.eden) => { - if (newState === state) return; - - const prevState = state; - - // keep the current state intact for one of the "uber" states - if (state && state._uber) { - // if we are done with the uber state - if (lastState === newState) state = newState; - lastState = newState; - } else { - lastState = state - state = newState; - } - - if (log.level === log.DEBUG) { - const previous = prevState ? prevState.name : '???'; - const current = state ? state.name : '???'; - const kept = lastState ? lastState.name : '???'; - - log.debug(`[state] ${previous} -> ${current} [${kept}]`); - } - }; - - const onGameRoomAvailable = () => { - // room is ready - }; - - const onConnectionReady = () => { - // start a game right away or show the menu - if (room.getId()) { - startGame(); - } else { - state.menuReady(); - } - }; - - const onLatencyCheck = async (data) => { - message.show('Connecting to fastest server...'); - const servers = await workerManager.checkLatencies(data); - const latencies = Object.assign({}, ...servers); - log.info('[ping] <->', latencies); - api.server.latencyCheck(data.packetId, latencies); - }; - - const helpScreen = { - // don't call $ if holding the button - shown: false, - // use function () if you need "this" - show: function (show, event) { - if (this.shown === show) return; - - const isGameScreen = state === app.state.game - if (isGameScreen) { - stream.toggle(!show); - } else { - gui.toggle(menuScreen, !show); - } - - gui.toggle(keyButtons[KEY.SAVE], show || isGameScreen); - gui.toggle(keyButtons[KEY.LOAD], show || isGameScreen); - - gui.toggle(helpOverlay, show) - - this.shown = show; - - if (event) event.pub(HELP_OVERLAY_TOGGLED, {shown: show}); - } - }; - - const showMenuScreen = () => { - log.debug('[control] loading menu screen'); - - stream.toggle(false); - gui.hide(keyButtons[KEY.SAVE]); - gui.hide(keyButtons[KEY.LOAD]); - - gameList.show(); - gui.show(menuScreen); - - setState(app.state.menu); - }; - - const startGame = () => { - if (!webrtc.isConnected()) { - message.show('Game cannot load. Please refresh'); - return; - } - - if (!webrtc.isInputReady()) { - message.show('Game is not ready yet. Please wait'); - return; - } - - log.info('[control] game start'); - - setState(app.state.game); - - stream.play() - - api.game.start( - gameList.selected, - room.getId(), - recording.isActive(), - recording.getUser(), - +playerIndex.value - 1, - ); - - // clear menu screen - input.poll.disable(); - gui.hide(menuScreen); - stream.toggle(true); - stream.forceFullscreenMaybe(); - gui.show(keyButtons[KEY.SAVE]); - gui.show(keyButtons[KEY.LOAD]); - // end clear - input.poll.enable(); - }; - - const saveGame = utils.debounce(() => api.game.save(), 1000); - const loadGame = utils.debounce(() => api.game.load(), 1000); - - const onMessage = (m) => { - const {id, t, p: payload} = m; - switch (t) { - case api.endpoint.INIT: - event.pub(WEBRTC_NEW_CONNECTION, payload); - break; - case api.endpoint.OFFER: - event.pub(WEBRTC_SDP_OFFER, {sdp: payload}); - break; - case api.endpoint.ICE_CANDIDATE: - event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); - break; - case api.endpoint.GAME_START: - if (payload.av) { - event.pub(APP_VIDEO_CHANGED, payload.av) - } - event.pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId}); - break; - case api.endpoint.GAME_SAVE: - event.pub(GAME_SAVED); - break; - case api.endpoint.GAME_LOAD: - event.pub(GAME_LOADED); - break; - case api.endpoint.GAME_SET_PLAYER_INDEX: - event.pub(GAME_PLAYER_IDX_SET, payload); - break; - case api.endpoint.GET_WORKER_LIST: - event.pub(WORKER_LIST_FETCHED, payload); - break; - case api.endpoint.LATENCY_CHECK: - event.pub(LATENCY_CHECK_REQUESTED, {packetId: id, addresses: payload}); - break; - case api.endpoint.GAME_RECORDING: - event.pub(RECORDING_STATUS_CHANGED, payload); - break; - case api.endpoint.GAME_ERROR_NO_FREE_SLOTS: - event.pub(GAME_ERROR_NO_FREE_SLOTS); - break; - case api.endpoint.APP_VIDEO_CHANGE: - event.pub(APP_VIDEO_CHANGED, {...payload}) - break; - } - } - - const _dpadArrowKeys = [KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT]; - - // pre-state key press handler - const onKeyPress = (data) => { - const button = keyButtons[data.key]; - - if (_dpadArrowKeys.includes(data.key)) { - button.classList.add('dpad-pressed'); - } else { - if (button) button.classList.add('pressed'); - } - - if (state !== app.state.settings) { - if (KEY.HELP === data.key) helpScreen.show(true, event); - } - - state.keyPress(data.key); - }; - - // pre-state key release handler - const onKeyRelease = data => { - const button = keyButtons[data.key]; - - if (_dpadArrowKeys.includes(data.key)) { - button.classList.remove('dpad-pressed'); - } else { - if (button) button.classList.remove('pressed'); - } - - if (state !== app.state.settings) { - if (KEY.HELP === data.key) helpScreen.show(false, event); - } - - // maybe move it somewhere - if (!interacted) { - // unmute when there is user interaction - stream.audio.mute(false); - interacted = true; - } - - // change app state if settings - if (KEY.SETTINGS === data.key) setState(app.state.settings); - - state.keyRelease(data.key); - }; - - const updatePlayerIndex = (idx, not_game = false) => { - playerIndex.value = idx + 1; - !not_game && api.game.setPlayerIndex(idx); - }; - - // noop function for the state - const _nil = () => ({/*_*/}) - - const onAxisChanged = (data) => { - // maybe move it somewhere - if (!interacted) { - // unmute when there is user interaction - stream.audio.mute(false); - interacted = true; - } - - state.axisChanged(data.id, data.value); - }; - - const handleToggle = () => { - const toggle = document.getElementById('dpad-toggle'); - toggle.checked = !toggle.checked; - event.pub(DPAD_TOGGLE, {checked: toggle.checked}); - }; - - const handleRecording = (data) => { - const {recording, userName} = data; - api.game.toggleRecording(recording, userName); - } - - const handleRecordingStatus = (data) => { - if (data === 'ok') { - message.show(`Recording ${recording.isActive() ? 'on' : 'off'}`) - if (recording.isActive()) { - recording.setIndicator(true) - } - } else { - message.show(`Recording failed ):`) - recording.setIndicator(false) - } - log.debug("recording is ", recording.isActive()) - } - - const _default = { - name: 'default', - axisChanged: _nil, - keyPress: _nil, - keyRelease: _nil, - menuReady: _nil, - } - const app = { - state: { - eden: { - ..._default, - name: 'eden', - menuReady: showMenuScreen - }, - - settings: { - ..._default, - _uber: true, - name: 'settings', - keyRelease: (() => { - settings.ui.onToggle = (o) => !o && setState(lastState); - return (key) => key === KEY.SETTINGS && settings.ui.toggle() - })(), - menuReady: showMenuScreen - }, - - menu: { - ..._default, - name: 'menu', - axisChanged: (id, val) => id === 1 && gameList.scroll(val < -.5 ? -1 : val > .5 ? 1 : 0), - keyPress: (key) => { - switch (key) { - case KEY.UP: - case KEY.DOWN: - gameList.scroll(key === KEY.UP ? -1 : 1) - break; - } - }, - keyRelease: (key) => { - switch (key) { - case KEY.UP: - case KEY.DOWN: - gameList.scroll(0); - break; - case KEY.JOIN: - case KEY.A: - case KEY.B: - case KEY.X: - case KEY.Y: - case KEY.START: - case KEY.SELECT: - startGame(); - break; - case KEY.QUIT: - message.show('You are already in menu screen!'); - break; - case KEY.LOAD: - message.show('Loading the game.'); - break; - case KEY.SAVE: - message.show('Saving the game.'); - break; - case KEY.STATS: - event.pub(STATS_TOGGLE); - break; - case KEY.SETTINGS: - break; - case KEY.DTOGGLE: - handleToggle(); - break; - } - }, - }, - - game: { - ..._default, - name: 'game', - axisChanged: (id, value) => input.setAxisChanged(id, value), - keyPress: key => input.setKeyState(key, true), - keyRelease: function (key) { - input.setKeyState(key, false); - - switch (key) { - case KEY.JOIN: // or SHARE - // save when click share - saveGame(); - room.copyToClipboard(); - message.show('Shared link copied to the clipboard!'); - break; - case KEY.SAVE: - saveGame(); - break; - case KEY.LOAD: - loadGame(); - break; - case KEY.FULL: - stream.video.toggleFullscreen(); - break; - case KEY.PAD1: - updatePlayerIndex(0); - break; - case KEY.PAD2: - updatePlayerIndex(1); - break; - case KEY.PAD3: - updatePlayerIndex(2); - break; - case KEY.PAD4: - updatePlayerIndex(3); - break; - case KEY.QUIT: - input.poll.disable(); - api.game.quit(room.getId()); - room.reset(); - window.location = window.location.pathname; - break; - case KEY.STATS: - event.pub(STATS_TOGGLE); - break; - case KEY.DTOGGLE: - handleToggle(); - break; - } - }, - } - } - }; - - // subscriptions - event.sub(MESSAGE, onMessage); - - event.sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2); - event.sub(GAME_SAVED, () => message.show('Saved')); - event.sub(GAME_LOADED, () => message.show('Loaded')); - event.sub(GAME_PLAYER_IDX, data => { - updatePlayerIndex(+data.index, state !== app.state.game); - }); - event.sub(GAME_PLAYER_IDX_SET, idx => { - if (!isNaN(+idx)) message.show(+idx + 1); - }); - event.sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500)); - event.sub(WEBRTC_NEW_CONNECTION, (data) => { - workerManager.whoami(data.wid); - webrtc.onData = (x) => onMessage(api.decode(x.data)) - webrtc.start(data.ice); - api.server.initWebrtc() - gameList.set(data.games); - }); - event.sub(WEBRTC_ICE_CANDIDATE_FOUND, (data) => api.server.sendIceCandidate(data.candidate)); - event.sub(WEBRTC_SDP_ANSWER, (data) => api.server.sendSdp(data.sdp)); - event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el())); - event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)); - event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); - event.sub(WEBRTC_CONNECTION_READY, onConnectionReady); - event.sub(WEBRTC_CONNECTION_CLOSED, () => { - input.poll.disable(); - webrtc.stop(); - }); - event.sub(LATENCY_CHECK_REQUESTED, onLatencyCheck); - event.sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected')); - event.sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected')); - // touch stuff - event.sub(MENU_HANDLER_ATTACHED, (data) => { - menuScreen.addEventListener(data.event, data.handler, {passive: true}); - }); - event.sub(KEY_PRESSED, onKeyPress); - event.sub(KEY_RELEASED, onKeyRelease); - event.sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); - event.sub(AXIS_CHANGED, onAxisChanged); - event.sub(CONTROLLER_UPDATED, data => webrtc.input(data)); - // recording - event.sub(RECORDING_TOGGLED, handleRecording); - event.sub(RECORDING_STATUS_CHANGED, handleRecordingStatus); - - event.sub(SETTINGS_CHANGED, () => { - const newValue = settings.get()[opts.LOG_LEVEL]; - if (newValue !== log.level) { - log.level = newValue; - } - }); - - // initial app state - setState(app.state.eden); -})(api, document, event, env, gameList, input, KEY, log, message, recording, room, settings, socket, stats, stream, utils, webrtc, workerManager); diff --git a/web/js/env.js b/web/js/env.js index a8f79ebd..ef1ef3d5 100644 --- a/web/js/env.js +++ b/web/js/env.js @@ -1,119 +1,117 @@ -const env = (() => { - // UI - const page = document.getElementsByTagName('html')[0]; - const gameBoy = document.getElementById('gamebody'); - const sourceLink = document.getElementsByClassName('source')[0]; +// UI +const page = document.getElementsByTagName('html')[0]; +const gameBoy = document.getElementById('gamebody'); +const sourceLink = document.getElementsByClassName('source')[0]; - let isLayoutSwitched = false; +let isLayoutSwitched = false; - // Window rerender / rotate screen if needed - const fixScreenLayout = () => { - let pw = getWidth(page), - ph = getHeight(page), - targetWidth = Math.round(pw * 0.9 / 2) * 2, - targetHeight = Math.round(ph * 0.9 / 2) * 2; +// Window rerender / rotate screen if needed +const fixScreenLayout = () => { + let pw = getWidth(page), + ph = getHeight(page), + targetWidth = Math.round(pw * 0.9 / 2) * 2, + targetHeight = Math.round(ph * 0.9 / 2) * 2; - // save page rotation - isLayoutSwitched = isPortrait(); + // save page rotation + isLayoutSwitched = isPortrait(); - rescaleGameBoy(targetWidth, targetHeight); + rescaleGameBoy(targetWidth, targetHeight); - sourceLink.style['bottom'] = isLayoutSwitched ? 0 : ''; - if (isLayoutSwitched) { - sourceLink.style.removeProperty('right'); - sourceLink.style['left'] = 5; - } else { - sourceLink.style.removeProperty('left'); - sourceLink.style['right'] = 5; + sourceLink.style['bottom'] = isLayoutSwitched ? 0 : ''; + if (isLayoutSwitched) { + sourceLink.style.removeProperty('right'); + sourceLink.style['left'] = 5; + } else { + sourceLink.style.removeProperty('left'); + sourceLink.style['right'] = 5; + } + sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : ''; + sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : ''; +}; + +const rescaleGameBoy = (targetWidth, targetHeight) => { + const transformations = ['translate(-50%, -50%)']; + + if (isLayoutSwitched) { + transformations.push('rotate(90deg)'); + [targetWidth, targetHeight] = [targetHeight, targetWidth] + } + + // scale, fit to target size + const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy)); + transformations.push(`scale(${scale})`); + + gameBoy.style['transform'] = transformations.join(' '); +} + +const getOS = () => { + // linux? ios? + let OSName = 'unknown'; + if (navigator.appVersion.indexOf('Win') !== -1) OSName = 'win'; + else if (navigator.appVersion.indexOf('Mac') !== -1) OSName = 'mac'; + else if (navigator.userAgent.indexOf('Linux') !== -1) OSName = 'linux'; + else if (navigator.userAgent.indexOf('Android') !== -1) OSName = 'android'; + return OSName; +}; + +const getBrowser = () => { + let browserName = 'unknown'; + if (navigator.userAgent.indexOf('Firefox') !== -1) browserName = 'firefox'; + if (navigator.userAgent.indexOf('Chrome') !== -1) browserName = 'chrome'; + if (navigator.userAgent.indexOf('Edge') !== -1) browserName = 'edge'; + if (navigator.userAgent.indexOf('Version/') !== -1) browserName = 'safari'; + if (navigator.userAgent.indexOf('UCBrowser') !== -1) browserName = 'uc'; + return browserName; +}; + +const isPortrait = () => getWidth(page) < getHeight(page); + +const toggleFullscreen = (enable, element) => { + const el = enable ? element : document; + + if (enable) { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.mozRequestFullScreen) { /* Firefox */ + el.mozRequestFullScreen(); + } else if (el.webkitRequestFullscreen) { /* Chrome, Safari and Opera */ + el.webkitRequestFullscreen(); + } else if (el.msRequestFullscreen) { /* IE/Edge */ + el.msRequestFullscreen(); } - sourceLink.style['transform'] = isLayoutSwitched ? 'rotate(-90deg)' : ''; - sourceLink.style['transform-origin'] = isLayoutSwitched ? 'left top' : ''; - }; - - const rescaleGameBoy = (targetWidth, targetHeight) => { - const transformations = ['translate(-50%, -50%)']; - - if (isLayoutSwitched) { - transformations.push('rotate(90deg)'); - [targetWidth, targetHeight] = [targetHeight, targetWidth] + } else { + if (el.exitFullscreen) { + el.exitFullscreen(); + } else if (el.mozCancelFullScreen) { /* Firefox */ + el.mozCancelFullScreen(); + } else if (el.webkitExitFullscreen) { /* Chrome, Safari and Opera */ + el.webkitExitFullscreen(); + } else if (el.msExitFullscreen) { /* IE/Edge */ + el.msExitFullscreen(); } - - // scale, fit to target size - const scale = Math.min(targetWidth / getWidth(gameBoy), targetHeight / getHeight(gameBoy)); - transformations.push(`scale(${scale})`); - - gameBoy.style['transform'] = transformations.join(' '); } +}; - const getOS = () => { - // linux? ios? - let OSName = 'unknown'; - if (navigator.appVersion.indexOf('Win') !== -1) OSName = 'win'; - else if (navigator.appVersion.indexOf('Mac') !== -1) OSName = 'mac'; - else if (navigator.userAgent.indexOf('Linux') !== -1) OSName = 'linux'; - else if (navigator.userAgent.indexOf('Android') !== -1) OSName = 'android'; - return OSName; - }; +function getHeight(el) { + return parseFloat(getComputedStyle(el, null).height.replace("px", "")); +} - const getBrowser = () => { - let browserName = 'unknown'; - if (navigator.userAgent.indexOf('Firefox') !== -1) browserName = 'firefox'; - if (navigator.userAgent.indexOf('Chrome') !== -1) browserName = 'chrome'; - if (navigator.userAgent.indexOf('Edge') !== -1) browserName = 'edge'; - if (navigator.userAgent.indexOf('Version/') !== -1) browserName = 'safari'; - if (navigator.userAgent.indexOf('UCBrowser') !== -1) browserName = 'uc'; - return browserName; - }; +function getWidth(el) { + return parseFloat(getComputedStyle(el, null).width.replace("px", "")); +} - const isPortrait = () => getWidth(page) < getHeight(page); +window.addEventListener('resize', fixScreenLayout); +window.addEventListener('orientationchange', fixScreenLayout); +document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false); - const toggleFullscreen = (enable, element) => { - const el = enable ? element : document; - - if (enable) { - if (el.requestFullscreen) { - el.requestFullscreen(); - } else if (el.mozRequestFullScreen) { /* Firefox */ - el.mozRequestFullScreen(); - } else if (el.webkitRequestFullscreen) { /* Chrome, Safari and Opera */ - el.webkitRequestFullscreen(); - } else if (el.msRequestFullscreen) { /* IE/Edge */ - el.msRequestFullscreen(); - } - } else { - if (el.exitFullscreen) { - el.exitFullscreen(); - } else if (el.mozCancelFullScreen) { /* Firefox */ - el.mozCancelFullScreen(); - } else if (el.webkitExitFullscreen) { /* Chrome, Safari and Opera */ - el.webkitExitFullscreen(); - } else if (el.msExitFullscreen) { /* IE/Edge */ - el.msExitFullscreen(); - } - } - }; - - function getHeight(el) { - return parseFloat(getComputedStyle(el, null).height.replace("px", "")); - } - - function getWidth(el) { - return parseFloat(getComputedStyle(el, null).width.replace("px", "")); - } - - window.addEventListener('resize', fixScreenLayout); - window.addEventListener('orientationchange', fixScreenLayout); - document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false); - - return { - getOs: getOS, - getBrowser: getBrowser, - isMobileDevice: () => /Mobi|Android|iPhone/i.test(navigator.userAgent), - display: () => ({ - isPortrait: isPortrait, - toggleFullscreen: toggleFullscreen, - fixScreenLayout: fixScreenLayout, - isLayoutSwitched: isLayoutSwitched - }) - } -})(document, log, navigator, screen, window); +export const env = { + getOs: getOS, + getBrowser: getBrowser, + isMobileDevice: () => /Mobi|Android|iPhone/i.test(navigator.userAgent), + display: () => ({ + isPortrait: isPortrait, + toggleFullscreen: toggleFullscreen, + fixScreenLayout: fixScreenLayout, + isLayoutSwitched: isLayoutSwitched + }) +} diff --git a/web/js/event.js b/web/js/event.js new file mode 100644 index 00000000..df189044 --- /dev/null +++ b/web/js/event.js @@ -0,0 +1,100 @@ +/** + * Event publishing / subscribe module. + * Just a simple observer pattern. + */ + +const topics = {}; + +// internal listener index +let _index = 0; + +/** + * Subscribes onto some event. + * + * @param topic The name of the event. + * @param listener A callback function to call during the event. + * @param order A number in a queue of event handlers to run callback in ordered manner. + * @returns {{unsub: unsub}} The function to remove this subscription. + * @example + * const sub01 = event.sub('rapture', () => {a}, 1) + * ... + * sub01.unsub() + */ +export const sub = (topic, listener, order = undefined) => { + if (!topics[topic]) topics[topic] = {}; + // order index * big pad + next internal index (e.g. 1*100+1=101) + // use some arbitrary big number to not overlap with non-ordered + let i = (order !== undefined ? order * 1000000 : 0) + _index++; + topics[topic][i] = listener; + return { + unsub: () => { + delete topics[topic][i] + } + } +} + +/** + * Publishes some event for handling. + * + * @param topic The name of the event. + * @param data Additional data for the event handling. + * Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data). + * @example + * event.pub('rapture', {time: now()}) + */ +export const pub = (topic, data) => { + if (!topics[topic]) return; + Object.keys(topics[topic]).forEach((ls) => { + topics[topic][ls](data !== undefined ? data : {}) + }); +} + +// events +export const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; +export const PING_REQUEST = 'pingRequest'; +export const PING_RESPONSE = 'pingResponse'; + +export const WORKER_LIST_FETCHED = 'workerListFetched'; + +export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; +export const GAME_SAVED = 'gameSaved'; +export const GAME_LOADED = 'gameLoaded'; +export const GAME_PLAYER_IDX = 'gamePlayerIndex'; +export const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet' +export const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots' + +export const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed'; +export const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady'; +export const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound' +export const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived'; +export const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush'; +export const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection'; +export const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer' +export const WEBRTC_SDP_OFFER = 'webrtcSdpOffer'; + +export const MESSAGE = 'message' + +export const GAMEPAD_CONNECTED = 'gamepadConnected'; +export const GAMEPAD_DISCONNECTED = 'gamepadDisconnected'; + +export const MENU_HANDLER_ATTACHED = 'menuHandlerAttached'; +export const MENU_PRESSED = 'menuPressed'; +export const MENU_RELEASED = 'menuReleased'; + +export const KEY_PRESSED = 'keyPressed'; +export const KEY_RELEASED = 'keyReleased'; +export const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode'; +export const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed'; +export const AXIS_CHANGED = 'axisChanged'; +export const CONTROLLER_UPDATED = 'controllerUpdated'; + +export const DPAD_TOGGLE = 'dpadToggle'; +export const STATS_TOGGLE = 'statsToggle'; +export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; + +export const SETTINGS_CHANGED = 'settingsChanged'; + +export const RECORDING_TOGGLED = 'recordingToggle' +export const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' + +export const APP_VIDEO_CHANGED = 'appVideoChanged' diff --git a/web/js/event/event.js b/web/js/event/event.js deleted file mode 100644 index 301e731b..00000000 --- a/web/js/event/event.js +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Event publishing / subscribe module. - * Just a simple observer pattern. - * @version 1 - */ -const event = (() => { - const topics = {}; - - // internal listener index - let _index = 0; - - return { - /** - * Subscribes onto some event. - * - * @param topic The name of the event. - * @param listener A callback function to call during the event. - * @param order A number in a queue of event handlers to run callback in ordered manner. - * @returns {{unsub: unsub}} The function to remove this subscription. - * @example - * const sub01 = event.sub('rapture', () => {a}, 1) - * ... - * sub01.unsub() - */ - sub: (topic, listener, order = undefined) => { - if (!topics[topic]) topics[topic] = {}; - // order index * big pad + next internal index (e.g. 1*100+1=101) - // use some arbitrary big number to not overlap with non-ordered - let i = (order !== undefined ? order * 1000000 : 0) + _index++; - topics[topic][i] = listener; - return Object.freeze({ - unsub: () => { - delete topics[topic][i] - } - }); - }, - - /** - * Publishes some event for handling. - * - * @param topic The name of the event. - * @param data Additional data for the event handling. - * Because of compatibility we have to use a dumb obj wrapper {a: a, b: b} for params instead of (topic, ...data). - * @example - * event.pub('rapture', {time: now()}) - */ - pub: (topic, data) => { - if (!topics[topic]) return; - Object.keys(topics[topic]).forEach((ls) => { - topics[topic][ls](data !== undefined ? data : {}) - }); - } - } -})(); - -// events -const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; -const PING_REQUEST = 'pingRequest'; -const PING_RESPONSE = 'pingResponse'; - -const WORKER_LIST_FETCHED = 'workerListFetched'; - -const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; -const GAME_SAVED = 'gameSaved'; -const GAME_LOADED = 'gameLoaded'; -const GAME_PLAYER_IDX = 'gamePlayerIndex'; -const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet' -const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots' - -const WEBRTC_CONNECTION_CLOSED = 'webrtcConnectionClosed'; -const WEBRTC_CONNECTION_READY = 'webrtcConnectionReady'; -const WEBRTC_ICE_CANDIDATE_FOUND = 'webrtcIceCandidateFound' -const WEBRTC_ICE_CANDIDATE_RECEIVED = 'webrtcIceCandidateReceived'; -const WEBRTC_ICE_CANDIDATES_FLUSH = 'webrtcIceCandidatesFlush'; -const WEBRTC_NEW_CONNECTION = 'webrtcNewConnection'; -const WEBRTC_SDP_ANSWER = 'webrtcSdpAnswer' -const WEBRTC_SDP_OFFER = 'webrtcSdpOffer'; - -const MESSAGE = 'message' - -const GAMEPAD_CONNECTED = 'gamepadConnected'; -const GAMEPAD_DISCONNECTED = 'gamepadDisconnected'; - -const MENU_HANDLER_ATTACHED = 'menuHandlerAttached'; -const MENU_PRESSED = 'menuPressed'; -const MENU_RELEASED = 'menuReleased'; - -const KEY_PRESSED = 'keyPressed'; -const KEY_RELEASED = 'keyReleased'; -const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode'; -const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed'; -const AXIS_CHANGED = 'axisChanged'; -const CONTROLLER_UPDATED = 'controllerUpdated'; - -const DPAD_TOGGLE = 'dpadToggle'; -const STATS_TOGGLE = 'statsToggle'; -const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; - -const SETTINGS_CHANGED = 'settingsChanged'; - -const RECORDING_TOGGLED = 'recordingToggle' -const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' - -const APP_VIDEO_CHANGED = 'appVideoChanged' diff --git a/web/js/gameList.js b/web/js/gameList.js index 74338a3e..48ef73b6 100644 --- a/web/js/gameList.js +++ b/web/js/gameList.js @@ -1,235 +1,239 @@ -/** - * Game list module. - * @version 1 - */ -const gameList = (() => { - const TOP_POSITION = 102 - const SELECT_THRESHOLD_MS = 160 +import { + sub, + MENU_PRESSED, + MENU_RELEASED +} from 'event'; +import {gui} from 'gui'; - const games = (() => { - let list = [], index = 0 - return { - get index() { - return index - }, - get list() { - return list - }, - get selected() { - return list[index].title // selected by the game title, oof - }, - set index(i) { - //-2 | - //-1 | | - // 0 < | < - // 1 | | - // 2 < < | - //+1 | | - //+2 | - index = i < -1 ? i = 0 : - i > list.length ? i = list.length - 1 : - (i % list.length + list.length) % list.length - }, - set: (data = []) => list = data.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1), - empty: () => list.length === 0 - } - })() - - const scroll = ((DEFAULT_INTERVAL) => { - const state = { - IDLE: 0, UP: -1, DOWN: 1, DRAG: 3 - } - let last = state.IDLE - let _si - let onShift, onStop - - const shift = (delta) => { - if (scroll.scrolling) return - onShift(delta) - // velocity? - // keep rolling the game list if the button is pressed - _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL) - } - - const stop = () => { - onStop() - _si && (clearInterval(_si) && (_si = null)) - } - - const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null} - - return { - scroll: (move = state.IDLE) => { - handle[move] && handle[move](move) - last = move - }, - get scrolling() { - return last !== state.IDLE - }, - set onShift(fn) { - onShift = fn - }, - set onStop(fn) { - onStop = fn - }, - state, - last: () => last - } - })(SELECT_THRESHOLD_MS) - - const ui = (() => { - const rootEl = document.getElementById('menu-container') - const choiceMarkerEl = document.getElementById('menu-item-choice') - - const TRANSITION_DEFAULT = `top ${SELECT_THRESHOLD_MS}ms` - let listTopPos = TOP_POSITION - - rootEl.style.transition = TRANSITION_DEFAULT - - let onTransitionEnd = () => ({}) - - //rootEl.addEventListener('transitionend', () => onTransitionEnd()) - - let items = [] - - const item = (parent) => { - const title = parent.firstChild.firstChild - const desc = parent.children[1] - - const _desc = { - hide: () => gui.hide(desc), - show: async () => { - gui.show(desc) - await gui.anim.fadeIn(desc, .054321) - }, - } - - const _title = { - animate: () => title.classList.add('text-move'), - pick: () => title.classList.add('pick'), - reset: () => title.classList.remove('pick', 'text-move'), - } - - const clear = () => { - _title.reset() - // _desc.hide() - } - - return { - get description() { - return _desc - }, - get title() { - return _title - }, - clear, - } - } - - const render = () => { - rootEl.innerHTML = games.list.map(game => - ``) - .join('') - items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x)) - } - - return { - get items() { - return items - }, - get selected() { - return items[games.index] - }, - get roundIndex() { - const closest = Math.round((listTopPos - TOP_POSITION) / -36) - return closest < 0 ? 0 : - closest > games.list.length - 1 ? games.list.length - 1 : - closest // don't wrap the list on drag - }, - set onTransitionEnd(x) { - onTransitionEnd = x - }, - set pos(idx) { - listTopPos = TOP_POSITION - idx * 36 - rootEl.style.top = `${listTopPos}px` - }, - drag: { - startPos: (pos) => { - rootEl.style.top = `${listTopPos - pos}px` - rootEl.style.transition = '' - }, - stopPos: (pos) => { - listTopPos -= pos - rootEl.style.transition = TRANSITION_DEFAULT - }, - }, - render, - marker: { - show: () => gui.show(choiceMarkerEl) - }, - NO_TRANSITION: onTransitionEnd(), - } - })(TOP_POSITION, SELECT_THRESHOLD_MS, games) - - const show = () => { - ui.render() - ui.marker.show() // we show square pseudo-selection marker only after rendering - scroll.scroll(scroll.state.DOWN) // interactively moves games select down - scroll.scroll(scroll.state.IDLE) - } - - const select = (index) => { - ui.items.forEach(i => i.clear()) // !to rewrite - games.index = index - ui.pos = games.index - } - - scroll.onShift = (delta) => select(games.index + delta) - - let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED - - scroll.onStop = () => { - const item = ui.selected - if (item) { - item.title.pick() - item.title.animate() - // hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show() - } - } - - event.sub(MENU_PRESSED, (position) => { - if (games.empty()) return - ui.onTransitionEnd = ui.NO_TRANSITION - hasTransition = false - scroll.scroll(scroll.state.DRAG) - ui.selected && ui.selected.clear() - ui.drag.startPos(position) - }) - - event.sub(MENU_RELEASED, (position) => { - if (games.empty()) return - ui.drag.stopPos(position) - select(ui.roundIndex) - hasTransition = !hasTransition - scroll.scroll(scroll.state.IDLE) - hasTransition = true - }) +const TOP_POSITION = 102 +const SELECT_THRESHOLD_MS = 160 +const games = (() => { + let list = [], index = 0 return { - scroll: (x) => { - if (games.empty()) return - scroll.scroll(x) + get index() { + return index + }, + get list() { + return list }, get selected() { - return games.selected + return list[index].title // selected by the game title, oof }, - set: games.set, - show: () => { - if (games.empty()) return - show() + set index(i) { + //-2 | + //-1 | | + // 0 < | < + // 1 | | + // 2 < < | + //+1 | | + //+2 | + index = i < -1 ? i = 0 : + i > list.length ? i = list.length - 1 : + (i % list.length + list.length) % list.length }, + set: (data = []) => list = data.sort((a, b) => a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1), + empty: () => list.length === 0 } -})(document, event, gui) +})() + +const scroll = ((DEFAULT_INTERVAL) => { + const state = { + IDLE: 0, UP: -1, DOWN: 1, DRAG: 3 + } + let last = state.IDLE + let _si + let onShift, onStop + + const shift = (delta) => { + if (scroll.scrolling) return + onShift(delta) + // velocity? + // keep rolling the game list if the button is pressed + _si = setInterval(() => onShift(delta), DEFAULT_INTERVAL) + } + + const stop = () => { + onStop() + _si && (clearInterval(_si) && (_si = null)) + } + + const handle = {[state.IDLE]: stop, [state.UP]: shift, [state.DOWN]: shift, [state.DRAG]: null} + + return { + scroll: (move = state.IDLE) => { + handle[move] && handle[move](move) + last = move + }, + get scrolling() { + return last !== state.IDLE + }, + set onShift(fn) { + onShift = fn + }, + set onStop(fn) { + onStop = fn + }, + state, + last: () => last + } +})(SELECT_THRESHOLD_MS) + +const ui = (() => { + const rootEl = document.getElementById('menu-container') + const choiceMarkerEl = document.getElementById('menu-item-choice') + + const TRANSITION_DEFAULT = `top ${SELECT_THRESHOLD_MS}ms` + let listTopPos = TOP_POSITION + + rootEl.style.transition = TRANSITION_DEFAULT + + let onTransitionEnd = () => ({}) + + //rootEl.addEventListener('transitionend', () => onTransitionEnd()) + + let items = [] + + const item = (parent) => { + const title = parent.firstChild.firstChild + const desc = parent.children[1] + + const _desc = { + hide: () => gui.hide(desc), + show: async () => { + gui.show(desc) + await gui.anim.fadeIn(desc, .054321) + }, + } + + const _title = { + animate: () => title.classList.add('text-move'), + pick: () => title.classList.add('pick'), + reset: () => title.classList.remove('pick', 'text-move'), + } + + const clear = () => { + _title.reset() + // _desc.hide() + } + + return { + get description() { + return _desc + }, + get title() { + return _title + }, + clear, + } + } + + const render = () => { + rootEl.innerHTML = games.list.map(game => + ``) + .join('') + items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x)) + } + + return { + get items() { + return items + }, + get selected() { + return items[games.index] + }, + get roundIndex() { + const closest = Math.round((listTopPos - TOP_POSITION) / -36) + return closest < 0 ? 0 : + closest > games.list.length - 1 ? games.list.length - 1 : + closest // don't wrap the list on drag + }, + set onTransitionEnd(x) { + onTransitionEnd = x + }, + set pos(idx) { + listTopPos = TOP_POSITION - idx * 36 + rootEl.style.top = `${listTopPos}px` + }, + drag: { + startPos: (pos) => { + rootEl.style.top = `${listTopPos - pos}px` + rootEl.style.transition = '' + }, + stopPos: (pos) => { + listTopPos -= pos + rootEl.style.transition = TRANSITION_DEFAULT + }, + }, + render, + marker: { + show: () => gui.show(choiceMarkerEl) + }, + NO_TRANSITION: onTransitionEnd(), + } +})(TOP_POSITION, SELECT_THRESHOLD_MS, games) + +const show = () => { + ui.render() + ui.marker.show() // we show square pseudo-selection marker only after rendering + scroll.scroll(scroll.state.DOWN) // interactively moves games select down + scroll.scroll(scroll.state.IDLE) +} + +const select = (index) => { + ui.items.forEach(i => i.clear()) // !to rewrite + games.index = index + ui.pos = games.index +} + +scroll.onShift = (delta) => select(games.index + delta) + +let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED + +scroll.onStop = () => { + const item = ui.selected + if (item) { + item.title.pick() + item.title.animate() + // hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show() + } +} + +sub(MENU_PRESSED, (position) => { + if (games.empty()) return + ui.onTransitionEnd = ui.NO_TRANSITION + hasTransition = false + scroll.scroll(scroll.state.DRAG) + ui.selected && ui.selected.clear() + ui.drag.startPos(position) +}) + +sub(MENU_RELEASED, (position) => { + if (games.empty()) return + ui.drag.stopPos(position) + select(ui.roundIndex) + hasTransition = !hasTransition + scroll.scroll(scroll.state.IDLE) + hasTransition = true +}) + +/** + * Game list module. + */ +export const gameList = { + scroll: (x) => { + if (games.empty()) return + scroll.scroll(x) + }, + get selected() { + return games.selected + }, + set: games.set, + show: () => { + if (games.empty()) return + show() + }, +} diff --git a/web/js/gui.js b/web/js/gui.js new file mode 100644 index 00000000..0d295ea1 --- /dev/null +++ b/web/js/gui.js @@ -0,0 +1,259 @@ +/** + * App UI elements module. + */ + +const _create = (name = 'div', modFn) => { + const el = document.createElement(name); + if (modFn) { + modFn(el); + } + return el; +} + +const _option = (text = '', selected = false, label) => { + const el = _create('option'); + if (label) { + el.textContent = label; + el.value = text; + } else { + el.textContent = text; + } + if (selected) el.selected = true; + + return el; +} + +const select = (key = '', callback = () => ({}), values = {values: [], labels: []}, current = '') => { + const el = _create(); + const select = _create('select'); + select.onchange = event => { + callback(key, event.target.value); + }; + el.append(select); + + select.append(_option('none', current === '')); + values.values.forEach((value, index) => { + select.append(_option(value, current === value, values.labels?.[index])); + }); + + return el; +} + +const checkbox = (id, cb = () => ({}), checked = false, label = '', cc = '') => { + const el = _create(); + cc !== '' && el.classList.add(cc); + + let parent = el; + + if (label) { + const _label = _create('label', (el) => { + el.setAttribute('htmlFor', id); + }) + _label.innerText = label; + el.append(_label) + parent = _label; + } + + const input = _create('input', (el) => { + el.setAttribute('id', id); + el.setAttribute('name', id); + el.setAttribute('type', 'checkbox'); + el.onclick = ((e) => { + checked = e.target.checked + cb(id, checked) + }) + checked && el.setAttribute('checked', ''); + }); + parent.prepend(input); + + return el; +} + +const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => { + const state = { + shown: false, + loading: false, + title: title, + } + + const tHandlers = []; + onToggle && tHandlers.push(onToggle); + + const _root = root || _create('div'); + _root.classList.add('panel'); + gui.hide(_root); + + const header = _create('div', (el) => el.classList.add('panel__header')); + const _content = _create('div', (el) => { + if (cc) { + el.classList.add(cc); + } + el.classList.add('panel__content') + }); + + const _title = _create('span', (el) => { + el.classList.add('panel__header__title'); + el.innerText = title; + }); + header.append(_title); + + header.append(_create('div', (el) => { + el.classList.add('panel__header__controls'); + + buttons.forEach((b => el.append(_create('span', (el) => { + if (Object.keys(b).length === 0) { + el.classList.add('panel__button_separator'); + return + } + el.classList.add('panel__button'); + if (b.cl) b.cl.forEach(class_ => el.classList.add(class_)); + if (b.title) el.title = b.title; + el.innerText = b.caption; + el.addEventListener('click', b.handler) + })))) + + el.append(_create('span', (el) => { + el.classList.add('panel__button'); + el.innerText = 'X'; + el.title = 'Close'; + el.addEventListener('click', () => toggle(false)) + })) + })) + + root.append(header, _content); + if (content) { + _content.append(content); + } + + const setContent = (content) => _content.replaceChildren(content) + + const setLoad = (load = true) => { + state.loading = load; + _title.innerText = state.loading ? `${state.title}...` : state.title; + } + + const toggle = (() => { + let br = window.getComputedStyle(_root.parentElement).borderRadius; + return (force) => { + state.shown = force !== undefined ? force : !state.shown; + // hack for not transparent jpeg corners :_; + _root.parentElement.style.borderRadius = state.shown ? '0px' : br; + tHandlers.forEach(h => h?.(state.shown, _root)); + state.shown ? gui.show(_root) : gui.hide(_root) + } + })() + + return { + contentEl: _content, + isHidden: () => !state.shown, + onToggle: (fn) => tHandlers.push(fn), + setContent, + setLoad, + toggle, + } +} + +const _bind = (cb = () => ({}), name = '', oldValue) => { + const el = _create('button'); + el.onclick = () => cb(name, oldValue); + el.textContent = name; + return el; +} + +const binding = (key = '', value = '', cb = () => ({})) => { + const el = _create(); + el.setAttribute('class', 'binding-element'); + + const k = _bind(cb, key, value); + + el.append(k); + + const v = _create(); + v.textContent = value; + el.append(v); + + return el; +} + +const show = (el) => { + el.classList.remove('hidden'); +} + +const inputN = (key = '', cb = () => ({}), current = 0) => { + const el = _create(); + const input = _create('input'); + input.type = 'number'; + input.value = current; + input.onchange = event => cb(key, event.target.value); + el.append(input); + return el; +} + +const hide = (el) => { + el.classList.add('hidden'); +} + +const toggle = (el, what) => { + if (what) { + show(el) + } else { + hide(el) + } +} + +const fadeIn = async (el, speed = .1) => { + el.style.opacity = '0'; + el.style.display = 'block'; + return new Promise((done) => (function fade() { + let val = parseFloat(el.style.opacity); + const proceed = ((val += speed) <= 1); + if (proceed) { + el.style.opacity = '' + val; + requestAnimationFrame(fade); + } else { + done(); + } + })() + ); +} + +const fadeOut = async (el, speed = .1) => { + el.style.opacity = '1'; + return new Promise((done) => (function fade() { + if ((el.style.opacity -= speed) < 0) { + el.style.display = "none"; + done(); + } else { + requestAnimationFrame(fade); + } + })() + ) +} + +const fragment = () => document.createDocumentFragment(); + +const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +const fadeInOut = async (el, wait = 1000, speed = .1) => { + await fadeIn(el, speed) + await sleep(wait); + await fadeOut(el, speed) +} + +export const gui = { + anim: { + fadeIn, + fadeOut, + fadeInOut, + }, + binding, + checkbox, + create: _create, + fragment, + hide, + inputN, + panel, + select, + show, + toggle, +} diff --git a/web/js/gui/gui.js b/web/js/gui/gui.js deleted file mode 100644 index c63a4b89..00000000 --- a/web/js/gui/gui.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * App UI elements module. - * - * @version 1 - */ -const gui = (() => { - - const _create = (name = 'div', modFn) => { - const el = document.createElement(name); - if (modFn) { - modFn(el); - } - return el; - } - - const _option = (text = '', selected = false, label) => { - const el = _create('option'); - if (label) { - el.textContent = label; - el.value = text; - } else { - el.textContent = text; - } - if (selected) el.selected = true; - - return el; - } - - const select = (key = '', callback = () => ({}), values = {values: [], labels: []}, current = '') => { - const el = _create(); - const select = _create('select'); - select.onchange = event => { - callback(key, event.target.value); - }; - el.append(select); - - select.append(_option('none', current === '')); - values.values.forEach((value, index) => { - select.append(_option(value, current === value, values.labels?.[index])); - }); - - return el; - } - - const checkbox = (id, cb = () => ({}), checked = false, label = '', cc = '') => { - const el = _create(); - cc !== '' && el.classList.add(cc); - - let parent = el; - - if (label) { - const _label = _create('label', (el) => { - el.setAttribute('htmlFor', id); - }) - _label.innerText = label; - el.append(_label) - parent = _label; - } - - const input = _create('input', (el) => { - el.setAttribute('id', id); - el.setAttribute('name', id); - el.setAttribute('type', 'checkbox'); - el.onclick = ((e) => { - checked = e.target.checked - cb(id, checked) - }) - checked && el.setAttribute('checked', ''); - }); - parent.prepend(input); - - return el; - } - - const panel = (root, title = '', cc = '', content, buttons = [], onToggle) => { - const state = { - shown: false, - loading: false, - title: title, - } - - const tHandlers = []; - onToggle && tHandlers.push(onToggle); - - const _root = root || _create('div'); - _root.classList.add('panel'); - gui.hide(_root); - - const header = _create('div', (el) => el.classList.add('panel__header')); - const _content = _create('div', (el) => { - if (cc) { - el.classList.add(cc); - } - el.classList.add('panel__content') - }); - - const _title = _create('span', (el) => { - el.classList.add('panel__header__title'); - el.innerText = title; - }); - header.append(_title); - - header.append(_create('div', (el) => { - el.classList.add('panel__header__controls'); - - buttons.forEach((b => el.append(_create('span', (el) => { - if (Object.keys(b).length === 0) { - el.classList.add('panel__button_separator'); - return - } - el.classList.add('panel__button'); - if (b.cl) b.cl.forEach(class_ => el.classList.add(class_)); - if (b.title) el.title = b.title; - el.innerText = b.caption; - el.addEventListener('click', b.handler) - })))) - - el.append(_create('span', (el) => { - el.classList.add('panel__button'); - el.innerText = 'X'; - el.title = 'Close'; - el.addEventListener('click', () => toggle(false)) - })) - })) - - root.append(header, _content); - if (content) { - _content.append(content); - } - - const setContent = (content) => _content.replaceChildren(content) - - const setLoad = (load = true) => { - state.loading = load; - _title.innerText = state.loading ? `${state.title}...` : state.title; - } - - const toggle = (() => { - let br = window.getComputedStyle(_root.parentElement).borderRadius; - return (force) => { - state.shown = force !== undefined ? force : !state.shown; - // hack for not transparent jpeg corners :_; - _root.parentElement.style.borderRadius = state.shown ? '0px' : br; - tHandlers.forEach(h => h?.(state.shown, _root)); - state.shown ? gui.show(_root) : gui.hide(_root) - } - })() - - return { - contentEl: _content, - isHidden: () => !state.shown, - onToggle: (fn) => tHandlers.push(fn), - setContent, - setLoad, - toggle, - } - } - - const _bind = (callback = function () { - }, name = '', oldValue) => { - const el = _create('button'); - el.onclick = () => callback(name, oldValue); - - el.textContent = name; - - return el; - } - - const binding = (key = '', value = '', callback = function () { - }) => { - const el = _create(); - el.setAttribute('class', 'binding-element'); - - const k = _bind(callback, key, value); - - el.append(k); - - const v = _create(); - v.textContent = value; - el.append(v); - - return el; - } - - const show = (el) => { - el.classList.remove('hidden'); - } - - const inputN = (key = '', cb = () => ({}), current = 0) => { - const el = _create(); - const input = _create('input'); - input.type = 'number'; - input.value = current; - input.onchange = event => cb(key, event.target.value); - el.append(input); - return el; - } - - const hide = (el) => { - el.classList.add('hidden'); - } - - const toggle = (el, what) => { - if (what) { - show(el) - } else { - hide(el) - } - } - - const fadeIn = async (el, speed = .1) => { - el.style.opacity = '0'; - el.style.display = 'block'; - return new Promise((done) => (function fade() { - let val = parseFloat(el.style.opacity); - const proceed = ((val += speed) <= 1); - if (proceed) { - el.style.opacity = '' + val; - requestAnimationFrame(fade); - } else { - done(); - } - })() - ); - } - - const fadeOut = async (el, speed = .1) => { - el.style.opacity = '1'; - return new Promise((done) => (function fade() { - if ((el.style.opacity -= speed) < 0) { - el.style.display = "none"; - done(); - } else { - requestAnimationFrame(fade); - } - })() - ) - } - - const fragment = () => document.createDocumentFragment(); - - const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); - - const fadeInOut = async (el, wait = 1000, speed = .1) => { - await fadeIn(el, speed) - await sleep(wait); - await fadeOut(el, speed) - } - - return { - anim: { - fadeIn, - fadeOut, - fadeInOut, - }, - binding, - checkbox, - create: _create, - fragment, - hide, - inputN, - panel, - select, - show, - toggle, - } -})(document); diff --git a/web/js/gui/message.js b/web/js/gui/message.js deleted file mode 100644 index 598d69e9..00000000 --- a/web/js/gui/message.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * App UI message module. - * - * @version 1 - */ -const message = (() => { - const popupBox = document.getElementById('noti-box'); - - // fifo queue - let queue = []; - const queueMaxSize = 5; - - let isScreenFree = true; - - const _popup = (time = 1000) => { - // recursion edge case: - // no messages in the queue or one on the screen - if (!(queue.length > 0 && isScreenFree)) { - return; - } - - isScreenFree = false; - popupBox.innerText = queue.shift(); - gui.anim.fadeInOut(popupBox, time, .05).finally(() => { - isScreenFree = true; - _popup(); - }) - } - - const _storeMessage = (text) => { - if (queue.length <= queueMaxSize) { - queue.push(text); - } - } - - const _proceed = (text, time) => { - _storeMessage(text); - _popup(time); - } - - const show = (text, time = 1000) => _proceed(text, time) - - return Object.freeze({ - show: show - }) -})(document, gui, utils); diff --git a/web/js/init.js b/web/js/init.js deleted file mode 100644 index d421bddb..00000000 --- a/web/js/init.js +++ /dev/null @@ -1,26 +0,0 @@ -settings.init(); - -(() => { - let lvl = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); - // migrate old log level options - // !to remove at some point - if (isNaN(lvl)) { - console.warn( - `The log value [${lvl}] is not supported! ` + - `The default value [debug] will be used instead.`); - settings.set(opts.LOG_LEVEL, `${log.DEFAULT}`) - lvl = log.DEFAULT - } - log.level = lvl -})(); - -keyboard.init(); -joystick.init(); -touch.init(); -stream.init(); - -[roomId, zone] = room.loadMaybe(); -// find worker id if present -const wid = new URLSearchParams(document.location.search).get('wid'); -// if from URL -> start game immediately! -socket.init(roomId, wid, zone); diff --git a/web/js/input/input.js b/web/js/input/input.js index 0ccf5b48..a6aa333d 100644 --- a/web/js/input/input.js +++ b/web/js/input/input.js @@ -1,114 +1,5 @@ -const input = (() => { - const pollingIntervalMs = 4; - let controllerChangedIndex = -1; - - // Libretro config - let controllerState = { - [KEY.B]: false, - [KEY.Y]: false, - [KEY.SELECT]: false, - [KEY.START]: false, - [KEY.UP]: false, - [KEY.DOWN]: false, - [KEY.LEFT]: false, - [KEY.RIGHT]: false, - [KEY.A]: false, - [KEY.X]: false, - // extra - [KEY.L]: false, - [KEY.R]: false, - [KEY.L2]: false, - [KEY.R2]: false, - [KEY.L3]: false, - [KEY.R3]: false - }; - - const poll = (intervalMs, callback) => { - let _ticker = 0; - return { - enable: () => { - if (_ticker > 0) return; - log.debug(`[input] poll set to ${intervalMs}ms`); - _ticker = setInterval(callback, intervalMs) - }, - disable: () => { - if (_ticker < 1) return; - log.debug('[input] poll has been disabled'); - clearInterval(_ticker); - _ticker = 0; - } - } - }; - - const controllerEncoded = [0, 0, 0, 0, 0]; - const keys = Object.keys(controllerState); - - const compare = (a, b) => { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) { - return false; - } - } - return true; - }; - - - // let lastState = controllerEncoded; - - const sendControllerState = () => { - if (controllerChangedIndex >= 0) { - const state = _getState(); - - // log.debug(state) - - // if (compare(lastState, state)) { - // log.debug('!skip') - // } else { - event.pub(CONTROLLER_UPDATED, _encodeState(state)); - // } - // lastState = state; - controllerChangedIndex = -1; - } - }; - - const setKeyState = (name, state) => { - if (controllerState[name] !== undefined) { - controllerState[name] = state; - controllerChangedIndex = Math.max(controllerChangedIndex, 0); - } - }; - - const setAxisChanged = (index, value) => { - if (controllerEncoded[index + 1] !== undefined) { - controllerEncoded[index + 1] = Math.floor(32767 * value); - controllerChangedIndex = Math.max(controllerChangedIndex, index + 1); - } - }; - - /** - * Converts key state into a bitmap and prepends it to the axes state. - * - * @returns {Uint16Array} The controller state. - * First uint16 is the controller state bitmap. - * The other uint16 are the axes values. - * Truncated to the last value changed. - * - * @private - */ - const _encodeState = (state) => new Uint16Array(state) - - const _getState = () => { - controllerEncoded[0] = 0; - for (let i = 0, len = keys.length; i < len; i++) { - controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0; - } - return controllerEncoded.slice(0, controllerChangedIndex + 1); - } - - return { - poll: poll(pollingIntervalMs, sendControllerState), - setKeyState, - setAxisChanged, - } -})(event, KEY, log); +export {joystick} from './joystick.js?v=3'; +export {KEY} from './keys.js?v=3'; +export {keyboard} from './keyboard.js?v=3' +export {retropad} from './retropad.js?v=3'; +export {touch} from './touch.js?v=3'; diff --git a/web/js/input/joystick.js b/web/js/input/joystick.js index c5ee2fdb..b7f9a54a 100644 --- a/web/js/input/joystick.js +++ b/web/js/input/joystick.js @@ -1,3 +1,260 @@ +import { + pub, + sub, + AXIS_CHANGED, + DPAD_TOGGLE, + GAMEPAD_CONNECTED, + GAMEPAD_DISCONNECTED, + KEY_PRESSED, + KEY_RELEASED +} from 'event'; +import {env} from 'env'; +import {KEY} from 'input'; +import {log} from 'log'; + +const deadZone = 0.1; +let joystickMap; +let joystickState = {}; +let joystickAxes = []; +let joystickIdx; +let joystickTimer = null; +let dpadMode = true; + +function onDpadToggle(checked) { + if (dpadMode === checked) { + return //error? + } + if (dpadMode) { + dpadMode = false; + // reset dpad keys pressed before moving to analog stick mode + checkJoystickAxisState(KEY.LEFT, false); + checkJoystickAxisState(KEY.RIGHT, false); + checkJoystickAxisState(KEY.UP, false); + checkJoystickAxisState(KEY.DOWN, false); + } else { + dpadMode = true; + // reset analog stick axes before moving to dpad mode + joystickAxes.forEach(function (value, index) { + checkJoystickAxis(index, 0); + }); + } +} + +// check state for each axis -> dpad +function checkJoystickAxisState(name, state) { + if (joystickState[name] !== state) { + joystickState[name] = state; + pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name}); + } +} + +function checkJoystickAxis(axis, value) { + if (-deadZone < value && value < deadZone) value = 0; + if (joystickAxes[axis] !== value) { + joystickAxes[axis] = value; + pub(AXIS_CHANGED, {id: axis, value: value}); + } +} + +// loop timer for checking joystick state +function checkJoystickState() { + let gamepad = navigator.getGamepads()[joystickIdx]; + if (gamepad) { + if (dpadMode) { + // axis -> dpad + let corX = gamepad.axes[0]; // -1 -> 1, left -> right + let corY = gamepad.axes[1]; // -1 -> 1, up -> down + checkJoystickAxisState(KEY.LEFT, corX <= -0.5); + checkJoystickAxisState(KEY.RIGHT, corX >= 0.5); + checkJoystickAxisState(KEY.UP, corY <= -0.5); + checkJoystickAxisState(KEY.DOWN, corY >= 0.5); + } else { + gamepad.axes.forEach(function (value, index) { + checkJoystickAxis(index, value); + }); + } + + // normal button map + Object.keys(joystickMap).forEach(function (btnIdx) { + const buttonState = gamepad.buttons[btnIdx]; + + const isPressed = navigator.webkitGetGamepads ? buttonState === 1 : + buttonState.value > 0 || buttonState.pressed === true; + + if (joystickState[btnIdx] !== isPressed) { + joystickState[btnIdx] = isPressed; + pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]}); + } + }); + } +} + +// we only capture the last plugged joystick +const onGamepadConnected = (e) => { + let gamepad = e.gamepad; + log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`); + + joystickIdx = gamepad.index; + + // Ref: https://github.com/giongto35/cloud-game/issues/14 + // get mapping first (default KeyMap2) + let os = env.getOs(); + let browser = env.getBrowser(); + + if (os === 'android') { + // default of android is KeyMap1 + joystickMap = { + 2: KEY.A, + 0: KEY.B, + 3: KEY.START, + 4: KEY.SELECT, + 10: KEY.LOAD, + 11: KEY.SAVE, + 8: KEY.HELP, + 9: KEY.QUIT, + 12: KEY.UP, + 13: KEY.DOWN, + 14: KEY.LEFT, + 15: KEY.RIGHT + }; + } else { + // default of other OS is KeyMap2 + joystickMap = { + 0: KEY.A, + 1: KEY.B, + 2: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 12: KEY.UP, + 13: KEY.DOWN, + 14: KEY.LEFT, + 15: KEY.RIGHT + }; + } + + if (os === 'android' && (browser === 'firefox' || browser === 'uc')) { //KeyMap2 + joystickMap = { + 0: KEY.A, + 1: KEY.B, + 2: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 12: KEY.UP, + 13: KEY.DOWN, + 14: KEY.LEFT, + 15: KEY.RIGHT + }; + } + + if (os === 'win' && browser === 'firefox') { //KeyMap3 + joystickMap = { + 1: KEY.A, + 2: KEY.B, + 0: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT + }; + } + + if (os === 'mac' && browser === 'safari') { //KeyMap4 + joystickMap = { + 1: KEY.A, + 2: KEY.B, + 0: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 14: KEY.UP, + 15: KEY.DOWN, + 16: KEY.LEFT, + 17: KEY.RIGHT + }; + } + + if (os === 'mac' && browser === 'firefox') { //KeyMap5 + joystickMap = { + 1: KEY.A, + 2: KEY.B, + 0: KEY.START, + 3: KEY.SELECT, + 8: KEY.LOAD, + 9: KEY.SAVE, + 6: KEY.HELP, + 7: KEY.QUIT, + 14: KEY.UP, + 15: KEY.DOWN, + 16: KEY.LEFT, + 17: KEY.RIGHT + }; + } + + // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272 + if (gamepad.id.includes('PLAYSTATION(R)3')) { + if (browser === 'chrome') { + joystickMap = { + 1: KEY.A, + 0: KEY.B, + 2: KEY.Y, + 3: KEY.X, + 4: KEY.L, + 5: KEY.R, + 8: KEY.SELECT, + 9: KEY.START, + 10: KEY.DTOGGLE, + 11: KEY.R3, + }; + } else { + joystickMap = { + 13: KEY.A, + 14: KEY.B, + 12: KEY.X, + 15: KEY.Y, + 3: KEY.START, + 0: KEY.SELECT, + 4: KEY.UP, + 6: KEY.DOWN, + 7: KEY.LEFT, + 5: KEY.RIGHT, + 10: KEY.L, + 11: KEY.R, + 8: KEY.L2, + 9: KEY.R2, + 1: KEY.DTOGGLE, + 2: KEY.R3, + }; + } + } + + // reset state + joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; + Object.keys(joystickMap).forEach(function (btnIdx) { + joystickState[btnIdx] = false; + }); + + joystickAxes = new Array(gamepad.axes.length).fill(0); + + // looper, too intense? + if (joystickTimer !== null) { + clearInterval(joystickTimer); + } + + joystickTimer = setInterval(checkJoystickState, 10); // milliseconds per hit + pub(GAMEPAD_CONNECTED); +}; + +sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + /** * Joystick controls. * @@ -16,263 +273,18 @@ * * @version 1 */ -const joystick = (() => { - const deadZone = 0.1; - let joystickMap; - let joystickState = {}; - let joystickAxes = []; - let joystickIdx; - let joystickTimer = null; - let dpadMode = true; +export const joystick = { + init: () => { + // we only capture the last plugged joystick + window.addEventListener('gamepadconnected', onGamepadConnected); - function onDpadToggle(checked) { - if (dpadMode === checked) { - return //error? - } - if (dpadMode) { - dpadMode = false; - // reset dpad keys pressed before moving to analog stick mode - checkJoystickAxisState(KEY.LEFT, false); - checkJoystickAxisState(KEY.RIGHT, false); - checkJoystickAxisState(KEY.UP, false); - checkJoystickAxisState(KEY.DOWN, false); - } else { - dpadMode = true; - // reset analog stick axes before moving to dpad mode - joystickAxes.forEach(function (value, index) { - checkJoystickAxis(index, 0); - }); - } - } - - // check state for each axis -> dpad - function checkJoystickAxisState(name, state) { - if (joystickState[name] !== state) { - joystickState[name] = state; - event.pub(state === true ? KEY_PRESSED : KEY_RELEASED, {key: name}); - } - } - - function checkJoystickAxis(axis, value) { - if (-deadZone < value && value < deadZone) value = 0; - if (joystickAxes[axis] !== value) { - joystickAxes[axis] = value; - event.pub(AXIS_CHANGED, {id: axis, value: value}); - } - } - - // loop timer for checking joystick state - function checkJoystickState() { - let gamepad = navigator.getGamepads()[joystickIdx]; - if (gamepad) { - if (dpadMode) { - // axis -> dpad - let corX = gamepad.axes[0]; // -1 -> 1, left -> right - let corY = gamepad.axes[1]; // -1 -> 1, up -> down - checkJoystickAxisState(KEY.LEFT, corX <= -0.5); - checkJoystickAxisState(KEY.RIGHT, corX >= 0.5); - checkJoystickAxisState(KEY.UP, corY <= -0.5); - checkJoystickAxisState(KEY.DOWN, corY >= 0.5); - } else { - gamepad.axes.forEach(function (value, index) { - checkJoystickAxis(index, value); - }); - } - - // normal button map - Object.keys(joystickMap).forEach(function (btnIdx) { - const buttonState = gamepad.buttons[btnIdx]; - - const isPressed = navigator.webkitGetGamepads ? buttonState === 1 : - buttonState.value > 0 || buttonState.pressed === true; - - if (joystickState[btnIdx] !== isPressed) { - joystickState[btnIdx] = isPressed; - event.pub(isPressed === true ? KEY_PRESSED : KEY_RELEASED, {key: joystickMap[btnIdx]}); - } - }); - } - } - - // we only capture the last plugged joystick - const onGamepadConnected = (e) => { - let gamepad = e.gamepad; - log.info(`Gamepad connected at index ${gamepad.index}: ${gamepad.id}. ${gamepad.buttons.length} buttons, ${gamepad.axes.length} axes.`); - - joystickIdx = gamepad.index; - - // Ref: https://github.com/giongto35/cloud-game/issues/14 - // get mapping first (default KeyMap2) - let os = env.getOs(); - let browser = env.getBrowser(); - - if (os === 'android') { - // default of android is KeyMap1 - joystickMap = { - 2: KEY.A, - 0: KEY.B, - 3: KEY.START, - 4: KEY.SELECT, - 10: KEY.LOAD, - 11: KEY.SAVE, - 8: KEY.HELP, - 9: KEY.QUIT, - 12: KEY.UP, - 13: KEY.DOWN, - 14: KEY.LEFT, - 15: KEY.RIGHT - }; - } else { - // default of other OS is KeyMap2 - joystickMap = { - 0: KEY.A, - 1: KEY.B, - 2: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 12: KEY.UP, - 13: KEY.DOWN, - 14: KEY.LEFT, - 15: KEY.RIGHT - }; - } - - if (os === 'android' && (browser === 'firefox' || browser === 'uc')) { //KeyMap2 - joystickMap = { - 0: KEY.A, - 1: KEY.B, - 2: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 12: KEY.UP, - 13: KEY.DOWN, - 14: KEY.LEFT, - 15: KEY.RIGHT - }; - } - - if (os === 'win' && browser === 'firefox') { //KeyMap3 - joystickMap = { - 1: KEY.A, - 2: KEY.B, - 0: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT - }; - } - - if (os === 'mac' && browser === 'safari') { //KeyMap4 - joystickMap = { - 1: KEY.A, - 2: KEY.B, - 0: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 14: KEY.UP, - 15: KEY.DOWN, - 16: KEY.LEFT, - 17: KEY.RIGHT - }; - } - - if (os === 'mac' && browser === 'firefox') { //KeyMap5 - joystickMap = { - 1: KEY.A, - 2: KEY.B, - 0: KEY.START, - 3: KEY.SELECT, - 8: KEY.LOAD, - 9: KEY.SAVE, - 6: KEY.HELP, - 7: KEY.QUIT, - 14: KEY.UP, - 15: KEY.DOWN, - 16: KEY.LEFT, - 17: KEY.RIGHT - }; - } - - // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272 - if (gamepad.id.includes('PLAYSTATION(R)3')) { - if (browser === 'chrome') { - joystickMap = { - 1: KEY.A, - 0: KEY.B, - 2: KEY.Y, - 3: KEY.X, - 4: KEY.L, - 5: KEY.R, - 8: KEY.SELECT, - 9: KEY.START, - 10: KEY.DTOGGLE, - 11: KEY.R3, - }; - } else { - joystickMap = { - 13: KEY.A, - 14: KEY.B, - 12: KEY.X, - 15: KEY.Y, - 3: KEY.START, - 0: KEY.SELECT, - 4: KEY.UP, - 6: KEY.DOWN, - 7: KEY.LEFT, - 5: KEY.RIGHT, - 10: KEY.L, - 11: KEY.R, - 8: KEY.L2, - 9: KEY.R2, - 1: KEY.DTOGGLE, - 2: KEY.R3, - }; - } - } - - // reset state - joystickState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; - Object.keys(joystickMap).forEach(function (btnIdx) { - joystickState[btnIdx] = false; + // disconnected event is triggered + window.addEventListener('gamepaddisconnected', (event) => { + clearInterval(joystickTimer); + log.info(`Gamepad disconnected at index ${event.gamepad.index}`); + pub(GAMEPAD_DISCONNECTED); }); - joystickAxes = new Array(gamepad.axes.length).fill(0); - - // looper, too intense? - if (joystickTimer !== null) { - clearInterval(joystickTimer); - } - - joystickTimer = setInterval(checkJoystickState, 10); // miliseconds per hit - event.pub(GAMEPAD_CONNECTED); - }; - - event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - - return { - init: () => { - // we only capture the last plugged joystick - window.addEventListener('gamepadconnected', onGamepadConnected); - - // disconnected event is triggered - window.addEventListener('gamepaddisconnected', (event) => { - clearInterval(joystickTimer); - log.info(`Gamepad disconnected at index ${event.gamepad.index}`); - event.pub(GAMEPAD_DISCONNECTED); - }); - - log.info('[input] joystick has been initialized'); - } + log.info('[input] joystick has been initialized'); } -})(event, env, KEY, navigator, window); +} diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index 7b46c2cb..1ccba499 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -1,131 +1,142 @@ +import { + pub, + sub, + KEYBOARD_TOGGLE_FILTER_MODE, + AXIS_CHANGED, + DPAD_TOGGLE, + KEY_PRESSED, + KEY_RELEASED, + KEYBOARD_KEY_PRESSED +} from 'event'; +import {KEY} from 'input'; +import {log} from 'log' +import {opts, settings} from 'settings'; + +// default keyboard bindings +const defaultMap = Object.freeze({ + ArrowLeft: KEY.LEFT, + ArrowUp: KEY.UP, + ArrowRight: KEY.RIGHT, + ArrowDown: KEY.DOWN, + KeyZ: KEY.A, + KeyX: KEY.B, + KeyC: KEY.X, + KeyV: KEY.Y, + KeyA: KEY.L, + KeyS: KEY.R, + Semicolon: KEY.L2, + Quote: KEY.R2, + Period: KEY.L3, + Slash: KEY.R3, + Enter: KEY.START, + ShiftLeft: KEY.SELECT, + // non-game + KeyQ: KEY.QUIT, + KeyW: KEY.JOIN, + KeyK: KEY.SAVE, + KeyL: KEY.LOAD, + Digit1: KEY.PAD1, + Digit2: KEY.PAD2, + Digit3: KEY.PAD3, + Digit4: KEY.PAD4, + KeyF: KEY.FULL, + KeyH: KEY.HELP, + Backslash: KEY.STATS, + Digit9: KEY.SETTINGS, + KeyT: KEY.DTOGGLE +}); + +let keyMap = {}; +let isKeysFilteredMode = true; + +const remap = (map = {}) => { + settings.set(opts.INPUT_KEYBOARD_MAP, map); + log.info('Keyboard keys have been remapped') +} + +sub(KEYBOARD_TOGGLE_FILTER_MODE, data => { + isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode; + log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`); +}); + +let dpadMode = true; +let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; + +function onDpadToggle(checked) { + if (dpadMode === checked) { + return //error? + } + + dpadMode = !dpadMode + if (dpadMode) { + // reset dpad keys pressed before moving to analog stick mode + for (const key in dpadState) { + if (dpadState[key]) { + dpadState[key] = false; + pub(KEY_RELEASED, {key: key}); + } + } + } else { + // reset analog stick axes before moving to dpad mode + if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) { + pub(AXIS_CHANGED, {id: 0, value: 0}); + } + if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) { + pub(AXIS_CHANGED, {id: 1, value: 0}); + } + dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; + } +} + +const onKey = (code, evt, state) => { + const key = keyMap[code] + if (key === undefined) return + + if (dpadState[key] !== undefined) { + dpadState[key] = state + if (!dpadMode) { + const LR = key === KEY.LEFT || key === KEY.RIGHT + pub(AXIS_CHANGED, { + id: !LR, + value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP] + }) + return + } + } + pub(evt, {key: key}) +} + +sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + /** * Keyboard controls. - * - * @version 1 */ -const keyboard = (() => { - // default keyboard bindings - const defaultMap = Object.freeze({ - ArrowLeft: KEY.LEFT, - ArrowUp: KEY.UP, - ArrowRight: KEY.RIGHT, - ArrowDown: KEY.DOWN, - KeyZ: KEY.A, - KeyX: KEY.B, - KeyC: KEY.X, - KeyV: KEY.Y, - KeyA: KEY.L, - KeyS: KEY.R, - Semicolon: KEY.L2, - Quote: KEY.R2, - Period: KEY.L3, - Slash: KEY.R3, - Enter: KEY.START, - ShiftLeft: KEY.SELECT, - // non-game - KeyQ: KEY.QUIT, - KeyW: KEY.JOIN, - KeyK: KEY.SAVE, - KeyL: KEY.LOAD, - Digit1: KEY.PAD1, - Digit2: KEY.PAD2, - Digit3: KEY.PAD3, - Digit4: KEY.PAD4, - KeyF: KEY.FULL, - KeyH: KEY.HELP, - Backslash: KEY.STATS, - Digit9: KEY.SETTINGS, - KeyT: KEY.DTOGGLE - }); - - let keyMap = {}; - let isKeysFilteredMode = true; - - const remap = (map = {}) => { - settings.set(opts.INPUT_KEYBOARD_MAP, map); - log.info('Keyboard keys have been remapped') - } - - event.sub(KEYBOARD_TOGGLE_FILTER_MODE, data => { - isKeysFilteredMode = data.mode !== undefined ? data.mode : !isKeysFilteredMode; - log.debug(`New keyboard filter mode: ${isKeysFilteredMode}`); - }); - - let dpadMode = true; - let dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; - - function onDpadToggle(checked) { - if (dpadMode === checked) { - return //error? - } - - dpadMode = !dpadMode - if (dpadMode) { - // reset dpad keys pressed before moving to analog stick mode - for (const key in dpadState) { - if (dpadState[key]) { - dpadState[key] = false; - event.pub(KEY_RELEASED, {key: key}); - } +export const keyboard = { + init: () => { + keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap); + const body = document.body; + // !to use prevent default as everyone + body.addEventListener('keyup', e => { + e.stopPropagation(); + if (isKeysFilteredMode) { + onKey(e.code, KEY_RELEASED, false) + } else { + pub(KEYBOARD_KEY_PRESSED, {key: e.code}); } - } else { - // reset analog stick axes before moving to dpad mode - if (!!dpadState[KEY.RIGHT] - !!dpadState[KEY.LEFT] !== 0) { - event.pub(AXIS_CHANGED, {id: 0, value: 0}); + }, false); + + body.addEventListener('keydown', e => { + e.stopPropagation(); + if (isKeysFilteredMode) { + onKey(e.code, KEY_PRESSED, true) + } else { + pub(KEYBOARD_KEY_PRESSED, {key: e.code}); } - if (!!dpadState[KEY.DOWN] - !!dpadState[KEY.UP] !== 0) { - event.pub(AXIS_CHANGED, {id: 1, value: 0}); - } - dpadState = {[KEY.LEFT]: false, [KEY.RIGHT]: false, [KEY.UP]: false, [KEY.DOWN]: false}; - } + }); + + log.info('[input] keyboard has been initialized'); + }, + settings: { + remap } - - const onKey = (code, evt, state) => { - const key = keyMap[code] - if (key === undefined) return - - if (dpadState[key] !== undefined) { - dpadState[key] = state - if (!dpadMode) { - const LR = key === KEY.LEFT || key === KEY.RIGHT - event.pub(AXIS_CHANGED, { - id: !LR, - value: !!dpadState[LR ? KEY.RIGHT : KEY.DOWN] - !!dpadState[LR ? KEY.LEFT : KEY.UP] - }) - return - } - } - event.pub(evt, {key: key}) - } - - event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - - return { - init: () => { - keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap); - const body = document.body; - // !to use prevent default as everyone - body.addEventListener('keyup', e => { - e.stopPropagation(); - if (isKeysFilteredMode) { - onKey(e.code, KEY_RELEASED, false) - } else { - event.pub(KEYBOARD_KEY_PRESSED, {key: e.code}); - } - }, false); - - body.addEventListener('keydown', e => { - e.stopPropagation(); - if (isKeysFilteredMode) { - onKey(e.code, KEY_PRESSED, true) - } else { - event.pub(KEYBOARD_KEY_PRESSED, {key: e.code}); - } - }); - - log.info('[input] keyboard has been initialized'); - }, settings: { - remap - } - } -})(event, document, KEY, log, opts, settings); +} diff --git a/web/js/input/keys.js b/web/js/input/keys.js index 7b16777c..6f94c2ff 100644 --- a/web/js/input/keys.js +++ b/web/js/input/keys.js @@ -1,34 +1,32 @@ -const KEY = (() => { - return { - A: 'a', - B: 'b', - X: 'x', - Y: 'y', - L: 'l', - R: 'r', - START: 'start', - SELECT: 'select', - LOAD: 'load', - SAVE: 'save', - HELP: 'help', - JOIN: 'join', - FULL: 'full', - QUIT: 'quit', - UP: 'up', - DOWN: 'down', - LEFT: 'left', - RIGHT: 'right', - PAD1: 'pad1', - PAD2: 'pad2', - PAD3: 'pad3', - PAD4: 'pad4', - STATS: 'stats', - SETTINGS: 'settings', - DTOGGLE: 'dtoggle', - L2: 'l2', - R2: 'r2', - L3: 'l3', - R3: 'r3', - REC: 'rec', - } -})(); +export const KEY = { + A: 'a', + B: 'b', + X: 'x', + Y: 'y', + L: 'l', + R: 'r', + START: 'start', + SELECT: 'select', + LOAD: 'load', + SAVE: 'save', + HELP: 'help', + JOIN: 'join', + FULL: 'full', + QUIT: 'quit', + UP: 'up', + DOWN: 'down', + LEFT: 'left', + RIGHT: 'right', + PAD1: 'pad1', + PAD2: 'pad2', + PAD3: 'pad3', + PAD4: 'pad4', + STATS: 'stats', + SETTINGS: 'settings', + DTOGGLE: 'dtoggle', + L2: 'l2', + R2: 'r2', + L3: 'l3', + R3: 'r3', + REC: 'rec', +} diff --git a/web/js/input/retropad.js b/web/js/input/retropad.js new file mode 100644 index 00000000..e841dea6 --- /dev/null +++ b/web/js/input/retropad.js @@ -0,0 +1,98 @@ +import { + pub, + CONTROLLER_UPDATED +} from 'event'; +import {KEY} from 'input' +import {log} from 'log'; + +const pollingIntervalMs = 4; +let controllerChangedIndex = -1; + +// Libretro config +let controllerState = { + [KEY.B]: false, + [KEY.Y]: false, + [KEY.SELECT]: false, + [KEY.START]: false, + [KEY.UP]: false, + [KEY.DOWN]: false, + [KEY.LEFT]: false, + [KEY.RIGHT]: false, + [KEY.A]: false, + [KEY.X]: false, + // extra + [KEY.L]: false, + [KEY.R]: false, + [KEY.L2]: false, + [KEY.R2]: false, + [KEY.L3]: false, + [KEY.R3]: false +}; + +const poll = (intervalMs, callback) => { + let _ticker = 0; + return { + enable: () => { + if (_ticker > 0) return; + log.debug(`[input] poll set to ${intervalMs}ms`); + _ticker = setInterval(callback, intervalMs) + }, + disable: () => { + if (_ticker < 1) return; + log.debug('[input] poll has been disabled'); + clearInterval(_ticker); + _ticker = 0; + } + } +}; + +const controllerEncoded = [0, 0, 0, 0, 0]; +const keys = Object.keys(controllerState); + +const sendControllerState = () => { + if (controllerChangedIndex >= 0) { + const state = _getState(); + pub(CONTROLLER_UPDATED, _encodeState(state)); + controllerChangedIndex = -1; + } +}; + +const setKeyState = (name, state) => { + if (controllerState[name] !== undefined) { + controllerState[name] = state; + controllerChangedIndex = Math.max(controllerChangedIndex, 0); + } +}; + +const setAxisChanged = (index, value) => { + if (controllerEncoded[index + 1] !== undefined) { + controllerEncoded[index + 1] = Math.floor(32767 * value); + controllerChangedIndex = Math.max(controllerChangedIndex, index + 1); + } +}; + +/** + * Converts key state into a bitmap and prepends it to the axes state. + * + * @returns {Uint16Array} The controller state. + * First uint16 is the controller state bitmap. + * The other uint16 are the axes values. + * Truncated to the last value changed. + * + * @private + */ +const _encodeState = (state) => new Uint16Array(state) + +const _getState = () => { + controllerEncoded[0] = 0; + for (let i = 0, len = keys.length; i < len; i++) { + controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0; + } + return controllerEncoded.slice(0, controllerChangedIndex + 1); +} + +export const retropad = { + poll: poll(pollingIntervalMs, sendControllerState), + setKeyState, + setAxisChanged, +} diff --git a/web/js/input/touch.js b/web/js/input/touch.js index a0a8c32d..a246ef56 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -1,3 +1,300 @@ +import {env} from 'env'; +import { + pub, + sub, + AXIS_CHANGED, + KEY_PRESSED, + KEY_RELEASED, + GAME_PLAYER_IDX, + DPAD_TOGGLE, + MENU_HANDLER_ATTACHED, + MENU_PRESSED, + MENU_RELEASED +} from 'event'; +import {KEY} from 'input'; +import {log} from 'log'; + +const MAX_DIFF = 20; // radius of circle boundary + +// vpad state, use for mouse button down +let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false}; +let analogState = [0, 0]; + +let vpadTouchIdx = null; +let vpadTouchDrag = null; +let vpadHolder = document.getElementById('circle-pad-holder'); +let vpadCircle = document.getElementById('circle-pad'); + +const buttons = Array.from(document.getElementsByClassName('btn')); +const playerSlider = document.getElementById('playeridx'); +const dpad = Array.from(document.getElementsByClassName('dpad')); + +const dpadToggle = document.getElementById('dpad-toggle') +dpadToggle.addEventListener('change', (e) => { + pub(DPAD_TOGGLE, {checked: e.target.checked}); +}); + +let dpadMode = true; +const deadZone = 0.1; + +function onDpadToggle(checked) { + if (dpadMode === checked) { + return //error? + } + if (dpadMode) { + dpadMode = false; + vpadHolder.classList.add('dpad-empty'); + vpadCircle.classList.add('bong-full'); + // reset dpad keys pressed before moving to analog stick mode + resetVpadState() + } else { + dpadMode = true; + vpadHolder.classList.remove('dpad-empty'); + vpadCircle.classList.remove('bong-full'); + } +} + +function resetVpadState() { + if (dpadMode) { + // trigger up event? + checkVpadState(KEY.UP, false); + checkVpadState(KEY.DOWN, false); + checkVpadState(KEY.LEFT, false); + checkVpadState(KEY.RIGHT, false); + } else { + checkAnalogState(0, 0); + checkAnalogState(1, 0); + } + + vpadTouchDrag = null; + vpadTouchIdx = null; + + dpad.forEach(arrow => arrow.classList.remove('pressed')); +} + +function checkVpadState(axis, state) { + if (state !== vpadState[axis]) { + vpadState[axis] = state; + pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis}); + } +} + +function checkAnalogState(axis, value) { + if (-deadZone < value && value < deadZone) value = 0; + if (analogState[axis] !== value) { + analogState[axis] = value; + pub(AXIS_CHANGED, {id: axis, value: value}); + } +} + +function handleVpadJoystickDown(event) { + vpadCircle.style['transition'] = '0s'; + + if (event.changedTouches) { + resetVpadState(); + vpadTouchIdx = event.changedTouches[0].identifier; + event.clientX = event.changedTouches[0].clientX; + event.clientY = event.changedTouches[0].clientY; + } + + vpadTouchDrag = {x: event.clientX, y: event.clientY}; +} + +function handleVpadJoystickUp() { + if (vpadTouchDrag === null) return; + + vpadCircle.style['transition'] = '.2s'; + vpadCircle.style['transform'] = 'translate3d(0px, 0px, 0px)'; + + resetVpadState(); +} + +function handleVpadJoystickMove(event) { + if (vpadTouchDrag === null) return; + + if (event.changedTouches) { + // check if moving source is from other touch? + for (let i = 0; i < event.changedTouches.length; i++) { + if (event.changedTouches[i].identifier === vpadTouchIdx) { + event.clientX = event.changedTouches[i].clientX; + event.clientY = event.changedTouches[i].clientY; + } + } + if (event.clientX === undefined || event.clientY === undefined) + return; + } + + let xDiff = event.clientX - vpadTouchDrag.x; + let yDiff = event.clientY - vpadTouchDrag.y; + let angle = Math.atan2(yDiff, xDiff); + let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff)); + let xNew = distance * Math.cos(angle); + let yNew = distance * Math.sin(angle); + + if (env.display().isLayoutSwitched) { + let tmp = xNew; + xNew = yNew; + yNew = -tmp; + } + + vpadCircle.style['transform'] = `translate(${xNew}px, ${yNew}px)`; + + let xRatio = xNew / MAX_DIFF; + let yRatio = yNew / MAX_DIFF; + + if (dpadMode) { + checkVpadState(KEY.LEFT, xRatio <= -0.5); + checkVpadState(KEY.RIGHT, xRatio >= 0.5); + checkVpadState(KEY.UP, yRatio <= -0.5); + checkVpadState(KEY.DOWN, yRatio >= 0.5); + } else { + checkAnalogState(0, xRatio); + checkAnalogState(1, yRatio); + } +} + +// right side - control buttons +const _handleButton = (key, state) => checkVpadState(key, state) + +function handleButtonDown() { + _handleButton(this.getAttribute('value'), true); +} + +function handleButtonUp() { + _handleButton(this.getAttribute('value'), false); +} + +function handleButtonClick() { + _handleButton(this.getAttribute('value'), true); + setTimeout(() => { + _handleButton(this.getAttribute('value'), false); + }, 30); +} + +function handlePlayerSlider() { + pub(GAME_PLAYER_IDX, {index: this.value - 1}); +} + +// Touch menu +let menuTouchIdx = null; +let menuTouchDrag = null; +let menuTouchTime = null; + +function handleMenuDown(event) { + // Identify of touch point + if (event.changedTouches) { + menuTouchIdx = event.changedTouches[0].identifier; + event.clientX = event.changedTouches[0].clientX; + event.clientY = event.changedTouches[0].clientY; + } + + menuTouchDrag = {x: event.clientX, y: event.clientY,}; + menuTouchTime = Date.now(); +} + +function handleMenuMove(evt) { + if (menuTouchDrag === null) return; + + if (evt.changedTouches) { + // check if moving source is from other touch? + for (let i = 0; i < evt.changedTouches.length; i++) { + if (evt.changedTouches[i].identifier === menuTouchIdx) { + evt.clientX = evt.changedTouches[i].clientX; + evt.clientY = evt.changedTouches[i].clientY; + } + } + if (evt.clientX === undefined || evt.clientY === undefined) + return; + } + + const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY; + pub(MENU_PRESSED, pos); +} + +function handleMenuUp(evt) { + if (menuTouchDrag === null) return; + if (evt.changedTouches) { + if (evt.changedTouches[0].identifier !== menuTouchIdx) + return; + evt.clientX = evt.changedTouches[0].clientX; + evt.clientY = evt.changedTouches[0].clientY; + } + + let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY; + + let interval = Date.now() - menuTouchTime; // 100ms? + if (interval < 200) { + // calc velocity + newY = newY / interval * 250; + } + + // current item? + pub(MENU_RELEASED, newY); + menuTouchDrag = null; +} + +// Common events +function handleWindowMove(event) { + event.preventDefault(); + handleVpadJoystickMove(event); + handleMenuMove(event); + + // moving touch + if (event.changedTouches) { + for (let i = 0; i < event.changedTouches.length; i++) { + if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) { + // check class + + let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY); + + if (elem.classList.contains('btn')) { + elem.dispatchEvent(new Event('touchstart')); + } else { + elem.dispatchEvent(new Event('touchend')); + } + } + } + } +} + +function handleWindowUp(ev) { + handleVpadJoystickUp(ev); + handleMenuUp(ev); + buttons.forEach((btn) => { + btn.dispatchEvent(new Event('touchend')); + }); +} + +// touch/mouse events for control buttons. mouseup events is bound to window. +buttons.forEach((btn) => { + btn.addEventListener('mousedown', handleButtonDown); + btn.addEventListener('touchstart', handleButtonDown, {passive: true}); + btn.addEventListener('touchend', handleButtonUp); +}); + +// touch/mouse events for dpad. mouseup events is bound to window. +vpadHolder.addEventListener('mousedown', handleVpadJoystickDown); +vpadHolder.addEventListener('touchstart', handleVpadJoystickDown, {passive: true}); +vpadHolder.addEventListener('touchend', handleVpadJoystickUp); + +dpad.forEach((arrow) => { + arrow.addEventListener('click', handleButtonClick); +}); + +// touch/mouse events for player slider. +playerSlider.addEventListener('oninput', handlePlayerSlider); +playerSlider.addEventListener('onchange', handlePlayerSlider); +playerSlider.addEventListener('click', handlePlayerSlider); +playerSlider.addEventListener('touchend', handlePlayerSlider); + +// Bind events for menu +// TODO change this flow +pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); +pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown}); +pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp}); + +sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + /** * Touch controls. * @@ -7,300 +304,17 @@ * @link https://jsfiddle.net/aa0et7tr/5/ * @version 1 */ -const touch = (() => { - const MAX_DIFF = 20; // radius of circle boundary - - // vpad state, use for mouse button down - let vpadState = {[KEY.UP]: false, [KEY.DOWN]: false, [KEY.LEFT]: false, [KEY.RIGHT]: false}; - let analogState = [0, 0]; - - let vpadTouchIdx = null; - let vpadTouchDrag = null; - let vpadHolder = document.getElementById('circle-pad-holder'); - let vpadCircle = document.getElementById('circle-pad'); - - const buttons = Array.from(document.getElementsByClassName('btn')); - const playerSlider = document.getElementById('playeridx'); - const dpad = Array.from(document.getElementsByClassName('dpad')); - - const dpadToggle = document.getElementById('dpad-toggle') - dpadToggle.addEventListener('change', (e) => { - event.pub(DPAD_TOGGLE, {checked: e.target.checked}); - }); - - let dpadMode = true; - const deadZone = 0.1; - - function onDpadToggle(checked) { - if (dpadMode === checked) { - return //error? - } - if (dpadMode) { - dpadMode = false; - vpadHolder.classList.add('dpad-empty'); - vpadCircle.classList.add('bong-full'); - // reset dpad keys pressed before moving to analog stick mode - resetVpadState() - } else { - dpadMode = true; - vpadHolder.classList.remove('dpad-empty'); - vpadCircle.classList.remove('bong-full'); - } - } - - function resetVpadState() { - if (dpadMode) { - // trigger up event? - checkVpadState(KEY.UP, false); - checkVpadState(KEY.DOWN, false); - checkVpadState(KEY.LEFT, false); - checkVpadState(KEY.RIGHT, false); - } else { - checkAnalogState(0, 0); - checkAnalogState(1, 0); - } - - vpadTouchDrag = null; - vpadTouchIdx = null; - - dpad.forEach(arrow => arrow.classList.remove('pressed')); - } - - function checkVpadState(axis, state) { - if (state !== vpadState[axis]) { - vpadState[axis] = state; - event.pub(state ? KEY_PRESSED : KEY_RELEASED, {key: axis}); - } - } - - function checkAnalogState(axis, value) { - if (-deadZone < value && value < deadZone) value = 0; - if (analogState[axis] !== value) { - analogState[axis] = value; - event.pub(AXIS_CHANGED, {id: axis, value: value}); - } - } - - function handleVpadJoystickDown(event) { - vpadCircle.style['transition'] = '0s'; - - if (event.changedTouches) { - resetVpadState(); - vpadTouchIdx = event.changedTouches[0].identifier; - event.clientX = event.changedTouches[0].clientX; - event.clientY = event.changedTouches[0].clientY; - } - - vpadTouchDrag = {x: event.clientX, y: event.clientY}; - } - - function handleVpadJoystickUp() { - if (vpadTouchDrag === null) return; - - vpadCircle.style['transition'] = '.2s'; - vpadCircle.style['transform'] = 'translate3d(0px, 0px, 0px)'; - - resetVpadState(); - } - - function handleVpadJoystickMove(event) { - if (vpadTouchDrag === null) return; - - if (event.changedTouches) { - // check if moving source is from other touch? - for (let i = 0; i < event.changedTouches.length; i++) { - if (event.changedTouches[i].identifier === vpadTouchIdx) { - event.clientX = event.changedTouches[i].clientX; - event.clientY = event.changedTouches[i].clientY; - } - } - if (event.clientX === undefined || event.clientY === undefined) - return; - } - - let xDiff = event.clientX - vpadTouchDrag.x; - let yDiff = event.clientY - vpadTouchDrag.y; - let angle = Math.atan2(yDiff, xDiff); - let distance = Math.min(MAX_DIFF, Math.hypot(xDiff, yDiff)); - let xNew = distance * Math.cos(angle); - let yNew = distance * Math.sin(angle); - - if (env.display().isLayoutSwitched) { - let tmp = xNew; - xNew = yNew; - yNew = -tmp; - } - - vpadCircle.style['transform'] = `translate(${xNew}px, ${yNew}px)`; - - let xRatio = xNew / MAX_DIFF; - let yRatio = yNew / MAX_DIFF; - - if (dpadMode) { - checkVpadState(KEY.LEFT, xRatio <= -0.5); - checkVpadState(KEY.RIGHT, xRatio >= 0.5); - checkVpadState(KEY.UP, yRatio <= -0.5); - checkVpadState(KEY.DOWN, yRatio >= 0.5); - } else { - checkAnalogState(0, xRatio); - checkAnalogState(1, yRatio); - } - } - - // right side - control buttons - const _handleButton = (key, state) => checkVpadState(key, state) - - function handleButtonDown() { - _handleButton(this.getAttribute('value'), true); - } - - function handleButtonUp() { - _handleButton(this.getAttribute('value'), false); - } - - function handleButtonClick() { - _handleButton(this.getAttribute('value'), true); - setTimeout(() => { - _handleButton(this.getAttribute('value'), false); - }, 30); - } - - function handlePlayerSlider() { - event.pub(GAME_PLAYER_IDX, {index: this.value - 1}); - } - - // Touch menu - let menuTouchIdx = null; - let menuTouchDrag = null; - let menuTouchTime = null; - - function handleMenuDown(event) { - // Identify of touch point - if (event.changedTouches) { - menuTouchIdx = event.changedTouches[0].identifier; - event.clientX = event.changedTouches[0].clientX; - event.clientY = event.changedTouches[0].clientY; - } - - menuTouchDrag = {x: event.clientX, y: event.clientY,}; - menuTouchTime = Date.now(); - } - - function handleMenuMove(evt) { - if (menuTouchDrag === null) return; - - if (evt.changedTouches) { - // check if moving source is from other touch? - for (let i = 0; i < evt.changedTouches.length; i++) { - if (evt.changedTouches[i].identifier === menuTouchIdx) { - evt.clientX = evt.changedTouches[i].clientX; - evt.clientY = evt.changedTouches[i].clientY; - } - } - if (evt.clientX === undefined || evt.clientY === undefined) - return; - } - - const pos = env.display().isLayoutSwitched ? evt.clientX - menuTouchDrag.x : menuTouchDrag.y - evt.clientY; - event.pub(MENU_PRESSED, pos); - } - - function handleMenuUp(evt) { - if (menuTouchDrag === null) return; - if (evt.changedTouches) { - if (evt.changedTouches[0].identifier !== menuTouchIdx) - return; - evt.clientX = evt.changedTouches[0].clientX; - evt.clientY = evt.changedTouches[0].clientY; - } - - let newY = env.display().isLayoutSwitched ? -menuTouchDrag.x + evt.clientX : menuTouchDrag.y - evt.clientY; - - let interval = Date.now() - menuTouchTime; // 100ms? - if (interval < 200) { - // calc velo - newY = newY / interval * 250; - } - - // current item? - event.pub(MENU_RELEASED, newY); - menuTouchDrag = null; - } - - // Common events - function handleWindowMove(event) { - event.preventDefault(); - handleVpadJoystickMove(event); - handleMenuMove(event); - - // moving touch - if (event.changedTouches) { - for (let i = 0; i < event.changedTouches.length; i++) { - if (event.changedTouches[i].identifier !== menuTouchIdx && event.changedTouches[i].identifier !== vpadTouchIdx) { - // check class - - let elem = document.elementFromPoint(event.changedTouches[i].clientX, event.changedTouches[i].clientY); - - if (elem.classList.contains('btn')) { - elem.dispatchEvent(new Event('touchstart')); - } else { - elem.dispatchEvent(new Event('touchend')); - } - } - } - } - } - - function handleWindowUp(ev) { - handleVpadJoystickUp(ev); - handleMenuUp(ev); - buttons.forEach((btn) => { - btn.dispatchEvent(new Event('touchend')); +export const touch = { + init: () => { + // add buttons into the state 🤦 + Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => { + vpadState[el.getAttribute('value')] = false; }); + + window.addEventListener('mousemove', handleWindowMove); + window.addEventListener('touchmove', handleWindowMove, {passive: false}); + window.addEventListener('mouseup', handleWindowUp); + + log.info('[input] touch input has been initialized'); } - - // touch/mouse events for control buttons. mouseup events is binded to window. - buttons.forEach((btn) => { - btn.addEventListener('mousedown', handleButtonDown); - btn.addEventListener('touchstart', handleButtonDown, {passive: true}); - btn.addEventListener('touchend', handleButtonUp); - }); - - // touch/mouse events for dpad. mouseup events is binded to window. - vpadHolder.addEventListener('mousedown', handleVpadJoystickDown); - vpadHolder.addEventListener('touchstart', handleVpadJoystickDown, {passive: true}); - vpadHolder.addEventListener('touchend', handleVpadJoystickUp); - - dpad.forEach((arrow) => { - arrow.addEventListener('click', handleButtonClick); - }); - - // touch/mouse events for player slider. - playerSlider.addEventListener('oninput', handlePlayerSlider); - playerSlider.addEventListener('onchange', handlePlayerSlider); - playerSlider.addEventListener('click', handlePlayerSlider); - playerSlider.addEventListener('touchend', handlePlayerSlider); - - // Bind events for menu - // TODO change this flow - event.pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); - event.pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown}); - event.pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp}); - - event.sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - - return { - init: () => { - // add buttons into the state 🤦 - Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => { - vpadState[el.getAttribute('value')] = false; - }); - - window.addEventListener('mousemove', handleWindowMove); - window.addEventListener('touchmove', handleWindowMove, {passive: false}); - window.addEventListener('mouseup', handleWindowUp); - - log.info('[input] touch input has been initialized'); - } - } -})(document, event, KEY, window); +} diff --git a/web/js/log.js b/web/js/log.js index af138188..2c316225 100644 --- a/web/js/log.js +++ b/web/js/log.js @@ -1,35 +1,31 @@ +const noop = () => ({}) + +const _log = { + ASSERT: 1, + ERROR: 2, + WARN: 3, + INFO: 4, + DEBUG: 5, + TRACE: 6, + + DEFAULT: 5, + + set level(level) { + this.assert = level >= this.ASSERT ? console.assert.bind(window.console) : noop; + this.error = level >= this.ERROR ? console.error.bind(window.console) : noop; + this.warn = level >= this.WARN ? console.warn.bind(window.console) : noop; + this.info = level >= this.INFO ? console.info.bind(window.console) : noop; + this.debug = level >= this.DEBUG ? console.debug.bind(window.console) : noop; + this.trace = level >= this.TRACE ? console.log.bind(window.console) : noop; + this._level = level; + }, + get level() { + return this._level; + } +} +_log.level = _log.DEFAULT; + /** * Logging module. - * - * @version 2 */ -const log = (() => { - const noop = () => ({}) - - const _log = { - ASSERT: 1, - ERROR: 2, - WARN: 3, - INFO: 4, - DEBUG: 5, - TRACE: 6, - - DEFAULT: 5, - - set level(level) { - this.assert = level >= this.ASSERT ? console.assert.bind(window.console) : noop; - this.error = level >= this.ERROR ? console.error.bind(window.console) : noop; - this.warn = level >= this.WARN ? console.warn.bind(window.console) : noop; - this.info = level >= this.INFO ? console.info.bind(window.console) : noop; - this.debug = level >= this.DEBUG ? console.debug.bind(window.console) : noop; - this.trace = level >= this.TRACE ? console.log.bind(window.console) : noop; - this._level = level; - }, - get level() { - return this._level; - } - } - _log.level = _log.DEFAULT; - - return _log -})(console, window); +export const log = _log diff --git a/web/js/message.js b/web/js/message.js new file mode 100644 index 00000000..41e8e66e --- /dev/null +++ b/web/js/message.js @@ -0,0 +1,44 @@ +import {gui} from 'gui'; + +const popupBox = document.getElementById('noti-box'); + +// fifo queue +let queue = []; +const queueMaxSize = 5; + +let isScreenFree = true; + +const _popup = (time = 1000) => { + // recursion edge case: + // no messages in the queue or one on the screen + if (!(queue.length > 0 && isScreenFree)) { + return; + } + + isScreenFree = false; + popupBox.innerText = queue.shift(); + gui.anim.fadeInOut(popupBox, time, .05).finally(() => { + isScreenFree = true; + _popup(); + }) +} + +const _storeMessage = (text) => { + if (queue.length <= queueMaxSize) { + queue.push(text); + } +} + +const _proceed = (text, time) => { + _storeMessage(text); + _popup(time); +} + +const show = (text, time = 1000) => _proceed(text, time) + +/** + * App UI message module. + */ +export const message = { + show, +} diff --git a/web/js/network/ajax.js b/web/js/network/ajax.js index 6f8c69c2..c2f09ccd 100644 --- a/web/js/network/ajax.js +++ b/web/js/network/ajax.js @@ -1,29 +1,26 @@ +const defaultTimeout = 10000; /** * AJAX request module. * @version 1 */ -const ajax = (() => { - const defaultTimeout = 10000; +export const ajax = { + fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => { + const controller = new AbortController(); + const signal = controller.signal; + const allOptions = Object.assign({}, options, signal); - return { - fetch: (url, options, timeout = defaultTimeout) => new Promise((resolve, reject) => { - const controller = new AbortController(); - const signal = controller.signal; - const allOptions = Object.assign({}, options, signal); - - // fetch(url, {...options, signal}) - fetch(url, allOptions) - .then(resolve, () => { - controller.abort(); - return reject - }); - - // auto abort when a timeout reached - setTimeout(() => { + // fetch(url, {...options, signal}) + fetch(url, allOptions) + .then(resolve, () => { controller.abort(); - reject(); - }, timeout); - }), - defaultTimeoutMs: () => defaultTimeout - } -})(); \ No newline at end of file + return reject + }); + + // auto abort when a timeout reached + setTimeout(() => { + controller.abort(); + reject(); + }, timeout); + }), + defaultTimeoutMs: () => defaultTimeout +} diff --git a/web/js/network/network.js b/web/js/network/network.js new file mode 100644 index 00000000..ca21be6a --- /dev/null +++ b/web/js/network/network.js @@ -0,0 +1,3 @@ +export {ajax} from './ajax.js?v=3'; +export {socket} from './socket.js?v=3'; +export {webrtc} from './webrtc.js?v=3'; diff --git a/web/js/network/socket.js b/web/js/network/socket.js index 06351246..47314d9d 100644 --- a/web/js/network/socket.js +++ b/web/js/network/socket.js @@ -1,53 +1,51 @@ +import { + pub, + MESSAGE +} from 'event'; +import {log} from 'log'; + +let conn; + +const buildUrl = (params = {}) => { + const url = new URL(window.location); + url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss'; + url.pathname = "/ws"; + Object.keys(params).forEach(k => { + if (!!params[k]) url.searchParams.set(k, params[k]) + }) + return url +} + +const init = (roomId, wid, zone) => { + let objParams = {room_id: roomId, zone: zone}; + if (wid) objParams.wid = wid; + const url = buildUrl(objParams) + console.info(`[ws] connecting to ${url}`); + conn = new WebSocket(url.toString()); + conn.onopen = () => { + log.info('[ws] <- open connection'); + }; + conn.onerror = () => log.error('[ws] some error!'); + conn.onclose = (event) => log.info(`[ws] closed (${event.code})`); + conn.onmessage = response => { + const data = JSON.parse(response.data); + log.debug('[ws] <- ', data); + pub(MESSAGE, data); + }; +}; + +const send = (data) => { + if (conn.readyState === 1) { + conn.send(JSON.stringify(data)); + } +} + /** * WebSocket connection module. * * Needs init() call. - * - * @version 1 - * - * Events: - * @link MESSAGE - * */ -const socket = (() => { - let conn; - - const buildUrl = (params = {}) => { - const url = new URL(window.location); - url.protocol = location.protocol !== 'https:' ? 'ws' : 'wss'; - url.pathname = "/ws"; - Object.keys(params).forEach(k => { - if (!!params[k]) url.searchParams.set(k, params[k]) - }) - return url - } - - const init = (roomId, wid, zone) => { - let objParams = {room_id: roomId, zone: zone}; - if (wid) objParams.wid = wid; - const url = buildUrl(objParams) - console.info(`[ws] connecting to ${url}`); - conn = new WebSocket(url.toString()); - conn.onopen = () => { - log.info('[ws] <- open connection'); - }; - conn.onerror = () => log.error('[ws] some error!'); - conn.onclose = (event) => log.info(`[ws] closed (${event.code})`); - conn.onmessage = response => { - const data = JSON.parse(response.data); - log.debug('[ws] <- ', data); - event.pub(MESSAGE, data); - }; - }; - - const send = (data) => { - if (conn.readyState === 1) { - conn.send(JSON.stringify(data)); - } - } - - return { - init: init, - send: send, - } -})(event, log); +export const socket = { + init, + send +} diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js index 544a9370..5e8ae47d 100644 --- a/web/js/network/webrtc.js +++ b/web/js/network/webrtc.js @@ -1,177 +1,176 @@ -/** - * WebRTC connection module. - * @version 1 - * - * Events: - * @link WEBRTC_CONNECTION_CLOSED - * @link WEBRTC_CONNECTION_READY - * @link WEBRTC_ICE_CANDIDATE_FOUND - * @link WEBRTC_ICE_CANDIDATES_FLUSH - * @link WEBRTC_SDP_ANSWER - * - */ -const webrtc = (() => { - let connection; - let dataChannel; - let mediaStream; - let candidates = []; - let isAnswered = false; - let isFlushing = false; +import { + pub, + WEBRTC_CONNECTION_CLOSED, + WEBRTC_CONNECTION_READY, + WEBRTC_ICE_CANDIDATE_FOUND, + WEBRTC_ICE_CANDIDATES_FLUSH, + WEBRTC_SDP_ANSWER +} from 'event'; +import {log} from 'log'; - let connected = false; - let inputReady = false; +let connection; +let dataChannel; +let mediaStream; +let candidates = []; +let isAnswered = false; +let isFlushing = false; - let onData; +let connected = false; +let inputReady = false; - const start = (iceservers) => { - log.info('[rtc] <- ICE servers', iceservers); - const servers = iceservers || []; - connection = new RTCPeerConnection({iceServers: servers}); - mediaStream = new MediaStream(); +let onData; - connection.ondatachannel = e => { - log.debug('[rtc] ondatachannel', e.channel.label) - e.channel.binaryType = "arraybuffer"; +const start = (iceservers) => { + log.info('[rtc] <- ICE servers', iceservers); + const servers = iceservers || []; + connection = new RTCPeerConnection({iceServers: servers}); + mediaStream = new MediaStream(); - dataChannel = e.channel; - dataChannel.onopen = () => { - log.info('[rtc] the input channel has been opened'); - inputReady = true; - event.pub(WEBRTC_CONNECTION_READY) - }; - if (onData) { - dataChannel.onmessage = onData; - } - dataChannel.onclose = () => log.info('[rtc] the input channel has been closed'); + connection.ondatachannel = e => { + log.debug('[rtc] ondatachannel', e.channel.label) + e.channel.binaryType = "arraybuffer"; + + dataChannel = e.channel; + dataChannel.onopen = () => { + log.info('[rtc] the input channel has been opened'); + inputReady = true; + pub(WEBRTC_CONNECTION_READY) + }; + if (onData) { + dataChannel.onmessage = onData; } - connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; - connection.onicegatheringstatechange = ice.onIceStateChange; - connection.onicecandidate = ice.onIcecandidate; - connection.ontrack = event => { - mediaStream.addTrack(event.track); - } - }; - - const stop = () => { - if (mediaStream) { - mediaStream.getTracks().forEach(t => { - t.stop(); - mediaStream.removeTrack(t); - }); - mediaStream = null; - } - if (connection) { - connection.close(); - connection = null; - } - if (dataChannel) { - dataChannel.close(); - dataChannel = null; - } - candidates = Array(); - log.info('[rtc] WebRTC has been closed'); + dataChannel.onclose = () => log.info('[rtc] the input channel has been closed'); } + connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; + connection.onicegatheringstatechange = ice.onIceStateChange; + connection.onicecandidate = ice.onIcecandidate; + connection.ontrack = event => { + mediaStream.addTrack(event.track); + } +}; - const ice = (() => { - const ICE_TIMEOUT = 2000; - let timeForIceGathering; +const stop = () => { + if (mediaStream) { + mediaStream.getTracks().forEach(t => { + t.stop(); + mediaStream.removeTrack(t); + }); + mediaStream = null; + } + if (connection) { + connection.close(); + connection = null; + } + if (dataChannel) { + dataChannel.close(); + dataChannel = null; + } + candidates = []; + log.info('[rtc] WebRTC has been closed'); +} - return { - onIcecandidate: data => { - if (!data.candidate) return; - log.info('[rtc] user candidate', data.candidate); - event.pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate}) - }, - onIceStateChange: event => { - switch (event.target.iceGatheringState) { - case 'gathering': - log.info('[rtc] ice gathering'); - timeForIceGathering = setTimeout(() => { - log.warn(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`); - // sendCandidates(); - }, ICE_TIMEOUT); - break; - case 'complete': - log.info('[rtc] ice gathering has been completed'); - if (timeForIceGathering) { - clearTimeout(timeForIceGathering); - } - } - }, - onIceConnectionStateChange: () => { - log.info('[rtc] <- iceConnectionState', connection.iceConnectionState); - switch (connection.iceConnectionState) { - case 'connected': { - log.info('[rtc] connected...'); - connected = true; - break; - } - case 'disconnected': { - log.info(`[rtc] disconnected... ` + - `connection: ${connection.connectionState}, ice: ${connection.iceConnectionState}, ` + - `gathering: ${connection.iceGatheringState}, signalling: ${connection.signalingState}`) - connected = false; - event.pub(WEBRTC_CONNECTION_CLOSED); - break; - } - case 'failed': { - log.error('[rtc] failed establish connection, retry...'); - connected = false; - connection.createOffer({iceRestart: true}) - .then(description => connection.setLocalDescription(description).catch(log.error)) - .catch(log.error); - break; - } - } - } - } - })(); +const ice = (() => { + const ICE_TIMEOUT = 2000; + let timeForIceGathering; return { - start: start, - setRemoteDescription: async (data, media) => { - log.debug('[rtc] remote SDP', data) - const offer = new RTCSessionDescription(JSON.parse(atob(data))); - await connection.setRemoteDescription(offer); - - const answer = await connection.createAnswer(); - // Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround - // force stereo params for Opus tracks (a=fmtp:111 ...) - answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1'); - await connection.setLocalDescription(answer); - log.debug("[rtc] local SDP", answer) - - isAnswered = true; - event.pub(WEBRTC_ICE_CANDIDATES_FLUSH); - event.pub(WEBRTC_SDP_ANSWER, {sdp: answer}); - media.srcObject = mediaStream; + onIcecandidate: data => { + if (!data.candidate) return; + log.info('[rtc] user candidate', data.candidate); + pub(WEBRTC_ICE_CANDIDATE_FOUND, {candidate: data.candidate}) }, - addCandidate: (data) => { - if (data === '') { - event.pub(WEBRTC_ICE_CANDIDATES_FLUSH); - } else { - candidates.push(data); + onIceStateChange: event => { + switch (event.target.iceGatheringState) { + case 'gathering': + log.info('[rtc] ice gathering'); + timeForIceGathering = setTimeout(() => { + log.warn(`[rtc] ice gathering was aborted due to timeout ${ICE_TIMEOUT}ms`); + // sendCandidates(); + }, ICE_TIMEOUT); + break; + case 'complete': + log.info('[rtc] ice gathering has been completed'); + if (timeForIceGathering) { + clearTimeout(timeForIceGathering); + } } }, - flushCandidates: () => { - if (isFlushing || !isAnswered) return; - isFlushing = true; - log.debug('[rtc] flushing candidates', candidates); - candidates.forEach(data => { - const candidate = new RTCIceCandidate(JSON.parse(atob(data))) - connection.addIceCandidate(candidate).catch(e => { - log.error('[rtc] candidate add failed', e.name); - }); - }); - isFlushing = false; - }, - input: (data) => dataChannel.send(data), - isConnected: () => connected, - isInputReady: () => inputReady, - getConnection: () => connection, - stop, - set onData(fn) { - onData = fn + onIceConnectionStateChange: () => { + log.info('[rtc] <- iceConnectionState', connection.iceConnectionState); + switch (connection.iceConnectionState) { + case 'connected': { + log.info('[rtc] connected...'); + connected = true; + break; + } + case 'disconnected': { + log.info(`[rtc] disconnected... ` + + `connection: ${connection.connectionState}, ice: ${connection.iceConnectionState}, ` + + `gathering: ${connection.iceGatheringState}, signalling: ${connection.signalingState}`) + connected = false; + pub(WEBRTC_CONNECTION_CLOSED); + break; + } + case 'failed': { + log.error('[rtc] failed establish connection, retry...'); + connected = false; + connection.createOffer({iceRestart: true}) + .then(description => connection.setLocalDescription(description).catch(log.error)) + .catch(log.error); + break; + } + } } } -})(event, log); +})(); + +/** + * WebRTC connection module. + */ +export const webrtc = { + start, + setRemoteDescription: async (data, media) => { + log.debug('[rtc] remote SDP', data) + const offer = new RTCSessionDescription(JSON.parse(atob(data))); + await connection.setRemoteDescription(offer); + + const answer = await connection.createAnswer(); + // Chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=818180 workaround + // force stereo params for Opus tracks (a=fmtp:111 ...) + answer.sdp = answer.sdp.replace(/(a=fmtp:111 .*)/g, '$1;stereo=1'); + await connection.setLocalDescription(answer); + log.debug("[rtc] local SDP", answer) + + isAnswered = true; + pub(WEBRTC_ICE_CANDIDATES_FLUSH); + pub(WEBRTC_SDP_ANSWER, {sdp: answer}); + media.srcObject = mediaStream; + }, + addCandidate: (data) => { + if (data === '') { + pub(WEBRTC_ICE_CANDIDATES_FLUSH); + } else { + candidates.push(data); + } + }, + flushCandidates: () => { + if (isFlushing || !isAnswered) return; + isFlushing = true; + log.debug('[rtc] flushing candidates', candidates); + candidates.forEach(data => { + const candidate = new RTCIceCandidate(JSON.parse(atob(data))) + connection.addIceCandidate(candidate).catch(e => { + log.error('[rtc] candidate add failed', e.name); + }); + }); + isFlushing = false; + }, + input: (data) => dataChannel.send(data), + isConnected: () => connected, + isInputReady: () => inputReady, + getConnection: () => connection, + stop, + set onData(fn) { + onData = fn + } +} diff --git a/web/js/recording.js b/web/js/recording.js index b78cc01e..70f18ad0 100644 --- a/web/js/recording.js +++ b/web/js/recording.js @@ -1,64 +1,66 @@ -const RECORDING_ON = 1; -const RECORDING_OFF = 0; -const RECORDING_REC = 2; +import { + pub, + KEYBOARD_TOGGLE_FILTER_MODE, + RECORDING_TOGGLED +} from 'event'; +import {throttle} from 'utils'; -/** - * Recording module. - * @version 1 - */ -const recording = (() => { - const userName = document.getElementById('user-name'), - recButton = document.getElementById('btn-rec'); +export const RECORDING_ON = 1; +export const RECORDING_OFF = 0; +export const RECORDING_REC = 2; - if (!userName || !recButton) { - return { - isActive: () => false, - getUser: () => '', +const userName = document.getElementById('user-name'), + recButton = document.getElementById('btn-rec'); + +let state = { + userName: '', + state: RECORDING_OFF, +}; + +const restoreLastState = () => { + const lastState = localStorage.getItem('recording'); + if (lastState) { + const _last = JSON.parse(lastState); + if (_last) { + state = _last; } } + userName.value = state.userName +} - let state = { - userName: '', - state: RECORDING_OFF, - }; +const setRec = (val) => { + recButton.classList.toggle('record', val); +} +const setIndicator = (val) => { + recButton.classList.toggle('blink', val); +}; - const restoreLastState = () => { - const lastState = localStorage.getItem('recording'); - if (lastState) { - const _last = JSON.parse(lastState); - if (_last) { - state = _last; - } - } - userName.value = state.userName - } +// persistence +const saveLastState = () => { + const _state = Object.keys(state) + .filter(key => !key.startsWith('_')) + .reduce((obj, key) => ({...obj, [key]: state[key]}), {}); + localStorage.setItem('recording', JSON.stringify(_state)); +} +const saveUserName = throttle(() => { + state.userName = userName.value; + saveLastState(); +}, 500) - const setRec = (val) => { - recButton.classList.toggle('record', val); - } - const setIndicator = (val) => { - recButton.classList.toggle('blink', val); - }; - - // persistence - const saveLastState = () => { - const _state = Object.keys(state) - .filter(key => !key.startsWith('_')) - .reduce((obj, key) => ({...obj, [key]: state[key]}), {}); - localStorage.setItem('recording', JSON.stringify(_state)); - } - const saveUserName = utils.throttle(() => { - state.userName = userName.value; - saveLastState(); - }, 500) +let _recording = { + isActive: () => false, + getUser: () => '', + setIndicator: () => ({}), +} +if (userName && recButton) { restoreLastState(); setIndicator(false); setRec(state.state === RECORDING_ON) // text - userName.addEventListener('focus', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE)) - userName.addEventListener('blur', () => event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true})) + userName.addEventListener('focus', () => pub(KEYBOARD_TOGGLE_FILTER_MODE)) + userName.addEventListener('blur', () => pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true})) userName.addEventListener('keyup', ev => { ev.stopPropagation(); saveUserName() @@ -70,11 +72,17 @@ const recording = (() => { const active = state.state === RECORDING_ON setRec(active) saveLastState() - event.pub(RECORDING_TOGGLED, {userName: state.userName, recording: active}) + pub(RECORDING_TOGGLED, {userName: state.userName, recording: active}) }) - return { + + _recording = { isActive: () => state.state > 0, getUser: () => state.userName, - setIndicator: setIndicator, + setIndicator, } -})(document, event, localStorage, utils); +} + +/** + * Recording module. + */ +export const recording = _recording diff --git a/web/js/room.js b/web/js/room.js index 20a53a73..f8f2e37f 100644 --- a/web/js/room.js +++ b/web/js/room.js @@ -1,76 +1,79 @@ +import { + sub, + GAME_ROOM_AVAILABLE +} from 'event'; + +let id = ''; + +// UI +const roomLabel = document.getElementById('room-txt'); + +// !to rewrite +const parseURLForRoom = () => { + let queryDict = {}; + let regex = /^\/?([A-Za-z]*)\/?/g; + const zone = regex.exec(location.pathname)[1]; + let room = null; + + // get room from URL + location.search.substr(1) + .split('&') + .forEach((item) => { + queryDict[item.split('=')[0]] = item.split('=')[1] + }); + + if (typeof queryDict.id === 'string') { + room = decodeURIComponent(queryDict.id); + } + + return [room, zone]; +}; + +sub(GAME_ROOM_AVAILABLE, data => { + room.setId(data.roomId); + room.save(data.roomId); +}, 1); + /** * Game room module. - * @version 1 */ -const room = (() => { - let id = ''; +export const room = { + getId: () => id, + setId: (id_) => { + id = id_; + roomLabel.value = id; + }, + reset: () => { + id = ''; + roomLabel.value = id; + }, + save: (roomIndex) => { + localStorage.setItem('roomID', roomIndex); + }, + load: () => localStorage.getItem('roomID'), + getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`, + loadMaybe: () => { + // localStorage first + //roomID = loadRoomID(); + let zone = ''; - // UI - const roomLabel = document.getElementById('room-txt'); - - // !to rewrite - const parseURLForRoom = () => { - let queryDict = {}; - let regex = /^\/?([A-Za-z]*)\/?/g; - const zone = regex.exec(location.pathname)[1]; - let room = null; - - // get room from URL - location.search.substr(1) - .split('&') - .forEach((item) => { - queryDict[item.split('=')[0]] = item.split('=')[1] - }); - - if (typeof queryDict.id === 'string') { - room = decodeURIComponent(queryDict.id); + // Shared URL second + const [parsedId, czone] = parseURLForRoom(); + if (parsedId !== null) { + id = parsedId; + } + if (czone !== null) { + zone = czone; } - return [room, zone]; - }; - - event.sub(GAME_ROOM_AVAILABLE, data => { - room.setId(data.roomId); - room.save(data.roomId); - }, 1); - - return { - getId: () => id, - setId: (id_) => { - id = id_; - roomLabel.value = id; - }, - reset: () => { - id = ''; - roomLabel.value = id; - }, - save: (roomIndex) => { - localStorage.setItem('roomID', roomIndex); - }, - load: () => localStorage.getItem('roomID'), - getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`, - loadMaybe: () => { - // localStorage first - //roomID = loadRoomID(); - - // Shared URL second - const [parsedId, czone] = parseURLForRoom(); - if (parsedId !== null) { - id = parsedId; - } - if (czone !== null) { - zone = czone; - } - - return [id, zone]; - }, - copyToClipboard: () => { - const el = document.createElement('textarea'); - el.value = room.getLink(); - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); - document.body.removeChild(el); - } + return [id, zone]; + }, + copyToClipboard: () => { + const el = document.createElement('textarea'); + el.value = room.getLink(); + document.body.appendChild(el); + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); } -})(document, event, location, localStorage, window); +} diff --git a/web/js/settings.js b/web/js/settings.js new file mode 100644 index 00000000..e63b1394 --- /dev/null +++ b/web/js/settings.js @@ -0,0 +1,537 @@ +import { + pub, + sub, + SETTINGS_CHANGED, + KEYBOARD_KEY_PRESSED, + KEYBOARD_TOGGLE_FILTER_MODE +} from 'event'; +import {gui} from 'gui'; +import {log} from 'log'; + +/** + * Stores app wide option names. + * + * Use the following format: + * UPPERCASE_NAME: 'uppercase.name' + * + * @version 1 + */ +export const opts = { + _VERSION: '_version', + LOG_LEVEL: 'log.level', + INPUT_KEYBOARD_MAP: 'input.keyboard.map', + MIRROR_SCREEN: 'mirror.screen', + VOLUME: 'volume', + FORCE_FULLSCREEN: 'force.fullscreen' +} + + +// internal structure version +const revision = 1.51; + +// default settings +// keep them for revert to defaults option +const _defaults = Object.create(null); +_defaults[opts._VERSION] = revision; + +/** + * The main store with settings passed around by reference + * (because of that we need a wrapper object) + * don't do this at work (it's faster to write than immutable code). + * + * @type {{settings: {_version: number}}} + */ +let store = { + settings: { + ..._defaults + } +}; +let provider; + +/** + * Enum for settings types (the explicit type of a key-value pair). + * + * @readonly + * @enum {number} + */ +const option = Object.freeze({undefined: 0, string: 1, number: 2, object: 3, list: 4}); + +const exportFileName = `cloud-game.settings.v${revision}.txt`; + +const getStore = () => store.settings; + +/** + * The NullObject provider if everything else fails. + */ +const voidProvider = (store_ = {settings: {}}) => { + const nil = () => ({}) + + return { + get: key => store_.settings[key], + set: nil, + remove: nil, + save: nil, + loadSettings: nil, + reset: nil, + } +} + +/** + * The LocalStorage backend for our settings (store). + * + * For simplicity it will rewrite all the settings on every store change. + * If you want to roll your own, then use its "interface". + */ +const localStorageProvider = ((store_ = {settings: {}}) => { + if (!_isSupported()) return; + + const root = 'settings'; + + const _serialize = data => JSON.stringify(data, null, 2); + + const save = () => localStorage.setItem(root, _serialize(store_.settings)); + + function _isSupported() { + const testKey = '_test_42'; + try { + // check if it's writable and isn't full + localStorage.setItem(testKey, testKey); + localStorage.removeItem(testKey); + return true; + } catch (e) { + log.error(e); + return false; + } + } + + const get = key => JSON.parse(localStorage.getItem(key)); + + const set = () => save(); + + const remove = () => save(); + + const loadSettings = () => { + if (!localStorage.getItem(root)) save(); + store_.settings = JSON.parse(localStorage.getItem(root)); + } + + const reset = () => { + localStorage.removeItem(root); + localStorage.setItem(root, _serialize(store_.settings)); + } + + return { + get, + clear: () => localStorage.removeItem(root), + set, + remove, + save, + loadSettings, + reset, + } +}); + +/** + * Nuke existing settings with provided data. + * @param text The text to extract data from. + * @private + */ +const _import = text => { + try { + for (const property of Object.getOwnPropertyNames(store.settings)) delete store.settings[property]; + Object.assign(store.settings, JSON.parse(text).settings); + provider.save(); + pub(SETTINGS_CHANGED); + } catch (e) { + log.error(`Your import file is broken!`); + } + + _render(); +} + +const _export = () => { + let el = document.createElement('a'); + el.setAttribute( + 'href', + `data:text/plain;charset=utf-8,${encodeURIComponent(JSON.stringify(store, null, 2))}` + ); + el.setAttribute('download', exportFileName); + el.style.display = 'none'; + document.body.appendChild(el); + el.click(); + document.body.removeChild(el); +} + +const init = () => { + // try to load settings from the localStorage with fallback to null-object + provider = localStorageProvider(store) || voidProvider(store); + provider.loadSettings(); + + const lastRev = (store.settings || {_version: 0})._version + + if (revision > lastRev) { + log.warn(`Your settings are in older format (v${lastRev}) and will be reset to (v${revision})!`); + _reset(); + } +} + +const get = () => store.settings; + +const _isLoaded = key => store.settings.hasOwnProperty(key); + +/** + * Tries to load settings by some key. + * + * @param key A key to find values with. + * @param default_ The default values to set if none exist. + * @returns A slice of the settings with the given key or a copy of the value. + */ +const loadOr = (key, default_) => { + // preserve defaults + _defaults[key] = default_; + + if (!_isLoaded(key)) { + store.settings[key] = {}; + set(key, default_); + } else { + // !to check if settings do have new properties from default & update + // or it have ones that defaults doesn't + } + + return store.settings[key]; +} + +const set = (key, value, updateProvider = true) => { + const type = _getType(value); + + // mutate settings w/o changing the reference + switch (type) { + case option.list: + store.settings[key].splice(0, Infinity, ...value); + break; + case option.object: + for (let option of Object.keys(value)) { + log.debug(`Change key [${option}] from ${store.settings[key][option]} to ${value[option]}`); + store.settings[key][option] = value[option]; + } + break; + case option.string: + case option.number: + case option.undefined: + default: + store.settings[key] = value; + } + + if (updateProvider) { + provider.set(key, value); + pub(SETTINGS_CHANGED); + } +} + +const _reset = () => { + for (let _option of Object.keys(_defaults)) { + const value = _defaults[_option]; + + // delete all sub-options not in defaults + if (_getType(value) === option.object) { + for (let opt of Object.keys(store.settings[_option])) { + const prev = store.settings[_option][opt]; + const isDeleted = delete store.settings[_option][opt]; + log.debug(`User option [${opt}=${prev}] has been deleted (${isDeleted}) from the [${_option}]`); + } + } + + set(_option, value, false); + } + + provider.reset(); + pub(SETTINGS_CHANGED); +} + +const remove = (key, subKey) => { + const isRemoved = subKey !== undefined ? delete store.settings[key][subKey] : delete store.settings[key]; + if (!isRemoved) log.warn(`The key: ${key + (subKey ? '.' + subKey : '')} wasn't deleted!`); + provider.remove(key, subKey); +} + +const _render = () => { + renderer.data = panel.contentEl; + renderer.render() +} + +const panel = gui.panel(document.getElementById('settings'), '> OPTIONS', 'settings', null, [ + {caption: 'Export', handler: () => _export(), title: 'Save',}, + {caption: 'Import', handler: () => _fileReader.read(onFileLoad), title: 'Load',}, + { + caption: 'Reset', + handler: () => { + if (window.confirm("Are you sure want to reset your settings?")) { + _reset(); + pub(SETTINGS_CHANGED); + } + }, + title: 'Reset', + }, + {} + ], + (show) => { + if (show) { + _render(); + return; + } + + // to make sure it's disabled, but it's a tad verbose + pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); + }) + +function _getType(value) { + if (value === undefined) return option.undefined + else if (Array.isArray(value)) return option.list + else if (typeof value === 'object' && value !== null) return option.object + else if (typeof value === 'string') return option.string + else if (typeof value === 'number') return option.number + else return option.undefined; +} + +const _fileReader = (() => { + let callback_ = () => ({}) + + const el = document.createElement('input'); + const reader = new FileReader(); + + el.type = 'file'; + el.accept = '.txt'; + el.onchange = event => event.target.files.length && reader.readAsBinaryString(event.target.files[0]); + reader.onload = event => callback_(event.target.result); + + return { + read: callback => { + callback_ = callback; + el.click(); + }, + } +})(); + +const onFileLoad = text => { + try { + _import(text); + } catch (e) { + log.error(`Couldn't read your settings!`, e); + } +} + +sub(SETTINGS_CHANGED, _render); + +/** + * App settings module. + * + * So the basic idea is to let app modules request their settings + * from an abstract store first, and if the store doesn't contain such settings yet, + * then let the store to take default values from the module to save them before that. + * The return value with the settings is gonna be a slice of in-memory structure + * backed by a data provider (localStorage). + * Doing it this way allows us to considerably simplify the code and make sure that + * exposed settings will have the latest values without additional update/get calls. + */ +export const settings = { + init, + loadOr, + getStore, + get, + set, + remove, + import: _import, + export: _export, + ui: { + set onToggle(fn) { + panel.onToggle(fn); + }, + toggle: () => panel.toggle(), + }, +} + +// don't show these options (i.e. ignored = {'_version': 1}) +const ignored = {'_version': 1}; + +// the main display data holder element +let data = null; + +const scrollState = ((sx = 0, sy = 0, el) => ({ + track(_el) { + el = _el + el.addEventListener("scroll", () => ({scrollTop: sx, scrollLeft: sy} = el), {passive: true}) + }, + restore() { + el.scrollTop = sx + el.scrollLeft = sy + } +}))() + +// a fast way to clear data holder. +const clearData = () => { + while (data.firstChild) data.removeChild(data.firstChild) +}; + +const _option = (holderEl) => { + const wrapperEl = document.createElement('div'); + wrapperEl.classList.add('settings__option'); + + const titleEl = document.createElement('div'); + titleEl.classList.add('settings__option-title'); + wrapperEl.append(titleEl); + + const nameEl = document.createElement('div'); + + const valueEl = document.createElement('div'); + valueEl.classList.add('settings__option-value'); + wrapperEl.append(valueEl); + + return { + withName: function (name = '') { + if (name === '') return this; + nameEl.classList.add('settings__option-name'); + nameEl.textContent = name; + titleEl.append(nameEl); + return this; + }, + withClass: function (name = '') { + wrapperEl.classList.add(name); + return this; + }, + withDescription(text = '') { + if (text === '') return this; + const descEl = document.createElement('div'); + descEl.classList.add('settings__option-desc'); + descEl.textContent = text; + titleEl.append(descEl); + return this; + }, + restartNeeded: function () { + nameEl.classList.add('restart-needed-asterisk'); + return this; + }, + add: function (...elements) { + if (elements.length) for (let _el of elements.flat()) valueEl.append(_el); + return this; + }, + build: () => holderEl.append(wrapperEl), + }; +} + +const onKeyChange = (key, oldValue, newValue, handler) => { + + if (newValue !== 'Escape') { + const _settings = settings.get()[opts.INPUT_KEYBOARD_MAP]; + + if (_settings[newValue] !== undefined) { + log.warn(`There are old settings for key: ${_settings[newValue]}, won't change!`); + } else { + settings.remove(opts.INPUT_KEYBOARD_MAP, oldValue); + settings.set(opts.INPUT_KEYBOARD_MAP, {[newValue]: key}); + } + } + + handler?.unsub(); + + pub(KEYBOARD_TOGGLE_FILTER_MODE); + pub(SETTINGS_CHANGED); +} + +const _keyChangeOverlay = (keyName, oldValue) => { + const wrapperEl = document.createElement('div'); + wrapperEl.classList.add('settings__key-wait'); + wrapperEl.textContent = `Let's choose a ${keyName} key...`; + + let handler = sub(KEYBOARD_KEY_PRESSED, button => onKeyChange(keyName, oldValue, button.key, handler)); + + return wrapperEl; +} + +/** + * Handles a normal option change. + * + * @param key The name (id) of an option. + * @param newValue A new value to set. + */ +const onChange = (key, newValue) => { + settings.set(key, newValue); + scrollState.restore(data); +} + +const onKeyBindingChange = (key, oldValue) => { + clearData(); + data.append(_keyChangeOverlay(key, oldValue)); + pub(KEYBOARD_TOGGLE_FILTER_MODE); +} + +const render = function () { + const _settings = settings.getStore(); + + clearData(); + for (let k of Object.keys(_settings).sort()) { + if (ignored[k]) continue; + + const value = _settings[k]; + switch (k) { + case opts._VERSION: + _option(data).withName('Options format version').add(value).build(); + break; + case opts.LOG_LEVEL: + _option(data).withName('Log level') + .add(gui.select(k, onChange, { + labels: ['trace', 'debug', 'warning', 'info'], + values: [log.TRACE, log.DEBUG, log.WARN, log.INFO].map(String) + }, value)) + .build(); + break; + case opts.INPUT_KEYBOARD_MAP: + _option(data).withName('Keyboard bindings') + .withClass('keyboard-bindings') + .add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange))) + .build(); + break; + case opts.MIRROR_SCREEN: + _option(data).withName('Video mirroring') + .add(gui.select(k, onChange, {values: ['mirror'], labels: []}, value)) + .withDescription('Disables video image smoothing by rendering the video on a canvas (much more demanding for browser)') + .build(); + break; + case opts.VOLUME: + _option(data).withName('Volume (%)') + .add(gui.inputN(k, onChange, value)) + .restartNeeded() + .build() + break; + case opts.FORCE_FULLSCREEN: + _option(data).withName('Force fullscreen') + .withDescription( + 'Whether games should open in full-screen mode after starting up (excluding mobile devices)' + ) + .add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox')) + .build() + break; + default: + _option(data).withName(k).add(value).build(); + } + } + + data.append( + gui.create('br'), + gui.create('div', (el) => { + el.classList.add('settings__info', 'restart-needed-asterisk-b'); + el.innerText = ' -- applied after page reload' + }), + gui.create('div', (el) => { + el.classList.add('settings__info'); + el.innerText = `Options format version: ${_settings?._version}`; + }) + ); +} + +const renderer = { + render, + set data(el) { + data = el; + scrollState.track(el) + } +} diff --git a/web/js/settings/opts.js b/web/js/settings/opts.js deleted file mode 100644 index 5e9fac03..00000000 --- a/web/js/settings/opts.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Stores app wide option names. - * - * Use the following format: - * UPPERCASE_NAME: 'uppercase.name' - * - * @version 1 - */ -const opts = Object.freeze({ - _VERSION: '_version', - LOG_LEVEL: 'log.level', - INPUT_KEYBOARD_MAP: 'input.keyboard.map', - MIRROR_SCREEN: 'mirror.screen', - VOLUME: 'volume', - FORCE_FULLSCREEN: 'force.fullscreen' -}); diff --git a/web/js/settings/settings.js b/web/js/settings/settings.js deleted file mode 100644 index 76c20c33..00000000 --- a/web/js/settings/settings.js +++ /dev/null @@ -1,525 +0,0 @@ -/** - * App settings module. - * - * So the basic idea is to let app modules request their settings - * from an abstract store first, and if the store doesn't contain such settings yet, - * then let the store to take default values from the module to save them before that. - * The return value with the settings is gonna be a slice of in-memory structure - * backed by a data provider (localStorage). - * Doing it this way allows us to considerably simplify the code and make sure that - * exposed settings will have the latest values without additional update/get calls. - * - * Uses ES8. - * - * @version 1 - */ -const settings = (() => { - // internal structure version - const revision = 1.51; - - // default settings - // keep them for revert to defaults option - const _defaults = Object.create(null); - _defaults[opts._VERSION] = revision; - - /** - * The main store with settings passed around by reference - * (because of that we need a wrapper object) - * don't do this at work (it's faster to write than immutable code). - * - * @type {{settings: {_version: number}}} - */ - let store = { - settings: { - ..._defaults - } - }; - let provider; - - /** - * Enum for settings types (the explicit type of a key-value pair). - * - * @readonly - * @enum {number} - */ - const option = Object.freeze({undefined: 0, string: 1, number: 2, object: 3, list: 4}); - - const exportFileName = `cloud-game.settings.v${revision}.txt`; - - let _renderer = {render: () => ({})}; - - const getStore = () => store.settings; - - /** - * The NullObject provider if everything else fails. - */ - const voidProvider = (store_ = {settings: {}}) => { - const nil = () => ({}) - - return { - get: key => store_.settings[key], - set: nil, - remove: nil, - save: nil, - loadSettings: nil, - reset: nil, - } - } - - /** - * The LocalStorage backend for our settings (store). - * - * For simplicity it will rewrite all the settings on every store change. - * If you want to roll your own, then use its "interface". - */ - const localStorageProvider = ((store_ = {settings: {}}) => { - if (!_isSupported()) return; - - const root = 'settings'; - - const _serialize = data => JSON.stringify(data, null, 2); - - const save = () => localStorage.setItem(root, _serialize(store_.settings)); - - function _isSupported() { - const testKey = '_test_42'; - try { - // check if it's writable and isn't full - localStorage.setItem(testKey, testKey); - localStorage.removeItem(testKey); - return true; - } catch (e) { - log.error(e); - return false; - } - } - - const get = key => JSON.parse(localStorage.getItem(key)); - - const set = () => save(); - - const remove = () => save(); - - const loadSettings = () => { - if (!localStorage.getItem(root)) save(); - store_.settings = JSON.parse(localStorage.getItem(root)); - } - - const reset = () => { - localStorage.removeItem(root); - localStorage.setItem(root, _serialize(store_.settings)); - } - - return { - get, - clear: () => localStorage.removeItem(root), - set, - remove, - save, - loadSettings, - reset, - } - }); - - /** - * Nuke existing settings with provided data. - * @param text The text to extract data from. - * @private - */ - const _import = text => { - try { - for (const property of Object.getOwnPropertyNames(store.settings)) delete store.settings[property]; - Object.assign(store.settings, JSON.parse(text).settings); - provider.save(); - event.pub(SETTINGS_CHANGED); - } catch (e) { - log.error(`Your import file is broken!`); - } - - _render(); - } - - const _export = () => { - let el = document.createElement('a'); - el.setAttribute( - 'href', - `data:text/plain;charset=utf-8,${encodeURIComponent(JSON.stringify(store, null, 2))}` - ); - el.setAttribute('download', exportFileName); - el.style.display = 'none'; - document.body.appendChild(el); - el.click(); - document.body.removeChild(el); - } - - const init = () => { - // try to load settings from the localStorage with fallback to null-object - provider = localStorageProvider(store) || voidProvider(store); - provider.loadSettings(); - - const lastRev = (store.settings || {_version: 0})._version - - if (revision > lastRev) { - log.warn(`Your settings are in older format (v${lastRev}) and will be reset to (v${revision})!`); - _reset(); - } - } - - const get = () => store.settings; - - const _isLoaded = key => store.settings.hasOwnProperty(key); - - /** - * Tries to load settings by some key. - * - * @param key A key to find values with. - * @param default_ The default values to set if none exist. - * @returns A slice of the settings with the given key or a copy of the value. - */ - const loadOr = (key, default_) => { - // preserve defaults - _defaults[key] = default_; - - if (!_isLoaded(key)) { - store.settings[key] = {}; - set(key, default_); - } else { - // !to check if settings do have new properties from default & update - // or it have ones that defaults doesn't - } - - return store.settings[key]; - } - - const set = (key, value, updateProvider = true) => { - const type = _getType(value); - - // mutate settings w/o changing the reference - switch (type) { - case option.list: - store.settings[key].splice(0, Infinity, ...value); - break; - case option.object: - for (let option of Object.keys(value)) { - log.debug(`Change key [${option}] from ${store.settings[key][option]} to ${value[option]}`); - store.settings[key][option] = value[option]; - } - break; - case option.string: - case option.number: - case option.undefined: - default: - store.settings[key] = value; - } - - if (updateProvider) { - provider.set(key, value); - event.pub(SETTINGS_CHANGED); - } - } - - const _reset = () => { - for (let _option of Object.keys(_defaults)) { - const value = _defaults[_option]; - - // delete all sub-options not in defaults - if (_getType(value) === option.object) { - for (let opt of Object.keys(store.settings[_option])) { - const prev = store.settings[_option][opt]; - const isDeleted = delete store.settings[_option][opt]; - log.debug(`User option [${opt}=${prev}] has been deleted (${isDeleted}) from the [${_option}]`); - } - } - - set(_option, value, false); - } - - provider.reset(); - event.pub(SETTINGS_CHANGED); - } - - const remove = (key, subKey) => { - const isRemoved = subKey !== undefined ? delete store.settings[key][subKey] : delete store.settings[key]; - if (!isRemoved) log.warn(`The key: ${key + (subKey ? '.' + subKey : '')} wasn't deleted!`); - provider.remove(key, subKey); - } - - - const _render = () => { - _renderer.data = panel.contentEl; - _renderer.render() - } - - - const panel = gui.panel(document.getElementById('settings'), '> OPTIONS', 'settings', null, [ - {caption: 'Export', handler: () => _export(), title: 'Save',}, - {caption: 'Import', handler: () => _fileReader.read(onFileLoad), title: 'Load',}, - { - caption: 'Reset', - handler: () => { - if (window.confirm("Are you sure want to reset your settings?")) { - _reset(); - event.pub(SETTINGS_CHANGED); - } - }, - title: 'Reset', - }, - {} - ], - (show) => { - if (show) { - _render(); - return; - } - - // to make sure it's disabled, but it's a tad verbose - event.pub(KEYBOARD_TOGGLE_FILTER_MODE, {mode: true}); - }) - - function _getType(value) { - if (value === undefined) return option.undefined - else if (Array.isArray(value)) return option.list - else if (typeof value === 'object' && value !== null) return option.object - else if (typeof value === 'string') return option.string - else if (typeof value === 'number') return option.number - else return option.undefined; - } - - const _fileReader = (() => { - let callback_ = () => ({}) - - const el = document.createElement('input'); - const reader = new FileReader(); - - el.type = 'file'; - el.accept = '.txt'; - el.onchange = event => event.target.files.length && reader.readAsBinaryString(event.target.files[0]); - reader.onload = event => callback_(event.target.result); - - return { - read: callback => { - callback_ = callback; - el.click(); - }, - } - })(); - - const onFileLoad = text => { - try { - _import(text); - } catch (e) { - log.error(`Couldn't read your settings!`, e); - } - } - - event.sub(SETTINGS_CHANGED, _render); - - return { - init, - loadOr, - getStore, - get, - set, - remove, - import: _import, - export: _export, - ui: { - set onToggle(fn) { - panel.onToggle(fn); - }, - toggle: () => panel.toggle(), - }, - set renderer(fn) { - _renderer = fn; - } - } -})(document, event, JSON, localStorage, log, window); - -// hardcoded ui stuff -settings.renderer = (() => { - // don't show these options (i.e. ignored = {'_version': 1}) - const ignored = {'_version': 1}; - - // the main display data holder element - let data = null; - - const scrollState = ((sx = 0, sy = 0, el) => ({ - track(_el) { - el = _el - el.addEventListener("scroll", () => ({scrollTop: sx, scrollLeft: sy} = el), {passive: true}) - }, - restore() { - el.scrollTop = sx - el.scrollLeft = sy - } - }))() - - // a fast way to clear data holder. - const clearData = () => { - while (data.firstChild) data.removeChild(data.firstChild) - }; - - const _option = (holderEl) => { - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add('settings__option'); - - const titleEl = document.createElement('div'); - titleEl.classList.add('settings__option-title'); - wrapperEl.append(titleEl); - - const nameEl = document.createElement('div'); - - const valueEl = document.createElement('div'); - valueEl.classList.add('settings__option-value'); - wrapperEl.append(valueEl); - - return { - withName: function (name = '') { - if (name === '') return this; - nameEl.classList.add('settings__option-name'); - nameEl.textContent = name; - titleEl.append(nameEl); - return this; - }, - withClass: function (name = '') { - wrapperEl.classList.add(name); - return this; - }, - withDescription(text = '') { - if (text === '') return this; - const descEl = document.createElement('div'); - descEl.classList.add('settings__option-desc'); - descEl.textContent = text; - titleEl.append(descEl); - return this; - }, - restartNeeded: function () { - nameEl.classList.add('restart-needed-asterisk'); - return this; - }, - add: function (...elements) { - if (elements.length) for (let _el of elements.flat()) valueEl.append(_el); - return this; - }, - build: () => holderEl.append(wrapperEl), - }; - } - - const onKeyChange = (key, oldValue, newValue, handler) => { - - if (newValue !== 'Escape') { - const _settings = settings.get()[opts.INPUT_KEYBOARD_MAP]; - - if (_settings[newValue] !== undefined) { - log.warn(`There are old settings for key: ${_settings[newValue]}, won't change!`); - } else { - settings.remove(opts.INPUT_KEYBOARD_MAP, oldValue); - settings.set(opts.INPUT_KEYBOARD_MAP, {[newValue]: key}); - } - } - - handler?.unsub(); - - event.pub(KEYBOARD_TOGGLE_FILTER_MODE); - event.pub(SETTINGS_CHANGED); - } - - const _keyChangeOverlay = (keyName, oldValue) => { - const wrapperEl = document.createElement('div'); - wrapperEl.classList.add('settings__key-wait'); - wrapperEl.textContent = `Let's choose a ${keyName} key...`; - - let handler = event.sub(KEYBOARD_KEY_PRESSED, button => onKeyChange(keyName, oldValue, button.key, handler)); - - return wrapperEl; - } - - /** - * Handles a normal option change. - * - * @param key The name (id) of an option. - * @param newValue A new value to set. - */ - const onChange = (key, newValue) => { - settings.set(key, newValue); - scrollState.restore(data); - } - - const onKeyBindingChange = (key, oldValue) => { - clearData(); - data.append(_keyChangeOverlay(key, oldValue)); - event.pub(KEYBOARD_TOGGLE_FILTER_MODE); - } - - const render = function () { - const _settings = settings.getStore(); - - clearData(); - for (let k of Object.keys(_settings).sort()) { - if (ignored[k]) continue; - - const value = _settings[k]; - switch (k) { - case opts._VERSION: - _option(data).withName('Options format version').add(value).build(); - break; - case opts.LOG_LEVEL: - _option(data).withName('Log level') - .add(gui.select(k, onChange, { - labels: ['trace', 'debug', 'warning', 'info'], - values: [log.TRACE, log.DEBUG, log.WARN, log.INFO].map(String) - }, value)) - .build(); - break; - case opts.INPUT_KEYBOARD_MAP: - _option(data).withName('Keyboard bindings') - .withClass('keyboard-bindings') - .add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange))) - .build(); - break; - case opts.MIRROR_SCREEN: - _option(data).withName('Video mirroring') - .add(gui.select(k, onChange, {values: ['mirror'], labels: []}, value)) - .withDescription('Disables video image smoothing by rendering the video on a canvas (much more demanding for browser)') - .build(); - break; - case opts.VOLUME: - _option(data).withName('Volume (%)') - .add(gui.inputN(k, onChange, value)) - .restartNeeded() - .build() - break; - case opts.FORCE_FULLSCREEN: - _option(data).withName('Force fullscreen') - .withDescription( - 'Whether games should open in full-screen mode after starting up (excluding mobile devices)' - ) - .add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox')) - .build() - break; - default: - _option(data).withName(k).add(value).build(); - } - } - - data.append( - gui.create('br'), - gui.create('div', (el) => { - el.classList.add('settings__info', 'restart-needed-asterisk-b'); - el.innerText = ' -- applied after page reload' - }), - gui.create('div', (el) => { - el.classList.add('settings__info'); - el.innerText = `Options format version: ${_settings?._version}`; - }) - ); - } - - return { - render, - set data(el) { - data = el; - scrollState.track(el) - } - } -})(document, gui, log, opts, settings); diff --git a/web/js/stats.js b/web/js/stats.js new file mode 100644 index 00000000..126ca4a8 --- /dev/null +++ b/web/js/stats.js @@ -0,0 +1,440 @@ +import {env} from 'env'; +import { + pub, + sub, + STATS_TOGGLE, + HELP_OVERLAY_TOGGLED, + PING_REQUEST, + PING_RESPONSE +} from 'event'; +import {log} from 'log'; +import {webrtc} from 'network'; + +const _modules = []; +let tempHide = false; + +// internal rendering stuff +const fps = 30; +let time = 0; +let active = false; + +// !to add connection drop notice + +const statsOverlayEl = document.getElementById('stats-overlay'); + +/** + * The graph element. + */ +const graph = (parent, opts = { + historySize: 60, + width: 60 * 2 + 2, + height: 20, + pad: 4, + scale: 1, + style: { + barColor: '#9bd914', + barFallColor: '#c12604' + } +}) => { + const _canvas = document.createElement('canvas'); + const _context = _canvas.getContext('2d'); + + let data = []; + + _canvas.setAttribute('class', 'graph'); + + _canvas.width = opts.width * opts.scale; + _canvas.height = opts.height * opts.scale; + + _context.scale(opts.scale, opts.scale); + _context.imageSmoothingEnabled = false; + _context.fillStyle = opts.fillStyle; + + if (parent) parent.append(_canvas); + + // bar size + const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize); + const barHeight = Math.round(_canvas.height / opts.scale); + + let maxN = 0, + minN = 0; + + const max = () => maxN + + const get = () => _canvas + + const add = (value) => { + if (data.length > opts.historySize) data.shift(); + data.push(value); + render(); + } + + /** + * Draws a bar graph on the canvas. + */ + const render = () => { + // 0,0 w,0 0,0 w,0 0,0 w,0 + // +-------+ +-------+ +---------+ + // | | |+---+ | |+---+ | + // | | |||||| | ||||||+---+ + // | | |||||| | ||||||||||| + // +-------+ +----+--+ +---------+ + // 0,h w,h 0,h w,h 0,h w,h + // [] [3] [3, 2] + // + + _context.clearRect(0, 0, _canvas.width, _canvas.height); + + maxN = data[0] || 1; + minN = 0; + for (let k = 1; k < data.length; k++) { + if (data[k] > maxN) maxN = data[k]; + if (data[k] < minN) minN = data[k]; + } + + for (let j = 0; j < data.length; j++) { + let x = j * barWidth, + y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad; + + const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor; + + drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color); + } + } + + const drawRect = (x, y, w, h, color = opts.style.barColor) => { + _context.fillStyle = color; + _context.fillRect(x, y, w, h); + } + + return {add, get, max, render} +} + +/** + * Get cached module UI. + * + * HTML: + *
LABEL
VALUE[]
+ * + * @param label The name of the stat to show. + * @param withGraph True if to draw a graph. + * @param postfix Supposed to be the name of the stat passed as a function. + * @returns {{el: HTMLDivElement, update: function}} + */ +const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { + const ui = document.createElement('div'), + _label = document.createElement('div'), + _value = document.createElement('span'); + ui.append(_label, _value); + + let postfix_ = postfix; + + let _graph; + if (withGraph) { + const _container = document.createElement('span'); + ui.append(_container); + _graph = graph(_container); + } + + _label.innerHTML = label; + + const withPostfix = (value) => postfix_ = value; + + const update = (value) => { + if (_graph) _graph.add(value); + // 203 (333) ms + _value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; + } + + return {el: ui, update, withPostfix} +} + +/** + * Latency stats submodule. + * + * Accumulates the simple rolling mean value + * between the next server request and following server response values. + * + * window + * _____________ + * | | + * [1, 1, 3, 4, 1, 4, 3, 1, 2, 1, 1, 1, 2, ... n] + * | + * stats_snapshot_period + * mean = round(next - mean / length % window) + * + * Events: + * <- PING_RESPONSE + * <- PING_REQUEST + * + * ?Interface: + * HTMLElement get() + * void enable() + * void disable() + * void render() + * + * @version 1 + */ +const latency = (() => { + let listeners = []; + + let mean = 0; + let length = 0; + let previous = 0; + const window = 5; + + const ui = moduleUi('Ping(c)', true); + + const onPingRequest = (data) => previous = data.time; + + const onPingResponse = () => { + length++; + const delta = Date.now() - previous; + mean += Math.round((delta - mean) / length); + + if (length % window === 0) { + length = 1; + mean = delta; + } + } + + const enable = () => { + listeners.push( + sub(PING_RESPONSE, onPingResponse), + sub(PING_REQUEST, onPingRequest) + ); + } + + const disable = () => { + while (listeners.length) listeners.shift().unsub(); + } + + const render = () => ui.update(mean); + + const get = () => ui.el; + + return {get, enable, disable, render} +})(event, moduleUi); + +/** + * User agent memory stats. + * + * ?Interface: + * HTMLElement get() + * void enable() + * void disable() + * void render() + * + * @version 1 + */ +const clientMemory = (() => { + let active = false; + + const measures = ['B', 'KB', 'MB', 'GB']; + const precision = 1; + let mLog = 0; + + const ui = moduleUi('Memory', false, (x) => (x > 0) ? measures[mLog] : ''); + + const get = () => ui.el; + + const enable = () => { + active = true; + render(); + } + + const disable = () => active = false; + + const render = () => { + if (!active) return; + + const m = performance.memory.usedJSHeapSize; + let newValue = 'N/A'; + + if (m > 0) { + mLog = Math.floor(Math.log(m) / Math.log(1000)); + newValue = Math.round(m * precision / Math.pow(1000, mLog)) / precision; + } + + ui.update(newValue); + } + + if (window.performance && !performance.memory) performance.memory = {usedJSHeapSize: 0, totalJSHeapSize: 0}; + + return {get, enable, disable, render} +})(moduleUi, performance, window); + + +const webRTCStats_ = (() => { + let interval = null + + function getStats() { + if (!webrtc.isConnected()) return; + webrtc.getConnection().getStats(null).then(stats => { + let frameStatValue = '?'; + stats.forEach(report => { + if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) { + frameStatValue = report["framesReceived"] - report["framesDecoded"] - report["framesDropped"]; + pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) + } else if (report["framerateMean"] !== undefined) { + frameStatValue = Math.round(report["framerateMean"] * 100) / 100; + pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) + } + + if (report["nominated"] && report["currentRoundTripTime"] !== undefined) { + pub('STATS_WEBRTC_ICE_RTT', report["currentRoundTripTime"] * 1000); + } + }); + }); + } + + const enable = () => { + interval = window.setInterval(getStats, 1000); + } + + const disable = () => window.clearInterval(interval); + + return {enable, disable, internal: true} +})(event, webrtc, window); + +/** + * User agent frame stats. + * + * ?Interface: + * HTMLElement get() + * void enable() + * void disable() + * void render() + * + * @version 1 + */ +const webRTCFrameStats = (() => { + let value = 0; + let listener; + + const label = env.getBrowser() === 'firefox' ? 'FramerateMean' : 'FrameDelay'; + const ui = moduleUi(label, false, () => ''); + + const get = () => ui.el; + + const enable = () => { + listener = sub('STATS_WEBRTC_FRAME_STATS', onStats); + } + + const disable = () => { + value = 0; + if (listener) listener.unsub(); + } + + const render = () => ui.update(value); + + function onStats(val) { + value = val; + } + + return {get, enable, disable, render} +})(env, event, moduleUi); + +const webRTCRttStats = (() => { + let value = 0; + let listener; + + const ui = moduleUi('RTT', true, () => 'ms'); + + const get = () => ui.el; + + const enable = () => { + listener = sub('STATS_WEBRTC_ICE_RTT', onStats); + } + + const disable = () => { + value = 0; + if (listener) listener.unsub(); + } + + const render = () => ui.update(value); + + function onStats(val) { + value = val; + } + + return {get, enable, disable, render} +})(event, moduleUi); + +const modules = (fn, force = true) => _modules.forEach(m => (force || !m.internal) && fn(m)) + +const enable = () => { + active = true; + modules(m => m.enable()) + render(); + draw(); + _show(); +}; + +function draw(timestamp) { + if (!active) return; + + const time_ = time + 1000 / fps; + + if (timestamp > time_) { + time = timestamp; + render(); + } + + requestAnimationFrame(draw); +} + +const disable = () => { + active = false; + modules(m => m.disable()); + _hide(); +} + +const _show = () => statsOverlayEl.style.visibility = 'visible'; +const _hide = () => statsOverlayEl.style.visibility = 'hidden'; + +const onToggle = () => active ? disable() : enable(); + +/** + * Handles help overlay toggle event. + * Workaround for a not normal app layout layering. + * + * !to remove when app layering is fixed + * + * @param {Object} overlay Overlay data. + * @param {boolean} overlay.shown A flag if the overlay is being currently showed. + */ +const onHelpOverlayToggle = (overlay) => { + if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) { + _hide(); + tempHide = true; + } else { + if (tempHide) { + _show(); + tempHide = false; + } + } +} + +const render = () => modules(m => m.render(), false); + +// add submodules +_modules.push( + webRTCRttStats, + // latency, + clientMemory, + webRTCStats_, + webRTCFrameStats +); +modules(m => statsOverlayEl.append(m.get()), false); + +sub(STATS_TOGGLE, onToggle); +sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle) + +/** + * App statistics module. + */ +export const stats = { + enable, + disable +} diff --git a/web/js/stats/stats.js b/web/js/stats/stats.js deleted file mode 100644 index c71b96e7..00000000 --- a/web/js/stats/stats.js +++ /dev/null @@ -1,433 +0,0 @@ -/** - * App statistics module. - * - * Events: - * <- STATS_TOGGLE - * <- HELP_OVERLAY_TOGGLED - * - * @version 1 - */ -const stats = (() => { - const _modules = []; - let tempHide = false; - - // internal rendering stuff - const fps = 30; - let time = 0; - let active = false; - - // !to add connection drop notice - - const statsOverlayEl = document.getElementById('stats-overlay'); - - /** - * The graph element. - */ - const graph = (parent, opts = { - historySize: 60, - width: 60 * 2 + 2, - height: 20, - pad: 4, - scale: 1, - style: { - barColor: '#9bd914', - barFallColor: '#c12604' - } - }) => { - const _canvas = document.createElement('canvas'); - const _context = _canvas.getContext('2d'); - - let data = []; - - _canvas.setAttribute('class', 'graph'); - - _canvas.width = opts.width * opts.scale; - _canvas.height = opts.height * opts.scale; - - _context.scale(opts.scale, opts.scale); - _context.imageSmoothingEnabled = false; - _context.fillStyle = opts.fillStyle; - - if (parent) parent.append(_canvas); - - // bar size - const barWidth = Math.round(_canvas.width / opts.scale / opts.historySize); - const barHeight = Math.round(_canvas.height / opts.scale); - - let maxN = 0, - minN = 0; - - const max = () => maxN - - const get = () => _canvas - - const add = (value) => { - if (data.length > opts.historySize) data.shift(); - data.push(value); - render(); - } - - /** - * Draws a bar graph on the canvas. - */ - const render = () => { - // 0,0 w,0 0,0 w,0 0,0 w,0 - // +-------+ +-------+ +---------+ - // | | |+---+ | |+---+ | - // | | |||||| | ||||||+---+ - // | | |||||| | ||||||||||| - // +-------+ +----+--+ +---------+ - // 0,h w,h 0,h w,h 0,h w,h - // [] [3] [3, 2] - // - - _context.clearRect(0, 0, _canvas.width, _canvas.height); - - maxN = data[0] || 1; - minN = 0; - for (let k = 1; k < data.length; k++) { - if (data[k] > maxN) maxN = data[k]; - if (data[k] < minN) minN = data[k]; - } - - for (let j = 0; j < data.length; j++) { - let x = j * barWidth, - y = (barHeight - opts.pad * 2) * (data[j] - minN) / (maxN - minN) + opts.pad; - - const color = j > 0 && data[j] > data[j - 1] ? opts.style.barFallColor : opts.style.barColor; - - drawRect(x, barHeight - Math.round(y), barWidth, barHeight, color); - } - } - - const drawRect = (x, y, w, h, color = opts.style.barColor) => { - _context.fillStyle = color; - _context.fillRect(x, y, w, h); - } - - return {add, get, max, render} - } - - /** - * Get cached module UI. - * - * HTML: - *
LABEL
VALUE[]
- * - * @param label The name of the stat to show. - * @param withGraph True if to draw a graph. - * @param postfix Supposed to be the name of the stat passed as a function. - * @returns {{el: HTMLDivElement, update: function}} - */ - const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { - const ui = document.createElement('div'), - _label = document.createElement('div'), - _value = document.createElement('span'); - ui.append(_label, _value); - - let postfix_ = postfix; - - let _graph; - if (withGraph) { - const _container = document.createElement('span'); - ui.append(_container); - _graph = graph(_container); - } - - _label.innerHTML = label; - - const withPostfix = (value) => postfix_ = value; - - const update = (value) => { - if (_graph) _graph.add(value); - // 203 (333) ms - _value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; - } - - return {el: ui, update, withPostfix} - } - - /** - * Latency stats submodule. - * - * Accumulates the simple rolling mean value - * between the next server request and following server response values. - * - * window - * _____________ - * | | - * [1, 1, 3, 4, 1, 4, 3, 1, 2, 1, 1, 1, 2, ... n] - * | - * stats_snapshot_period - * mean = round(next - mean / length % window) - * - * Events: - * <- PING_RESPONSE - * <- PING_REQUEST - * - * ?Interface: - * HTMLElement get() - * void enable() - * void disable() - * void render() - * - * @version 1 - */ - const latency = (() => { - let listeners = []; - - let mean = 0; - let length = 0; - let previous = 0; - const window = 5; - - const ui = moduleUi('Ping(c)', true); - - const onPingRequest = (data) => previous = data.time; - - const onPingResponse = () => { - length++; - const delta = Date.now() - previous; - mean += Math.round((delta - mean) / length); - - if (length % window === 0) { - length = 1; - mean = delta; - } - } - - const enable = () => { - listeners.push( - event.sub(PING_RESPONSE, onPingResponse), - event.sub(PING_REQUEST, onPingRequest) - ); - } - - const disable = () => { - while (listeners.length) listeners.shift().unsub(); - } - - const render = () => ui.update(mean); - - const get = () => ui.el; - - return {get, enable, disable, render} - })(event, moduleUi); - - /** - * User agent memory stats. - * - * ?Interface: - * HTMLElement get() - * void enable() - * void disable() - * void render() - * - * @version 1 - */ - const clientMemory = (() => { - let active = false; - - const measures = ['B', 'KB', 'MB', 'GB']; - const precision = 1; - let mLog = 0; - - const ui = moduleUi('Memory', false, (x) => (x > 0) ? measures[mLog] : ''); - - const get = () => ui.el; - - const enable = () => { - active = true; - render(); - } - - const disable = () => active = false; - - const render = () => { - if (!active) return; - - const m = performance.memory.usedJSHeapSize; - let newValue = 'N/A'; - - if (m > 0) { - mLog = Math.floor(Math.log(m) / Math.log(1000)); - newValue = Math.round(m * precision / Math.pow(1000, mLog)) / precision; - } - - ui.update(newValue); - } - - if (window.performance && !performance.memory) performance.memory = {usedJSHeapSize: 0, totalJSHeapSize: 0}; - - return {get, enable, disable, render} - })(moduleUi, performance, window); - - - const webRTCStats_ = (() => { - let interval = null - - function getStats() { - if (!webrtc.isConnected()) return; - webrtc.getConnection().getStats(null).then(stats => { - let frameStatValue = '?'; - stats.forEach(report => { - if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) { - frameStatValue = report["framesReceived"] - report["framesDecoded"] - report["framesDropped"]; - event.pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) - } else if (report["framerateMean"] !== undefined) { - frameStatValue = Math.round(report["framerateMean"] * 100) / 100; - event.pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) - } - - if (report["nominated"] && report["currentRoundTripTime"] !== undefined) { - event.pub('STATS_WEBRTC_ICE_RTT', report["currentRoundTripTime"] * 1000); - } - }); - }); - } - - const enable = () => { - interval = window.setInterval(getStats, 1000); - } - - const disable = () => window.clearInterval(interval); - - return {enable, disable, internal: true} - })(event, webrtc, window); - - /** - * User agent frame stats. - * - * ?Interface: - * HTMLElement get() - * void enable() - * void disable() - * void render() - * - * @version 1 - */ - const webRTCFrameStats = (() => { - let value = 0; - let listener; - - const label = env.getBrowser() === 'firefox' ? 'FramerateMean' : 'FrameDelay'; - const ui = moduleUi(label, false, () => ''); - - const get = () => ui.el; - - const enable = () => { - listener = event.sub('STATS_WEBRTC_FRAME_STATS', onStats); - } - - const disable = () => { - value = 0; - if (listener) listener.unsub(); - } - - const render = () => ui.update(value); - - function onStats(val) { - value = val; - } - - return {get, enable, disable, render} - })(env, event, moduleUi); - - const webRTCRttStats = (() => { - let value = 0; - let listener; - - const ui = moduleUi('RTT', true, () => 'ms'); - - const get = () => ui.el; - - const enable = () => { - listener = event.sub('STATS_WEBRTC_ICE_RTT', onStats); - } - - const disable = () => { - value = 0; - if (listener) listener.unsub(); - } - - const render = () => ui.update(value); - - function onStats(val) { - value = val; - } - - return {get, enable, disable, render} - })(event, moduleUi); - - const modules = (fn, force = true) => _modules.forEach(m => (force || !m.internal) && fn(m)) - - const enable = () => { - active = true; - modules(m => m.enable()) - render(); - draw(); - _show(); - }; - - function draw(timestamp) { - if (!active) return; - - const time_ = time + 1000 / fps; - - if (timestamp > time_) { - time = timestamp; - render(); - } - - requestAnimationFrame(draw); - } - - const disable = () => { - active = false; - modules(m => m.disable()); - _hide(); - } - - const _show = () => statsOverlayEl.style.visibility = 'visible'; - const _hide = () => statsOverlayEl.style.visibility = 'hidden'; - - const onToggle = () => active ? disable() : enable(); - - /** - * Handles help overlay toggle event. - * Workaround for a not normal app layout layering. - * - * !to remove when app layering is fixed - * - * @param {Object} overlay Overlay data. - * @param {boolean} overlay.shown A flag if the overlay is being currently showed. - */ - const onHelpOverlayToggle = (overlay) => { - if (statsOverlayEl.style.visibility === 'visible' && overlay.shown && !tempHide) { - _hide(); - tempHide = true; - } else { - if (tempHide) { - _show(); - tempHide = false; - } - } - } - - const render = () => modules(m => m.render(), false); - - // add submodules - _modules.push( - webRTCRttStats, - // latency, - clientMemory, - webRTCStats_, - webRTCFrameStats - ); - modules(m => statsOverlayEl.append(m.get()), false); - - event.sub(STATS_TOGGLE, onToggle); - event.sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle) - - return {enable, disable} -})(document, env, event, log, webrtc, window); diff --git a/web/js/stream.js b/web/js/stream.js new file mode 100644 index 00000000..cd213464 --- /dev/null +++ b/web/js/stream.js @@ -0,0 +1,222 @@ +import {env} from 'env'; +import { + sub, + APP_VIDEO_CHANGED, + SETTINGS_CHANGED +} from 'event' ; +import {gui} from 'gui'; +import {log} from 'log'; +import {opts, settings} from 'settings'; + +const screen = document.getElementById('stream'); + +let options = { + volume: 0.5, + poster: '/img/screen_loading.gif', + mirrorMode: null, + mirrorUpdateRate: 1 / 60, + forceFullscreen: true, + }, + state = { + screen: screen, + fullscreen: false, + timerId: null, + w: 0, + h: 0, + aspect: 4 / 3 + }; + +const mute = (mute) => screen.muted = mute + +const _stream = () => { + screen.play() + .then(() => log.info('Media can autoplay')) + .catch(error => { + log.error('Media failed to play', error); + }); +} + +const toggle = (show) => { + state.screen.toggleAttribute('hidden', !show) +} + +const toggleFullscreen = () => { + let h = parseFloat(getComputedStyle(state.screen, null) + .height + .replace('px', '') + ) + env.display().toggleFullscreen(h !== window.innerHeight, state.screen); +} + +const getVideoEl = () => screen + +screen.onerror = (e) => { + // video playback failed - show a message saying why + switch (e.target.error.code) { + case e.target.error.MEDIA_ERR_ABORTED: + log.error('You aborted the video playback.'); + break; + case e.target.error.MEDIA_ERR_NETWORK: + log.error('A network error caused the video download to fail part-way.'); + break; + case e.target.error.MEDIA_ERR_DECODE: + log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.'); + break; + case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: + log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.'); + break; + default: + log.error('An unknown video error occurred.'); + break; + } +}; + +screen.addEventListener('loadedmetadata', () => { + if (state.screen !== screen) { + state.screen.setAttribute('width', screen.videoWidth); + state.screen.setAttribute('height', screen.videoHeight); + } +}, false); +screen.addEventListener('loadstart', () => { + screen.volume = options.volume; + screen.poster = options.poster; +}, false); +screen.addEventListener('canplay', () => { + screen.poster = ''; + useCustomScreen(options.mirrorMode === 'mirror'); +}, false); + +screen.addEventListener('fullscreenchange', () => { + state.fullscreen = !!document.fullscreenElement; + + const w = window.screen.width ?? window.innerWidth; + const h = window.screen.height ?? window.innerHeight; + + const ww = document.documentElement.innerWidth; + const hh = document.documentElement.innerHeight; + + screen.style.padding = '0' + if (state.fullscreen) { + const dw = (w - ww * state.aspect) / 2 + screen.style.padding = `0 ${dw}px` + // chrome bug + setTimeout(() => { + const dw = (h - hh * state.aspect) / 2 + screen.style.padding = `0 ${dw}px` + }, 1) + makeFullscreen(true); + } else { + makeFullscreen(false); + } + + // !to flipped +}) + +const makeFullscreen = (make = false) => { + screen.classList.toggle('no-media-controls', make) +} + +const forceFullscreenMaybe = () => { + const touchMode = env.isMobileDevice(); + log.debug('touch check', touchMode) + !touchMode && options.forceFullscreen && toggleFullscreen(); +} + +const useCustomScreen = (use) => { + if (use) { + if (screen.paused || screen.ended) return; + + let id = state.screen.getAttribute('id'); + if (id === 'canvas-mirror') return; + + const canvas = gui.create('canvas'); + canvas.setAttribute('id', 'canvas-mirror'); + canvas.setAttribute('hidden', ''); + canvas.setAttribute('width', screen.videoWidth); + canvas.setAttribute('height', screen.videoHeight); + canvas.style['image-rendering'] = 'pixelated'; + canvas.style.width = '100%' + canvas.style.height = '100%' + canvas.classList.add('game-screen'); + + // stretch depending on the video orientation + // portrait -- vertically, landscape -- horizontally + const isPortrait = screen.videoWidth < screen.videoHeight; + canvas.style.width = isPortrait ? 'auto' : canvas.style.width; + // canvas.style.height = isPortrait ? canvas.style.height : 'auto'; + + let surface = canvas.getContext('2d'); + screen.parentNode.insertBefore(canvas, screen.nextSibling); + toggle(false) + state.screen = canvas + toggle(true) + state.timerId = setInterval(function () { + if (screen.paused || screen.ended || !surface) return; + surface.drawImage(screen, 0, 0); + }, options.mirrorUpdateRate); + } else { + clearInterval(state.timerId); + let mirror = state.screen; + state.screen = screen; + toggle(true); + if (mirror !== screen) { + mirror.parentNode.removeChild(mirror); + } + } +} + +const init = () => { + options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none'); + options.volume = settings.loadOr(opts.VOLUME, 50) / 100; + options.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false); +} + +sub(SETTINGS_CHANGED, () => { + const s = settings.get(); + const newValue = s[opts.MIRROR_SCREEN]; + if (newValue !== options.mirrorMode) { + useCustomScreen(newValue === 'mirror'); + options.mirrorMode = newValue; + } + const newValue2 = s[opts.FORCE_FULLSCREEN]; + if (newValue2 !== options.forceFullscreen) { + options.forceFullscreen = newValue2; + } +}); + +const fit = 'contain' + +sub(APP_VIDEO_CHANGED, (payload) => { + const {w, h, a, s} = payload + + const scale = !s ? 1 : s; + const ww = w * scale; + const hh = h * scale; + + state.aspect = a + + const a2 = (ww / hh).toFixed(6) + + state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : fit + state.h = hh + state.w = Math.floor(hh * a) + state.screen.setAttribute('width', '' + ww) + state.screen.setAttribute('height', '' + hh) + state.screen.style.aspectRatio = '' + state.aspect +}) + +/** + * Game streaming module. + * Contains HTML5 AV media elements. + * + * @version 1 + */ +export const stream = { + audio: {mute}, + video: {toggleFullscreen, el: getVideoEl}, + play: _stream, + toggle, + useCustomScreen, + forceFullscreenMaybe, + init +} diff --git a/web/js/stream/stream.js b/web/js/stream/stream.js deleted file mode 100644 index b0fb730d..00000000 --- a/web/js/stream/stream.js +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Game streaming module. - * Contains HTML5 AV media elements. - * - * @version 1 - */ -const stream = (() => { - const screen = document.getElementById('stream'); - - let options = { - volume: 0.5, - poster: '/img/screen_loading.gif', - mirrorMode: null, - mirrorUpdateRate: 1 / 60, - forceFullscreen: true, - }, - state = { - screen: screen, - fullscreen: false, - timerId: null, - w: 0, - h: 0, - aspect: 4 / 3 - }; - - const mute = (mute) => screen.muted = mute - - const stream = () => { - screen.play() - .then(() => log.info('Media can autoplay')) - .catch(error => { - log.error('Media failed to play', error); - }); - } - - const toggle = (show) => { - state.screen.toggleAttribute('hidden', !show) - } - - const toggleFullscreen = () => { - let h = parseFloat(getComputedStyle(state.screen, null) - .height - .replace('px', '') - ) - env.display().toggleFullscreen(h !== window.innerHeight, state.screen); - } - - const getVideoEl = () => screen - - screen.onerror = (e) => { - // video playback failed - show a message saying why - switch (e.target.error.code) { - case e.target.error.MEDIA_ERR_ABORTED: - log.error('You aborted the video playback.'); - break; - case e.target.error.MEDIA_ERR_NETWORK: - log.error('A network error caused the video download to fail part-way.'); - break; - case e.target.error.MEDIA_ERR_DECODE: - log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.'); - break; - case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: - log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.'); - break; - default: - log.error('An unknown video error occurred.'); - break; - } - }; - - screen.addEventListener('loadedmetadata', () => { - if (state.screen !== screen) { - state.screen.setAttribute('width', screen.videoWidth); - state.screen.setAttribute('height', screen.videoHeight); - } - }, false); - screen.addEventListener('loadstart', () => { - screen.volume = options.volume; - screen.poster = options.poster; - }, false); - screen.addEventListener('canplay', () => { - screen.poster = ''; - useCustomScreen(options.mirrorMode === 'mirror'); - }, false); - - screen.addEventListener('fullscreenchange', () => { - state.fullscreen = !!document.fullscreenElement; - - const w = window.screen.width ?? window.innerWidth; - const h = window.screen.height ?? window.innerHeight; - - const ww = document.documentElement.innerWidth; - const hh = document.documentElement.innerHeight; - - screen.style.padding = '0' - if (state.fullscreen) { - const dw = (w - ww * state.aspect) / 2 - screen.style.padding = `0 ${dw}px` - // chrome bug - setTimeout(() => { - const dw = (h - hh * state.aspect) / 2 - screen.style.padding = `0 ${dw}px` - }, 1) - makeFullscreen(true); - } else { - makeFullscreen(false); - } - - // !to flipped - }) - - const makeFullscreen = (make = false) => { - screen.classList.toggle('no-media-controls', make) - } - - const forceFullscreenMaybe = () => { - const touchMode = env.isMobileDevice(); - log.debug('touch check', touchMode) - !touchMode && options.forceFullscreen && toggleFullscreen(); - } - - const useCustomScreen = (use) => { - if (use) { - if (screen.paused || screen.ended) return; - - let id = state.screen.getAttribute('id'); - if (id === 'canvas-mirror') return; - - const canvas = gui.create('canvas'); - canvas.setAttribute('id', 'canvas-mirror'); - canvas.setAttribute('hidden', ''); - canvas.setAttribute('width', screen.videoWidth); - canvas.setAttribute('height', screen.videoHeight); - canvas.style['image-rendering'] = 'pixelated'; - canvas.style.width = '100%' - canvas.style.height = '100%' - canvas.classList.add('game-screen'); - - // stretch depending on the video orientation - // portrait -- vertically, landscape -- horizontally - const isPortrait = screen.videoWidth < screen.videoHeight; - canvas.style.width = isPortrait ? 'auto' : canvas.style.width; - // canvas.style.height = isPortrait ? canvas.style.height : 'auto'; - - let surface = canvas.getContext('2d'); - screen.parentNode.insertBefore(canvas, screen.nextSibling); - toggle(false) - state.screen = canvas - toggle(true) - state.timerId = setInterval(function () { - if (screen.paused || screen.ended || !surface) return; - surface.drawImage(screen, 0, 0); - }, options.mirrorUpdateRate); - } else { - clearInterval(state.timerId); - let mirror = state.screen; - state.screen = screen; - toggle(true); - if (mirror !== screen) { - mirror.parentNode.removeChild(mirror); - } - } - } - - const init = () => { - options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none'); - options.volume = settings.loadOr(opts.VOLUME, 50) / 100; - options.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false); - } - - event.sub(SETTINGS_CHANGED, () => { - const s = settings.get(); - const newValue = s[opts.MIRROR_SCREEN]; - if (newValue !== options.mirrorMode) { - useCustomScreen(newValue === 'mirror'); - options.mirrorMode = newValue; - } - const newValue2 = s[opts.FORCE_FULLSCREEN]; - if (newValue2 !== options.forceFullscreen) { - options.forceFullscreen = newValue2; - } - }); - - - const fit = 'contain' - - event.sub(APP_VIDEO_CHANGED, (payload) => { - const {w, h, a, s} = payload - - const scale = !s ? 1 : s; - const ww = w * scale; - const hh = h * scale; - - state.aspect = a - - const a2 = (ww / hh).toFixed(6) - - state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : fit - state.h = hh - state.w = Math.floor(hh * a) - state.screen.setAttribute('width', '' + ww) - state.screen.setAttribute('height', '' + hh) - state.screen.style.aspectRatio = '' + state.aspect - }) - - return { - audio: {mute}, - video: {toggleFullscreen, el: getVideoEl}, - play: stream, - toggle, - useCustomScreen, - forceFullscreenMaybe, - init - } - } -)(env, event, gui, log, opts, settings); diff --git a/web/js/utils.js b/web/js/utils.js index 28f557ab..5725c6cb 100644 --- a/web/js/utils.js +++ b/web/js/utils.js @@ -1,58 +1,50 @@ /** - * Utility module. - * @version 1 + * A decorator that passes the call to function at maximum once per specified milliseconds. + * @param f The function to call. + * @param ms The amount of time in milliseconds to ignore the function calls. + * @returns {Function} + * @example + * const showMessage = () => { alert('00001'); } + * const showOnlyOnceASecond = debounce(showMessage, 1000); */ -const utils = (() => { - return { - /** - * A decorator that passes the call to function at maximum once per specified milliseconds. - * @param f The function to call. - * @param ms The amount of time in milliseconds to ignore the function calls. - * @returns {Function} - * @example - * const showMessage = () => { alert('00001'); } - * const showOnlyOnceASecond = debounce(showMessage, 1000); - */ - debounce: (f, ms) => { - let wait = false; +export const debounce = (f, ms) => { + let wait = false; - return function () { - if (wait) return; + return function () { + if (wait) return; - f.apply(this, arguments); - wait = true; - setTimeout(() => wait = false, ms); - }; - }, + f.apply(this, arguments); + wait = true; + setTimeout(() => wait = false, ms); + }; +} - /** - * A decorator that blocks and calls the last function until the specified amount of milliseconds. - * @param f The function to call. - * @param ms The amount of time in milliseconds to ignore the function calls. - * @returns {Function} - */ - throttle: (f, ms) => { - let lastCall; - let lastTime; +/** + * A decorator that blocks and calls the last function until the specified amount of milliseconds. + * @param f The function to call. + * @param ms The amount of time in milliseconds to ignore the function calls. + * @returns {Function} + */ +export const throttle = (f, ms) => { + let lastCall; + let lastTime; - return function () { - // could be a stack - const lastContext = this; - const lastArguments = arguments; + return function () { + // could be a stack + const lastContext = this; + const lastArguments = arguments; - if (!lastTime) { + if (!lastTime) { + f.apply(lastContext, lastArguments); + lastTime = Date.now() + } else { + clearTimeout(lastCall); + lastCall = setTimeout(() => { + if (Date.now() - lastTime >= ms) { f.apply(lastContext, lastArguments); lastTime = Date.now() - } else { - clearTimeout(lastCall); - lastCall = setTimeout(() => { - if (Date.now() - lastTime >= ms) { - f.apply(lastContext, lastArguments); - lastTime = Date.now() - } - }, ms - (Date.now() - lastTime)) } - } + }, ms - (Date.now() - lastTime)) } } -})(); +} diff --git a/web/js/workerManager.js b/web/js/workerManager.js index 3d119b2a..4afba4ca 100644 --- a/web/js/workerManager.js +++ b/web/js/workerManager.js @@ -1,151 +1,158 @@ -/** - * Worker manager module. - * @version 1 - */ -const workerManager = (() => { - const id = 'servers', - _class = 'server-list', - trigger = document.getElementById('w'), - panel = gui.panel(document.getElementById(id), 'WORKERS', 'server-list', null, [ - { - caption: '⟳', - cl: ['bold'], - handler: utils.debounce(handleReload, 1000), - title: 'Reload server data', - } - ]), - index = ((i = 1) => ({v: () => i++, r: () => i = 1}))(), - // caption -- the field caption - // renderer -- an arbitrary DOM output for the field - list = { - 'n': { - renderer: renderIdEl - }, - 'id': { - caption: 'ID', - renderer: (data) => data?.in_group ? `${data.id} x ${data.replicas}` : data.id - }, - 'addr': { - caption: 'Address', - renderer: (data) => data?.port ? `${data.addr}:${data.port}` : data.addr - }, - 'is_busy': { - caption: 'State', - renderer: renderStateEl - }, - 'use': { - caption: 'Use', - renderer: renderServerChangeEl - } +import {api} from 'api'; +import { + sub, + WORKER_LIST_FETCHED +} from 'event' +import {gui} from 'gui'; +import {log} from 'log'; +import {ajax} from 'network'; +import {debounce} from 'utils'; + +const id = 'servers', + _class = 'server-list', + trigger = document.getElementById('w'), + panel = gui.panel(document.getElementById(id), 'WORKERS', 'server-list', null, [ + { + caption: '⟳', + cl: ['bold'], + handler: debounce(handleReload, 1000), + title: 'Reload server data', + } + ]), + index = ((i = 1) => ({v: () => i++, r: () => i = 1}))(), + // caption -- the field caption + // renderer -- an arbitrary DOM output for the field + list = { + 'n': { + renderer: renderIdEl }, - fields = Object.keys(list); - - let state = { - lastId: null, - workers: [], - } - - const onNewData = (dat = {servers: []}) => { - panel.setLoad(false); - index.r(); - state.workers = dat?.servers || []; - _render(state.workers); - } - - function _render(servers = []) { - if (panel.isHidden()) return; - - const content = gui.fragment(); - - if (servers.length === 0) { - content.append(gui.create('span', (el) => el.innerText = 'No data :(')); - panel.setContent(content); - return; + 'id': { + caption: 'ID', + renderer: (data) => data?.in_group ? `${data.id} x ${data.replicas}` : data.id + }, + 'addr': { + caption: 'Address', + renderer: (data) => data?.port ? `${data.addr}:${data.port}` : data.addr + }, + 'is_busy': { + caption: 'State', + renderer: renderStateEl + }, + 'use': { + caption: 'Use', + renderer: renderServerChangeEl } + }, + fields = Object.keys(list); - const header = gui.create('div', (el) => { - el.classList.add(`${_class}__header`); - fields.forEach(field => el.append(gui.create('span', (f) => f.innerHTML = list[field]?.caption || ''))) - }); - content.append(header) +let state = { + lastId: null, + workers: [], +} - const renderRow = (server) => (row) => { - if (server?.id && state.lastId && state.lastId === server?.id) { - row.classList.add('active'); - } - return fields.forEach(field => { - const val = server.hasOwnProperty(field) ? server[field] : ''; - const renderer = list[field]?.renderer; - row.append(gui.create('span', (f) => f.append(renderer ? renderer(server) : val))); - }) - } - servers.forEach(server => content.append(gui.create('div', renderRow(server)))) +const onNewData = (dat = {servers: []}) => { + panel.setLoad(false); + index.r(); + state.workers = dat?.servers || []; + _render(state.workers); +} + +function _render(servers = []) { + if (panel.isHidden()) return; + + const content = gui.fragment(); + + if (servers.length === 0) { + content.append(gui.create('span', (el) => el.innerText = 'No data :(')); panel.setContent(content); + return; } - function handleReload() { - panel.setLoad(true); - api.server.getWorkerList(); - } + const header = gui.create('div', (el) => { + el.classList.add(`${_class}__header`); + fields.forEach(field => el.append(gui.create('span', (f) => f.innerHTML = list[field]?.caption || ''))) + }); + content.append(header) - function renderIdEl(server) { - const id = String(index.v()).padStart(2, '0'); - const isActive = server?.id && state.lastId && state.lastId === server?.id - return `${(isActive ? '>' : '')}${id}` - } - - function renderServerChangeEl(server) { - const handleServerChange = (e) => { - e.preventDefault(); - window.location.search = `wid=${server.id}` + const renderRow = (server) => (row) => { + if (server?.id && state.lastId && state.lastId === server?.id) { + row.classList.add('active'); } - return gui.create('a', (el) => { - el.innerText = '>>'; - el.href = "#"; - el.addEventListener('click', handleServerChange); + return fields.forEach(field => { + const val = server.hasOwnProperty(field) ? server[field] : ''; + const renderer = list[field]?.renderer; + row.append(gui.create('span', (f) => f.append(renderer ? renderer(server) : val))); }) } + servers.forEach(server => content.append(gui.create('div', renderRow(server)))) + panel.setContent(content); +} - function renderStateEl(server) { - const state = server?.is_busy === true ? 'R' : '' - if (server.room) { - return gui.create('a', (el) => { - el.innerText = state; - el.href = "/?id=" + server.room; - }) - } - return state +function handleReload() { + panel.setLoad(true); + api.server.getWorkerList(); +} + +function renderIdEl(server) { + const id = String(index.v()).padStart(2, '0'); + const isActive = server?.id && state.lastId && state.lastId === server?.id + return `${(isActive ? '>' : '')}${id}` +} + +function renderServerChangeEl(server) { + const handleServerChange = (e) => { + e.preventDefault(); + window.location.search = `wid=${server.id}` } - - panel.toggle(false); - - trigger.addEventListener('click', () => { - handleReload(); - panel.toggle(true); + return gui.create('a', (el) => { + el.innerText = '>>'; + el.href = "#"; + el.addEventListener('click', handleServerChange); }) +} - const checkLatencies = (data) => { - const timeoutMs = 1111; - // deduplicate - const addresses = [...new Set(data.addresses || [])]; - - return Promise.all(addresses.map(address => { - const start = Date.now(); - return ajax.fetch(`${address}?_=${start}`, {method: "GET", redirect: "follow"}, timeoutMs) - .then(() => ({[address]: Date.now() - start})) - .catch(() => ({[address]: 9999})); - })) - }; - - const whoami = (id) => { - state.lastId = id; - _render(state.workers); +function renderStateEl(server) { + const state = server?.is_busy === true ? 'R' : '' + if (server.room) { + return gui.create('a', (el) => { + el.innerText = state; + el.href = "/?id=" + server.room; + }) } + return state +} - event.sub(WORKER_LIST_FETCHED, onNewData); +panel.toggle(false); - return { - checkLatencies, - whoami, - } -})(ajax, api, document, event, gui, log, utils); +trigger.addEventListener('click', () => { + handleReload(); + panel.toggle(true); +}) + +const checkLatencies = (data) => { + const timeoutMs = 1111; + // deduplicate + const addresses = [...new Set(data.addresses || [])]; + + return Promise.all(addresses.map(address => { + const start = Date.now(); + return ajax.fetch(`${address}?_=${start}`, {method: "GET", redirect: "follow"}, timeoutMs) + .then(() => ({[address]: Date.now() - start})) + .catch(() => ({[address]: 9999})); + })) +}; + +const whoami = (id) => { + state.lastId = id; + _render(state.workers); +} + +sub(WORKER_LIST_FETCHED, onNewData); + +/** + * Worker manager module. + */ +export const workerManager = { + checkLatencies, + whoami, +} From 8654604b9bc636ebb025e660ad88fac8632fbc65 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 17 Mar 2024 22:01:10 +0300 Subject: [PATCH 072/240] Fix index.html warnings --- web/index.html | 41 ++++++++++++++++++++--------------------- web/js/input/touch.js | 15 ++++++++++----- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/web/index.html b/web/index.html index 61f24f13..8093f5d4 100644 --- a/web/index.html +++ b/web/index.html @@ -24,10 +24,10 @@
W
-
-
-
-
+
+
+
+
@@ -53,31 +53,30 @@ Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the clipboard)
-
+
player choice - +
-
+
- - -
-
+ + +
+
-
-
-
-
+
+
+
+
-
+
- - +
-
+
{{if .Recording.Enabled}} -
+ class="record-user" aria-label=""> +
{{end}}
diff --git a/web/js/input/touch.js b/web/js/input/touch.js index a246ef56..d60a30b4 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -34,6 +34,8 @@ dpadToggle.addEventListener('change', (e) => { pub(DPAD_TOGGLE, {checked: e.target.checked}); }); +const getKey = (el) => el.dataset.key + let dpadMode = true; const deadZone = 0.1; @@ -157,17 +159,17 @@ function handleVpadJoystickMove(event) { const _handleButton = (key, state) => checkVpadState(key, state) function handleButtonDown() { - _handleButton(this.getAttribute('value'), true); + _handleButton(getKey(this), true); } function handleButtonUp() { - _handleButton(this.getAttribute('value'), false); + _handleButton(getKey(this), false); } function handleButtonClick() { - _handleButton(this.getAttribute('value'), true); + _handleButton(getKey(this), true); setTimeout(() => { - _handleButton(this.getAttribute('value'), false); + _handleButton(getKey(this), false); }, 30); } @@ -286,6 +288,9 @@ playerSlider.addEventListener('oninput', handlePlayerSlider); playerSlider.addEventListener('onchange', handlePlayerSlider); playerSlider.addEventListener('click', handlePlayerSlider); playerSlider.addEventListener('touchend', handlePlayerSlider); +playerSlider.onkeydown = (e) => { + e.preventDefault(); +} // Bind events for menu // TODO change this flow @@ -308,7 +313,7 @@ export const touch = { init: () => { // add buttons into the state 🤦 Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => { - vpadState[el.getAttribute('value')] = false; + vpadState[getKey(el)] = false; }); window.addEventListener('mousemove', handleWindowMove); From 104498dec0228fd01a0971d4cda103c32b51c706 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 18 Mar 2024 13:45:01 +0300 Subject: [PATCH 073/240] Fix wrong import order of some modules --- web/js/app.js | 24 ++++++------------------ web/js/gui.js | 2 +- web/js/input/touch.js | 16 ++++++++-------- web/js/network/socket.js | 2 +- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/web/js/app.js b/web/js/app.js index 5e951d26..408125dc 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,3 +1,9 @@ +import {log} from 'log'; +import {opts, settings} from 'settings'; + +settings.init(); +log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); + import {api} from 'api'; import { pub, @@ -36,8 +42,6 @@ import { } from 'event'; import {gui} from 'gui'; import {keyboard, KEY, joystick, retropad, touch} from 'input'; -import {log} from 'log'; -import {opts, settings} from 'settings'; import {socket, webrtc} from 'network'; import {debounce} from 'utils'; @@ -512,22 +516,6 @@ sub(SETTINGS_CHANGED, () => { // initial app state setState(app.state.eden); -settings.init(); - -(() => { - let lvl = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); - // migrate old log level options - // !to remove at some point - if (isNaN(lvl)) { - console.warn( - `The log value [${lvl}] is not supported! ` + - `The default value [debug] will be used instead.`); - settings.set(opts.LOG_LEVEL, `${log.DEFAULT}`) - lvl = log.DEFAULT - } - log.level = lvl -})(); - keyboard.init(); joystick.init(); touch.init(); diff --git a/web/js/gui.js b/web/js/gui.js index 0d295ea1..8be85b19 100644 --- a/web/js/gui.js +++ b/web/js/gui.js @@ -31,7 +31,7 @@ const select = (key = '', callback = () => ({}), values = {values: [], labels: [ }; el.append(select); - select.append(_option('none', current === '')); + select.append(_option(0, current === '', 'none')); values.values.forEach((value, index) => { select.append(_option(value, current === value, values.labels?.[index])); }); diff --git a/web/js/input/touch.js b/web/js/input/touch.js index d60a30b4..fb2c1ed1 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -292,14 +292,6 @@ playerSlider.onkeydown = (e) => { e.preventDefault(); } -// Bind events for menu -// TODO change this flow -pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); -pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown}); -pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp}); - -sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); - /** * Touch controls. * @@ -311,6 +303,14 @@ sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); */ export const touch = { init: () => { + // Bind events for menu + // TODO change this flow + pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); + pub(MENU_HANDLER_ATTACHED, {event: 'touchstart', handler: handleMenuDown}); + pub(MENU_HANDLER_ATTACHED, {event: 'touchend', handler: handleMenuUp}); + + sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); + // add buttons into the state 🤦 Array.from(document.querySelectorAll('.btn,.btn-big')).forEach((el) => { vpadState[getKey(el)] = false; diff --git a/web/js/network/socket.js b/web/js/network/socket.js index 47314d9d..e153f441 100644 --- a/web/js/network/socket.js +++ b/web/js/network/socket.js @@ -20,7 +20,7 @@ const init = (roomId, wid, zone) => { let objParams = {room_id: roomId, zone: zone}; if (wid) objParams.wid = wid; const url = buildUrl(objParams) - console.info(`[ws] connecting to ${url}`); + log.info(`[ws] connecting to ${url}`); conn = new WebSocket(url.toString()); conn.onopen = () => { log.info('[ws] <- open connection'); From ff6c344a15a1a0fbc2f6ee8d9d8133c50550542c Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 21 Mar 2024 16:01:56 +0300 Subject: [PATCH 074/240] Update dependencies --- go.mod | 6 +++--- go.sum | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 37f7db61..abe5f5c0 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/gorilla/websocket v1.5.1 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.0 - github.com/minio/minio-go/v7 v7.0.68 + github.com/minio/minio-go/v7 v7.0.69 github.com/pion/ice/v3 v3.0.3 github.com/pion/interceptor v0.1.25 github.com/pion/logging v0.2.2 @@ -45,8 +45,8 @@ require ( github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.14 // indirect - github.com/pion/rtp v1.8.3 // indirect - github.com/pion/sctp v1.8.12 // indirect + github.com/pion/rtp v1.8.4 // indirect + github.com/pion/sctp v1.8.13 // indirect github.com/pion/sdp/v3 v3.0.8 // indirect github.com/pion/srtp/v3 v3.0.1 // indirect github.com/pion/stun/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index 73859611..70024001 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.68 h1:hTqSIfLlpXaKuNy4baAp4Jjy2sqZEN9hRxD0M4aOfrQ= -github.com/minio/minio-go/v7 v7.0.68/go.mod h1:XAvOPJQ5Xlzk5o3o/ArO2NMbhSGkimC+bpW/ngRKDmQ= +github.com/minio/minio-go/v7 v7.0.69 h1:l8AnsQFyY1xiwa/DaQskY4NXSLA2yrGsW5iD9nRPVS0= +github.com/minio/minio-go/v7 v7.0.69/go.mod h1:XAvOPJQ5Xlzk5o3o/ArO2NMbhSGkimC+bpW/ngRKDmQ= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -106,11 +106,13 @@ github.com/pion/rtcp v1.2.13/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9 github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/rtp v1.8.3 h1:VEHxqzSVQxCkKDSHro5/4IUUG1ea+MFdqR2R3xSpNU8= github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.4 h1:VqNGMNjMDMy9y0d+h+0dfjiWVKUEDQvA963jhJwu200= +github.com/pion/rtp v1.8.4/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.12 h1:2VX50pedElH+is6FI+OKyRTeN5oy4mrk2HjnGa3UCmY= github.com/pion/sctp v1.8.12/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI= +github.com/pion/sctp v1.8.13 h1:YUJR44pWM2FPUhkl8l+vDyF2EDE3aTWtr3c+LDhCRcQ= +github.com/pion/sctp v1.8.13/go.mod h1:YKSgO/bO/6aOMP9LCie1DuD7m+GamiK2yIiPM6vH+GA= github.com/pion/sdp/v3 v3.0.8 h1:yd/wkrS0nzXEAb+uwv1TL3SG/gzsTiXHVOtXtD7EKl0= github.com/pion/sdp/v3 v3.0.8/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= github.com/pion/srtp/v3 v3.0.1 h1:AkIQRIZ+3tAOJMQ7G301xtrD1vekQbNeRO7eY1K8ZHk= From 4d5033f03c575c4a938fca5eb0ad02cf1848e0a6 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 21 Mar 2024 16:03:52 +0300 Subject: [PATCH 075/240] Allow duplicate frames Some cores for performance reasons may return duplicate frames (i.e. previous frames) instead of rendering them again. --- pkg/config/config.yaml | 2 ++ pkg/config/emulator.go | 1 + pkg/worker/caged/libretro/frontend.go | 11 ++++++++++- pkg/worker/caged/libretro/nanoarch/nanoarch.go | 13 ++++++++----- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 57cb39de..60fc6f53 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -135,6 +135,8 @@ emulator: # 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: diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index 49bf6713..24993cbe 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -28,6 +28,7 @@ type LibretroConfig struct { List map[string]LibretroCoreConfig } DebounceMs int + Dup bool SaveCompression bool LogLevel int } diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 1b6638e3..935a24f3 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -93,6 +93,7 @@ var ( noData = func([]byte) {} noVideo = func(app.Video) {} videoPool sync.Pool + lastFrame *app.Video ) // NewFrontend implements Emulator interface for a Libretro frontend. @@ -156,6 +157,7 @@ func (f *Frontend) LoadCore(emu string) { conf := f.conf.GetLibretroCoreConfig(emu) meta := nanoarch.Metadata{ AutoGlContext: conf.AutoGlContext, + FrameDup: f.conf.Libretro.Dup, Hacks: conf.Hacks, HasVFR: conf.VFR, Hid: conf.Hid, @@ -190,7 +192,6 @@ func (f *Frontend) handleAudio(audio unsafe.Pointer, samples int) { } func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) { - // !to merge both pools fr, _ := videoPool.Get().(*app.Video) if fr == nil { fr = new(app.Video) @@ -200,10 +201,17 @@ 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() { + f.onVideo(*lastFrame) +} + func (f *Frontend) Shutdown() { f.mu.Lock() f.nano.Shutdown() @@ -224,6 +232,7 @@ func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) { f.nano.OnDpad = f.input.isDpadTouched f.nano.OnVideo = f.handleVideo f.nano.OnAudio = f.handleAudio + f.nano.OnDup = f.handleDup } func (f *Frontend) SetVideoChangeCb(fn func()) { diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 95cd24a6..94ff0b0d 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -78,6 +78,7 @@ type Handlers struct { 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() } @@ -88,6 +89,7 @@ type FrameInfo struct { } type Metadata struct { + FrameDup bool LibPath string // the full path to some emulator lib IsGlAllowed bool UsesLibCo bool @@ -127,6 +129,7 @@ var Nan0 = Nanoarch{ OnKeyPress: func(uint, int) int { return 0 }, OnAudio: func(unsafe.Pointer, int) {}, OnVideo: func([]byte, int32, FrameInfo) {}, + OnDup: func() {}, }, } @@ -559,9 +562,9 @@ func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { } 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 } @@ -694,9 +697,9 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { setRotation((*(*uint)(data) % 4) * 90) return true case C.RETRO_ENVIRONMENT_GET_CAN_DUPE: - // !to implement frame dup (nil) some time later - *(*C.bool)(data) = C.bool(false) - return false + 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 From 84f55691eb34f7f524b64cba951a591d8bc49582 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 21 Mar 2024 23:02:02 +0300 Subject: [PATCH 076/240] Check if dup frame didn't exist FBNeo can return dup frame flag before its first frame. --- pkg/worker/caged/libretro/frontend.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 935a24f3..77be7be8 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -209,7 +209,9 @@ func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) } func (f *Frontend) handleDup() { - f.onVideo(*lastFrame) + if lastFrame != nil { + f.onVideo(*lastFrame) + } } func (f *Frontend) Shutdown() { @@ -217,6 +219,7 @@ func (f *Frontend) Shutdown() { f.nano.Shutdown() f.SetAudioCb(noAudio) f.SetVideoCb(noVideo) + lastFrame = nil f.mu.Unlock() f.log.Debug().Msgf("frontend shutdown done") } From 5da77a6b4fd975ef46263f00112c97bbe97fb98f Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 21 Mar 2024 23:02:53 +0300 Subject: [PATCH 077/240] Fix aspect ratio of PSX games in full-screen --- web/js/stream.js | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/web/js/stream.js b/web/js/stream.js index cd213464..3ccfaee4 100644 --- a/web/js/stream.js +++ b/web/js/stream.js @@ -86,28 +86,26 @@ screen.addEventListener('canplay', () => { useCustomScreen(options.mirrorMode === 'mirror'); }, false); +const screenToAspect = (el) => { + const w = window.screen.width ?? window.innerWidth; + const hh = el.innerHeight || el.clientHeight || 0; + const dw = (w - hh * state.aspect) / 2 + screen.style.padding = `0 ${dw}px` +} + screen.addEventListener('fullscreenchange', () => { state.fullscreen = !!document.fullscreenElement; - const w = window.screen.width ?? window.innerWidth; - const h = window.screen.height ?? window.innerHeight; - - const ww = document.documentElement.innerWidth; - const hh = document.documentElement.innerHeight; - - screen.style.padding = '0' - if (state.fullscreen) { - const dw = (w - ww * state.aspect) / 2 - screen.style.padding = `0 ${dw}px` + if (!state.fullscreen) { + screen.style.padding = '0' + } else { + screenToAspect(document.fullscreenElement); // chrome bug setTimeout(() => { - const dw = (h - hh * state.aspect) / 2 - screen.style.padding = `0 ${dw}px` + screenToAspect(document.fullscreenElement) }, 1) - makeFullscreen(true); - } else { - makeFullscreen(false); } + makeFullscreen(state.fullscreen); // !to flipped }) From 084c14175e5167998a682388b8282d8e744eff1a Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 22 Mar 2024 00:17:22 +0300 Subject: [PATCH 078/240] Use AR correction in MAME --- pkg/config/config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 60fc6f53..d9505247 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -224,6 +224,7 @@ emulator: # https://docs.libretro.com/library/fbneo/ mame: lib: fbneo_libretro + coreAspectRatio: true roms: [ "zip" ] nes: lib: nestopia_libretro From ecbe7f6ad9f72d58f8a79c3475b92aa12d30f630 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 31 Mar 2024 21:22:41 +0300 Subject: [PATCH 079/240] Remove unused ping stats module --- web/js/event.js | 2 -- web/js/stats.js | 90 +++++++------------------------------------------ 2 files changed, 12 insertions(+), 80 deletions(-) diff --git a/web/js/event.js b/web/js/event.js index df189044..e2ae32a1 100644 --- a/web/js/event.js +++ b/web/js/event.js @@ -51,8 +51,6 @@ export const pub = (topic, data) => { // events export const LATENCY_CHECK_REQUESTED = 'latencyCheckRequested'; -export const PING_REQUEST = 'pingRequest'; -export const PING_RESPONSE = 'pingResponse'; export const WORKER_LIST_FETCHED = 'workerListFetched'; diff --git a/web/js/stats.js b/web/js/stats.js index 126ca4a8..fdd2c444 100644 --- a/web/js/stats.js +++ b/web/js/stats.js @@ -3,9 +3,7 @@ import { pub, sub, STATS_TOGGLE, - HELP_OVERLAY_TOGGLED, - PING_REQUEST, - PING_RESPONSE + HELP_OVERLAY_TOGGLED } from 'event'; import {log} from 'log'; import {webrtc} from 'network'; @@ -20,6 +18,10 @@ let active = false; // !to add connection drop notice +// internal events +const WEBRTC_STATS_FRAME = 'STATS_WEBRTC_FRAME_STATS'; +const WEBRTC_STATS_RTT = 'STATS_WEBRTC_ICE_RTT'; + const statsOverlayEl = document.getElementById('stats-overlay'); /** @@ -149,73 +151,6 @@ const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { return {el: ui, update, withPostfix} } -/** - * Latency stats submodule. - * - * Accumulates the simple rolling mean value - * between the next server request and following server response values. - * - * window - * _____________ - * | | - * [1, 1, 3, 4, 1, 4, 3, 1, 2, 1, 1, 1, 2, ... n] - * | - * stats_snapshot_period - * mean = round(next - mean / length % window) - * - * Events: - * <- PING_RESPONSE - * <- PING_REQUEST - * - * ?Interface: - * HTMLElement get() - * void enable() - * void disable() - * void render() - * - * @version 1 - */ -const latency = (() => { - let listeners = []; - - let mean = 0; - let length = 0; - let previous = 0; - const window = 5; - - const ui = moduleUi('Ping(c)', true); - - const onPingRequest = (data) => previous = data.time; - - const onPingResponse = () => { - length++; - const delta = Date.now() - previous; - mean += Math.round((delta - mean) / length); - - if (length % window === 0) { - length = 1; - mean = delta; - } - } - - const enable = () => { - listeners.push( - sub(PING_RESPONSE, onPingResponse), - sub(PING_REQUEST, onPingRequest) - ); - } - - const disable = () => { - while (listeners.length) listeners.shift().unsub(); - } - - const render = () => ui.update(mean); - - const get = () => ui.el; - - return {get, enable, disable, render} -})(event, moduleUi); - /** * User agent memory stats. * @@ -264,25 +199,25 @@ const clientMemory = (() => { return {get, enable, disable, render} })(moduleUi, performance, window); - const webRTCStats_ = (() => { let interval = null function getStats() { if (!webrtc.isConnected()) return; - webrtc.getConnection().getStats(null).then(stats => { + + webrtc.getConnection().getStats().then(stats => { let frameStatValue = '?'; stats.forEach(report => { if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) { frameStatValue = report["framesReceived"] - report["framesDecoded"] - report["framesDropped"]; - pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) + pub(WEBRTC_STATS_FRAME, frameStatValue) } else if (report["framerateMean"] !== undefined) { frameStatValue = Math.round(report["framerateMean"] * 100) / 100; - pub('STATS_WEBRTC_FRAME_STATS', frameStatValue) + pub(WEBRTC_STATS_FRAME, frameStatValue) } if (report["nominated"] && report["currentRoundTripTime"] !== undefined) { - pub('STATS_WEBRTC_ICE_RTT', report["currentRoundTripTime"] * 1000); + pub(WEBRTC_STATS_RTT, report["currentRoundTripTime"] * 1000); } }); }); @@ -339,12 +274,12 @@ const webRTCRttStats = (() => { let value = 0; let listener; - const ui = moduleUi('RTT', true, () => 'ms'); + const ui = moduleUi('Ping', true, () => 'ms'); const get = () => ui.el; const enable = () => { - listener = sub('STATS_WEBRTC_ICE_RTT', onStats); + listener = sub(WEBRTC_STATS_RTT, onStats); } const disable = () => { @@ -421,7 +356,6 @@ const render = () => modules(m => m.render(), false); // add submodules _modules.push( webRTCRttStats, - // latency, clientMemory, webRTCStats_, webRTCFrameStats From 7377b4f15b17c31742f4dd7b90a920781ca181e0 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 31 Mar 2024 21:30:46 +0300 Subject: [PATCH 080/240] Update dependencies --- go.mod | 14 +++++++------- go.sum | 34 +++++++++++++++++++--------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index abe5f5c0..5b0b031c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/giongto35/cloud-game/v3 go 1.22 require ( - github.com/VictoriaMetrics/metrics v1.33.0 + github.com/VictoriaMetrics/metrics v1.33.1 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.7.0 github.com/goccy/go-json v0.10.2 @@ -13,9 +13,9 @@ require ( github.com/knadh/koanf/v2 v2.1.0 github.com/minio/minio-go/v7 v7.0.69 github.com/pion/ice/v3 v3.0.3 - github.com/pion/interceptor v0.1.25 + github.com/pion/interceptor v0.1.27 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.0-beta.13 + github.com/pion/webrtc/v4 v4.0.0-beta.14 github.com/rs/xid v1.5.0 github.com/rs/zerolog v1.32.0 github.com/veandco/go-sdl2 v0.4.38 @@ -40,18 +40,18 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pion/datachannel v1.5.5 // indirect + github.com/pion/datachannel v1.5.6 // indirect github.com/pion/dtls/v2 v2.2.10 // indirect github.com/pion/mdns v0.0.12 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.14 // indirect - github.com/pion/rtp v1.8.4 // indirect + github.com/pion/rtp v1.8.5 // indirect github.com/pion/sctp v1.8.13 // indirect - github.com/pion/sdp/v3 v3.0.8 // indirect + github.com/pion/sdp/v3 v3.0.9 // indirect github.com/pion/srtp/v3 v3.0.1 // 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/transport/v3 v3.0.2 // indirect github.com/pion/turn/v3 v3.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/testify v1.9.0 // indirect diff --git a/go.sum b/go.sum index 70024001..6daceafc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.33.0 h1:EnkDEaGiL2u95t+W76GfecC/LMYpy+tFrexYzBWQIAc= -github.com/VictoriaMetrics/metrics v1.33.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.33.1 h1:CNV3tfm2Kpv7Y9W3ohmvqgFWPR55tV2c7M2U6OIo+UM= +github.com/VictoriaMetrics/metrics v1.33.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= 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= @@ -85,51 +85,50 @@ github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042 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/datachannel v1.5.6 h1:1IxKJntfSlYkpUj8LlYRSWpYiTTC02nUrOE8T3DqGeg= +github.com/pion/datachannel v1.5.6/go.mod h1:1eKT6Q85pRnr2mHiWHxJwO50SfZRtWHTsNIVb/NfGW4= github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= github.com/pion/dtls/v2 v2.2.10 h1:u2Axk+FyIR1VFTPurktB+1zoEPGIW3bmyj3LEFrXjAA= github.com/pion/dtls/v2 v2.2.10/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE= github.com/pion/ice/v3 v3.0.3 h1:Mu5QkZ2pYmcjq9JETDcDR7F8UzjP1VHmcZmgU0yqsyk= github.com/pion/ice/v3 v3.0.3/go.mod h1:YMLU7qbKfVjmEv7EoZPIVEI+kNYxWCdPK3VS0BU+U4Q= -github.com/pion/interceptor v0.1.25 h1:pwY9r7P6ToQ3+IF0bajN0xmk/fNw/suTgaTdlwTDmhc= -github.com/pion/interceptor v0.1.25/go.mod h1:wkbPYAak5zKsfpVDYMtEfWEy8D4zL+rpxCxPImLOg3Y= +github.com/pion/interceptor v0.1.27 h1:mZ01OiGiukwRxezmDGzYjjokCVlDOk4T6BfaL5qrtGo= +github.com/pion/interceptor v0.1.27/go.mod h1:/vVaqLwDjGv4GRbgmChIKZIT5EXFDijwmj4WmIYy9bI= 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.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8= github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk= 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/go.mod h1:ztfEwXZNLGyF1oQDttz/ZKIBaeeg/oWbRYqzBM9TL1I= github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= -github.com/pion/rtcp v1.2.13/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= -github.com/pion/rtp v1.8.2/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/rtp v1.8.4 h1:VqNGMNjMDMy9y0d+h+0dfjiWVKUEDQvA963jhJwu200= github.com/pion/rtp v1.8.4/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.5 h1:uYzINfaK+9yWs7r537z/Rc1SvT8ILjBcmDOpJcTB+OU= +github.com/pion/rtp v1.8.5/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.12/go.mod h1:cMLT45jqw3+jiJCrtHVwfQLnfR0MGZ4rgOJwUOIqLkI= github.com/pion/sctp v1.8.13 h1:YUJR44pWM2FPUhkl8l+vDyF2EDE3aTWtr3c+LDhCRcQ= github.com/pion/sctp v1.8.13/go.mod h1:YKSgO/bO/6aOMP9LCie1DuD7m+GamiK2yIiPM6vH+GA= -github.com/pion/sdp/v3 v3.0.8 h1:yd/wkrS0nzXEAb+uwv1TL3SG/gzsTiXHVOtXtD7EKl0= github.com/pion/sdp/v3 v3.0.8/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= +github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= github.com/pion/srtp/v3 v3.0.1 h1:AkIQRIZ+3tAOJMQ7G301xtrD1vekQbNeRO7eY1K8ZHk= github.com/pion/srtp/v3 v3.0.1/go.mod h1:3R3a1qIOIxBkVTLGFjafKK6/fJoTdQDhcC67HOyMbJ8= 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/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkLg4= +github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= 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.13 h1:nIhz2viUhaFvKlbLDpF/XQqlsS+PhfYTxd8qcAo1pL8= -github.com/pion/webrtc/v4 v4.0.0-beta.13/go.mod h1:ojwmbdrsIkmRXPumQf9OFIkTJVB9AV/Z9ItMpNvsuhM= +github.com/pion/webrtc/v4 v4.0.0-beta.14 h1:pNBIkhCsLBdgeCzCNIK5xBWdTWGf7to6ifMw+fvYTMQ= +github.com/pion/webrtc/v4 v4.0.0-beta.14/go.mod h1:MMP3NBhZaFIHbVLWqJdILOkXbDh4V0as+v4SA6Gi08E= 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= @@ -166,6 +165,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= @@ -187,6 +187,7 @@ 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.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -218,6 +219,7 @@ golang.org/x/sys v0.8.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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -228,6 +230,8 @@ 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.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= 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= From 3e0fcfbfcfc811082aad9fe1c8c23b42b24cf30f Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 31 Mar 2024 21:31:18 +0300 Subject: [PATCH 081/240] Enable SCTP zero checksums --- pkg/network/webrtc/factory.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/network/webrtc/factory.go b/pkg/network/webrtc/factory.go index 0eaa9dd7..e450abfb 100644 --- a/pkg/network/webrtc/factory.go +++ b/pkg/network/webrtc/factory.go @@ -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) From f557d169970214d1690b3614b754d0e40b46c651 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 31 Mar 2024 22:08:32 +0300 Subject: [PATCH 082/240] Fix broken link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 654dc6d6..24c5a405 100644 --- a/README.md +++ b/README.md @@ -125,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) From cebbcdf2562b0e5241259d0d30f004dcedf1c30f Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 2 Apr 2024 00:55:25 +0300 Subject: [PATCH 083/240] Refactor WebRTC stats --- web/js/app.js | 48 ++++++++- web/js/event.js | 1 - web/js/network/webrtc.js | 5 +- web/js/stats.js | 223 ++++++++------------------------------- 4 files changed, 92 insertions(+), 185 deletions(-) diff --git a/web/js/app.js b/web/js/app.js index 408125dc..e6473b8a 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -29,7 +29,6 @@ import { RECORDING_STATUS_CHANGED, RECORDING_TOGGLED, SETTINGS_CHANGED, - STATS_TOGGLE, WEBRTC_CONNECTION_CLOSED, WEBRTC_CONNECTION_READY, WEBRTC_ICE_CANDIDATE_FOUND, @@ -38,7 +37,7 @@ import { WEBRTC_NEW_CONNECTION, WEBRTC_SDP_ANSWER, WEBRTC_SDP_OFFER, - WORKER_LIST_FETCHED + WORKER_LIST_FETCHED, } from 'event'; import {gui} from 'gui'; import {keyboard, KEY, joystick, retropad, touch} from 'input'; @@ -395,7 +394,7 @@ const app = { message.show('Saving the game.'); break; case KEY.STATS: - pub(STATS_TOGGLE); + stats.toggle(); break; case KEY.SETTINGS: break; @@ -449,7 +448,7 @@ const app = { window.location = window.location.pathname; break; case KEY.STATS: - pub(STATS_TOGGLE); + stats.toggle(); break; case KEY.DTOGGLE: handleToggle(); @@ -527,3 +526,44 @@ const wid = new URLSearchParams(document.location.search).get('wid'); // if from URL -> start game immediately! socket.init(roomId, wid, zone); api.transport = socket; + +// stats +let WEBRTC_STATS_FRAME_DELAY; +let WEBRTC_STATS_RTT; + +stats.modules = [ + { + mui: stats.mui('Ping', true), + init() { + WEBRTC_STATS_RTT = (v) => (this.val = v) + }, + }, + { + mui: stats.mui('FrameDelay', false, () => ''), + init() { + WEBRTC_STATS_FRAME_DELAY = (v) => (this.val = v) + } + }, + { + async stats() { + const stats = await webrtc.stats(); + if (!stats) return; + + stats.forEach(report => { + const {framesReceived, framesDecoded, framesDropped} = report; + if (framesReceived !== undefined && framesDecoded !== undefined && framesDropped !== undefined) { + WEBRTC_STATS_FRAME_DELAY(framesReceived - framesDecoded - framesDropped) + } + const {nominated, currentRoundTripTime} = report; + if (nominated && currentRoundTripTime !== undefined) { + WEBRTC_STATS_RTT(currentRoundTripTime * 1000); + } + }); + }, + enable() { + this.interval = window.setInterval(this.stats, 999); + }, + disable() { + window.clearInterval(this.interval); + }, + }] diff --git a/web/js/event.js b/web/js/event.js index e2ae32a1..6a742af1 100644 --- a/web/js/event.js +++ b/web/js/event.js @@ -87,7 +87,6 @@ export const AXIS_CHANGED = 'axisChanged'; export const CONTROLLER_UPDATED = 'controllerUpdated'; export const DPAD_TOGGLE = 'dpadToggle'; -export const STATS_TOGGLE = 'statsToggle'; export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; export const SETTINGS_CHANGED = 'settingsChanged'; diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js index 5e8ae47d..53e02988 100644 --- a/web/js/network/webrtc.js +++ b/web/js/network/webrtc.js @@ -168,7 +168,10 @@ export const webrtc = { input: (data) => dataChannel.send(data), isConnected: () => connected, isInputReady: () => inputReady, - getConnection: () => connection, + stats: async () => { + if (!connected) return Promise.resolve(); + return await connection.getStats() + }, stop, set onData(fn) { onData = fn diff --git a/web/js/stats.js b/web/js/stats.js index fdd2c444..6bc5fb34 100644 --- a/web/js/stats.js +++ b/web/js/stats.js @@ -1,12 +1,7 @@ -import {env} from 'env'; import { - pub, sub, - STATS_TOGGLE, HELP_OVERLAY_TOGGLED } from 'event'; -import {log} from 'log'; -import {webrtc} from 'network'; const _modules = []; let tempHide = false; @@ -18,10 +13,6 @@ let active = false; // !to add connection drop notice -// internal events -const WEBRTC_STATS_FRAME = 'STATS_WEBRTC_FRAME_STATS'; -const WEBRTC_STATS_RTT = 'STATS_WEBRTC_ICE_RTT'; - const statsOverlayEl = document.getElementById('stats-overlay'); /** @@ -73,18 +64,16 @@ const graph = (parent, opts = { /** * Draws a bar graph on the canvas. + * + * @example + * +-------+ +-------+ +---------+ + * | | |+---+ | |+---+ | + * | | |||||| | ||||||+---+ + * | | |||||| | ||||||||||| + * +-------+ +----+--+ +---------+ + * [] [3] [3, 2] */ const render = () => { - // 0,0 w,0 0,0 w,0 0,0 w,0 - // +-------+ +-------+ +---------+ - // | | |+---+ | |+---+ | - // | | |||||| | ||||||+---+ - // | | |||||| | ||||||||||| - // +-------+ +----+--+ +---------+ - // 0,h w,h 0,h w,h 0,h w,h - // [] [3] [3, 2] - // - _context.clearRect(0, 0, _canvas.width, _canvas.height); maxN = data[0] || 1; @@ -109,7 +98,12 @@ const graph = (parent, opts = { _context.fillRect(x, y, w, h); } - return {add, get, max, render} + const clear = () => { + data = []; + render(); + } + + return {add, get, max, render, clear} } /** @@ -148,155 +142,34 @@ const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { _value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; } - return {el: ui, update, withPostfix} + const clear = () => { + _graph && _graph.clear(); + } + + return {el: ui, update, withPostfix, clear} } -/** - * User agent memory stats. - * - * ?Interface: - * HTMLElement get() - * void enable() - * void disable() - * void render() - * - * @version 1 - */ -const clientMemory = (() => { - let active = false; +const modules = (fn, force = true) => _modules.forEach(m => (force || m.get) && fn(m)) - const measures = ['B', 'KB', 'MB', 'GB']; - const precision = 1; - let mLog = 0; - - const ui = moduleUi('Memory', false, (x) => (x > 0) ? measures[mLog] : ''); - - const get = () => ui.el; - - const enable = () => { - active = true; - render(); +const module = (mod) => { + mod = { + val: 0, + enable: () => ({}), + ...mod, + _disable: function () { + mod.val = 0; + mod.disable && mod.disable(); + mod.mui && mod.mui.clear(); + }, + ...(mod.mui && { + get: () => mod.mui.el, + render: () => mod.mui.update(mod.val) + }) } - - const disable = () => active = false; - - const render = () => { - if (!active) return; - - const m = performance.memory.usedJSHeapSize; - let newValue = 'N/A'; - - if (m > 0) { - mLog = Math.floor(Math.log(m) / Math.log(1000)); - newValue = Math.round(m * precision / Math.pow(1000, mLog)) / precision; - } - - ui.update(newValue); - } - - if (window.performance && !performance.memory) performance.memory = {usedJSHeapSize: 0, totalJSHeapSize: 0}; - - return {get, enable, disable, render} -})(moduleUi, performance, window); - -const webRTCStats_ = (() => { - let interval = null - - function getStats() { - if (!webrtc.isConnected()) return; - - webrtc.getConnection().getStats().then(stats => { - let frameStatValue = '?'; - stats.forEach(report => { - if (report["framesReceived"] !== undefined && report["framesDecoded"] !== undefined && report["framesDropped"] !== undefined) { - frameStatValue = report["framesReceived"] - report["framesDecoded"] - report["framesDropped"]; - pub(WEBRTC_STATS_FRAME, frameStatValue) - } else if (report["framerateMean"] !== undefined) { - frameStatValue = Math.round(report["framerateMean"] * 100) / 100; - pub(WEBRTC_STATS_FRAME, frameStatValue) - } - - if (report["nominated"] && report["currentRoundTripTime"] !== undefined) { - pub(WEBRTC_STATS_RTT, report["currentRoundTripTime"] * 1000); - } - }); - }); - } - - const enable = () => { - interval = window.setInterval(getStats, 1000); - } - - const disable = () => window.clearInterval(interval); - - return {enable, disable, internal: true} -})(event, webrtc, window); - -/** - * User agent frame stats. - * - * ?Interface: - * HTMLElement get() - * void enable() - * void disable() - * void render() - * - * @version 1 - */ -const webRTCFrameStats = (() => { - let value = 0; - let listener; - - const label = env.getBrowser() === 'firefox' ? 'FramerateMean' : 'FrameDelay'; - const ui = moduleUi(label, false, () => ''); - - const get = () => ui.el; - - const enable = () => { - listener = sub('STATS_WEBRTC_FRAME_STATS', onStats); - } - - const disable = () => { - value = 0; - if (listener) listener.unsub(); - } - - const render = () => ui.update(value); - - function onStats(val) { - value = val; - } - - return {get, enable, disable, render} -})(env, event, moduleUi); - -const webRTCRttStats = (() => { - let value = 0; - let listener; - - const ui = moduleUi('Ping', true, () => 'ms'); - - const get = () => ui.el; - - const enable = () => { - listener = sub(WEBRTC_STATS_RTT, onStats); - } - - const disable = () => { - value = 0; - if (listener) listener.unsub(); - } - - const render = () => ui.update(value); - - function onStats(val) { - value = val; - } - - return {get, enable, disable, render} -})(event, moduleUi); - -const modules = (fn, force = true) => _modules.forEach(m => (force || !m.internal) && fn(m)) + mod.init?.(); + _modules.push(mod); + modules(m => m.get && statsOverlayEl.append(m.get()), false); +} const enable = () => { active = true; @@ -321,15 +194,13 @@ function draw(timestamp) { const disable = () => { active = false; - modules(m => m.disable()); + modules(m => m._disable()); _hide(); } const _show = () => statsOverlayEl.style.visibility = 'visible'; const _hide = () => statsOverlayEl.style.visibility = 'hidden'; -const onToggle = () => active ? disable() : enable(); - /** * Handles help overlay toggle event. * Workaround for a not normal app layout layering. @@ -354,21 +225,15 @@ const onHelpOverlayToggle = (overlay) => { const render = () => modules(m => m.render(), false); // add submodules -_modules.push( - webRTCRttStats, - clientMemory, - webRTCStats_, - webRTCFrameStats -); -modules(m => statsOverlayEl.append(m.get()), false); - -sub(STATS_TOGGLE, onToggle); sub(HELP_OVERLAY_TOGGLED, onHelpOverlayToggle) /** * App statistics module. */ export const stats = { - enable, - disable + toggle: () => active ? disable() : enable(), + set modules(m) { + m && m.forEach(mod => module(mod)) + }, + mui: moduleUi, } From effa5c46c59a4faeed7f7167de78f6d5150e2af3 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 3 Apr 2024 19:52:42 +0300 Subject: [PATCH 084/240] Update UA/PLT detection --- web/js/env.js | 73 ++++++++++++++++------------------------ web/js/input/joystick.js | 18 +++++----- web/js/stream.js | 10 +++--- 3 files changed, 43 insertions(+), 58 deletions(-) diff --git a/web/js/env.js b/web/js/env.js index ef1ef3d5..b9d5f2e7 100644 --- a/web/js/env.js +++ b/web/js/env.js @@ -3,6 +3,9 @@ const page = document.getElementsByTagName('html')[0]; const gameBoy = document.getElementById('gamebody'); const sourceLink = document.getElementsByClassName('source')[0]; +export const browser = {unknown: 0, firefox: 1, chrome: 2, edge: 3, safari: 4} +export const platform = {unknown: 0, windows: 1, linux: 2, macos: 3, android: 4,} + let isLayoutSwitched = false; // Window rerender / rotate screen if needed @@ -44,53 +47,35 @@ const rescaleGameBoy = (targetWidth, targetHeight) => { gameBoy.style['transform'] = transformations.join(' '); } -const getOS = () => { - // linux? ios? - let OSName = 'unknown'; - if (navigator.appVersion.indexOf('Win') !== -1) OSName = 'win'; - else if (navigator.appVersion.indexOf('Mac') !== -1) OSName = 'mac'; - else if (navigator.userAgent.indexOf('Linux') !== -1) OSName = 'linux'; - else if (navigator.userAgent.indexOf('Android') !== -1) OSName = 'android'; - return OSName; -}; +const os = () => { + const ua = window.navigator.userAgent; + // noinspection JSUnresolvedReference,JSDeprecatedSymbols + const plt = window.navigator?.userAgentData?.platform || window.navigator.platform; + const macs = ["Macintosh", "MacIntel"]; + const wins = ["Win32", "Win64", "Windows"]; + if (wins.indexOf(plt) !== -1) return platform.windows; + if (macs.indexOf(plt) !== -1) return platform.macos; + if (/Linux/.test(plt)) return platform.linux; + if (/Android/.test(ua)) return platform.android; + return platform.unknown +} -const getBrowser = () => { - let browserName = 'unknown'; - if (navigator.userAgent.indexOf('Firefox') !== -1) browserName = 'firefox'; - if (navigator.userAgent.indexOf('Chrome') !== -1) browserName = 'chrome'; - if (navigator.userAgent.indexOf('Edge') !== -1) browserName = 'edge'; - if (navigator.userAgent.indexOf('Version/') !== -1) browserName = 'safari'; - if (navigator.userAgent.indexOf('UCBrowser') !== -1) browserName = 'uc'; - return browserName; -}; +const _browser = () => { + if (navigator.userAgent.indexOf('Firefox') !== -1) return browser.firefox; + if (navigator.userAgent.indexOf('Chrome') !== -1) return browser.chrome; + if (navigator.userAgent.indexOf('Edge') !== -1) return browser.edge; + if (navigator.userAgent.indexOf('Version/') !== -1) return browser.safari; + return browser.unknown; +} + +const isMobile = () => /Mobi|Android|iPhone/i.test(navigator.userAgent); const isPortrait = () => getWidth(page) < getHeight(page); const toggleFullscreen = (enable, element) => { const el = enable ? element : document; - - if (enable) { - if (el.requestFullscreen) { - el.requestFullscreen(); - } else if (el.mozRequestFullScreen) { /* Firefox */ - el.mozRequestFullScreen(); - } else if (el.webkitRequestFullscreen) { /* Chrome, Safari and Opera */ - el.webkitRequestFullscreen(); - } else if (el.msRequestFullscreen) { /* IE/Edge */ - el.msRequestFullscreen(); - } - } else { - if (el.exitFullscreen) { - el.exitFullscreen(); - } else if (el.mozCancelFullScreen) { /* Firefox */ - el.mozCancelFullScreen(); - } else if (el.webkitExitFullscreen) { /* Chrome, Safari and Opera */ - el.webkitExitFullscreen(); - } else if (el.msExitFullscreen) { /* IE/Edge */ - el.msExitFullscreen(); - } - } -}; + enable ? el.requestFullscreen?.() : el.exitFullscreen?.(); +} function getHeight(el) { return parseFloat(getComputedStyle(el, null).height.replace("px", "")); @@ -105,9 +90,9 @@ window.addEventListener('orientationchange', fixScreenLayout); document.addEventListener('DOMContentLoaded', () => fixScreenLayout(), false); export const env = { - getOs: getOS, - getBrowser: getBrowser, - isMobileDevice: () => /Mobi|Android|iPhone/i.test(navigator.userAgent), + getOs: os(), + getBrowser: _browser(), + isMobileDevice: isMobile(), display: () => ({ isPortrait: isPortrait, toggleFullscreen: toggleFullscreen, diff --git a/web/js/input/joystick.js b/web/js/input/joystick.js index b7f9a54a..4faa30ce 100644 --- a/web/js/input/joystick.js +++ b/web/js/input/joystick.js @@ -8,7 +8,7 @@ import { KEY_PRESSED, KEY_RELEASED } from 'event'; -import {env} from 'env'; +import {env, browser as br, platform} from 'env'; import {KEY} from 'input'; import {log} from 'log'; @@ -98,10 +98,10 @@ const onGamepadConnected = (e) => { // Ref: https://github.com/giongto35/cloud-game/issues/14 // get mapping first (default KeyMap2) - let os = env.getOs(); - let browser = env.getBrowser(); + const os = env.getOs(); + const browser = env.getBrowser(); - if (os === 'android') { + if (os === platform.android) { // default of android is KeyMap1 joystickMap = { 2: KEY.A, @@ -135,7 +135,7 @@ const onGamepadConnected = (e) => { }; } - if (os === 'android' && (browser === 'firefox' || browser === 'uc')) { //KeyMap2 + if (os === platform.android && browser === br.firefox) { //KeyMap2 joystickMap = { 0: KEY.A, 1: KEY.B, @@ -152,7 +152,7 @@ const onGamepadConnected = (e) => { }; } - if (os === 'win' && browser === 'firefox') { //KeyMap3 + if (os === platform.windows && browser === br.firefox) { //KeyMap3 joystickMap = { 1: KEY.A, 2: KEY.B, @@ -165,7 +165,7 @@ const onGamepadConnected = (e) => { }; } - if (os === 'mac' && browser === 'safari') { //KeyMap4 + if (os === platform.macos && browser === br.safari) { //KeyMap4 joystickMap = { 1: KEY.A, 2: KEY.B, @@ -182,7 +182,7 @@ const onGamepadConnected = (e) => { }; } - if (os === 'mac' && browser === 'firefox') { //KeyMap5 + if (os === platform.macos && browser === br.firefox) { //KeyMap5 joystickMap = { 1: KEY.A, 2: KEY.B, @@ -201,7 +201,7 @@ const onGamepadConnected = (e) => { // https://bugs.chromium.org/p/chromium/issues/detail?id=1076272 if (gamepad.id.includes('PLAYSTATION(R)3')) { - if (browser === 'chrome') { + if (browser === br.chrome) { joystickMap = { 1: KEY.A, 0: KEY.B, diff --git a/web/js/stream.js b/web/js/stream.js index 3ccfaee4..9a3d68b0 100644 --- a/web/js/stream.js +++ b/web/js/stream.js @@ -26,7 +26,7 @@ let options = { aspect: 4 / 3 }; -const mute = (mute) => screen.muted = mute +const mute = (mute) => (screen.muted = mute) const _stream = () => { screen.play() @@ -96,14 +96,14 @@ const screenToAspect = (el) => { screen.addEventListener('fullscreenchange', () => { state.fullscreen = !!document.fullscreenElement; - if (!state.fullscreen) { - screen.style.padding = '0' - } else { + if (state.fullscreen) { screenToAspect(document.fullscreenElement); // chrome bug setTimeout(() => { screenToAspect(document.fullscreenElement) }, 1) + } else { + screen.style.padding = '0' } makeFullscreen(state.fullscreen); @@ -115,7 +115,7 @@ const makeFullscreen = (make = false) => { } const forceFullscreenMaybe = () => { - const touchMode = env.isMobileDevice(); + const touchMode = env.isMobileDevice; log.debug('touch check', touchMode) !touchMode && options.forceFullscreen && toggleFullscreen(); } From 22d1bd7620260db17eb5fdefddf24d80007242a4 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 7 Apr 2024 00:14:04 +0300 Subject: [PATCH 085/240] Add screen component --- web/css/main.css | 42 +++----------- web/css/ui.css | 21 +++++++ web/index.html | 8 +-- web/js/app.js | 56 +++++++----------- web/js/env.js | 12 ++-- web/js/gui.js | 8 +-- web/js/menu.js | 17 ++++++ web/js/screen.js | 48 +++++++++++++++ web/js/settings.js | 11 +++- web/js/stats.js | 7 +-- web/js/stream.js | 141 +++++++++++++++++---------------------------- 11 files changed, 192 insertions(+), 179 deletions(-) create mode 100644 web/js/menu.js create mode 100644 web/js/screen.js diff --git a/web/css/main.css b/web/css/main.css index 270b7ce8..f9fdf5f4 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -3,12 +3,11 @@ src: url('/fonts/6809-Chargen.woff2'); } - +/*noinspection CssInvalidPseudoSelector*/ .no-media-controls::-webkit-media-controls { display: none !important; } - html { /* force full size for Firefox */ width: 100%; @@ -170,25 +169,6 @@ body { transform: translateY(-50%); } - -#bottom-screen { - display: flex; - align-items: center; - justify-content: center; - - width: 320px; - height: 240px; - position: absolute; - top: 23px; - left: 150px; - overflow: hidden; - background-color: #333; - - border-radius: 5px 5px 5px 5px; - - box-shadow: 0 0 2px 2px rgba(25, 25, 25, 1); -} - #color-button-holder { display: block; width: 120px; @@ -419,14 +399,8 @@ body { opacity: 0.75; } -#bottom-screen { - position: absolute; - /* popups under the screen fix */ - z-index: -1; -} - .game-screen { - position: absolute; + position: relative; object-fit: contain; width: inherit; height: inherit; @@ -665,6 +639,7 @@ body { position: absolute; z-index: 200; backface-visibility: hidden; + cursor: default; display: flex; flex-direction: column; @@ -674,13 +649,13 @@ body { right: 1.1em; color: #fff; background: #000; - opacity: .765; - padding: .5em 1em .1em 1em; + opacity: .465; + font-size: 2vh; font-family: monospace; - font-size: 40%; + min-width: 3.5em; - width: 70px; + padding-right: .2em; visibility: hidden; } @@ -689,8 +664,7 @@ body { display: flex; flex-flow: wrap; justify-content: space-between; - - margin-bottom: .7em; + align-items: center; } #stats-overlay > div > div { diff --git a/web/css/ui.css b/web/css/ui.css index ce3cfd27..28041893 100644 --- a/web/css/ui.css +++ b/web/css/ui.css @@ -227,3 +227,24 @@ .app-button:hover { color: #7e7e7e; } + + +#screen { + display: flex; + align-items: center; + justify-content: center; + + position: absolute; + /* popups under the screen fix */ + z-index: -1; + + width: 320px; + height: 240px; + top: 23px; + left: 150px; + overflow: hidden; + background-color: #333; + + border-radius: 5px 5px 5px 5px; + box-shadow: 0 0 2px 2px rgba(25, 25, 25, 1); +} diff --git a/web/index.html b/web/index.html index 8093f5d4..580bcdb5 100644 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,6 @@ - @@ -31,14 +30,9 @@
-
+
- -
-
+ +
- Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), 1/2 (1st/2nd player), - Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link to the - clipboard) + Arrows (move), ZXCVAS;'./ (game ABXYL1-L3R1-R3), + Shift/Enter/K/L (select/start/save/load), F (fullscreen), share (copy the link)
@@ -55,6 +61,9 @@
+ +
@@ -91,10 +100,9 @@
{{end}}
-
- 69ff8ae + diff --git a/web/js/api.js b/web/js/api.js index 6b93264b..736328ee 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -19,6 +19,277 @@ const endpoints = { APP_VIDEO_CHANGE: 150, } +let transport = { + send: (packet) => { + log.warn('Default transport is used! Change it with the api.transport variable.', packet) + }, + keyboard: (packet) => { + log.warn('Default transport is used! Change it with the api.transport variable.', packet) + }, + mouse: (packet) => { + log.warn('Default transport is used! Change it with the api.transport variable.', packet) + } +} + +const packet = (type, payload, id) => { + const packet = {t: type} + if (id !== undefined) packet.id = id + if (payload !== undefined) packet.p = payload + transport.send(packet) +} + +const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b)) + +const keyboardPress = (() => { + // 0 1 2 3 4 5 6 + // [CODE ] P MOD + const buffer = new ArrayBuffer(7) + const dv = new DataView(buffer) + + return (pressed = false, e) => { + if (e.repeat) return // skip pressed key events + + const key = libretro.mod + let code = libretro.map('', e.code) + let shift = e.shiftKey + + // a special Esc for &$&!& Firefox + if (shift && code === 96) { + code = 27 + shift = false + } + + const mod = 0 + | (e.altKey && key.ALT) + | (e.ctrlKey && key.CTRL) + | (e.metaKey && key.META) + | (shift && key.SHIFT) + | (e.getModifierState('NumLock') && key.NUMLOCK) + | (e.getModifierState('CapsLock') && key.CAPSLOCK) + | (e.getModifierState('ScrollLock') && key.SCROLLOCK) + dv.setUint32(0, code) + dv.setUint8(4, +pressed) + dv.setUint16(5, mod) + transport.keyboard(buffer) + } +})() + +const mouse = { + MOVEMENT: 0, + BUTTONS: 1 +} + +const mouseMove = (() => { + // 0 1 2 3 4 + // T DX DY + const buffer = new ArrayBuffer(5) + const dv = new DataView(buffer) + + return (dx = 0, dy = 0) => { + dv.setUint8(0, mouse.MOVEMENT) + dv.setInt16(1, dx) + dv.setInt16(3, dy) + transport.mouse(buffer) + } +})() + +const mousePress = (() => { + // 0 1 + // T B + const buffer = new ArrayBuffer(2) + const dv = new DataView(buffer) + + // 0: Main button pressed, usually the left button or the un-initialized state + // 1: Auxiliary button pressed, usually the wheel button or the middle button (if present) + // 2: Secondary button pressed, usually the right button + // 3: Fourth button, typically the Browser Back button + // 4: Fifth button, typically the Browser Forward button + + const b2r = [1, 4, 2, 0, 0] // browser mouse button to retro button + // assumed that only one button pressed / released + + return (button = 0, pressed = false) => { + dv.setUint8(0, mouse.BUTTONS) + dv.setUint8(1, pressed ? b2r[button] : 0) + transport.mouse(buffer) + } +})() + + +const libretro = function () {// RETRO_KEYBOARD + const retro = { + '': 0, + 'Unidentified': 0, + 'Unknown': 0, // ??? + 'First': 0, // ??? + 'Backspace': 8, + 'Tab': 9, + 'Clear': 12, + 'Enter': 13, 'Return': 13, + 'Pause': 19, + 'Escape': 27, + 'Space': 32, + 'Exclaim': 33, + 'Quotedbl': 34, + 'Hash': 35, + 'Dollar': 36, + 'Ampersand': 38, + 'Quote': 39, + 'Leftparen': 40, '(': 40, + 'Rightparen': 41, ')': 41, + 'Asterisk': 42, + 'Plus': 43, + 'Comma': 44, + 'Minus': 45, + 'Period': 46, + 'Slash': 47, + 'Digit0': 48, + 'Digit1': 49, + 'Digit2': 50, + 'Digit3': 51, + 'Digit4': 52, + 'Digit5': 53, + 'Digit6': 54, + 'Digit7': 55, + 'Digit8': 56, + 'Digit9': 57, + 'Colon': 58, ':': 58, + 'Semicolon': 59, ';': 59, + 'Less': 60, '<': 60, + 'Equal': 61, '=': 61, + 'Greater': 62, '>': 62, + 'Question': 63, '?': 63, + // RETROK_AT = 64, + 'BracketLeft': 91, '[': 91, + 'Backslash': 92, '\\': 92, + 'BracketRight': 93, ']': 93, + // RETROK_CARET = 94, + // RETROK_UNDERSCORE = 95, + 'Backquote': 96, '`': 96, + 'KeyA': 97, + 'KeyB': 98, + 'KeyC': 99, + 'KeyD': 100, + 'KeyE': 101, + 'KeyF': 102, + 'KeyG': 103, + 'KeyH': 104, + 'KeyI': 105, + 'KeyJ': 106, + 'KeyK': 107, + 'KeyL': 108, + 'KeyM': 109, + 'KeyN': 110, + 'KeyO': 111, + 'KeyP': 112, + 'KeyQ': 113, + 'KeyR': 114, + 'KeyS': 115, + 'KeyT': 116, + 'KeyU': 117, + 'KeyV': 118, + 'KeyW': 119, + 'KeyX': 120, + 'KeyY': 121, + 'KeyZ': 122, + '{': 123, + '|': 124, + '}': 125, + 'Tilde': 126, '~': 126, + 'Delete': 127, + + 'Numpad0': 256, + 'Numpad1': 257, + 'Numpad2': 258, + 'Numpad3': 259, + 'Numpad4': 260, + 'Numpad5': 261, + 'Numpad6': 262, + 'Numpad7': 263, + 'Numpad8': 264, + 'Numpad9': 265, + 'NumpadDecimal': 266, + 'NumpadDivide': 267, + 'NumpadMultiply': 268, + 'NumpadSubtract': 269, + 'NumpadAdd': 270, + 'NumpadEnter': 271, + 'NumpadEqual': 272, + + 'ArrowUp': 273, + 'ArrowDown': 274, + 'ArrowRight': 275, + 'ArrowLeft': 276, + 'Insert': 277, + 'Home': 278, + 'End': 279, + 'PageUp': 280, + 'PageDown': 281, + + 'F1': 282, + 'F2': 283, + 'F3': 284, + 'F4': 285, + 'F5': 286, + 'F6': 287, + 'F7': 288, + 'F8': 289, + 'F9': 290, + 'F10': 291, + 'F11': 292, + 'F12': 293, + 'F13': 294, + 'F14': 295, + 'F15': 296, + + 'NumLock': 300, + 'CapsLock': 301, + 'ScrollLock': 302, + 'ShiftRight': 303, + 'ShiftLeft': 304, + 'ControlRight': 305, + 'ControlLeft': 306, + 'AltRight': 307, + 'AltLeft': 308, + 'MetaRight': 309, + 'MetaLeft': 310, + // RETROK_LSUPER = 311, + // RETROK_RSUPER = 312, + // RETROK_MODE = 313, + // RETROK_COMPOSE = 314, + + // RETROK_HELP = 315, + // RETROK_PRINT = 316, + // RETROK_SYSREQ = 317, + // RETROK_BREAK = 318, + // RETROK_MENU = 319, + 'Power': 320, + // RETROK_EURO = 321, + // RETROK_UNDO = 322, + // RETROK_OEM_102 = 323, + } + + const retroMod = { + NONE: 0x0000, + SHIFT: 0x01, + CTRL: 0x02, + ALT: 0x04, + META: 0x08, + NUMLOCK: 0x10, + CAPSLOCK: 0x20, + SCROLLOCK: 0x40, + } + + const _map = (key = '', code = '') => { + return retro[code] || retro[key] || 0 + } + + return { + map: _map, + mod: retroMod, + } +}() + /** * Server API. * @@ -38,6 +309,15 @@ export const api = { getWorkerList: () => packet(endpoints.GET_WORKER_LIST), }, game: { + input: { + keyboard: { + press: keyboardPress, + }, + mouse: { + move: mouseMove, + press: mousePress, + } + }, load: () => packet(endpoints.GAME_LOAD), save: () => packet(endpoints.GAME_SAVE), setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), @@ -53,18 +333,3 @@ export const api = { quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}), } } - -let transport = { - send: (packet) => { - log.warn('Default transport is used! Change it with the api.transport variable.', packet) - } -} - -const packet = (type, payload, id) => { - const packet = {t: type}; - if (id !== undefined) packet.id = id; - if (payload !== undefined) packet.p = payload; - transport.send(packet); -} - -const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b)) diff --git a/web/js/app.js b/web/js/app.js index 23b363f3..ad5bb6c6 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -1,19 +1,13 @@ import {log} from 'log'; import {opts, settings} from 'settings'; - -settings.init(); -log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); - import {api} from 'api'; import { - pub, - sub, APP_VIDEO_CHANGED, AXIS_CHANGED, CONTROLLER_UPDATED, DPAD_TOGGLE, + FULLSCREEN_CHANGE, GAME_ERROR_NO_FREE_SLOTS, - GAME_LOADED, GAME_PLAYER_IDX, GAME_PLAYER_IDX_SET, GAME_ROOM_AVAILABLE, @@ -21,12 +15,19 @@ import { GAMEPAD_CONNECTED, GAMEPAD_DISCONNECTED, HELP_OVERLAY_TOGGLED, + KB_MOUSE_FLAG, KEY_PRESSED, KEY_RELEASED, + KEYBOARD_KEY_DOWN, + KEYBOARD_KEY_UP, LATENCY_CHECK_REQUESTED, MESSAGE, + MOUSE_MOVED, + MOUSE_PRESSED, + POINTER_LOCK_CHANGE, RECORDING_STATUS_CHANGED, RECORDING_TOGGLED, + REFRESH_INPUT, SETTINGS_CHANGED, WEBRTC_CONNECTION_CLOSED, WEBRTC_CONNECTION_READY, @@ -37,9 +38,11 @@ import { WEBRTC_SDP_ANSWER, WEBRTC_SDP_OFFER, WORKER_LIST_FETCHED, + pub, + sub, } from 'event'; import {gui} from 'gui'; -import {keyboard, KEY, joystick, retropad, touch} from 'input'; +import {input, KEY} from 'input'; import {socket, webrtc} from 'network'; import {debounce} from 'utils'; @@ -53,7 +56,10 @@ import {stats} from './stats.js?v=3'; import {stream} from './stream.js?v=3'; import {workerManager} from "./workerManager.js?v=3"; -// application state +settings.init(); +log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT); + +// application display state let state; let lastState; @@ -102,18 +108,7 @@ const setState = (newState = app.state.eden) => { } }; -const onGameRoomAvailable = () => { - // room is ready -}; - -const onConnectionReady = () => { - // start a game right away or show the menu - if (room.getId()) { - startGame(); - } else { - state.menuReady(); - } -}; +const onConnectionReady = () => room.id ? startGame() : state.menuReady() const onLatencyCheck = async (data) => { message.show('Connecting to fastest server...'); @@ -169,23 +164,21 @@ const startGame = () => { setState(app.state.game); - stream.play() + screen.toggle(stream) api.game.start( gameList.selected, - room.getId(), + room.id, recording.isActive(), recording.getUser(), +playerIndex.value - 1, - ); + ) - // clear menu screen - retropad.poll.disable(); - screen.toggle(stream); + gameList.disable() + input.retropad.toggle(false) gui.show(keyButtons[KEY.SAVE]); gui.show(keyButtons[KEY.LOAD]); - // end clear - retropad.poll.enable(); + input.retropad.toggle(true) }; const saveGame = debounce(() => api.game.save(), 1000); @@ -204,16 +197,14 @@ const onMessage = (m) => { pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload}); break; case api.endpoint.GAME_START: - if (payload.av) { - pub(APP_VIDEO_CHANGED, payload.av) - } + payload.av && pub(APP_VIDEO_CHANGED, payload.av) + payload.kb_mouse && pub(KB_MOUSE_FLAG) pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId}); break; case api.endpoint.GAME_SAVE: pub(GAME_SAVED); break; case api.endpoint.GAME_LOAD: - pub(GAME_LOADED); break; case api.endpoint.GAME_SET_PLAYER_INDEX: pub(GAME_PLAYER_IDX_SET, payload); @@ -252,7 +243,7 @@ const onKeyPress = (data) => { if (KEY.HELP === data.key) helpScreen.show(true, event); } - state.keyPress(data.key); + state.keyPress(data.key, data.code) }; // pre-state key release handler @@ -279,7 +270,7 @@ const onKeyRelease = data => { // change app state if settings if (KEY.SETTINGS === data.key) setState(app.state.settings); - state.keyRelease(data.key); + state.keyRelease(data.key, data.code); }; const updatePlayerIndex = (idx, not_game = false) => { @@ -301,8 +292,10 @@ const onAxisChanged = (data) => { state.axisChanged(data.id, data.value); }; -const handleToggle = () => { +const handleToggle = (force = false) => { const toggle = document.getElementById('dpad-toggle'); + + force && toggle.setAttribute('checked', '') toggle.checked = !toggle.checked; pub(DPAD_TOGGLE, {checked: toggle.checked}); }; @@ -402,10 +395,13 @@ const app = { game: { ..._default, name: 'game', - axisChanged: (id, value) => retropad.setAxisChanged(id, value), - keyPress: key => retropad.setKeyState(key, true), + axisChanged: (id, value) => input.retropad.setAxisChanged(id, value), + keyboardInput: (pressed, e) => api.game.input.keyboard.press(pressed, e), + mouseMove: (e) => api.game.input.mouse.move(e.dx, e.dy), + mousePress: (e) => api.game.input.mouse.press(e.b, e.p), + keyPress: (key) => input.retropad.setKeyState(key, true), keyRelease: function (key) { - retropad.setKeyState(key, false); + input.retropad.setKeyState(key, false); switch (key) { case KEY.JOIN: // or SHARE @@ -436,8 +432,8 @@ const app = { updatePlayerIndex(3); break; case KEY.QUIT: - retropad.poll.disable(); - api.game.quit(room.getId()); + input.retropad.toggle(false) + api.game.quit(room.id) room.reset(); window.location = window.location.pathname; break; @@ -453,10 +449,37 @@ const app = { } }; +// switch keyboard+mouse / retropad +const kbmEl = document.getElementById('kbm') +const kbmEl2 = document.getElementById('kbm2') +let kbmSkip = false +const kbmCb = () => { + input.kbm = kbmSkip + kbmSkip = !kbmSkip + pub(REFRESH_INPUT) +} +gui.multiToggle([kbmEl, kbmEl2], { + list: [ + {caption: '⌨️+🖱️', cb: kbmCb}, + {caption: ' 🎮 ', cb: kbmCb} + ] +}) +sub(KB_MOUSE_FLAG, () => { + gui.show(kbmEl, kbmEl2) + handleToggle(true) + message.show('Keyboard and mouse work in fullscreen') +}) + +// Browser lock API +document.onpointerlockchange = () => pub(POINTER_LOCK_CHANGE, document.pointerLockElement) +document.onfullscreenchange = () => pub(FULLSCREEN_CHANGE, document.fullscreenElement) + // subscriptions sub(MESSAGE, onMessage); -sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2); +sub(GAME_ROOM_AVAILABLE, async () => { + stream.play() +}, 2) sub(GAME_SAVED, () => message.show('Saved')); sub(GAME_PLAYER_IDX, data => { updatePlayerIndex(+data.index, state !== app.state.game); @@ -479,14 +502,25 @@ sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate) sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates()); sub(WEBRTC_CONNECTION_READY, onConnectionReady); sub(WEBRTC_CONNECTION_CLOSED, () => { - retropad.poll.disable(); + input.retropad.toggle(false) webrtc.stop(); }); sub(LATENCY_CHECK_REQUESTED, onLatencyCheck); sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected')); sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected')); + +// keyboard handler in the Screen Lock mode +sub(KEYBOARD_KEY_DOWN, (v) => state.keyboardInput?.(true, v)) +sub(KEYBOARD_KEY_UP, (v) => state.keyboardInput?.(false, v)) + +// mouse handler in the Screen Lock mode +sub(MOUSE_MOVED, (e) => state.mouseMove?.(e)) +sub(MOUSE_PRESSED, (e) => state.mousePress?.(e)) + +// general keyboard handler sub(KEY_PRESSED, onKeyPress); sub(KEY_RELEASED, onKeyRelease); + sub(SETTINGS_CHANGED, () => message.show('Settings have been updated')); sub(AXIS_CHANGED, onAxisChanged); sub(CONTROLLER_UPDATED, data => webrtc.input(data)); @@ -496,18 +530,13 @@ sub(RECORDING_STATUS_CHANGED, handleRecordingStatus); sub(SETTINGS_CHANGED, () => { const s = settings.get(); log.level = s[opts.LOG_LEVEL]; - if (state.showPing !== s[opts.SHOW_PING]) { - state.showPing = s[opts.SHOW_PING]; - stats.toggle(); - } }); // initial app state setState(app.state.eden); -keyboard.init(); -joystick.init(); -touch.init(); +input.init() + stream.init(); screen.init(); @@ -516,28 +545,72 @@ let [roomId, zone] = room.loadMaybe(); const wid = new URLSearchParams(document.location.search).get('wid'); // if from URL -> start game immediately! socket.init(roomId, wid, zone); -api.transport = socket; +api.transport = { + send: socket.send, + keyboard: webrtc.keyboard, + mouse: webrtc.mouse, +} // stats let WEBRTC_STATS_RTT; +let VIDEO_BITRATE; +let GET_V_CODEC, SET_CODEC; + +const bitrate = (() => { + let bytesPrev, timestampPrev + const w = [0, 0, 0, 0, 0, 0] + const n = w.length + let i = 0 + return (now, bytes) => { + w[i++ % n] = timestampPrev ? Math.floor(8 * (bytes - bytesPrev) / (now - timestampPrev)) : 0 + bytesPrev = bytes + timestampPrev = now + return Math.floor(w.reduce((a, b) => a + b) / n) + } +})() stats.modules = [ { - mui: stats.mui(), + mui: stats.mui('', '<1'), init() { WEBRTC_STATS_RTT = (v) => (this.val = v) }, }, + { + mui: stats.mui('', '', false, () => ''), + init() { + GET_V_CODEC = (v) => (this.val = v + ' @ ') + } + }, + { + mui: stats.mui('', '', false, () => ''), + init() { + sub(APP_VIDEO_CHANGED, (payload) => (this.val = `${payload.w}x${payload.h}`)) + }, + }, + { + mui: stats.mui('', '', false, () => ' kb/s', 'stats-bitrate'), + init() { + VIDEO_BITRATE = (v) => (this.val = v) + } + }, { async stats() { const stats = await webrtc.stats(); if (!stats) return; stats.forEach(report => { - const {nominated, currentRoundTripTime} = report; + if (!SET_CODEC && report.mimeType?.startsWith('video/')) { + GET_V_CODEC(report.mimeType.replace('video/', '').toLowerCase()) + SET_CODEC = 1 + } + const {nominated, currentRoundTripTime, type, kind} = report; if (nominated && currentRoundTripTime !== undefined) { WEBRTC_STATS_RTT(currentRoundTripTime * 1000); } + if (type === 'inbound-rtp' && kind === 'video') { + VIDEO_BITRATE(bitrate(report.timestamp, report.bytesReceived)) + } }); }, enable() { @@ -548,5 +621,4 @@ stats.modules = [ }, }] -state.showPing = settings.loadOr(opts.SHOW_PING, true); -state.showPing && stats.toggle(); +stats.toggle() diff --git a/web/js/env.js b/web/js/env.js index bda02725..a725c87d 100644 --- a/web/js/env.js +++ b/web/js/env.js @@ -1,3 +1,8 @@ +import { + pub, + TRANSFORM_CHANGE +} from 'event'; + // UI const page = document.getElementsByTagName('html')[0]; const gameBoy = document.getElementById('gamebody'); @@ -47,6 +52,8 @@ const rescaleGameBoy = (targetWidth, targetHeight) => { gameBoy.style['transform'] = transformations.join(' '); } +new MutationObserver(() => pub(TRANSFORM_CHANGE)).observe(gameBoy, {attributeFilter: ['style']}) + const os = () => { const ua = window.navigator.userAgent; // noinspection JSUnresolvedReference,JSDeprecatedSymbols diff --git a/web/js/event.js b/web/js/event.js index 6a742af1..8ade9024 100644 --- a/web/js/event.js +++ b/web/js/event.js @@ -56,7 +56,6 @@ export const WORKER_LIST_FETCHED = 'workerListFetched'; export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable'; export const GAME_SAVED = 'gameSaved'; -export const GAME_LOADED = 'gameLoaded'; export const GAME_PLAYER_IDX = 'gamePlayerIndex'; export const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet' export const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots' @@ -83,9 +82,19 @@ export const KEY_PRESSED = 'keyPressed'; export const KEY_RELEASED = 'keyReleased'; export const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode'; export const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed'; +export const KEYBOARD_KEY_DOWN = 'keyboardKeyDown'; +export const KEYBOARD_KEY_UP = 'keyboardKeyUp'; + export const AXIS_CHANGED = 'axisChanged'; export const CONTROLLER_UPDATED = 'controllerUpdated'; +export const MOUSE_MOVED = 'mouseMoved' +export const MOUSE_PRESSED = 'mousePressed' + +export const FULLSCREEN_CHANGE = 'fsc' +export const POINTER_LOCK_CHANGE = 'plc' +export const TRANSFORM_CHANGE = 'tc' + export const DPAD_TOGGLE = 'dpadToggle'; export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled'; @@ -95,3 +104,6 @@ export const RECORDING_TOGGLED = 'recordingToggle' export const RECORDING_STATUS_CHANGED = 'recordingStatusChanged' export const APP_VIDEO_CHANGED = 'appVideoChanged' +export const KB_MOUSE_FLAG = 'kbMouseFlag' + +export const REFRESH_INPUT = 'refreshInput' diff --git a/web/js/gameList.js b/web/js/gameList.js index 48ef73b6..bb92b34c 100644 --- a/web/js/gameList.js +++ b/web/js/gameList.js @@ -1,8 +1,4 @@ -import { - sub, - MENU_PRESSED, - MENU_RELEASED -} from 'event'; +import {MENU_PRESSED, MENU_RELEASED, sub} from 'event'; import {gui} from 'gui'; const TOP_POSITION = 102 @@ -21,13 +17,6 @@ const games = (() => { return list[index].title // selected by the game title, oof }, set index(i) { - //-2 | - //-1 | | - // 0 < | < - // 1 | | - // 2 < < | - //+1 | | - //+2 | index = i < -1 ? i = 0 : i > list.length ? i = list.length - 1 : (i % list.length + list.length) % list.length @@ -90,10 +79,41 @@ const ui = (() => { let onTransitionEnd = () => ({}) - //rootEl.addEventListener('transitionend', () => onTransitionEnd()) - let items = [] + const marque = (() => { + const speed = 1 + const sep = ' '.repeat(10) + + let el = null + let raf = 0 + let txt = null + let w = 0 + + const move = () => { + const shift = parseFloat(getComputedStyle(el).left) - speed + el.style.left = w + shift < 1 ? `0px` : `${shift}px` + raf = requestAnimationFrame(move) + } + + return { + reset() { + cancelAnimationFrame(raf) + el && (el.style.left = `0px`) + }, + enable(cap) { + txt && (el.textContent = txt) // restore the text + el = cap + txt = el.textContent + el.textContent += sep + w = el.scrollWidth // keep the text width + el.textContent += txt + cancelAnimationFrame(raf) + raf = requestAnimationFrame(move) + } + } + })() + const item = (parent) => { const title = parent.firstChild.firstChild const desc = parent.children[1] @@ -106,16 +126,20 @@ const ui = (() => { }, } + const isOverflown = () => title.scrollWidth > title.clientWidth + const _title = { - animate: () => title.classList.add('text-move'), - pick: () => title.classList.add('pick'), - reset: () => title.classList.remove('pick', 'text-move'), + pick: () => { + title.classList.add('pick') + isOverflown() && marque.enable(title) + }, + reset: () => { + title.classList.remove('pick') + isOverflown() && marque.reset() + } } - const clear = () => { - _title.reset() - // _desc.hide() - } + const clear = () => _title.reset() return { get description() { @@ -132,7 +156,7 @@ const ui = (() => { rootEl.innerHTML = games.list.map(game => ``) .join('') items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x)) @@ -191,21 +215,14 @@ const select = (index) => { scroll.onShift = (delta) => select(games.index + delta) -let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED - scroll.onStop = () => { const item = ui.selected - if (item) { - item.title.pick() - item.title.animate() - // hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show() - } + item && item.title.pick() } sub(MENU_PRESSED, (position) => { if (games.empty()) return ui.onTransitionEnd = ui.NO_TRANSITION - hasTransition = false scroll.scroll(scroll.state.DRAG) ui.selected && ui.selected.clear() ui.drag.startPos(position) @@ -215,15 +232,14 @@ sub(MENU_RELEASED, (position) => { if (games.empty()) return ui.drag.stopPos(position) select(ui.roundIndex) - hasTransition = !hasTransition scroll.scroll(scroll.state.IDLE) - hasTransition = true }) /** * Game list module. */ export const gameList = { + disable: () => ui.selected?.clear(), scroll: (x) => { if (games.empty()) return scroll.scroll(x) diff --git a/web/js/gui.js b/web/js/gui.js index df3a97bb..b6eb9d94 100644 --- a/web/js/gui.js +++ b/web/js/gui.js @@ -175,8 +175,8 @@ const binding = (key = '', value = '', cb = () => ({})) => { return el; } -const show = (el) => { - el.classList.remove('hidden'); +const show = (...els) => { + els.forEach(el => el.classList.remove('hidden')) } const inputN = (key = '', cb = () => ({}), current = 0) => { @@ -201,6 +201,23 @@ const toggle = (el, what) => { what ? show(el) : hide(el) } +const multiToggle = (elements = [], options = {list: []}) => { + if (!options.list.length || !elements.length) return + + let i = 0 + + const setText = () => elements.forEach(el => el.innerText = options.list[i].caption) + + const handleClick = () => { + options.list[i].cb() + i = (i + 1) % options.list.length + setText() + } + + setText() + elements.forEach(el => el.addEventListener('click', handleClick)) +} + const fadeIn = async (el, speed = .1) => { el.style.opacity = '0'; el.style.display = 'block'; @@ -252,6 +269,7 @@ export const gui = { fragment, hide, inputN, + multiToggle, panel, select, show, diff --git a/web/js/input/input.js b/web/js/input/input.js index a6aa333d..a636c6ab 100644 --- a/web/js/input/input.js +++ b/web/js/input/input.js @@ -1,5 +1,56 @@ -export {joystick} from './joystick.js?v=3'; +import { + REFRESH_INPUT, + KB_MOUSE_FLAG, + pub, + sub +} from 'event'; + export {KEY} from './keys.js?v=3'; -export {keyboard} from './keyboard.js?v=3' -export {retropad} from './retropad.js?v=3'; -export {touch} from './touch.js?v=3'; + +import {joystick} from './joystick.js?v=3'; +import {keyboard} from './keyboard.js?v=3' +import {pointer} from './pointer.js?v=3'; +import {retropad} from './retropad.js?v=3'; +import {touch} from './touch.js?v=3'; + +export {joystick, keyboard, pointer, retropad, touch}; + +const input_state = { + joystick: true, + keyboard: false, + pointer: true, // aka mouse + retropad: true, + touch: true, + + kbm: false, +} + +const init = () => { + keyboard.init() + joystick.init() + touch.init() +} + +sub(KB_MOUSE_FLAG, () => { + input_state.kbm = true + pub(REFRESH_INPUT) +}) + +export const input = { + state: input_state, + init, + retropad: { + ...retropad, + toggle(on = true) { + if (on === input_state.retropad) return + input_state.retropad = on + on ? retropad.enable() : retropad.disable() + } + }, + set kbm(v) { + input_state.kbm = v + }, + get kbm() { + return input_state.kbm + } +} diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index 1ccba499..b29b61bf 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -1,12 +1,14 @@ import { pub, sub, - KEYBOARD_TOGGLE_FILTER_MODE, AXIS_CHANGED, DPAD_TOGGLE, KEY_PRESSED, KEY_RELEASED, - KEYBOARD_KEY_PRESSED + KEYBOARD_KEY_PRESSED, + KEYBOARD_KEY_DOWN, + KEYBOARD_KEY_UP, + KEYBOARD_TOGGLE_FILTER_MODE, } from 'event'; import {KEY} from 'input'; import {log} from 'log' @@ -47,11 +49,16 @@ const defaultMap = Object.freeze({ }); let keyMap = {}; +// special mode for changing button bindings in the options let isKeysFilteredMode = true; +// if the browser supports Keyboard Lock API (Firefox does not) +let hasKeyboardLock = ('keyboard' in navigator) && ('lock' in navigator.keyboard) + +let locked = false const remap = (map = {}) => { settings.set(opts.INPUT_KEYBOARD_MAP, map); - log.info('Keyboard keys have been remapped') + log.debug('Keyboard keys have been remapped') } sub(KEYBOARD_TOGGLE_FILTER_MODE, data => { @@ -88,9 +95,16 @@ function onDpadToggle(checked) { } } +const lock = async (lock) => { + locked = lock + if (hasKeyboardLock) { + lock ? await navigator.keyboard.lock() : navigator.keyboard.unlock() + } + // if the browser doesn't support keyboard lock, it will be emulated +} + const onKey = (code, evt, state) => { const key = keyMap[code] - if (key === undefined) return if (dpadState[key] !== undefined) { dpadState[key] = state @@ -103,7 +117,7 @@ const onKey = (code, evt, state) => { return } } - pub(evt, {key: key}) + pub(evt, {key: key, code: code}) } sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked)); @@ -115,28 +129,35 @@ export const keyboard = { init: () => { keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap); const body = document.body; - // !to use prevent default as everyone + body.addEventListener('keyup', e => { - e.stopPropagation(); - if (isKeysFilteredMode) { - onKey(e.code, KEY_RELEASED, false) - } else { - pub(KEYBOARD_KEY_PRESSED, {key: e.code}); + e.stopPropagation() + !hasKeyboardLock && locked && e.preventDefault() + + let lock = locked + // hack with Esc up when outside of lock + if (e.code === 'Escape') { + lock = true } - }, false); + + isKeysFilteredMode ? + (lock ? pub(KEYBOARD_KEY_UP, e) : onKey(e.code, KEY_RELEASED, false)) + : pub(KEYBOARD_KEY_PRESSED, {key: e.code}) + }, false) body.addEventListener('keydown', e => { - e.stopPropagation(); - if (isKeysFilteredMode) { - onKey(e.code, KEY_PRESSED, true) - } else { - pub(KEYBOARD_KEY_PRESSED, {key: e.code}); - } - }); + e.stopPropagation() + !hasKeyboardLock && locked && e.preventDefault() - log.info('[input] keyboard has been initialized'); + isKeysFilteredMode ? + (locked ? pub(KEYBOARD_KEY_DOWN, e) : onKey(e.code, KEY_PRESSED, true)) : + pub(KEYBOARD_KEY_PRESSED, {key: e.code}) + }) + + log.info('[input] keyboard has been initialized') }, settings: { remap - } + }, + lock, } diff --git a/web/js/input/pointer.js b/web/js/input/pointer.js new file mode 100644 index 00000000..e0fab075 --- /dev/null +++ b/web/js/input/pointer.js @@ -0,0 +1,153 @@ +// Pointer (aka mouse) stuff +import { + MOUSE_PRESSED, + MOUSE_MOVED, + pub +} from 'event'; +import {browser, env} from 'env'; + +const hasRawPointer = 'onpointerrawupdate' in window + +const p = {dx: 0, dy: 0} + +const move = (e, cb, single = false) => { + // !to fix ff https://github.com/w3c/pointerlock/issues/42 + if (single) { + p.dx = e.movementX + p.dy = e.movementY + cb(p) + } else { + const _events = e.getCoalescedEvents?.() + if (_events && (hasRawPointer || _events.length > 1)) { + for (let i = 0; i < _events.length; i++) { + p.dx = _events[i].movementX + p.dy = _events[i].movementY + cb(p) + } + } + } +} + +const _track = (el, cb, single) => { + const _move = (e) => { + move(e, cb, single) + } + el.addEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move) + return () => { + el.removeEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move) + } +} + +const dpiScaler = () => { + let ex = 0 + let ey = 0 + let scaled = {dx: 0, dy: 0} + return { + scale(x, y, src_w, src_h, dst_w, dst_h) { + scaled.dx = x / (src_w / dst_w) + ex + scaled.dy = y / (src_h / dst_h) + ey + + ex = scaled.dx % 1 + ey = scaled.dy % 1 + + scaled.dx -= ex + scaled.dy -= ey + + return scaled + } + } +} + +const dpi = dpiScaler() + +const handlePointerMove = (el, cb) => { + let w, h = 0 + let s = false + const dw = 640, dh = 480 + return (p) => { + ({w, h, s} = cb()) + pub(MOUSE_MOVED, s ? dpi.scale(p.dx, p.dy, w, h, dw, dh) : p) + } +} + +const trackPointer = (el, cb) => { + let mpu, mpd + let noTrack + + // disable coalesced mouse move events + const single = true + + // coalesced event are broken since FF 120 + const isFF = env.getBrowser === browser.firefox + + const pm = handlePointerMove(el, cb) + + return (enabled) => { + if (enabled) { + !noTrack && (noTrack = _track(el, pm, isFF || single)) + mpu = pointer.handle.up(el) + mpd = pointer.handle.down(el) + return + } + + mpu?.() + mpd?.() + noTrack?.() + noTrack = null + } +} + +const handleDown = ((b = {b: null, p: true}) => (e) => { + b.b = e.button + pub(MOUSE_PRESSED, b) +})() + +const handleUp = ((b = {b: null, p: false}) => (e) => { + b.b = e.button + pub(MOUSE_PRESSED, b) +})() + +const autoHide = (el, time = 3000) => { + let tm + let move + const cl = el.classList + + const hide = (force = false) => { + cl.add('no-pointer') + !force && el.addEventListener('pointermove', move) + } + + move = () => { + cl.remove('no-pointer') + clearTimeout(tm) + tm = setTimeout(hide, time) + } + + const show = () => { + clearTimeout(tm) + el.removeEventListener('pointermove', move) + cl.remove('no-pointer') + } + + return { + autoHide: (on) => on ? show() : hide() + } +} + +export const pointer = { + autoHide, + lock: async (el) => { + await el.requestPointerLock(/*{ unadjustedMovement: true}*/) + }, + track: trackPointer, + handle: { + down: (el) => { + el.onpointerdown = handleDown + return () => (el.onpointerdown = null) + }, + up: (el) => { + el.onpointerup = handleUp + return () => (el.onpointerup = null) + } + } +} diff --git a/web/js/input/retropad.js b/web/js/input/retropad.js index 6e39c69f..2ecbd659 100644 --- a/web/js/input/retropad.js +++ b/web/js/input/retropad.js @@ -94,7 +94,8 @@ const _getState = () => { const _poll = poll(pollingIntervalMs, sendControllerState) export const retropad = { - poll: _poll, + enable: () => _poll.enable(), + disable: () => _poll.disable(), setKeyState, setAxisChanged, } diff --git a/web/js/input/touch.js b/web/js/input/touch.js index fb2c1ed1..f98359fc 100644 --- a/web/js/input/touch.js +++ b/web/js/input/touch.js @@ -39,6 +39,8 @@ const getKey = (el) => el.dataset.key let dpadMode = true; const deadZone = 0.1; +let enabled = false + function onDpadToggle(checked) { if (dpadMode === checked) { return //error? @@ -237,6 +239,8 @@ function handleMenuUp(evt) { // Common events function handleWindowMove(event) { + if (!enabled) return + event.preventDefault(); handleVpadJoystickMove(event); handleMenuMove(event); @@ -303,6 +307,7 @@ playerSlider.onkeydown = (e) => { */ export const touch = { init: () => { + enabled = true // Bind events for menu // TODO change this flow pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown}); @@ -316,10 +321,11 @@ export const touch = { vpadState[getKey(el)] = false; }); - window.addEventListener('mousemove', handleWindowMove); + window.addEventListener('pointermove', handleWindowMove); window.addEventListener('touchmove', handleWindowMove, {passive: false}); window.addEventListener('mouseup', handleWindowUp); log.info('[input] touch input has been initialized'); - } + }, + toggle: (v) => v === undefined ? (enabled = !enabled) : (enabled = v) } diff --git a/web/js/network/webrtc.js b/web/js/network/webrtc.js index ae12748c..3bc5ff76 100644 --- a/web/js/network/webrtc.js +++ b/web/js/network/webrtc.js @@ -9,7 +9,9 @@ import { import {log} from 'log'; let connection; -let dataChannel; +let dataChannel +let keyboardChannel +let mouseChannel let mediaStream; let candidates = []; let isAnswered = false; @@ -30,6 +32,16 @@ const start = (iceservers) => { log.debug('[rtc] ondatachannel', e.channel.label) e.channel.binaryType = "arraybuffer"; + if (e.channel.label === 'keyboard') { + keyboardChannel = e.channel + return + } + + if (e.channel.label === 'mouse') { + mouseChannel = e.channel + return + } + dataChannel = e.channel; dataChannel.onopen = () => { log.info('[rtc] the input channel has been opened'); @@ -39,7 +51,10 @@ const start = (iceservers) => { if (onData) { dataChannel.onmessage = onData; } - dataChannel.onclose = () => log.info('[rtc] the input channel has been closed'); + dataChannel.onclose = () => { + inputReady = false + log.info('[rtc] the input channel has been closed') + } } connection.oniceconnectionstatechange = ice.onIceConnectionStateChange; connection.onicegatheringstatechange = ice.onIceStateChange; @@ -62,8 +77,16 @@ const stop = () => { connection = null; } if (dataChannel) { - dataChannel.close(); - dataChannel = null; + dataChannel.close() + dataChannel = null + } + if (keyboardChannel) { + keyboardChannel?.close() + keyboardChannel = null + } + if (mouseChannel) { + mouseChannel?.close() + mouseChannel = null } candidates = []; log.info('[rtc] WebRTC has been closed'); @@ -162,7 +185,9 @@ export const webrtc = { }); isFlushing = false; }, - input: (data) => dataChannel.send(data), + keyboard: (data) => keyboardChannel?.send(data), + mouse: (data) => mouseChannel?.send(data), + input: (data) => inputReady && dataChannel.send(data), isConnected: () => connected, isInputReady: () => inputReady, stats: async () => { diff --git a/web/js/room.js b/web/js/room.js index f8f2e37f..1321fc10 100644 --- a/web/js/room.js +++ b/web/js/room.js @@ -30,7 +30,7 @@ const parseURLForRoom = () => { }; sub(GAME_ROOM_AVAILABLE, data => { - room.setId(data.roomId); + room.id = data.roomId room.save(data.roomId); }, 1); @@ -38,8 +38,10 @@ sub(GAME_ROOM_AVAILABLE, data => { * Game room module. */ export const room = { - getId: () => id, - setId: (id_) => { + get id() { + return id + }, + set id(id_) { id = id_; roomLabel.value = id; }, @@ -51,7 +53,7 @@ export const room = { localStorage.setItem('roomID', roomIndex); }, load: () => localStorage.getItem('roomID'), - getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`, + getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.id)}`, loadMaybe: () => { // localStorage first //roomID = loadRoomID(); diff --git a/web/js/screen.js b/web/js/screen.js index 955df691..b4342e3c 100644 --- a/web/js/screen.js +++ b/web/js/screen.js @@ -1,8 +1,15 @@ +import { + sub, + SETTINGS_CHANGED, + REFRESH_INPUT, +} from 'event'; +import {env} from 'env'; +import {input, pointer, keyboard} from 'input'; import {opts, settings} from 'settings'; -import {SETTINGS_CHANGED, sub} from "event"; -import {env} from "env"; +import {gui} from 'gui'; -const rootEl = document.getElementById('screen'); +const rootEl = document.getElementById('screen') +const footerEl = document.getElementsByClassName('screen__footer')[0] const state = { components: [], @@ -10,30 +17,63 @@ const state = { forceFullscreen: false, } -const toggle = (component, force) => { - component && (state.current = component); // keep the last component - state.components.forEach(c => c.toggle(false)); - state.current?.toggle(force); - component && !env.isMobileDevice && !state.current?.noFullscreen && state.forceFullscreen && fullscreen(); +const toggle = async (component, force) => { + component && (state.current = component) // keep the last component + state.components.forEach(c => c.toggle(false)) + state.current?.toggle(force) + state.forceFullscreen && fullscreen(true) } const init = () => { - state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false); + state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false) sub(SETTINGS_CHANGED, () => { - state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN]; - }); + state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN] + }) } +const cursor = pointer.autoHide(rootEl, 2000) + +const trackPointer = pointer.track(rootEl, () => { + const display = state.current; + return {...display.video.size, s: !!display?.hasDisplay} +}) + const fullscreen = () => { - let h = parseFloat(getComputedStyle(rootEl, null) - .height - .replace('px', '') - ) - env.display().toggleFullscreen(h !== window.innerHeight, rootEl); + if (state.current?.noFullscreen) return + + let h = parseFloat(getComputedStyle(rootEl, null).height.replace('px', '')) + env.display().toggleFullscreen(h !== window.innerHeight, rootEl) } -rootEl.addEventListener('fullscreenchange', () => { - state.current?.onFullscreen?.(document.fullscreenElement !== null) +const controls = async (locked = false) => { + if (!state.current?.hasDisplay) return + if (env.isMobileDevice) return + if (!input.kbm) return + + if (locked) { + await pointer.lock(rootEl) + } + + // oof, remove hover:hover when the pointer is forcibly locked, + // leaving the element in the hovered state + locked ? footerEl.classList.remove('hover') : footerEl.classList.add('hover') + + trackPointer(locked) + await keyboard.lock(locked) + input.retropad.toggle(!locked) +} + +rootEl.addEventListener('fullscreenchange', async () => { + const fs = document.fullscreenElement !== null + + cursor.autoHide(!fs) + gui.toggle(footerEl, fs) + await controls(fs) + state.current?.onFullscreen?.(fs) +}) + +sub(REFRESH_INPUT, async () => { + await controls(document.fullscreenElement !== null) }) export const screen = { diff --git a/web/js/settings.js b/web/js/settings.js index 39ef121d..7dc30b06 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -23,7 +23,6 @@ export const opts = { MIRROR_SCREEN: 'mirror.screen', VOLUME: 'volume', FORCE_FULLSCREEN: 'force.fullscreen', - SHOW_PING: 'show.ping', } @@ -229,6 +228,14 @@ const set = (key, value, updateProvider = true) => { } } +const changed = (key, obj, key2) => { + if (!store.settings.hasOwnProperty(key)) return + const newValue = store.settings[key] + const changed = newValue !== obj[key2] + changed && (obj[key2] = newValue) + return changed +} + const _reset = () => { for (let _option of Object.keys(_defaults)) { const value = _defaults[_option]; @@ -340,6 +347,7 @@ export const settings = { getStore, get, set, + changed, remove, import: _import, export: _export, @@ -488,6 +496,8 @@ const render = function () { case opts.INPUT_KEYBOARD_MAP: _option(data).withName('Keyboard bindings') .withClass('keyboard-bindings') + .withDescription( + 'Bindings for RetroPad. There is an alternate ESC key [Shift+`] (tilde) for cores with keyboard+mouse controls (DosBox)') .add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange))) .build(); break; @@ -500,7 +510,6 @@ const render = function () { case opts.VOLUME: _option(data).withName('Volume (%)') .add(gui.inputN(k, onChange, value)) - .restartNeeded() .build() break; case opts.FORCE_FULLSCREEN: @@ -511,12 +520,6 @@ const render = function () { .add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox')) .build() break; - case opts.SHOW_PING: - _option(data).withName('Show ping') - .withDescription('Always display ping info on the screen') - .add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox')) - .build() - break; default: _option(data).withName(k).add(value).build(); } diff --git a/web/js/stats.js b/web/js/stats.js index 3ef1648c..d8d28974 100644 --- a/web/js/stats.js +++ b/web/js/stats.js @@ -110,19 +110,23 @@ const graph = (parent, opts = { * Get cached module UI. * * HTML: - *
LABEL
VALUE[]
+ * `
LABEL
VALUE[]
` * * @param label The name of the stat to show. + * @param nan A value to show when zero. * @param withGraph True if to draw a graph. * @param postfix Supposed to be the name of the stat passed as a function. + * @param cl Class of the UI div element. * @returns {{el: HTMLDivElement, update: function}} */ -const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { +const moduleUi = (label = '', nan = '', withGraph = false, postfix = () => 'ms', cl = '') => { const ui = document.createElement('div'), _label = document.createElement('div'), _value = document.createElement('span'); ui.append(_label, _value); + cl && ui.classList.add(cl) + let postfix_ = postfix; let _graph; @@ -139,7 +143,7 @@ const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => { const update = (value) => { if (_graph) _graph.add(value); // 203 (333) ms - _value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; + _value.textContent = `${value < 1 ? nan : value}${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`; } const clear = () => { @@ -157,7 +161,7 @@ const module = (mod) => { enable: () => ({}), ...mod, _disable: function () { - mod.val = 0; + // mod.val = 0; mod.disable && mod.disable(); mod.mui && mod.mui.clear(); }, diff --git a/web/js/stream.js b/web/js/stream.js index 8356026a..7e09a5f9 100644 --- a/web/js/stream.js +++ b/web/js/stream.js @@ -1,13 +1,14 @@ import { sub, APP_VIDEO_CHANGED, - SETTINGS_CHANGED -} from 'event' ; -import {gui} from 'gui'; + SETTINGS_CHANGED, + TRANSFORM_CHANGE +} from 'event'; import {log} from 'log'; import {opts, settings} from 'settings'; -const videoEl = document.getElementById('stream'); +const videoEl = document.getElementById('stream') +const mirrorEl = document.getElementById('mirror-stream') const options = { volume: 0.5, @@ -21,151 +22,151 @@ const state = { timerId: null, w: 0, h: 0, - aspect: 4 / 3 + aspect: 4 / 3, + ready: false } const mute = (mute) => (videoEl.muted = mute) -const _stream = () => { +const play = () => { videoEl.play() - .then(() => log.info('Media can autoplay')) - .catch(error => { - log.error('Media failed to play', error); - }); + .then(() => { + state.ready = true + videoEl.poster = '' + useCustomScreen(options.mirrorMode === 'mirror') + }) + .catch(error => log.error('Can\'t autoplay', error)) } const toggle = (show) => state.screen.toggleAttribute('hidden', show === undefined ? show : !show) -videoEl.onerror = (e) => { - // video playback failed - show a message saying why - switch (e.target.error.code) { - case e.target.error.MEDIA_ERR_ABORTED: - log.error('You aborted the video playback.'); - break; - case e.target.error.MEDIA_ERR_NETWORK: - log.error('A network error caused the video download to fail part-way.'); - break; - case e.target.error.MEDIA_ERR_DECODE: - log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.'); - break; - case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: - log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.'); - break; - default: - log.error('An unknown video error occurred.'); - break; - } -}; +// Track resize even when the underlying media stream changes its video size +videoEl.addEventListener('resize', () => { + recalculateSize() + if (state.screen === videoEl) return + + state.screen.setAttribute('width', videoEl.videoWidth) + state.screen.setAttribute('height', videoEl.videoHeight) +}) -videoEl.addEventListener('loadedmetadata', () => { - if (state.screen !== videoEl) { - state.screen.setAttribute('width', videoEl.videoWidth); - state.screen.setAttribute('height', videoEl.videoHeight); - } -}, false); videoEl.addEventListener('loadstart', () => { - videoEl.volume = options.volume; - videoEl.poster = options.poster; -}, false); -videoEl.addEventListener('play', () => { - videoEl.poster = ''; - useCustomScreen(options.mirrorMode === 'mirror'); -}, false); + videoEl.volume = options.volume / 100 + videoEl.poster = options.poster +}) -const screenToAspect = (el) => { - const w = window.screen.width ?? window.innerWidth; - const hh = el.innerHeight || el.clientHeight || 0; - const dw = (w - hh * state.aspect) / 2 - videoEl.style.padding = `0 ${dw}px` -} +videoEl.onfocus = () => videoEl.blur() +videoEl.onerror = (e) => log.error('Playback error', e) -const onFullscreen = (y) => { - if (y) { - screenToAspect(document.fullscreenElement); - // chrome bug +const onFullscreen = (fullscreen) => { + const el = document.fullscreenElement + + if (fullscreen) { + // timeout is due to a chrome bug setTimeout(() => { - screenToAspect(document.fullscreenElement) + // aspect ratio calc + const w = window.screen.width ?? window.innerWidth + const hh = el.innerHeight || el.clientHeight || 0 + const dw = (w - hh * state.aspect) / 2 + state.screen.style.padding = `0 ${dw}px` + state.screen.classList.toggle('with-footer') }, 1) } else { - videoEl.style.padding = '0' + state.screen.style.padding = '0' + state.screen.classList.toggle('with-footer') } - videoEl.classList.toggle('no-media-controls', !!y) + + if (el === videoEl) { + videoEl.classList.toggle('no-media-controls', !fullscreen) + videoEl.blur() + } +} + +const vs = {w: 1, h: 1} + +const recalculateSize = () => { + const fullscreen = document.fullscreenElement !== null + const {aspect, screen} = state + + let width, height + if (fullscreen) { + // we can't get the real
- +
+
diff --git a/web/js/stream.js b/web/js/stream.js index a2532cf2..01718e2a 100644 --- a/web/js/stream.js +++ b/web/js/stream.js @@ -9,6 +9,7 @@ import {opts, settings} from 'settings'; const videoEl = document.getElementById('stream') const mirrorEl = document.getElementById('mirror-stream') +const playEl = document.getElementById('play-stream') const options = { volume: 0.5, @@ -24,20 +25,36 @@ const state = { h: 0, aspect: 4 / 3, fit: 'contain', - ready: false + ready: false, + autoplayWait: false } const mute = (mute) => (videoEl.muted = mute) +const onPlay = () => { + state.ready = true + videoEl.poster = '' + resize(state.w, state.h, state.aspect, state.fit) + useCustomScreen(options.mirrorMode === 'mirror') +} + const play = () => { - videoEl.play() - .then(() => { - state.ready = true - videoEl.poster = '' - resize(state.w, state.h, state.aspect, state.fit) - useCustomScreen(options.mirrorMode === 'mirror') + const promise = videoEl.play() + + if (promise === undefined) { + log.error('oh no, the video is not a promise!') + return + } + + promise + .then(onPlay) + .catch(error => { + if (error.name === 'NotAllowedError') { + showPlayButton() + } else { + log.error('Playback fail', error) + } }) - .catch(error => log.error('Can\'t autoplay', error)) } const toggle = (show) => state.screen.toggleAttribute('hidden', show === undefined ? show : !show) @@ -51,6 +68,19 @@ const resize = (w, h, aspect, fit) => { fit !== undefined && (state.screen.style['object-fit'] = fit) } +const showPlayButton = () => { + state.autoplayWait = true + toggle() + playEl.removeAttribute('hidden') +} + +playEl.addEventListener('click', () => { + playEl.setAttribute('hidden', "") + state.autoplayWait = false + play() + toggle() +}) + // Track resize even when the underlying media stream changes its video size videoEl.addEventListener('resize', () => { recalculateSize() @@ -189,6 +219,7 @@ export const stream = { }, }, play, + showPlayButton, toggle, hasDisplay: true, init, From 56e3ce328e98e51661fbb360a16b4461dc51de3d Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 30 Nov 2024 21:35:40 +0300 Subject: [PATCH 162/240] Update Go to 1.23.3 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 73ff0e27..f00b2ac3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:oracular AS build0 -ARG GO=1.23.1 +ARG GO=1.23.3 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ From 9caf45af7865586ce1db48c748096385e6ef375b Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 1 Dec 2024 18:20:54 +0300 Subject: [PATCH 163/240] Reset fail timer on success --- pkg/network/retry.go | 13 +++---------- pkg/worker/worker.go | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/pkg/network/retry.go b/pkg/network/retry.go index 0498b7db..9fb706dc 100644 --- a/pkg/network/retry.go +++ b/pkg/network/retry.go @@ -13,14 +13,7 @@ func NewRetry() Retry { return Retry{t: retry} } -func (r *Retry) Fail() *Retry { r.fail = true; time.Sleep(r.t); return r } -func (r *Retry) Failed() bool { return r.fail } -func (r *Retry) Multiply(x int) { r.t *= time.Duration(x) } -func (r *Retry) SuccessCheck() { - if r.fail { - return - } - r.t = retry - r.fail = false -} +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 } diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 505a55d2..00a358aa 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -126,7 +126,7 @@ func (w *Worker) Start(done chan struct{}) { w.cord.SendLibrary(w) w.cord.SendPrevSessions(w) <-wait - retry.SuccessCheck() + retry.Success() } } }() From 5a42dc985704f5199ae989bfc5593ed2ec2cfbea Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sun, 1 Dec 2024 20:26:29 +0300 Subject: [PATCH 164/240] Fail x2 on no coordinator connection --- pkg/worker/worker.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 00a358aa..f98b2e97 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -115,8 +115,7 @@ func (w *Worker) Start(done chan struct{}) { default: cord, err := newCoordinatorConnection(remoteAddr, w.conf.Worker, w.address, w.log) if err != nil { - w.log.Warn().Err(err).Msgf("no connection: %v. Retrying in %v", remoteAddr, retry.Time()) - retry.Fail() + onRetryFail(err) continue } cord.SetErrorHandler(onRetryFail) From 713478224549c4df04048c83950c0f328394b171 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 3 Dec 2024 00:34:43 +0300 Subject: [PATCH 165/240] Enable frame duplication for Mupen64 --- pkg/config/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index fdcba87b..48d78ed3 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -271,7 +271,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 From 954bb23bb8d1f743281a8589d7b01307694a2aa7 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Tue, 3 Dec 2024 00:38:15 +0300 Subject: [PATCH 166/240] Add Reset with 0 key --- pkg/api/api.go | 3 +++ pkg/api/worker.go | 6 +++++- pkg/coordinator/user.go | 6 ++++++ pkg/coordinator/userhandlers.go | 7 +++++++ pkg/coordinator/workerapi.go | 4 ++++ pkg/worker/caged/libretro/frontend.go | 2 ++ pkg/worker/caged/libretro/nanoarch/nanoarch.c | 4 ++++ pkg/worker/caged/libretro/nanoarch/nanoarch.go | 6 ++++++ pkg/worker/caged/libretro/nanoarch/nanoarch.h | 1 + pkg/worker/coordinator.go | 6 ++++++ pkg/worker/coordinatorhandlers.go | 8 ++++++++ web/js/api.js | 2 ++ web/js/app.js | 3 +++ web/js/input/keyboard.js | 3 ++- web/js/input/keys.js | 1 + 15 files changed, 60 insertions(+), 2 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 3c33e1e7..2deeb44a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -81,6 +81,7 @@ const ( RecordGame PT = 110 GetWorkerList PT = 111 ErrNoFreeSlots PT = 112 + ResetGame PT = 113 RegisterRoom PT = 201 CloseRoom PT = 202 IceCandidate = WebrtcIce @@ -120,6 +121,8 @@ func (p PT) String() string { return "GetWorkerList" case ErrNoFreeSlots: return "NoFreeSlots" + case ResetGame: + return "ResetGame" case RegisterRoom: return "RegisterRoom" case CloseRoom: diff --git a/pkg/api/worker.go b/pkg/api/worker.go index 5031afab..189daf66 100644 --- a/pkg/api/worker.go +++ b/pkg/api/worker.go @@ -12,7 +12,11 @@ type ( LoadGameRequest[T Id] struct { StatefulRoom[T] } - LoadGameResponse string + LoadGameResponse string + ResetGameRequest[T Id] struct { + StatefulRoom[T] + } + ResetGameResponse string SaveGameRequest[T Id] struct { StatefulRoom[T] } diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go index 47156171..a6865d75 100644 --- a/pkg/coordinator/user.go +++ b/pkg/coordinator/user.go @@ -83,6 +83,12 @@ func (u *User) HandleRequests(info HasServerInfo, conf config.CoordinatorConfig) return api.ErrMalformed } u.HandleChangePlayer(*rq) + case api.ResetGame: + rq := api.Unwrap[api.ResetGameRequest[com.Uid]](payload) + if rq == nil { + return api.ErrMalformed + } + u.HandleResetGame(*rq) case api.RecordGame: if !conf.Recording.Enabled { return api.ErrForbidden diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go index 61c3f494..811ce332 100644 --- a/pkg/coordinator/userhandlers.go +++ b/pkg/coordinator/userhandlers.go @@ -50,6 +50,13 @@ func (u *User) HandleQuitGame(rq api.GameQuitRequest[com.Uid]) { } } +func (u *User) HandleResetGame(rq api.ResetGameRequest[com.Uid]) { + if rq.Room.Rid != u.w.RoomId { + return + } + u.w.ResetGame(u.Id()) +} + func (u *User) HandleSaveGame() error { resp, err := u.w.SaveGame(u.Id()) if err != nil { diff --git a/pkg/coordinator/workerapi.go b/pkg/coordinator/workerapi.go index 05ead1d9..7a1ccf51 100644 --- a/pkg/coordinator/workerapi.go +++ b/pkg/coordinator/workerapi.go @@ -48,6 +48,10 @@ func (w *Worker) ChangePlayer(id com.Uid, index int) (*api.ChangePlayerResponse, w.Send(api.ChangePlayer, api.ChangePlayerRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId), Index: index})) } +func (w *Worker) ResetGame(id com.Uid) { + w.Notify(api.ResetGame, api.ResetGameRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)}) +} + func (w *Worker) RecordGame(id com.Uid, rec bool, recUser string) (*api.RecordGameResponse, error) { return api.UnwrapChecked[api.RecordGameResponse]( w.Send(api.RecordGame, api.RecordGameRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId), Active: rec, User: recUser})) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 057d7e21..341038a4 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -47,6 +47,7 @@ type Emulator interface { Input(player int, device byte, data []byte) // Scale returns set video scale factor Scale() float64 + Reset() } type Frontend struct { @@ -307,6 +308,7 @@ 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() } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index 4c46963b..2bbd1883 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -90,6 +90,10 @@ void bridge_retro_unload_game(void *f) { ((void (*)(void)) f)(); } +void bridge_retro_reset(void *f) { + ((void (*)(void)) f)(); +} + void bridge_retro_run(void *f) { ((void (*)(void)) f)(); } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 4db0dcea..9d8447a0 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -230,6 +230,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") @@ -396,6 +397,10 @@ func (n *Nanoarch) Shutdown() { C.free(unsafe.Pointer(n.cSystemDirectory)) } +func (n *Nanoarch) Reset() { + C.bridge_retro_reset(retroReset) +} + func (n *Nanoarch) Run() { if n.LibCo { C.same_thread(retroRun) @@ -595,6 +600,7 @@ var ( coreLib unsafe.Pointer retroInit unsafe.Pointer retroLoadGame unsafe.Pointer + retroReset unsafe.Pointer retroRun unsafe.Pointer retroSetAudioSample unsafe.Pointer retroSetAudioSampleBatch unsafe.Pointer diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.h b/pkg/worker/caged/libretro/nanoarch/nanoarch.h index 66103643..c1e09462 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.h +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.h @@ -15,6 +15,7 @@ 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_reset(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); diff --git a/pkg/worker/coordinator.go b/pkg/worker/coordinator.go index 1dd4c7ef..07963577 100644 --- a/pkg/worker/coordinator.go +++ b/pkg/worker/coordinator.go @@ -126,6 +126,12 @@ func (c *coordinator) HandleRequests(w *Worker) chan struct{} { } else { out = c.HandleChangePlayer(*dat, w) } + case api.ResetGame: + dat := api.Unwrap[api.ResetGameRequest[com.Uid]](x.Payload) + if dat == nil { + return api.ErrMalformed + } + c.HandleResetGame(*dat, w) case api.RecordGame: if dat := api.Unwrap[api.RecordGameRequest[com.Uid]](x.Payload); dat == nil { err, out = api.ErrMalformed, api.EmptyPacket diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index db0b5ad0..97cb2784 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -234,6 +234,14 @@ func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest[com.Uid], w *Worker) } } +func (c *coordinator) HandleResetGame(rq api.ResetGameRequest[com.Uid], w *Worker) api.Out { + if r := w.router.FindRoom(rq.Rid); r != nil { + room.WithEmulator(r.App()).Reset() + return api.OkPacket + } + return api.ErrPacket +} + func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest[com.Uid], w *Worker) api.Out { r := w.router.FindRoom(rq.Rid) if r == nil { diff --git a/web/js/api.js b/web/js/api.js index 736328ee..906342b0 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -15,6 +15,7 @@ const endpoints = { GAME_RECORDING: 110, GET_WORKER_LIST: 111, GAME_ERROR_NO_FREE_SLOTS: 112, + GAME_RESET: 113, APP_VIDEO_CHANGE: 150, } @@ -319,6 +320,7 @@ export const api = { } }, load: () => packet(endpoints.GAME_LOAD), + reset: (roomId) => packet(endpoints.GAME_RESET, {room_id: roomId}), save: () => packet(endpoints.GAME_SAVE), setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i), start: (game, roomId, record, recordUser, player) => packet(endpoints.GAME_START, { diff --git a/web/js/app.js b/web/js/app.js index ad5bb6c6..01327be3 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -437,6 +437,9 @@ const app = { room.reset(); window.location = window.location.pathname; break; + case KEY.RESET: + api.game.reset(room.id) + break; case KEY.STATS: stats.toggle(); break; diff --git a/web/js/input/keyboard.js b/web/js/input/keyboard.js index b29b61bf..4c26e9db 100644 --- a/web/js/input/keyboard.js +++ b/web/js/input/keyboard.js @@ -45,7 +45,8 @@ const defaultMap = Object.freeze({ KeyH: KEY.HELP, Backslash: KEY.STATS, Digit9: KEY.SETTINGS, - KeyT: KEY.DTOGGLE + KeyT: KEY.DTOGGLE, + Digit0: KEY.RESET, }); let keyMap = {}; diff --git a/web/js/input/keys.js b/web/js/input/keys.js index 6f94c2ff..4406823e 100644 --- a/web/js/input/keys.js +++ b/web/js/input/keys.js @@ -29,4 +29,5 @@ export const KEY = { L3: 'l3', R3: 'r3', REC: 'rec', + RESET: 'reset', } From a7acebc5d0ccce454329e988729d13ca93720c6e Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 4 Dec 2024 22:09:51 +0300 Subject: [PATCH 167/240] Try YUV without the mem pool --- pkg/encoder/encoder.go | 2 +- pkg/encoder/yuv/yuv.go | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pkg/encoder/encoder.go b/pkg/encoder/encoder.go index 550f75d1..0372c2c5 100644 --- a/pkg/encoder/encoder.go +++ b/pkg/encoder/encoder.go @@ -80,7 +80,7 @@ func (v *Video) Encode(frame InFrame) OutFrame { } yCbCr := v.y.Process(yuv.RawFrame(frame), v.rot, v.pf) - defer v.y.Put(&yCbCr) + //defer v.y.Put(&yCbCr) if bytes := v.codec.Encode(yCbCr); len(bytes) > 0 { return bytes } diff --git a/pkg/encoder/yuv/yuv.go b/pkg/encoder/yuv/yuv.go index 25d591d1..69bc89c8 100644 --- a/pkg/encoder/yuv/yuv.go +++ b/pkg/encoder/yuv/yuv.go @@ -8,10 +8,12 @@ import ( ) type Conv struct { - w, h int - sw, sh int - scale float64 - pool sync.Pool + w, h int + sw, sh int + scale float64 + pool sync.Pool + frame []byte + frameSc []byte } type RawFrame struct { @@ -35,7 +37,9 @@ func NewYuvConv(w, h int, scale float64) Conv { 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 }}, + pool: sync.Pool{New: func() any { b := make([]byte, bufSize); return &b }}, + frame: make([]byte, bufSize), + frameSc: make([]byte, bufSize), } } @@ -52,13 +56,13 @@ func (c *Conv) Process(frame RawFrame, rot uint, pf PixFmt) []byte { stride = frame.Stride >> 1 } - buf := *c.pool.Get().(*[]byte) + buf := c.frame //*c.pool.Get().(*[]byte) libyuv.Y420(frame.Data, buf, frame.W, frame.H, stride, dx, dy, rot, uint32(pf), cx, cy) if c.scale > 1 { - dstBuf := *c.pool.Get().(*[]byte) + dstBuf := c.frameSc //*c.pool.Get().(*[]byte) libyuv.Y420Scale(buf, dstBuf, dx, dy, c.sw, c.sh) - c.pool.Put(&buf) + //c.pool.Put(&buf) return dstBuf } return buf From 8fa53f4e32de9228cae06d1b930dbfb56e3120bb Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Wed, 4 Dec 2024 22:16:58 +0300 Subject: [PATCH 168/240] Disable macos --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e866f76d..c5b62044 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: build: strategy: matrix: - os: [ ubuntu-latest, macos-12, windows-latest ] + os: [ ubuntu-latest, windows-latest ] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 From db32479c4e1b7bcdc58c57c3ca03819c45638ce6 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 5 Dec 2024 01:10:16 +0300 Subject: [PATCH 169/240] Destroy rooms when the coordinator was lost --- pkg/worker/worker.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index f98b2e97..28c29b60 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -113,6 +113,7 @@ func (w *Worker) Start(done chan struct{}) { case <-done: return default: + w.Reset() cord, err := newCoordinatorConnection(remoteAddr, w.conf.Worker, w.address, w.log) if err != nil { onRetryFail(err) From 5649d4410a85285830d7a00d8303dbf34946c6ec Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 5 Dec 2024 01:11:02 +0300 Subject: [PATCH 170/240] Remove pools from YUV conv --- pkg/encoder/yuv/yuv.go | 43 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/pkg/encoder/yuv/yuv.go b/pkg/encoder/yuv/yuv.go index 69bc89c8..4718c7c1 100644 --- a/pkg/encoder/yuv/yuv.go +++ b/pkg/encoder/yuv/yuv.go @@ -2,7 +2,6 @@ package yuv import ( "image" - "sync" "github.com/giongto35/cloud-game/v3/pkg/encoder/yuv/libyuv" ) @@ -11,7 +10,6 @@ type Conv struct { w, h int sw, sh int scale float64 - pool sync.Pool frame []byte frameSc []byte } @@ -33,42 +31,49 @@ 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 }}, - frame: make([]byte, bufSize), - frameSc: make([]byte, bufSize), + 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) || pf == PixFmt(libyuv.FourccRgb0) { + var stride int + switch pf { + case PixFmt(libyuv.FourccRgbp), PixFmt(libyuv.FourccRgb0): stride = frame.Stride >> 1 + default: + stride = frame.Stride >> 2 } - buf := c.frame //*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.frameSc //*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 } From 297ec9005c3774eb1c48ec613464c05a0f2a28f9 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 5 Dec 2024 01:35:48 +0300 Subject: [PATCH 171/240] Display video scaling info --- web/js/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/js/app.js b/web/js/app.js index 01327be3..3d58dc89 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -588,7 +588,7 @@ stats.modules = [ { mui: stats.mui('', '', false, () => ''), init() { - sub(APP_VIDEO_CHANGED, (payload) => (this.val = `${payload.w}x${payload.h}`)) + sub(APP_VIDEO_CHANGED, ({s = 1, w, h}) => (this.val = `${w * s}x${h * s}`)) }, }, { From d77d69a3310d013863588b6fa8603aafdbeabc51 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 5 Dec 2024 13:50:39 +0300 Subject: [PATCH 172/240] Remove pool from the audio stretcher --- pkg/worker/media/media.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index 3b844a02..3d8d6ab8 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -32,7 +32,7 @@ type ( var ( encoderOnce = sync.Once{} opusCoder *opus.Encoder - audioPool = sync.Pool{New: func() any { b := make([]int16, sampleBufLen); return &b }} + buf = make([]int16, sampleBufLen) ) func newBuffer(srcLen int) buffer { return buffer{s: make(samples, srcLen)} } @@ -86,7 +86,7 @@ func frame(hz int, frame int) int { return hz * frame / 1000 * 2 } // stretch does a simple stretching of audio samples. // something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] func (s samples) stretch(size int) []int16 { - out := (*audioPool.Get().(*[]int16))[:size] + out := buf[:size] n := len(s) ratio := float32(size) / float32(n) sPtr := unsafe.Pointer(&s[0]) @@ -181,7 +181,6 @@ func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSize int) error { func (wmp *WebrtcMediaPipe) encodeAudio(pcm samples) { data, err := wmp.Audio().Encode(pcm) - audioPool.Put((*[]int16)(&pcm)) if err != nil { wmp.log.Error().Err(err).Msgf("opus encode fail") return From 6bb82b22042251e136a8f96359df4e25a122198d Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 6 Dec 2024 15:27:18 +0300 Subject: [PATCH 173/240] Allow 2.5ms Opus frame --- pkg/config/worker.go | 2 +- pkg/worker/media/media.go | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/config/worker.go b/pkg/config/worker.go index 9e865672..1a476b05 100644 --- a/pkg/config/worker.go +++ b/pkg/config/worker.go @@ -51,7 +51,7 @@ type Encoder struct { } type Audio struct { - Frame int + Frame float32 } type Video struct { diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index 3d8d6ab8..3c8c8713 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -2,6 +2,7 @@ package media import ( "fmt" + "math" "sync" "time" "unsafe" @@ -81,7 +82,9 @@ func DefaultOpus() (*opus.Encoder, error) { } // frame calculates an audio stereo frame size, i.e. 48k*frame/1000*2 -func frame(hz int, frame int) int { return hz * frame / 1000 * 2 } +func frame(hz int, frame float32) int { + return int(math.Round(float64(hz) * float64(frame) / 1000 * 2)) +} // stretch does a simple stretching of audio samples. // something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] @@ -114,7 +117,7 @@ type WebrtcMediaPipe struct { vConf config.Video AudioSrcHz int - AudioFrame int + AudioFrame float32 VideoW, VideoH int VideoScale float64 @@ -162,7 +165,7 @@ func (wmp *WebrtcMediaPipe) Init() error { return nil } -func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSize int) error { +func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSize float32) error { au, err := DefaultOpus() if err != nil { return fmt.Errorf("opus fail: %w", err) From f54089e072d75b4be6a458e54f3c693c0176459d Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 7 Dec 2024 00:47:27 +0300 Subject: [PATCH 174/240] Stretch samples a bit better with the GBA's 32768Hz --- pkg/worker/media/media.go | 3 ++- pkg/worker/media/media_test.go | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index 3c8c8713..ed356308 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -82,8 +82,9 @@ func DefaultOpus() (*opus.Encoder, error) { } // frame calculates an audio stereo frame size, i.e. 48k*frame/1000*2 +// with round(x / 2) * 2 for the closest even number func frame(hz int, frame float32) int { - return int(math.Round(float64(hz) * float64(frame) / 1000 * 2)) + return int(math.Round(float64(hz)*float64(frame)/1000/2) * 2 * 2) } // stretch does a simple stretching of audio samples. diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 756688de..ab27f7fa 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -192,7 +192,7 @@ func TestBufferWrite(t *testing.T) { buf.write(samplesOf(w.sample, w.len), func(s samples) { lastResult = s }) } if !reflect.DeepEqual(test.expect, lastResult) { - t.Errorf("not expted buffer, %v != %v", lastResult, test.expect) + t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, buf.s) } } } @@ -217,17 +217,20 @@ func samplesOf(v int16, len int) (s samples) { return } -func Test_frame(t *testing.T) { +func TestFrame(t *testing.T) { type args struct { hz int - frame int + frame float32 } tests := []struct { name string args args want int }{ - {name: "mGBA", args: args{hz: 32768, frame: 10}, want: 654}, + {name: "mGBA", args: args{hz: 32768, frame: 10}, want: 656}, + {name: "mGBA", args: args{hz: 32768, frame: 5}, want: 328}, + {name: "mGBA", args: args{hz: 32768, frame: 2.5}, want: 164}, + {name: "nes", args: args{hz: 48000, frame: 2.5}, want: 240}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From ed3b195b26fea2a736a1ec38a4e01db6c9a5edba Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 12 Dec 2024 21:13:43 +0300 Subject: [PATCH 175/240] Dynamic audio buf * Ugly audio buf * Use dynamic Opus frames with config --- pkg/config/config.yaml | 5 ++ pkg/config/loader_test.go | 13 +++- pkg/config/worker.go | 2 +- pkg/worker/coordinatorhandlers.go | 2 +- pkg/worker/media/buffer.go | 119 ++++++++++++++++++++++++++++++ pkg/worker/media/buffer_test.go | 77 +++++++++++++++++++ pkg/worker/media/media.go | 105 ++++++-------------------- pkg/worker/media/media_test.go | 64 ---------------- pkg/worker/room/room_test.go | 2 +- 9 files changed, 235 insertions(+), 154 deletions(-) create mode 100644 pkg/worker/media/buffer.go create mode 100644 pkg/worker/media/buffer_test.go diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 48d78ed3..9c1ee1aa 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -300,7 +300,12 @@ encoder: # 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 video: # h264, vpx (vp8) or vp9 codec: h264 diff --git a/pkg/config/loader_test.go b/pkg/config/loader_test.go index 355f19a4..08e17dd3 100644 --- a/pkg/config/loader_test.go +++ b/pkg/config/loader_test.go @@ -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"] diff --git a/pkg/config/worker.go b/pkg/config/worker.go index 1a476b05..5a509b0c 100644 --- a/pkg/config/worker.go +++ b/pkg/config/worker.go @@ -51,7 +51,7 @@ type Encoder struct { } type Audio struct { - Frame float32 + Frames []float32 } type Video struct { diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 97cb2784..536c6ed6 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -168,7 +168,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke } m.AudioSrcHz = app.AudioSampleRate() - m.AudioFrame = w.conf.Encoder.Audio.Frame + m.AudioFrames = w.conf.Encoder.Audio.Frames m.VideoW, m.VideoH = app.ViewportSize() m.VideoScale = app.Scale() diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go new file mode 100644 index 00000000..bba28959 --- /dev/null +++ b/pkg/worker/media/buffer.go @@ -0,0 +1,119 @@ +package media + +import ( + "errors" + "math" + "unsafe" +) + +// buffer is a simple non-concurrent safe buffer for audio samples. +type buffer struct { + stretch bool + frameHz []int + + raw samples + buckets []Bucket + cur *Bucket +} + +type Bucket struct { + mem samples + ms float32 + lv int + dst int +} + +func newBuffer(frames []float32, hz int) (*buffer, error) { + if hz < 2000 { + return nil, errors.New("hz should be > than 2000") + } + + buf := buffer{} + + // preallocate continuous array + s := 0 + for _, f := range frames { + s += frame(hz, f) + } + buf.raw = make(samples, s) + + next := 0 + for _, f := range frames { + s := frame(hz, f) + buf.buckets = append(buf.buckets, Bucket{ + mem: buf.raw[next : next+s], + ms: f, + }) + next += s + } + buf.cur = &buf.buckets[len(buf.buckets)-1] + return &buf, nil +} + +func (b *buffer) choose(l int) { + for _, bb := range b.buckets { + if l >= len(bb.mem) { + b.cur = &bb + break + } + } +} + +func (b *buffer) resample(hz int) { + b.stretch = true + for i := range b.buckets { + b.buckets[i].dst = frame(hz, float32(b.buckets[i].ms)) + } +} + +// write fills the buffer until it's full and then passes the gathered data into a callback. +// +// There are two cases to consider: +// 1. Underflow, when the length of the written data is less than the buffer's available space. +// 2. Overflow, when the length exceeds the current available buffer space. +// +// We overwrite any previous values in the buffer and move the internal write pointer +// by the length of the written data. +// In the first case, we won't call the callback, but it will be called every time +// when the internal buffer overflows until all samples are read. +func (b *buffer) write(s samples, onFull func(samples, float32)) (r int) { + for r < len(s) { + buf := b.cur + w := copy(buf.mem[buf.lv:], s[r:]) + r += w + buf.lv += w + if buf.lv == len(buf.mem) { + if b.stretch { + onFull(buf.mem.stretch(buf.dst), buf.ms) + } else { + onFull(buf.mem, buf.ms) + } + b.choose(len(s) - r) + b.cur.lv = 0 + } + } + return +} + +// frame calculates an audio stereo frame size, i.e. 48k*frame/1000*2 +// with round(x / 2) * 2 for the closest even number +func frame(hz int, frame float32) int { + return int(math.Round(float64(hz)*float64(frame)/1000/2) * 2 * 2) +} + +// stretch does a simple stretching of audio samples. +// something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] +func (s samples) stretch(size int) []int16 { + out := buf[:size] + n := len(s) + ratio := float32(size) / float32(n) + sPtr := unsafe.Pointer(&s[0]) + for i, l, r := 0, 0, 0; i < n; i += 2 { + l, r = r, int(float32((i+2)>>1)*ratio)<<1 // index in src * ratio -> approximated index in dst *2 due to int16 + for j := l; j < r; j += 2 { + *(*int32)(unsafe.Pointer(&out[j])) = *(*int32)(sPtr) // out[j] = s[i]; out[j+1] = s[i+1] + } + sPtr = unsafe.Add(sPtr, uintptr(4)) + } + return out +} diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go new file mode 100644 index 00000000..29f2fc6a --- /dev/null +++ b/pkg/worker/media/buffer_test.go @@ -0,0 +1,77 @@ +package media + +import ( + "reflect" + "testing" +) + +type bufWrite struct { + sample int16 + len int +} + +func TestBufferWrite(t *testing.T) { + tests := []struct { + bufLen int + writes []bufWrite + expect samples + }{ + { + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 10}, + {sample: 2, len: 20}, + {sample: 3, len: 30}, + }, + expect: samples{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, + }, + { + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 3}, + {sample: 2, len: 18}, + {sample: 3, len: 2}, + }, + expect: samples{2, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, + }, + } + + for _, test := range tests { + var lastResult samples + buf, err := newBuffer([]float32{10, 5}, test.bufLen) + if err != nil { + t.Fatalf("oof, %v", err) + } + for _, w := range test.writes { + buf.write(samplesOf(w.sample, w.len), + func(s samples, ms float32) { lastResult = s }, + ) + } + if !reflect.DeepEqual(test.expect, lastResult) { + t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, len(buf.cur.mem)) + } + } +} + +func BenchmarkBufferWrite(b *testing.B) { + fn := func(_ samples, _ float32) {} + l := 2000 + buf, err := newBuffer([]float32{10}, l) + if err != nil { + b.Fatalf("oof: %v", err) + } + samples1 := samplesOf(1, l/2) + samples2 := samplesOf(2, l*2) + for i := 0; i < b.N; i++ { + buf.write(samples1, fn) + buf.write(samples2, fn) + } +} + +func samplesOf(v int16, len int) (s samples) { + s = make(samples, len) + for i := range s { + s[i] = v + } + return +} diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index ed356308..bece8a09 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -2,16 +2,13 @@ package media import ( "fmt" - "math" - "sync" - "time" - "unsafe" - "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/encoder" "github.com/giongto35/cloud-game/v3/pkg/encoder/opus" "github.com/giongto35/cloud-game/v3/pkg/logger" "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" + "sync" + "time" ) const ( @@ -19,16 +16,7 @@ const ( sampleBufLen = 1024 * 4 ) -// buffer is a simple non-concurrent safe ring buffer for audio samples. -type ( - buffer struct { - s samples - wi int - dst int - stretch bool - } - samples []int16 -) +type samples []int16 var ( encoderOnce = sync.Once{} @@ -36,39 +24,6 @@ var ( buf = make([]int16, sampleBufLen) ) -func newBuffer(srcLen int) buffer { return buffer{s: make(samples, srcLen)} } - -// enableStretch adds a simple stretching of buffer to a desired size before -// the onFull callback call. -func (b *buffer) enableStretch(l int) { b.stretch = true; b.dst = l } - -// write fills the buffer until it's full and then passes the gathered data into a callback. -// -// There are two cases to consider: -// 1. Underflow, when the length of the written data is less than the buffer's available space. -// 2. Overflow, when the length exceeds the current available buffer space. -// -// We overwrite any previous values in the buffer and move the internal write pointer -// by the length of the written data. -// In the first case, we won't call the callback, but it will be called every time -// when the internal buffer overflows until all samples are read. -func (b *buffer) write(s samples, onFull func(samples)) (r int) { - for r < len(s) { - w := copy(b.s[b.wi:], s[r:]) - r += w - b.wi += w - if b.wi == len(b.s) { - b.wi = 0 - if b.stretch { - onFull(b.s.stretch(b.dst)) - } else { - onFull(b.s) - } - } - } - return -} - func DefaultOpus() (*opus.Encoder, error) { var err error encoderOnce.Do(func() { opusCoder, err = opus.NewEncoder(audioHz) }) @@ -81,34 +36,11 @@ func DefaultOpus() (*opus.Encoder, error) { return opusCoder, nil } -// frame calculates an audio stereo frame size, i.e. 48k*frame/1000*2 -// with round(x / 2) * 2 for the closest even number -func frame(hz int, frame float32) int { - return int(math.Round(float64(hz)*float64(frame)/1000/2) * 2 * 2) -} - -// stretch does a simple stretching of audio samples. -// something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] -func (s samples) stretch(size int) []int16 { - out := buf[:size] - n := len(s) - ratio := float32(size) / float32(n) - sPtr := unsafe.Pointer(&s[0]) - for i, l, r := 0, 0, 0; i < n; i += 2 { - l, r = r, int(float32((i+2)>>1)*ratio)<<1 // index in src * ratio -> approximated index in dst *2 due to int16 - for j := l; j < r; j += 2 { - *(*int32)(unsafe.Pointer(&out[j])) = *(*int32)(sPtr) // out[j] = s[i]; out[j+1] = s[i+1] - } - sPtr = unsafe.Add(sPtr, uintptr(4)) - } - return out -} - type WebrtcMediaPipe struct { a *opus.Encoder v *encoder.Video - onAudio func([]byte) - audioBuf buffer + onAudio func([]byte, float32) + audioBuf *buffer log *logger.Logger mua sync.RWMutex @@ -118,7 +50,7 @@ type WebrtcMediaPipe struct { vConf config.Video AudioSrcHz int - AudioFrame float32 + AudioFrames []float32 VideoW, VideoH int VideoScale float64 @@ -135,8 +67,9 @@ func NewWebRtcMediaPipe(ac config.Audio, vc config.Video, log *logger.Logger) *W } func (wmp *WebrtcMediaPipe) SetAudioCb(cb func([]byte, int32)) { - fr := int32(time.Duration(wmp.AudioFrame) * time.Millisecond) - wmp.onAudio = func(bytes []byte) { cb(bytes, fr) } + wmp.onAudio = func(bytes []byte, ms float32) { + cb(bytes, int32(time.Duration(ms)*time.Millisecond)) + } } func (wmp *WebrtcMediaPipe) Destroy() { v := wmp.Video() @@ -144,10 +77,12 @@ func (wmp *WebrtcMediaPipe) Destroy() { v.Stop() } } -func (wmp *WebrtcMediaPipe) PushAudio(audio []int16) { wmp.audioBuf.write(audio, wmp.encodeAudio) } +func (wmp *WebrtcMediaPipe) PushAudio(audio []int16) { + wmp.audioBuf.write(audio, wmp.encodeAudio) +} func (wmp *WebrtcMediaPipe) Init() error { - if err := wmp.initAudio(wmp.AudioSrcHz, wmp.AudioFrame); err != nil { + if err := wmp.initAudio(wmp.AudioSrcHz, wmp.AudioFrames); err != nil { return err } if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil { @@ -166,30 +101,34 @@ func (wmp *WebrtcMediaPipe) Init() error { return nil } -func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSize float32) error { +func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSizes []float32) error { au, err := DefaultOpus() if err != nil { return fmt.Errorf("opus fail: %w", err) } wmp.log.Debug().Msgf("Opus: %v", au.GetInfo()) wmp.SetAudio(au) - buf := newBuffer(frame(srcHz, frameSize)) + buf, err := newBuffer(frameSizes, srcHz) + if err != nil { + return err + } + wmp.log.Debug().Msgf("Opus frames (ms): %v", frameSizes) dstHz, _ := au.SampleRate() if srcHz != dstHz { - buf.enableStretch(frame(dstHz, frameSize)) + buf.resample(dstHz) wmp.log.Debug().Msgf("Resample %vHz -> %vHz", srcHz, dstHz) } wmp.audioBuf = buf return nil } -func (wmp *WebrtcMediaPipe) encodeAudio(pcm samples) { +func (wmp *WebrtcMediaPipe) encodeAudio(pcm samples, ms float32) { data, err := wmp.Audio().Encode(pcm) if err != nil { wmp.log.Error().Err(err).Msgf("opus encode fail") return } - wmp.onAudio(data) + wmp.onAudio(data, ms) } func (wmp *WebrtcMediaPipe) initVideo(w, h int, scale float64, conf config.Video) (err error) { diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index ab27f7fa..4b9a431b 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -3,7 +3,6 @@ package media import ( "image" "math/rand/v2" - "reflect" "testing" "github.com/giongto35/cloud-game/v3/pkg/config" @@ -154,69 +153,6 @@ func gen(l int) []int16 { return nums } -type bufWrite struct { - sample int16 - len int -} - -func TestBufferWrite(t *testing.T) { - tests := []struct { - bufLen int - writes []bufWrite - expect samples - }{ - { - bufLen: 20, - writes: []bufWrite{ - {sample: 1, len: 10}, - {sample: 2, len: 20}, - {sample: 3, len: 30}, - }, - expect: samples{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, - }, - { - bufLen: 11, - writes: []bufWrite{ - {sample: 1, len: 3}, - {sample: 2, len: 18}, - {sample: 3, len: 2}, - }, - expect: samples{3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3}, - }, - } - - for _, test := range tests { - var lastResult samples - buf := newBuffer(test.bufLen) - for _, w := range test.writes { - buf.write(samplesOf(w.sample, w.len), func(s samples) { lastResult = s }) - } - if !reflect.DeepEqual(test.expect, lastResult) { - t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, buf.s) - } - } -} - -func BenchmarkBufferWrite(b *testing.B) { - fn := func(_ samples) {} - l := 1920 - buf := newBuffer(l) - samples1 := samplesOf(1, l/2) - samples2 := samplesOf(2, l*2) - for i := 0; i < b.N; i++ { - buf.write(samples1, fn) - buf.write(samples2, fn) - } -} - -func samplesOf(v int16, len int) (s samples) { - s = make(samples, len) - for i := range s { - s[i] = v - } - return -} - func TestFrame(t *testing.T) { type args struct { hz int diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go index a33e4d82..9a4bdd73 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room/room_test.go @@ -229,7 +229,7 @@ func room(cfg conf) testRoom { m := media.NewWebRtcMediaPipe(conf.Encoder.Audio, conf.Encoder.Video, l) m.AudioSrcHz = emu.AudioSampleRate() - m.AudioFrame = conf.Encoder.Audio.Frame + m.AudioFrames = conf.Encoder.Audio.Frames m.VideoW, m.VideoH = emu.ViewportSize() m.VideoScale = emu.Scale() if err := m.Init(); err != nil { From 89ae98b035e34ab6aa8c4efbe1e65f2233697fb2 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 13 Dec 2024 18:57:25 +0300 Subject: [PATCH 176/240] 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. --- pkg/worker/media/buffer.go | 25 +++++++++++++++---------- pkg/worker/media/media.go | 5 +++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index bba28959..e80a7c82 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -12,14 +12,14 @@ type buffer struct { frameHz []int raw samples - buckets []Bucket - cur *Bucket + buckets []bucket + cur *bucket } -type Bucket struct { +type bucket struct { mem samples ms float32 - lv int + p int dst int } @@ -37,10 +37,14 @@ func newBuffer(frames []float32, hz int) (*buffer, error) { } buf.raw = make(samples, s) + if len(buf.raw) == 0 { + return nil, errors.New("seems those params are bad and the buffer is 0") + } + next := 0 for _, f := range frames { s := frame(hz, f) - buf.buckets = append(buf.buckets, Bucket{ + buf.buckets = append(buf.buckets, bucket{ mem: buf.raw[next : next+s], ms: f, }) @@ -62,7 +66,7 @@ func (b *buffer) choose(l int) { func (b *buffer) resample(hz int) { b.stretch = true for i := range b.buckets { - b.buckets[i].dst = frame(hz, float32(b.buckets[i].ms)) + b.buckets[i].dst = frame(hz, b.buckets[i].ms) } } @@ -76,20 +80,21 @@ func (b *buffer) resample(hz int) { // by the length of the written data. // In the first case, we won't call the callback, but it will be called every time // when the internal buffer overflows until all samples are read. +// It will choose between multiple internal buffers to fit remaining samples. func (b *buffer) write(s samples, onFull func(samples, float32)) (r int) { for r < len(s) { buf := b.cur - w := copy(buf.mem[buf.lv:], s[r:]) + w := copy(buf.mem[buf.p:], s[r:]) r += w - buf.lv += w - if buf.lv == len(buf.mem) { + buf.p += w + if buf.p == len(buf.mem) { if b.stretch { onFull(buf.mem.stretch(buf.dst), buf.ms) } else { onFull(buf.mem, buf.ms) } b.choose(len(s) - r) - b.cur.lv = 0 + b.cur.p = 0 } } return diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index bece8a09..b08ec692 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -2,13 +2,14 @@ package media import ( "fmt" + "sync" + "time" + "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/encoder" "github.com/giongto35/cloud-game/v3/pkg/encoder/opus" "github.com/giongto35/cloud-game/v3/pkg/logger" "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" - "sync" - "time" ) const ( From 82aebf66473dd6ed43026addc420e1cd374a9b01 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 14 Dec 2024 14:14:56 +0300 Subject: [PATCH 177/240] Fix Package 'libgl1-mesa-glx' has no installation candidate --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5b62044..81225ca7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ 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 make build xvfb-run --auto-servernum make test verify-cores From 600243c87d1e118b017b04d854d732458bc9bd68 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 16 Dec 2024 13:48:34 +0300 Subject: [PATCH 178/240] Update dependencies --- go.mod | 22 +++++++++++----------- go.sum | 47 ++++++++++++++++++++++++----------------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index ccef851d..09c07fd3 100644 --- a/go.mod +++ b/go.mod @@ -6,20 +6,20 @@ require ( github.com/VictoriaMetrics/metrics v1.35.1 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.8.0 - github.com/goccy/go-json v0.10.3 + github.com/goccy/go-json v0.10.4 github.com/gofrs/flock v0.12.1 github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.2 - github.com/minio/minio-go/v7 v7.0.81 - github.com/pion/ice/v4 v4.0.2 + github.com/minio/minio-go/v7 v7.0.82 + github.com/pion/ice/v4 v4.0.3 github.com/pion/interceptor v0.1.37 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.2 + github.com/pion/webrtc/v4 v4.0.6 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/veandco/go-sdl2 v0.4.40 - golang.org/x/crypto v0.29.0 + golang.org/x/crypto v0.31.0 golang.org/x/image v0.19.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -36,13 +36,13 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pion/datachannel v1.5.9 // indirect + github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.4 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.14 // indirect + github.com/pion/rtcp v1.2.15 // indirect github.com/pion/rtp v1.8.9 // indirect - github.com/pion/sctp v1.8.34 // indirect + github.com/pion/sctp v1.8.35 // indirect github.com/pion/sdp/v3 v3.0.9 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/stun/v3 v3.0.0 // indirect @@ -52,7 +52,7 @@ require ( github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/net v0.32.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index e56b4db7..f170316e 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ 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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= @@ -44,18 +44,18 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.81 h1:SzhMN0TQ6T/xSBu6Nvw3M5M8voM+Ht8RH3hE8S7zxaA= -github.com/minio/minio-go/v7 v7.0.81/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= +github.com/minio/minio-go/v7 v7.0.82 h1:tWfICLhmp2aFPXL8Tli0XDTHj2VB/fNf0PC1f/i1gRo= +github.com/minio/minio-go/v7 v7.0.82/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA= -github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= +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.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= -github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s= -github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg= +github.com/pion/ice/v4 v4.0.3 h1:9s5rI1WKzF5DRqhJ+Id8bls/8PzM7mau0mj1WZb4IXE= +github.com/pion/ice/v4 v4.0.3/go.mod h1:VfHy0beAZ5loDT7BmJ2LtMtC4dbawIkkkejHPRZNB3Y= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -64,12 +64,12 @@ github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 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.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= -github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= +github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= +github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= -github.com/pion/sctp v1.8.34 h1:rCuD3m53i0oGxCSp7FLQKvqVx0Nf5AUAHhMRXTTQjBc= -github.com/pion/sctp v1.8.34/go.mod h1:yWkCClkXlzVW7BXfI2PjrUGBwUI0CjXJBkhLt+sdo4U= +github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA= +github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg= github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= @@ -80,8 +80,8 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1 github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.2 h1:fBwm5/hqSUybrCWl0DDBSTDrpbkcgkqpeLmXw9CsBQA= -github.com/pion/webrtc/v4 v4.0.2/go.mod h1:moylBT2A4dNoEaYBCdV1nThM3TLwRHzWszIG+eSPaqQ= +github.com/pion/webrtc/v4 v4.0.6 h1:OfxfGeZGhneUDnZEoebLGDkzwjowSJ0avbOu2xaIUeM= +github.com/pion/webrtc/v4 v4.0.6/go.mod h1:j7oMHYvjl7lESJ/nYiE4d2URyjFbAo3uqJ6Xse6hbSg= 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= @@ -99,8 +99,9 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ 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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= @@ -109,19 +110,19 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= -golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 4aaeda3fbb2deab822d714c5fa883333fdf289fc Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Mon, 16 Dec 2024 23:28:14 +0300 Subject: [PATCH 179/240] Move some RETRO_ENVIRONMENT vars into C --- pkg/worker/caged/libretro/nanoarch/nanoarch.c | 35 ++++++++++++++++--- .../caged/libretro/nanoarch/nanoarch.go | 29 ++------------- pkg/worker/caged/libretro/nanoarch/nanoarch.h | 1 - 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index 2bbd1883..cb474d98 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -4,6 +4,8 @@ #include #include +#define RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB (3 | 0x800000) + int initialized = 0; typedef struct { @@ -127,16 +129,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); } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 9d8447a0..40b3c231 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -23,8 +23,6 @@ import ( #include "libretro.h" #include "nanoarch.h" #include - -#define RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB (3 | 0x800000) */ import "C" @@ -771,13 +769,8 @@ func coreGetProcAddress(sym *C.char) C.retro_proc_address_t { //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: @@ -829,9 +822,6 @@ 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 { return false @@ -884,25 +874,10 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool { //Nan0.log.Debug().Msgf("%v", cInfo.String()) } return true - case C.RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS: - *(*C.unsigned)(data) = C.unsigned(4) - Nan0.log.Debug().Msgf("Set max users: %v", 4) - return true 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 - case C.RETRO_ENVIRONMENT_GET_INPUT_BITMASKS: - Nan0.log.Debug().Msgf("Set input bitmasks: false") - 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 - } - return true } return false } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.h b/pkg/worker/caged/libretro/nanoarch/nanoarch.h index c1e09462..1ad85f08 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.h +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.h @@ -23,7 +23,6 @@ void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned de 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); From 535e725618a99ca2953f19bc23c24fd1619014b8 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Thu, 19 Dec 2024 21:40:14 +0300 Subject: [PATCH 180/240] Panic when dlib functions are missing --- pkg/worker/caged/libretro/nanoarch/loader.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/worker/caged/libretro/nanoarch/loader.go b/pkg/worker/caged/libretro/nanoarch/loader.go index 274a1a8d..d7d0c662 100644 --- a/pkg/worker/caged/libretro/nanoarch/loader.go +++ b/pkg/worker/caged/libretro/nanoarch/loader.go @@ -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) { From f78bcf3e4b50aec52b09ebc278972f43b85c436d Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Fri, 20 Dec 2024 01:32:20 +0300 Subject: [PATCH 181/240] Allow config for the remote Libretro core repos --- pkg/config/config.yaml | 26 ++++++++ pkg/config/emulator.go | 39 +++++++++-- pkg/worker/caged/libretro/frontend.go | 9 +++ pkg/worker/caged/libretro/frontend_test.go | 8 +++ pkg/worker/caged/libretro/manager/http.go | 24 ++++--- .../caged/libretro/manager/repository.go | 65 +++++++++++++++++++ .../caged/libretro/manager/repository_test.go | 61 +++++++++++++++++ .../caged/libretro/nanoarch/nanoarch.go | 18 ++--- pkg/worker/caged/libretro/repo/arch/arch.go | 39 ----------- .../libretro/repo/buildbot/repository.go | 34 ---------- .../libretro/repo/buildbot/repository_test.go | 55 ---------------- .../caged/libretro/repo/github/repository.go | 18 ----- .../libretro/repo/github/repository_test.go | 55 ---------------- .../caged/libretro/repo/raw/repository.go | 14 ---- pkg/worker/caged/libretro/repo/repository.go | 36 ---------- 15 files changed, 219 insertions(+), 282 deletions(-) create mode 100644 pkg/worker/caged/libretro/manager/repository.go create mode 100644 pkg/worker/caged/libretro/manager/repository_test.go delete mode 100644 pkg/worker/caged/libretro/repo/arch/arch.go delete mode 100644 pkg/worker/caged/libretro/repo/buildbot/repository.go delete mode 100644 pkg/worker/caged/libretro/repo/buildbot/repository_test.go delete mode 100644 pkg/worker/caged/libretro/repo/github/repository.go delete mode 100644 pkg/worker/caged/libretro/repo/github/repository_test.go delete mode 100644 pkg/worker/caged/libretro/repo/raw/repository.go delete mode 100644 pkg/worker/caged/libretro/repo/repository.go diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 9c1ee1aa..33eb0b2a 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -160,6 +160,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 diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index 21c5b2ad..d3daca3e 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -1,8 +1,10 @@ package config import ( + "errors" "path" "path/filepath" + "runtime" "strings" ) @@ -19,12 +21,7 @@ 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 @@ -33,12 +30,42 @@ type LibretroConfig struct { 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 diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 341038a4..c3666e98 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -142,6 +142,14 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) { func (f *Frontend) LoadCore(emu string) { conf := f.conf.GetLibretroCoreConfig(emu) + + libExt := "" + if ar, err := f.conf.Libretro.Cores.Repo.Guess(); err == nil { + libExt = ar.Ext + } else { + f.log.Warn().Err(err).Msg("system arch guesser failed") + } + meta := nanoarch.Metadata{ AutoGlContext: conf.AutoGlContext, FrameDup: f.conf.Libretro.Dup, @@ -155,6 +163,7 @@ func (f *Frontend) LoadCore(emu string) { UsesLibCo: conf.UsesLibCo, CoreAspectRatio: conf.CoreAspectRatio, KbMouseSupport: conf.KbMouseSupport, + LibExt: libExt, } f.mu.Lock() f.SaveStateFs = conf.SaveStateFs diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index e4c4105c..f2b108ee 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -26,6 +26,7 @@ type TestFrontend struct { *Frontend corePath string + coreExt string gamePath string system string } @@ -78,6 +79,11 @@ func EmulatorMock(room string, system string) *TestFrontend { 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{ @@ -92,6 +98,7 @@ func EmulatorMock(room string, system string) *TestFrontend { SaveOnClose: false, }, corePath: expand(conf.Emulator.GetLibretroCoreConfig(system).Lib), + coreExt: arch.Ext, gamePath: expand(conf.Library.BasePath), system: system, } @@ -133,6 +140,7 @@ func (emu *TestFrontend) loadRom(game string) { Options4rom: conf.Options4rom, UsesLibCo: conf.UsesLibCo, CoreAspectRatio: conf.CoreAspectRatio, + LibExt: emu.coreExt, } emu.nano.CoreLoad(meta) diff --git a/pkg/worker/caged/libretro/manager/http.go b/pkg/worker/caged/libretro/manager/http.go index 308677a4..ff57a2de 100644 --- a/pkg/worker/caged/libretro/manager/http.go +++ b/pkg/worker/caged/libretro/manager/http.go @@ -4,16 +4,14 @@ import ( "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/logger" "github.com/giongto35/cloud-game/v3/pkg/os" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo" - "github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/repo/arch" ) type Manager struct { BasicManager - arch arch.Info - repo repo.Repository - altRepo repo.Repository + arch ArchInfo + repo Repository + altRepo Repository client Downloader fmu *os.Flock log *logger.Logger @@ -29,24 +27,24 @@ func NewRemoteHttpManager(conf config.LibretroConfig, log *logger.Logger) Manage 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, 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 @@ -81,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 } @@ -92,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 } @@ -137,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 } diff --git a/pkg/worker/caged/libretro/manager/repository.go b/pkg/worker/caged/libretro/manager/repository.go new file mode 100644 index 00000000..3dbe0686 --- /dev/null +++ b/pkg/worker/caged/libretro/manager/repository.go @@ -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 +} diff --git a/pkg/worker/caged/libretro/manager/repository_test.go b/pkg/worker/caged/libretro/manager/repository_test.go new file mode 100644 index 00000000..bff2c16a --- /dev/null +++ b/pkg/worker/caged/libretro/manager/repository_test.go @@ -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) + } + } +} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 40b3c231..1748ec15 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -15,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" ) @@ -100,6 +99,7 @@ type Metadata struct { Hid map[int][]int CoreAspectRatio bool KbMouseSupport bool + LibExt string } type PixFmt struct { @@ -200,20 +200,14 @@ func (n *Nanoarch) CoreLoad(meta Metadata) { 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) } } diff --git a/pkg/worker/caged/libretro/repo/arch/arch.go b/pkg/worker/caged/libretro/repo/arch/arch.go deleted file mode 100644 index 16e5a88d..00000000 --- a/pkg/worker/caged/libretro/repo/arch/arch.go +++ /dev/null @@ -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) - } -} diff --git a/pkg/worker/caged/libretro/repo/buildbot/repository.go b/pkg/worker/caged/libretro/repo/buildbot/repository.go deleted file mode 100644 index 44bdcd1b..00000000 --- a/pkg/worker/caged/libretro/repo/buildbot/repository.go +++ /dev/null @@ -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() -} diff --git a/pkg/worker/caged/libretro/repo/buildbot/repository_test.go b/pkg/worker/caged/libretro/repo/buildbot/repository_test.go deleted file mode 100644 index 5aa007b9..00000000 --- a/pkg/worker/caged/libretro/repo/buildbot/repository_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/pkg/worker/caged/libretro/repo/github/repository.go b/pkg/worker/caged/libretro/repo/github/repository.go deleted file mode 100644 index 532c02f0..00000000 --- a/pkg/worker/caged/libretro/repo/github/repository.go +++ /dev/null @@ -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" -} diff --git a/pkg/worker/caged/libretro/repo/github/repository_test.go b/pkg/worker/caged/libretro/repo/github/repository_test.go deleted file mode 100644 index cf3a4380..00000000 --- a/pkg/worker/caged/libretro/repo/github/repository_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/pkg/worker/caged/libretro/repo/raw/repository.go b/pkg/worker/caged/libretro/repo/raw/repository.go deleted file mode 100644 index 33c9056a..00000000 --- a/pkg/worker/caged/libretro/repo/raw/repository.go +++ /dev/null @@ -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 } diff --git a/pkg/worker/caged/libretro/repo/repository.go b/pkg/worker/caged/libretro/repo/repository.go deleted file mode 100644 index e2a99c1e..00000000 --- a/pkg/worker/caged/libretro/repo/repository.go +++ /dev/null @@ -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 -} From 0c768bb3d6dd55decf4ba2ee90515c4748c70a62 Mon Sep 17 00:00:00 2001 From: Sergey Stepanov Date: Sat, 21 Dec 2024 01:37:42 +0300 Subject: [PATCH 182/240] Add some notes on recording in regards to ffconcat --- pkg/worker/caged/libretro/recording.go | 13 +------------ pkg/worker/recorder/ffmpegmux.go | 15 +++++++++++++++ pkg/worker/recorder/recorder.go | 12 ++++++++++++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/pkg/worker/caged/libretro/recording.go b/pkg/worker/caged/libretro/recording.go index cc4cdcdd..64734536 100644 --- a/pkg/worker/caged/libretro/recording.go +++ b/pkg/worker/caged/libretro/recording.go @@ -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 } diff --git a/pkg/worker/recorder/ffmpegmux.go b/pkg/worker/recorder/ffmpegmux.go index 37c9df6a..ba543551 100644 --- a/pkg/worker/recorder/ffmpegmux.go +++ b/pkg/worker/recorder/ffmpegmux.go @@ -17,6 +17,21 @@ const demuxFile = "input.txt" // // !to change // +// - can't read pix_fmt from ffconcat +// - maybe change raw output to yuv420? +// - frame durations and size can change dynamically +// - or maybe merge encoded streams +// +// new: +// +// ffmpeg -f image2 -framerate 59 -video_size 384x224 -pixel_format rgb565le \ +// -i "./f%07d__384x224__768.raw" \ +// -ac 2 -channel_layout stereo -i audio.wav -b:a 192K \ +// -c:v libx264 -pix_fmt yuv420p -crf 20 \ +// output.mp4 +// +// old: +// // ffmpeg -f concat -i input.txt \ // -ac 2 -channel_layout stereo -i audio.wav \ // -b:a 192K -crf 23 -vf fps=30 -pix_fmt yuv420p \ diff --git a/pkg/worker/recorder/recorder.go b/pkg/worker/recorder/recorder.go index 5c1bf904..8082ab50 100644 --- a/pkg/worker/recorder/recorder.go +++ b/pkg/worker/recorder/recorder.go @@ -165,6 +165,18 @@ func (r *Recording) Set(enable bool, user string) { func (r *Recording) SetFramerate(fps float64) { r.opts.Fps = fps } func (r *Recording) SetAudioFrequency(fq int) { r.opts.Frequency = fq } +func (r *Recording) SetPixFormat(fmt uint32) { + pix := "" + switch fmt { + case 0: + pix = "rgb1555" + case 1: + pix = "brga" + case 2: + pix = "rgb565le" + } + r.opts.Pix = pix +} func (r *Recording) Enabled() bool { r.Lock() From b02cd5c4f0118520696c1a0ccfe2f48fa2f15769 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 4 Jan 2025 10:41:51 +0300 Subject: [PATCH 183/240] It is time to update the copyright year --- web/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index c6d7378d..4bd8c591 100644 --- a/web/index.html +++ b/web/index.html @@ -104,7 +104,7 @@
{{end}}
-
Cloudretro (ɔ) 2024 +
Cloudretro (ɔ) 2025 69ff8ae From 3dbf4f9b1970decac2546319bd034577edd25f4c Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 11 Jan 2025 16:53:52 +0300 Subject: [PATCH 184/240] Update dependencies --- go.mod | 16 ++++++++-------- go.sum | 31 ++++++++++++++++--------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index 09c07fd3..6f973d54 100644 --- a/go.mod +++ b/go.mod @@ -11,16 +11,16 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.2 - github.com/minio/minio-go/v7 v7.0.82 + github.com/minio/minio-go/v7 v7.0.83 github.com/pion/ice/v4 v4.0.3 github.com/pion/interceptor v0.1.37 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.6 + github.com/pion/webrtc/v4 v4.0.7 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/veandco/go-sdl2 v0.4.40 - golang.org/x/crypto v0.31.0 - golang.org/x/image v0.19.0 + golang.org/x/crypto v0.32.0 + golang.org/x/image v0.23.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -31,7 +31,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -41,7 +41,7 @@ require ( github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.9 // indirect + github.com/pion/rtp v1.8.10 // indirect github.com/pion/sctp v1.8.35 // indirect github.com/pion/sdp/v3 v3.0.9 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect @@ -52,7 +52,7 @@ require ( github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.32.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index f170316e..631dd4f4 100644 --- a/go.sum +++ b/go.sum @@ -36,16 +36,17 @@ 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/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.82 h1:tWfICLhmp2aFPXL8Tli0XDTHj2VB/fNf0PC1f/i1gRo= -github.com/minio/minio-go/v7 v7.0.82/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= +github.com/minio/minio-go/v7 v7.0.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA= +github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -66,8 +67,8 @@ 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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= -github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= +github.com/pion/rtp v1.8.10 h1:puphjdbjPB+L+NFaVuZ5h6bt1g5q4kFIoI+r5q/g0CU= +github.com/pion/rtp v1.8.10/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA= github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg= github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= @@ -80,8 +81,8 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1 github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.6 h1:OfxfGeZGhneUDnZEoebLGDkzwjowSJ0avbOu2xaIUeM= -github.com/pion/webrtc/v4 v4.0.6/go.mod h1:j7oMHYvjl7lESJ/nYiE4d2URyjFbAo3uqJ6Xse6hbSg= +github.com/pion/webrtc/v4 v4.0.7 h1:aeq78uVnFZd2umXW0O9A2VFQYuS7+BZxWetQvSp2jPo= +github.com/pion/webrtc/v4 v4.0.7/go.mod h1:oFVBBVSHU3vAEwSgnk3BuKCwAUwpDwQhko1EDwyZWbU= 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= @@ -110,17 +111,17 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= -golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= -golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= -golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From ffb0abe4da136eebc66939125d24a28efda67c5e Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 18 Jan 2025 19:41:21 +0300 Subject: [PATCH 185/240] Update dependencies --- go.mod | 8 ++++---- go.sum | 26 ++++++++------------------ 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 6f973d54..2a5dd1b5 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,10 @@ require ( github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.2 github.com/minio/minio-go/v7 v7.0.83 - github.com/pion/ice/v4 v4.0.3 + github.com/pion/ice/v4 v4.0.5 github.com/pion/interceptor v0.1.37 github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.7 + github.com/pion/webrtc/v4 v4.0.8 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/veandco/go-sdl2 v0.4.40 @@ -41,9 +41,9 @@ require ( github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.10 // indirect + github.com/pion/rtp v1.8.11 // indirect github.com/pion/sctp v1.8.35 // indirect - github.com/pion/sdp/v3 v3.0.9 // indirect + github.com/pion/sdp/v3 v3.0.10 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect diff --git a/go.sum b/go.sum index 631dd4f4..a8564760 100644 --- a/go.sum +++ b/go.sum @@ -3,7 +3,6 @@ github.com/VictoriaMetrics/metrics v1.35.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsK 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -55,8 +54,8 @@ github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= -github.com/pion/ice/v4 v4.0.3 h1:9s5rI1WKzF5DRqhJ+Id8bls/8PzM7mau0mj1WZb4IXE= -github.com/pion/ice/v4 v4.0.3/go.mod h1:VfHy0beAZ5loDT7BmJ2LtMtC4dbawIkkkejHPRZNB3Y= +github.com/pion/ice/v4 v4.0.5 h1:6awVfa1jg9YsI9/Lep4TG/o3kwS1Oayr5b8xz50ibJ8= +github.com/pion/ice/v4 v4.0.5/go.mod h1:JJaoEIxUIlGDA9gaRZbwXYqI3j6VG/QchpjX+QmwN6A= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= @@ -67,12 +66,12 @@ 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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.10 h1:puphjdbjPB+L+NFaVuZ5h6bt1g5q4kFIoI+r5q/g0CU= -github.com/pion/rtp v1.8.10/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/rtp v1.8.11 h1:17xjnY5WO5hgO6SD3/NTIUPvSFw/PbLsIJyz1r1yNIk= +github.com/pion/rtp v1.8.11/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA= github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg= -github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= -github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= +github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA= +github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= @@ -81,8 +80,8 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1 github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.7 h1:aeq78uVnFZd2umXW0O9A2VFQYuS7+BZxWetQvSp2jPo= -github.com/pion/webrtc/v4 v4.0.7/go.mod h1:oFVBBVSHU3vAEwSgnk3BuKCwAUwpDwQhko1EDwyZWbU= +github.com/pion/webrtc/v4 v4.0.8 h1:T1ZmnT9qxIJIt4d8XoiMOBrTClGHDDXNg9e/fh018Qc= +github.com/pion/webrtc/v4 v4.0.8/go.mod h1:HHBeUVBAC+j4ZFnYhovEFStF02Arb1EyD4G7e7HBTJw= 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= @@ -93,14 +92,6 @@ 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.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= -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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= @@ -127,6 +118,5 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 15ff2f3282867bffcbf90bdd5a60f493361da23f Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 12 Feb 2025 14:17:43 +0300 Subject: [PATCH 186/240] Update Go to 1.24.0 --- Dockerfile | 2 +- go.mod | 20 ++++++++++---------- go.sum | 40 ++++++++++++++++++++-------------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Dockerfile b/Dockerfile index f00b2ac3..8545a1b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:oracular AS build0 -ARG GO=1.23.3 +ARG GO=1.24.0 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ diff --git a/go.mod b/go.mod index 2a5dd1b5..68dfe632 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module github.com/giongto35/cloud-game/v3 go 1.22 require ( - github.com/VictoriaMetrics/metrics v1.35.1 + github.com/VictoriaMetrics/metrics v1.35.2 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.8.0 - github.com/goccy/go-json v0.10.4 + github.com/goccy/go-json v0.10.5 github.com/gofrs/flock v0.12.1 github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.2 - github.com/minio/minio-go/v7 v7.0.83 - github.com/pion/ice/v4 v4.0.5 + github.com/minio/minio-go/v7 v7.0.85 + github.com/pion/ice/v4 v4.0.6 github.com/pion/interceptor v0.1.37 - github.com/pion/logging v0.2.2 - github.com/pion/webrtc/v4 v4.0.8 + github.com/pion/logging v0.2.3 + github.com/pion/webrtc/v4 v4.0.9 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/veandco/go-sdl2 v0.4.40 - golang.org/x/crypto v0.32.0 + golang.org/x/crypto v0.33.0 golang.org/x/image v0.23.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -52,7 +52,7 @@ require ( github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index a8564760..19f6068e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.35.1 h1:o84wtBKQbzLdDy14XeskkCZih6anG+veZ1SwJHFGwrU= -github.com/VictoriaMetrics/metrics v1.35.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.35.2 h1:Bj6L6ExfnakZKYPpi7mGUnkJP4NGQz2v5wiChhXNyWQ= +github.com/VictoriaMetrics/metrics v1.35.2/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= 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= @@ -13,8 +13,8 @@ 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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= -github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +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.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= @@ -44,8 +44,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.83 h1:W4Kokksvlz3OKf3OqIlzDNKd4MERlC2oN8YptwJ0+GA= -github.com/minio/minio-go/v7 v7.0.83/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= +github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo= +github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -54,12 +54,12 @@ github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= -github.com/pion/ice/v4 v4.0.5 h1:6awVfa1jg9YsI9/Lep4TG/o3kwS1Oayr5b8xz50ibJ8= -github.com/pion/ice/v4 v4.0.5/go.mod h1:JJaoEIxUIlGDA9gaRZbwXYqI3j6VG/QchpjX+QmwN6A= +github.com/pion/ice/v4 v4.0.6 h1:jmM9HwI9lfetQV/39uD0nY4y++XZNPhvzIPCb8EwxUM= +github.com/pion/ice/v4 v4.0.6/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= -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/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= +github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= @@ -80,8 +80,8 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1 github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.8 h1:T1ZmnT9qxIJIt4d8XoiMOBrTClGHDDXNg9e/fh018Qc= -github.com/pion/webrtc/v4 v4.0.8/go.mod h1:HHBeUVBAC+j4ZFnYhovEFStF02Arb1EyD4G7e7HBTJw= +github.com/pion/webrtc/v4 v4.0.9 h1:PyOYMRKJgfy0dzPcYtFD/4oW9zaw3Ze3oZzzbj2LV9E= +github.com/pion/webrtc/v4 v4.0.9/go.mod h1:ViHLVaNpiuvaH8pdiuQxuA9awuE6KVzAXx3vVWilOck= 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= @@ -102,19 +102,19 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From a1506d0f31a9b10446a18de1bb66ec138db09d8b Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 13 Feb 2025 01:00:13 +0300 Subject: [PATCH 187/240] Add PGO with 1.24.0 --- cmd/worker/default.pgo | Bin 0 -> 50275 bytes go.mod | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 cmd/worker/default.pgo diff --git a/cmd/worker/default.pgo b/cmd/worker/default.pgo new file mode 100644 index 0000000000000000000000000000000000000000..c659757c8a0ce417a264ff653257726397442835 GIT binary patch literal 50275 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 literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod index 68dfe632..04e2ffd5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/giongto35/cloud-game/v3 -go 1.22 +go 1.24 require ( github.com/VictoriaMetrics/metrics v1.35.2 From 7c878b1ee3bb3de74592d48ba8dbd7bb5f2919ab Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 15 Mar 2025 11:21:28 +0300 Subject: [PATCH 188/240] Update dependencies --- Dockerfile | 2 +- go.mod | 29 ++++++++++++++------------- go.sum | 58 ++++++++++++++++++++++++++++-------------------------- 3 files changed, 46 insertions(+), 43 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8545a1b0..1b34737c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:oracular AS build0 -ARG GO=1.24.0 +ARG GO=1.24.1 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ diff --git a/go.mod b/go.mod index 04e2ffd5..3c220273 100644 --- a/go.mod +++ b/go.mod @@ -11,16 +11,16 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.1 github.com/knadh/koanf/v2 v2.1.2 - github.com/minio/minio-go/v7 v7.0.85 - github.com/pion/ice/v4 v4.0.6 + github.com/minio/minio-go/v7 v7.0.88 + github.com/pion/ice/v4 v4.0.7 github.com/pion/interceptor v0.1.37 github.com/pion/logging v0.2.3 - github.com/pion/webrtc/v4 v4.0.9 + github.com/pion/webrtc/v4 v4.0.13 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.33.0 github.com/veandco/go-sdl2 v0.4.40 - golang.org/x/crypto v0.33.0 - golang.org/x/image v0.23.0 + golang.org/x/crypto v0.36.0 + golang.org/x/image v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,21 +29,22 @@ require ( github.com/go-ini/ini v1.67.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/minio/crc64nvme v1.0.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.4 // indirect + github.com/pion/dtls/v3 v3.0.5 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.11 // indirect - github.com/pion/sctp v1.8.35 // indirect - github.com/pion/sdp/v3 v3.0.10 // indirect + github.com/pion/rtp v1.8.13 // indirect + github.com/pion/sctp v1.8.37 // indirect + github.com/pion/sdp/v3 v3.0.11 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect @@ -52,7 +53,7 @@ require ( github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index 19f6068e..8f94cf6a 100644 --- a/go.sum +++ b/go.sum @@ -22,11 +22,11 @@ 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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= @@ -42,20 +42,22 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= +github.com/minio/crc64nvme v1.0.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.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo= -github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY= +github.com/minio/minio-go/v7 v7.0.88 h1:v8MoIJjwYxOkehp+eiLIuvXk87P2raUtoU5klrAAshs= +github.com/minio/minio-go/v7 v7.0.88/go.mod h1:33+O8h0tO7pCeCWwBVa07RhVVfB/3vS4kEX7rwYKmIg= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/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.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= -github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= -github.com/pion/ice/v4 v4.0.6 h1:jmM9HwI9lfetQV/39uD0nY4y++XZNPhvzIPCb8EwxUM= -github.com/pion/ice/v4 v4.0.6/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/dtls/v3 v3.0.5 h1:OGWLu21/Wc5+H8R75F1BWvedH7H+nYUPFzJOew4k1iA= +github.com/pion/dtls/v3 v3.0.5/go.mod h1:JVCnfmbgq45QoU07AaxFbdjF2iomKzYouVNy+W5kqmY= +github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= +github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= @@ -66,12 +68,12 @@ 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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.11 h1:17xjnY5WO5hgO6SD3/NTIUPvSFw/PbLsIJyz1r1yNIk= -github.com/pion/rtp v1.8.11/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= -github.com/pion/sctp v1.8.35 h1:qwtKvNK1Wc5tHMIYgTDJhfZk7vATGVHhXbUDfHbYwzA= -github.com/pion/sctp v1.8.35/go.mod h1:EcXP8zCYVTRy3W9xtOF7wJm1L1aXfKRQzaM33SjQlzg= -github.com/pion/sdp/v3 v3.0.10 h1:6MChLE/1xYB+CjumMw+gZ9ufp2DPApuVSnDT8t5MIgA= -github.com/pion/sdp/v3 v3.0.10/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= +github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= +github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= +github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= +github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= @@ -80,8 +82,8 @@ github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1 github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.9 h1:PyOYMRKJgfy0dzPcYtFD/4oW9zaw3Ze3oZzzbj2LV9E= -github.com/pion/webrtc/v4 v4.0.9/go.mod h1:ViHLVaNpiuvaH8pdiuQxuA9awuE6KVzAXx3vVWilOck= +github.com/pion/webrtc/v4 v4.0.13 h1:XuUaWTjRufsiGJRC+G71OgiSMe7tl7mQ0kkd4bAqIaQ= +github.com/pion/webrtc/v4 v4.0.13/go.mod h1:Fadzxm0CbY99YdCEfxrgiVr0L4jN1l8bf8DBkPPpJbs= 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= @@ -102,19 +104,19 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 3ac7a559df93559d3c88ebe79e9997a91c9f5652 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 17 Apr 2025 08:53:40 +0300 Subject: [PATCH 189/240] Skip YUV test It is broken on Windows --- pkg/encoder/yuv/yuv_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/encoder/yuv/yuv_test.go b/pkg/encoder/yuv/yuv_test.go index 75a61bc2..e4e74b30 100644 --- a/pkg/encoder/yuv/yuv_test.go +++ b/pkg/encoder/yuv/yuv_test.go @@ -18,6 +18,7 @@ import ( ) func TestYuvPredefined(t *testing.T) { + t.Skip("Skipped because on Windows some colors are different") im := []uint8{101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 101, 0, 106, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255, 18, 226, 78, 255} should := []byte{ 52, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, 142, From 410610349bfa55ab3c6a2f54f8e49a711bd22299 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 17 Apr 2025 08:56:47 +0300 Subject: [PATCH 190/240] Switch to UCRT toolchain in MSYS2 --- .github/workflows/build.yml | 22 ++++++++++++---------- README.md | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81225ca7..66b0b414 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,19 +50,19 @@ jobs: - 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-libx264 - 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 - name: Windows if: matrix.os == 'windows-latest' @@ -71,6 +71,8 @@ jobs: MESA_GL_VERSION_OVERRIDE: 3.3COMPAT shell: msys2 {0} run: | + set MSYSTEM=UCRT64 + wget -q https://github.com/pal1000/mesa-dist-win/releases/download/$MESA_VERSION/mesa3d-$MESA_VERSION-release-msvc.7z "/c/Program Files/7-Zip/7z.exe" x mesa3d-$MESA_VERSION-release-msvc.7z -omesa echo -e " 1\r\n 9\r\n " >> commands diff --git a/README.md b/README.md index 24c5a405..1f357d4a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo # Windows (MSYS2) -pacman -Sy --noconfirm --needed git make mingw-w64-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,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} ``` (You don't need to download libyuv on macOS) From debd4b23dfcf147dfa12160c87d7f33b51a84598 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 17 Apr 2025 08:57:32 +0300 Subject: [PATCH 191/240] Disable default static build It is broken with SDL2 on Windows --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 70af44de..1fbe81de 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ROOT = ${REPO_ROOT}/${PROJECT} CGO_CFLAGS='-g -O3' CGO_LDFLAGS='-g -O3' -GO_TAGS=static +GO_TAGS= .PHONY: clean test From a431b7050fca8b179cd1b6876c13c24c2447b08c Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 17 Apr 2025 08:58:50 +0300 Subject: [PATCH 192/240] Update dependencies --- Dockerfile | 2 +- go.mod | 26 +++++++++++++------------- go.sum | 26 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1b34737c..94e61bc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:oracular AS build0 -ARG GO=1.24.1 +ARG GO=1.24.2 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ diff --git a/go.mod b/go.mod index 3c220273..88a4a581 100644 --- a/go.mod +++ b/go.mod @@ -5,21 +5,21 @@ go 1.24 require ( github.com/VictoriaMetrics/metrics v1.35.2 github.com/cavaliergopher/grab/v3 v3.0.1 - github.com/fsnotify/fsnotify v1.8.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 github.com/gofrs/flock v0.12.1 github.com/gorilla/websocket v1.5.3 - github.com/knadh/koanf/maps v0.1.1 - github.com/knadh/koanf/v2 v2.1.2 - github.com/minio/minio-go/v7 v7.0.88 - github.com/pion/ice/v4 v4.0.7 + github.com/knadh/koanf/maps v0.1.2 + github.com/knadh/koanf/v2 v2.2.0 + github.com/minio/minio-go/v7 v7.0.90 + github.com/pion/ice/v4 v4.0.10 github.com/pion/interceptor v0.1.37 github.com/pion/logging v0.2.3 - github.com/pion/webrtc/v4 v4.0.13 + github.com/pion/webrtc/v4 v4.0.15 github.com/rs/xid v1.6.0 - github.com/rs/zerolog v1.33.0 + github.com/rs/zerolog v1.34.0 github.com/veandco/go-sdl2 v0.4.40 - golang.org/x/crypto v0.36.0 + golang.org/x/crypto v0.37.0 golang.org/x/image v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -38,12 +38,12 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.5 // indirect + github.com/pion/dtls/v3 v3.0.6 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect github.com/pion/rtp v1.8.13 // indirect - github.com/pion/sctp v1.8.37 // indirect + github.com/pion/sctp v1.8.38 // indirect github.com/pion/sdp/v3 v3.0.11 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/stun/v3 v3.0.0 // indirect @@ -53,7 +53,7 @@ require ( github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.37.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 8f94cf6a..e5b53776 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= @@ -29,8 +31,12 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2 github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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/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.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU= +github.com/knadh/koanf/v2 v2.2.0/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= 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= @@ -48,6 +54,8 @@ 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.88 h1:v8MoIJjwYxOkehp+eiLIuvXk87P2raUtoU5klrAAshs= github.com/minio/minio-go/v7 v7.0.88/go.mod h1:33+O8h0tO7pCeCWwBVa07RhVVfB/3vS4kEX7rwYKmIg= +github.com/minio/minio-go/v7 v7.0.90 h1:TmSj1083wtAD0kEYTx7a5pFsv3iRYMsOJ6A4crjA1lE= +github.com/minio/minio-go/v7 v7.0.90/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -56,8 +64,12 @@ github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.5 h1:OGWLu21/Wc5+H8R75F1BWvedH7H+nYUPFzJOew4k1iA= github.com/pion/dtls/v3 v3.0.5/go.mod h1:JVCnfmbgq45QoU07AaxFbdjF2iomKzYouVNy+W5kqmY= +github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= +github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= +github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= @@ -72,6 +84,8 @@ github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/sctp v1.8.38 h1:rntHxO7CyH8jeqC/bkuirl2uJ+BqTp2uxhisi5AYPRQ= +github.com/pion/sctp v1.8.38/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= @@ -84,6 +98,8 @@ github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= github.com/pion/webrtc/v4 v4.0.13 h1:XuUaWTjRufsiGJRC+G71OgiSMe7tl7mQ0kkd4bAqIaQ= github.com/pion/webrtc/v4 v4.0.13/go.mod h1:Fadzxm0CbY99YdCEfxrgiVr0L4jN1l8bf8DBkPPpJbs= +github.com/pion/webrtc/v4 v4.0.15 h1:DWuBtTHBa9rQNqyhW+jptkq6r3zdGqr1OQ4pa2Q+Ey4= +github.com/pion/webrtc/v4 v4.0.15/go.mod h1:RXf6sJ8FUX+qwF4+AwB+A3c2Y6WpuATRTe4L/fTWNa4= 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= @@ -94,6 +110,8 @@ 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.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= @@ -106,17 +124,25 @@ 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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From ddfc9249ecee3daa61525fcf68db2c707a98e7b4 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 2 May 2025 10:06:23 +0300 Subject: [PATCH 193/240] 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. --- pkg/coordinator/hub.go | 9 +- pkg/coordinator/user.go | 5 +- pkg/coordinator/worker.go | 40 ++++++- pkg/coordinator/worker_test.go | 193 +++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 pkg/coordinator/worker_test.go diff --git a/pkg/coordinator/hub.go b/pkg/coordinator/hub.go index 8b33b00d..f4a1398c 100644 --- a/pkg/coordinator/hub.go +++ b/pkg/coordinator/hub.go @@ -68,7 +68,14 @@ func (h *Hub) handleUserConnection() http.HandlerFunc { h.log.Info().Msg("no free workers") return } - user.Bind(worker) + + bound := user.Bind(worker) + if !bound { + user.Notify(api.ErrNoFreeSlots, "") + h.log.Info().Msg("no free slots") + return + } + h.users.Add(user) apps := worker.AppNames() diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go index a6865d75..5d1e7ed5 100644 --- a/pkg/coordinator/user.go +++ b/pkg/coordinator/user.go @@ -28,9 +28,10 @@ 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() + + return u.w.TryReserve() } func (u *User) Disconnect() { diff --git a/pkg/coordinator/worker.go b/pkg/coordinator/worker.go index a00ad226..f4b2b2d0 100644 --- a/pkg/coordinator/worker.go +++ b/pkg/coordinator/worker.go @@ -135,6 +135,11 @@ func (w *Worker) AppNames() []api.GameInfo { } 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{}{} } @@ -159,13 +164,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 + } } } diff --git a/pkg/coordinator/worker_test.go b/pkg/coordinator/worker_test.go new file mode 100644 index 00000000..a08b8b71 --- /dev/null +++ b/pkg/coordinator/worker_test.go @@ -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 i := 0; i < workers; i++ { + 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 i := 0; i < workers; i++ { + 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") + } +} From 9d4256306ee5894b9817e5c203768b7db597a5d0 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 2 May 2025 10:34:22 +0300 Subject: [PATCH 194/240] Add missing C function header for Go debugger --- pkg/encoder/yuv/libyuv/libyuv.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/encoder/yuv/libyuv/libyuv.go b/pkg/encoder/yuv/libyuv/libyuv.go index bf7bbd66..0848c095 100644 --- a/pkg/encoder/yuv/libyuv/libyuv.go +++ b/pkg/encoder/yuv/libyuv/libyuv.go @@ -58,6 +58,20 @@ 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" { From 37a4a8099658d549c314c819cce55721946a6885 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 3 May 2025 15:55:26 +0300 Subject: [PATCH 195/240] Use the save state size before each save/load call --- pkg/worker/caged/libretro/frontend_test.go | 23 +++++++----- .../caged/libretro/nanoarch/nanoarch.go | 36 ++++++++++++------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index f2b108ee..fda0ecbe 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -48,6 +48,7 @@ var ( alwa = game{system: "nes", rom: "nes/Alwa's Awakening (Demo).nes"} sushi = game{system: "gba", rom: "gba/Sushi The Cat.gba"} angua = game{system: "gba", rom: "gba/anguna.gba"} + rogue = game{system: "dos", rom: "dos/rogue.zip"} ) // TestMain runs all tests in the main thread in macOS. @@ -176,6 +177,13 @@ func (emu *TestFrontend) dumpState() (cur string, prev string) { return } +func (emu *TestFrontend) save() ([]byte, error) { + emu.mu.Lock() + defer emu.mu.Unlock() + + return nanoarch.SaveState() +} + func BenchmarkEmulators(b *testing.B) { log.SetOutput(io.Discard) os.Stdout, _ = os.Open(os.DevNull) @@ -204,6 +212,7 @@ func TestSavePersistence(t *testing.T) { tests := []testRun{ {system: sushi.system, rom: sushi.rom, frames: 100}, {system: angua.system, rom: angua.rom, frames: 100}, + {system: rogue.system, rom: rogue.rom, frames: 200}, } for _, test := range tests { @@ -215,14 +224,12 @@ func TestSavePersistence(t *testing.T) { test.frames-- } - _, _ = front.dumpState() - if err := front.Save(); err != nil { - t.Error(err) - } - - hash1, hash2 := front.dumpState() - if hash1 != hash2 { - t.Errorf("%v != %v", hash1, hash2) + for range 10 { + v, _ := front.save() + if v == nil || len(v) == 0 { + t.Errorf("couldn't persist the state") + t.Fail() + } } front.Shutdown() diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 1748ec15..1aca8506 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -495,32 +495,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 && !Nan0.hackSkipSameThreadSave { - rez = *(*bool)(C.same_thread_with_args2(retroSerialize, C.int(CallSerialize), unsafe.Pointer(&data[0]), unsafe.Pointer(&Nan0.serializeSize))) + 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.bridge_retro_serialize_size(retroSerializeSize) + 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 } From 83056bbf4f82354018c6226a5e3f7938f03bb5c0 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 3 May 2025 15:57:41 +0300 Subject: [PATCH 196/240] Update dependencies --- go.mod | 12 ++++++------ go.sum | 51 ++++++++++++--------------------------------------- 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 88a4a581..4df5c637 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/giongto35/cloud-game/v3 go 1.24 require ( - github.com/VictoriaMetrics/metrics v1.35.2 + github.com/VictoriaMetrics/metrics v1.35.4 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 @@ -11,11 +11,11 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.2 github.com/knadh/koanf/v2 v2.2.0 - github.com/minio/minio-go/v7 v7.0.90 + github.com/minio/minio-go/v7 v7.0.91 github.com/pion/ice/v4 v4.0.10 github.com/pion/interceptor v0.1.37 github.com/pion/logging v0.2.3 - github.com/pion/webrtc/v4 v4.0.15 + github.com/pion/webrtc/v4 v4.1.0 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 github.com/veandco/go-sdl2 v0.4.40 @@ -42,13 +42,13 @@ require ( github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.13 // indirect - github.com/pion/sctp v1.8.38 // indirect + github.com/pion/rtp v1.8.15 // indirect + github.com/pion/sctp v1.8.39 // indirect github.com/pion/sdp/v3 v3.0.11 // indirect github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.0 // indirect + github.com/pion/turn/v4 v4.0.1 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect diff --git a/go.sum b/go.sum index e5b53776..6ebe2c6d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.35.2 h1:Bj6L6ExfnakZKYPpi7mGUnkJP4NGQz2v5wiChhXNyWQ= -github.com/VictoriaMetrics/metrics v1.35.2/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.35.4 h1:GhZ17bHYkujpSzGaH459F7huTLNJ7z90ZXsOXjhHx/8= +github.com/VictoriaMetrics/metrics v1.35.4/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= 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= @@ -7,8 +7,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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= @@ -29,12 +27,8 @@ github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYW github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -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/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.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= -github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU= github.com/knadh/koanf/v2 v2.2.0/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -52,22 +46,16 @@ github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY github.com/minio/crc64nvme v1.0.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.88 h1:v8MoIJjwYxOkehp+eiLIuvXk87P2raUtoU5klrAAshs= -github.com/minio/minio-go/v7 v7.0.88/go.mod h1:33+O8h0tO7pCeCWwBVa07RhVVfB/3vS4kEX7rwYKmIg= -github.com/minio/minio-go/v7 v7.0.90 h1:TmSj1083wtAD0kEYTx7a5pFsv3iRYMsOJ6A4crjA1lE= -github.com/minio/minio-go/v7 v7.0.90/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go= +github.com/minio/minio-go/v7 v7.0.91 h1:tWLZnEfo3OZl5PoXQwcwTAPNNrjyWwOh6cbZitW5JQc= +github.com/minio/minio-go/v7 v7.0.91/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/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.5 h1:OGWLu21/Wc5+H8R75F1BWvedH7H+nYUPFzJOew4k1iA= -github.com/pion/dtls/v3 v3.0.5/go.mod h1:JVCnfmbgq45QoU07AaxFbdjF2iomKzYouVNy+W5kqmY= github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= -github.com/pion/ice/v4 v4.0.7 h1:mnwuT3n3RE/9va41/9QJqN5+Bhc0H/x/ZyiVlWMw35M= -github.com/pion/ice/v4 v4.0.7/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= @@ -80,12 +68,10 @@ 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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.13 h1:8uSUPpjSL4OlwZI8Ygqu7+h2p9NPFB+yAZ461Xn5sNg= -github.com/pion/rtp v1.8.13/go.mod h1:8uMBJj32Pa1wwx8Fuv/AsFhn8jsgw+3rUC2PfoBZ8p4= -github.com/pion/sctp v1.8.37 h1:ZDmGPtRPX9mKCiVXtMbTWybFw3z/hVKAZgU81wcOrqs= -github.com/pion/sctp v1.8.37/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sctp v1.8.38 h1:rntHxO7CyH8jeqC/bkuirl2uJ+BqTp2uxhisi5AYPRQ= -github.com/pion/sctp v1.8.38/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/rtp v1.8.15 h1:MuhuGn1cxpVCPLNY1lI7F1tQ8Spntpgf12ob+pOYT8s= +github.com/pion/rtp v1.8.15/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= +github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= @@ -94,22 +80,17 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= -github.com/pion/webrtc/v4 v4.0.13 h1:XuUaWTjRufsiGJRC+G71OgiSMe7tl7mQ0kkd4bAqIaQ= -github.com/pion/webrtc/v4 v4.0.13/go.mod h1:Fadzxm0CbY99YdCEfxrgiVr0L4jN1l8bf8DBkPPpJbs= -github.com/pion/webrtc/v4 v4.0.15 h1:DWuBtTHBa9rQNqyhW+jptkq6r3zdGqr1OQ4pa2Q+Ey4= -github.com/pion/webrtc/v4 v4.0.15/go.mod h1:RXf6sJ8FUX+qwF4+AwB+A3c2Y6WpuATRTe4L/fTWNa4= +github.com/pion/turn/v4 v4.0.1 h1:01UTBhYToe8PDC8piB++i66q1mmctfhhoeguaFqB84c= +github.com/pion/turn/v4 v4.0.1/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= +github.com/pion/webrtc/v4 v4.1.0 h1:yq/p0G5nKGbHISf0YKNA8Yk+kmijbblBvuSLwaJ4QYg= +github.com/pion/webrtc/v4 v4.1.0/go.mod h1:cgEGkcpxGkT6Di2ClBYO5lP9mFXbCfEOrkYUpjjCQO4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 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.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -122,25 +103,17 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 36da07f277a0880db82e8d1ec7a81a97133c3c20 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 3 May 2025 16:34:18 +0300 Subject: [PATCH 197/240] Use the actual state size when loading ROMs --- pkg/worker/caged/libretro/nanoarch/nanoarch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 1aca8506..1f32af3a 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -518,7 +518,7 @@ func RestoreSaveState(st State) error { return errors.New("empty load state") } - size := C.bridge_retro_serialize_size(retroSerializeSize) + size := C.size_t(len(st)) rez := false if Nan0.LibCo { From 817a19c757c08b240da34633f143b14b8e9beda7 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 16 May 2025 12:15:37 +0300 Subject: [PATCH 198/240] Fix the circle-pad's roundness --- web/css/main.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/css/main.css b/web/css/main.css index e01eb372..4b95690d 100644 --- a/web/css/main.css +++ b/web/css/main.css @@ -100,7 +100,7 @@ body { #circle-pad { display: block; - width: 70px; + width: 69px; height: 70px; position: absolute; background-size: contain; From 02210f1f8d4015821533a12f7eebd9d9e5425659 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 18 May 2025 12:46:32 +0300 Subject: [PATCH 199/240] Remove unnecessary C bridge functions --- pkg/worker/caged/libretro/nanoarch/nanoarch.c | 52 ++++--------------- .../caged/libretro/nanoarch/nanoarch.go | 52 ++++++++----------- pkg/worker/caged/libretro/nanoarch/nanoarch.h | 12 ++--- 3 files changed, 35 insertions(+), 81 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index cb474d98..6290150c 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -36,14 +36,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) { @@ -62,44 +60,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_reset(void *f) { - ((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); } @@ -173,8 +141,6 @@ 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) { @@ -182,16 +148,16 @@ int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, uns return coreInputState(port, device, index, id); } -void core_audio_sample_cgo(int16_t left, int16_t right) { - void coreAudioSample(int16_t, int16_t); - coreAudioSample(left, right); -} - size_t core_audio_sample_batch_cgo(const int16_t *data, size_t frames) { size_t coreAudioSampleBatch(const int16_t *, size_t); 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(); diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 1f32af3a..fd12ef14 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -49,8 +49,9 @@ type Nanoarch struct { serializeSize C.size_t Stopped atomic.Bool sys struct { - av C.struct_retro_system_av_info - i C.struct_retro_system_info + av C.struct_retro_system_av_info + i C.struct_retro_system_info + api C.unsigned } tickTime int64 cSaveDirectory *C.char @@ -213,7 +214,7 @@ func (n *Nanoarch) CoreLoad(meta Metadata) { 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") @@ -234,22 +235,24 @@ 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) } + n.sys.api = C.bridge_retro_api_version(retroAPIVersion) C.bridge_retro_get_system_info(retroGetSystemInfo, &n.sys.i) - n.log.Debug().Msgf("System >>> %v (%v) [%v] nfp: %v", + 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)) + C.GoString(n.sys.i.valid_extensions), bool(n.sys.i.need_fullpath), + uint(n.sys.api)) } func (n *Nanoarch) LoadGame(path string) error { @@ -367,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() @@ -390,7 +393,7 @@ func (n *Nanoarch) Shutdown() { } func (n *Nanoarch) Reset() { - C.bridge_retro_reset(retroReset) + C.bridge_call(retroReset) } func (n *Nanoarch) Run() { @@ -404,7 +407,7 @@ func (n *Nanoarch) Run() { 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() } @@ -553,19 +556,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 } @@ -595,7 +598,7 @@ func (m Metadata) HasHack(h string) bool { } var ( - //retroAPIVersion unsafe.Pointer + retroAPIVersion unsafe.Pointer retroDeinit unsafe.Pointer retroGetSystemAVInfo unsafe.Pointer retroGetSystemInfo unsafe.Pointer @@ -669,9 +672,6 @@ 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 { //Nan0.log.Debug().Msgf("%v %v %v %v", port, device, index, id) @@ -725,12 +725,6 @@ func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.u return Released } -//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() { diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.h b/pkg/worker/caged/libretro/nanoarch/nanoarch.h index 1ad85f08..d8e09265 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.h +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.h @@ -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,10 @@ 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_reset(void *f); -void bridge_retro_run(void *f); -void bridge_retro_set_audio_sample(void *f, void *callback); -void bridge_retro_set_audio_sample_batch(void *f, void *callback); void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned device); -void bridge_retro_set_input_poll(void *f, void *callback); void bridge_retro_set_input_state(void *f, void *callback); -void bridge_retro_set_video_refresh(void *f, void *callback); void bridge_retro_keyboard_callback(void *f, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers); bool core_environment_cgo(unsigned cmd, void *data); From 8083ba086b1137734980e7aabcb3eaa1745b64ca Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 28 May 2025 08:19:02 +0300 Subject: [PATCH 200/240] Use /usr/bin/env in the shebang line of shell scripts to ensure portability --- scripts/mkdirs.sh | 2 +- scripts/version.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/mkdirs.sh b/scripts/mkdirs.sh index 93e975b1..2ddfb767 100755 --- a/scripts/mkdirs.sh +++ b/scripts/mkdirs.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env sh app="$1" diff --git a/scripts/version.sh b/scripts/version.sh index 8a33daa6..3e273791 100755 --- a/scripts/version.sh +++ b/scripts/version.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/usr/bin/env sh file="$1" version="$2" From d8eed66a1da8e72c0ab662484bf97b2eb82164f0 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 28 May 2025 15:30:51 +0300 Subject: [PATCH 201/240] Update dependencies --- go.mod | 32 +++++++++++++------------ go.sum | 76 +++++++++++++++++++++++++++++++++++++++------------------- 2 files changed, 69 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 4df5c637..0ef958d6 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module github.com/giongto35/cloud-game/v3 go 1.24 require ( - github.com/VictoriaMetrics/metrics v1.35.4 + github.com/VictoriaMetrics/metrics v1.38.0 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 github.com/gofrs/flock v0.12.1 github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.2 - github.com/knadh/koanf/v2 v2.2.0 - github.com/minio/minio-go/v7 v7.0.91 + github.com/knadh/koanf/v2 v2.2.1 + github.com/minio/minio-go/v7 v7.0.94 github.com/pion/ice/v4 v4.0.10 - github.com/pion/interceptor v0.1.37 + github.com/pion/interceptor v0.1.40 github.com/pion/logging v0.2.3 - github.com/pion/webrtc/v4 v4.1.0 + github.com/pion/webrtc/v4 v4.1.2 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.37.0 + golang.org/x/crypto v0.39.0 golang.org/x/image v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -27,33 +27,35 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/minio/crc64nvme v1.0.1 // indirect + github.com/minio/crc64nvme v1.0.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.15 // indirect + github.com/pion/rtp v1.8.19 // indirect github.com/pion/sctp v1.8.39 // indirect - github.com/pion/sdp/v3 v3.0.11 // indirect - github.com/pion/srtp/v3 v3.0.4 // indirect + github.com/pion/sdp/v3 v3.0.14 // indirect + github.com/pion/srtp/v3 v3.0.6 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.0.2 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index 6ebe2c6d..5ca2900f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -github.com/VictoriaMetrics/metrics v1.35.4 h1:GhZ17bHYkujpSzGaH459F7huTLNJ7z90ZXsOXjhHx/8= -github.com/VictoriaMetrics/metrics v1.35.4/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.37.0 h1:u5Yr+HFofQyn7kgmmkufgkX0nEA6G1oEyK2eaKsVaUM= +github.com/VictoriaMetrics/metrics v1.37.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.38.0 h1:1d0dRgVH8Nnu8dKMfisKefPC3q7gqf3/odyO0quAvyA= +github.com/VictoriaMetrics/metrics v1.38.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= 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= @@ -13,6 +15,8 @@ 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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.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= @@ -31,6 +35,8 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU= github.com/knadh/koanf/v2 v2.2.0/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= +github.com/knadh/koanf/v2 v2.2.1 h1:jaleChtw85y3UdBnI0wCqcg1sj1gPoz6D3caGNHtrNE= +github.com/knadh/koanf/v2 v2.2.1/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= 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= @@ -42,24 +48,30 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= -github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= +github.com/minio/crc64nvme v1.0.2/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.91 h1:tWLZnEfo3OZl5PoXQwcwTAPNNrjyWwOh6cbZitW5JQc= -github.com/minio/minio-go/v7 v7.0.91/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go= +github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw= +github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0= +github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM= +github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/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.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= -github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= +github.com/pion/interceptor v0.1.38 h1:Mgt3XIIq47uR5vcLLahfRucE6tFPjxHak+z5ZZFEzLU= +github.com/pion/interceptor v0.1.38/go.mod h1:HS9X+Ue5LDE6q2C2tuvOuO83XkBdJFgn6MBDtfoJX4Q= +github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= +github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= @@ -68,22 +80,30 @@ 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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.15 h1:MuhuGn1cxpVCPLNY1lI7F1tQ8Spntpgf12ob+pOYT8s= -github.com/pion/rtp v1.8.15/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/rtp v1.8.16 h1:0mpfguLyN9HCpPIXcoOho4BkMsz5eB1Yjvf+obI5cEQ= +github.com/pion/rtp v1.8.16/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c= +github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.11 h1:VhgVSopdsBKwhCFoyyPmT1fKMeV9nLMrEKxNOdy3IVI= -github.com/pion/sdp/v3 v3.0.11/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4= +github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= +github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= +github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= +github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.1 h1:01UTBhYToe8PDC8piB++i66q1mmctfhhoeguaFqB84c= -github.com/pion/turn/v4 v4.0.1/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= -github.com/pion/webrtc/v4 v4.1.0 h1:yq/p0G5nKGbHISf0YKNA8Yk+kmijbblBvuSLwaJ4QYg= -github.com/pion/webrtc/v4 v4.1.0/go.mod h1:cgEGkcpxGkT6Di2ClBYO5lP9mFXbCfEOrkYUpjjCQO4= +github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= +github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= +github.com/pion/webrtc/v4 v4.1.1 h1:PMFPtLg1kpD2pVtun+LGUzA3k54JdFl87WO0Z1+HKug= +github.com/pion/webrtc/v4 v4.1.1/go.mod h1:cgEGkcpxGkT6Di2ClBYO5lP9mFXbCfEOrkYUpjjCQO4= +github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= +github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= 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= @@ -95,6 +115,8 @@ 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= 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= @@ -103,19 +125,25 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 42b003db6293f8d9b832dd1312b661e1f9218cca Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 20 Jun 2025 18:12:47 +0300 Subject: [PATCH 202/240] 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. --- pkg/config/config.yaml | 3 +++ pkg/config/emulator.go | 1 + pkg/worker/caged/libretro/caged.go | 8 ++++++++ pkg/worker/caged/libretro/frontend.go | 5 +++++ pkg/worker/caged/libretro/graphics/sdl.go | 9 +++++++++ pkg/worker/caged/libretro/nanoarch/nanoarch.go | 1 + 6 files changed, 27 insertions(+) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 33eb0b2a..1a1d2803 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -137,6 +137,9 @@ emulator: # path for storing emulator generated files localPath: "./libretro" + # checks if the system supports running an emulator at startup + failFast: true + libretro: # use zip compression for emulator save states saveCompression: true diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index d3daca3e..013225f7 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -9,6 +9,7 @@ import ( ) type Emulator struct { + FailFast bool Threads int Storage string LocalPath string diff --git a/pkg/worker/caged/libretro/caged.go b/pkg/worker/caged/libretro/caged.go index 8c06776b..3d21db11 100644 --- a/pkg/worker/caged/libretro/caged.go +++ b/pkg/worker/caged/libretro/caged.go @@ -31,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 } @@ -92,3 +99,4 @@ 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() } diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index c3666e98..8d0a73ac 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -13,6 +13,7 @@ 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" ) @@ -422,6 +423,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) diff --git a/pkg/worker/caged/libretro/graphics/sdl.go b/pkg/worker/caged/libretro/graphics/sdl.go index d0df2c1d..7c25efbd 100644 --- a/pkg/worker/caged/libretro/graphics/sdl.go +++ b/pkg/worker/caged/libretro/graphics/sdl.go @@ -76,6 +76,15 @@ func NewSDLContext(cfg Config, log *logger.Logger) (*SDL, error) { return &display, nil } +// TryInit check weather SDL context can be created on the system. +func TryInit() error { + if err := sdl.Init(sdl.INIT_VIDEO); err != nil { + return fmt.Errorf("SDL init fail: %w", err) + } + sdl.Quit() + return nil +} + // Deinit destroys SDL/OpenGL context. // Uses main thread lock (see thread/mainthread). func (s *SDL) Deinit() error { diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index fd12ef14..e26ee07d 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -414,6 +414,7 @@ func (n *Nanoarch) Run() { } } +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.Input(port, data) } From e03fbadcaa422e2e30af57022ae51ce50b9eb67c Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 8 Aug 2025 19:41:17 +0300 Subject: [PATCH 203/240] 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 --- go.mod | 34 +++++++++++----------- go.sum | 90 ++++++++++++++++++++++------------------------------------ 2 files changed, 51 insertions(+), 73 deletions(-) diff --git a/go.mod b/go.mod index 0ef958d6..3428336f 100644 --- a/go.mod +++ b/go.mod @@ -3,59 +3,59 @@ module github.com/giongto35/cloud-game/v3 go 1.24 require ( - github.com/VictoriaMetrics/metrics v1.38.0 + github.com/VictoriaMetrics/metrics v1.39.1 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 github.com/gofrs/flock v0.12.1 github.com/gorilla/websocket v1.5.3 github.com/knadh/koanf/maps v0.1.2 - github.com/knadh/koanf/v2 v2.2.1 - github.com/minio/minio-go/v7 v7.0.94 + github.com/knadh/koanf/v2 v2.2.2 + github.com/minio/minio-go/v7 v7.0.95 github.com/pion/ice/v4 v4.0.10 github.com/pion/interceptor v0.1.40 - github.com/pion/logging v0.2.3 - github.com/pion/webrtc/v4 v4.1.2 + github.com/pion/logging v0.2.4 + github.com/pion/webrtc/v4 v4.1.3 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.39.0 - golang.org/x/image v0.25.0 + golang.org/x/crypto v0.41.0 + golang.org/x/image v0.30.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-viper/mapstructure/v2 v2.3.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.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.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.0.2 // indirect + github.com/minio/crc64nvme v1.1.0 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect github.com/pion/mdns/v2 v2.0.7 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.19 // indirect + github.com/pion/rtp v1.8.21 // indirect github.com/pion/sctp v1.8.39 // indirect - github.com/pion/sdp/v3 v3.0.14 // indirect + github.com/pion/sdp/v3 v3.0.15 // indirect github.com/pion/srtp/v3 v3.0.6 // indirect github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.0.2 // indirect + github.com/pion/turn/v4 v4.1.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 5ca2900f..1ab980c2 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.37.0 h1:u5Yr+HFofQyn7kgmmkufgkX0nEA6G1oEyK2eaKsVaUM= -github.com/VictoriaMetrics/metrics v1.37.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= -github.com/VictoriaMetrics/metrics v1.38.0 h1:1d0dRgVH8Nnu8dKMfisKefPC3q7gqf3/odyO0quAvyA= -github.com/VictoriaMetrics/metrics v1.38.0/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= +github.com/VictoriaMetrics/metrics v1.39.1 h1:AT7jz7oSpAK9phDl5O5Tmy06nXnnzALwqVnf4ros3Ow= +github.com/VictoriaMetrics/metrics v1.39.1/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= @@ -13,10 +11,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S 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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +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= @@ -29,14 +25,12 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/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.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU= -github.com/knadh/koanf/v2 v2.2.0/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= -github.com/knadh/koanf/v2 v2.2.1 h1:jaleChtw85y3UdBnI0wCqcg1sj1gPoz6D3caGNHtrNE= -github.com/knadh/koanf/v2 v2.2.1/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= +github.com/knadh/koanf/v2 v2.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A= +github.com/knadh/koanf/v2 v2.2.2/go.mod h1:abWQc0cBXLSF/PSOMCB/SK+T13NXDsPvOksbpi5e/9Q= 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= @@ -48,62 +42,50 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= -github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= +github.com/minio/crc64nvme v1.1.0/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.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw= -github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0= -github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM= -github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc= +github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= +github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +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.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.38 h1:Mgt3XIIq47uR5vcLLahfRucE6tFPjxHak+z5ZZFEzLU= -github.com/pion/interceptor v0.1.38/go.mod h1:HS9X+Ue5LDE6q2C2tuvOuO83XkBdJFgn6MBDtfoJX4Q= github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= -github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= -github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90= +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.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.16 h1:0mpfguLyN9HCpPIXcoOho4BkMsz5eB1Yjvf+obI5cEQ= -github.com/pion/rtp v1.8.16/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c= -github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= +github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y= +github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4= -github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI= -github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= -github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= +github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk= +github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= -github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= -github.com/pion/webrtc/v4 v4.1.1 h1:PMFPtLg1kpD2pVtun+LGUzA3k54JdFl87WO0Z1+HKug= -github.com/pion/webrtc/v4 v4.1.1/go.mod h1:cgEGkcpxGkT6Di2ClBYO5lP9mFXbCfEOrkYUpjjCQO4= -github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= -github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U= +github.com/pion/turn/v4 v4.1.0 h1:+J56+aS8Bi6B4zij3ah6VvJpRuy8W8FtExR0OJPiTdM= +github.com/pion/turn/v4 v4.1.0/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= +github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= +github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= 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= @@ -125,25 +107,21 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= +golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 6b0d7c0ce1803e082b98567864a607b6dce2dd05 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 15 Aug 2025 12:25:04 +0300 Subject: [PATCH 204/240] Update Go to 1.25.0 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 94e61bc6..e196e457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:oracular AS build0 -ARG GO=1.24.2 +ARG GO=1.25.0 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ From bbad4539b1ea8b841eecacafe471f65f2a058afa Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 20 Nov 2025 00:33:03 +0300 Subject: [PATCH 205/240] Update libretro.h --- pkg/worker/caged/libretro/nanoarch/libretro.h | 95 ++++++++++++------- 1 file changed, 63 insertions(+), 32 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/libretro.h b/pkg/worker/caged/libretro/nanoarch/libretro.h index 7e7b9505..c549976d 100644 --- a/pkg/worker/caged/libretro/nanoarch/libretro.h +++ b/pkg/worker/caged/libretro/nanoarch/libretro.h @@ -4,12 +4,12 @@ * @file libretro.h * @version 1 * @author libretro - * @copyright Copyright (C) 2010-2023 The RetroArch team + * @copyright Copyright (C) 2010-2024 The RetroArch team * * @paragraph LICENSE * The following license statement only applies to this libretro API header (libretro.h). * - * Copyright (C) 2010-2023 The RetroArch team + * Copyright (C) 2010-2024 The RetroArch team * * Permission is hereby granted, free of charge, * to any person obtaining a copy of this software and associated documentation files (the "Software"), @@ -219,7 +219,7 @@ extern "C" { #define RETRO_DEVICE_KEYBOARD 3 /** - * An abstraction around a light gun, simular to the PlayStation's Guncon. + * An abstraction around a light gun, similar to the PlayStation's Guncon. * * When provided as the \c device argument to \c retro_input_state_t, * the \c id argument denotes one of several possible inputs. @@ -272,7 +272,10 @@ extern "C" { * [-0x7fff, 0x7fff]: -0x7fff corresponds to the far left/top of the screen, * and 0x7fff corresponds to the far right/bottom of the screen. * The "screen" is here defined as area that is passed to the frontend and - * later displayed on the monitor. + * later displayed on the monitor. If the pointer is outside this screen, + * such as in the black surrounding areas when actual display is larger, + * edge position is reported. An explicit edge detection is also provided, + * that will return 1 if the pointer is near the screen edge or actually outside it. * * The frontend is free to scale/resize this screen as it sees fit, however, * (X, Y) = (-0x7fff, -0x7fff) will correspond to the top-left pixel of the @@ -406,7 +409,8 @@ extern "C" { /* Id values for LIGHTGUN. */ #define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_X 13 /*Absolute Position*/ -#define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_Y 14 /*Absolute*/ +#define RETRO_DEVICE_ID_LIGHTGUN_SCREEN_Y 14 /*Absolute Position*/ +/** Indicates if lightgun points off the screen or near the edge */ #define RETRO_DEVICE_ID_LIGHTGUN_IS_OFFSCREEN 15 /*Status Check*/ #define RETRO_DEVICE_ID_LIGHTGUN_TRIGGER 2 #define RETRO_DEVICE_ID_LIGHTGUN_RELOAD 16 /*Forced off-screen shot*/ @@ -421,17 +425,18 @@ extern "C" { #define RETRO_DEVICE_ID_LIGHTGUN_DPAD_RIGHT 12 /* deprecated */ #define RETRO_DEVICE_ID_LIGHTGUN_X 0 /*Relative Position*/ -#define RETRO_DEVICE_ID_LIGHTGUN_Y 1 /*Relative*/ -#define RETRO_DEVICE_ID_LIGHTGUN_CURSOR 3 /*Use Aux:A*/ -#define RETRO_DEVICE_ID_LIGHTGUN_TURBO 4 /*Use Aux:B*/ -#define RETRO_DEVICE_ID_LIGHTGUN_PAUSE 5 /*Use Start*/ +#define RETRO_DEVICE_ID_LIGHTGUN_Y 1 /*Relative Position*/ +#define RETRO_DEVICE_ID_LIGHTGUN_CURSOR 3 /*Use Aux:A instead*/ +#define RETRO_DEVICE_ID_LIGHTGUN_TURBO 4 /*Use Aux:B instead*/ +#define RETRO_DEVICE_ID_LIGHTGUN_PAUSE 5 /*Use Start instead*/ /* Id values for POINTER. */ -#define RETRO_DEVICE_ID_POINTER_X 0 -#define RETRO_DEVICE_ID_POINTER_Y 1 -#define RETRO_DEVICE_ID_POINTER_PRESSED 2 -#define RETRO_DEVICE_ID_POINTER_COUNT 3 - +#define RETRO_DEVICE_ID_POINTER_X 0 +#define RETRO_DEVICE_ID_POINTER_Y 1 +#define RETRO_DEVICE_ID_POINTER_PRESSED 2 +#define RETRO_DEVICE_ID_POINTER_COUNT 3 +/** Indicates if pointer is off the screen or near the edge */ +#define RETRO_DEVICE_ID_POINTER_IS_OFFSCREEN 15 /** @} */ /* Returned from retro_get_region(). */ @@ -479,6 +484,7 @@ enum retro_language RETRO_LANGUAGE_BELARUSIAN = 32, RETRO_LANGUAGE_GALICIAN = 33, RETRO_LANGUAGE_NORWEGIAN = 34, + RETRO_LANGUAGE_IRISH = 35, RETRO_LANGUAGE_LAST, /** Defined to ensure that sizeof(retro_language) == sizeof(int). Do not use. */ @@ -1094,7 +1100,7 @@ enum retro_mod * to write audio. The audio callbacks must be called from within the * notification callback. * The amount of audio data to write is up to the core. - * Generally, the audio callback will be called continously in a loop. + * Generally, the audio callback will be called continuously in a loop. * * A frontend may disable this callback in certain situations. * The core must be able to render audio with the "normal" interface. @@ -1332,7 +1338,7 @@ enum retro_mod *
  • Changing the emulated system's internal resolution, * within the limits defined by the existing values of \c max_width and \c max_height. * Use \c RETRO_ENVIRONMENT_SET_GEOMETRY instead, - * and adjust \c retro_get_system_av_info to account fo + * and adjust \c retro_get_system_av_info to account for * supported scale factors and screen layouts * when computing \c max_width and \c max_height. * Only use this environment call if \c max_width or \c max_height needs to increase. @@ -2556,6 +2562,31 @@ enum retro_mod */ #define RETRO_ENVIRONMENT_GET_PLAYLIST_DIRECTORY 79 +/** + * Returns the "file browser" start directory of the frontend. + * + * This directory can serve as a start directory for the core in case it + * provides an internal way of loading content. + * + * @param[out] data const char **. + * May be \c NULL. If so, no such directory is defined, and it's up to the + * implementation to find a suitable directory. + * @return \c true if the environment call is available. + */ +#define RETRO_ENVIRONMENT_GET_FILE_BROWSER_START_DIRECTORY 80 + +/** + * Returns the audio sample rate the frontend is targeting, in Hz. + * The intended use case is for the core to use the result to select an ideal sample rate. + * + * @param[out] data unsigned *. + * Pointer to the \c unsigned integer in which the frontend will store its target sample rate. + * Behavior is undefined if \c data is NULL. + * @return \c true if this environment call is available, + * regardless of the value returned in \c data. +*/ +#define RETRO_ENVIRONMENT_GET_TARGET_SAMPLE_RATE (81 | RETRO_ENVIRONMENT_EXPERIMENTAL) + /**@}*/ /** @@ -5161,14 +5192,14 @@ struct retro_hw_render_callback * character is the text character of the pressed key. (UTF-32). * key_modifiers is a set of RETROKMOD values or'ed together. * - * The pressed/keycode state can be indepedent of the character. + * The pressed/keycode state can be independent of the character. * It is also possible that multiple characters are generated from a * single keypress. * Keycode events should be treated separately from character events. * However, when possible, the frontend should try to synchronize these. * If only a character is posted, keycode should be RETROK_UNKNOWN. * - * Similarily if only a keycode event is generated with no corresponding + * Similarly if only a keycode event is generated with no corresponding * character, character should be 0. */ typedef void (RETRO_CALLCONV *retro_keyboard_event_t)(bool down, unsigned keycode, @@ -5347,14 +5378,14 @@ typedef bool (RETRO_CALLCONV *retro_set_initial_image_t)(unsigned index, const c * on the host's file system. * * @param index The index of the disk image to get the path of. - * @param path A buffer to store the path in. - * @param len The size of \c path, in bytes. + * @param s A buffer to store the path in. + * @param len The size of \c s, in bytes. * @return \c true if the disk image's location was successfully - * queried and copied into \c path, + * queried and copied into \c s, * \c false if the index is invalid * or the core couldn't locate the disk image. */ -typedef bool (RETRO_CALLCONV *retro_get_image_path_t)(unsigned index, char *path, size_t len); +typedef bool (RETRO_CALLCONV *retro_get_image_path_t)(unsigned index, char *s, size_t len); /** * Returns a friendly label for the given disk image. @@ -5370,12 +5401,12 @@ typedef bool (RETRO_CALLCONV *retro_get_image_path_t)(unsigned index, char *path * so that the frontend can provide better guidance to the player. * * @param index The index of the disk image to return a label for. - * @param label A buffer to store the resulting label in. - * @param len The length of \c label, in bytes. + * @param s A buffer to store the resulting label in. + * @param len The length of \c s, in bytes. * @return \c true if the disk image at \c index is valid - * and a label was copied into \c label. + * and a label was copied into \c s. */ -typedef bool (RETRO_CALLCONV *retro_get_image_label_t)(unsigned index, char *label, size_t len); +typedef bool (RETRO_CALLCONV *retro_get_image_label_t)(unsigned index, char *s, size_t len); /** * An interface that the frontend can use to exchange disks @@ -5719,7 +5750,7 @@ struct retro_message enum retro_message_target { /** - * Indicates that the frontent should display the given message + * Indicates that the frontend should display the given message * using all other targets defined by \c retro_message_target at once. */ RETRO_MESSAGE_TARGET_ALL = 0, @@ -5910,7 +5941,7 @@ struct retro_message_ext /** * The progress of an asynchronous task. * - * A value betwen 0 and 100 (inclusive) indicates the task's percentage, + * A value between 0 and 100 (inclusive) indicates the task's percentage, * and a value of -1 indicates a task of unknown completion. * * @note Since message type is a hint, a frontend may ignore progress values. @@ -7426,7 +7457,7 @@ typedef void (RETRO_CALLCONV *retro_audio_sample_t)(int16_t left, int16_t right) * is defined as a sample of left and right channels, interleaved. * For example: int16_t buf[4] = { l, r, l, r }; would be 2 frames. * - * @return The number of samples that were processed. + * @return The number of frames that were processed. * * @see retro_set_audio_sample_batch() * @see retro_set_audio_sample() @@ -7687,7 +7718,7 @@ RETRO_API size_t retro_serialize_size(void); * @see retro_serialize_size() * @see retro_unserialize() */ -RETRO_API bool retro_serialize(void *data, size_t size); +RETRO_API bool retro_serialize(void *data, size_t len); /** * Unserialize the given state data, and load it into the internal state. @@ -7696,7 +7727,7 @@ RETRO_API bool retro_serialize(void *data, size_t size); * * @see retro_serialize() */ -RETRO_API bool retro_unserialize(const void *data, size_t size); +RETRO_API bool retro_unserialize(const void *data, size_t len); /** * Reset all the active cheats to their default disabled state. @@ -7812,4 +7843,4 @@ RETRO_API size_t retro_get_memory_size(unsigned id); } #endif -#endif +#endif \ No newline at end of file From 3392251dda800d9eda86897df358882e307caa8a Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Thu, 20 Nov 2025 00:36:13 +0300 Subject: [PATCH 206/240] Update dependencies --- go.mod | 49 ++++++++++++++-------------- go.sum | 100 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 75 insertions(+), 74 deletions(-) diff --git a/go.mod b/go.mod index 3428336f..d5ebd227 100644 --- a/go.mod +++ b/go.mod @@ -1,25 +1,25 @@ module github.com/giongto35/cloud-game/v3 -go 1.24 +go 1.25 require ( - github.com/VictoriaMetrics/metrics v1.39.1 + github.com/VictoriaMetrics/metrics v1.40.2 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 - github.com/gofrs/flock v0.12.1 + 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.2.2 - github.com/minio/minio-go/v7 v7.0.95 + github.com/knadh/koanf/v2 v2.3.0 + github.com/minio/minio-go/v7 v7.0.97 github.com/pion/ice/v4 v4.0.10 - github.com/pion/interceptor v0.1.40 + github.com/pion/interceptor v0.1.42 github.com/pion/logging v0.2.4 - github.com/pion/webrtc/v4 v4.1.3 + github.com/pion/webrtc/v4 v4.1.6 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.41.0 + golang.org/x/crypto v0.45.0 golang.org/x/image v0.30.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,33 +29,34 @@ require ( 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.0 // indirect + github.com/klauspost/compress v1.18.1 // 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.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.6 // indirect - github.com/pion/mdns/v2 v2.0.7 // indirect + github.com/pion/dtls/v3 v3.0.7 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.21 // indirect - github.com/pion/sctp v1.8.39 // indirect - github.com/pion/sdp/v3 v3.0.15 // indirect - github.com/pion/srtp/v3 v3.0.6 // indirect - github.com/pion/stun/v3 v3.0.0 // indirect - github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/turn/v4 v4.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect + github.com/pion/rtp v1.8.25 // indirect + github.com/pion/sctp v1.8.40 // indirect + github.com/pion/sdp/v3 v3.0.16 // indirect + github.com/pion/srtp/v3 v3.0.8 // indirect + github.com/pion/stun/v3 v3.0.1 // 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.3.0 // indirect + github.com/tinylib/msgp v1.5.0 // indirect github.com/valyala/fastrand v1.1.0 // indirect github.com/valyala/histogram v1.2.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 1ab980c2..55499af2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/VictoriaMetrics/metrics v1.39.1 h1:AT7jz7oSpAK9phDl5O5Tmy06nXnnzALwqVnf4ros3Ow= -github.com/VictoriaMetrics/metrics v1.39.1/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA= +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= @@ -16,21 +16,23 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd 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.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= 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.2.2 h1:ghbduIkpFui3L587wavneC9e3WIliCgiCgdxYO/wd7A= -github.com/knadh/koanf/v2 v2.2.2/go.mod h1:abWQc0cBXLSF/PSOMCB/SK+T13NXDsPvOksbpi5e/9Q= +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= @@ -42,12 +44,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= -github.com/minio/crc64nvme v1.1.0/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +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.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= -github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= +github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ= +github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -56,36 +58,36 @@ 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.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E= -github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU= +github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= +github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= -github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +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.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= -github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= +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.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= -github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.21 h1:3yrOwmZFyUpcIosNcWRpQaU+UXIJ6yxLuJ8Bx0mw37Y= -github.com/pion/rtp v1.8.21/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk= -github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= -github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= -github.com/pion/sdp/v3 v3.0.15 h1:F0I1zds+K/+37ZrzdADmx2Q44OFDOPRLhPnNTaUX9hk= -github.com/pion/sdp/v3 v3.0.15/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E= -github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= -github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.1.0 h1:+J56+aS8Bi6B4zij3ah6VvJpRuy8W8FtExR0OJPiTdM= -github.com/pion/turn/v4 v4.1.0/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= -github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c= -github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM= +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.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw= +github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= +github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= +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/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= +github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= +github.com/pion/stun/v3 v3.0.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA= +github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw= +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.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= +github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= 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= @@ -95,10 +97,10 @@ 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= -github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +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.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc= +github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= 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= @@ -107,21 +109,19 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 5c6406c1e791199438bd6cf59abda55378efa453 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 21 Nov 2025 20:13:27 +0300 Subject: [PATCH 207/240] 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. --- pkg/worker/caged/libretro/frontend.go | 57 ++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 8d0a73ac..5fc05a61 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -283,20 +283,67 @@ func (f *Frontend) Start() { } } - 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 = 4 + + 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! + } + } else { + // lagging behind the target framerate so we don't sleep + f.log.Debug().Msgf("[] Frame drop: %v", elapsed) + } + + // 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() + } } } } From efa7a1d7b54c5ffe74ed8cc085aa8e951501a0a1 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 21 Nov 2025 20:44:27 +0300 Subject: [PATCH 208/240] Update outdated Docker build --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index e196e457..b3da7099 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG BUILD_PATH=/tmp/cloud-game ARG VERSION=master # base build stage -FROM ubuntu:oracular AS build0 +FROM ubuntu:plucky AS build0 ARG GO=1.25.0 ARG GO_DIST=go${GO}.linux-amd64.tar.gz @@ -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} @@ -73,7 +73,7 @@ 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:oracular AS worker +FROM ubuntu:plucky AS worker RUN apt-get -q update && apt-get -q install --no-install-recommends -y \ curl \ From 3df6a24a0a97bfceaa7c3cedf5fa44e9c8e6bc7d Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 21 Nov 2025 22:35:33 +0300 Subject: [PATCH 209/240] Skip video frames when they are late --- pkg/worker/caged/libretro/frontend.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 5fc05a61..ff4b6432 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "path/filepath" + "runtime" "strings" "sync" "time" @@ -64,6 +65,11 @@ type Frontend struct { th int // draw threads vw, vh int // out frame size + // directives + + // skipVideo used when new frame was too late + skipVideo bool + mu sync.Mutex mui sync.Mutex @@ -198,6 +204,10 @@ func (f *Frontend) handleAudio(audio unsafe.Pointer, samples int) { } func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) { + if f.skipVideo { + return + } + fr, _ := videoPool.Get().(*app.Video) if fr == nil { fr = new(app.Video) @@ -258,6 +268,10 @@ func (f *Frontend) Start() { 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() @@ -297,7 +311,7 @@ func (f *Frontend) Start() { const spinThreshold = 1 * time.Millisecond // how many frames will be considered not normal - const lateFramesThreshold = 4 + const lateFramesThreshold = 3 lastFrameStart := time.Now() @@ -326,9 +340,11 @@ func (f *Frontend) Start() { for time.Since(lastFrameStart) < targetFrameTime { // CPU burn! } + f.skipVideo = false } else { // lagging behind the target framerate so we don't sleep f.log.Debug().Msgf("[] Frame drop: %v", elapsed) + f.skipVideo = true } // timer reset From 76b376aef7e4431fa790dfec4b9479ef7248b4c0 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 11:59:08 +0300 Subject: [PATCH 210/240] Add config option for skipping late video frames --- pkg/config/config.yaml | 3 +++ pkg/config/emulator.go | 13 +++++++------ pkg/worker/caged/libretro/frontend.go | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 1a1d2803..d34c55a8 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -140,6 +140,9 @@ emulator: # checks if the system supports running an emulator at startup failFast: true + # do not send late video frames + skipLateFrames: false + libretro: # use zip compression for emulator save states saveCompression: true diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index 013225f7..3f4f6190 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -9,12 +9,13 @@ import ( ) type Emulator struct { - FailFast bool - Threads int - Storage string - LocalPath string - Libretro LibretroConfig - AutosaveSec int + FailFast bool + Threads int + Storage string + LocalPath string + Libretro LibretroConfig + AutosaveSec int + SkipLateFrames bool } type LibretroConfig struct { diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index ff4b6432..033a2c91 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -204,7 +204,7 @@ func (f *Frontend) handleAudio(audio unsafe.Pointer, samples int) { } func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) { - if f.skipVideo { + if f.conf.SkipLateFrames && f.skipVideo { return } From baaeaf43b1ee64488b9eed05d78aa3cae00833bf Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 12:32:56 +0300 Subject: [PATCH 211/240] Add config option for logging dropped frames --- pkg/config/config.yaml | 3 +++ pkg/config/emulator.go | 15 ++++++++------- pkg/worker/caged/libretro/frontend.go | 5 ++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index d34c55a8..7de1b43d 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -143,6 +143,9 @@ emulator: # do not send late video frames skipLateFrames: false + # log dropped frames (temp) + logDroppedFrames: false + libretro: # use zip compression for emulator save states saveCompression: true diff --git a/pkg/config/emulator.go b/pkg/config/emulator.go index 3f4f6190..6a0ad9bb 100644 --- a/pkg/config/emulator.go +++ b/pkg/config/emulator.go @@ -9,13 +9,14 @@ import ( ) type Emulator struct { - FailFast bool - Threads int - Storage string - LocalPath string - Libretro LibretroConfig - AutosaveSec int - SkipLateFrames bool + FailFast bool + Threads int + Storage string + LocalPath string + Libretro LibretroConfig + AutosaveSec int + SkipLateFrames bool + LogDroppedFrames bool } type LibretroConfig struct { diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 033a2c91..b3baecde 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -343,7 +343,10 @@ func (f *Frontend) Start() { f.skipVideo = false } else { // lagging behind the target framerate so we don't sleep - f.log.Debug().Msgf("[] Frame drop: %v", elapsed) + if f.conf.LogDroppedFrames { + // !to make some stats counter instead + f.log.Debug().Msgf("[] Frame drop: %v", elapsed) + } f.skipVideo = true } From 859d0c8f1ad39cdd8695a5507def9a8f56d233d7 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 17:22:10 +0300 Subject: [PATCH 212/240] Add user input caching --- pkg/worker/caged/libretro/frontend.go | 60 +++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index b3baecde..1dd0eaae 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -70,6 +70,8 @@ type Frontend struct { // skipVideo used when new frame was too late skipVideo bool + inputs [maxPort]inputCache + mu sync.Mutex mui sync.Mutex @@ -81,6 +83,16 @@ type Frontend struct { type Device byte +type inputCache struct { + mu sync.Mutex + pad []byte + key []byte + mouse []byte + dirty uint8 // bitmask: 1=Pad, 2=Key, 4=Mouse +} + +const maxPort = 8 + const ( RetroPad = Device(nanoarch.RetroPad) Keyboard = Device(nanoarch.Keyboard) @@ -395,18 +407,58 @@ 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) Tick() { f.syncInputs(); 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) { + if port >= maxPort { + return + } + + c := &f.inputs[port] + + c.mu.Lock() + switch Device(device) { case RetroPad: - f.nano.InputRetropad(port, data) + c.pad = append(c.pad[:0], data...) + c.dirty |= 1 case Keyboard: - f.nano.InputKeyboard(port, data) + c.key = append(c.key[:0], data...) + c.dirty |= 2 case Mouse: - f.nano.InputMouse(port, data) + c.mouse = append(c.mouse[:0], data...) + c.dirty |= 4 + } + + c.mu.Unlock() +} + +func (f *Frontend) syncInputs() { + for i := 0; i < maxPort; i++ { + c := &f.inputs[i] + + c.mu.Lock() + if c.dirty == 0 { + c.mu.Unlock() + continue + } + + d := c.dirty + c.dirty = 0 + + if d&1 != 0 { + f.nano.InputRetropad(i, c.pad) + } + if d&2 != 0 { + f.nano.InputKeyboard(i, c.key) + } + if d&4 != 0 { + f.nano.InputMouse(i, c.mouse) + } + + c.mu.Unlock() } } From 09a0c9c3f2de9e85c23ba3075c019aa43f058ec4 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 17:46:07 +0300 Subject: [PATCH 213/240] Revert "Add user input caching" This reverts commit 859d0c8f1ad39cdd8695a5507def9a8f56d233d7. --- pkg/worker/caged/libretro/frontend.go | 60 ++------------------------- 1 file changed, 4 insertions(+), 56 deletions(-) diff --git a/pkg/worker/caged/libretro/frontend.go b/pkg/worker/caged/libretro/frontend.go index 1dd0eaae..b3baecde 100644 --- a/pkg/worker/caged/libretro/frontend.go +++ b/pkg/worker/caged/libretro/frontend.go @@ -70,8 +70,6 @@ type Frontend struct { // skipVideo used when new frame was too late skipVideo bool - inputs [maxPort]inputCache - mu sync.Mutex mui sync.Mutex @@ -83,16 +81,6 @@ type Frontend struct { type Device byte -type inputCache struct { - mu sync.Mutex - pad []byte - key []byte - mouse []byte - dirty uint8 // bitmask: 1=Pad, 2=Key, 4=Mouse -} - -const maxPort = 8 - const ( RetroPad = Device(nanoarch.RetroPad) Keyboard = Device(nanoarch.Keyboard) @@ -407,58 +395,18 @@ 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.syncInputs(); f.mu.Lock(); f.nano.Run(); f.mu.Unlock() } +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) { - if port >= maxPort { - return - } - - c := &f.inputs[port] - - c.mu.Lock() - switch Device(device) { case RetroPad: - c.pad = append(c.pad[:0], data...) - c.dirty |= 1 + f.nano.InputRetropad(port, data) case Keyboard: - c.key = append(c.key[:0], data...) - c.dirty |= 2 + f.nano.InputKeyboard(port, data) case Mouse: - c.mouse = append(c.mouse[:0], data...) - c.dirty |= 4 - } - - c.mu.Unlock() -} - -func (f *Frontend) syncInputs() { - for i := 0; i < maxPort; i++ { - c := &f.inputs[i] - - c.mu.Lock() - if c.dirty == 0 { - c.mu.Unlock() - continue - } - - d := c.dirty - c.dirty = 0 - - if d&1 != 0 { - f.nano.InputRetropad(i, c.pad) - } - if d&2 != 0 { - f.nano.InputKeyboard(i, c.key) - } - if d&4 != 0 { - f.nano.InputMouse(i, c.mouse) - } - - c.mu.Unlock() + f.nano.InputMouse(port, data) } } From c05e42f5972fc6e2f0659f54b4315934fbf5d9dd Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 21:20:16 +0300 Subject: [PATCH 214/240] Cleanup nanoarch.go --- pkg/worker/caged/libretro/nanoarch/nanoarch.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index e26ee07d..7c05d962 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -626,7 +626,6 @@ var ( //export coreVideoRefresh func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { if Nan0.Stopped.Load() { - Nan0.log.Warn().Msgf(">>> skip video") return } @@ -635,13 +634,12 @@ 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 // when the core returns a duplicate frame if data == nil { @@ -651,8 +649,9 @@ func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { // 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 @@ -729,9 +728,6 @@ func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.u //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) - } return frames } Nan0.Handlers.OnAudio(data, int(frames)<<1) From 9191861cab22b70103df85e073429cd3e41bd822 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 22:09:38 +0300 Subject: [PATCH 215/240] Use iterators in the custom map implementation --- pkg/com/map.go | 147 ++++++++++++++++++++++++++-------------- pkg/com/map_test.go | 8 ++- pkg/com/net.go | 4 +- pkg/coordinator/hub.go | 8 +-- pkg/worker/room/room.go | 23 +++++-- 5 files changed, 126 insertions(+), 64 deletions(-) diff --git a/pkg/com/map.go b/pkg/com/map.go index 6a4df33a..ce2c5cd5 100644 --- a/pkg/com/map.go +++ b/pkg/com/map.go @@ -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 + } + } } } diff --git a/pkg/com/map_test.go b/pkg/com/map_test.go index 4ebe1005..15af76c4 100644 --- a/pkg/com/map_test.go +++ b/pkg/com/map_test.go @@ -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) } diff --git a/pkg/com/net.go b/pkg/com/net.go index 558c8148..04ed7e54 100644 --- a/pkg/com/net.go +++ b/pkg/com/net.go @@ -170,10 +170,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) - }) + } } diff --git a/pkg/coordinator/hub.go b/pkg/coordinator/hub.go index f4a1398c..490747df 100644 --- a/pkg/coordinator/hub.go +++ b/pkg/coordinator/hub.go @@ -155,7 +155,7 @@ func (h *Hub) handleWorkerConnection() http.HandlerFunc { func (h *Hub) GetServerList() (r []api.Server) { debug := h.conf.Coordinator.Debug - h.workers.ForEach(func(w *Worker) { + for w := range h.workers.Values() { server := api.Server{ Addr: w.Addr, Id: w.Id(), @@ -170,7 +170,7 @@ func (h *Hub) GetServerList() (r []api.Server) { server.Room = w.RoomId } r = append(r, server) - }) + } return } @@ -240,11 +240,11 @@ func (h *Hub) findWorkerByRoom(id string, region string) *Worker { 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 } diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index c52f091d..c2686bdc 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -1,6 +1,7 @@ package room import ( + "iter" "sync" "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" @@ -27,10 +28,10 @@ type SessionManager[T Session] interface { Add(T) bool Empty() bool Find(string) T - ForEach(func(T)) RemoveL(T) int // Reset used for proper cleanup of the resources if needed. Reset() + Values() iter.Seq[T] } type Session interface { @@ -65,13 +66,19 @@ func NewRoom[T Session](id string, app app.App, um SessionManager[T], media Medi func (r *Room[T]) InitAudio() { r.app.SetAudioCb(func(a app.Audio) { r.media.PushAudio(a.Data) }) - r.media.SetAudioCb(func(d []byte, l int32) { r.users.ForEach(func(u T) { u.SendAudio(d, l) }) }) + r.media.SetAudioCb(func(d []byte, l int32) { + for u := range r.users.Values() { + u.SendAudio(d, l) + } + }) } func (r *Room[T]) InitVideo() { r.app.SetVideoCb(func(v app.Video) { data := r.media.ProcessVideo(v) - r.users.ForEach(func(u T) { u.SendVideo(data, v.Duration) }) + for u := range r.users.Values() { + u.SendVideo(data, v.Duration) + } }) } @@ -81,7 +88,11 @@ func (r *Room[T]) Id() string { return r.id } func (r *Room[T]) SetApp(app app.App) { r.app = app } func (r *Room[T]) SetMedia(m MediaPipe) { r.media = m } func (r *Room[T]) StartApp() { r.app.Start() } -func (r *Room[T]) Send(data []byte) { r.users.ForEach(func(u T) { u.SendData(data) }) } +func (r *Room[T]) Send(data []byte) { + for u := range r.users.Values() { + u.SendData(data) + } +} func (r *Room[T]) Close() { if r == nil || r.closed { @@ -137,7 +148,9 @@ func (r *Router[T]) Reset() { r.room.Close() r.room = nil } - r.users.ForEach(func(u T) { u.Disconnect() }) + for u := range r.users.Values() { + u.Disconnect() + } r.users.Reset() r.mu.Unlock() } From 129690e90184caaf4e711b880b9518f552e6be09 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 22 Nov 2025 22:21:05 +0300 Subject: [PATCH 216/240] Fix map test --- pkg/worker/room/router_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/worker/room/router_test.go b/pkg/worker/room/router_test.go index f404073c..013f1e27 100644 --- a/pkg/worker/room/router_test.go +++ b/pkg/worker/room/router_test.go @@ -25,7 +25,9 @@ type lookMap struct { func (l *lookMap) Reset() { l.prev = com.NewNetMap[string, *tSession]() - l.Map.ForEach(func(s *tSession) { l.prev.Add(s) }) + for s := range l.Map.Values() { + l.prev.Add(s) + } l.NetMap.Reset() } @@ -59,7 +61,9 @@ func TestRouterReset(t *testing.T) { router.Reset() disconnected := true - u.prev.ForEach(func(u *tSession) { disconnected = disconnected && !u.connected }) + for u := range u.prev.Values() { + disconnected = disconnected && !u.connected + } if !disconnected { t.Errorf("not all users were disconnected, but should") } From 84ad0a4cace683ad0243e568224a59b24f34901e Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 13 Dec 2025 23:56:38 +0300 Subject: [PATCH 217/240] Add audio resampling option You can now select between linear interpolation and nearest-neighbor resampling algorithms. --- pkg/config/config.yaml | 3 + pkg/config/worker.go | 3 +- pkg/worker/media/buffer.go | 233 ++++++++++++++++++++++---------- pkg/worker/media/buffer_test.go | 10 +- pkg/worker/media/media.go | 8 +- pkg/worker/media/media_test.go | 6 +- 6 files changed, 178 insertions(+), 85 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 7de1b43d..275eea59 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -341,6 +341,9 @@ encoder: frames: - 10 - 5 + # linear (1) or nearest neighbour (0) audio resampler + # linear should sound slightly better + resampler: 1 video: # h264, vpx (vp8) or vp9 codec: h264 diff --git a/pkg/config/worker.go b/pkg/config/worker.go index 5a509b0c..014ce644 100644 --- a/pkg/config/worker.go +++ b/pkg/config/worker.go @@ -51,7 +51,8 @@ type Encoder struct { } type Audio struct { - Frames []float32 + Frames []float32 + Resampler int } type Video struct { diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index e80a7c82..836e1c53 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -1,19 +1,28 @@ package media -import ( - "errors" - "math" - "unsafe" +import "errors" + +type ResampleAlgo uint8 + +const ( + ResampleNearest ResampleAlgo = iota + ResampleLinear ) +// preallocated scratch buffer for resampling output +// size for max Opus frame: 60ms at 48kHz stereo = 48000 * 0.06 * 2 = 5760 samples +var stretchBuf = make(samples, 5760) + // buffer is a simple non-concurrent safe buffer for audio samples. type buffer struct { - stretch bool - frameHz []int + useResample bool + algo ResampleAlgo + srcHz int + + raw samples - raw samples buckets []bucket - cur *bucket + bi int } type bucket struct { @@ -25,100 +34,180 @@ type bucket struct { func newBuffer(frames []float32, hz int) (*buffer, error) { if hz < 2000 { - return nil, errors.New("hz should be > than 2000") + return nil, errors.New("hz should be > 2000") + } + if len(frames) == 0 { + return nil, errors.New("frames list is empty") } - buf := buffer{} + buf := buffer{srcHz: hz} - // preallocate continuous array - s := 0 + totalSize := 0 for _, f := range frames { - s += frame(hz, f) - } - buf.raw = make(samples, s) - - if len(buf.raw) == 0 { - return nil, errors.New("seems those params are bad and the buffer is 0") + totalSize += frameStereoSamples(hz, f) } - next := 0 + if totalSize == 0 { + return nil, errors.New("calculated buffer size is 0, check params") + } + + buf.raw = make(samples, totalSize) + + // map buckets to the raw continuous array + offset := 0 for _, f := range frames { - s := frame(hz, f) + size := frameStereoSamples(hz, f) buf.buckets = append(buf.buckets, bucket{ - mem: buf.raw[next : next+s], + mem: buf.raw[offset : offset+size], ms: f, }) - next += s + offset += size } - buf.cur = &buf.buckets[len(buf.buckets)-1] + + // start with the largest bucket (last one, assuming frames are sorted ascending) + buf.bi = len(buf.buckets) - 1 + return &buf, nil } -func (b *buffer) choose(l int) { - for _, bb := range b.buckets { - if l >= len(bb.mem) { - b.cur = &bb - break +// cur returns the current bucket pointer +func (b *buffer) cur() *bucket { return &b.buckets[b.bi] } + +// choose selects the best bucket for the remaining samples. +// It picks the largest bucket that can be completely filled. +// Buckets should be sorted by size ascending for this to work optimally. +func (b *buffer) choose(remaining int) { + // search from largest to smallest + for i := len(b.buckets) - 1; i >= 0; i-- { + if remaining >= len(b.buckets[i].mem) { + b.bi = i + return } } + // fall back to smallest bucket if remaining < all bucket sizes + b.bi = 0 } -func (b *buffer) resample(hz int) { - b.stretch = true +// resample enables resampling to target Hz with specified algorithm +func (b *buffer) resample(targetHz int, algo ResampleAlgo) { + b.useResample = true + b.algo = algo for i := range b.buckets { - b.buckets[i].dst = frame(hz, b.buckets[i].ms) + b.buckets[i].dst = frameStereoSamples(targetHz, b.buckets[i].ms) } } -// write fills the buffer until it's full and then passes the gathered data into a callback. -// -// There are two cases to consider: -// 1. Underflow, when the length of the written data is less than the buffer's available space. -// 2. Overflow, when the length exceeds the current available buffer space. -// -// We overwrite any previous values in the buffer and move the internal write pointer -// by the length of the written data. -// In the first case, we won't call the callback, but it will be called every time -// when the internal buffer overflows until all samples are read. -// It will choose between multiple internal buffers to fit remaining samples. -func (b *buffer) write(s samples, onFull func(samples, float32)) (r int) { - for r < len(s) { - buf := b.cur - w := copy(buf.mem[buf.p:], s[r:]) - r += w - buf.p += w - if buf.p == len(buf.mem) { - if b.stretch { - onFull(buf.mem.stretch(buf.dst), buf.ms) +// stretch applies the selected resampling algorithm +func (b *buffer) stretch(src samples, dstSize int) samples { + switch b.algo { + case ResampleNearest: + return stretchNearest(src, dstSize) + case ResampleLinear: + return stretchLinear(src, dstSize) + default: + return stretchLinear(src, dstSize) + } +} + +// write fills the buffer and calls onFull when a complete frame is ready. +// returns the number of samples consumed. +func (b *buffer) write(s samples, onFull func(samples, float32)) int { + read := 0 + for read < len(s) { + cur := b.cur() + + // copy all samples into current bucket + n := copy(cur.mem[cur.p:], s[read:]) + read += n + cur.p += n + + // bucket is full - emit frame + if cur.p == len(cur.mem) { + if b.useResample { + onFull(b.stretch(cur.mem, cur.dst), cur.ms) } else { - onFull(buf.mem, buf.ms) + onFull(cur.mem, cur.ms) } - b.choose(len(s) - r) - b.cur.p = 0 + + // select next bucket and reset write position + b.choose(len(s) - read) + b.cur().p = 0 } } - return + return read } -// frame calculates an audio stereo frame size, i.e. 48k*frame/1000*2 -// with round(x / 2) * 2 for the closest even number -func frame(hz int, frame float32) int { - return int(math.Round(float64(hz)*float64(frame)/1000/2) * 2 * 2) +// frameStereoSamples calculates stereo frame size in samples. +// e.g., 48000 Hz * 20ms = 960 samples/channel * 2 channels = 1920 total samples +func frameStereoSamples(hz int, ms float32) int { + samplesPerChannel := int(float32(hz)*ms/1000 + 0.5) // round to nearest + return samplesPerChannel * 2 // stereo } -// stretch does a simple stretching of audio samples. -// something like: [1,2,3,4,5,6] -> [1,2,x,x,3,4,x,x,5,6,x,x] -> [1,2,1,2,3,4,3,4,5,6,5,6] -func (s samples) stretch(size int) []int16 { - out := buf[:size] - n := len(s) - ratio := float32(size) / float32(n) - sPtr := unsafe.Pointer(&s[0]) - for i, l, r := 0, 0, 0; i < n; i += 2 { - l, r = r, int(float32((i+2)>>1)*ratio)<<1 // index in src * ratio -> approximated index in dst *2 due to int16 - for j := l; j < r; j += 2 { - *(*int32)(unsafe.Pointer(&out[j])) = *(*int32)(sPtr) // out[j] = s[i]; out[j+1] = s[i+1] - } - sPtr = unsafe.Add(sPtr, uintptr(4)) +// stretchLinear resamples stereo audio using linear interpolation. +func stretchLinear(src samples, dstSize int) samples { + srcLen := len(src) + if srcLen < 2 || dstSize < 2 { + return stretchBuf[:dstSize] } + + out := stretchBuf[:dstSize] + + srcPairs := srcLen / 2 + dstPairs := dstSize / 2 + + // Fixed-point ratio for precision (16.16 fixed point) + ratio := ((srcPairs - 1) << 16) / (dstPairs - 1) + + for i := 0; i < dstPairs; i++ { + // Calculate source position in fixed-point + pos := i * ratio + srcIdx := pos >> 16 + frac := pos & 0xFFFF + + dstIdx := i * 2 + + if srcIdx >= srcPairs-1 { + // Last sample - no interpolation + out[dstIdx] = src[srcLen-2] + out[dstIdx+1] = src[srcLen-1] + } else { + // Linear interpolation for both channels + srcBase := srcIdx * 2 + + // Left channel + l0 := int32(src[srcBase]) + l1 := int32(src[srcBase+2]) + out[dstIdx] = int16(l0 + ((l1-l0)*int32(frac))>>16) + + // Right channel + r0 := int32(src[srcBase+1]) + r1 := int32(src[srcBase+3]) + out[dstIdx+1] = int16(r0 + ((r1-r0)*int32(frac))>>16) + } + } + + return out +} + +// stretchNearest is a faster nearest-neighbor version if quality isn't critical +func stretchNearest(src samples, dstSize int) samples { + srcLen := len(src) + if srcLen < 2 || dstSize < 2 { + return stretchBuf[:dstSize] + } + + out := stretchBuf[:dstSize] + + srcPairs := srcLen / 2 + dstPairs := dstSize / 2 + + for i := 0; i < dstPairs; i++ { + srcIdx := (i * srcPairs / dstPairs) * 2 + dstIdx := i * 2 + out[dstIdx] = src[srcIdx] + out[dstIdx+1] = src[srcIdx+1] + } + return out } diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go index 29f2fc6a..28a596ba 100644 --- a/pkg/worker/media/buffer_test.go +++ b/pkg/worker/media/buffer_test.go @@ -23,7 +23,11 @@ func TestBufferWrite(t *testing.T) { {sample: 2, len: 20}, {sample: 3, len: 30}, }, - expect: samples{3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, + expect: samples{ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + }, }, { bufLen: 2000, @@ -32,7 +36,7 @@ func TestBufferWrite(t *testing.T) { {sample: 2, len: 18}, {sample: 3, len: 2}, }, - expect: samples{2, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, + expect: samples{1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, }, } @@ -48,7 +52,7 @@ func TestBufferWrite(t *testing.T) { ) } if !reflect.DeepEqual(test.expect, lastResult) { - t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, len(buf.cur.mem)) + t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, len(buf.buckets)) } } } diff --git a/pkg/worker/media/media.go b/pkg/worker/media/media.go index b08ec692..0d1407d6 100644 --- a/pkg/worker/media/media.go +++ b/pkg/worker/media/media.go @@ -12,17 +12,13 @@ import ( "github.com/giongto35/cloud-game/v3/pkg/worker/caged/app" ) -const ( - audioHz = 48000 - sampleBufLen = 1024 * 4 -) +const audioHz = 48000 type samples []int16 var ( encoderOnce = sync.Once{} opusCoder *opus.Encoder - buf = make([]int16, sampleBufLen) ) func DefaultOpus() (*opus.Encoder, error) { @@ -116,7 +112,7 @@ func (wmp *WebrtcMediaPipe) initAudio(srcHz int, frameSizes []float32) error { wmp.log.Debug().Msgf("Opus frames (ms): %v", frameSizes) dstHz, _ := au.SampleRate() if srcHz != dstHz { - buf.resample(dstHz) + buf.resample(dstHz, ResampleAlgo(wmp.aConf.Resampler)) wmp.log.Debug().Msgf("Resample %vHz -> %vHz", srcHz, dstHz) } wmp.audioBuf = buf diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 4b9a431b..f754e17e 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -126,7 +126,7 @@ func TestResampleStretch(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rez2 := tt.args.pcm.stretch(tt.args.size) + rez2 := stretchNearest(tt.args.pcm, tt.args.size) if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { @@ -141,7 +141,7 @@ func BenchmarkResampler(b *testing.B) { pcm := samples(gen(1764)) size := 1920 for i := 0; i < b.N; i++ { - pcm.stretch(size) + stretchLinear(pcm, size) } } @@ -170,7 +170,7 @@ func TestFrame(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := frame(tt.args.hz, tt.args.frame); got != tt.want { + if got := frameStereoSamples(tt.args.hz, tt.args.frame); got != tt.want { t.Errorf("frame() = %v, want %v", got, tt.want) } }) From 460c4660530cb816ddf4e1a5d2387e8561322dd2 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 13:18:34 +0300 Subject: [PATCH 218/240] Try atomic-based locks in the same thread execution loop instead of a bunch of mutexes. --- pkg/worker/caged/libretro/nanoarch/nanoarch.c | 315 +++++++++++------- .../caged/libretro/nanoarch/nanoarch.go | 10 +- 2 files changed, 196 insertions(+), 129 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index 6290150c..23bf8543 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -1,30 +1,44 @@ #include "libretro.h" + #include #include #include +#include #include +#include #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; -} call_def_t; - -call_def_t call; +// ============================================================================ +// Call types for same_thread operations +// ============================================================================ enum call_type { - CALL_VOID = -1, + CALL_VOID = 0, CALL_SERIALIZE = 1, CALL_UNSERIALIZE = 2, }; -void *same_thread_with_args(void *f, int type, ...); +// ============================================================================ +// Lock-free call structure +// ============================================================================ + +typedef struct { + atomic_int state; // 0=idle, 1=pending, 2=done + int type; + void *fn; + void *arg1; + size_t arg2; + bool result; +} lf_call_t; + +static lf_call_t lf_call = {0}; +static atomic_int thread_running = 0; +static pthread_t worker_thread; + +// ============================================================================ +// Logging +// ============================================================================ void core_log_cgo(enum retro_log_level level, const char *fmt, ...) { char msg[2048] = {0}; @@ -36,6 +50,10 @@ void core_log_cgo(enum retro_log_level level, const char *fmt, ...) { coreLog(level, msg); } +// ============================================================================ +// Bridge functions for calling libretro core +// ============================================================================ + void bridge_call(void *f) { ((void (*)(void)) f)(); } @@ -92,49 +110,55 @@ void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned de ((void (*)(unsigned, unsigned)) f)(port, device); } +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); +} + +void bridge_context_reset(retro_hw_context_reset_t f) { + f(); +} + +// ============================================================================ +// Environment callback +// ============================================================================ + static bool clear_all_thread_waits_cb(unsigned v, void *data) { core_log_cgo(RETRO_LOG_DEBUG, "CLEAR_ALL_THREAD_WAITS_CB (%d)\n", v); return true; } -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; + return false; case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: - return false; - break; + return false; case RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB: - *(retro_environment_t *)data = clear_all_thread_waits_cb; - return true; - break; + *(retro_environment_t *)data = clear_all_thread_waits_cb; + return true; 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; + *(unsigned *)data = 4; + core_log_cgo(RETRO_LOG_DEBUG, "Set max users: %d\n", 4); + return true; case RETRO_ENVIRONMENT_GET_INPUT_BITMASKS: - return false; + return false; case RETRO_ENVIRONMENT_SHUTDOWN: - return false; - break; + return false; case RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT: - if (data != NULL) *(int *)data = RETRO_SAVESTATE_CONTEXT_NORMAL; - return true; - break; + if (data != NULL) *(int *)data = RETRO_SAVESTATE_CONTEXT_NORMAL; + return true; } return coreEnvironment(cmd, data); } +// ============================================================================ +// Core callbacks +// ============================================================================ + void core_video_refresh_cgo(void *data, unsigned width, unsigned height, size_t pitch) { void coreVideoRefresh(void *, unsigned, unsigned, size_t); coreVideoRefresh(data, width, height, pitch); @@ -168,9 +192,9 @@ retro_proc_address_t core_get_proc_address_cgo(const char *sym) { return coreGetProcAddress(sym); } -void bridge_context_reset(retro_hw_context_reset_t f) { - f(); -} +// ============================================================================ +// Video init/deinit +// ============================================================================ void init_video_cgo() { void initVideo(); @@ -182,106 +206,151 @@ void deinit_video_cgo() { deinitVideo(); } -typedef struct { - pthread_mutex_t m; - pthread_cond_t cond; -} mutex_t; +// ============================================================================ +// CPU pause hints for spin loops +// ============================================================================ -void mutex_init(mutex_t *m) { - pthread_mutex_init(&m->m, NULL); - pthread_cond_init(&m->cond, NULL); +static inline void cpu_relax(void) { +#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) + __asm__ volatile("pause" ::: "memory"); +#elif defined(__aarch64__) + __asm__ volatile("isb" ::: "memory"); +#elif defined(__arm__) + __asm__ volatile("yield" ::: "memory"); +#else + // Generic fallback - compiler barrier + __asm__ volatile("" ::: "memory"); +#endif } -void mutex_destroy(mutex_t *m) { - pthread_mutex_trylock(&m->m); - pthread_mutex_unlock(&m->m); - pthread_mutex_destroy(&m->m); - pthread_cond_signal(&m->cond); - pthread_cond_destroy(&m->cond); -} +// ============================================================================ +// Lock-free same_thread implementation. +// Needed due to C/Go stack grow issues (libco). +// ============================================================================ -void mutex_lock(mutex_t *m) { pthread_mutex_lock(&m->m); } -void mutex_wait(mutex_t *m) { pthread_cond_wait(&m->cond, &m->m); } -void mutex_unlock(mutex_t *m) { pthread_mutex_unlock(&m->m); } -void mutex_signal(mutex_t *m) { pthread_cond_signal(&m->cond); } +static void *run_loop_fast(void *unused) { + core_log_cgo(RETRO_LOG_DEBUG, "Worker thread started\n"); -static pthread_t thread; -mutex_t run_mutex, done_mutex; + while (atomic_load_explicit(&thread_running, memory_order_acquire)) { + // Check if there's a pending call + int state = atomic_load_explicit(&lf_call.state, memory_order_acquire); -void *run_loop(void *unused) { - core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop start\n"); - mutex_lock(&done_mutex); - mutex_lock(&run_mutex); - mutex_signal(&done_mutex); - mutex_unlock(&done_mutex); - while (initialized) { - mutex_wait(&run_mutex); - switch (call.type) { - case CALL_SERIALIZE: - case CALL_UNSERIALIZE: - *(bool*)call.result = ((bool (*)(void*, size_t))call.fn)(call.arg1, *(size_t*)call.arg2); - break; - default: - ((void (*)(void)) call.fn)(); + if (state == 1) { + // Execute the call + switch (lf_call.type) { + case CALL_SERIALIZE: + lf_call.result = ((bool (*)(void*, size_t))lf_call.fn)( + lf_call.arg1, lf_call.arg2); + break; + case CALL_UNSERIALIZE: + lf_call.result = ((bool (*)(void*, size_t))lf_call.fn)( + lf_call.arg1, lf_call.arg2); + break; + case CALL_VOID: + default: + ((void (*)(void))lf_call.fn)(); + break; + } + + // Mark as done + atomic_store_explicit(&lf_call.state, 2, memory_order_release); + } else { + // Spin with CPU hint to reduce power consumption + cpu_relax(); } - mutex_lock(&done_mutex); - mutex_signal(&done_mutex); - mutex_unlock(&done_mutex); } - mutex_destroy(&run_mutex); - mutex_destroy(&done_mutex); - pthread_detach(thread); - core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop stop\n"); - pthread_exit(NULL); + + core_log_cgo(RETRO_LOG_DEBUG, "Worker thread stopped\n"); + return NULL; } -void same_thread_stop() { - initialized = 0; -} - -void *same_thread_with_args(void *f, int type, ...) { - if (!initialized) { - initialized = 1; - mutex_init(&run_mutex); - mutex_init(&done_mutex); - mutex_lock(&done_mutex); - pthread_create(&thread, NULL, run_loop, NULL); - mutex_wait(&done_mutex); - mutex_unlock(&done_mutex); +// Initialize the worker thread if not already running +static void same_thread_ensure_init(void) { + int expected = 0; + if (atomic_compare_exchange_strong_explicit( + &thread_running, &expected, 1, + memory_order_acq_rel, memory_order_acquire)) { + // We won the race to initialize + atomic_store_explicit(&lf_call.state, 0, memory_order_release); + pthread_create(&worker_thread, NULL, run_loop_fast, NULL); + core_log_cgo(RETRO_LOG_DEBUG, "Worker thread initialized\n"); } - mutex_lock(&run_mutex); - mutex_lock(&done_mutex); +} - call.type = type; - call.fn = f; - - if (type != CALL_VOID) { - va_list args; - va_start(args, type); - switch (type) { - case CALL_SERIALIZE: - case CALL_UNSERIALIZE: - call.arg1 = va_arg(args, void*); - size_t size; - size = va_arg(args, size_t); - call.arg2 = &size; - bool result; - call.result = &result; - break; - } - va_end(args); +// Stop the worker thread +void same_thread_stop(void) { + if (atomic_load_explicit(&thread_running, memory_order_acquire)) { + atomic_store_explicit(&thread_running, 0, memory_order_release); + pthread_join(worker_thread, NULL); + core_log_cgo(RETRO_LOG_DEBUG, "Worker thread stopped\n"); } - mutex_signal(&run_mutex); - mutex_unlock(&run_mutex); - mutex_wait(&done_mutex); - mutex_unlock(&done_mutex); - return call.result; -} - -void *same_thread_with_args2(void *f, int type, void *arg1, void *arg2) { - return same_thread_with_args(f, type, arg1, arg2); } +// Execute a void function on the worker thread void same_thread(void *f) { - same_thread_with_args(f, CALL_VOID); + same_thread_ensure_init(); + + // Wait for any previous call to complete + while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 0) { + cpu_relax(); + } + + // Set up the call + lf_call.fn = f; + lf_call.type = CALL_VOID; + + // Signal that a call is pending + atomic_store_explicit(&lf_call.state, 1, memory_order_release); + + // Wait for completion + while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 2) { + cpu_relax(); + } + + // Reset to idle + atomic_store_explicit(&lf_call.state, 0, memory_order_release); } + +// Execute a serialize/unserialize function on the worker thread +// Returns pointer to the result (stored in lf_call.result) +bool same_thread_serialize(void *f, int type, void *data, size_t size) { + same_thread_ensure_init(); + + // Wait for any previous call to complete + while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 0) { + cpu_relax(); + } + + // Set up the call - store values directly, not pointers to locals! + lf_call.fn = f; + lf_call.type = type; + lf_call.arg1 = data; + lf_call.arg2 = size; + lf_call.result = false; + + // Signal that a call is pending + atomic_store_explicit(&lf_call.state, 1, memory_order_release); + + // Wait for completion + while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 2) { + cpu_relax(); + } + + // Get result before resetting + bool result = lf_call.result; + + // Reset to idle + atomic_store_explicit(&lf_call.state, 0, memory_order_release); + + return result; +} + +// Execute functions on the same thread. +void *same_thread_with_args2(void *f, int type, void *arg1, void *arg2) { + size_t size = *(size_t*)arg2; + + static _Thread_local bool result_storage; + result_storage = same_thread_serialize(f, type, arg1, size); + + return &result_storage; +} \ No newline at end of file diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 7c05d962..710e0aec 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -504,9 +504,8 @@ func SaveState() (State, error) { rez := false 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]), size)) + rez = *(*bool)(C.same_thread_with_args2(retroSerialize, C.int(CallSerialize), + unsafe.Pointer(&data[0]), unsafe.Pointer(&size))) } if !rez { @@ -526,9 +525,8 @@ func RestoreSaveState(st State) error { 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)) + rez = *(*bool)(C.same_thread_with_args2(retroUnserialize, C.int(CallUnserialize), + unsafe.Pointer(&st[0]), unsafe.Pointer(&size))) } if !rez { From f708fce1122f115c5e12f61d3c8790ba3313c09b Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 13:30:45 +0300 Subject: [PATCH 219/240] Revert "Try atomic-based locks in the same thread execution loop instead of a bunch of mutexes." This reverts commit 460c4660530cb816ddf4e1a5d2387e8561322dd2. --- pkg/worker/caged/libretro/nanoarch/nanoarch.c | 315 +++++++----------- .../caged/libretro/nanoarch/nanoarch.go | 10 +- 2 files changed, 129 insertions(+), 196 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index 23bf8543..6290150c 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -1,44 +1,30 @@ #include "libretro.h" - #include #include #include -#include #include -#include #define RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB (3 | 0x800000) -// ============================================================================ -// Call types for same_thread operations -// ============================================================================ +int initialized = 0; + +typedef struct { + int type; + void* fn; + void* arg1; + void* arg2; + void* result; +} call_def_t; + +call_def_t call; enum call_type { - CALL_VOID = 0, + CALL_VOID = -1, CALL_SERIALIZE = 1, CALL_UNSERIALIZE = 2, }; -// ============================================================================ -// Lock-free call structure -// ============================================================================ - -typedef struct { - atomic_int state; // 0=idle, 1=pending, 2=done - int type; - void *fn; - void *arg1; - size_t arg2; - bool result; -} lf_call_t; - -static lf_call_t lf_call = {0}; -static atomic_int thread_running = 0; -static pthread_t worker_thread; - -// ============================================================================ -// Logging -// ============================================================================ +void *same_thread_with_args(void *f, int type, ...); void core_log_cgo(enum retro_log_level level, const char *fmt, ...) { char msg[2048] = {0}; @@ -50,10 +36,6 @@ void core_log_cgo(enum retro_log_level level, const char *fmt, ...) { coreLog(level, msg); } -// ============================================================================ -// Bridge functions for calling libretro core -// ============================================================================ - void bridge_call(void *f) { ((void (*)(void)) f)(); } @@ -110,55 +92,49 @@ void bridge_retro_set_controller_port_device(void *f, unsigned port, unsigned de ((void (*)(unsigned, unsigned)) f)(port, device); } -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); -} - -void bridge_context_reset(retro_hw_context_reset_t f) { - f(); -} - -// ============================================================================ -// Environment callback -// ============================================================================ - static bool clear_all_thread_waits_cb(unsigned v, void *data) { core_log_cgo(RETRO_LOG_DEBUG, "CLEAR_ALL_THREAD_WAITS_CB (%d)\n", v); return true; } +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; + return false; + break; case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: - return false; + return false; + break; case RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB: - *(retro_environment_t *)data = clear_all_thread_waits_cb; - return true; + *(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; + *(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; + return false; case RETRO_ENVIRONMENT_SHUTDOWN: - return false; + return false; + break; case RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT: - if (data != NULL) *(int *)data = RETRO_SAVESTATE_CONTEXT_NORMAL; - return true; + if (data != NULL) *(int *)data = RETRO_SAVESTATE_CONTEXT_NORMAL; + return true; + break; } return coreEnvironment(cmd, data); } -// ============================================================================ -// Core callbacks -// ============================================================================ - void core_video_refresh_cgo(void *data, unsigned width, unsigned height, size_t pitch) { void coreVideoRefresh(void *, unsigned, unsigned, size_t); coreVideoRefresh(data, width, height, pitch); @@ -192,9 +168,9 @@ retro_proc_address_t core_get_proc_address_cgo(const char *sym) { return coreGetProcAddress(sym); } -// ============================================================================ -// Video init/deinit -// ============================================================================ +void bridge_context_reset(retro_hw_context_reset_t f) { + f(); +} void init_video_cgo() { void initVideo(); @@ -206,151 +182,106 @@ void deinit_video_cgo() { deinitVideo(); } -// ============================================================================ -// CPU pause hints for spin loops -// ============================================================================ +typedef struct { + pthread_mutex_t m; + pthread_cond_t cond; +} mutex_t; -static inline void cpu_relax(void) { -#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86) - __asm__ volatile("pause" ::: "memory"); -#elif defined(__aarch64__) - __asm__ volatile("isb" ::: "memory"); -#elif defined(__arm__) - __asm__ volatile("yield" ::: "memory"); -#else - // Generic fallback - compiler barrier - __asm__ volatile("" ::: "memory"); -#endif +void mutex_init(mutex_t *m) { + pthread_mutex_init(&m->m, NULL); + pthread_cond_init(&m->cond, NULL); } -// ============================================================================ -// Lock-free same_thread implementation. -// Needed due to C/Go stack grow issues (libco). -// ============================================================================ +void mutex_destroy(mutex_t *m) { + pthread_mutex_trylock(&m->m); + pthread_mutex_unlock(&m->m); + pthread_mutex_destroy(&m->m); + pthread_cond_signal(&m->cond); + pthread_cond_destroy(&m->cond); +} -static void *run_loop_fast(void *unused) { - core_log_cgo(RETRO_LOG_DEBUG, "Worker thread started\n"); +void mutex_lock(mutex_t *m) { pthread_mutex_lock(&m->m); } +void mutex_wait(mutex_t *m) { pthread_cond_wait(&m->cond, &m->m); } +void mutex_unlock(mutex_t *m) { pthread_mutex_unlock(&m->m); } +void mutex_signal(mutex_t *m) { pthread_cond_signal(&m->cond); } - while (atomic_load_explicit(&thread_running, memory_order_acquire)) { - // Check if there's a pending call - int state = atomic_load_explicit(&lf_call.state, memory_order_acquire); +static pthread_t thread; +mutex_t run_mutex, done_mutex; - if (state == 1) { - // Execute the call - switch (lf_call.type) { - case CALL_SERIALIZE: - lf_call.result = ((bool (*)(void*, size_t))lf_call.fn)( - lf_call.arg1, lf_call.arg2); - break; - case CALL_UNSERIALIZE: - lf_call.result = ((bool (*)(void*, size_t))lf_call.fn)( - lf_call.arg1, lf_call.arg2); - break; - case CALL_VOID: - default: - ((void (*)(void))lf_call.fn)(); - break; - } - - // Mark as done - atomic_store_explicit(&lf_call.state, 2, memory_order_release); - } else { - // Spin with CPU hint to reduce power consumption - cpu_relax(); +void *run_loop(void *unused) { + core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop start\n"); + mutex_lock(&done_mutex); + mutex_lock(&run_mutex); + mutex_signal(&done_mutex); + mutex_unlock(&done_mutex); + while (initialized) { + mutex_wait(&run_mutex); + switch (call.type) { + case CALL_SERIALIZE: + case CALL_UNSERIALIZE: + *(bool*)call.result = ((bool (*)(void*, size_t))call.fn)(call.arg1, *(size_t*)call.arg2); + break; + default: + ((void (*)(void)) call.fn)(); } + mutex_lock(&done_mutex); + mutex_signal(&done_mutex); + mutex_unlock(&done_mutex); } - - core_log_cgo(RETRO_LOG_DEBUG, "Worker thread stopped\n"); - return NULL; + mutex_destroy(&run_mutex); + mutex_destroy(&done_mutex); + pthread_detach(thread); + core_log_cgo(RETRO_LOG_DEBUG, "UnLibCo run loop stop\n"); + pthread_exit(NULL); } -// Initialize the worker thread if not already running -static void same_thread_ensure_init(void) { - int expected = 0; - if (atomic_compare_exchange_strong_explicit( - &thread_running, &expected, 1, - memory_order_acq_rel, memory_order_acquire)) { - // We won the race to initialize - atomic_store_explicit(&lf_call.state, 0, memory_order_release); - pthread_create(&worker_thread, NULL, run_loop_fast, NULL); - core_log_cgo(RETRO_LOG_DEBUG, "Worker thread initialized\n"); - } +void same_thread_stop() { + initialized = 0; } -// Stop the worker thread -void same_thread_stop(void) { - if (atomic_load_explicit(&thread_running, memory_order_acquire)) { - atomic_store_explicit(&thread_running, 0, memory_order_release); - pthread_join(worker_thread, NULL); - core_log_cgo(RETRO_LOG_DEBUG, "Worker thread stopped\n"); +void *same_thread_with_args(void *f, int type, ...) { + if (!initialized) { + initialized = 1; + mutex_init(&run_mutex); + mutex_init(&done_mutex); + mutex_lock(&done_mutex); + pthread_create(&thread, NULL, run_loop, NULL); + mutex_wait(&done_mutex); + mutex_unlock(&done_mutex); } + mutex_lock(&run_mutex); + mutex_lock(&done_mutex); + + call.type = type; + call.fn = f; + + if (type != CALL_VOID) { + va_list args; + va_start(args, type); + switch (type) { + case CALL_SERIALIZE: + case CALL_UNSERIALIZE: + call.arg1 = va_arg(args, void*); + size_t size; + size = va_arg(args, size_t); + call.arg2 = &size; + bool result; + call.result = &result; + break; + } + va_end(args); + } + mutex_signal(&run_mutex); + mutex_unlock(&run_mutex); + mutex_wait(&done_mutex); + mutex_unlock(&done_mutex); + return call.result; } -// Execute a void function on the worker thread -void same_thread(void *f) { - same_thread_ensure_init(); - - // Wait for any previous call to complete - while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 0) { - cpu_relax(); - } - - // Set up the call - lf_call.fn = f; - lf_call.type = CALL_VOID; - - // Signal that a call is pending - atomic_store_explicit(&lf_call.state, 1, memory_order_release); - - // Wait for completion - while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 2) { - cpu_relax(); - } - - // Reset to idle - atomic_store_explicit(&lf_call.state, 0, memory_order_release); -} - -// Execute a serialize/unserialize function on the worker thread -// Returns pointer to the result (stored in lf_call.result) -bool same_thread_serialize(void *f, int type, void *data, size_t size) { - same_thread_ensure_init(); - - // Wait for any previous call to complete - while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 0) { - cpu_relax(); - } - - // Set up the call - store values directly, not pointers to locals! - lf_call.fn = f; - lf_call.type = type; - lf_call.arg1 = data; - lf_call.arg2 = size; - lf_call.result = false; - - // Signal that a call is pending - atomic_store_explicit(&lf_call.state, 1, memory_order_release); - - // Wait for completion - while (atomic_load_explicit(&lf_call.state, memory_order_acquire) != 2) { - cpu_relax(); - } - - // Get result before resetting - bool result = lf_call.result; - - // Reset to idle - atomic_store_explicit(&lf_call.state, 0, memory_order_release); - - return result; -} - -// Execute functions on the same thread. void *same_thread_with_args2(void *f, int type, void *arg1, void *arg2) { - size_t size = *(size_t*)arg2; + return same_thread_with_args(f, type, arg1, arg2); +} - static _Thread_local bool result_storage; - result_storage = same_thread_serialize(f, type, arg1, size); - - return &result_storage; -} \ No newline at end of file +void same_thread(void *f) { + same_thread_with_args(f, CALL_VOID); +} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 710e0aec..7c05d962 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -504,8 +504,9 @@ func SaveState() (State, error) { rez := false if Nan0.LibCo && !Nan0.hackSkipSameThreadSave { - rez = *(*bool)(C.same_thread_with_args2(retroSerialize, C.int(CallSerialize), - unsafe.Pointer(&data[0]), unsafe.Pointer(&size))) + 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]), size)) } if !rez { @@ -525,8 +526,9 @@ func RestoreSaveState(st State) error { rez := false if Nan0.LibCo { - rez = *(*bool)(C.same_thread_with_args2(retroUnserialize, C.int(CallUnserialize), - unsafe.Pointer(&st[0]), unsafe.Pointer(&size))) + 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 { From 671e875f1298f7cf564b5cc53a64019246696be9 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 13:53:21 +0300 Subject: [PATCH 220/240] Add input cache for retropad, keyboard and mouse --- pkg/worker/caged/libretro/nanoarch/input.go | 43 ++++++- pkg/worker/caged/libretro/nanoarch/nanoarch.c | 119 ++++++++++++++++-- .../caged/libretro/nanoarch/nanoarch.go | 64 ++-------- 3 files changed, 163 insertions(+), 63 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/input.go b/pkg/worker/caged/libretro/nanoarch/input.go index 246988d4..e095bbd8 100644 --- a/pkg/worker/caged/libretro/nanoarch/input.go +++ b/pkg/worker/caged/libretro/nanoarch/input.go @@ -6,8 +6,16 @@ import ( "sync/atomic" ) -//#include -//#include "libretro.h" +/* +#include +#include "libretro.h" + +void input_cache_set_port(unsigned port, uint32_t buttons, + int16_t axis0, int16_t axis1, int16_t axis2, int16_t axis3); +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 ( @@ -84,6 +92,19 @@ func (s *InputState) IsDpadTouched(port uint, axis uint) (shift C.int16_t) { return C.int16_t(atomic.LoadInt32(&s[port].axes[axis])) } +// SyncToCache syncs the entire input state to the C-side cache. +// Call this once before each Run() instead of having C call back into Go. +func (s *InputState) SyncToCache() { + for port := uint(0); port < maxPort; port++ { + buttons := atomic.LoadUint32(&s[port].keys) + axis0 := C.int16_t(atomic.LoadInt32(&s[port].axes[0])) + axis1 := C.int16_t(atomic.LoadInt32(&s[port].axes[1])) + axis2 := C.int16_t(atomic.LoadInt32(&s[port].axes[2])) + axis3 := C.int16_t(atomic.LoadInt32(&s[port].axes[3])) + C.input_cache_set_port(C.uint(port), C.uint32_t(buttons), axis0, axis1, axis2, axis3) + } +} + // SetKey sets keyboard state. // // 0 1 2 3 4 5 6 @@ -120,6 +141,15 @@ func (ks *KeyboardState) Pressed(key uint) C.int16_t { return Released } +// SyncToCache syncs keyboard state to the C-side cache. +func (ks *KeyboardState) SyncToCache() { + ks.mu.Lock() + defer ks.mu.Unlock() + for id, pressed := range ks.keys { + C.input_cache_set_keyboard_key(C.uint(id), C.uint8_t(pressed)) + } +} + // ShiftPos sets mouse relative position state. // // 0 1 2 3 @@ -148,3 +178,12 @@ func (ms *MouseState) Buttons() (l, r, m bool) { m = mbs&MouseMiddle != 0 return } + +// SyncToCache syncs mouse state to the C-side cache. +// This consumes the delta values (swaps to 0). +func (ms *MouseState) SyncToCache() { + dx := C.int16_t(ms.dx.Swap(0)) + dy := C.int16_t(ms.dy.Swap(0)) + buttons := C.uint8_t(ms.buttons.Load()) + C.input_cache_set_mouse(dx, dy, buttons) +} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index 6290150c..d9010539 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -3,17 +3,18 @@ #include #include #include +#include #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; @@ -26,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 { + // Retropad: store raw button bitmask and analog axes per port + uint32_t buttons[INPUT_MAX_PORTS]; + int16_t analog[INPUT_MAX_PORTS][4]; // 4 axes per port + + // Keyboard + uint8_t keyboard[INPUT_MAX_KEYS]; + + // Mouse + int16_t mouse_x; + int16_t mouse_y; + uint8_t mouse_buttons; // bit 0=left, bit 1=right, bit 2=middle +} 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 axis0, int16_t axis1, int16_t axis2, int16_t axis3) { + if (port < INPUT_MAX_PORTS) { + input_cache.buttons[port] = buttons; + input_cache.analog[port][0] = axis0; + input_cache.analog[port][1] = axis1; + input_cache.analog[port][2] = axis2; + input_cache.analog[port][3] = axis3; + } +} + +// 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; @@ -144,8 +196,61 @@ void core_input_poll_cgo() { } 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; + } + + switch (device) { + case RETRO_DEVICE_JOYPAD: + // Extract button bit from cached bitmask + return (int16_t)((input_cache.buttons[port] >> id) & 1); + + case RETRO_DEVICE_ANALOG: + switch (index) { + case RETRO_DEVICE_INDEX_ANALOG_LEFT: + // id: 0=X, 1=Y + if (id < 2) { + return input_cache.analog[port][id]; + } + break; + case RETRO_DEVICE_INDEX_ANALOG_RIGHT: + // id: 0=X, 1=Y -> stored in axes[2], axes[3] + if (id < 2) { + return input_cache.analog[port][2 + id]; + } + 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; // Consume delta + return x; + } + case RETRO_DEVICE_ID_MOUSE_Y: { + int16_t y = input_cache.mouse_y; + input_cache.mouse_y = 0; // Consume delta + 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) { diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 7c05d962..e0be87fd 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -396,12 +396,21 @@ 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") @@ -672,59 +681,6 @@ func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) { Nan0.Handlers.OnVideo(data_, int32(dt), FrameInfo{W: width, H: height, Stride: packed}) } -//export coreInputState -func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t { - //Nan0.log.Debug().Msgf("%v %v %v %v", port, device, index, id) - - // something like PCSX-ReArmed has 8 ports - if port >= maxPort { - return Released - } - - switch device { - case C.RETRO_DEVICE_JOYPAD: - return Nan0.retropad.IsKeyPressed(uint(port), int(id)) - case C.RETRO_DEVICE_ANALOG: - switch index { - case C.RETRO_DEVICE_INDEX_ANALOG_LEFT: - return Nan0.retropad.IsDpadTouched(uint(port), uint(index*2+id)) - case C.RETRO_DEVICE_INDEX_ANALOG_RIGHT: - case C.RETRO_DEVICE_INDEX_ANALOG_BUTTON: - } - case C.RETRO_DEVICE_KEYBOARD: - return Nan0.keyboard.Pressed(uint(id)) - case C.RETRO_DEVICE_MOUSE: - switch id { - case C.RETRO_DEVICE_ID_MOUSE_X: - x := Nan0.mouse.PopX() - return x - case C.RETRO_DEVICE_ID_MOUSE_Y: - y := Nan0.mouse.PopY() - return y - case C.RETRO_DEVICE_ID_MOUSE_LEFT: - if l, _, _ := Nan0.mouse.Buttons(); l { - return Pressed - } - case C.RETRO_DEVICE_ID_MOUSE_RIGHT: - if _, r, _ := Nan0.mouse.Buttons(); r { - return Pressed - } - case C.RETRO_DEVICE_ID_MOUSE_WHEELUP: - case C.RETRO_DEVICE_ID_MOUSE_WHEELDOWN: - case C.RETRO_DEVICE_ID_MOUSE_MIDDLE: - if _, _, m := Nan0.mouse.Buttons(); m { - return Pressed - } - case C.RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELUP: - case C.RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELDOWN: - case C.RETRO_DEVICE_ID_MOUSE_BUTTON_4: - case C.RETRO_DEVICE_ID_MOUSE_BUTTON_5: - } - } - - return Released -} - //export coreAudioSampleBatch func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t { if Nan0.Stopped.Load() { From 9d54ea4c493cbddd0fbd777fef2abb413a99e404 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 16:24:35 +0300 Subject: [PATCH 221/240] Add and use Speex audio resampler --- Dockerfile | 1 + README.md | 6 +- go.mod | 29 ++--- go.sum | 30 +++++ pkg/config/config.yaml | 6 +- pkg/worker/media/buffer.go | 237 ++++++++++++++++--------------------- 6 files changed, 155 insertions(+), 154 deletions(-) diff --git a/Dockerfile b/Dockerfile index b3da7099..3b3f23c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/* diff --git a/README.md b/README.md index 1f357d4a..1054d2ab 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,13 @@ a better sense of performance. ``` # 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-ucrt-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,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) diff --git a/go.mod b/go.mod index d5ebd227..67bdd591 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25 require ( github.com/VictoriaMetrics/metrics v1.40.2 + github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 @@ -12,15 +13,15 @@ require ( 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.0.10 + 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.6 + 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.45.0 - golang.org/x/image v0.30.0 + golang.org/x/crypto v0.46.0 + golang.org/x/image v0.34.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -29,7 +30,7 @@ require ( 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.1 // 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 @@ -40,23 +41,23 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.0.7 // 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.16 // indirect - github.com/pion/rtp v1.8.25 // indirect - github.com/pion/sctp v1.8.40 // indirect + github.com/pion/rtp v1.8.26 // indirect + github.com/pion/sctp v1.8.41 // indirect github.com/pion/sdp/v3 v3.0.16 // indirect - github.com/pion/srtp/v3 v3.0.8 // indirect - github.com/pion/stun/v3 v3.0.1 // 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.5.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 github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // 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 ) diff --git a/go.sum b/go.sum index 55499af2..a8bf22f5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac= github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA= +github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225 h1:qZ9Sv2nmB9oFAZLdhsvjpBW4NyKexrSnCzjQJPfcaTU= +github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225/go.mod h1:gui3wdg1cup88TpLbUDkl88CPrD+b9ICs886eDh2hOQ= 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= @@ -24,6 +26,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +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= @@ -60,8 +64,12 @@ github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= +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.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= +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= @@ -74,20 +82,30 @@ 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.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw= github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +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/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= +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/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= +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.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA= github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw= +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.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= +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= @@ -101,6 +119,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc= github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +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= @@ -111,17 +131,27 @@ 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 275eea59..9b142aff 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -341,9 +341,9 @@ encoder: frames: - 10 - 5 - # linear (1) or nearest neighbour (0) audio resampler - # linear should sound slightly better - resampler: 1 + # speex (2), linear (1) or nearest neighbour (0) audio resampler + # linear should sound slightly better than 0 + resampler: 2 video: # h264, vpx (vp8) or vp9 codec: h264 diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index 836e1c53..223c0e43 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -1,28 +1,28 @@ package media -import "errors" +import ( + "errors" + + "github.com/aam335/speexdsp" +) type ResampleAlgo uint8 const ( ResampleNearest ResampleAlgo = iota ResampleLinear + ResampleSpeex ) -// preallocated scratch buffer for resampling output -// size for max Opus frame: 60ms at 48kHz stereo = 48000 * 0.06 * 2 = 5760 samples -var stretchBuf = make(samples, 5760) - -// buffer is a simple non-concurrent safe buffer for audio samples. type buffer struct { - useResample bool - algo ResampleAlgo - srcHz int - - raw samples - - buckets []bucket - bi int + raw samples + scratch samples + buckets []bucket + resampler *speexdsp.Resampler + srcHz int + dstHz int + bi int + algo ResampleAlgo } type bucket struct { @@ -33,181 +33,150 @@ type bucket struct { } func newBuffer(frames []float32, hz int) (*buffer, error) { - if hz < 2000 { - return nil, errors.New("hz should be > 2000") - } - if len(frames) == 0 { - return nil, errors.New("frames list is empty") + if hz < 2000 || len(frames) == 0 { + return nil, errors.New("invalid params") } - buf := buffer{srcHz: hz} - - totalSize := 0 + var totalSize int for _, f := range frames { - totalSize += frameStereoSamples(hz, f) + totalSize += stereoSamples(hz, f) } - if totalSize == 0 { - return nil, errors.New("calculated buffer size is 0, check params") + return nil, errors.New("zero buffer size") } - buf.raw = make(samples, totalSize) + buf := &buffer{ + raw: make(samples, totalSize), + scratch: make(samples, 5760), + srcHz: hz, + dstHz: hz, + } - // map buckets to the raw continuous array offset := 0 for _, f := range frames { - size := frameStereoSamples(hz, f) - buf.buckets = append(buf.buckets, bucket{ - mem: buf.raw[offset : offset+size], - ms: f, - }) + size := stereoSamples(hz, f) + buf.buckets = append(buf.buckets, bucket{mem: buf.raw[offset : offset+size], ms: f, dst: size}) offset += size } - - // start with the largest bucket (last one, assuming frames are sorted ascending) buf.bi = len(buf.buckets) - 1 - return &buf, nil + return buf, nil } -// cur returns the current bucket pointer -func (b *buffer) cur() *bucket { return &b.buckets[b.bi] } +func (b *buffer) close() { + if b.resampler != nil { + b.resampler.Destroy() + b.resampler = nil + } +} + +func (b *buffer) resample(targetHz int, algo ResampleAlgo) error { + b.algo = algo + b.dstHz = targetHz + + for i := range b.buckets { + b.buckets[i].dst = stereoSamples(targetHz, b.buckets[i].ms) + } + + if algo == ResampleSpeex { + var err error + if b.resampler, err = speexdsp.ResamplerInit(2, b.srcHz, targetHz, speexdsp.QualityDesktop); err != nil { + return err + } + } + return nil +} + +func (b *buffer) write(s samples, onFull func(samples, float32)) int { + read := 0 + for read < len(s) { + cur := &b.buckets[b.bi] + n := copy(cur.mem[cur.p:], s[read:]) + read += n + cur.p += n + + if cur.p == len(cur.mem) { + onFull(b.stretch(cur.mem, cur.dst), cur.ms) + b.choose(len(s) - read) + b.buckets[b.bi].p = 0 + } + } + return read +} -// choose selects the best bucket for the remaining samples. -// It picks the largest bucket that can be completely filled. -// Buckets should be sorted by size ascending for this to work optimally. func (b *buffer) choose(remaining int) { - // search from largest to smallest for i := len(b.buckets) - 1; i >= 0; i-- { if remaining >= len(b.buckets[i].mem) { b.bi = i return } } - // fall back to smallest bucket if remaining < all bucket sizes b.bi = 0 } -// resample enables resampling to target Hz with specified algorithm -func (b *buffer) resample(targetHz int, algo ResampleAlgo) { - b.useResample = true - b.algo = algo - for i := range b.buckets { - b.buckets[i].dst = frameStereoSamples(targetHz, b.buckets[i].ms) - } -} - -// stretch applies the selected resampling algorithm func (b *buffer) stretch(src samples, dstSize int) samples { switch b.algo { - case ResampleNearest: - return stretchNearest(src, dstSize) - case ResampleLinear: - return stretchLinear(src, dstSize) - default: - return stretchLinear(src, dstSize) - } -} - -// write fills the buffer and calls onFull when a complete frame is ready. -// returns the number of samples consumed. -func (b *buffer) write(s samples, onFull func(samples, float32)) int { - read := 0 - for read < len(s) { - cur := b.cur() - - // copy all samples into current bucket - n := copy(cur.mem[cur.p:], s[read:]) - read += n - cur.p += n - - // bucket is full - emit frame - if cur.p == len(cur.mem) { - if b.useResample { - onFull(b.stretch(cur.mem, cur.dst), cur.ms) - } else { - onFull(cur.mem, cur.ms) + case ResampleSpeex: + if b.resampler != nil { + if _, out, err := b.resampler.PocessIntInterleaved(src); err == nil { + if len(out) == dstSize { + return out + } + src = out // use speex output for linear correction } - - // select next bucket and reset write position - b.choose(len(s) - read) - b.cur().p = 0 } + fallthrough + case ResampleLinear: + return b.linear(src, dstSize) + case ResampleNearest: + return b.nearest(src, dstSize) + default: + return b.linear(src, dstSize) } - return read } -// frameStereoSamples calculates stereo frame size in samples. -// e.g., 48000 Hz * 20ms = 960 samples/channel * 2 channels = 1920 total samples -func frameStereoSamples(hz int, ms float32) int { - samplesPerChannel := int(float32(hz)*ms/1000 + 0.5) // round to nearest - return samplesPerChannel * 2 // stereo -} - -// stretchLinear resamples stereo audio using linear interpolation. -func stretchLinear(src samples, dstSize int) samples { +func (b *buffer) linear(src samples, dstSize int) samples { srcLen := len(src) if srcLen < 2 || dstSize < 2 { - return stretchBuf[:dstSize] + return b.scratch[:dstSize] } - out := stretchBuf[:dstSize] - - srcPairs := srcLen / 2 - dstPairs := dstSize / 2 - - // Fixed-point ratio for precision (16.16 fixed point) + out := b.scratch[:dstSize] + srcPairs, dstPairs := srcLen/2, dstSize/2 ratio := ((srcPairs - 1) << 16) / (dstPairs - 1) for i := 0; i < dstPairs; i++ { - // Calculate source position in fixed-point pos := i * ratio - srcIdx := pos >> 16 - frac := pos & 0xFFFF + idx, frac := (pos>>16)*2, pos&0xFFFF + di := i * 2 - dstIdx := i * 2 - - if srcIdx >= srcPairs-1 { - // Last sample - no interpolation - out[dstIdx] = src[srcLen-2] - out[dstIdx+1] = src[srcLen-1] + if idx >= srcLen-2 { + out[di], out[di+1] = src[srcLen-2], src[srcLen-1] } else { - // Linear interpolation for both channels - srcBase := srcIdx * 2 - - // Left channel - l0 := int32(src[srcBase]) - l1 := int32(src[srcBase+2]) - out[dstIdx] = int16(l0 + ((l1-l0)*int32(frac))>>16) - - // Right channel - r0 := int32(src[srcBase+1]) - r1 := int32(src[srcBase+3]) - out[dstIdx+1] = int16(r0 + ((r1-r0)*int32(frac))>>16) + out[di] = int16(int32(src[idx]) + ((int32(src[idx+2])-int32(src[idx]))*int32(frac))>>16) + out[di+1] = int16(int32(src[idx+1]) + ((int32(src[idx+3])-int32(src[idx+1]))*int32(frac))>>16) } } - return out } -// stretchNearest is a faster nearest-neighbor version if quality isn't critical -func stretchNearest(src samples, dstSize int) samples { +func (b *buffer) nearest(src samples, dstSize int) samples { srcLen := len(src) if srcLen < 2 || dstSize < 2 { - return stretchBuf[:dstSize] + return b.scratch[:dstSize] } - out := stretchBuf[:dstSize] - - srcPairs := srcLen / 2 - dstPairs := dstSize / 2 + out := b.scratch[:dstSize] + srcPairs, dstPairs := srcLen/2, dstSize/2 for i := 0; i < dstPairs; i++ { - srcIdx := (i * srcPairs / dstPairs) * 2 - dstIdx := i * 2 - out[dstIdx] = src[srcIdx] - out[dstIdx+1] = src[srcIdx+1] + si := (i * srcPairs / dstPairs) * 2 + di := i * 2 + out[di], out[di+1] = src[si], src[si+1] } - return out } + +func stereoSamples(hz int, ms float32) int { + return int(float32(hz)*ms/1000+0.5) * 2 +} From e2f3e005ef8c1f541e9d5f9574be4e40ed9d6b0e Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 16:31:13 +0300 Subject: [PATCH 222/240] Fix speex build libs --- .github/workflows/build.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66b0b414..40fed385 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: sudo apt-get -qq install -y \ make pkg-config \ libvpx-dev libx264-dev libopus-dev libyuv-dev libjpeg-turbo8-dev \ - libsdl2-dev libgl1 libglx-mesa0 + libsdl2-dev libgl1 libglx-mesa0 libspeexdsp-dev make build xvfb-run --auto-servernum make test verify-cores @@ -44,7 +44,7 @@ jobs: - name: macOS if: matrix.os == 'macos-12' run: | - brew install libvpx x264 sdl2 + brew install libvpx x264 sdl2 speexdsp make build test verify-cores - uses: msys2/setup-msys2@v2 @@ -63,6 +63,7 @@ jobs: 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' From 9feb7881082cbb5e08471b33099ff22b9d1e6df8 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 17:01:04 +0300 Subject: [PATCH 223/240] Make speexdsp statically linked --- go.mod | 1 - go.sum | 30 --------- pkg/worker/media/buffer.go | 10 +-- pkg/worker/media/speex.go | 97 ++++++++++++++++++++++++++++++ pkg/worker/media/speex_resampler.h | 59 ++++++++++++++++++ 5 files changed, 159 insertions(+), 38 deletions(-) create mode 100644 pkg/worker/media/speex.go create mode 100644 pkg/worker/media/speex_resampler.h diff --git a/go.mod b/go.mod index 67bdd591..7763a58c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25 require ( github.com/VictoriaMetrics/metrics v1.40.2 - github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225 github.com/cavaliergopher/grab/v3 v3.0.1 github.com/fsnotify/fsnotify v1.9.0 github.com/goccy/go-json v0.10.5 diff --git a/go.sum b/go.sum index a8bf22f5..b5ab82f5 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac= github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA= -github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225 h1:qZ9Sv2nmB9oFAZLdhsvjpBW4NyKexrSnCzjQJPfcaTU= -github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225/go.mod h1:gui3wdg1cup88TpLbUDkl88CPrD+b9ICs886eDh2hOQ= 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= @@ -24,8 +22,6 @@ 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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= 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= @@ -62,12 +58,8 @@ 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.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= -github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= 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.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= -github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= 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= @@ -80,30 +72,20 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.8.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw= -github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= 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/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= -github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= 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/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= -github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= 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.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA= -github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw= 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.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= -github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= 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= @@ -117,8 +99,6 @@ 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.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc= -github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= 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= @@ -129,27 +109,17 @@ github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/ 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= -golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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= diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index 223c0e43..7d528452 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -1,10 +1,6 @@ package media -import ( - "errors" - - "github.com/aam335/speexdsp" -) +import "errors" type ResampleAlgo uint8 @@ -18,7 +14,7 @@ type buffer struct { raw samples scratch samples buckets []bucket - resampler *speexdsp.Resampler + resampler *Resampler srcHz int dstHz int bi int @@ -80,7 +76,7 @@ func (b *buffer) resample(targetHz int, algo ResampleAlgo) error { if algo == ResampleSpeex { var err error - if b.resampler, err = speexdsp.ResamplerInit(2, b.srcHz, targetHz, speexdsp.QualityDesktop); err != nil { + if b.resampler, err = ResamplerInit(2, b.srcHz, targetHz, QualityDesktop); err != nil { return err } } diff --git a/pkg/worker/media/speex.go b/pkg/worker/media/speex.go new file mode 100644 index 00000000..3b4b18cd --- /dev/null +++ b/pkg/worker/media/speex.go @@ -0,0 +1,97 @@ +package media + +/* + #cgo pkg-config: speexdsp + #cgo st LDFLAGS: -l:libspeexdsp.a + + #include + #include "speex_resampler.h" +*/ +import "C" + +import "errors" + +type Resampler struct { + resampler *C.SpeexResamplerState + outBuff []int16 // one of these buffers used when typed data read + outBuffFloat []float32 + channels int + multiplier float32 +} + +// Quality +const ( + QualityMax = 10 + QualityMin = 0 + QualityDefault = 4 + QualityDesktop = 5 + QualityVoid = 3 +) + +// Errors +const ( + ErrorSuccess = iota + ErrorAllocFailed + ErrorBadState + ErrorInvalidArg + ErrorPtrOverlap + ErrorMaxError +) + +const ( + reserve = 1.1 +) + +// ResamplerInit Create a new resampler with integer input and output rates +// Resampling quality between 0 and 10, where 0 has poor quality +// and 10 has very high quality +func ResamplerInit(channels, inRate, outRate, quality int) (*Resampler, error) { + err := C.int(0) + r := &Resampler{channels: channels} + r.multiplier = float32(outRate) / float32(inRate) * 1.1 + 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)) + } + return r, nil +} + +// Destroy a resampler +func (r *Resampler) Destroy() error { + if r.resampler != nil { + C.speex_resampler_destroy((*C.SpeexResamplerState)(r.resampler)) + return nil + } + return StrError(ErrorInvalidArg) +} + +// PocessIntInterleaved Resample an int slice interleaved +func (r *Resampler) PocessIntInterleaved(in []int16) (int, []int16, error) { + outBuffCap := int(float32(len(in)) * r.multiplier) + if outBuffCap > cap(r.outBuff) { + r.outBuff = make([]int16, int(float32(outBuffCap)*reserve)*4) + } + inLen := C.spx_uint32_t(len(in) / r.channels) + outLen := C.spx_uint32_t(len(r.outBuff) / r.channels) + res := C.speex_resampler_process_interleaved_int( + r.resampler, + (*C.spx_int16_t)(&in[0]), + &inLen, + (*C.spx_int16_t)(&r.outBuff[0]), + &outLen, + ) + if res != ErrorSuccess { + return 0, nil, StrError(ErrorInvalidArg) + } + return int(inLen) * r.channels, r.outBuff[:outLen*2], nil +} + +// StrError returns error message +func StrError(errorCode int) error { + cS := C.speex_resampler_strerror(C.int(errorCode)) + if cS == nil { + return nil + } + return errors.New(C.GoString(cS)) +} diff --git a/pkg/worker/media/speex_resampler.h b/pkg/worker/media/speex_resampler.h new file mode 100644 index 00000000..27d510f5 --- /dev/null +++ b/pkg/worker/media/speex_resampler.h @@ -0,0 +1,59 @@ +#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); + +/** 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 \ No newline at end of file From 46a57990797077aa30b16915c4303513cbc74e46 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 18:54:06 +0300 Subject: [PATCH 224/240] Fix media tests --- pkg/worker/media/media_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index f754e17e..10152bf5 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -125,8 +125,9 @@ func TestResampleStretch(t *testing.T) { {name: "", args: args{pcm: gen(1764), size: 1920}, want: nil}, } for _, tt := range tests { + buf, _ := newBuffer([]float32{20}, 2000) t.Run(tt.name, func(t *testing.T) { - rez2 := stretchNearest(tt.args.pcm, tt.args.size) + rez2 := buf.nearest(tt.args.pcm, tt.args.size) if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { @@ -140,8 +141,9 @@ func TestResampleStretch(t *testing.T) { func BenchmarkResampler(b *testing.B) { pcm := samples(gen(1764)) size := 1920 + buf, _ := newBuffer([]float32{20}, 1000) for i := 0; i < b.N; i++ { - stretchLinear(pcm, size) + buf.linear(pcm, size) } } @@ -170,7 +172,7 @@ func TestFrame(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := frameStereoSamples(tt.args.hz, tt.args.frame); got != tt.want { + if got := stereoSamples(tt.args.hz, tt.args.frame); got != tt.want { t.Errorf("frame() = %v, want %v", got, tt.want) } }) From 7c8e74716d17c0ba760ee2c2618b6c38696a789d Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 22:14:55 +0300 Subject: [PATCH 225/240] Disable mGBA low-pass filter --- pkg/config/config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 9b142aff..c6332696 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -260,8 +260,8 @@ emulator: lib: mgba_libretro roms: [ "gba", "gbc" ] options: - mgba_audio_low_pass_filter: enabled - mgba_audio_low_pass_range: 40 + mgba_audio_low_pass_filter: disabled + mgba_audio_low_pass_range: 50 pcsx: lib: pcsx_rearmed_libretro roms: [ "cue", "chd" ] From 1e4e5b3c65d015dc5d3ebbf640dcbd6db5fe7201 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 22:15:28 +0300 Subject: [PATCH 226/240] Clean media buffer --- pkg/worker/media/buffer.go | 176 +++++++++++++---------------- pkg/worker/media/buffer_test.go | 112 ++++++++++++------ pkg/worker/media/media_test.go | 6 +- pkg/worker/media/speex.go | 90 +++++++-------- pkg/worker/media/speex_resampler.h | 31 +++++ 5 files changed, 224 insertions(+), 191 deletions(-) diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index 7d528452..2228085f 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -1,6 +1,9 @@ package media -import "errors" +import ( + "errors" + "slices" +) type ResampleAlgo uint8 @@ -11,163 +14,138 @@ const ( ) type buffer struct { - raw samples - scratch samples - buckets []bucket + in, out samples + frames []float32 resampler *Resampler srcHz int dstHz int - bi int + fi int + p int algo ResampleAlgo } -type bucket struct { - mem samples - ms float32 - p int - dst int -} - func newBuffer(frames []float32, hz int) (*buffer, error) { if hz < 2000 || len(frames) == 0 { return nil, errors.New("invalid params") } - var totalSize int - for _, f := range frames { - totalSize += stereoSamples(hz, f) - } - if totalSize == 0 { - return nil, errors.New("zero buffer size") - } + // frames should be sorted ascending, largest last + frames = slices.Clone(frames) + slices.Sort(frames) - buf := &buffer{ - raw: make(samples, totalSize), - scratch: make(samples, 5760), - srcHz: hz, - dstHz: hz, - } + maxSize := stereoSamples(hz, frames[len(frames)-1]) - offset := 0 - for _, f := range frames { - size := stereoSamples(hz, f) - buf.buckets = append(buf.buckets, bucket{mem: buf.raw[offset : offset+size], ms: f, dst: size}) - offset += size - } - buf.bi = len(buf.buckets) - 1 - - return buf, nil + return &buffer{ + in: make(samples, maxSize), + out: make(samples, maxSize), + frames: frames, + srcHz: hz, + dstHz: hz, + fi: len(frames) - 1, // start with largest + }, nil } func (b *buffer) close() { if b.resampler != nil { b.resampler.Destroy() - b.resampler = nil } } func (b *buffer) resample(targetHz int, algo ResampleAlgo) error { - b.algo = algo - b.dstHz = targetHz - - for i := range b.buckets { - b.buckets[i].dst = stereoSamples(targetHz, b.buckets[i].ms) - } + b.algo, b.dstHz = algo, targetHz + b.out = make(samples, stereoSamples(targetHz, b.frames[len(b.frames)-1])) if algo == ResampleSpeex { var err error - if b.resampler, err = ResamplerInit(2, b.srcHz, targetHz, QualityDesktop); err != nil { - return err - } + b.resampler, err = NewResampler(2, b.srcHz, targetHz, QualityMax) + return err } return nil } -func (b *buffer) write(s samples, onFull func(samples, float32)) int { - read := 0 - for read < len(s) { - cur := &b.buckets[b.bi] - n := copy(cur.mem[cur.p:], s[read:]) - read += n - cur.p += n +func (b *buffer) write(s samples, onFull func(samples, float32)) { + for len(s) > 0 { + srcSize := stereoSamples(b.srcHz, b.frames[b.fi]) - if cur.p == len(cur.mem) { - onFull(b.stretch(cur.mem, cur.dst), cur.ms) - b.choose(len(s) - read) - b.buckets[b.bi].p = 0 + n := copy(b.in[b.p:srcSize], s) + if n == 0 { + // oof + break + } + + s = s[n:] + b.p += n + + if b.p >= srcSize { + onFull(b.stretch(srcSize), b.frames[b.fi]) + b.p = 0 + b.choose(len(s)) } } - return read + // Remaining samples stay in buffer, will be completed on next write } func (b *buffer) choose(remaining int) { - for i := len(b.buckets) - 1; i >= 0; i-- { - if remaining >= len(b.buckets[i].mem) { - b.bi = i + // Find the largest bucket that fits in remaining samples + for i := len(b.frames) - 1; i >= 0; i-- { + if remaining >= stereoSamples(b.srcHz, b.frames[i]) { + b.fi = i return } } - b.bi = 0 + // Nothing fits - use smallest and wait for more data + b.fi = 0 } -func (b *buffer) stretch(src samples, dstSize int) samples { +func (b *buffer) stretch(srcSize int) samples { + dstSize := stereoSamples(b.dstHz, b.frames[b.fi]) + src, out := b.in[:srcSize], b.out[:dstSize] + + if srcSize == dstSize { + return src + } + switch b.algo { case ResampleSpeex: - if b.resampler != nil { - if _, out, err := b.resampler.PocessIntInterleaved(src); err == nil { - if len(out) == dstSize { - return out - } - src = out // use speex output for linear correction - } + if n, _ := b.resampler.Process(src, out); n == dstSize { + return out } fallthrough case ResampleLinear: - return b.linear(src, dstSize) + return linear(src, out) case ResampleNearest: - return b.nearest(src, dstSize) - default: - return b.linear(src, dstSize) + return nearest(src, out) } + return src } -func (b *buffer) linear(src samples, dstSize int) samples { - srcLen := len(src) - if srcLen < 2 || dstSize < 2 { - return b.scratch[:dstSize] +func linear(src, out samples) samples { + sn, dn := len(src)/2, len(out)/2 + if sn < 2 || dn < 2 { + return out } - - out := b.scratch[:dstSize] - srcPairs, dstPairs := srcLen/2, dstSize/2 - ratio := ((srcPairs - 1) << 16) / (dstPairs - 1) - - for i := 0; i < dstPairs; i++ { + ratio := ((sn - 1) << 16) / (dn - 1) + for i := 0; i < dn; i++ { pos := i * ratio - idx, frac := (pos>>16)*2, pos&0xFFFF + si, frac := (pos>>16)*2, pos&0xFFFF di := i * 2 - - if idx >= srcLen-2 { - out[di], out[di+1] = src[srcLen-2], src[srcLen-1] + if si >= len(src)-2 { + out[di], out[di+1] = src[len(src)-2], src[len(src)-1] } else { - out[di] = int16(int32(src[idx]) + ((int32(src[idx+2])-int32(src[idx]))*int32(frac))>>16) - out[di+1] = int16(int32(src[idx+1]) + ((int32(src[idx+3])-int32(src[idx+1]))*int32(frac))>>16) + out[di] = int16(int32(src[si]) + ((int32(src[si+2])-int32(src[si]))*int32(frac))>>16) + out[di+1] = int16(int32(src[si+1]) + ((int32(src[si+3])-int32(src[si+1]))*int32(frac))>>16) } } return out } -func (b *buffer) nearest(src samples, dstSize int) samples { - srcLen := len(src) - if srcLen < 2 || dstSize < 2 { - return b.scratch[:dstSize] +func nearest(src, out samples) samples { + sn, dn := len(src)/2, len(out)/2 + if sn < 2 || dn < 2 { + return out } - - out := b.scratch[:dstSize] - srcPairs, dstPairs := srcLen/2, dstSize/2 - - for i := 0; i < dstPairs; i++ { - si := (i * srcPairs / dstPairs) * 2 - di := i * 2 + for i := 0; i < dn; i++ { + si, di := (i*sn/dn)*2, i*2 out[di], out[di+1] = src[si], src[si+1] } return out diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go index 28a596ba..096b4ef9 100644 --- a/pkg/worker/media/buffer_test.go +++ b/pkg/worker/media/buffer_test.go @@ -11,71 +11,109 @@ type bufWrite struct { } func TestBufferWrite(t *testing.T) { + // At 2000Hz stereo: + // 5ms = 2000 * 0.005 * 2 = 20 samples + // 10ms = 2000 * 0.01 * 2 = 40 samples + tests := []struct { + frames []float32 bufLen int writes []bufWrite expect samples }{ { + frames: []float32{5, 10}, + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 20}, + {sample: 2, len: 40}, + {sample: 3, len: 60}, + }, + expect: samples(rep(1, 20).add(2, 40).add(3, 40).add(3, 20)), + }, + { + frames: []float32{5, 10}, + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 6}, + {sample: 2, len: 36}, + {sample: 3, len: 4}, + }, + expect: samples(rep(1, 6).add(2, 34)), + }, + { + frames: []float32{5, 10}, + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 40}, + }, + expect: samples(rep(1, 40)), + }, + { + frames: []float32{5, 10}, + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 100}, + }, + expect: samples(rep(1, 40).add(1, 40).add(1, 20)), + }, + { + frames: []float32{5}, bufLen: 2000, writes: []bufWrite{ {sample: 1, len: 10}, - {sample: 2, len: 20}, - {sample: 3, len: 30}, + {sample: 2, len: 15}, }, - expect: samples{ - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, - }, - }, - { - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 3}, - {sample: 2, len: 18}, - {sample: 3, len: 2}, - }, - expect: samples{1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, + expect: samples(rep(1, 10).add(2, 10)), }, } - for _, test := range tests { - var lastResult samples - buf, err := newBuffer([]float32{10, 5}, test.bufLen) + for i, test := range tests { + var results samples + buf, err := newBuffer(test.frames, test.bufLen) if err != nil { - t.Fatalf("oof, %v", err) + t.Fatalf("test %d: %v", i, err) } for _, w := range test.writes { - buf.write(samplesOf(w.sample, w.len), - func(s samples, ms float32) { lastResult = s }, - ) + buf.write(samplesOf(w.sample, w.len), func(s samples, ms float32) { + tmp := make(samples, len(s)) + copy(tmp, s) + results = append(results, tmp...) + }) } - if !reflect.DeepEqual(test.expect, lastResult) { - t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, len(buf.buckets)) + if !reflect.DeepEqual(test.expect, results) { + t.Errorf("test %d:\ngot %v (len=%d)\nwant %v (len=%d)", i, results, len(results), test.expect, len(test.expect)) } } } func BenchmarkBufferWrite(b *testing.B) { fn := func(_ samples, _ float32) {} - l := 2000 - buf, err := newBuffer([]float32{10}, l) - if err != nil { - b.Fatalf("oof: %v", err) - } - samples1 := samplesOf(1, l/2) - samples2 := samplesOf(2, l*2) + buf, _ := newBuffer([]float32{10}, 2000) + s1 := samplesOf(1, 1000) + s2 := samplesOf(2, 4000) for i := 0; i < b.N; i++ { - buf.write(samples1, fn) - buf.write(samples2, fn) + buf.write(s1, fn) + buf.write(s2, fn) } } -func samplesOf(v int16, len int) (s samples) { - s = make(samples, len) +// helpers + +func samplesOf(v int16, l int) samples { + s := make(samples, l) for i := range s { s[i] = v } - return + return s +} + +type builder samples + +func rep(v int16, n int) builder { + return builder(samplesOf(v, n)) +} + +func (b builder) add(v int16, n int) builder { + return append(b, samplesOf(v, n)...) } diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 10152bf5..64659427 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -125,9 +125,8 @@ func TestResampleStretch(t *testing.T) { {name: "", args: args{pcm: gen(1764), size: 1920}, want: nil}, } for _, tt := range tests { - buf, _ := newBuffer([]float32{20}, 2000) t.Run(tt.name, func(t *testing.T) { - rez2 := buf.nearest(tt.args.pcm, tt.args.size) + rez2 := nearest(tt.args.pcm, make(samples, tt.args.size)) if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { @@ -141,9 +140,8 @@ func TestResampleStretch(t *testing.T) { func BenchmarkResampler(b *testing.B) { pcm := samples(gen(1764)) size := 1920 - buf, _ := newBuffer([]float32{20}, 1000) for i := 0; i < b.N; i++ { - buf.linear(pcm, size) + linear(pcm, make(samples, size)) } } diff --git a/pkg/worker/media/speex.go b/pkg/worker/media/speex.go index 3b4b18cd..5f9fa591 100644 --- a/pkg/worker/media/speex.go +++ b/pkg/worker/media/speex.go @@ -12,86 +12,74 @@ import "C" import "errors" type Resampler struct { - resampler *C.SpeexResamplerState - outBuff []int16 // one of these buffers used when typed data read - outBuffFloat []float32 - channels int - multiplier float32 + resampler *C.SpeexResamplerState + channels int } -// Quality const ( QualityMax = 10 QualityMin = 0 QualityDefault = 4 QualityDesktop = 5 - QualityVoid = 3 + QualityVoIP = 3 ) -// Errors -const ( - ErrorSuccess = iota - ErrorAllocFailed - ErrorBadState - ErrorInvalidArg - ErrorPtrOverlap - ErrorMaxError -) - -const ( - reserve = 1.1 -) - -// ResamplerInit Create a new resampler with integer input and output rates -// Resampling quality between 0 and 10, where 0 has poor quality -// and 10 has very high quality -func ResamplerInit(channels, inRate, outRate, quality int) (*Resampler, error) { - err := C.int(0) +func NewResampler(channels, inRate, outRate, quality int) (*Resampler, error) { + var err C.int r := &Resampler{channels: channels} - r.multiplier = float32(outRate) / float32(inRate) * 1.1 - 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) + + // Use fractional init for exact ratio + g := gcd(outRate, inRate) + r.resampler = C.speex_resampler_init_frac( + C.spx_uint32_t(channels), + C.spx_uint32_t(outRate/g), + C.spx_uint32_t(inRate/g), + C.spx_uint32_t(inRate), + C.spx_uint32_t(outRate), + C.int(quality), + &err, + ) if r.resampler == nil { - return nil, StrError(int(err)) + return nil, errors.New(C.GoString(C.speex_resampler_strerror(err))) } + + C.speex_resampler_skip_zeros(r.resampler) + return r, nil } -// Destroy a resampler -func (r *Resampler) Destroy() error { +func (r *Resampler) Destroy() { if r.resampler != nil { - C.speex_resampler_destroy((*C.SpeexResamplerState)(r.resampler)) - return nil + C.speex_resampler_destroy(r.resampler) + r.resampler = nil } - return StrError(ErrorInvalidArg) } -// PocessIntInterleaved Resample an int slice interleaved -func (r *Resampler) PocessIntInterleaved(in []int16) (int, []int16, error) { - outBuffCap := int(float32(len(in)) * r.multiplier) - if outBuffCap > cap(r.outBuff) { - r.outBuff = make([]int16, int(float32(outBuffCap)*reserve)*4) +func (r *Resampler) Process(in, out []int16) (int, error) { + if r.resampler == nil || len(in) < r.channels || len(out) < r.channels { + return 0, nil } + inLen := C.spx_uint32_t(len(in) / r.channels) - outLen := C.spx_uint32_t(len(r.outBuff) / r.channels) + outLen := C.spx_uint32_t(len(out) / r.channels) + res := C.speex_resampler_process_interleaved_int( r.resampler, (*C.spx_int16_t)(&in[0]), &inLen, - (*C.spx_int16_t)(&r.outBuff[0]), + (*C.spx_int16_t)(&out[0]), &outLen, ) - if res != ErrorSuccess { - return 0, nil, StrError(ErrorInvalidArg) + if res != 0 { + return 0, errors.New(C.GoString(C.speex_resampler_strerror(res))) } - return int(inLen) * r.channels, r.outBuff[:outLen*2], nil + + return int(outLen) * r.channels, nil } -// StrError returns error message -func StrError(errorCode int) error { - cS := C.speex_resampler_strerror(C.int(errorCode)) - if cS == nil { - return nil +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b } - return errors.New(C.GoString(cS)) + return a } diff --git a/pkg/worker/media/speex_resampler.h b/pkg/worker/media/speex_resampler.h index 27d510f5..88b03fd6 100644 --- a/pkg/worker/media/speex_resampler.h +++ b/pkg/worker/media/speex_resampler.h @@ -36,11 +36,42 @@ SpeexResamplerState *speex_resampler_init(spx_uint32_t nb_channels, spx_uint32_t out_rate, int quality, int *err); +/** Create a new resampler with fractional input/output rates. The sampling + * rate ratio is an arbitrary rational number with both the numerator and + * denominator being 32-bit integers. + * @param nb_channels Number of channels to be processed + * @param ratio_num Numerator of the sampling rate ratio + * @param ratio_den Denominator of the sampling rate ratio + * @param in_rate Input sampling rate rounded to the nearest integer (in Hz). + * @param out_rate Output sampling rate rounded to the nearest integer (in 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_frac(spx_uint32_t nb_channels, + spx_uint32_t ratio_num, + spx_uint32_t ratio_den, + 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 From 3178086dd75cb511af0e1f06c77d6000f46f9201 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 22:29:27 +0300 Subject: [PATCH 227/240] Revert due to weird 32KHz mGBA issues (fix later) --- pkg/worker/media/buffer.go | 176 ++++++++++++++++------------- pkg/worker/media/buffer_test.go | 112 ++++++------------ pkg/worker/media/media_test.go | 6 +- pkg/worker/media/speex.go | 90 ++++++++------- pkg/worker/media/speex_resampler.h | 31 ----- 5 files changed, 191 insertions(+), 224 deletions(-) diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index 2228085f..57adeb90 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -1,9 +1,6 @@ package media -import ( - "errors" - "slices" -) +import "errors" type ResampleAlgo uint8 @@ -14,138 +11,163 @@ const ( ) type buffer struct { - in, out samples - frames []float32 + raw samples + scratch samples + buckets []bucket resampler *Resampler srcHz int dstHz int - fi int - p int + bi int algo ResampleAlgo } +type bucket struct { + mem samples + ms float32 + p int + dst int +} + func newBuffer(frames []float32, hz int) (*buffer, error) { if hz < 2000 || len(frames) == 0 { return nil, errors.New("invalid params") } - // frames should be sorted ascending, largest last - frames = slices.Clone(frames) - slices.Sort(frames) + var totalSize int + for _, f := range frames { + totalSize += stereoSamples(hz, f) + } + if totalSize == 0 { + return nil, errors.New("zero buffer size") + } - maxSize := stereoSamples(hz, frames[len(frames)-1]) + buf := &buffer{ + raw: make(samples, totalSize), + scratch: make(samples, 5760), + srcHz: hz, + dstHz: hz, + } - return &buffer{ - in: make(samples, maxSize), - out: make(samples, maxSize), - frames: frames, - srcHz: hz, - dstHz: hz, - fi: len(frames) - 1, // start with largest - }, nil + offset := 0 + for _, f := range frames { + size := stereoSamples(hz, f) + buf.buckets = append(buf.buckets, bucket{mem: buf.raw[offset : offset+size], ms: f, dst: size}) + offset += size + } + buf.bi = len(buf.buckets) - 1 + + return buf, nil } func (b *buffer) close() { if b.resampler != nil { b.resampler.Destroy() + b.resampler = nil } } func (b *buffer) resample(targetHz int, algo ResampleAlgo) error { - b.algo, b.dstHz = algo, targetHz - b.out = make(samples, stereoSamples(targetHz, b.frames[len(b.frames)-1])) + b.algo = algo + b.dstHz = targetHz + + for i := range b.buckets { + b.buckets[i].dst = stereoSamples(targetHz, b.buckets[i].ms) + } if algo == ResampleSpeex { var err error - b.resampler, err = NewResampler(2, b.srcHz, targetHz, QualityMax) - return err + if b.resampler, err = ResamplerInit(2, b.srcHz, targetHz, QualityMax); err != nil { + return err + } } return nil } -func (b *buffer) write(s samples, onFull func(samples, float32)) { - for len(s) > 0 { - srcSize := stereoSamples(b.srcHz, b.frames[b.fi]) +func (b *buffer) write(s samples, onFull func(samples, float32)) int { + read := 0 + for read < len(s) { + cur := &b.buckets[b.bi] + n := copy(cur.mem[cur.p:], s[read:]) + read += n + cur.p += n - n := copy(b.in[b.p:srcSize], s) - if n == 0 { - // oof - break - } - - s = s[n:] - b.p += n - - if b.p >= srcSize { - onFull(b.stretch(srcSize), b.frames[b.fi]) - b.p = 0 - b.choose(len(s)) + if cur.p == len(cur.mem) { + onFull(b.stretch(cur.mem, cur.dst), cur.ms) + b.choose(len(s) - read) + b.buckets[b.bi].p = 0 } } - // Remaining samples stay in buffer, will be completed on next write + return read } func (b *buffer) choose(remaining int) { - // Find the largest bucket that fits in remaining samples - for i := len(b.frames) - 1; i >= 0; i-- { - if remaining >= stereoSamples(b.srcHz, b.frames[i]) { - b.fi = i + for i := len(b.buckets) - 1; i >= 0; i-- { + if remaining >= len(b.buckets[i].mem) { + b.bi = i return } } - // Nothing fits - use smallest and wait for more data - b.fi = 0 + b.bi = 0 } -func (b *buffer) stretch(srcSize int) samples { - dstSize := stereoSamples(b.dstHz, b.frames[b.fi]) - src, out := b.in[:srcSize], b.out[:dstSize] - - if srcSize == dstSize { - return src - } - +func (b *buffer) stretch(src samples, dstSize int) samples { switch b.algo { case ResampleSpeex: - if n, _ := b.resampler.Process(src, out); n == dstSize { - return out + if b.resampler != nil { + if _, out, err := b.resampler.ProcessIntInterleaved(src); err == nil { + if len(out) == dstSize { + return out + } + src = out // use speex output for linear correction + } } fallthrough case ResampleLinear: - return linear(src, out) + return b.linear(src, dstSize) case ResampleNearest: - return nearest(src, out) + return b.nearest(src, dstSize) + default: + return b.linear(src, dstSize) } - return src } -func linear(src, out samples) samples { - sn, dn := len(src)/2, len(out)/2 - if sn < 2 || dn < 2 { - return out +func (b *buffer) linear(src samples, dstSize int) samples { + srcLen := len(src) + if srcLen < 2 || dstSize < 2 { + return b.scratch[:dstSize] } - ratio := ((sn - 1) << 16) / (dn - 1) - for i := 0; i < dn; i++ { + + out := b.scratch[:dstSize] + srcPairs, dstPairs := srcLen/2, dstSize/2 + ratio := ((srcPairs - 1) << 16) / (dstPairs - 1) + + for i := 0; i < dstPairs; i++ { pos := i * ratio - si, frac := (pos>>16)*2, pos&0xFFFF + idx, frac := (pos>>16)*2, pos&0xFFFF di := i * 2 - if si >= len(src)-2 { - out[di], out[di+1] = src[len(src)-2], src[len(src)-1] + + if idx >= srcLen-2 { + out[di], out[di+1] = src[srcLen-2], src[srcLen-1] } else { - out[di] = int16(int32(src[si]) + ((int32(src[si+2])-int32(src[si]))*int32(frac))>>16) - out[di+1] = int16(int32(src[si+1]) + ((int32(src[si+3])-int32(src[si+1]))*int32(frac))>>16) + out[di] = int16(int32(src[idx]) + ((int32(src[idx+2])-int32(src[idx]))*int32(frac))>>16) + out[di+1] = int16(int32(src[idx+1]) + ((int32(src[idx+3])-int32(src[idx+1]))*int32(frac))>>16) } } return out } -func nearest(src, out samples) samples { - sn, dn := len(src)/2, len(out)/2 - if sn < 2 || dn < 2 { - return out +func (b *buffer) nearest(src samples, dstSize int) samples { + srcLen := len(src) + if srcLen < 2 || dstSize < 2 { + return b.scratch[:dstSize] } - for i := 0; i < dn; i++ { - si, di := (i*sn/dn)*2, i*2 + + out := b.scratch[:dstSize] + srcPairs, dstPairs := srcLen/2, dstSize/2 + + for i := 0; i < dstPairs; i++ { + si := (i * srcPairs / dstPairs) * 2 + di := i * 2 out[di], out[di+1] = src[si], src[si+1] } return out diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go index 096b4ef9..28a596ba 100644 --- a/pkg/worker/media/buffer_test.go +++ b/pkg/worker/media/buffer_test.go @@ -11,109 +11,71 @@ type bufWrite struct { } func TestBufferWrite(t *testing.T) { - // At 2000Hz stereo: - // 5ms = 2000 * 0.005 * 2 = 20 samples - // 10ms = 2000 * 0.01 * 2 = 40 samples - tests := []struct { - frames []float32 bufLen int writes []bufWrite expect samples }{ { - frames: []float32{5, 10}, - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 20}, - {sample: 2, len: 40}, - {sample: 3, len: 60}, - }, - expect: samples(rep(1, 20).add(2, 40).add(3, 40).add(3, 20)), - }, - { - frames: []float32{5, 10}, - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 6}, - {sample: 2, len: 36}, - {sample: 3, len: 4}, - }, - expect: samples(rep(1, 6).add(2, 34)), - }, - { - frames: []float32{5, 10}, - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 40}, - }, - expect: samples(rep(1, 40)), - }, - { - frames: []float32{5, 10}, - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 100}, - }, - expect: samples(rep(1, 40).add(1, 40).add(1, 20)), - }, - { - frames: []float32{5}, bufLen: 2000, writes: []bufWrite{ {sample: 1, len: 10}, - {sample: 2, len: 15}, + {sample: 2, len: 20}, + {sample: 3, len: 30}, }, - expect: samples(rep(1, 10).add(2, 10)), + expect: samples{ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + }, + }, + { + bufLen: 2000, + writes: []bufWrite{ + {sample: 1, len: 3}, + {sample: 2, len: 18}, + {sample: 3, len: 2}, + }, + expect: samples{1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, }, } - for i, test := range tests { - var results samples - buf, err := newBuffer(test.frames, test.bufLen) + for _, test := range tests { + var lastResult samples + buf, err := newBuffer([]float32{10, 5}, test.bufLen) if err != nil { - t.Fatalf("test %d: %v", i, err) + t.Fatalf("oof, %v", err) } for _, w := range test.writes { - buf.write(samplesOf(w.sample, w.len), func(s samples, ms float32) { - tmp := make(samples, len(s)) - copy(tmp, s) - results = append(results, tmp...) - }) + buf.write(samplesOf(w.sample, w.len), + func(s samples, ms float32) { lastResult = s }, + ) } - if !reflect.DeepEqual(test.expect, results) { - t.Errorf("test %d:\ngot %v (len=%d)\nwant %v (len=%d)", i, results, len(results), test.expect, len(test.expect)) + if !reflect.DeepEqual(test.expect, lastResult) { + t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, len(buf.buckets)) } } } func BenchmarkBufferWrite(b *testing.B) { fn := func(_ samples, _ float32) {} - buf, _ := newBuffer([]float32{10}, 2000) - s1 := samplesOf(1, 1000) - s2 := samplesOf(2, 4000) + l := 2000 + buf, err := newBuffer([]float32{10}, l) + if err != nil { + b.Fatalf("oof: %v", err) + } + samples1 := samplesOf(1, l/2) + samples2 := samplesOf(2, l*2) for i := 0; i < b.N; i++ { - buf.write(s1, fn) - buf.write(s2, fn) + buf.write(samples1, fn) + buf.write(samples2, fn) } } -// helpers - -func samplesOf(v int16, l int) samples { - s := make(samples, l) +func samplesOf(v int16, len int) (s samples) { + s = make(samples, len) for i := range s { s[i] = v } - return s -} - -type builder samples - -func rep(v int16, n int) builder { - return builder(samplesOf(v, n)) -} - -func (b builder) add(v int16, n int) builder { - return append(b, samplesOf(v, n)...) + return } diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 64659427..10152bf5 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -125,8 +125,9 @@ func TestResampleStretch(t *testing.T) { {name: "", args: args{pcm: gen(1764), size: 1920}, want: nil}, } for _, tt := range tests { + buf, _ := newBuffer([]float32{20}, 2000) t.Run(tt.name, func(t *testing.T) { - rez2 := nearest(tt.args.pcm, make(samples, tt.args.size)) + rez2 := buf.nearest(tt.args.pcm, tt.args.size) if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { @@ -140,8 +141,9 @@ func TestResampleStretch(t *testing.T) { func BenchmarkResampler(b *testing.B) { pcm := samples(gen(1764)) size := 1920 + buf, _ := newBuffer([]float32{20}, 1000) for i := 0; i < b.N; i++ { - linear(pcm, make(samples, size)) + buf.linear(pcm, size) } } diff --git a/pkg/worker/media/speex.go b/pkg/worker/media/speex.go index 5f9fa591..a306fd2b 100644 --- a/pkg/worker/media/speex.go +++ b/pkg/worker/media/speex.go @@ -12,74 +12,86 @@ import "C" import "errors" type Resampler struct { - resampler *C.SpeexResamplerState - channels int + resampler *C.SpeexResamplerState + outBuff []int16 // one of these buffers used when typed data read + outBuffFloat []float32 + channels int + multiplier float32 } +// Quality const ( QualityMax = 10 QualityMin = 0 QualityDefault = 4 QualityDesktop = 5 - QualityVoIP = 3 + QualityVoid = 3 ) -func NewResampler(channels, inRate, outRate, quality int) (*Resampler, error) { - var err C.int +// Errors +const ( + ErrorSuccess = iota + ErrorAllocFailed + ErrorBadState + ErrorInvalidArg + ErrorPtrOverlap + ErrorMaxError +) + +const ( + reserve = 1.1 +) + +// ResamplerInit Create a new resampler with integer input and output rates +// Resampling quality between 0 and 10, where 0 has poor quality +// and 10 has very high quality +func ResamplerInit(channels, inRate, outRate, quality int) (*Resampler, error) { + err := C.int(0) r := &Resampler{channels: channels} - - // Use fractional init for exact ratio - g := gcd(outRate, inRate) - r.resampler = C.speex_resampler_init_frac( - C.spx_uint32_t(channels), - C.spx_uint32_t(outRate/g), - C.spx_uint32_t(inRate/g), - C.spx_uint32_t(inRate), - C.spx_uint32_t(outRate), - C.int(quality), - &err, - ) + r.multiplier = float32(outRate) / float32(inRate) * 1.1 + 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, errors.New(C.GoString(C.speex_resampler_strerror(err))) + return nil, StrError(int(err)) } - - C.speex_resampler_skip_zeros(r.resampler) - return r, nil } -func (r *Resampler) Destroy() { +// Destroy a resampler +func (r *Resampler) Destroy() error { if r.resampler != nil { - C.speex_resampler_destroy(r.resampler) - r.resampler = nil + C.speex_resampler_destroy((*C.SpeexResamplerState)(r.resampler)) + return nil } + return StrError(ErrorInvalidArg) } -func (r *Resampler) Process(in, out []int16) (int, error) { - if r.resampler == nil || len(in) < r.channels || len(out) < r.channels { - return 0, nil +// ProcessIntInterleaved Resample an int slice interleaved +func (r *Resampler) ProcessIntInterleaved(in []int16) (int, []int16, error) { + outBuffCap := int(float32(len(in)) * r.multiplier) + if outBuffCap > cap(r.outBuff) { + r.outBuff = make([]int16, int(float32(outBuffCap)*reserve)*4) } - inLen := C.spx_uint32_t(len(in) / r.channels) - outLen := C.spx_uint32_t(len(out) / r.channels) - + outLen := C.spx_uint32_t(len(r.outBuff) / r.channels) res := C.speex_resampler_process_interleaved_int( r.resampler, (*C.spx_int16_t)(&in[0]), &inLen, - (*C.spx_int16_t)(&out[0]), + (*C.spx_int16_t)(&r.outBuff[0]), &outLen, ) - if res != 0 { - return 0, errors.New(C.GoString(C.speex_resampler_strerror(res))) + if res != ErrorSuccess { + return 0, nil, StrError(ErrorInvalidArg) } - - return int(outLen) * r.channels, nil + return int(inLen) * r.channels, r.outBuff[:outLen*2], nil } -func gcd(a, b int) int { - for b != 0 { - a, b = b, a%b +// StrError returns error message +func StrError(errorCode int) error { + cS := C.speex_resampler_strerror(C.int(errorCode)) + if cS == nil { + return nil } - return a + return errors.New(C.GoString(cS)) } diff --git a/pkg/worker/media/speex_resampler.h b/pkg/worker/media/speex_resampler.h index 88b03fd6..27d510f5 100644 --- a/pkg/worker/media/speex_resampler.h +++ b/pkg/worker/media/speex_resampler.h @@ -36,42 +36,11 @@ SpeexResamplerState *speex_resampler_init(spx_uint32_t nb_channels, spx_uint32_t out_rate, int quality, int *err); -/** Create a new resampler with fractional input/output rates. The sampling - * rate ratio is an arbitrary rational number with both the numerator and - * denominator being 32-bit integers. - * @param nb_channels Number of channels to be processed - * @param ratio_num Numerator of the sampling rate ratio - * @param ratio_den Denominator of the sampling rate ratio - * @param in_rate Input sampling rate rounded to the nearest integer (in Hz). - * @param out_rate Output sampling rate rounded to the nearest integer (in 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_frac(spx_uint32_t nb_channels, - spx_uint32_t ratio_num, - spx_uint32_t ratio_den, - 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 From b3ccea5f0eea2b23854c25d04165893542ac8dad Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 15 Dec 2025 15:41:51 +0300 Subject: [PATCH 228/240] Refactor media buffer Mostly cleanup. --- pkg/resampler/simple.go | 62 ++++ pkg/resampler/speex.go | 106 ++++++ .../media => resampler}/speex_resampler.h | 11 + pkg/worker/media/buffer.go | 165 ++++----- pkg/worker/media/buffer_test.go | 337 +++++++++++++++--- pkg/worker/media/media_test.go | 68 ---- pkg/worker/media/speex.go | 97 ----- 7 files changed, 531 insertions(+), 315 deletions(-) create mode 100644 pkg/resampler/simple.go create mode 100644 pkg/resampler/speex.go rename pkg/{worker/media => resampler}/speex_resampler.h (82%) delete mode 100644 pkg/worker/media/speex.go diff --git a/pkg/resampler/simple.go b/pkg/resampler/simple.go new file mode 100644 index 00000000..f2859a3e --- /dev/null +++ b/pkg/resampler/simple.go @@ -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 := 0; i < dstPairs; i++ { + 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 := 0; i < dstPairs; i++ { + 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] + } +} diff --git a/pkg/resampler/speex.go b/pkg/resampler/speex.go new file mode 100644 index 00000000..b62d2be1 --- /dev/null +++ b/pkg/resampler/speex.go @@ -0,0 +1,106 @@ +package resampler + +/* + #cgo pkg-config: speexdsp + #cgo st LDFLAGS: -l:libspeexdsp.a + + #include + #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)) +} diff --git a/pkg/worker/media/speex_resampler.h b/pkg/resampler/speex_resampler.h similarity index 82% rename from pkg/worker/media/speex_resampler.h rename to pkg/resampler/speex_resampler.h index 27d510f5..9e046ed7 100644 --- a/pkg/worker/media/speex_resampler.h +++ b/pkg/resampler/speex_resampler.h @@ -41,6 +41,17 @@ SpeexResamplerState *speex_resampler_init(spx_uint32_t nb_channels, */ 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 diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index 57adeb90..e13bb1f0 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -1,6 +1,10 @@ package media -import "errors" +import ( + "errors" + + "github.com/giongto35/cloud-game/v3/pkg/resampler" +) type ResampleAlgo uint8 @@ -11,14 +15,15 @@ const ( ) type buffer struct { - raw samples - scratch samples - buckets []bucket - resampler *Resampler - srcHz int - dstHz int - bi int - algo ResampleAlgo + raw samples + scratch samples + buckets []bucket + srcHz int + dstHz int + bi int + algo ResampleAlgo + + resampler *resampler.Resampler } type bucket struct { @@ -33,30 +38,31 @@ func newBuffer(frames []float32, hz int) (*buffer, error) { return nil, errors.New("invalid params") } - var totalSize int - for _, f := range frames { - totalSize += stereoSamples(hz, f) + buckets := make([]bucket, len(frames)) + var total int + for i, ms := range frames { + n := stereoSamples(hz, ms) + buckets[i] = bucket{ms: ms, dst: n} + total += n } - if totalSize == 0 { + if total == 0 { return nil, errors.New("zero buffer size") } - buf := &buffer{ - raw: make(samples, totalSize), + raw := make(samples, total) + for i, off := 0, 0; i < len(buckets); i++ { + buckets[i].mem = raw[off : off+buckets[i].dst] + off += buckets[i].dst + } + + return &buffer{ + raw: raw, scratch: make(samples, 5760), + buckets: buckets, srcHz: hz, dstHz: hz, - } - - offset := 0 - for _, f := range frames { - size := stereoSamples(hz, f) - buf.buckets = append(buf.buckets, bucket{mem: buf.raw[offset : offset+size], ms: f, dst: size}) - offset += size - } - buf.bi = len(buf.buckets) - 1 - - return buf, nil + bi: len(buckets) - 1, + }, nil } func (b *buffer) close() { @@ -66,43 +72,38 @@ func (b *buffer) close() { } } -func (b *buffer) resample(targetHz int, algo ResampleAlgo) error { - b.algo = algo - b.dstHz = targetHz - +func (b *buffer) resample(hz int, algo ResampleAlgo) error { + b.algo, b.dstHz = algo, hz for i := range b.buckets { - b.buckets[i].dst = stereoSamples(targetHz, b.buckets[i].ms) + b.buckets[i].dst = stereoSamples(hz, b.buckets[i].ms) } - if algo == ResampleSpeex { var err error - if b.resampler, err = ResamplerInit(2, b.srcHz, targetHz, QualityMax); err != nil { - return err - } + b.resampler, err = resampler.Init(2, b.srcHz, hz, resampler.QualityMax) + return err } return nil } func (b *buffer) write(s samples, onFull func(samples, float32)) int { - read := 0 - for read < len(s) { + n := len(s) + for i := 0; i < n; { cur := &b.buckets[b.bi] - n := copy(cur.mem[cur.p:], s[read:]) - read += n - cur.p += n - + c := copy(cur.mem[cur.p:], s[i:]) + i += c + cur.p += c if cur.p == len(cur.mem) { onFull(b.stretch(cur.mem, cur.dst), cur.ms) - b.choose(len(s) - read) + b.choose(n - i) b.buckets[b.bi].p = 0 } } - return read + return n } -func (b *buffer) choose(remaining int) { +func (b *buffer) choose(rem int) { for i := len(b.buckets) - 1; i >= 0; i-- { - if remaining >= len(b.buckets[i].mem) { + if rem >= len(b.buckets[i].mem) { b.bi = i return } @@ -110,65 +111,29 @@ func (b *buffer) choose(remaining int) { b.bi = 0 } -func (b *buffer) stretch(src samples, dstSize int) samples { - switch b.algo { - case ResampleSpeex: - if b.resampler != nil { - if _, out, err := b.resampler.ProcessIntInterleaved(src); err == nil { - if len(out) == dstSize { - return out - } - src = out // use speex output for linear correction +func (b *buffer) stretch(src samples, size int) samples { + if len(src) == size { + return src + } + + if cap(b.scratch) < size { + b.scratch = make(samples, size) + } + out := b.scratch[:size] + + if b.algo == ResampleSpeex && b.resampler != nil { + if n, _ := b.resampler.Process(out, src); n > 0 { + for i := n; i < size; i += 2 { + out[i], out[i+1] = out[n-2], out[n-1] } - } - fallthrough - case ResampleLinear: - return b.linear(src, dstSize) - case ResampleNearest: - return b.nearest(src, dstSize) - default: - return b.linear(src, dstSize) - } -} - -func (b *buffer) linear(src samples, dstSize int) samples { - srcLen := len(src) - if srcLen < 2 || dstSize < 2 { - return b.scratch[:dstSize] - } - - out := b.scratch[:dstSize] - srcPairs, dstPairs := srcLen/2, dstSize/2 - ratio := ((srcPairs - 1) << 16) / (dstPairs - 1) - - for i := 0; i < dstPairs; i++ { - pos := i * ratio - idx, frac := (pos>>16)*2, pos&0xFFFF - di := i * 2 - - if idx >= srcLen-2 { - out[di], out[di+1] = src[srcLen-2], src[srcLen-1] - } else { - out[di] = int16(int32(src[idx]) + ((int32(src[idx+2])-int32(src[idx]))*int32(frac))>>16) - out[di+1] = int16(int32(src[idx+1]) + ((int32(src[idx+3])-int32(src[idx+1]))*int32(frac))>>16) + return out } } - return out -} -func (b *buffer) nearest(src samples, dstSize int) samples { - srcLen := len(src) - if srcLen < 2 || dstSize < 2 { - return b.scratch[:dstSize] - } - - out := b.scratch[:dstSize] - srcPairs, dstPairs := srcLen/2, dstSize/2 - - for i := 0; i < dstPairs; i++ { - si := (i * srcPairs / dstPairs) * 2 - di := i * 2 - out[di], out[di+1] = src[si], src[si+1] + if b.algo == ResampleNearest { + resampler.Nearest(out, src) + } else { + resampler.Linear(out, src) } return out } diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go index 28a596ba..a2be89d8 100644 --- a/pkg/worker/media/buffer_test.go +++ b/pkg/worker/media/buffer_test.go @@ -3,79 +3,316 @@ package media import ( "reflect" "testing" + + "github.com/giongto35/cloud-game/v3/pkg/resampler" ) -type bufWrite struct { - sample int16 - len int +func mustBuffer(t *testing.T, frames []float32, hz int) *buffer { + t.Helper() + buf, err := newBuffer(frames, hz) + if err != nil { + t.Fatalf("failed to create buffer: %v", err) + } + return buf +} + +func samplesOf(v int16, n int) samples { + s := make(samples, n) + for i := range s { + s[i] = v + } + return s +} + +func ramp(pairs int) samples { + s := make(samples, pairs*2) + for i := 0; i < pairs; i++ { + s[i*2], s[i*2+1] = int16(i), int16(i) + } + return s +} + +func TestNewBuffer(t *testing.T) { + tests := []struct { + name string + frames []float32 + hz int + wantErr bool + }{ + {"valid single", []float32{10}, 48000, false}, + {"valid multi", []float32{10, 20}, 48000, false}, + {"hz too low", []float32{10}, 1999, true}, + {"empty frames", []float32{}, 48000, true}, + {"nil frames", nil, 48000, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf, err := newBuffer(tt.frames, tt.hz) + if (err != nil) != tt.wantErr { + t.Errorf("err = %v, wantErr %v", err, tt.wantErr) + } + if buf != nil { + buf.close() + } + }) + } +} + +func TestBufferBucketSizes(t *testing.T) { + buf := mustBuffer(t, []float32{10, 20}, 48000) + defer buf.close() + + if len(buf.buckets) != 2 { + t.Fatalf("got %d buckets, want 2", len(buf.buckets)) + } + if n := len(buf.buckets[0].mem); n != 960 { + t.Errorf("bucket[0] = %d, want 960", n) + } + if n := len(buf.buckets[1].mem); n != 1920 { + t.Errorf("bucket[1] = %d, want 1920", n) + } +} + +func TestBufferClose(t *testing.T) { + buf := mustBuffer(t, []float32{10}, 48000) + buf.close() + buf.close() // idempotent + if buf.resampler != nil { + t.Error("resampler should be nil after close") + } } func TestBufferWrite(t *testing.T) { tests := []struct { - bufLen int - writes []bufWrite - expect samples + name string + writes []struct { + v int16 + n int + } + want samples }{ { - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 10}, - {sample: 2, len: 20}, - {sample: 3, len: 30}, - }, - expect: samples{ - 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, - 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + name: "overflow triggers callback", + writes: []struct { + v int16 + n int + }{{1, 10}, {2, 20}, {3, 30}}, + want: samples{ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, }, }, { - bufLen: 2000, - writes: []bufWrite{ - {sample: 1, len: 3}, - {sample: 2, len: 18}, - {sample: 3, len: 2}, - }, - expect: samples{1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, + name: "partial fill", + writes: []struct { + v int16 + n int + }{{1, 3}, {2, 18}, {3, 2}}, + want: samples{1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2}, }, } - for _, test := range tests { - var lastResult samples - buf, err := newBuffer([]float32{10, 5}, test.bufLen) - if err != nil { - t.Fatalf("oof, %v", err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := mustBuffer(t, []float32{10, 5}, 2000) + defer buf.close() + + var got samples + for _, w := range tt.writes { + buf.write(samplesOf(w.v, w.n), func(s samples, _ float32) { got = s }) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("\ngot: %v\nwant: %v", got, tt.want) + } + }) + } +} + +func TestBufferWriteExact(t *testing.T) { + buf := mustBuffer(t, []float32{10}, 2000) // 40 samples + defer buf.close() + + calls := 0 + buf.write(samplesOf(1, 40), func(_ samples, ms float32) { + calls++ + if ms != 10 { + t.Errorf("ms = %v, want 10", ms) } - for _, w := range test.writes { - buf.write(samplesOf(w.sample, w.len), - func(s samples, ms float32) { lastResult = s }, - ) - } - if !reflect.DeepEqual(test.expect, lastResult) { - t.Errorf("not expted buffer, %v != %v, %v", lastResult, test.expect, len(buf.buckets)) + }) + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } +} + +func TestBufferWriteReturn(t *testing.T) { + buf := mustBuffer(t, []float32{10}, 2000) + defer buf.close() + + if n := buf.write(samplesOf(1, 100), func(samples, float32) {}); n != 100 { + t.Errorf("return = %d, want 100", n) + } +} + +func TestBufferChoose(t *testing.T) { + buf := mustBuffer(t, []float32{20, 10, 5}, 48000) // 1920, 960, 480 + defer buf.close() + + tests := []struct{ rem, want int }{ + {10000, 2}, {500, 2}, {479, 0}, {0, 0}, + } + for _, tt := range tests { + buf.choose(tt.rem) + if buf.bi != tt.want { + t.Errorf("choose(%d) = %d, want %d", tt.rem, buf.bi, tt.want) } } } -func BenchmarkBufferWrite(b *testing.B) { - fn := func(_ samples, _ float32) {} - l := 2000 - buf, err := newBuffer([]float32{10}, l) - if err != nil { - b.Fatalf("oof: %v", err) +func TestStereoSamples(t *testing.T) { + tests := []struct { + hz int + ms float32 + want int + }{ + {16000, 5, 160}, + {32768, 10, 656}, + {32768, 2.5, 164}, + {32768, 5, 328}, + {44100, 10, 882}, + {48000, 10, 960}, + {48000, 2.5, 240}, } - samples1 := samplesOf(1, l/2) - samples2 := samplesOf(2, l*2) - for i := 0; i < b.N; i++ { - buf.write(samples1, fn) - buf.write(samples2, fn) + for _, tt := range tests { + if got := stereoSamples(tt.hz, tt.ms); got != tt.want { + t.Errorf("stereoSamples(%d, %.0f) = %d, want %d", tt.hz, tt.ms, got, tt.want) + } } } -func samplesOf(v int16, len int) (s samples) { - s = make(samples, len) - for i := range s { - s[i] = v +func TestStretchPassthrough(t *testing.T) { + buf := mustBuffer(t, []float32{10}, 48000) + defer buf.close() + + src := samples{1, 2, 3, 4} + if res := buf.stretch(src, 4); &res[0] != &src[0] { + t.Error("expected zero-copy when sizes match") } - return +} + +func TestLinear(t *testing.T) { + t.Run("interpolation", func(t *testing.T) { + out := make(samples, 8) + resampler.Linear(out, samples{0, 0, 100, 100}) + if out[2] <= 0 || out[2] >= 100 { + t.Errorf("middle value %d not interpolated", out[2]) + } + }) + + t.Run("sizes", func(t *testing.T) { + cases := []struct{ srcPairs, dstSize int }{ + {4, 16}, {8, 8}, {4, 8}, + } + for _, tc := range cases { + out := make(samples, tc.dstSize) + resampler.Linear(out, ramp(tc.srcPairs)) + if len(out) != tc.dstSize { + t.Errorf("len = %d, want %d", len(out), tc.dstSize) + } + } + }) +} + +func TestNearest(t *testing.T) { + tests := []struct { + src samples + want samples + }{ + {samples{10, 20, 30, 40}, samples{10, 20, 10, 20, 30, 40, 30, 40}}, + {samples{10, 20, 30, 40, 50, 60, 70, 80}, samples{10, 20, 50, 60}}, + } + for _, tt := range tests { + out := make(samples, len(tt.want)) + resampler.Nearest(out, tt.src) + if !reflect.DeepEqual(out, tt.want) { + t.Errorf("nearest(%v) = %v, want %v", tt.src, out, tt.want) + } + } +} + +func TestSpeex(t *testing.T) { + buf := mustBuffer(t, []float32{10}, 48000) + defer buf.close() + + if err := buf.resample(24000, ResampleSpeex); err != nil { + t.Fatal(err) + } + + t.Run("stretch", func(t *testing.T) { + res := buf.stretch(samplesOf(1000, 960), 480) + if len(res) != 480 { + t.Errorf("len = %d, want 480", len(res)) + } + for _, s := range res { + if s != 0 { + return + } + } + t.Error("output is silent") + }) + + t.Run("write", func(t *testing.T) { + calls := 0 + buf.write(samplesOf(5000, 960), func(s samples, ms float32) { + calls++ + if len(s) != 480 { + t.Errorf("len = %d, want 480", len(s)) + } + if ms != 10 { + t.Errorf("ms = %v, want 10", ms) + } + }) + if calls != 1 { + t.Errorf("calls = %d, want 1", calls) + } + }) +} + +func BenchmarkStretch(b *testing.B) { + src := samplesOf(1000, 1920) // 20ms @ 48kHz + + b.Run("speex", func(b *testing.B) { + buf, _ := newBuffer([]float32{20}, 48000) + defer buf.close() + _ = buf.resample(24000, ResampleSpeex) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.stretch(src, 960) + } + }) + + b.Run("linear", func(b *testing.B) { + buf, _ := newBuffer([]float32{20}, 48000) + defer buf.close() + _ = buf.resample(24000, ResampleLinear) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.stretch(src, 960) + } + }) + + b.Run("nearest", func(b *testing.B) { + buf, _ := newBuffer([]float32{20}, 48000) + defer buf.close() + _ = buf.resample(24000, ResampleNearest) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.stretch(src, 960) + } + }) } diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 10152bf5..3e264e80 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -110,71 +110,3 @@ func genTestImage(w, h int, seed float32) *image.RGBA { } return img } - -func TestResampleStretch(t *testing.T) { - type args struct { - pcm samples - size int - } - tests := []struct { - name string - args args - want []int16 - }{ - //1764:1920 - {name: "", args: args{pcm: gen(1764), size: 1920}, want: nil}, - } - for _, tt := range tests { - buf, _ := newBuffer([]float32{20}, 2000) - t.Run(tt.name, func(t *testing.T) { - rez2 := buf.nearest(tt.args.pcm, tt.args.size) - if rez2[0] != tt.args.pcm[0] || rez2[1] != tt.args.pcm[1] || - rez2[len(rez2)-1] != tt.args.pcm[len(tt.args.pcm)-1] || - rez2[len(rez2)-2] != tt.args.pcm[len(tt.args.pcm)-2] { - t.Logf("%v\n%v", tt.args.pcm, rez2) - t.Errorf("2nd is wrong (2)") - } - }) - } -} - -func BenchmarkResampler(b *testing.B) { - pcm := samples(gen(1764)) - size := 1920 - buf, _ := newBuffer([]float32{20}, 1000) - for i := 0; i < b.N; i++ { - buf.linear(pcm, size) - } -} - -func gen(l int) []int16 { - nums := make([]int16, l) - for i := range nums { - nums[i] = int16(rand.IntN(10)) - } - return nums -} - -func TestFrame(t *testing.T) { - type args struct { - hz int - frame float32 - } - tests := []struct { - name string - args args - want int - }{ - {name: "mGBA", args: args{hz: 32768, frame: 10}, want: 656}, - {name: "mGBA", args: args{hz: 32768, frame: 5}, want: 328}, - {name: "mGBA", args: args{hz: 32768, frame: 2.5}, want: 164}, - {name: "nes", args: args{hz: 48000, frame: 2.5}, want: 240}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := stereoSamples(tt.args.hz, tt.args.frame); got != tt.want { - t.Errorf("frame() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/pkg/worker/media/speex.go b/pkg/worker/media/speex.go deleted file mode 100644 index a306fd2b..00000000 --- a/pkg/worker/media/speex.go +++ /dev/null @@ -1,97 +0,0 @@ -package media - -/* - #cgo pkg-config: speexdsp - #cgo st LDFLAGS: -l:libspeexdsp.a - - #include - #include "speex_resampler.h" -*/ -import "C" - -import "errors" - -type Resampler struct { - resampler *C.SpeexResamplerState - outBuff []int16 // one of these buffers used when typed data read - outBuffFloat []float32 - channels int - multiplier float32 -} - -// Quality -const ( - QualityMax = 10 - QualityMin = 0 - QualityDefault = 4 - QualityDesktop = 5 - QualityVoid = 3 -) - -// Errors -const ( - ErrorSuccess = iota - ErrorAllocFailed - ErrorBadState - ErrorInvalidArg - ErrorPtrOverlap - ErrorMaxError -) - -const ( - reserve = 1.1 -) - -// ResamplerInit Create a new resampler with integer input and output rates -// Resampling quality between 0 and 10, where 0 has poor quality -// and 10 has very high quality -func ResamplerInit(channels, inRate, outRate, quality int) (*Resampler, error) { - err := C.int(0) - r := &Resampler{channels: channels} - r.multiplier = float32(outRate) / float32(inRate) * 1.1 - 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)) - } - return r, nil -} - -// Destroy a resampler -func (r *Resampler) Destroy() error { - if r.resampler != nil { - C.speex_resampler_destroy((*C.SpeexResamplerState)(r.resampler)) - return nil - } - return StrError(ErrorInvalidArg) -} - -// ProcessIntInterleaved Resample an int slice interleaved -func (r *Resampler) ProcessIntInterleaved(in []int16) (int, []int16, error) { - outBuffCap := int(float32(len(in)) * r.multiplier) - if outBuffCap > cap(r.outBuff) { - r.outBuff = make([]int16, int(float32(outBuffCap)*reserve)*4) - } - inLen := C.spx_uint32_t(len(in) / r.channels) - outLen := C.spx_uint32_t(len(r.outBuff) / r.channels) - res := C.speex_resampler_process_interleaved_int( - r.resampler, - (*C.spx_int16_t)(&in[0]), - &inLen, - (*C.spx_int16_t)(&r.outBuff[0]), - &outLen, - ) - if res != ErrorSuccess { - return 0, nil, StrError(ErrorInvalidArg) - } - return int(inLen) * r.channels, r.outBuff[:outLen*2], nil -} - -// StrError returns error message -func StrError(errorCode int) error { - cS := C.speex_resampler_strerror(C.int(errorCode)) - if cS == nil { - return nil - } - return errors.New(C.GoString(cS)) -} From d45daeab7aa810a3155ae0104ca6cdbe83ef15bc Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 15 Dec 2025 18:42:41 +0300 Subject: [PATCH 229/240] Tweak room join/creation logic --- pkg/coordinator/hub.go | 33 ++++++++++++-------- pkg/coordinator/user.go | 7 +++-- pkg/coordinator/userhandlers.go | 50 +++++++++++++++++++++++++++++++ pkg/coordinator/workerhandlers.go | 1 + 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/pkg/coordinator/hub.go b/pkg/coordinator/hub.go index 490747df..9e646ced 100644 --- a/pkg/coordinator/hub.go +++ b/pkg/coordinator/hub.go @@ -69,12 +69,10 @@ func (h *Hub) handleUserConnection() http.HandlerFunc { return } - bound := user.Bind(worker) - if !bound { - user.Notify(api.ErrNoFreeSlots, "") - h.log.Info().Msg("no free slots") - return - } + // Link the user to the selected worker. Slot reservation is handled later + // on game start; this keeps connections lightweight and lets deep-link + // joins share a worker without consuming its single game slot. + user.w = worker h.users.Add(user) @@ -178,12 +176,15 @@ 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) - sessionId, _ := api.ExplodeDeepLink(roomId) - var worker *Worker if wid != "" { @@ -195,7 +196,7 @@ func (h *Hub) findWorkerFor(usr *User, q url.Values, log *logger.Logger) *Worker } } - if worker = h.findWorkerByRoom(roomId, zone); worker != nil { + if worker = h.findWorkerByRoom(roomIdRaw, roomId, zone); worker != nil { log.Debug().Str("room", roomId).Msg("An existing worker has been found") } else if worker = h.findWorkerByPreviousRoom(sessionId); worker != nil { log.Debug().Msgf("Worker %v with the previous room: %v is found", wid, roomId) @@ -228,13 +229,19 @@ func (h *Hub) findWorkerByPreviousRoom(id string) *Worker { return w } -func (h *Hub) findWorkerByRoom(id string, region string) *Worker { - if id == "" { +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 } diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go index 5d1e7ed5..b9d87e7e 100644 --- a/pkg/coordinator/user.go +++ b/pkg/coordinator/user.go @@ -30,14 +30,15 @@ func NewUser(sock *com.Connection, log *logger.Logger) *User { func (u *User) Bind(w *Worker) bool { u.w = w - - return u.w.TryReserve() + // 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()) } } diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go index 811ce332..80d0dc6e 100644 --- a/pkg/coordinator/userhandlers.go +++ b/pkg/coordinator/userhandlers.go @@ -2,6 +2,7 @@ package coordinator import ( "sort" + "time" "github.com/giongto35/cloud-game/v3/pkg/api" "github.com/giongto35/cloud-game/v3/pkg/com" @@ -26,6 +27,55 @@ func (u *User) HandleWebrtcIceCandidate(rq api.WebrtcUserIceCandidate) { } 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 + } + } + startGameResp, err := u.w.StartGame(u.Id(), rq) if err != nil || startGameResp == nil { u.log.Error().Err(err).Msg("malformed game start response") diff --git a/pkg/coordinator/workerhandlers.go b/pkg/coordinator/workerhandlers.go index 5716b621..edd7e210 100644 --- a/pkg/coordinator/workerhandlers.go +++ b/pkg/coordinator/workerhandlers.go @@ -10,6 +10,7 @@ func (w *Worker) HandleRegisterRoom(rq api.RegisterRoomRequest) { w.RoomId = str func (w *Worker) HandleCloseRoom(rq api.CloseRoomRequest) { if string(rq) == w.RoomId { w.RoomId = "" + w.FreeSlots() } } From 7c91d200e4c3ee5e9d33bdfe09673a829396ef72 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 17 Dec 2025 23:12:50 +0300 Subject: [PATCH 230/240] Update Go version to 1.26rc1 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3b3f23c6..1cb760b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG VERSION=master # base build stage FROM ubuntu:plucky AS build0 -ARG GO=1.25.0 +ARG GO=1.26rc1 ARG GO_DIST=go${GO}.linux-amd64.tar.gz ADD https://go.dev/dl/$GO_DIST ./ From c800dd4bf964818226b8fd8f76799285826fc2e8 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 22 Dec 2025 15:08:50 +0300 Subject: [PATCH 231/240] Fix with go fix --- pkg/coordinator/worker_test.go | 4 ++-- pkg/encoder/color/rgba/rgba.go | 4 ++-- pkg/encoder/h264/x264_test.go | 2 +- pkg/encoder/yuv/yuv_test.go | 10 +++++----- pkg/games/library_test.go | 2 +- pkg/resampler/simple.go | 4 ++-- pkg/worker/caged/libretro/frontend_test.go | 6 ++---- pkg/worker/caged/libretro/nanoarch/input_test.go | 2 +- pkg/worker/media/buffer_test.go | 2 +- pkg/worker/media/media_test.go | 6 +++--- pkg/worker/recorder/recorder_test.go | 6 +++--- 11 files changed, 23 insertions(+), 25 deletions(-) diff --git a/pkg/coordinator/worker_test.go b/pkg/coordinator/worker_test.go index a08b8b71..fe4f7a1a 100644 --- a/pkg/coordinator/worker_test.go +++ b/pkg/coordinator/worker_test.go @@ -82,7 +82,7 @@ func testUnReserveConcurrent(t *testing.T) { atomic.StoreInt32((*int32)(&s), int32(workers)) wg.Add(workers) - for i := 0; i < workers; i++ { + for range workers { go func() { defer wg.Done() s.UnReserve() @@ -126,7 +126,7 @@ func testTryReserveConcurrent(t *testing.T) { var wg sync.WaitGroup wg.Add(workers) - for i := 0; i < workers; i++ { + for range workers { go func() { defer wg.Done() if s.TryReserve() { diff --git a/pkg/encoder/color/rgba/rgba.go b/pkg/encoder/color/rgba/rgba.go index c37d6218..5bb2e9bc 100644 --- a/pkg/encoder/color/rgba/rgba.go +++ b/pkg/encoder/color/rgba/rgba.go @@ -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) diff --git a/pkg/encoder/h264/x264_test.go b/pkg/encoder/h264/x264_test.go index 0fe1bb43..822e4cec 100644 --- a/pkg/encoder/h264/x264_test.go +++ b/pkg/encoder/h264/x264_test.go @@ -23,7 +23,7 @@ func Benchmark(b *testing.B) { return } data := make([]byte, int(float64(w)*float64(h)*1.5)) - for i := 0; i < b.N; i++ { + for b.Loop() { h264.Encode(data) } } diff --git a/pkg/encoder/yuv/yuv_test.go b/pkg/encoder/yuv/yuv_test.go index e4e74b30..4e0ebbf7 100644 --- a/pkg/encoder/yuv/yuv_test.go +++ b/pkg/encoder/yuv/yuv_test.go @@ -123,7 +123,7 @@ func TestYuvPredefined(t *testing.T) { t.Fatalf("different size a: %v, o: %v", len(a), len(should)) } - for i := 0; i < len(a); i++ { + for i := range a { if a[i] != should[i] { t.Fatalf("diff in %vth, %v != %v \n%v\n%v", i, a[i], should[i], im, should) } @@ -188,8 +188,8 @@ func BenchmarkYuv(b *testing.B) { func genFrame(w, h int, seed float32) RawFrame { img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) - for x := 0; x < w; x++ { - for y := 0; y < h; y++ { + for x := range w { + for y := range h { col := color.RGBA{R: uint8(seed * 255), G: uint8(seed * 255), B: uint8(seed * 255), A: 0xff} img.Set(x, y, col) } @@ -217,9 +217,9 @@ func TestGen24bitFull(t *testing.T) { // radius = centerY //} - for y := 0; y < wh; y++ { + for y := range wh { dy := float64(y - centerY) - for x := 0; x < wh; x++ { + for x := range wh { dx := float64(x - centerX) dist := math.Sqrt(dx*dx + dy*dy) if dist <= float64(radius) { diff --git a/pkg/games/library_test.go b/pkg/games/library_test.go index dc85fbbf..28975647 100644 --- a/pkg/games/library_test.go +++ b/pkg/games/library_test.go @@ -117,7 +117,7 @@ func Benchmark(b *testing.B) { Supported: []string{"gba", "zip", "nes"}, }, config.Emulator{}, log) - for range b.N { + for b.Loop() { library.Scan() _ = library.GetAll() } diff --git a/pkg/resampler/simple.go b/pkg/resampler/simple.go index f2859a3e..39e509c0 100644 --- a/pkg/resampler/simple.go +++ b/pkg/resampler/simple.go @@ -10,7 +10,7 @@ func Linear(dst, src []int16) { // replicate single pair input or output if srcPairs == 1 || dstPairs == 1 { - for i := 0; i < dstPairs; i++ { + for i := range dstPairs { dst[i*2], dst[i*2+1] = src[0], src[1] } return @@ -46,7 +46,7 @@ func Nearest(dst, src []int16) { srcPairs, dstPairs := nSrc>>1, nDst>>1 if srcPairs == 1 || dstPairs == 1 { - for i := 0; i < dstPairs; i++ { + for i := range dstPairs { dst[i*2], dst[i*2+1] = src[0], src[1] } return diff --git a/pkg/worker/caged/libretro/frontend_test.go b/pkg/worker/caged/libretro/frontend_test.go index fda0ecbe..2cacd5a4 100644 --- a/pkg/worker/caged/libretro/frontend_test.go +++ b/pkg/worker/caged/libretro/frontend_test.go @@ -329,8 +329,7 @@ func TestStateConcurrency(t *testing.T) { qLock.Unlock() if lucky() && !lucky() { - ops.Add(1) - go func() { + ops.Go(func() { qLock.Lock() defer qLock.Unlock() @@ -344,8 +343,7 @@ func TestStateConcurrency(t *testing.T) { if snapshot1 != snapshot2 { t.Errorf("States are inconsistent %v != %v on tick %v\n", snapshot1, snapshot2, i+1) } - ops.Done() - }() + }) } } diff --git a/pkg/worker/caged/libretro/nanoarch/input_test.go b/pkg/worker/caged/libretro/nanoarch/input_test.go index 4921df59..042d108e 100644 --- a/pkg/worker/caged/libretro/nanoarch/input_test.go +++ b/pkg/worker/caged/libretro/nanoarch/input_test.go @@ -13,7 +13,7 @@ func TestConcurrentInput(t *testing.T) { events := 1000 wg.Add(2 * events) - for i := 0; i < events; i++ { + for range events { player := rand.Intn(maxPort) go func() { state.Input(player, []byte{0, 1}); wg.Done() }() go func() { state.IsKeyPressed(uint(player), 100); wg.Done() }() diff --git a/pkg/worker/media/buffer_test.go b/pkg/worker/media/buffer_test.go index a2be89d8..6c8d300a 100644 --- a/pkg/worker/media/buffer_test.go +++ b/pkg/worker/media/buffer_test.go @@ -26,7 +26,7 @@ func samplesOf(v int16, n int) samples { func ramp(pairs int) samples { s := make(samples, pairs*2) - for i := 0; i < pairs; i++ { + for i := range pairs { s[i*2], s[i*2+1] = int16(i), int16(i) } return s diff --git a/pkg/worker/media/media_test.go b/pkg/worker/media/media_test.go index 3e264e80..a0fd9399 100644 --- a/pkg/worker/media/media_test.go +++ b/pkg/worker/media/media_test.go @@ -79,7 +79,7 @@ func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RG b = genTestImage(w, h, rand.Float32()) } - for i := 0; i < count; i++ { + for i := range count { im := a if i%2 == 0 { im = b @@ -98,8 +98,8 @@ func run(w, h int, cod encoder.VideoCodec, count int, a *image.RGBA, b *image.RG func genTestImage(w, h int, seed float32) *image.RGBA { img := image.NewRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}}) - for x := 0; x < w; x++ { - for y := 0; y < h; y++ { + for x := range w { + for y := range h { i := img.PixOffset(x, y) s := img.Pix[i : i+4 : i+4] s[0] = uint8(seed * 255) diff --git a/pkg/worker/recorder/recorder_test.go b/pkg/worker/recorder/recorder_test.go index fcfe1f57..d968cc34 100644 --- a/pkg/worker/recorder/recorder_test.go +++ b/pkg/worker/recorder/recorder_test.go @@ -46,7 +46,7 @@ func TestName(t *testing.T) { audioWg.Add(iterations) frame := genFrame(100, 100) - for i := 0; i < 222; i++ { + for range 222 { go func() { recorder.WriteVideo(Video{Frame: frame, Duration: 16 * time.Millisecond}) imgWg.Done() @@ -134,8 +134,8 @@ func benchmarkRecorder(w, h int, b *testing.B) { func genFrame(w, h int) Frame { img := image.NewRGBA(image.Rect(0, 0, w, h)) - for x := 0; x < w; x++ { - for y := 0; y < h; y++ { + for x := range w { + for y := range h { img.Set(x, y, randomColor()) } } From 94e13cb93bca6d993520b7b9f577c1af8da6420c Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 22 Dec 2025 15:37:04 +0300 Subject: [PATCH 232/240] Clean api --- pkg/api/api.go | 23 ++++++++++ pkg/coordinator/user.go | 53 ++++++----------------- pkg/coordinator/userapi.go | 8 +--- pkg/coordinator/worker.go | 62 +++++++++------------------ pkg/coordinator/workerapi.go | 30 ++++++------- pkg/worker/coordinator.go | 83 +++++++++--------------------------- 6 files changed, 91 insertions(+), 168 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 2deeb44a..93fedb17 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -39,6 +39,14 @@ type ( PT uint8 ) +func State[T Id](id T) Stateful[T] { + return Stateful[T]{Id: id} +} + +func StateRoom[T Id](id T, rid string) StatefulRoom[T] { + return StatefulRoom[T]{Stateful: State(id), Room: Room{Rid: rid}} +} + type In[I Id] struct { Id I `json:"id,omitempty"` T PT `json:"t"` @@ -157,6 +165,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 { diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go index b9d87e7e..2a4f1e98 100644 --- a/pkg/coordinator/user.go +++ b/pkg/coordinator/user.go @@ -44,67 +44,38 @@ func (u *User) Disconnect() { } func (u *User) HandleRequests(info HasServerInfo, conf config.CoordinatorConfig) chan struct{} { - return u.ProcessPackets(func(x api.In[com.Uid]) error { - payload := x.GetPayload() - switch x.GetType() { + 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, 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) + err = api.Do(x, u.HandleChangePlayer) case api.ResetGame: - rq := api.Unwrap[api.ResetGameRequest[com.Uid]](payload) - if rq == nil { - return api.ErrMalformed - } - u.HandleResetGame(*rq) + 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 }) } diff --git a/pkg/coordinator/userapi.go b/pkg/coordinator/userapi.go index 047fe1d1..fd8b7235 100644 --- a/pkg/coordinator/userapi.go +++ b/pkg/coordinator/userapi.go @@ -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. diff --git a/pkg/coordinator/worker.go b/pkg/coordinator/worker.go index f4b2b2d0..e89d3dad 100644 --- a/pkg/coordinator/worker.go +++ b/pkg/coordinator/worker.go @@ -1,6 +1,7 @@ package coordinator import ( + "errors" "fmt" "sync/atomic" @@ -75,60 +76,35 @@ 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[com.Uid]) error { + return w.HandleIceCandidate(d, users) + }) case api.LibNewGameList: - inf := api.Unwrap[api.LibGameListInfo](payload) - if inf == nil { - return api.ErrMalformed - } - if err := w.HandleLibGameList(*inf); err != nil { - w.log.Error().Err(err).Send() - return api.ErrMalformed - } + err = api.DoE(p, w.HandleLibGameList) case api.PrevSessions: - sess := api.Unwrap[api.PrevSessionInfo](payload) - if sess == nil { - return api.ErrMalformed - } - if err := w.HandlePrevSessionList(*sess); err != nil { - w.log.Error().Err(err).Send() - return api.ErrMalformed - } + 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) SetLib(list []api.GameInfo) { w.Lib = list } func (w *Worker) AppNames() []api.GameInfo { return w.Lib diff --git a/pkg/coordinator/workerapi.go b/pkg/coordinator/workerapi.go index 7a1ccf51..43205871 100644 --- a/pkg/coordinator/workerapi.go +++ b/pkg/coordinator/workerapi.go @@ -5,23 +5,27 @@ import ( "github.com/giongto35/cloud-game/v3/pkg/com" ) +func (w *Worker) room(id com.Uid) api.StatefulRoom[com.Uid] { + return api.StateRoom(id, w.RoomId) +} + func (w *Worker) WebrtcInit(id com.Uid) (*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[com.Uid]{Stateful: api.State(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}) + w.Notify(api.WebrtcAnswer, api.WebrtcAnswerRequest[com.Uid]{Stateful: api.State(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}) + w.Notify(api.WebrtcIce, api.WebrtcIceCandidateRequest[com.Uid]{Stateful: api.State(id), Candidate: can}) } func (w *Worker) StartGame(id com.Uid, req api.GameStartUserRequest) (*api.StartGameResponse, error) { return api.UnwrapChecked[api.StartGameResponse]( w.Send(api.StartGame, api.StartGameRequest[com.Uid]{ - StatefulRoom: StateRoom(id, req.RoomId), + StatefulRoom: api.StateRoom(id, req.RoomId), Game: req.GameName, PlayerIndex: req.PlayerIndex, Record: req.Record, @@ -30,37 +34,33 @@ func (w *Worker) StartGame(id com.Uid, req api.GameStartUserRequest) (*api.Start } func (w *Worker) QuitGame(id com.Uid) { - w.Notify(api.QuitGame, api.GameQuitRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)}) + w.Notify(api.QuitGame, api.GameQuitRequest[com.Uid]{StatefulRoom: w.room(id)}) } func (w *Worker) SaveGame(id com.Uid) (*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[com.Uid]{StatefulRoom: w.room(id)})) } func (w *Worker) LoadGame(id com.Uid) (*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[com.Uid]{StatefulRoom: w.room(id)})) } func (w *Worker) ChangePlayer(id com.Uid, 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[com.Uid]{StatefulRoom: w.room(id), Index: index})) } func (w *Worker) ResetGame(id com.Uid) { - w.Notify(api.ResetGame, api.ResetGameRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId)}) + w.Notify(api.ResetGame, api.ResetGameRequest[com.Uid]{StatefulRoom: w.room(id)}) } func (w *Worker) RecordGame(id com.Uid, rec bool, recUser string) (*api.RecordGameResponse, error) { return api.UnwrapChecked[api.RecordGameResponse]( - w.Send(api.RecordGame, api.RecordGameRequest[com.Uid]{StatefulRoom: StateRoom(id, w.RoomId), Active: rec, User: recUser})) + w.Send(api.RecordGame, api.RecordGameRequest[com.Uid]{StatefulRoom: w.room(id), 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}} + _, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest[com.Uid]{Stateful: api.State(id)}) } diff --git a/pkg/worker/coordinator.go b/pkg/worker/coordinator.go index 07963577..f56f84b4 100644 --- a/pkg/worker/coordinator.go +++ b/pkg/worker/coordinator.go @@ -67,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[com.Uid]) { 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[com.Uid]) { 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[com.Uid]) { 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[com.Uid]) { 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.ResetGame: - dat := api.Unwrap[api.ResetGameRequest[com.Uid]](x.Payload) - if dat == nil { - return api.ErrMalformed - } - c.HandleResetGame(*dat, w) + err = api.Do(x, func(d api.ChangePlayerRequest[com.Uid]) { 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[com.Uid]) { out = c.HandleRecordGame(d, w) }) + case api.WebrtcAnswer: + err = api.Do(x, func(d api.WebrtcAnswerRequest[com.Uid]) { c.HandleWebrtcAnswer(d, w) }) + case api.WebrtcIce: + err = api.Do(x, func(d api.WebrtcIceCandidateRequest[com.Uid]) { c.HandleWebrtcIceCandidate(d, w) }) + case api.TerminateSession: + err = api.Do(x, func(d api.TerminateSessionRequest[com.Uid]) { c.HandleTerminateSession(d, w) }) + case api.QuitGame: + err = api.Do(x, func(d api.GameQuitRequest[com.Uid]) { c.HandleQuitGame(d, w) }) + case api.ResetGame: + err = api.Do(x, func(d api.ResetGameRequest[com.Uid]) { 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 }) } From baa9bad6f8dae096126dc01459576bcfaf2bca33 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 22 Dec 2025 15:38:17 +0300 Subject: [PATCH 233/240] Update dependencies --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 7763a58c..5cfb890b 100644 --- a/go.mod +++ b/go.mod @@ -44,9 +44,9 @@ require ( github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.16 // indirect - github.com/pion/rtp v1.8.26 // indirect + github.com/pion/rtp v1.8.27 // indirect github.com/pion/sctp v1.8.41 // indirect - github.com/pion/sdp/v3 v3.0.16 // 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 diff --git a/go.sum b/go.sum index b5ab82f5..e104a47a 100644 --- a/go.sum +++ b/go.sum @@ -74,10 +74,14 @@ 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= From 059e19d7903c29ce14a41a795ea8f2d57c8998ee Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 24 Dec 2025 21:23:19 +0300 Subject: [PATCH 234/240] Remove com.Uid from the API --- pkg/api/api.go | 20 +++------- pkg/api/worker.go | 54 ++++++++++---------------- pkg/com/com.go | 17 ++++++-- pkg/com/net.go | 1 - pkg/coordinator/user.go | 2 +- pkg/coordinator/userhandlers.go | 32 ++++++++-------- pkg/coordinator/worker.go | 4 +- pkg/coordinator/workerapi.go | 64 ++++++++++++++++--------------- pkg/coordinator/workerhandlers.go | 9 ++--- pkg/worker/coordinator.go | 29 +++++++------- pkg/worker/coordinatorhandlers.go | 30 +++++++-------- pkg/worker/room/cast.go | 2 +- pkg/worker/room/room.go | 18 ++++----- pkg/worker/room/room_test.go | 2 +- pkg/worker/room/router_test.go | 18 +++++---- 15 files changed, 149 insertions(+), 153 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 93fedb17..6605a188 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -26,27 +26,19 @@ 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 ) -func State[T Id](id T) Stateful[T] { - return Stateful[T]{Id: id} -} - -func StateRoom[T Id](id T, rid string) StatefulRoom[T] { - return StatefulRoom[T]{Stateful: State(id), Room: Room{Rid: rid}} -} - type In[I Id] struct { Id I `json:"id,omitempty"` T PT `json:"t"` diff --git a/pkg/api/worker.go b/pkg/api/worker.go index 189daf66..c498009d 100644 --- a/pkg/api/worker.go +++ b/pkg/api/worker.go @@ -1,28 +1,20 @@ 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 - ResetGameRequest[T Id] struct { - StatefulRoom[T] - } - ResetGameResponse 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 string `json:"game"` @@ -42,26 +34,22 @@ type ( 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] - } - 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 { diff --git a/pkg/com/com.go b/pkg/com/com.go index bbeaa0d3..8b475622 100644 --- a/pkg/com/com.go +++ b/pkg/com/com.go @@ -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 diff --git a/pkg/com/net.go b/pkg/com/net.go index 04ed7e54..722ce9b5 100644 --- a/pkg/com/net.go +++ b/pkg/com/net.go @@ -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) diff --git a/pkg/coordinator/user.go b/pkg/coordinator/user.go index 2a4f1e98..e1efef49 100644 --- a/pkg/coordinator/user.go +++ b/pkg/coordinator/user.go @@ -39,7 +39,7 @@ func (u *User) Bind(w *Worker) bool { func (u *User) Disconnect() { u.Connection.Disconnect() if u.w != nil { - u.w.TerminateSession(u.Id()) + u.w.TerminateSession(u.Id().String()) } } diff --git a/pkg/coordinator/userhandlers.go b/pkg/coordinator/userhandlers.go index 80d0dc6e..6dddd30e 100644 --- a/pkg/coordinator/userhandlers.go +++ b/pkg/coordinator/userhandlers.go @@ -5,12 +5,12 @@ import ( "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" ) 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,11 +19,11 @@ 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, conf config.CoordinatorConfig) { @@ -76,7 +76,7 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, conf config.Coordina } } - startGameResp, err := u.w.StartGame(u.Id(), 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 @@ -94,21 +94,21 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, conf config.Coordina } } -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[com.Uid]) { - if rq.Room.Rid != u.w.RoomId { +func (u *User) HandleResetGame(rq api.ResetGameRequest) { + if rq.Rid != u.w.RoomId { return } - u.w.ResetGame(u.Id()) + 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 } @@ -124,7 +124,7 @@ func (u *User) HandleSaveGame() error { } 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 } @@ -133,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) @@ -142,7 +142,7 @@ func (u *User) HandleChangePlayer(rq api.ChangePlayerUserRequest) { u.Notify(api.ChangePlayer, rq) } -func (u *User) HandleRecordGame(rq api.RecordGameRequest[com.Uid]) { +func (u *User) HandleRecordGame(rq api.RecordGameRequest) { if u.w == nil { return } @@ -154,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 diff --git a/pkg/coordinator/worker.go b/pkg/coordinator/worker.go index e89d3dad..137d7777 100644 --- a/pkg/coordinator/worker.go +++ b/pkg/coordinator/worker.go @@ -35,7 +35,7 @@ type RegionalClient interface { } type HasUserRegistry interface { - Find(com.Uid) *User + Find(id string) *User } type AppLibrary interface { @@ -86,7 +86,7 @@ func (w *Worker) HandleRequests(users HasUserRegistry) chan struct{} { case api.CloseRoom: err = api.Do(p, w.HandleCloseRoom) case api.IceCandidate: - err = api.DoE(p, func(d api.WebrtcIceCandidateRequest[com.Uid]) error { + err = api.DoE(p, func(d api.WebrtcIceCandidateRequest) error { return w.HandleIceCandidate(d, users) }) case api.LibNewGameList: diff --git a/pkg/coordinator/workerapi.go b/pkg/coordinator/workerapi.go index 43205871..ccf8c700 100644 --- a/pkg/coordinator/workerapi.go +++ b/pkg/coordinator/workerapi.go @@ -1,31 +1,26 @@ 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) room(id com.Uid) api.StatefulRoom[com.Uid] { - return api.StateRoom(id, w.RoomId) -} - -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.State(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.State(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.State(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, 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: api.StateRoom(id, req.RoomId), + w.Send(api.StartGame, api.StartGameRequest{ + StatefulRoom: api.StatefulRoom{Id: id, Rid: req.RoomId}, Game: req.GameName, PlayerIndex: req.PlayerIndex, Record: req.Record, @@ -33,34 +28,41 @@ func (w *Worker) StartGame(id com.Uid, req api.GameStartUserRequest) (*api.Start })) } -func (w *Worker) QuitGame(id com.Uid) { - w.Notify(api.QuitGame, api.GameQuitRequest[com.Uid]{StatefulRoom: w.room(id)}) +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: w.room(id)})) + 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: w.room(id)})) + 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: w.room(id), Index: index})) + w.Send(api.ChangePlayer, api.ChangePlayerRequest{ + StatefulRoom: api.StatefulRoom{Id: id, Rid: w.RoomId}, + Index: index, + })) } -func (w *Worker) ResetGame(id com.Uid) { - w.Notify(api.ResetGame, api.ResetGameRequest[com.Uid]{StatefulRoom: w.room(id)}) +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: w.room(id), 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.State(id)}) +func (w *Worker) TerminateSession(id string) { + _, _ = w.Send(api.TerminateSession, api.TerminateSessionRequest{Id: id}) } diff --git a/pkg/coordinator/workerhandlers.go b/pkg/coordinator/workerhandlers.go index edd7e210..35609e06 100644 --- a/pkg/coordinator/workerhandlers.go +++ b/pkg/coordinator/workerhandlers.go @@ -1,9 +1,6 @@ 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) } @@ -14,11 +11,11 @@ func (w *Worker) HandleCloseRoom(rq api.CloseRoomRequest) { } } -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 } diff --git a/pkg/worker/coordinator.go b/pkg/worker/coordinator.go index f56f84b4..bd5cd3e1 100644 --- a/pkg/worker/coordinator.go +++ b/pkg/worker/coordinator.go @@ -73,27 +73,27 @@ func (c *coordinator) HandleRequests(w *Worker) chan struct{} { switch x.T { case api.WebrtcInit: - err = api.Do(x, func(d api.WebrtcInitRequest[com.Uid]) { out = c.HandleWebrtcInit(d, w, ap) }) + err = api.Do(x, func(d api.WebrtcInitRequest) { out = c.HandleWebrtcInit(d, w, ap) }) case api.StartGame: - err = api.Do(x, func(d api.StartGameRequest[com.Uid]) { out = c.HandleGameStart(d, w) }) + err = api.Do(x, func(d api.StartGameRequest) { out = c.HandleGameStart(d, w) }) case api.SaveGame: - err = api.Do(x, func(d api.SaveGameRequest[com.Uid]) { out = c.HandleSaveGame(d, w) }) + err = api.Do(x, func(d api.SaveGameRequest) { out = c.HandleSaveGame(d, w) }) case api.LoadGame: - err = api.Do(x, func(d api.LoadGameRequest[com.Uid]) { out = c.HandleLoadGame(d, w) }) + err = api.Do(x, func(d api.LoadGameRequest) { out = c.HandleLoadGame(d, w) }) case api.ChangePlayer: - err = api.Do(x, func(d api.ChangePlayerRequest[com.Uid]) { out = c.HandleChangePlayer(d, w) }) + err = api.Do(x, func(d api.ChangePlayerRequest) { out = c.HandleChangePlayer(d, w) }) case api.RecordGame: - err = api.Do(x, func(d api.RecordGameRequest[com.Uid]) { out = c.HandleRecordGame(d, 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[com.Uid]) { c.HandleWebrtcAnswer(d, w) }) + err = api.Do(x, func(d api.WebrtcAnswerRequest) { c.HandleWebrtcAnswer(d, w) }) case api.WebrtcIce: - err = api.Do(x, func(d api.WebrtcIceCandidateRequest[com.Uid]) { c.HandleWebrtcIceCandidate(d, w) }) + err = api.Do(x, func(d api.WebrtcIceCandidateRequest) { c.HandleWebrtcIceCandidate(d, w) }) case api.TerminateSession: - err = api.Do(x, func(d api.TerminateSessionRequest[com.Uid]) { c.HandleTerminateSession(d, w) }) + err = api.Do(x, func(d api.TerminateSessionRequest) { c.HandleTerminateSession(d, w) }) case api.QuitGame: - err = api.Do(x, func(d api.GameQuitRequest[com.Uid]) { c.HandleQuitGame(d, w) }) + err = api.Do(x, func(d api.GameQuitRequest) { c.HandleQuitGame(d, w) }) case api.ResetGame: - err = api.Do(x, func(d api.ResetGameRequest[com.Uid]) { c.HandleResetGame(d, w) }) + err = api.Do(x, func(d api.ResetGameRequest) { c.HandleResetGame(d, w) }) default: c.log.Warn().Msgf("unhandled packet type %v", x.T) } @@ -109,8 +109,11 @@ 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) { diff --git a/pkg/worker/coordinatorhandlers.go b/pkg/worker/coordinatorhandlers.go index 536c6ed6..d8e30a0e 100644 --- a/pkg/worker/coordinatorhandlers.go +++ b/pkg/worker/coordinatorhandlers.go @@ -28,7 +28,7 @@ func buildConnQuery(id com.Uid, conf config.Worker, address string) (string, err }) } -func (c *coordinator) HandleWebrtcInit(rq api.WebrtcInitRequest[com.Uid], w *Worker, factory *webrtc.ApiFactory) api.Out { +func (c *coordinator) HandleWebrtcInit(rq api.WebrtcInitRequest, w *Worker, factory *webrtc.ApiFactory) api.Out { peer := webrtc.New(c.log, factory) localSDP, err := peer.NewCall(w.conf.Encoder.Video.Codec, "opus", func(data any) { candidate, err := toBase64Json(data) @@ -55,7 +55,7 @@ func (c *coordinator) HandleWebrtcInit(rq api.WebrtcInitRequest[com.Uid], w *Wor return api.Out{Payload: sdp} } -func (c *coordinator) HandleWebrtcAnswer(rq api.WebrtcAnswerRequest[com.Uid], w *Worker) { +func (c *coordinator) HandleWebrtcAnswer(rq api.WebrtcAnswerRequest, w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { if err := room.WithWebRTC(user.Session).SetRemoteSDP(rq.Sdp, fromBase64Json); err != nil { c.log.Error().Err(err).Msgf("cannot set remote SDP of client [%v]", rq.Id) @@ -63,7 +63,7 @@ func (c *coordinator) HandleWebrtcAnswer(rq api.WebrtcAnswerRequest[com.Uid], w } } -func (c *coordinator) HandleWebrtcIceCandidate(rs api.WebrtcIceCandidateRequest[com.Uid], w *Worker) { +func (c *coordinator) HandleWebrtcIceCandidate(rs api.WebrtcIceCandidateRequest, w *Worker) { if user := w.router.FindUser(rs.Id); user != nil { if err := room.WithWebRTC(user.Session).AddCandidate(rs.Candidate, fromBase64Json); err != nil { c.log.Error().Err(err).Msgf("cannot add ICE candidate of the client [%v]", rs.Id) @@ -71,7 +71,7 @@ func (c *coordinator) HandleWebrtcIceCandidate(rs api.WebrtcIceCandidateRequest[ } } -func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worker) api.Out { +func (c *coordinator) HandleGameStart(rq api.StartGameRequest, w *Worker) api.Out { user := w.router.FindUser(rq.Id) if user == nil { c.log.Error().Msgf("no user [%v]", rq.Id) @@ -79,14 +79,14 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke } user.Index = rq.PlayerIndex - r := w.router.FindRoom(rq.Room.Rid) + r := w.router.FindRoom(rq.Rid) // +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 gameName := rq.Game - if rq.Room.Rid != "" { - name := w.launcher.ExtractAppNameFromUrl(rq.Room.Rid) + if rq.Rid != "" { + name := w.launcher.ExtractAppNameFromUrl(rq.Rid) if name == "" { c.log.Warn().Msg("couldn't decode game name from the room id") return api.EmptyPacket @@ -101,7 +101,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke } if r == nil { // new room - uid := rq.Room.Rid + uid := rq.Rid if uid == "" { uid = games.GenerateRoomID(gameName) } @@ -218,7 +218,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke } // HandleTerminateSession handles cases when a user has been disconnected from the websocket of coordinator. -func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest[com.Uid], w *Worker) { +func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest, w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { w.router.Remove(user) c.log.Debug().Msgf(">>> users: %v", w.router.Users()) @@ -227,14 +227,14 @@ func (c *coordinator) HandleTerminateSession(rq api.TerminateSessionRequest[com. } // HandleQuitGame handles cases when a user manually exits the game. -func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest[com.Uid], w *Worker) { +func (c *coordinator) HandleQuitGame(rq api.GameQuitRequest, w *Worker) { if user := w.router.FindUser(rq.Id); user != nil { w.router.Remove(user) c.log.Debug().Msgf(">>> users: %v", w.router.Users()) } } -func (c *coordinator) HandleResetGame(rq api.ResetGameRequest[com.Uid], w *Worker) api.Out { +func (c *coordinator) HandleResetGame(rq api.ResetGameRequest, w *Worker) api.Out { if r := w.router.FindRoom(rq.Rid); r != nil { room.WithEmulator(r.App()).Reset() return api.OkPacket @@ -242,7 +242,7 @@ func (c *coordinator) HandleResetGame(rq api.ResetGameRequest[com.Uid], w *Worke return api.ErrPacket } -func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest[com.Uid], w *Worker) api.Out { +func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest, w *Worker) api.Out { r := w.router.FindRoom(rq.Rid) if r == nil { return api.ErrPacket @@ -254,7 +254,7 @@ func (c *coordinator) HandleSaveGame(rq api.SaveGameRequest[com.Uid], w *Worker) return api.OkPacket } -func (c *coordinator) HandleLoadGame(rq api.LoadGameRequest[com.Uid], w *Worker) api.Out { +func (c *coordinator) HandleLoadGame(rq api.LoadGameRequest, w *Worker) api.Out { r := w.router.FindRoom(rq.Rid) if r == nil { return api.ErrPacket @@ -266,7 +266,7 @@ func (c *coordinator) HandleLoadGame(rq api.LoadGameRequest[com.Uid], w *Worker) return api.OkPacket } -func (c *coordinator) HandleChangePlayer(rq api.ChangePlayerRequest[com.Uid], w *Worker) api.Out { +func (c *coordinator) HandleChangePlayer(rq api.ChangePlayerRequest, w *Worker) api.Out { user := w.router.FindUser(rq.Id) if user == nil || w.router.FindRoom(rq.Rid) == nil { return api.Out{Payload: -1} // semi-predicates @@ -276,7 +276,7 @@ func (c *coordinator) HandleChangePlayer(rq api.ChangePlayerRequest[com.Uid], w return api.Out{Payload: rq.Index} } -func (c *coordinator) HandleRecordGame(rq api.RecordGameRequest[com.Uid], w *Worker) api.Out { +func (c *coordinator) HandleRecordGame(rq api.RecordGameRequest, w *Worker) api.Out { if !w.conf.Recording.Enabled { return api.ErrPacket } diff --git a/pkg/worker/room/cast.go b/pkg/worker/room/cast.go index d8a9710f..81a6c57d 100644 --- a/pkg/worker/room/cast.go +++ b/pkg/worker/room/cast.go @@ -11,7 +11,7 @@ type GameRouter struct { } func NewGameRouter() *GameRouter { - u := com.NewNetMap[string, *GameSession]() + u := com.NewNetMap[SessionKey, *GameSession]() return &GameRouter{Router: Router[*GameSession]{users: &u}} } diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index c2686bdc..88380683 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -41,9 +41,10 @@ type Session interface { SendData([]byte) } -type Uid interface { - Id() string -} +type SessionKey string + +func (s SessionKey) String() string { return string(s) } +func (s SessionKey) Id() string { return s.String() } type Room[T Session] struct { app app.App @@ -137,7 +138,7 @@ func (r *Router[T]) Remove(user T) { func (r *Router[T]) AddUser(user T) { r.users.Add(user) } func (r *Router[T]) Close() { r.mu.Lock(); r.room.Close(); r.room = nil; r.mu.Unlock() } -func (r *Router[T]) FindUser(uid Uid) T { return r.users.Find(uid.Id()) } +func (r *Router[T]) FindUser(uid string) T { return r.users.Find(uid) } func (r *Router[T]) Room() *Room[T] { r.mu.Lock(); defer r.mu.Unlock(); return r.room } func (r *Router[T]) SetRoom(room *Room[T]) { r.mu.Lock(); r.room = room; r.mu.Unlock() } func (r *Router[T]) HasRoom() bool { r.mu.Lock(); defer r.mu.Unlock(); return r.room != nil } @@ -156,18 +157,17 @@ func (r *Router[T]) Reset() { } type AppSession struct { - Uid Session - uid string + uid SessionKey } -func (p AppSession) Id() string { return p.uid } +func (p AppSession) Id() SessionKey { return p.uid } type GameSession struct { AppSession Index int // track user Index (i.e. player 1,2,3,4 select) } -func NewGameSession(id Uid, s Session) *GameSession { - return &GameSession{AppSession: AppSession{uid: id.Id(), Session: s}} +func NewGameSession(id string, s Session) *GameSession { + return &GameSession{AppSession: AppSession{uid: SessionKey(id), Session: s}} } diff --git a/pkg/worker/room/room_test.go b/pkg/worker/room/room_test.go index 9a4bdd73..7a537d69 100644 --- a/pkg/worker/room/room_test.go +++ b/pkg/worker/room/room_test.go @@ -236,7 +236,7 @@ func room(cfg conf) testRoom { l.Fatal().Err(err).Msgf("no init") } - room := NewRoom[*GameSession](id, emu, &com.NetMap[string, *GameSession]{}, m) + room := NewRoom[*GameSession](id, emu, &com.NetMap[SessionKey, *GameSession]{}, m) if cfg.autoAppStart { room.StartApp() } diff --git a/pkg/worker/room/router_test.go b/pkg/worker/room/router_test.go index 013f1e27..d4f2e621 100644 --- a/pkg/worker/room/router_test.go +++ b/pkg/worker/room/router_test.go @@ -6,8 +6,12 @@ import ( "github.com/giongto35/cloud-game/v3/pkg/com" ) +type sKey string + +func (s sKey) String() string { return string(s) } + type tSession struct { - id string + id sKey connected bool } @@ -16,15 +20,15 @@ func (t *tSession) SendVideo([]byte, int32) {} func (t *tSession) SendData([]byte) {} func (t *tSession) Connect() { t.connected = true } func (t *tSession) Disconnect() { t.connected = false } -func (t *tSession) Id() string { return t.id } +func (t *tSession) Id() sKey { return t.id } type lookMap struct { - com.NetMap[string, *tSession] - prev com.NetMap[string, *tSession] // we could use pointers in the original :3 + com.NetMap[sKey, *tSession] + prev com.NetMap[sKey, *tSession] // we could use pointers in the original :3 } func (l *lookMap) Reset() { - l.prev = com.NewNetMap[string, *tSession]() + l.prev = com.NewNetMap[sKey, *tSession]() for s := range l.Map.Values() { l.prev.Add(s) } @@ -51,7 +55,7 @@ func TestRouter(t *testing.T) { } func TestRouterReset(t *testing.T) { - u := lookMap{NetMap: com.NewNetMap[string, *tSession]()} + u := lookMap{NetMap: com.NewNetMap[sKey, *tSession]()} router := Router[*tSession]{users: &u} router.AddUser(&tSession{id: "1", connected: true}) @@ -73,6 +77,6 @@ func TestRouterReset(t *testing.T) { } func newTestRouter() *Router[*tSession] { - u := com.NewNetMap[string, *tSession]() + u := com.NewNetMap[sKey, *tSession]() return &Router[*tSession]{users: &u} } From aeb41008c92ec8062ce36bf70341e7ae490e34ef Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 24 Dec 2025 21:25:03 +0300 Subject: [PATCH 235/240] Remove room watchers --- pkg/worker/watcher.go | 46 ------------------------------------------- pkg/worker/worker.go | 4 +--- 2 files changed, 1 insertion(+), 49 deletions(-) delete mode 100644 pkg/worker/watcher.go diff --git a/pkg/worker/watcher.go b/pkg/worker/watcher.go deleted file mode 100644 index 953b0036..00000000 --- a/pkg/worker/watcher.go +++ /dev/null @@ -1,46 +0,0 @@ -package worker - -import ( - "time" - - "github.com/giongto35/cloud-game/v3/pkg/logger" - "github.com/giongto35/cloud-game/v3/pkg/worker/room" -) - -type Watcher struct { - r *room.GameRouter - t *time.Ticker - done chan struct{} - log *logger.Logger -} - -func NewWatcher(p time.Duration, router *room.GameRouter, log *logger.Logger) *Watcher { - return &Watcher{ - r: router, - t: time.NewTicker(p), - done: make(chan struct{}), - log: log, - } -} - -func (w *Watcher) Run() { - go func() { - for { - select { - case <-w.t.C: - if w.r.HasRoom() && w.r.Users().Empty() { - w.r.Close() - w.log.Warn().Msgf("Forced room close!") - } - case <-w.done: - return - } - } - }() -} - -func (w *Watcher) Stop() error { - w.t.Stop() - close(w.done) - return nil -} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index 28c29b60..0da257b2 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -3,7 +3,6 @@ package worker import ( "errors" "fmt" - "time" "github.com/giongto35/cloud-game/v3/pkg/config" "github.com/giongto35/cloud-game/v3/pkg/games" @@ -25,7 +24,7 @@ type Worker struct { log *logger.Logger mana *caged.Manager router *room.GameRouter - services [3]interface { + services [2]interface { Run() Stop() error } @@ -77,7 +76,6 @@ func New(conf config.WorkerConfig, log *logger.Logger) (*Worker, error) { log.Warn().Err(err).Msgf("cloud storage fail, using no storage") } worker.storage = st - worker.services[2] = NewWatcher(30*time.Minute, worker.router, log) return worker, nil } From 8754a5edfae0e061ebe5865eaba1f2c5fc0edc45 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Fri, 26 Dec 2025 16:17:37 +0300 Subject: [PATCH 236/240] Tweak OpenGL framebuffer Force alignment for GL ReadPixels and skip unbinding last framebuffer which makes it a bit faster. --- pkg/worker/caged/libretro/graphics/gl/gl.go | 11 +++++++++++ pkg/worker/caged/libretro/graphics/opengl.go | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/worker/caged/libretro/graphics/gl/gl.go b/pkg/worker/caged/libretro/graphics/gl/gl.go index d8f95a62..46e3842e 100644 --- a/pkg/worker/caged/libretro/graphics/gl/gl.go +++ b/pkg/worker/caged/libretro/graphics/gl/gl.go @@ -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)) } diff --git a/pkg/worker/caged/libretro/graphics/opengl.go b/pkg/worker/caged/libretro/graphics/opengl.go index 99eef10d..44a9a92f 100644 --- a/pkg/worker/caged/libretro/graphics/opengl.go +++ b/pkg/worker/caged/libretro/graphics/opengl.go @@ -110,10 +110,10 @@ func destroyFramebuffer() { } func ReadFramebuffer(bytes, w, h uint) []byte { - data := buf[:bytes] + data := buf[:bytes:bytes] + gl.PixelStorei(gl.PackAlignment, 1) 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 } From 58a19affcba89f20085de852a3cf8b6aaf5551c0 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sat, 27 Dec 2025 01:58:14 +0300 Subject: [PATCH 237/240] Clean SDL/OpenGL functions --- pkg/worker/caged/libretro/graphics/opengl.go | 121 +++++-------- pkg/worker/caged/libretro/graphics/sdl.go | 166 +++++++----------- .../caged/libretro/nanoarch/nanoarch.go | 51 +++--- 3 files changed, 135 insertions(+), 203 deletions(-) diff --git a/pkg/worker/caged/libretro/graphics/opengl.go b/pkg/worker/caged/libretro/graphics/opengl.go index 44a9a92f..fca78a6b 100644 --- a/pkg/worker/caged/libretro/graphics/opengl.go +++ b/pkg/worker/caged/libretro/graphics/opengl.go @@ -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 = make([]byte, 1024*1024) + 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:bytes] - gl.PixelStorei(gl.PackAlignment, 1) - gl.BindFramebuffer(gl.FRAMEBUFFER, opt.fbo) - gl.ReadPixels(0, 0, int32(w), int32(h), opt.pixType, opt.pixFormat, unsafe.Pointer(&data[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 } diff --git a/pkg/worker/caged/libretro/graphics/sdl.go b/pkg/worker/caged/libretro/graphics/sdl.go index 7c25efbd..7c885d88 100644 --- a/pkg/worker/caged/libretro/graphics/sdl.go +++ b/pkg/worker/caged/libretro/graphics/sdl.go @@ -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,123 +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 &display, nil + + return &SDL{w: w, ctx: ctx}, nil } -// TryInit check weather SDL context can be created on the system. +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) + } +} + +func (s *SDL) Deinit() error { + destroyFramebuffer() + sdl.GLDeleteContext(s.ctx) + err := s.w.Destroy() + sdl.Quit() + return err +} + +func (s *SDL) BindContext() error { return s.w.GLMakeCurrent(s.ctx) } +func GlProcAddress(proc string) unsafe.Pointer { return sdl.GLGetProcAddress(proc) } + func TryInit() error { if err := sdl.Init(sdl.INIT_VIDEO); err != nil { - return fmt.Errorf("SDL init fail: %w", err) - } - sdl.Quit() - return nil -} - -// Deinit destroys SDL/OpenGL context. -// Uses main thread lock (see thread/mainthread). -func (s *SDL) Deinit() error { - s.log.Debug().Msg("[SDL/OpenGL] shutdown...") - destroyFramebuffer() - var err error - // In OSX 10.14+ window deletion must happen in the main thread - thread.Main(func() { - err = s.destroyWindow() - }) - if err != nil { - return fmt.Errorf("[SDL/OpenGL] deinit fail: %w", err) - } - sdl.Quit() - s.log.Debug().Msgf("[SDL/OpenGL] shutdown codes:(%v, %v)", sdl.GetError(), GetGLError()) - return nil -} - -// createWindow creates a fake SDL window just for OpenGL initialization purposes. -func createWindow() (*sdl.Window, sdl.GLContext, error) { - w, err := sdl.CreateWindow( - "CloudRetro dummy window", - sdl.WINDOWPOS_UNDEFINED, sdl.WINDOWPOS_UNDEFINED, - 1, 1, - sdl.WINDOW_OPENGL|sdl.WINDOW_HIDDEN, - ) - if err != nil { - return nil, nil, fmt.Errorf("window creation fail: %w", err) - } - glWCtx, err := w.GLCreateContext() - if err != nil { - return nil, nil, fmt.Errorf("window OpenGL context fail: %w", err) - } - return w, glWCtx, nil -} - -// destroyWindow destroys previously created SDL window. -func (s *SDL) destroyWindow() error { - if err := s.BindContext(); err != nil { return err } - sdl.GLDeleteContext(s.glWCtx) - if err := s.w.Destroy(); err != nil { - return fmt.Errorf("window destroy fail: %w", err) - } + 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) } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index e0be87fd..6153f80e 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -486,10 +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())) - 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()) } @@ -711,11 +712,11 @@ 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 @@ -857,20 +858,22 @@ func initVideo() { context = graphics.CtxUnknown } - sdl, 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), - }, 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) + } + }) if Nan0.log.GetLevel() < logger.InfoLevel { printOpenGLDriverInfo() @@ -882,9 +885,11 @@ 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 From 368bae8c07ed0a5b47308eee5ee08e377e8c2853 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 28 Dec 2025 21:25:33 +0300 Subject: [PATCH 238/240] Swap mutex to atomics in keyboard input --- pkg/worker/caged/libretro/nanoarch/input.go | 172 +++----- .../caged/libretro/nanoarch/input_test.go | 372 ++++++++++++++++-- .../caged/libretro/nanoarch/nanoarch.go | 2 +- 3 files changed, 407 insertions(+), 139 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/input.go b/pkg/worker/caged/libretro/nanoarch/input.go index e095bbd8..59b5b014 100644 --- a/pkg/worker/caged/libretro/nanoarch/input.go +++ b/pkg/worker/caged/libretro/nanoarch/input.go @@ -2,7 +2,6 @@ package nanoarch import ( "encoding/binary" - "sync" "sync/atomic" ) @@ -19,36 +18,11 @@ void input_cache_clear(void); import "C" const ( - Released C.int16_t = iota - Pressed + maxPort = 4 + numAxes = 4 + RetrokLast = int(C.RETROK_LAST) ) -const RetrokLast = int(C.RETROK_LAST) - -// InputState stores full controller state. -// It consists of: -// - uint16 button values -// - int16 analog stick values -type InputState [maxPort]RetroPadState - -type ( - RetroPadState struct { - keys uint32 - axes [dpadAxes]int32 - } - KeyboardState struct { - keys [RetrokLast]byte - mod uint16 - mu sync.Mutex - } - MouseState struct { - dx, dy atomic.Int32 - buttons atomic.Int32 - } -) - -type MouseBtnState int32 - type Device byte const ( @@ -62,128 +36,110 @@ const ( MouseButton ) +type MouseBtnState int32 + const ( MouseLeft MouseBtnState = 1 << iota MouseRight MouseMiddle ) -const ( - maxPort = 4 - dpadAxes = 4 -) +// InputState stores controller state for all ports. +// - uint16 button bitmask +// - int16 analog axes x4 +type InputState [maxPort]struct { + keys uint32 + axes [numAxes]int32 +} -// Input sets input state for some player in a game session. -func (s *InputState) Input(port int, data []byte) { - atomic.StoreUint32(&s[port].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[port].axes[i], int32(data[axis+1])<<8+int32(data[axis])) +// SetInput sets input state for a player. +// +// [BTN:2][AX0:2][AX1:2][AX2:2][AX3:2] +func (s *InputState) SetInput(port int, data []byte) { + atomic.StoreUint32(&s[port].keys, uint32(binary.LittleEndian.Uint16(data))) + for i := 0; i < numAxes && i*2+3 < len(data); i++ { + atomic.StoreInt32(&s[port].axes[i], int32(int16(binary.LittleEndian.Uint16(data[i*2+2:])))) } } -// IsKeyPressed checks if some button is pressed by any player. -func (s *InputState) IsKeyPressed(port uint, key int) C.int16_t { - return C.int16_t((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1) +// Button check +func (s *InputState) Button(port, key uint) C.int16_t { + return C.int16_t((atomic.LoadUint32(&s[port].keys) >> key) & 1) } -// IsDpadTouched checks if D-pad is used by any player. -func (s *InputState) IsDpadTouched(port uint, axis uint) (shift C.int16_t) { - return C.int16_t(atomic.LoadInt32(&s[port].axes[axis])) -} - -// SyncToCache syncs the entire input state to the C-side cache. -// Call this once before each Run() instead of having C call back into Go. +// SyncToCache syncs input state to C-side cache before Run(). func (s *InputState) SyncToCache() { - for port := uint(0); port < maxPort; port++ { - buttons := atomic.LoadUint32(&s[port].keys) - axis0 := C.int16_t(atomic.LoadInt32(&s[port].axes[0])) - axis1 := C.int16_t(atomic.LoadInt32(&s[port].axes[1])) - axis2 := C.int16_t(atomic.LoadInt32(&s[port].axes[2])) - axis3 := C.int16_t(atomic.LoadInt32(&s[port].axes[3])) - C.input_cache_set_port(C.uint(port), C.uint32_t(buttons), axis0, axis1, axis2, axis3) + for p := uint(0); p < maxPort; p++ { + a := &s[p].axes + C.input_cache_set_port(C.uint(p), C.uint32_t(atomic.LoadUint32(&s[p].keys)), + C.int16_t(atomic.LoadInt32(&a[0])), C.int16_t(atomic.LoadInt32(&a[1])), + C.int16_t(atomic.LoadInt32(&a[2])), C.int16_t(atomic.LoadInt32(&a[3]))) } } +// 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. // -// 0 1 2 3 4 5 6 -// [ KEY ] P MOD +// [KEY:4][P:1][MOD:2] // -// KEY contains Libretro code of the keyboard key (4 bytes). -// P contains 0 or 1 if the key is pressed (1 byte). -// MOD contains bitmask for Alt | Ctrl | Meta | Shift keys press state (2 bytes). -// -// Returns decoded state from the input bytes. +// 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 } - - press := data[4] - pressed = press == 1 key = uint(binary.BigEndian.Uint32(data)) mod = binary.BigEndian.Uint16(data[5:]) - ks.mu.Lock() - ks.keys[key] = press - ks.mod = mod - ks.mu.Unlock() + 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 } -func (ks *KeyboardState) Pressed(key uint) C.int16_t { - ks.mu.Lock() - press := ks.keys[key] - ks.mu.Unlock() - if press == 1 { - return Pressed - } - return Released -} - -// SyncToCache syncs keyboard state to the C-side cache. +// SyncToCache syncs keyboard state to C-side cache. func (ks *KeyboardState) SyncToCache() { - ks.mu.Lock() - defer ks.mu.Unlock() - for id, pressed := range ks.keys { + 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)) } } -// ShiftPos sets mouse relative position state. +// MouseState tracks mouse delta and buttons. +type MouseState struct { + dx, dy atomic.Int32 + buttons atomic.Int32 +} + +// ShiftPos adds relative mouse movement. // -// 0 1 2 3 -// [dx] [dy] -// -// dx and dy are relative mouse coordinates +// [dx:2][dy:2] func (ms *MouseState) ShiftPos(data []byte) { if len(data) != 4 { return } - dxy := binary.BigEndian.Uint32(data) - ms.dx.Add(int32(int16(dxy >> 16))) - ms.dy.Add(int32(int16(dxy))) + ms.dx.Add(int32(int16(binary.BigEndian.Uint16(data[:2])))) + ms.dy.Add(int32(int16(binary.BigEndian.Uint16(data[2:])))) } -func (ms *MouseState) PopX() C.int16_t { return C.int16_t(ms.dx.Swap(0)) } -func (ms *MouseState) PopY() C.int16_t { return C.int16_t(ms.dy.Swap(0)) } - -// SetButtons sets the state MouseBtnState of mouse buttons. -func (ms *MouseState) SetButtons(data byte) { ms.buttons.Store(int32(data)) } +func (ms *MouseState) SetButtons(b byte) { ms.buttons.Store(int32(b)) } func (ms *MouseState) Buttons() (l, r, m bool) { - mbs := MouseBtnState(ms.buttons.Load()) - l = mbs&MouseLeft != 0 - r = mbs&MouseRight != 0 - m = mbs&MouseMiddle != 0 - return + b := MouseBtnState(ms.buttons.Load()) + return b&MouseLeft != 0, b&MouseRight != 0, b&MouseMiddle != 0 } -// SyncToCache syncs mouse state to the C-side cache. -// This consumes the delta values (swaps to 0). +// SyncToCache syncs mouse state to C-side cache, consuming deltas. func (ms *MouseState) SyncToCache() { - dx := C.int16_t(ms.dx.Swap(0)) - dy := C.int16_t(ms.dy.Swap(0)) - buttons := C.uint8_t(ms.buttons.Load()) - C.input_cache_set_mouse(dx, dy, buttons) + 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())) } diff --git a/pkg/worker/caged/libretro/nanoarch/input_test.go b/pkg/worker/caged/libretro/nanoarch/input_test.go index 042d108e..4cda73f1 100644 --- a/pkg/worker/caged/libretro/nanoarch/input_test.go +++ b/pkg/worker/caged/libretro/nanoarch/input_test.go @@ -7,21 +7,215 @@ import ( "testing" ) -func TestConcurrentInput(t *testing.T) { +func TestInputState_SetInput(t *testing.T) { + tests := []struct { + name string + port int + data []byte + keys uint32 + axes [4]int32 + }{ + { + 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]int32{10000, -10000, -32768, 32767}, + }, + { + name: "partial axes", + port: 2, + data: []byte{0x01, 0x00, 0x64, 0x00}, + keys: 0x0001, + axes: [4]int32{100, 0, 0, 0}, + }, + { + name: "max port", + port: 3, + data: []byte{0xFF, 0xFF}, + keys: 0xFFFF, + }, + } + + 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) + } + for i, want := range test.axes { + if state[test.port].axes[i] != want { + t.Errorf("axes[%d]: got %v, want %v", i, state[test.port].axes[i], want) + } + } + }) + } +} + +func TestInputState_Concurrent(t *testing.T) { var wg sync.WaitGroup state := InputState{} events := 1000 - wg.Add(2 * events) + wg.Add(events) for range events { player := rand.Intn(maxPort) - go func() { state.Input(player, []byte{0, 1}); wg.Done() }() - go func() { state.IsKeyPressed(uint(player), 100); wg.Done() }() + go func() { + state.SetInput(player, []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0}) + wg.Done() + }() } wg.Wait() } -func TestMousePos(t *testing.T) { +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 @@ -30,42 +224,109 @@ func TestMousePos(t *testing.T) { ry int16 b func(dx, dy int16) []byte }{ - {name: "normal", dx: -10123, dy: 5678, rx: -10123, ry: 5678, b: func(dx, dy int16) []byte { - data := []byte{0, 0, 0, 0} - 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 := []byte{0, 0, 0, 0} - binary.LittleEndian.PutUint16(data, uint16(dx)) - binary.LittleEndian.PutUint16(data[2:], uint16(dy)) - return data - }}, + { + 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) { - data := test.b(test.dx, test.dy) - ms := MouseState{} - ms.ShiftPos(data) + ms.ShiftPos(test.b(test.dx, test.dy)) - x := int16(ms.PopX()) - y := int16(ms.PopY()) + x, y := int16(ms.dx.Swap(0)), int16(ms.dy.Swap(0)) if x != test.rx || y != test.ry { - t.Errorf("invalid state, %v = %v, %v = %v", test.rx, x, test.ry, y) + t.Errorf("got (%v, %v), want (%v, %v)", x, y, test.rx, test.ry) } if ms.dx.Load() != 0 || ms.dy.Load() != 0 { - t.Errorf("coordinates weren't cleared") + t.Error("coordinates weren't cleared") } }) } } -func TestMouseButtons(t *testing.T) { +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 @@ -73,10 +334,13 @@ func TestMouseButtons(t *testing.T) { r bool m bool }{ - {name: "l+r+m+", data: 1 + 2 + 4, l: true, r: true, m: true}, - {name: "l-r-m-", data: 0}, - {name: "l-r+m-", data: 2, r: true}, - {name: "l+r-m+", data: 1 + 4, l: true, m: true}, + {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{} @@ -86,8 +350,56 @@ func TestMouseButtons(t *testing.T) { ms.SetButtons(test.data) l, r, m := ms.Buttons() if l != test.l || r != test.r || m != test.m { - t.Errorf("wrong button state: %v -> %v, %v, %v", test.data, l, r, 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") + } +} diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.go b/pkg/worker/caged/libretro/nanoarch/nanoarch.go index 6153f80e..5d34dca3 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.go +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.go @@ -426,7 +426,7 @@ func (n *Nanoarch) Run() { 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.Input(port, data) } +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 From 1d5bae0c62b5293aa791f9781513577a92036f0e Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Mon, 29 Dec 2025 20:20:55 +0300 Subject: [PATCH 239/240] 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) --- pkg/worker/caged/libretro/nanoarch/input.go | 54 +++++--- .../caged/libretro/nanoarch/input_test.go | 129 ++++++++++++++++-- pkg/worker/caged/libretro/nanoarch/nanoarch.c | 49 ++++--- 3 files changed, 188 insertions(+), 44 deletions(-) diff --git a/pkg/worker/caged/libretro/nanoarch/input.go b/pkg/worker/caged/libretro/nanoarch/input.go index 59b5b014..eb6080c5 100644 --- a/pkg/worker/caged/libretro/nanoarch/input.go +++ b/pkg/worker/caged/libretro/nanoarch/input.go @@ -10,7 +10,8 @@ import ( #include "libretro.h" void input_cache_set_port(unsigned port, uint32_t buttons, - int16_t axis0, int16_t axis1, int16_t axis2, int16_t axis3); + 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); @@ -46,34 +47,55 @@ const ( // InputState stores controller state for all ports. // - uint16 button bitmask -// - int16 analog axes x4 +// - int16 analog axes x4 (left stick, right stick) +// - int16 analog triggers x2 (L2, R2) type InputState [maxPort]struct { - keys uint32 - axes [numAxes]int32 + 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][AX0:2][AX1:2][AX2:2][AX3:2] +// [BTN:2][LX:2][LY:2][RX:2][RY:2][L2:2][R2:2] func (s *InputState) SetInput(port int, data []byte) { - atomic.StoreUint32(&s[port].keys, uint32(binary.LittleEndian.Uint16(data))) - for i := 0; i < numAxes && i*2+3 < len(data); i++ { - atomic.StoreInt32(&s[port].axes[i], int32(int16(binary.LittleEndian.Uint16(data[i*2+2:])))) + if len(data) < 2 { + return } -} -// Button check -func (s *InputState) Button(port, key uint) C.int16_t { - return C.int16_t((atomic.LoadUint32(&s[port].keys) >> key) & 1) + // 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++ { - a := &s[p].axes - C.input_cache_set_port(C.uint(p), C.uint32_t(atomic.LoadUint32(&s[p].keys)), - C.int16_t(atomic.LoadInt32(&a[0])), C.int16_t(atomic.LoadInt32(&a[1])), - C.int16_t(atomic.LoadInt32(&a[2])), C.int16_t(atomic.LoadInt32(&a[3]))) + 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)) } } diff --git a/pkg/worker/caged/libretro/nanoarch/input_test.go b/pkg/worker/caged/libretro/nanoarch/input_test.go index 4cda73f1..1df81da7 100644 --- a/pkg/worker/caged/libretro/nanoarch/input_test.go +++ b/pkg/worker/caged/libretro/nanoarch/input_test.go @@ -9,11 +9,12 @@ import ( func TestInputState_SetInput(t *testing.T) { tests := []struct { - name string - port int - data []byte - keys uint32 - axes [4]int32 + name string + port int + data []byte + keys uint32 + axes [4]int16 + triggers [2]int16 }{ { name: "buttons only", @@ -26,14 +27,14 @@ func TestInputState_SetInput(t *testing.T) { port: 1, data: []byte{0x03, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x00, 0x80, 0xFF, 0x7F}, keys: 0x0003, - axes: [4]int32{10000, -10000, -32768, 32767}, + axes: [4]int16{10000, -10000, -32768, 32767}, }, { name: "partial axes", port: 2, data: []byte{0x01, 0x00, 0x64, 0x00}, keys: 0x0001, - axes: [4]int32{100, 0, 0, 0}, + axes: [4]int16{100, 0, 0, 0}, }, { name: "max port", @@ -41,6 +42,46 @@ func TestInputState_SetInput(t *testing.T) { 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 { @@ -51,15 +92,82 @@ func TestInputState_SetInput(t *testing.T) { 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 { - if state[test.port].axes[i] != want { - t.Errorf("axes[%d]: got %v, want %v", i, state[test.port].axes[i], want) + 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{} @@ -69,7 +177,8 @@ func TestInputState_Concurrent(t *testing.T) { for range events { player := rand.Intn(maxPort) go func() { - state.SetInput(player, []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0}) + // Full 14-byte input + state.SetInput(player, []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) wg.Done() }() } diff --git a/pkg/worker/caged/libretro/nanoarch/nanoarch.c b/pkg/worker/caged/libretro/nanoarch/nanoarch.c index d9010539..63d3b4d4 100644 --- a/pkg/worker/caged/libretro/nanoarch/nanoarch.c +++ b/pkg/worker/caged/libretro/nanoarch/nanoarch.c @@ -33,30 +33,30 @@ void *same_thread_with_args(void *f, int type, ...); #define INPUT_MAX_KEYS 512 typedef struct { - // Retropad: store raw button bitmask and analog axes per port uint32_t buttons[INPUT_MAX_PORTS]; - int16_t analog[INPUT_MAX_PORTS][4]; // 4 axes per port + int16_t analog[INPUT_MAX_PORTS][4]; // LX, LY, RX, RY + int16_t triggers[INPUT_MAX_PORTS][2]; // L2, R2 - // Keyboard uint8_t keyboard[INPUT_MAX_KEYS]; - - // Mouse int16_t mouse_x; int16_t mouse_y; - uint8_t mouse_buttons; // bit 0=left, bit 1=right, bit 2=middle + 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 axis0, int16_t axis1, int16_t axis2, int16_t axis3) { + 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] = axis0; - input_cache.analog[port][1] = axis1; - input_cache.analog[port][2] = axis2; - input_cache.analog[port][3] = axis3; + 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; } } @@ -202,23 +202,36 @@ int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, uns switch (device) { case RETRO_DEVICE_JOYPAD: - // Extract button bit from cached bitmask return (int16_t)((input_cache.buttons[port] >> id) & 1); case RETRO_DEVICE_ANALOG: switch (index) { case RETRO_DEVICE_INDEX_ANALOG_LEFT: - // id: 0=X, 1=Y - if (id < 2) { + // 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: 0=X, 1=Y -> stored in axes[2], axes[3] - if (id < 2) { + // 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; @@ -232,12 +245,12 @@ int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, uns switch (id) { case RETRO_DEVICE_ID_MOUSE_X: { int16_t x = input_cache.mouse_x; - input_cache.mouse_x = 0; // Consume delta + 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; // Consume delta + input_cache.mouse_y = 0; return y; } case RETRO_DEVICE_ID_MOUSE_LEFT: From 9e6efc231993fe5485e8e68ee9ddd78f1b752f53 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Tue, 30 Dec 2025 14:36:48 +0300 Subject: [PATCH 240/240] Update retropad input --- web/js/input/input.js | 4 +- web/js/input/keys.js | 10 ++- web/js/input/retropad.js | 151 +++++++++++++++------------------------ 3 files changed, 68 insertions(+), 97 deletions(-) diff --git a/web/js/input/input.js b/web/js/input/input.js index a636c6ab..2c3fffa4 100644 --- a/web/js/input/input.js +++ b/web/js/input/input.js @@ -5,7 +5,7 @@ import { sub } from 'event'; -export {KEY} from './keys.js?v=3'; +export {KEY, JOYPAD_KEYS} from './keys.js?v=3'; import {joystick} from './joystick.js?v=3'; import {keyboard} from './keyboard.js?v=3' @@ -44,7 +44,7 @@ export const input = { toggle(on = true) { if (on === input_state.retropad) return input_state.retropad = on - on ? retropad.enable() : retropad.disable() + retropad.toggle(on) } }, set kbm(v) { diff --git a/web/js/input/keys.js b/web/js/input/keys.js index 4406823e..60e45e3e 100644 --- a/web/js/input/keys.js +++ b/web/js/input/keys.js @@ -30,4 +30,12 @@ export const KEY = { R3: 'r3', REC: 'rec', RESET: 'reset', -} +}; + +// Keys match libretro RETRO_DEVICE_ID_JOYPAD_* +export const JOYPAD_KEYS = [ + KEY.B, KEY.Y, KEY.SELECT, KEY.START, + KEY.UP, KEY.DOWN, KEY.LEFT, KEY.RIGHT, + KEY.A, KEY.X, KEY.L, KEY.R, + KEY.L2, KEY.R2, KEY.L3, KEY.R3 +] diff --git a/web/js/input/retropad.js b/web/js/input/retropad.js index 2ecbd659..0e7026ee 100644 --- a/web/js/input/retropad.js +++ b/web/js/input/retropad.js @@ -1,101 +1,64 @@ -import { - pub, - CONTROLLER_UPDATED -} from 'event'; -import {KEY} from 'input' -import {log} from 'log'; +import {pub, CONTROLLER_UPDATED} from 'event'; +import {JOYPAD_KEYS} from 'input'; -const pollingIntervalMs = 5; -let controllerChangedIndex = -1; - -// Libretro config -let controllerState = { - [KEY.B]: false, - [KEY.Y]: false, - [KEY.SELECT]: false, - [KEY.START]: false, - [KEY.UP]: false, - [KEY.DOWN]: false, - [KEY.LEFT]: false, - [KEY.RIGHT]: false, - [KEY.A]: false, - [KEY.X]: false, - // extra - [KEY.L]: false, - [KEY.R]: false, - [KEY.L2]: false, - [KEY.R2]: false, - [KEY.L3]: false, - [KEY.R3]: false -}; - -const poll = (intervalMs, callback) => { - let _ticker = 0; - return { - enable: () => { - if (_ticker > 0) return; - log.debug(`[input] poll set to ${intervalMs}ms`); - _ticker = setInterval(callback, intervalMs) - }, - disable: () => { - if (_ticker < 1) return; - log.debug('[input] poll has been disabled'); - clearInterval(_ticker); - _ticker = 0; - } - } -}; - -const controllerEncoded = [0, 0, 0, 0, 0]; -const keys = Object.keys(controllerState); - -const sendControllerState = () => { - if (controllerChangedIndex >= 0) { - const state = _getState(); - pub(CONTROLLER_UPDATED, _encodeState(state)); - controllerChangedIndex = -1; - } -}; - -const setKeyState = (name, state) => { - if (controllerState[name] !== undefined) { - controllerState[name] = state; - controllerChangedIndex = Math.max(controllerChangedIndex, 0); - } -}; - -const setAxisChanged = (index, value) => { - if (controllerEncoded[index + 1] !== undefined) { - controllerEncoded[index + 1] = Math.floor(32767 * value); - controllerChangedIndex = Math.max(controllerChangedIndex, index + 1); - } -}; - -/** - * Converts key state into a bitmap and prepends it to the axes state. +/* + * [BUTTONS, LEFT_X, LEFT_Y, RIGHT_X, RIGHT_Y] * - * @returns {Uint16Array} The controller state. - * First uint16 is the controller state bitmap. - * The other uint16 are the axes values. - * Truncated to the last value changed. - * - * @private + * Buttons are packed into a 16-bit bitmask where each bit is one button. + * Axes are signed 16-bit values ranging from -32768 to 32767. + * The whole thing is 10 bytes when sent over the wire. */ -const _encodeState = (state) => new Uint16Array(state) +const state = new Int16Array(5); +let buttons = 0; +let dirty = false; +let rafId = 0; -const _getState = () => { - controllerEncoded[0] = 0; - for (let i = 0, len = keys.length; i < len; i++) { - controllerEncoded[0] += controllerState[keys[i]] ? 1 << i : 0; +/* + * Polls controller state using requestAnimationFrame which gives us + * ~60Hz update rate that syncs with the display. As a bonus, + * it automatically pauses when the tab goes to background. + * We only send data when something actually changed. + */ +const poll = () => { + if (dirty) { + state[0] = buttons; + pub(CONTROLLER_UPDATED, new Uint16Array(state.buffer)); + dirty = false; } - return controllerEncoded.slice(0, controllerChangedIndex + 1); -} + rafId = requestAnimationFrame(poll); +}; -const _poll = poll(pollingIntervalMs, sendControllerState) +/* + * Toggles a button on or off in the bitmask. The button's position + * in JOYPAD_KEYS determines which bit gets flipped. For example, + * if A is at index 8, pressing it sets bit 8. + */ +const setKeyState = (key, pressed) => { + const idx = JOYPAD_KEYS.indexOf(key); + if (idx < 0) return; -export const retropad = { - enable: () => _poll.enable(), - disable: () => _poll.disable(), - setKeyState, - setAxisChanged, -} + const prev = buttons; + buttons = pressed ? buttons | (1 << idx) : buttons & ~(1 << idx); + dirty ||= buttons !== prev; +}; + +/* + * Updates an analog stick axis. Axes 0-1 are the left stick (X and Y), + * axes 2-3 are the right stick. Input should be a float from -1 to 1 + * which gets converted to a signed 16-bit integer for transmission. + */ +const setAxisChanged = (axis, value) => { + if (axis < 0 || axis > 3) return; + + const v = Math.trunc(Math.max(-1, Math.min(1, value)) * 32767); + dirty ||= state[++axis] !== v; + state[axis] = v; +}; + +// Starts or stops the polling loop +const toggle = (on) => { + if (on === !!rafId) return; + rafId = on ? requestAnimationFrame(poll) : (cancelAnimationFrame(rafId), 0); +}; + +export const retropad = {toggle, setKeyState, setAxisChanged}; \ No newline at end of file