Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/frame-pacing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# P1: VSync and the software deadline

The run loop records whether it actually presented a frame. After presentation,
`FrameCap.pace` skips the software wait when VSync is active and the FPS cap is
at least the reported display refresh rate. Without presentation, with VSync
off, with an unknown rate, or with a lower FPS cap, the existing carried
deadline, one-millisecond sleep chunks and stall re-anchor remain active.

The default-on rollback switch is independent of all other performance work:

```lua
require("src.core.FrameCap").vsyncPacing = false
```

Display flags are sampled at most once per second to avoid allocating their
table each frame. Provider/VSync changes invalidate that sample; other display
rate changes are observed on the next periodic sample. Existing option values,
simulation timing, mod interfaces and callback call sites are unchanged.

`frame pacing: sleep` in the existing F11 FrameProfile measures the helper,
including its software wait. `present (GPU wait / vsync)` retains its original
scope. Removing a redundant software wait can move wall time into present
without changing FPS.

The owned-window probe runs the real helper and FrameProfile with the switch
off/on, each after 120 warm-up frames and over 120 measured frames. Run through
the guarded launcher:

```sh
python3 tools/check_frame_pacing.py --love /path/to/love --report /path/to/owned/qa/report.json
```

An older public checkout can use `--qa-tools /path/to/fork/tools`. The launcher
pins inputs, uses a separate QA identity, retains raw profile logs and records
SHA-256 hashes of the actual measured source files alongside the Git parent.

Observed macOS/arm64, 60-Hz display, VSync 1, cap 60:

| Backend | Software wait off/on, ms/frame | Present off/on, ms/frame | CPU off/on, ms/frame |
| --- | ---: | ---: | ---: |
| LÖVE 11.5 OpenGL | 0.00 / 0.01 | 16.59 / 16.54 | 0.672 / 0.790 |
| LÖVE 12 Metal | 16.43 / 0.01 | 0.13 / 16.56 | 0.769 / 0.629 |

Both enabled runs made zero extra sleep calls and stayed near 60 FPS, with no
measured interval exceeding 1.5 display periods. These short blank-window
checks establish the pacing decision and its profiler scope. They do not
establish a universal CPU/FPS gain, loaded-world behavior, or handheld results.
23 changes: 5 additions & 18 deletions main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1261,12 +1261,14 @@ function love.run()
closeU()
end

local presented = false
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
local closeP = FrameProfile.section("present (GPU wait / vsync)")
love.graphics.present()
presented = true
closeP()
end
-- Counted after present, so a frame in the trace means a frame that was
Expand Down Expand Up @@ -1299,24 +1301,9 @@ function love.run()

if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
local closeSleep = FrameProfile.section("frame pacing: sleep")
nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame)
closeSleep()
else
love.timer.sleep(0.001)
end
Expand Down
44 changes: 44 additions & 0 deletions src/core/FrameCap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,50 @@ FrameCap.DEFAULT = 60
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT

-- P1 rollback switch. Each frame still falls back to software pacing when
-- there was no present, VSync is off, or the display rate is unavailable.
FrameCap.vsyncPacing = true
local _refreshWindow, _refreshVsync, _refreshAt, _refreshRate
local REFRESH_POLL_SECONDS = 1

local function presentationPaces(window, now)
if not (window and window.getVSync and window.getMode) then return false end
local vsync = window.getVSync()
if type(vsync) ~= "number" or vsync == 0 then
_refreshVsync = vsync
return false
end
-- getMode returns a new flags table: sample it once a second rather than
-- allocate one each frame. Provider/VSync changes invalidate the sample;
-- display-rate changes are observed on the next periodic sample.
if window ~= _refreshWindow or vsync ~= _refreshVsync or not _refreshAt
or now < _refreshAt or now - _refreshAt >= REFRESH_POLL_SECONDS then
local _, _, flags = window.getMode()
local rate = type(flags) == "table" and tonumber(flags.refreshrate)
_refreshWindow, _refreshVsync, _refreshAt = window, vsync, now
_refreshRate = rate and rate > 0 and rate < math.huge and rate or nil
end
return _refreshRate ~= nil and FrameCap.current >= _refreshRate
end

-- The run loop owns the deadline and passes back the returned value. Keeping
-- the original fallback here lets tests exercise the real pacing operation.
function FrameCap.pace(timer, window, presented, nextFrame)
local now = timer.getTime()
if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then
return now
end
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
if now - nextFrame > budget then nextFrame = now end
while true do
local remaining = nextFrame - timer.getTime()
if remaining <= 0 then break end
timer.sleep(remaining < 0.001 and remaining or 0.001)
end
return nextFrame
end

-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
Expand Down
153 changes: 153 additions & 0 deletions tests/engine/frame_pacing.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
local T = require("tests.harness")
local Cap = require("src.core.FrameCap")
local function clock(start)
local now, sleeps, total = start or 0, 0, 0
return {
getTime = function()
return now
end,
sleep = function(dt)
now = now + dt
sleeps = sleeps + 1
total = total + dt
end,
advance = function(dt)
now = now + dt
end,
sleeps = function()
return sleeps, total
end,
}
end
local function window(sync, rate)
local calls = 0
return {
getVSync = function()
return sync
end,
getMode = function()
calls = calls + 1
return 320, 240, { refreshrate = rate }
end,
modeCalls = function()
return calls
end,
set = function(s, r)
sync, rate = s, r
end,
}
end
Cap.current = 60
T.eq(Cap.vsyncPacing, true, "P1 switch defaults on")
for _, sync in ipairs({ 1, -1, 2 }) do
local timer, w = clock(), window(sync, 60)
local nextFrame = 0
for _ = 1, 60 do
timer.advance(1 / 60)
nextFrame = Cap.pace(timer, w, true, nextFrame)
end
T.eq(timer.sleeps(), 0, "active VSync at the cap receives no additional sleep")
T.eq(w.modeCalls(), 1, "display flags are not allocated each frame")
end
for _, case in ipairs({
{ sync = 0, rate = 60, presented = true },
{ sync = 1, rate = 144, presented = true },
{ sync = 1, rate = 0, presented = true },
{ sync = 1, rate = 60, presented = false },
}) do
local timer, w = clock(), window(case.sync, case.rate)
local deadline = Cap.pace(timer, w, case.presented, 0)
T.check(timer.sleeps() > 0, "software cap remains for disabled/unknown/faster/unpresented cases")
T.check(math.abs(deadline - 1 / 60) < 1e-9, "software deadline remains one frame ahead")
end
local timer, w = clock(), window(1, 60)
Cap.vsyncPacing = false
Cap.pace(timer, w, true, 0)
T.check(timer.sleeps() > 0, "P1 switch restores software pacing")
T.eq(w.modeCalls(), 0, "disabled P1 does not query display flags")
Cap.vsyncPacing = true
timer, w = clock(), window(1, 60)
local deadline = Cap.pace(timer, w, true, 0)
w.set(0, 60)
deadline = Cap.pace(timer, w, true, deadline)
T.check(math.abs(deadline - 1 / 60) < 1e-9, "turning VSync off resumes the carried software deadline")
timer.advance(1)
deadline = Cap.pace(timer, w, true, deadline)
T.eq(deadline, timer.getTime(), "stalls re-anchor instead of accumulating catch-up frames")
deadline = Cap.pace(timer, w, true, deadline)
T.eq(deadline, timer.getTime(), "software pacing resumes after a stall")
timer, w = clock(), window(1, 60)
Cap.pace(timer, w, true, 0)
w.set(1, 144)
timer.advance(1.01)
deadline = Cap.pace(timer, w, true, timer.getTime())
T.check(timer.sleeps() > 0, "display rate changes refresh the software-cap decision")
T.eq(w.modeCalls(), 2, "display sample refresh is bounded")
timer = clock()
Cap.pace(timer, nil, true, 0)
T.check(timer.sleeps() > 0, "headless provider absence retains the software cap")

-- Execute the actual run-loop body with native present/timer boundaries
-- substituted, so a correct helper wired to the wrong loop still fails.
local file = assert(io.open("main.lua", "r"))
local source = file:read("*a")
file:close()
local start = assert(source:find("function love.run()", 1, true))
for _, active in ipairs({ true, false }) do
local fake = clock()
fake.step = function()
return 1 / 60
end
local presents = 0
local noop = function() end
local env = {
love = {
timer = fake,
window = window(1, 60),
graphics = {
isActive = function()
return active
end,
origin = noop,
clear = noop,
getBackgroundColor = function()
return 0, 0, 0, 1
end,
present = function()
presents = presents + 1
fake.advance(1 / 60 - 0.001)
end,
},
},
BootTrace = { mark = noop, frame = noop },
Frames = { tick = noop },
FrameProfile = {
section = function()
return noop
end,
},
WindowFrame = {
draw = function(fn)
fn()
end,
},
GraphicsStack = { drain = noop },
pacingEnabled = function()
return true
end,
lastLogFlush = 0,
}
setmetatable(env, { __index = _G })
local chunk
if setfenv then
chunk = assert(loadstring(source:sub(start), "@main.lua:love.run"))
setfenv(chunk, env)
else
chunk = assert(load(source:sub(start), "@main.lua:love.run", "t", env))
end
chunk()
env.love.run()()
T.eq(presents, active and 1 or 0, "actual main loop presents only active graphics")
T.eq(fake.sleeps() > 0, not active, "actual main loop bypasses sleep only after native presentation")
end
T.finish()
50 changes: 50 additions & 0 deletions tools/check_frame_pacing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Run owned-window VSync pacing measurements through the local QA guard."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import time


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--love', type=Path, required=True)
parser.add_argument('--report', type=Path, required=True)
parser.add_argument('--qa-tools', type=Path)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(args.qa_tools or root / 'tools'))
try:
from qa_storage import qa_source, qa_cache, qa_profile, qa_run
except ImportError:
parser.error('QA storage guard is required; provide --qa-tools')
qa_source(root)
qa_source(args.love.resolve())
qa_cache(args.report.parent)
args.report.parent.mkdir(parents=True, exist_ok=True)
identity = 'oasc-gen2recomp-qa-frame-pacing-' + str(time.time_ns())
qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity)
result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')],
cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity,
FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1'),
capture_output=True, text=True, timeout=90)
args.report.with_suffix('.log').write_text(result.stdout + result.stderr)
records = [line.removeprefix('P1_NATIVE_RESULT ') for line in result.stdout.splitlines()
if line.startswith('P1_NATIVE_RESULT ')]
if result.returncode or len(records) != 1:
raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log')))
report = json.loads(records[0])
report['source'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip()
files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua',
'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua')
report['sha256'] = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files}
args.report.write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))


if __name__ == '__main__':
main()
7 changes: 7 additions & 0 deletions tools/frame-pacing-check/conf.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
function love.conf(t)
assert(os.getenv("FRAME_PACING_QA_GUARDED") == "1", "use tools/check_frame_pacing.py")
t.identity = assert(os.getenv("POKEPORT_IDENTITY"))
t.window.title = "Frame pacing QA"
t.window.width, t.window.height, t.window.vsync = 320, 180, 1
t.modules.audio = false
end
Loading