From bd1ede2321f382da3cbad765040816232269aef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Andr=C3=A9=20Santoni?= Date: Sun, 12 Apr 2026 22:48:32 +0200 Subject: [PATCH] Pacing --- audio/audio.go | 339 ++++++++++++++++++++++++++++++++++++++------ audio/audio_test.go | 137 +++++++++++++----- core/core.go | 26 +++- core/environment.go | 3 +- main.go | 211 +++++++++++++++++++++++++-- main_test.go | 35 +++++ state/state.go | 3 + 7 files changed, 652 insertions(+), 102 deletions(-) create mode 100644 main_test.go diff --git a/audio/audio.go b/audio/audio.go index dae4bc60..767760d9 100644 --- a/audio/audio.go +++ b/audio/audio.go @@ -3,7 +3,9 @@ package audio import ( + "encoding/binary" "log" + "math" "path/filepath" "time" "unsafe" @@ -14,16 +16,31 @@ import ( "golang.org/x/mobile/exp/audio/al" ) -const bufSize = 1024 * 8 +const ( + bufSize = 1024 + bytesPerFrame = 4 + framesPerBuffer = bufSize / bytesPerFrame + outputRate = 48000 + targetLatencyMs = 96 + minNumBuffers = 2 + audioMaxTimingSkew = 0.05 + maxPlaybackRateSkew = 0.005 +) var ( - source al.Source - buffers []al.Buffer - rate int32 - numBuffers int32 - tmpBuf [bufSize]byte - tmpBufPtr int32 - resPtr int32 + source al.Source + buffers []al.Buffer + freeBufs []al.Buffer + rate int32 + inputRate float64 + videoSyncRate float64 + numBuffers int32 + queueBytes int32 + inputBuf []int16 + readPhase float64 + tmpBuf [bufSize]byte + srcRatioOrig float64 + srcRatioCurr float64 ) // Effects are sound effects @@ -31,9 +48,17 @@ var Effects map[string]*Effect // SetVolume sets the audio volume func SetVolume(vol float32) { + if source == 0 { + return + } source.SetGain(vol) } +// Stop clears queued game audio without tearing down the OpenAL device. +func Stop() { + clearQueue() +} + // Init initializes the audio device func Init() { err := al.OpenDevice() @@ -55,20 +80,76 @@ func Init() { // Reconfigure initializes the audio package. It sets the number of buffers, the // volume and the source for the games. func Reconfigure(r int32) { - rate = r - numBuffers = 4 + releaseSource() - log.Printf("[OpenAL]: Using %v buffers of %v bytes.\n", numBuffers, bufSize) + rate = outputRate + inputRate = float64(r) + numBuffers = queueBufferCount(rate) + queueBytes = numBuffers * bufSize + updateInputRate() + + log.Printf( + "[OpenAL]: Using %v buffers of %v bytes (~%v ms queued).\n", + numBuffers, + bufSize, + queueLatencyMs(rate, queueBytes), + ) source = al.GenSources(1)[0] buffers = al.GenBuffers(int(numBuffers)) - resPtr = numBuffers - tmpBufPtr = 0 + freeBufs = append(freeBufs[:0], buffers...) + inputBuf = inputBuf[:0] + readPhase = 0 tmpBuf = [bufSize]byte{} source.SetGain(settings.Current.AudioVolume) } +func releaseSource() { + if source == 0 { + return + } + + clearQueue() + al.DeleteSources(source) + source = 0 + + if len(buffers) > 0 { + al.DeleteBuffers(buffers...) + } + + buffers = nil + freeBufs = nil + queueBytes = 0 + inputBuf = nil + readPhase = 0 + tmpBuf = [bufSize]byte{} + inputRate = 0 + videoSyncRate = 0 + srcRatioOrig = 0 + srcRatioCurr = 0 +} + +func clearQueue() { + if source == 0 { + return + } + + al.StopSources(source) + + queued := source.BuffersQueued() + if queued > 0 { + unqueued := make([]al.Buffer, queued) + source.UnqueueBuffers(unqueued...) + } + + freeBufs = append(freeBufs[:0], buffers...) + inputBuf = inputBuf[:0] + readPhase = 0 + tmpBuf = [bufSize]byte{} + srcRatioCurr = srcRatioOrig +} + func min(a, b int32) int32 { if a < b { return a @@ -76,61 +157,237 @@ func min(a, b int32) int32 { return b } -func alUnqueueBuffers() bool { +func clampFloat64(v, low, high float64) float64 { + if v < low { + return low + } + if v > high { + return high + } + return v +} + +func queueBufferCount(rate int32) int32 { + totalBytes := int32(math.Ceil(float64(rate*bytesPerFrame*targetLatencyMs) / 1000.0)) + buffers := int32(math.Ceil(float64(totalBytes) / float64(bufSize))) + + if buffers < minNumBuffers { + return minNumBuffers + } + return buffers +} + +func adjustedInputRate(inputRate, inputFPS, targetVideoSyncRate float64) float64 { + if inputRate <= 0 { + return 0 + } + if inputFPS <= 0 || targetVideoSyncRate <= 0 { + return inputRate + } + + timingSkew := math.Abs(1.0 - inputFPS/targetVideoSyncRate) + if timingSkew <= audioMaxTimingSkew { + return inputRate * targetVideoSyncRate / inputFPS + } + + return inputRate +} + +func updateInputRate() { + if inputRate <= 0 || rate <= 0 { + srcRatioOrig = 0 + srcRatioCurr = 0 + return + } + + syncInputRate := adjustedInputRate(inputRate, state.CoreFPS, videoSyncRate) + if syncInputRate <= 0 { + syncInputRate = inputRate + } + + srcRatioOrig = float64(rate) / syncInputRate + srcRatioCurr = srcRatioOrig +} + +// SetVideoTiming updates the current video timing used for audio rate control. +func SetVideoTiming(refreshHz float64, swapInterval int) { + syncRate := refreshHz + if swapInterval > 1 { + syncRate /= float64(swapInterval) + } + + if math.Abs(syncRate-videoSyncRate) < 0.01 { + return + } + + videoSyncRate = syncRate + updateInputRate() +} + +func queueLatencyMs(rate, bytes int32) int32 { + if rate <= 0 { + return 0 + } + return int32(math.Round(float64(bytes*1000) / float64(rate*bytesPerFrame))) +} + +func pendingQueueBytes(queued, processed, offset int32) int32 { + active := queued - processed + if active <= 0 { + return 0 + } + + bytes := active * bufSize + if offset > 0 { + bytes -= min(offset, bufSize) + } + if bytes < 0 { + return 0 + } + return bytes +} + +func queuedWholeBufferBytes() int32 { + if source == 0 { + return 0 + } + + queued := source.BuffersQueued() + processed := source.BuffersProcessed() + return pendingQueueBytes(queued, processed, 0) +} + +func sourceWriteAvail() int32 { + alUnqueueBuffers() + + avail := queueBytes - queuedWholeBufferBytes() + if avail < 0 { + return 0 + } + if avail > queueBytes { + return queueBytes + } + return avail +} + +func currentResampleStep(writeAvail int32) float64 { + if rate <= 0 || queueBytes <= 0 || srcRatioOrig <= 0 { + return 1.0 + } + + halfSize := float64(queueBytes) / 2 + direction := 0.0 + if halfSize > 0 { + direction = (float64(writeAvail) - halfSize) / halfSize + } + + adjust := 1.0 + maxPlaybackRateSkew*clampFloat64(direction, -1.0, 1.0) + srcRatioCurr = srcRatioOrig * adjust + return 1.0 / srcRatioCurr +} + +func alUnqueueBuffers() int32 { + if source == 0 { + return 0 + } + val := source.BuffersProcessed() if val <= 0 { - return false + return 0 } - source.UnqueueBuffers(buffers[resPtr:val]...) - resPtr += val - return true + unqueued := make([]al.Buffer, val) + source.UnqueueBuffers(unqueued...) + freeBufs = append(freeBufs, unqueued...) + return val } func alGetBuffer() al.Buffer { - if resPtr == 0 { - for { - if alUnqueueBuffers() { - break - } - time.Sleep(time.Millisecond) + for len(freeBufs) == 0 { + if alUnqueueBuffers() > 0 { + break } + + // OpenAL does not provide a blocking dequeue API, so keep the queue + // short and sleep briefly while waiting for the next buffer to retire. + time.Sleep(time.Millisecond) } - resPtr-- - return buffers[resPtr] + last := len(freeBufs) - 1 + buffer := freeBufs[last] + freeBufs = freeBufs[:last] + return buffer } -func fillInternalBuf(buf []byte) int32 { - readSize := min(bufSize-tmpBufPtr, int32(len(buf))) - copy(tmpBuf[tmpBufPtr:], buf[:readSize]) - tmpBufPtr += readSize +func appendInput(buf []byte) int32 { + readSize := int32(len(buf)) + for i := 0; i+1 < len(buf); i += 2 { + inputBuf = append(inputBuf, int16(binary.LittleEndian.Uint16(buf[i:]))) + } return readSize } -func write(buf []byte, size int32) int32 { - written := int32(0) +func availableInputFrames() int { + return len(inputBuf) / 2 +} + +func requiredInputFrames(step float64) int { + return int(math.Ceil(readPhase+step*float64(framesPerBuffer-1))) + 2 +} + +func renderOutputBuffer(step float64) { + for frame := 0; frame < framesPerBuffer; frame++ { + base := int(readPhase) + frac := readPhase - float64(base) + + left0 := float64(inputBuf[base*2]) + right0 := float64(inputBuf[base*2+1]) + left1 := float64(inputBuf[(base+1)*2]) + right1 := float64(inputBuf[(base+1)*2+1]) + + left := int16(math.Round(left0 + (left1-left0)*frac)) + right := int16(math.Round(right0 + (right1-right0)*frac)) + + offset := frame * bytesPerFrame + binary.LittleEndian.PutUint16(tmpBuf[offset:], uint16(left)) + binary.LittleEndian.PutUint16(tmpBuf[offset+2:], uint16(right)) + readPhase += step + } + + discardFrames := int(readPhase) + if discardFrames <= 0 { + return + } + + discardSamples := discardFrames * 2 + copy(inputBuf, inputBuf[discardSamples:]) + inputBuf = inputBuf[:len(inputBuf)-discardSamples] + readPhase -= float64(discardFrames) +} + +func write(buf []byte, size int32) int32 { if state.FastForward { + clearQueue() return size } - for size > 0 { - - rc := fillInternalBuf(buf[written:]) + if source == 0 || len(buffers) == 0 { + return size + } - written += rc - size -= rc + written := appendInput(buf[:size]) - if tmpBufPtr != bufSize { + for { + writeAvail := sourceWriteAvail() + step := currentResampleStep(writeAvail) + if availableInputFrames() < requiredInputFrames(step) { break } - buffer := alGetBuffer() - + renderOutputBuffer(step) buffer.BufferData(al.FormatStereo16, tmpBuf[:], rate) - tmpBufPtr = 0 source.QueueBuffers(buffer) if source.State() != al.Playing { diff --git a/audio/audio_test.go b/audio/audio_test.go index dc4e8170..c8419f7c 100644 --- a/audio/audio_test.go +++ b/audio/audio_test.go @@ -1,58 +1,117 @@ package audio import ( + "math" "testing" ) -func Test_alUnqueueBuffers(t *testing.T) { - t.Run("Return false if no buffers were processed", func(t *testing.T) { - got := alUnqueueBuffers() - if got { - t.Errorf("alUnqueueBuffers() = %v, want %v", got, false) - } - }) -} - -func Test_Sample(t *testing.T) { - t.Run("Doesn't crash when called", func(t *testing.T) { - Sample(-30000, -30000) - Sample( 30000, 30000) - }) -} - -func Test_fillInternalBuf(t *testing.T) { - Reconfigure(48000) - type args struct { - buf []byte - size int32 - } +func TestPendingQueueBytes(t *testing.T) { tests := []struct { - name string - args args - want int32 + name string + queued int32 + processed int32 + offset int32 + want int32 }{ { - name: "Fill the buffer partially", - args: args{ - buf: make([]byte, bufSize), - size: 6000, - }, - want: 6000, + name: "Nothing queued", + queued: 0, + processed: 0, + offset: 0, + want: 0, + }, + { + name: "Processed buffers do not count as pending", + queued: 4, + processed: 2, + offset: 0, + want: 2 * bufSize, }, { - name: "Fill the buffer fully", - args: args{ - buf: make([]byte, bufSize), - size: 6000, - }, - want: 2192, + name: "Subtract current playback offset", + queued: 3, + processed: 1, + offset: 128, + want: 2*bufSize - 128, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := fillInternalBuf(tt.args.buf[:tt.args.size]); got != tt.want { - t.Errorf("fillInternalBuf() = %v, want %v", got, tt.want) + got := pendingQueueBytes(tt.queued, tt.processed, tt.offset) + if got != tt.want { + t.Errorf("pendingQueueBytes() = %v, want %v", got, tt.want) } }) } } + +func TestQueueBufferCount(t *testing.T) { + tests := []struct { + rate int32 + want int32 + }{ + {rate: 32040, want: 13}, + {rate: 48000, want: 18}, + {rate: 96000, want: 36}, + } + + for _, tt := range tests { + if got := queueBufferCount(tt.rate); got != tt.want { + t.Errorf("queueBufferCount(%v) = %v, want %v", tt.rate, got, tt.want) + } + } +} + +func TestCurrentPlaybackRate(t *testing.T) { + rate = outputRate + inputRate = 32768 + videoSyncRate = 60.0 + srcRatioOrig = 0 + srcRatioCurr = 0 + updateInputRate() + queueBytes = 5 * bufSize + nominalStep := 1.0 / srcRatioOrig + + t.Run("Low write availability speeds playback up", func(t *testing.T) { + got := currentResampleStep(0) + if got <= nominalStep { + t.Errorf("currentResampleStep() = %v, want > %v", got, nominalStep) + } + }) + + t.Run("High write availability slows playback down", func(t *testing.T) { + got := currentResampleStep(queueBytes) + if got >= nominalStep { + t.Errorf("currentResampleStep() = %v, want < %v", got, nominalStep) + } + }) + + t.Run("Half-full queue stays at nominal rate", func(t *testing.T) { + got := currentResampleStep(queueBytes / 2) + if math.Abs(got-nominalStep) > 1e-9 { + t.Errorf("currentResampleStep() = %v, want %v", got, nominalStep) + } + }) +} + +func TestAdjustedInputRate(t *testing.T) { + got := adjustedInputRate(32768, 59.7275, 60.0) + want := 32768 * 60.0 / 59.7275 + if math.Abs(got-want) > 1e-6 { + t.Fatalf("adjustedInputRate() = %v, want %v", got, want) + } + + got = adjustedInputRate(32768, 50.0, 60.0) + if got != 32768 { + t.Fatalf("adjustedInputRate() = %v, want %v outside timing skew window", got, 32768) + } +} + +func TestRequiredInputFrames(t *testing.T) { + readPhase = 0.25 + got := requiredInputFrames(1.0) + if got != framesPerBuffer+2 { + t.Errorf("requiredInputFrames() = %v, want %v", got, framesPerBuffer+2) + } +} diff --git a/core/core.go b/core/core.go index f4140f26..b657bbbf 100644 --- a/core/core.go +++ b/core/core.go @@ -6,6 +6,7 @@ package core import ( "errors" "log" + "math" "os" "path/filepath" "strings" @@ -33,6 +34,21 @@ func Init(v *video.Video) { vid = v } +func applySystemAVInfo(avi libretro.SystemAVInfo) { + vid.Geom = avi.Geometry + + if avi.Timing.FPS > 0 { + state.CoreFPS = avi.Timing.FPS + } + + if avi.Timing.SampleRate > 0 { + audio.Reconfigure(int32(math.Round(avi.Timing.SampleRate))) + if state.Core != nil && state.Core.AudioCallback != nil { + state.Core.AudioCallback.SetState(true) + } + } +} + // Load loads a libretro core func Load(sofile string) error { // In case a core is already loaded, we need to close it properly @@ -178,8 +194,7 @@ func LoadGame(gamePath string) error { } avi := state.Core.GetSystemAVInfo() - - vid.Geom = avi.Geometry + applySystemAVInfo(avi) // Append the library name to the window title. if len(si.LibraryName) > 0 { @@ -187,10 +202,6 @@ func LoadGame(gamePath string) error { } input.Init(vid) - audio.Reconfigure(int32(avi.Timing.SampleRate)) - if state.Core.AudioCallback != nil { - state.Core.AudioCallback.SetState(true) - } state.CoreRunning = true state.FastForward = false @@ -216,6 +227,7 @@ func Unload() { state.CorePath = "" state.Core = nil Options = nil + state.CoreFPS = 0 } } @@ -223,9 +235,11 @@ func Unload() { func UnloadGame() { if state.CoreRunning { savefiles.SaveSRAM() + audio.Stop() state.Core.UnloadGame() state.GamePath = "" state.CoreRunning = false + state.CoreFPS = 0 vid.ResetPitch() vid.ResetRot() } diff --git a/core/environment.go b/core/environment.go index 7c7a40dc..e265e3d3 100644 --- a/core/environment.go +++ b/core/environment.go @@ -171,8 +171,7 @@ func environment(cmd uint32, data unsafe.Pointer) bool { case libretro.EnvironmentSetGeometry: vid.Geom = libretro.GetGeometry(data) case libretro.EnvironmentSetSystemAVInfo: - avi := libretro.GetSystemAVInfo(data) - vid.Geom = avi.Geometry + applySystemAVInfo(libretro.GetSystemAVInfo(data)) case libretro.EnvironmentGetFastforwarding: libretro.SetBool(data, state.FastForward) case libretro.EnvironmentGetLanguage: diff --git a/main.go b/main.go index 450fa825..f6a8b0f2 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "log" + "math" "os" "runtime" "time" @@ -30,12 +31,148 @@ func init() { var frame = 0 +const ( + defaultCoreFPS = 60.0 + maxLoopDelta = 250 * time.Millisecond + maxPacingLagFrames = 2 + maxAutoSwapInt = 4 + pacingMatchSkew = 0.02 +) + +func coreFrameDuration() time.Duration { + fps := state.CoreFPS + if fps <= 0 { + fps = defaultCoreFPS + } + + return time.Duration(float64(time.Second) / fps) +} + +func frameTimeUsec(frameDuration time.Duration) int64 { + if state.Core != nil && state.Core.FrameTimeCallback != nil { + if state.FastForward && state.Core.FrameTimeCallback.Reference > 0 { + return state.Core.FrameTimeCallback.Reference + } + + usec := frameDuration / time.Microsecond + if usec > 0 { + return int64(usec) + } + + if state.Core.FrameTimeCallback.Reference > 0 { + return state.Core.FrameTimeCallback.Reference + } + } + + return int64(coreFrameDuration() / time.Microsecond) +} + +func runCoreFrame(frameDuration time.Duration) { + if state.Core == nil { + return + } + + if state.Core.FrameTimeCallback != nil { + state.Core.FrameTimeCallback.Callback(frameTimeUsec(frameDuration)) + } + + state.Core.Run() +} + +func updateSwapInterval(current *int, target int) { + if *current == target { + return + } + + glfw.SwapInterval(target) + *current = target +} + +func relativeSkew(a, b float64) float64 { + if a <= 0 || b <= 0 { + return math.Inf(1) + } + return math.Abs(a-b) / b +} + +func displayRefreshHz(vid *video.Video) float64 { + if vid == nil || vid.Window == nil { + return 0 + } + + monitor := vid.Window.GetMonitor() + if monitor == nil { + monitors := glfw.GetMonitors() + if len(monitors) > 0 { + index := settings.Current.VideoMonitorIndex + if index < 0 || index >= len(monitors) { + index = 0 + } + monitor = monitors[index] + } + } + + if monitor == nil { + return 0 + } + + mode := monitor.GetVideoMode() + if mode == nil || mode.RefreshRate <= 0 { + return 0 + } + + return float64(mode.RefreshRate) +} + +func autoSwapInterval(refreshHz, coreFPS float64) int { + if refreshHz <= 0 || coreFPS <= 0 { + return 1 + } + + bestInterval := 1 + bestSkew := relativeSkew(refreshHz, coreFPS) + + for interval := 2; interval <= maxAutoSwapInt; interval++ { + effectiveRefresh := refreshHz / float64(interval) + skew := relativeSkew(effectiveRefresh, coreFPS) + if skew <= pacingMatchSkew && skew < bestSkew { + bestInterval = interval + bestSkew = skew + } + } + + return bestInterval +} + +func effectiveRefreshHz(refreshHz float64, swapInterval int) float64 { + if refreshHz <= 0 { + return 0 + } + if swapInterval <= 0 { + return refreshHz + } + return refreshHz / float64(swapInterval) +} + +func blocksOnSwap(refreshHz, coreFPS float64) bool { + return relativeSkew(refreshHz, coreFPS) <= pacingMatchSkew +} + func runLoop(vid *video.Video, m *menu.Menu) { var currTime time.Time prevTime := time.Now() + accumulator := time.Duration(0) + swapInterval := -1 + pacingPrimed := false for !vid.Window.ShouldClose() { currTime = time.Now() - dt := float32(currTime.Sub(prevTime)) / 1000000000 + loopDelta := currTime.Sub(prevTime) + prevTime = currTime + if loopDelta > maxLoopDelta { + loopDelta = maxLoopDelta + } + + dt := float32(loopDelta) / 1000000000 glfw.PollEvents() m.ProcessHotkeys() ntf.Process(dt) @@ -43,33 +180,79 @@ func runLoop(vid *video.Video, m *menu.Menu) { m.UpdatePalette() input.Poll() if !state.MenuActive { + ranFrames := 0 if state.CoreRunning { - state.Core.Run() - if state.Core.FrameTimeCallback != nil { - state.Core.FrameTimeCallback.Callback(state.Core.FrameTimeCallback.Reference) + if state.FastForward { + updateSwapInterval(&swapInterval, 0) + audio.SetVideoTiming(0, 0) + runCoreFrame(coreFrameDuration()) + ranFrames = 1 + accumulator = 0 + pacingPrimed = false + } else { + frameDuration := coreFrameDuration() + refreshHz := displayRefreshHz(vid) + targetSwap := autoSwapInterval(refreshHz, state.CoreFPS) + updateSwapInterval(&swapInterval, targetSwap) + effectiveRefresh := effectiveRefreshHz(refreshHz, targetSwap) + audio.SetVideoTiming(refreshHz, targetSwap) + + if blocksOnSwap(effectiveRefresh, state.CoreFPS) { + runCoreFrame(frameDuration) + ranFrames = 1 + accumulator = 0 + pacingPrimed = false + } else { + if !pacingPrimed { + accumulator = frameDuration + pacingPrimed = true + } + + accumulator += loopDelta + maxAccumulator := frameDuration * maxPacingLagFrames + if accumulator > maxAccumulator { + accumulator = maxAccumulator + } + + if accumulator >= frameDuration { + runCoreFrame(frameDuration) + accumulator -= frameDuration + if accumulator > frameDuration { + accumulator = frameDuration + } + ranFrames = 1 + } + } } - if state.Core.AudioCallback != nil { + + if state.Core.AudioCallback != nil && ranFrames > 0 { state.Core.AudioCallback.Callback() } + } else { + updateSwapInterval(&swapInterval, 1) + audio.SetVideoTiming(0, 0) + accumulator = 0 + pacingPrimed = false } + vid.Render() - frame++ - if frame%600 == 0 { // save sram about every 10 sec - savefiles.SaveSRAM() + for i := 0; i < ranFrames; i++ { + frame++ + if frame%600 == 0 { // save sram about every 10 sec + savefiles.SaveSRAM() + } } } else { + updateSwapInterval(&swapInterval, 1) + audio.SetVideoTiming(0, 0) + accumulator = 0 + pacingPrimed = false m.Update(dt) vid.Render() m.Render(dt) } m.RenderNotifications() - if state.FastForward { - glfw.SwapInterval(0) - } else { - glfw.SwapInterval(1) - } vid.Window.SwapBuffers() - prevTime = currTime } } diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..03468e8c --- /dev/null +++ b/main_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +func TestAutoSwapInterval(t *testing.T) { + tests := []struct { + name string + refreshHz float64 + coreFPS float64 + want int + }{ + {name: "60Hz stays at swap 1", refreshHz: 60, coreFPS: 59.73, want: 1}, + {name: "120Hz prefers swap 2 for 60fps", refreshHz: 120, coreFPS: 60, want: 2}, + {name: "144Hz prefers swap 3 for 48fps", refreshHz: 144, coreFPS: 48, want: 3}, + {name: "144Hz keeps swap 1 for 60fps", refreshHz: 144, coreFPS: 60, want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := autoSwapInterval(tt.refreshHz, tt.coreFPS); got != tt.want { + t.Fatalf("autoSwapInterval(%v, %v) = %v, want %v", tt.refreshHz, tt.coreFPS, got, tt.want) + } + }) + } +} + +func TestBlocksOnSwap(t *testing.T) { + if got := blocksOnSwap(60.0, 59.73); !got { + t.Fatalf("blocksOnSwap() = %v, want %v", got, true) + } + + if got := blocksOnSwap(144.0, 60.0); got { + t.Fatalf("blocksOnSwap() = %v, want %v", got, false) + } +} diff --git a/state/state.go b/state/state.go index 8b0e6d62..668bfe3d 100644 --- a/state/state.go +++ b/state/state.go @@ -33,3 +33,6 @@ var LudOS bool // FastForward will run the core as fast as possible var FastForward bool + +// CoreFPS is the current emulated video rate declared by the core. +var CoreFPS float64