fixed drift in timer

This commit is contained in:
2026-05-12 21:10:14 -07:00
parent c3496cfdbe
commit 2d74ca0745
21 changed files with 3718 additions and 3773 deletions
+9 -9
View File
@@ -1,22 +1,22 @@
local color={}
local mt = {
__add = function (c1,c2)
return color.new(c1[1]+c2[1],c1[2]+c2[2],c1[2]+c2[2])
return color.new(c1[1]+c2[1],c1[2]+c2[2],c1[3]+c2[3])
end,
__sub = function (c1,c2)
return color.new(c1[1]-c2[1],c1[2]-c2[2],c1[2]-c2[2])
return color.new(c1[1]-c2[1],c1[2]-c2[2],c1[3]-c2[3])
end,
__mul = function (c1,c2)
return color.new(c1[1]*c2[1],c1[2]*c2[2],c1[2]*c2[2])
return color.new(c1[1]*c2[1],c1[2]*c2[2],c1[3]*c2[3])
end,
__div = function (c1,c2)
return color.new(c1[1]/c2[1],c1[2]/c2[2],c1[2]/c2[2])
return color.new(c1[1]/c2[1],c1[2]/c2[2],c1[3]/c2[3])
end,
__mod = function (c1,c2)
return color.new(c1[1]%c2[1],c1[2]%c2[2],c1[2]%c2[2])
return color.new(c1[1]%c2[1],c1[2]%c2[2],c1[3]%c2[3])
end,
__pow = function (c1,c2)
return color.new(c1[1]^c2[1],c1[2]^c2[2],c1[2]^c2[2])
return color.new(c1[1]^c2[1],c1[2]^c2[2],c1[3]^c2[3])
end,
__unm = function (c1)
return color.new(-c1[1],-c1[2],-c1[2])
@@ -25,13 +25,13 @@ local mt = {
return "("..c[1]..","..c[2]..","..c[3]..",".. (c[4] or "1") ..")"
end,
__eq = function (c1,c2)
return (c1[1]==c2[1] and c1[2]==c2[2] and c1[2]==c2[2])
return (c1[1]==c2[1] and c1[2]==c2[2] and c1[3]==c2[3])
end,
__lt = function (c1,c2)
return (c1[1]<c2[1] and c1[2]<c2[2] and c1[2]<c2[2])
return (c1[1]<c2[1] and c1[2]<c2[2] and c1[3]<c2[3])
end,
__le = function (c1,c2)
return (c1[1]<=c2[1] and c1[2]<=c2[2] and c1[2]<=c2[2])
return (c1[1]<=c2[1] and c1[2]<=c2[2] and c1[3]<=c2[3])
end
}
+437
View File
@@ -0,0 +1,437 @@
-- GIF Loader for Love2D with LZW Decompression
-- Note: love.data.compress/decompress don't support LZW, so we implement it
local GifLoader = {}
-- Pure Lua LZW decompression for GIF
local function decompressLZW(data, minCodeSize)
local clearCode = 2 ^ minCodeSize
local endCode = clearCode + 1
local nextCode = endCode + 1
local codeSize = minCodeSize + 1
local dict = {}
for i = 0, clearCode - 1 do
dict[i] = {string.byte(string.char(i))}
end
local output = {}
local bits = 0
local bitBuffer = 0
local pos = 1
local prevCode = nil
local function readCode()
while bits < codeSize do
if pos > #data then return nil end
bitBuffer = bitBuffer + bit.lshift(string.byte(data, pos), bits)
bits = bits + 8
pos = pos + 1
end
local code = bit.band(bitBuffer, bit.lshift(1, codeSize) - 1)
bitBuffer = bit.rshift(bitBuffer, codeSize)
bits = bits - codeSize
return code
end
local first = true
while true do
local code = readCode()
if not code or code == endCode then break end
if code == clearCode then
dict = {}
for i = 0, clearCode - 1 do
dict[i] = {string.byte(string.char(i))}
end
nextCode = endCode + 1
codeSize = minCodeSize + 1
prevCode = nil
first = true
else
local entry
if dict[code] then
entry = dict[code]
elseif code == nextCode and prevCode then
-- Special case: code not in dict yet
entry = {}
for i = 1, #dict[prevCode] do
entry[i] = dict[prevCode][i]
end
entry[#entry + 1] = dict[prevCode][1]
else
-- Invalid code, stop
break
end
-- Output the entry
for i = 1, #entry do
table.insert(output, entry[i])
end
-- Add new entry to dictionary
if not first and prevCode and nextCode < 4096 then
local newEntry = {}
for i = 1, #dict[prevCode] do
newEntry[i] = dict[prevCode][i]
end
newEntry[#newEntry + 1] = entry[1]
dict[nextCode] = newEntry
nextCode = nextCode + 1
-- Increase code size when needed
if nextCode >= bit.lshift(1, codeSize) and codeSize < 12 then
codeSize = codeSize + 1
end
end
prevCode = code
first = false
end
end
-- Convert output bytes to string
local result = {}
for i = 1, #output do
result[i] = string.char(output[i])
end
return table.concat(result)
end
function GifLoader.load(filepath)
local fileData = love.filesystem.read(filepath)
if not fileData then
error("Could not read GIF file: " .. filepath)
end
local gif = {
frames = {},
frameData = {}, -- Store ImageData for frame composition
delays = {},
currentFrame = 1,
timer = 0,
width = 0,
height = 0,
playing = true,
loop = true,
getWidth = function(self)
return self.width
end,
getHeight = function(self)
return self.height
end,
}
-- Parse GIF header
local header = fileData:sub(1, 6)
if header ~= "GIF87a" and header ~= "GIF89a" then
error("Not a valid GIF file")
end
-- Read logical screen descriptor
local pos = 7
gif.width = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256
gif.height = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256
local packed = string.byte(fileData, pos + 4)
local hasGlobalColorTable = bit.band(packed, 0x80) ~= 0
local backgroundColorIndex = string.byte(fileData, pos + 5)
pos = pos + 7
-- Read global color table
local globalColorTable = {}
if hasGlobalColorTable then
local size = 2 ^ (bit.band(packed, 0x07) + 1)
for i = 1, size do
local r = string.byte(fileData, pos) / 255
local g = string.byte(fileData, pos + 1) / 255
local b = string.byte(fileData, pos + 2) / 255
table.insert(globalColorTable, {r, g, b, 1})
pos = pos + 3
end
end
-- Parse blocks
local delay = 0.1
local transparentIndex = nil
local disposalMethod = 0
local delayForNextFrame = 0.1 -- Track delay for next frame
while pos <= #fileData do
local separator = string.byte(fileData, pos)
if separator == 0x21 then -- Extension
local label = string.byte(fileData, pos + 1)
pos = pos + 2
if label == 0xF9 then -- Graphic Control Extension
local blockSize = string.byte(fileData, pos)
pos = pos + 1
local flags = string.byte(fileData, pos)
disposalMethod = bit.rshift(bit.band(flags, 0x1C), 2)
local hasTransparency = bit.band(flags, 0x01) ~= 0
local delayTime = string.byte(fileData, pos + 1) + string.byte(fileData, pos + 2) * 256
-- GIF delay is in hundredths of a second, convert to seconds
-- Many GIFs use 0 or very small delays, set a minimum
if delayTime == 0 then
delayForNextFrame = 0.1 -- Default 100ms
elseif delayTime <= 2 then
delayForNextFrame = 0.02 -- Minimum 20ms for very fast animations
else
delayForNextFrame = delayTime / 100
end
if hasTransparency then
transparentIndex = string.byte(fileData, pos + 3)
else
transparentIndex = nil
end
pos = pos + blockSize + 1
else
-- Skip other extensions
repeat
local blockSize = string.byte(fileData, pos)
pos = pos + 1
if blockSize > 0 then
pos = pos + blockSize
end
until blockSize == 0
end
elseif separator == 0x2C then -- Image Descriptor
pos = pos + 1
local left = string.byte(fileData, pos) + string.byte(fileData, pos + 1) * 256
local top = string.byte(fileData, pos + 2) + string.byte(fileData, pos + 3) * 256
local width = string.byte(fileData, pos + 4) + string.byte(fileData, pos + 5) * 256
local height = string.byte(fileData, pos + 6) + string.byte(fileData, pos + 7) * 256
local imgPacked = string.byte(fileData, pos + 8)
local hasLocalColorTable = bit.band(imgPacked, 0x80) ~= 0
local interlaced = bit.band(imgPacked, 0x40) ~= 0
pos = pos + 9
local colorTable = globalColorTable
if hasLocalColorTable then
local size = 2 ^ (bit.band(imgPacked, 0x07) + 1)
colorTable = {}
for i = 1, size do
local r = string.byte(fileData, pos) / 255
local g = string.byte(fileData, pos + 1) / 255
local b = string.byte(fileData, pos + 2) / 255
table.insert(colorTable, {r, g, b, 1})
pos = pos + 3
end
end
-- Read LZW minimum code size
local minCodeSize = string.byte(fileData, pos)
pos = pos + 1
-- Read compressed image data blocks
local compressedData = {}
while true do
local blockSize = string.byte(fileData, pos)
pos = pos + 1
if blockSize == 0 then break end
table.insert(compressedData, fileData:sub(pos, pos + blockSize - 1))
pos = pos + blockSize
end
-- Decompress image data
local indexStream = decompressLZW(table.concat(compressedData), minCodeSize)
-- Create image data
local imageData = love.image.newImageData(gif.width, gif.height)
-- Fill with background if first frame
if #gif.frames == 0 and backgroundColorIndex and globalColorTable[backgroundColorIndex + 1] then
local bg = globalColorTable[backgroundColorIndex + 1]
for y = 0, gif.height - 1 do
for x = 0, gif.width - 1 do
imageData:setPixel(x, y, bg[1], bg[2], bg[3], bg[4])
end
end
elseif #gif.frames > 0 then
-- Copy previous frame if needed
local prevData = gif.frameData[#gif.frameData]
imageData:paste(prevData, 0, 0, 0, 0, gif.width, gif.height)
end
-- Draw current frame
if indexStream and #indexStream > 0 then
local idx = 1
-- Use mapPixel for faster pixel operations
local function setPixels(x, y, r, g, b, a)
if x >= left and x < left + width and y >= top and y < top + height then
local pixelIdx = (y - top) * width + (x - left) + 1
if pixelIdx <= #indexStream then
local colorIndex = string.byte(indexStream, pixelIdx)
-- Skip transparent pixels
if transparentIndex == nil or colorIndex ~= transparentIndex then
if colorTable[colorIndex + 1] then
local color = colorTable[colorIndex + 1]
return color[1], color[2], color[3], color[4]
end
end
end
end
return r, g, b, a
end
-- Only update the region where the frame is located
for y = top, top + height - 1 do
for x = left, left + width - 1 do
local pixelIdx = (y - top) * width + (x - left) + 1
if pixelIdx <= #indexStream then
local colorIndex = string.byte(indexStream, pixelIdx)
-- Skip transparent pixels
if transparentIndex == nil or colorIndex ~= transparentIndex then
if colorTable[colorIndex + 1] then
local color = colorTable[colorIndex + 1]
imageData:setPixel(x, y, color[1], color[2], color[3], color[4])
end
end
end
end
end
end
table.insert(gif.frameData, imageData)
table.insert(gif.frames, love.graphics.newImage(imageData))
table.insert(gif.delays, delayForNextFrame)
-- Reset delay for next frame
delayForNextFrame = 0.1
elseif separator == 0x3B then -- Trailer
break
else
pos = pos + 1
end
end
if #gif.frames == 0 then
error("No frames found in GIF")
end
-- Ensure all frames have valid delays
for i = 1, #gif.delays do
if gif.delays[i] <= 0 or gif.delays[i] ~= gif.delays[i] then -- check for 0 or NaN
gif.delays[i] = 0.1
end
end
return gif
end
function GifLoader.Updater(gif, proc)
local wait = function()
return gif.playing or gif.kill
end
proc:newThread("Gif Handler",function()
while true do
-- Only run if not paused
if gif.kill then -- When we want to clean up
thread.kill()
end
thread.hold(wait)
thread.sleep(gif.delays[gif.currentFrame] * 4)
gif.currentFrame = gif.currentFrame + 1
if gif.currentFrame > #gif.frames then
if gif.loop then
gif.currentFrame = 1
else
gif.currentFrame = #gif.frames
gif.playing = false
gif.timer = 0
end
end
end
end)
end
function GifLoader.update(gif, dt)
if not gif.playing or #gif.frames <= 1 then return end
gif.timer = gif.timer + dt
-- Simple, accurate frame advancement
if gif.timer >= gif.delays[gif.currentFrame] then
-- Subtract the current frame's delay
gif.timer = gif.timer - gif.delays[gif.currentFrame]
-- Move to next frame
gif.currentFrame = gif.currentFrame + 1
if gif.currentFrame > #gif.frames then
if gif.loop then
gif.currentFrame = 1
else
gif.currentFrame = #gif.frames
gif.playing = false
gif.timer = 0
end
end
-- If we've accumulated too much time (lag spike), cap it
if gif.timer > 0.5 then
gif.timer = 0
end
end
end
function GifLoader.draw(gif, x, y, r, sx, sy, ox, oy)
if gif.frames[gif.currentFrame] then
love.graphics.draw(gif.frames[gif.currentFrame], x, y, r or 0, sx or 1, sy or 1, ox or 0, oy or 0)
end
end
function GifLoader.play(gif)
gif.playing = true
end
function GifLoader.pause(gif)
gif.playing = false
end
function GifLoader.reset(gif)
gif.currentFrame = 1
gif.timer = 0
end
function GifLoader.setFixedFramerate(gif, fps)
local delay = 1 / fps
for i = 1, #gif.delays do
gif.delays[i] = delay
end
end
function GifLoader.getInfo(gif)
return {
width = gif.width,
height = gif.height,
frameCount = #gif.frames,
delays = gif.delays, -- Show actual delays for debugging
totalDuration = (function()
local total = 0
for i = 1, #gif.delays do
total = total + gif.delays[i]
end
return total
end)()
}
end
return GifLoader
+115
View File
@@ -0,0 +1,115 @@
--[[
scheduler_probe.lua
-------------------
A drop-in replacement for multi:getLoad() based on scheduler tick-slip
rather than step-count benchmarking.
THEORY
------
Schedule a repeating timer at a fixed interval T. Each time it fires,
measure how much later than T it actually arrived. On an idle scheduler
the slip is near zero. Under load the main loop is busy with other tasks
between iterations, so ticks are delayed.
We express load as:
lag = actual_interval - target_interval (seconds)
lag_ratio = lag / target_interval (0 = perfect, 1 = 1 full interval late)
load% = clamp(lag_ratio * 100, 0, 100)
To smooth out single-frame spikes we keep an exponential moving average
(EMA) of lag_ratio with a configurable smoothing factor.
WHY THIS IS BETTER THAN THE STEP-COUNT APPROACH
------------------------------------------------
- No calibration baseline that drifts with object count or warm-up
- No magic exponents or divisors
- Does not block or create temporary objects on each call
- Measures actual scheduler responsiveness, not raw throughput
- Works correctly regardless of which processor calls it
- A single lightweight TLoop is the only permanent overhead
USAGE
-----
local probe = require("scheduler_probe")
probe:install(multi) -- once, at startup
-- anywhere, non-blocking:
local load, lagMs = multi:getLoad()
-- load : integer 0-100
-- lagMs : smoothed lag in milliseconds (useful for display)
OPTIONAL PARAMETERS
-------------------
probe:install(multi, {
interval = 0.05, -- probe fires every N seconds (default 0.05 = 50ms)
alpha = 0.15, -- EMA smoothing factor 0-1 (default 0.15)
-- lower = smoother but slower to react
-- higher = more reactive but noisier
maxLag = 0.5, -- lag value (seconds) that maps to 100% load (default 0.5)
-- tune this to match your target frame budget
})
]]
local probe = {}
-- EMA state — written by the TLoop callback, read by getLoad()
-- Both are plain numbers so Lua's assignment is atomic within one thread.
local _emaRatio = 0 -- smoothed lag / maxLag, clamped 0-1
local _lagMs = 0 -- smoothed lag in milliseconds for display
local _installed = false
function probe:install(multi_obj, opts)
if _installed then return end
_installed = true
opts = opts or {}
local INTERVAL = opts.interval or 0.05 -- seconds between probes
local ALPHA = opts.alpha or 0.15 -- EMA weight for new sample
local MAX_LAG = opts.maxLag or 0.5 -- seconds of lag = 100% load
local clock = os.clock
-- Track when the tick *should* have fired so we can compute slip
-- relative to the scheduled time, not relative to the previous firing.
-- This avoids error accumulation over long runs.
local expectedTime = clock() + INTERVAL
local tloop = multi_obj:newTLoop(nil, INTERVAL)
tloop:setName("SchedulerProbe")
tloop:setPriority("core") -- run as early as possible each frame
tloop.OnLoop(function(self, life, dt)
local now = clock()
local lag = math.max(0, now - expectedTime) -- never negative
local ratio = math.min(lag / MAX_LAG, 1) -- clamp to [0,1]
-- Exponential moving average: new = alpha*sample + (1-alpha)*old
_emaRatio = ALPHA * ratio + (1 - ALPHA) * _emaRatio
_lagMs = ALPHA * lag*1000 + (1 - ALPHA) * _lagMs
-- Advance expected time by one interval from where it *should* have been,
-- not from now — prevents the probe from drifting under sustained load.
expectedTime = expectedTime + INTERVAL
-- If we fall more than one interval behind (e.g. after a long GC pause),
-- re-anchor so we don't fire in a catch-up burst.
if now > expectedTime + INTERVAL then
expectedTime = now + INTERVAL
end
end)
-- Replace multi:getLoad() with a non-blocking version that just reads the EMA
function multi_obj:getLoad()
local pct = math.ceil(_emaRatio * 100)
return pct, _lagMs
end
-- Also expose raw probe state for diagnostics
function multi_obj:getSchedulerLag()
return _lagMs, _emaRatio
end
return tloop
end
return probe
+1 -1
View File
@@ -1,6 +1,6 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local transition = require("gui.elements.transitions")
local transition = require("gui.core.transitions")
-- Triggers press then release
local function getPosition(obj, x, y)
+78
View File
@@ -0,0 +1,78 @@
local gui = require("gui")
local multi, thread = require("multi"):init()
local processor = gui:newProcessor("Transistion Processor")
local transition = {}
local width, height, flags = love.window.getMode()
local fps = 60
if flags.refreshrate > 0 then
fps = flags.refreshrate
end
transition.__index = transition
transition.__call = function(t, start, stop, time, ...)
local args = {...}
return function(st, sp, ti) -- allow these values to be overridden
if not (st or start) or not (sp or stop) then return multi.error("start and stop must be supplied") end
if start == stop then
local temp = {
OnStep = function() end,
OnStop = multi:newConnection()
}
proc:newTask(function()
temp.OnStop:Fire()
end)
return temp
end
local handle = t.func(t, start, stop, (ti or time) or 1, unpack(args))
return {
OnStep = handle.OnStatus,
OnStop = handle.OnReturn + handle.OnError,
Kill = t.Kill
}
end
end
function transition:newTransition(func)
local c = {}
setmetatable(c, self)
c.fps = fps
c.func = processor:newFunction(func)
c.OnStop = multi:newConnection()
c.kill = false
function c:SetFPS(fps)
self.fps = fps
end
function c:GetFPS(fps)
return self.fps
end
function c:Kill()
if c.running then
c.kill = true
end
end
return c
end
transition.glide = transition:newTransition(function(t, start, stop, time, ...)
local steps = t.fps*time
local piece = time/steps
local split = stop-start
t.running = true
for i = 0, steps do
if not(t.kill) then
thread.sleep(piece)
thread.pushStatus(start + i*(split/steps),piece*i)
end
end
t.running = false
t.kill = false
end)
return transition