From 7f2f1d70b11f48c38d63166bc7bad0e1f1e7987b Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Wed, 25 Oct 2023 21:41:36 +0300 Subject: [PATCH] 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 eeec41227..3ffc0891d 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 f7d71fc36..84e57ed87 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 2260b4c0c..20f958477 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 462a10d26..e33f6b0f2 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 7c13cb76d..ab64042c9 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 1b6612015..4b412a3c2 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 000000000..48f1152a1 --- /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 737c7bd98..e675d33d0 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") } })