cloud-game/pkg/worker/caged/libretro/frontend.go
2025-11-22 12:32:56 +03:00

545 lines
14 KiB
Go

package libretro
import (
"errors"
"fmt"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"unsafe"
"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/app"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/graphics"
"github.com/giongto35/cloud-game/v3/pkg/worker/caged/libretro/nanoarch"
)
type Emulator interface {
SetAudioCb(func(app.Audio))
SetVideoCb(func(app.Video))
SetDataCb(func([]byte))
LoadCore(name string)
LoadGame(path string) error
FPS() int
Flipped() bool
Rotation() uint
PixFormat() uint32
AudioSampleRate() int
IsPortrait() bool
// Start is called after LoadGame
Start()
// ViewportRecalculate calculates output resolution with aspect and scale
ViewportRecalculate()
RestoreGameState() error
// SetSessionId sets distinct name for the game session (in order to save/load it later)
SetSessionId(name string)
SaveGameState() error
SaveStateName() string
// HashPath returns the path emulator will save state to
HashPath() string
// HasSave returns true if the current ROM was saved before
HasSave() bool
// Close will be called when the game is done
Close()
// Input passes input to the emulator
Input(player int, device byte, data []byte)
// Scale returns set video scale factor
Scale() float64
Reset()
}
type Frontend struct {
conf config.Emulator
done chan struct{}
log *logger.Logger
nano *nanoarch.Nanoarch
onAudio func(app.Audio)
onData func([]byte)
onVideo func(app.Video)
storage Storage
scale float64
th int // draw threads
vw, vh int // out frame size
// directives
// skipVideo used when new frame was too late
skipVideo bool
mu sync.Mutex
mui sync.Mutex
DisableCanvasPool bool
SaveOnClose bool
UniqueSaveDir bool
SaveStateFs string
}
type Device byte
const (
RetroPad = Device(nanoarch.RetroPad)
Keyboard = Device(nanoarch.Keyboard)
Mouse = Device(nanoarch.Mouse)
)
var (
audioPool sync.Pool
noAudio = func(app.Audio) {}
noData = func([]byte) {}
noVideo = func(app.Video) {}
videoPool sync.Pool
lastFrame *app.Video
)
// NewFrontend implements Emulator interface for a Libretro frontend.
func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
path, err := filepath.Abs(conf.LocalPath)
if err != nil {
return nil, fmt.Errorf("failed to use emulator path: %v, %w", conf.LocalPath, err)
}
if err := os.CheckCreateDir(path); err != nil {
return nil, fmt.Errorf("failed to create local path: %v, %w", conf.LocalPath, err)
}
log.Info().Msgf("Emulator save path is %v", path)
// we use the global Nanoarch instance from nanoarch
nano := nanoarch.NewNano(path)
log = log.Extend(log.With().Str("m", "Libretro"))
level := logger.Level(conf.Libretro.LogLevel)
nano.SetLogger(log.Extend(log.Level(level).With()))
// Check if room is on local storage, if not, pull from GCS to local storage
log.Info().Msgf("Local storage path: %v", conf.Storage)
if err := os.CheckCreateDir(conf.Storage); err != nil {
return nil, fmt.Errorf("failed to create local storage path: %v, %w", conf.Storage, err)
}
var store Storage = &StateStorage{Path: conf.Storage}
if conf.Libretro.SaveCompression {
store = &ZipStorage{Storage: store}
}
// set global link to the Libretro
f := &Frontend{
conf: conf,
done: make(chan struct{}),
log: log,
onAudio: noAudio,
onData: noData,
onVideo: noVideo,
storage: store,
th: conf.Threads,
}
f.linkNano(nano)
if conf.Libretro.DebounceMs > 0 {
t := time.Duration(conf.Libretro.DebounceMs) * time.Millisecond
f.nano.SetVideoDebounce(t)
f.log.Debug().Msgf("set debounce time: %v", t)
}
return f, nil
}
func (f *Frontend) LoadCore(emu string) {
conf := f.conf.GetLibretroCoreConfig(emu)
libExt := ""
if ar, err := f.conf.Libretro.Cores.Repo.Guess(); err == nil {
libExt = ar.Ext
} else {
f.log.Warn().Err(err).Msg("system arch guesser failed")
}
meta := nanoarch.Metadata{
AutoGlContext: conf.AutoGlContext,
FrameDup: f.conf.Libretro.Dup,
Hacks: conf.Hacks,
HasVFR: conf.VFR,
Hid: conf.Hid,
IsGlAllowed: conf.IsGlAllowed,
LibPath: conf.Lib,
Options: conf.Options,
Options4rom: conf.Options4rom,
UsesLibCo: conf.UsesLibCo,
CoreAspectRatio: conf.CoreAspectRatio,
KbMouseSupport: conf.KbMouseSupport,
LibExt: libExt,
}
f.mu.Lock()
f.SaveStateFs = conf.SaveStateFs
if conf.UniqueSaveDir {
f.UniqueSaveDir = true
f.nano.SetSaveDirSuffix(f.storage.MainPath())
f.log.Debug().Msgf("Using unique dir for saves: %v", f.storage.MainPath())
}
scale := 1.0
if conf.Scale > 1 {
scale = conf.Scale
f.log.Debug().Msgf("Scale: x%v", scale)
}
f.storage.SetNonBlocking(conf.NonBlockingSave)
f.scale = scale
f.nano.CoreLoad(meta)
f.mu.Unlock()
}
func (f *Frontend) handleAudio(audio unsafe.Pointer, samples int) {
fr, _ := audioPool.Get().(*app.Audio)
if fr == nil {
fr = new(app.Audio)
}
// !to look if we need a copy
fr.Data = unsafe.Slice((*int16)(audio), samples)
// due to audio buffering for opus fixed frames and const duration up in the hierarchy,
// we skip Duration here
f.onAudio(*fr)
audioPool.Put(fr)
}
func (f *Frontend) handleVideo(data []byte, delta int32, fi nanoarch.FrameInfo) {
if f.conf.SkipLateFrames && f.skipVideo {
return
}
fr, _ := videoPool.Get().(*app.Video)
if fr == nil {
fr = new(app.Video)
}
fr.Frame.Data = data
fr.Frame.W = int(fi.W)
fr.Frame.H = int(fi.H)
fr.Frame.Stride = int(fi.Stride)
fr.Duration = delta
lastFrame = fr
f.onVideo(*fr)
videoPool.Put(fr)
}
func (f *Frontend) handleDup() {
if lastFrame != nil {
f.onVideo(*lastFrame)
}
}
func (f *Frontend) Shutdown() {
f.mu.Lock()
f.nano.Shutdown()
f.SetAudioCb(noAudio)
f.SetVideoCb(noVideo)
lastFrame = nil
f.mu.Unlock()
f.log.Debug().Msgf("frontend shutdown done")
}
func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) {
f.nano = nano
if nano == nil {
return
}
f.nano.WaitReady() // start only when nano is available
f.nano.OnVideo = f.handleVideo
f.nano.OnAudio = f.handleAudio
f.nano.OnDup = f.handleDup
}
func (f *Frontend) SetVideoChangeCb(fn func()) {
if f.nano != nil {
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
}
// don't jump between threads
runtime.LockOSThread()
defer runtime.UnlockOSThread()
f.mui.Lock()
f.done = make(chan struct{})
f.nano.LastFrameTime = time.Now().UnixNano()
defer func() {
// Save game on quit if it was saved before (shared or click-saved).
if f.SaveOnClose && f.HasSave() {
f.log.Debug().Msg("save on quit")
if err := f.Save(); err != nil {
f.log.Error().Err(err).Msg("save on quit failed")
}
}
f.mui.Unlock()
f.Shutdown()
}()
if f.HasSave() {
// advance 1 frame for Mupen, 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")
}
}
if f.conf.AutosaveSec > 0 {
// !to sync both for loops, can crash if the emulator starts later
go f.autosave(f.conf.AutosaveSec)
}
// The main loop of Libretro
// calculate the exact duration required for a frame (e.g., 16.666ms = 60 FPS)
targetFrameTime := time.Second / time.Duration(f.nano.VideoFramerate())
// stop sleeping and start spinning in the remaining 1ms
const spinThreshold = 1 * time.Millisecond
// how many frames will be considered not normal
const lateFramesThreshold = 3
lastFrameStart := time.Now()
for {
select {
case <-f.done:
return
default:
// run one tick of the emulation
f.Tick()
elapsed := time.Since(lastFrameStart)
sleepTime := targetFrameTime - elapsed
if sleepTime > 0 {
// SLEEP
// if we have plenty of time, sleep to save CPU and
// wake up slightly before the target time
if sleepTime > spinThreshold {
time.Sleep(sleepTime - spinThreshold)
}
// SPIN
// if we are close to the target,
// burn CPU and check the clock with ns resolution
for time.Since(lastFrameStart) < targetFrameTime {
// CPU burn!
}
f.skipVideo = false
} else {
// lagging behind the target framerate so we don't sleep
if f.conf.LogDroppedFrames {
// !to make some stats counter instead
f.log.Debug().Msgf("[] Frame drop: %v", elapsed)
}
f.skipVideo = true
}
// timer reset
//
// adding targetFrameTime to the previous start
// prevents drift, if one frame was late,
// we try to catch up in the next frame
lastFrameStart = lastFrameStart.Add(targetFrameTime)
// if execution was paused or heavily delayed,
// reset lastFrameStart so we don't try to run
// a bunch of frames instantly to catch up
if time.Since(lastFrameStart) > targetFrameTime*lateFramesThreshold {
lastFrameStart = time.Now()
}
}
}
}
func (f *Frontend) LoadGame(path string) error {
if f.UniqueSaveDir {
f.copyFsMaybe(path)
}
return f.nano.LoadGame(path)
}
func (f *Frontend) AspectRatio() float32 { return f.nano.AspectRatio() }
func (f *Frontend) AudioSampleRate() int { return f.nano.AudioSampleRate() }
func (f *Frontend) FPS() int { return f.nano.VideoFramerate() }
func (f *Frontend) Flipped() bool { return f.nano.IsGL() }
func (f *Frontend) FrameSize() (int, int) { return f.nano.BaseWidth(), f.nano.BaseHeight() }
func (f *Frontend) HasSave() bool { return os.Exists(f.HashPath()) }
func (f *Frontend) HashPath() string { return f.storage.GetSavePath() }
func (f *Frontend) IsPortrait() bool { return f.nano.IsPortrait() }
func (f *Frontend) KbMouseSupport() bool { return f.nano.KbMouseSupport() }
func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C }
func (f *Frontend) Reset() { f.mu.Lock(); defer f.mu.Unlock(); f.nano.Reset() }
func (f *Frontend) RestoreGameState() error { return f.Load() }
func (f *Frontend) Rotation() uint { return f.nano.Rot }
func (f *Frontend) SRAMPath() string { return f.storage.GetSRAMPath() }
func (f *Frontend) SaveGameState() error { return f.Save() }
func (f *Frontend) SaveStateName() string { return filepath.Base(f.HashPath()) }
func (f *Frontend) Scale() float64 { return f.scale }
func (f *Frontend) SetAudioCb(cb func(app.Audio)) { f.onAudio = cb }
func (f *Frontend) SetSessionId(name string) { f.storage.SetMainSaveName(name) }
func (f *Frontend) SetDataCb(cb func([]byte)) { f.onData = cb }
func (f *Frontend) SetVideoCb(ff func(app.Video)) { f.onVideo = ff }
func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f.mu.Unlock() }
func (f *Frontend) ViewportRecalculate() { f.mu.Lock(); f.vw, f.vh = f.ViewportCalc(); f.mu.Unlock() }
func (f *Frontend) ViewportSize() (int, int) { return f.vw, f.vh }
func (f *Frontend) Input(port int, device byte, data []byte) {
switch Device(device) {
case RetroPad:
f.nano.InputRetropad(port, data)
case Keyboard:
f.nano.InputKeyboard(port, data)
case Mouse:
f.nano.InputMouse(port, data)
}
}
func (f *Frontend) ViewportCalc() (nw int, nh int) {
w, h := f.FrameSize()
nw, nh = w, h
if f.IsPortrait() {
nw, nh = nh, nw
}
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()
f.nano.Close()
if f.UniqueSaveDir && !f.HasSave() {
if err := f.nano.DeleteSaveDir(); err != nil {
f.log.Error().Msgf("couldn't delete save dir: %v", err)
}
}
f.UniqueSaveDir = false
f.SaveStateFs = ""
f.mui.Unlock()
f.log.Debug().Msgf("frontend closed")
}
// Save writes the current state to the filesystem.
func (f *Frontend) Save() error {
f.mu.Lock()
defer f.mu.Unlock()
ss, err := nanoarch.SaveState()
if err != nil {
return err
}
if err := f.storage.Save(f.HashPath(), ss); err != nil {
return err
}
ss = nil
if sram := nanoarch.SaveRAM(); sram != nil {
if err := f.storage.Save(f.SRAMPath(), sram); err != nil {
return err
}
sram = nil
}
return nil
}
// Load restores the state from the filesystem.
func (f *Frontend) Load() error {
f.mu.Lock()
defer f.mu.Unlock()
ss, err := f.storage.Load(f.HashPath())
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err := nanoarch.RestoreSaveState(ss); err != nil {
return err
}
sram, err := f.storage.Load(f.SRAMPath())
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if sram != nil {
nanoarch.RestoreSaveRAM(sram)
}
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)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if f.nano.IsStopped() {
return
}
if err := f.Save(); err != nil {
f.log.Error().Msgf("Autosave failed: %v", err)
} else {
f.log.Debug().Msgf("Autosave done")
}
case <-f.done:
return
}
}
}
func (f *Frontend) copyFsMaybe(path string) {
if f.SaveStateFs == "" {
return
}
fileName := f.SaveStateFs
hasPlaceholder := strings.HasPrefix(f.SaveStateFs, "*")
if hasPlaceholder {
game := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
fileName = strings.Replace(f.SaveStateFs, "*", game, 1)
}
fullPath := filepath.Join(f.nano.SaveDir(), fileName)
if os.Exists(fullPath) {
return
}
storePath := filepath.Dir(path)
fsPath := filepath.Join(storePath, fileName)
if os.Exists(fsPath) {
if err := os.CopyFile(fsPath, fullPath); err != nil {
f.log.Error().Err(err).Msgf("fs copy fail")
} else {
f.log.Debug().Msgf("copied fs %v to %v", fsPath, fullPath)
}
}
}