mirror of
https://github.com/giongto35/cloud-game.git
synced 2026-01-23 02:34:42 +00:00
545 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|