From 53721406fdc4a5e31eadb145b3d313634b8f553c Mon Sep 17 00:00:00 2001 From: unreal79 Date: Sat, 25 Oct 2025 18:17:47 +0500 Subject: [PATCH] memory leak fix Also: - remove goto usage - many small fixes - add license (MIT) --- .gitignore | 2 + LICENSE | 21 +++++++ ReadMe.md | 6 ++ actor.lua | 55 +++++++++++++++-- animation.lua | 72 ++++++++++++++-------- init.lua | 162 ++++++++++++++++++++++++++++++++------------------ 6 files changed, 229 insertions(+), 89 deletions(-) create mode 100644 .gitignore create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8491fc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.git/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8e20d63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 unreal79 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ReadMe.md b/ReadMe.md index bb4cd32..80d2c8a 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,3 +1,9 @@ + +This repo hosts `animX` version with memory leak patch. + +Proof-of-concept demonstrating that `animX` (a LÖVE animation library) leaks memory is there: https://github.com/unreal79/animX-memory-leak + + # animX *Animation in Love2d has never been so easy!!* Now I hate to look like a soap salesman... but animX has [all the features](#features-of-animx) you'd expect from an animation library plus some extra features of its own! I suggest you head over to [A quick walkthrough](#a-quick-walkthrough) if you are short on time! diff --git a/actor.lua b/actor.lua index b3a52e6..1c97edd 100644 --- a/actor.lua +++ b/actor.lua @@ -4,9 +4,9 @@ ]] local Actor={ - animations, --list of all the animations in the actor - current, --the current animation (by it's name) - p_onAnimSwitch, --handler called when an animation is switched [internal] + animations = {}, --list of all the animations in the actor + current = nil, --the current animation (by it's name) + p_onAnimSwitch = function(...) end, --handler called when an animation is switched [internal] } --an internal function which just sets the default values @@ -42,7 +42,7 @@ end --Starts the animation for the actor function Actor:startAnimation() - if self:getCurrentAnimation() then + if not self:isActive() and self:getCurrentAnimation() then self:getCurrentAnimation():start() end return self @@ -94,7 +94,7 @@ function Actor:getWidth() return self:getCurrentAnimation():getWidth() end function Actor:getHeight() return self:getCurrentAnimation():getHeight() end --Sets the style of the image -function Actor:setStyle(val) self:getCurrentAnimation():setStyle(val) return self end +function Actor:setStyle(val) self:getCurrentAnimation():setStyle(val) return self end --Flips the current animation function Actor:flip(...) self:getCurrentAnimation():flip(...) return self end @@ -105,6 +105,49 @@ function Actor:render(...) self:getCurrentAnimation():render(...) end +--Update the current animation +function Actor:update(dt) + if self:getCurrentAnimation() then + self:getCurrentAnimation():update(dt) + end + return self +end + +--Cleanup resources to prevent memory leaks +function Actor:destroy() + -- Stop current animation + if self:isActive() then + self:stopAnimation() + end + + -- Get reference to animx module (should be available as global) + local animx = _G.animx + + -- Destroy all animations and remove them from global list + for name, anim in pairs(self.animations) do + if anim then + -- Remove from global animx.animObjs list + if animx and animx.removeAnimation then + animx.removeAnimation(anim) + end + + -- Destroy the animation + if anim.destroy then + anim:destroy() + end + end + end + + -- Clear animations table + self.animations = {} + self.current = nil + + -- Clear callback + self.p_onAnimSwitch = nil + + return self +end + --==Aliases==-- Actor.getCurrentAnim=Actor.getCurrentAnimation @@ -113,4 +156,4 @@ Actor.changeAnim=Actor.switch Actor.set=Actor.switch Actor.draw=Actor.render -return Actor \ No newline at end of file +return Actor diff --git a/animation.lua b/animation.lua index d1d3441..9df7a6b 100644 --- a/animation.lua +++ b/animation.lua @@ -5,31 +5,31 @@ --Unlike iffy we use metatables local Animation={ - texture, --the spritesheet for the animation - frames, --the frames in the animation - duration, --duration for each sprite- a smart table (idea stolen from Walt) - cache, --an internal variable to account for same duration across multiple frames - mode, --the mode of the animation - {'loop'/'rewind'/'once'/'bounce',times} - direction, --the sense of the animation - curFrame, --the current frame that's being rendered - active, --whether the animation is being played or not - curTimes, --keeps count of number of times animation executed - timer, --an internal timer variable - p_flipX, --whether to flip along x-axis [internal] - p_flipY, --whether to flip along y-axis [internal] - p_onCycleOver, --handler called when an animation cycle is complete [internal] - p_onAnimOver, --handler called when the entire animation is over [internal] - p_onAnimStart, --handler called when the animation is started [internal] - p_onAnimRestart, --handler called whenever the animation is restarted [internal] - p_onChange, --handler called whenever current frame is changed [internal] - p_onUpdate, --called at every frame regardless of it's active property [internal] + texture = {}, --the spritesheet for the animation + frames = {}, --the frames in the animation + duration = {}, --duration for each sprite- a smart table (idea stolen from Walt) + cache = {}, --an internal variable to account for same duration across multiple frames + mode = {}, --the mode of the animation - {'loop'/'rewind'/'once'/'bounce',times} + direction = 1, --the sense of the animation + curFrame = 1, --the current frame that's being rendered + active = false, --whether the animation is being played or not + curTimes = 0, --keeps count of number of times animation executed + timer = 0, --an internal timer variable + p_flipX = false, --whether to flip along x-axis [internal] + p_flipY = false, --whether to flip along y-axis [internal] + p_onCycleOver = function(...) end, --handler called when an animation cycle is complete [internal] + p_onAnimOver = function(...) end, --handler called when the entire animation is over [internal] + p_onAnimStart = function(...) end, --handler called when the animation is started [internal] + p_onAnimRestart = function(...) end, --handler called whenever the animation is restarted [internal] + p_onChange = function(...) end, --handler called whenever current frame is changed [internal] + p_onUpdate = function(...) end, --called at every frame regardless of it's active property [internal] } --an internal function which just sets the default values function Animation:init(startingFrame,delay) self:setDelay(delay) self.startingFrame=startingFrame - self:start() + self:start() self.mode={'loop',1} end @@ -205,7 +205,7 @@ end function Animation:stop() --It is important that active is first set to false and then handler is called self.active=false - self.p_onAnimOver(self) + self.p_onAnimOver(self) end --gets the duration of the given frame or current frame if provided nil @@ -227,14 +227,14 @@ end function Animation:setDelay(frame,delay) if not delay then --Set delay for all frames - delay,frame=frame + delay,frame=frame, nil self.duration={delay} self.cache={self:getSize()} return self end --Set delay for only one frame assert(frame>=1 and frame<=self:getSize(),"animX Error: Frame is out of bounds!") - + local u,v=1,1 for i=1,self:getSize() do @@ -296,7 +296,7 @@ function Animation:update(dt) if self.timer>delay then self.timer=self.timer%delay self:change() - if self.curFrame>self:getSize() then + if self.curFrame>self:getSize() then self:cycle() if self:getMode()=='bounce' then --If we are bouncing and we are done then stop @@ -326,7 +326,7 @@ function Animation:update(dt) elseif self.curFrame<1 then self.curFrame=1 - + if self:getMode()=='bounce' then self:cycle() end @@ -373,6 +373,30 @@ end --This function is not defined here but on the main library file! Animation.exportToXML=nil +--Cleanup resources to prevent memory leaks +function Animation:destroy() + -- Clear all callbacks + self.p_onAnimOver = nil + self.p_onCycleOver = nil + self.p_onAnimStart = nil + self.p_onAnimRestart = nil + self.p_onChange = nil + self.p_onUpdate = nil + + -- Clear frames (quads will be garbage collected) + self.frames = {} + self.duration = {} + self.cache = {} + + -- Clear texture reference + self.texture = nil + + -- Mark as inactive + self.active = false + + return self +end + --Just setting some aliases- kinda my speciality Animation.getTexture=Animation.getAtlas diff --git a/init.lua b/init.lua index 68db3bb..f3079f5 100644 --- a/init.lua +++ b/init.lua @@ -24,11 +24,11 @@ end --Borrowed from [euler](https://github.com/YoungNeer/euler) function round(value,precision) - local temp = 10^(precision or 0) - if value >= 0 then + local temp = 10^(precision or 0) + if value >= 0 then return math.floor(value * temp + 0.5) / temp - else - return math.ceil(value * temp - 0.5) / temp + else + return math.ceil(value * temp - 0.5) / temp end end @@ -36,7 +36,7 @@ end local function removePath(filename) local pos=1 local i = string.find(filename,'[\\/]', pos) - pos=i + pos=i or 1 while i do i = string.find(filename,'[\\/]', pos) if i then @@ -127,7 +127,7 @@ function animx.newAnimation(params) ]]-- --if user has not given sprites per row then let qh simply be image height - if not spr then + if not spr then qh=qh or img:getHeight() else if not qh then @@ -151,7 +151,7 @@ function animx.newAnimation(params) --If user has given us some quads to work with - we set this to zero if nil nq=nq or 0 end - + spr = spr or nq --If user has not given anything dissecting then make the image a quad! @@ -196,7 +196,7 @@ function animx.newAnimation(params) assert(qw and qh,"animX Error: Quad dimensions coudn't be calculated in `newAnimation`!") --IMPORTANT: We want integers not highly precise floats or doubles qw,qh=round(qw),round(qh) - end + end --Calculate offset from the startpoint if startPoint then @@ -231,7 +231,11 @@ function animx.newAnimation(params) ['texture']=img, ['frames']=quads } - table.insert(animx.animObjs,setmetatable(animation_obj,{__index=Animation})) + setmetatable(animation_obj,{__index=Animation}) + + -- Always add to global list for animx.update() to work + table.insert(animx.animObjs, animation_obj) + animation_obj:onAnimStart(onAnimStart):init(startingFrame,delay) animation_obj :onAnimOver(onAnimOver) @@ -239,8 +243,8 @@ function animx.newAnimation(params) :onChange(onChange) :onCycleOver(onCycleOver) :onUpdate(onUpdate) - - return animx.animObjs[#animx.animObjs] + + return animation_obj end --[[:- @@ -300,7 +304,7 @@ end The actor itself ]]-- function Actor:addAnimation(name,anim) - if anim.cache and anim.direction then + if anim.cache and anim.direction then --User provided an already created animation self.animations[name]=anim else @@ -325,18 +329,21 @@ function animx.newAnimationXML(image,filename) local _, frameNo = string.match(line, "name=([\"'])(.-)%1") frameNo=tonumber(frameNo) --Frames must start from 1! - if not frameNo or frameNo<=0 then goto continue end + if not frameNo or frameNo<=0 then + print("avoid to use goto in anumX") + -- goto continue + else + assert(not t[frameNo], + "animX Error!! Duplicate Frames found for ("..frameNo..") for "..filename + ) + local _, x = string.match(line, "x=([\"'])(.-)%1") + local _, y = string.match(line, "y=([\"'])(.-)%1") + local _, width = string.match(line, "width=([\"'])(.-)%1") + local _, height = string.match(line, "height=([\"'])(.-)%1") - assert(not t[frameNo], - "animX Error!! Duplicate Frames found for ("..frameNo..") for "..filename - ) - local _, x = string.match(line, "x=([\"'])(.-)%1") - local _, y = string.match(line, "y=([\"'])(.-)%1") - local _, width = string.match(line, "width=([\"'])(.-)%1") - local _, height = string.match(line, "height=([\"'])(.-)%1") - - t[frameNo]=love.graphics.newQuad(x,y,width,height,sw,sh) - ::continue:: + t[frameNo]=love.graphics.newQuad(x,y,width,height,sw,sh) + end + -- ::continue:: end i=i+1 end @@ -359,19 +366,22 @@ function animx.newActorXML(image,filename) local animName=frameNo:match('[%a ]+') frameNo=tonumber(frameNo:match('%d+')) --Frames must exist and must start from 1! Also animation name must be present - if not animName or not frameNo or frameNo<=0 then goto continue end + if not animName or not frameNo or frameNo<=0 then + -- goto continue + print("avoid to use goto in anumX") + else + if not t[animName] then t[animName]={} end + assert(not t[animName][frameNo], + "animX Error!! Duplicate Frames found for ("..frameNo..") for "..filename + ) + local _, x = string.match(line, "x=([\"'])(.-)%1") + local _, y = string.match(line, "y=([\"'])(.-)%1") + local _, width = string.match(line, "width=([\"'])(.-)%1") + local _, height = string.match(line, "height=([\"'])(.-)%1") - if not t[animName] then t[animName]={} end - assert(not t[animName][frameNo], - "animX Error!! Duplicate Frames found for ("..frameNo..") for "..filename - ) - local _, x = string.match(line, "x=([\"'])(.-)%1") - local _, y = string.match(line, "y=([\"'])(.-)%1") - local _, width = string.match(line, "width=([\"'])(.-)%1") - local _, height = string.match(line, "height=([\"'])(.-)%1") - - t[animName][frameNo]=love.graphics.newQuad(x,y,width,height,sw,sh) - ::continue:: + t[animName][frameNo]=love.graphics.newQuad(x,y,width,height,sw,sh) + end + -- ::continue:: end i=i+1 end @@ -388,7 +398,7 @@ end function Animation:exportToXML(filename) filename=removePath(filename) if fileExists(filename) then - if not animx.hideWarnings then + if not animx.hideWarnings then error(string.format("animX Warning! File '%s' Already Exists!",filename)) end end @@ -402,17 +412,21 @@ function Animation:exportToXML(filename) end local sname,x,y,width,height - outfile:write(string.format('\n',removeExtension(filename))) - for i=1,#self.frames do - x,y,width,height=self.frames[i]:getViewport() - outfile:write( - string.format('\t\n', - i,x,y,width,height + if outfile then + outfile:write(string.format('\n',removeExtension(filename))) + for i=1,#self.frames do + x,y,width,height=self.frames[i]:getViewport() + outfile:write( + string.format('\t\n', + i,x,y,width,height + ) ) - ) + end + outfile:write("") + return outfile:close() + else + error("animx Error! Something's wrong with the io") end - outfile:write("") - return outfile:close() end --[[ @@ -425,7 +439,7 @@ end function Actor:exportToXML(filename) filename=removePath(filename) if fileExists(filename) then - if not animx.hideWarnings then + if not animx.hideWarnings then error(string.format("animX Warning! File '%s' Already Exists!",filename)) end end @@ -439,30 +453,60 @@ function Actor:exportToXML(filename) end local sname,x,y,width,height - outfile:write(string.format('\n',removeExtension(filename))) - for anim in pairs(self.animations) do - for i=1,#self.animations[anim].frames do - x,y,width,height=self.animations[anim].frames[i]:getViewport() - outfile:write( - string.format('\t\n', - anim..i,x,y,width,height + if outfile then + outfile:write(string.format('\n',removeExtension(filename))) + for anim in pairs(self.animations) do + for i=1,#self.animations[anim].frames do + x,y,width,height=self.animations[anim].frames[i]:getViewport() + outfile:write( + string.format('\t\n', + anim..i,x,y,width,height + ) ) - ) + end end + outfile:write("") + return outfile:close() + else + error("animx Error! Something's wrong with the io") end - outfile:write("") - return outfile:close() end --Updates all the animation objects at once so you won't see them in your code function animx.update(dt) for i=1,#animx.animObjs do - animx.animObjs[i]:update(dt) + if animx.animObjs[i] then + animx.animObjs[i]:update(dt) + end + end +end + +--Manually clear the image cache (useful for freeing memory) +function animx.clearImageCache() + for k in pairs(imgCache) do + imgCache[k] = nil + end + collectgarbage("collect") +end + +--Remove a specific animation from the global list +function animx.removeAnimation(anim) + for i = #animx.animObjs, 1, -1 do + if animx.animObjs[i] == anim then + table.remove(animx.animObjs, i) + return true + end end + return false +end + +--Clear all animations (useful for scene changes) +function animx.clearAllAnimations() + animx.animObjs = {} end -love.update=function(dt) animx.update(dt) end -animx.newAnimatedSprite=animx.newActor +-- love.update=function(dt) animx.update(dt) end +-- animx.newAnimatedSprite=animx.newActor -- ? return animx