‹ Back to My Code Debug Library v1.0 | Debugging and Anaylsis Kit
modules lua
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--  ◄► Title:       Debug_Library
--  ◄► Version:     1.0
--  ◄► Date:        28/05/2025
--  ◄► Author:      DeathToTheStadium
--  ◄► Description: A human-readable debug, profiling, and visualization
--                  module for Roblox game development.
--                  Draw vectors, boxes, rays, spheres, log with levels,
--                  profile performance, assert with context, watch values,
--                  and inspect any object at runtime.
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--► Services     ◄--
local RunService       = game:GetService("RunService")
local Players          = game:GetService("Players")
local CollectionService = game:GetService("CollectionService")
--► Tables       ◄--
local Module = {}
--► Variables    ◄--
local LogHistory       = {}       -- all log entries
local Watches          = {}       -- active value watchers
local Timers           = {}       -- named performance timers
local DrawFolder       = nil      -- folder that holds all visual debug parts
local OverlayGui       = nil      -- ScreenGui for on-screen overlay
local OverlayFrame     = nil      -- Frame inside OverlayGui
local IsEnabled        = true     -- global on/off switch
--► Constants    ◄--
Module.MaxLogHistory   = 500
Module.DefaultColor    = Color3.fromRGB(255, 100, 0)   -- orange
Module.InfoColor       = Color3.fromRGB(80,  180, 255)  -- blue
Module.WarnColor       = Color3.fromRGB(255, 220, 0)    -- yellow
Module.ErrorColor      = Color3.fromRGB(255, 60,  60)   -- red
Module.SuccessColor    = Color3.fromRGB(80,  220, 100)  -- green
Module.VerboseColor    = Color3.fromRGB(180, 180, 180)  -- gray

-- Log level constants
Module.VERBOSE = 0
Module.INFO    = 1
Module.WARN    = 2
Module.ERROR   = 3
Module.SILENT  = 99  -- suppresses all output

Module.LogLevel = Module.INFO  -- default — change to VERBOSE or SILENT as needed

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	INTERNAL HELPERS
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

local function getTimestamp(): string
	local t = os.clock()
	local m = math.floor(t / 60)
	local s = math.floor(t % 60)
	local ms = math.floor((t % 1) * 1000)
	return string.format("[%02d:%02d.%03d]", m, s, ms)
end

local function getDrawFolder(): Folder
	if not DrawFolder or not DrawFolder.Parent then
		DrawFolder = Instance.new("Folder")
		DrawFolder.Name = "Debug_Visuals"
		DrawFolder.Parent = workspace
	end
	return DrawFolder
end

local function makeAdornment(className: string, adornee: Instance): Instance
	local a = Instance.new(className)
	a.Adornee = adornee
	a.AlwaysOnTop = true
	a.ZIndex = 10
	a.Parent = getDrawFolder()
	return a
end

local function makePart(name: string, size: Vector3, color: Color3, position: CFrame): Part
	local p = Instance.new("Part")
	p.Name         = name
	p.Size         = size
	p.CFrame       = position
	p.Anchored     = true
	p.CanCollide   = false
	p.CanQuery     = false
	p.CanTouch     = false
	p.CastShadow   = false
	p.Color        = color
	p.Material     = Enum.Material.Neon
	p.Transparency = 0.4
	p.Parent       = getDrawFolder()
	return p
end

local function ensureOverlay()
	if OverlayGui and OverlayGui.Parent then return end
	local player = Players.LocalPlayer
	if not player then return end
	local sg = Instance.new("ScreenGui")
	sg.Name            = "Debug_Overlay"
	sg.ResetOnSpawn    = false
	sg.ZIndexBehavior  = Enum.ZIndexBehavior.Sibling
	sg.Parent          = player:WaitForChild("PlayerGui")
	OverlayGui = sg

	local frame = Instance.new("Frame")
	frame.Name              = "Container"
	frame.Size              = UDim2.new(0, 300, 1, 0)
	frame.Position          = UDim2.new(0, 4, 0, 4)
	frame.BackgroundColor3  = Color3.new(0, 0, 0)
	frame.BackgroundTransparency = 0.5
	frame.BorderSizePixel   = 0
	frame.Parent            = sg
	OverlayFrame = frame

	local layout = Instance.new("UIListLayout")
	layout.Padding          = UDim.new(0, 2)
	layout.SortOrder        = Enum.SortOrder.LayoutOrder
	layout.Parent           = frame
end

local function pushLog(level: number, tag: string, message: string, color: Color3)
	if not IsEnabled then return end
	if level < Module.LogLevel then return end
	local entry = {
		time    = getTimestamp(),
		tag     = tag,
		message = message,
		level   = level,
		color   = color,
	}
	table.insert(LogHistory, entry)
	if #LogHistory > Module.MaxLogHistory then
		table.remove(LogHistory, 1)
	end
	local line = entry.time .. " [" .. tag .. "] " .. message
	if level == Module.ERROR then
		warn(line)
	else
		print(line)
	end
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	GLOBAL CONTROL
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Enables all debug output and visuals
Module.Enable = function()
	IsEnabled = true
end

--► Disables all debug output and visuals (silent mode)
Module.Disable = function()
	IsEnabled = false
end

--► Returns true if the debug system is currently enabled
Module.IsEnabled = function(): boolean
	return IsEnabled
end

--► Sets the minimum log level (VERBOSE / INFO / WARN / ERROR / SILENT)
Module.SetLogLevel = function(level: number)
	Module.LogLevel = level
end

--► Clears all stored log history
Module.ClearLog = function()
	LogHistory = {}
end

--► Removes all debug visual parts from the workspace
Module.ClearVisuals = function()
	if DrawFolder and DrawFolder.Parent then
		DrawFolder:ClearAllChildren()
	end
end

--► Removes all debug visuals AND clears the log history
Module.ClearAll = function()
	Module.ClearLog()
	Module.ClearVisuals()
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	LOGGING
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Logs a verbose message (lowest priority, for detailed tracing)
Module.Verbose = function(message: string, tag: string?)
	pushLog(Module.VERBOSE, tag or "VERBOSE", tostring(message), Module.VerboseColor)
end

--► Logs an informational message
Module.Info = function(message: string, tag: string?)
	pushLog(Module.INFO, tag or "INFO", tostring(message), Module.InfoColor)
end

--► Logs a warning message
Module.Warn = function(message: string, tag: string?)
	pushLog(Module.WARN, tag or "WARN", tostring(message), Module.WarnColor)
end

--► Logs an error message (does not throw — use Assert for that)
Module.Error = function(message: string, tag: string?)
	pushLog(Module.ERROR, tag or "ERROR", tostring(message), Module.ErrorColor)
end

--► Logs a success message (useful for confirmations)
Module.Success = function(message: string, tag: string?)
	pushLog(Module.INFO, tag or "OK", tostring(message), Module.SuccessColor)
end

--► Logs a message with a custom tag and color
Module.Log = function(message: string, tag: string, color: Color3?)
	pushLog(Module.INFO, tag, tostring(message), color or Module.DefaultColor)
end

--► Logs a separator line to visually divide log sections
Module.Separator = function(label: string?)
	local line = string.rep("─", 40)
	if label then
		local pad = math.floor((40 - #label - 2) / 2)
		line = string.rep("─", pad) .. " " .. label .. " " .. string.rep("─", pad)
	end
	print(line)
end

--► Logs a table's contents in a readable key = value format
Module.LogTable = function(t: {}, tag: string?)
	local prefix = tag and ("[" .. tag .. "] ") or ""
	print(prefix .. "{")
	for k, v in pairs(t) do
		print("  " .. tostring(k) .. " = " .. tostring(v))
	end
	print("}")
end

--► Logs a Vector3 with an optional label
Module.LogVector = function(v: Vector3, label: string?)
	local prefix = label and (label .. ": ") or ""
	Module.Info(string.format("%s(%.3f, %.3f, %.3f)", prefix, v.X, v.Y, v.Z), "VECTOR")
end

--► Logs a CFrame decomposed into position and euler angles in degrees
Module.LogCFrame = function(cf: CFrame, label: string?)
	local p       = cf.Position
	local rx, ry, rz = cf:ToEulerAnglesXYZ()
	local prefix  = label and (label .. " ") or ""
	Module.Info(string.format(
		"%sPos(%.2f, %.2f, %.2f) Rot(%.1f°, %.1f°, %.1f°)",
		prefix, p.X, p.Y, p.Z,
		math.deg(rx), math.deg(ry), math.deg(rz)
		), "CFRAME")
end

--► Logs all properties of a Roblox Instance
Module.LogInstance = function(instance: Instance)
	Module.Separator(instance.Name)
	Module.Info("ClassName: " .. instance.ClassName, "INSTANCE")
	Module.Info("Parent:    " .. tostring(instance.Parent), "INSTANCE")
	for _, child in ipairs(instance:GetChildren()) do
		Module.Info("  Child: " .. child.Name .. " (" .. child.ClassName .. ")", "INSTANCE")
	end
end

--► Returns the full log history as an array of entry tables
Module.GetLog = function(): {{}}
	return table.clone(LogHistory)
end

--► Returns only log entries at or above a given level
Module.GetLogFiltered = function(minLevel: number): {{}}
	local result = {}
	for _, entry in ipairs(LogHistory) do
		if entry.level >= minLevel then
			table.insert(result, entry)
		end
	end
	return result
end

--► Prints the last N log entries to the output
Module.PrintLastN = function(n: number)
	local count  = #LogHistory
	local start  = math.max(1, count - n + 1)
	Module.Separator("Last " .. n .. " Logs")
	for i = start, count do
		local e = LogHistory[i]
		print(e.time .. " [" .. e.tag .. "] " .. e.message)
	end
end

--► Logs the current memory usage in MB
Module.LogMemory = function()
	local mb = collectgarbage("count") / 1024
	Module.Info(string.format("Memory: %.2f MB", mb), "MEMORY")
end

--► Logs the current Roblox FPS (client only)
Module.LogFPS = function()
	local fps = math.floor(1 / RunService.Heartbeat:Wait())
	Module.Info("FPS: " .. fps, "PERF")
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	ASSERT & GUARDS
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Throws an error with a formatted message if condition is false
-- Better than raw assert — includes a tag and context in the message
Module.Assert = function(condition: boolean, message: string, tag: string?)
	if not condition then
		local prefix = tag and ("[" .. tag .. "] ") or ""
		error(prefix .. "ASSERTION FAILED: " .. message, 2)
	end
end

--► Asserts that a value is not nil
Module.AssertNotNil = function(value: any, name: string?)
	if value == nil then
		error("ASSERTION FAILED: " .. (name or "value") .. " must not be nil", 2)
	end
end

--► Asserts that a value is of the expected Lua type
Module.AssertType = function(value: any, expectedType: string, name: string?)
	local actual = type(value)
	if actual ~= expectedType then
		error(string.format(
			"ASSERTION FAILED: %s expected type '%s', got '%s'",
			name or "value", expectedType, actual
			), 2)
	end
end

--► Asserts that a value is within a numeric range [min, max]
Module.AssertRange = function(value: number, min: number, max: number, name: string?)
	if value < min or value > max then
		error(string.format(
			"ASSERTION FAILED: %s = %g is out of range [%g, %g]",
			name or "value", value, min, max
			), 2)
	end
end

--► Asserts that an instance exists and is of the expected class
Module.AssertInstance = function(instance: any, className: string, name: string?)
	if not instance or not instance:IsA(className) then
		error(string.format(
			"ASSERTION FAILED: %s must be a %s, got %s",
			name or "instance",
			className,
			instance and instance.ClassName or "nil"
			), 2)
	end
end

--► Soft assert — logs a warning instead of throwing when condition is false
-- Returns the condition so you can use it in an if-statement
Module.Expect = function(condition: boolean, message: string, tag: string?): boolean
	if not condition then
		Module.Warn("EXPECTATION FAILED: " .. message, tag or "EXPECT")
	end
	return condition
end

--► Guards a function call — catches errors and logs them instead of crashing
-- Returns true if successful, false + error message if it failed
Module.Try = function(fn: ()->(), tag: string?): (boolean, string?)
	local ok, err = pcall(fn)
	if not ok then
		Module.Error(tostring(err), tag or "TRY")
	end
	return ok, err
end

--► Guards a function and retries it up to N times before giving up
Module.Retry = function(fn: ()->(), attempts: number, tag: string?): boolean
	for i = 1, attempts do
		local ok, err = pcall(fn)
		if ok then return true end
		Module.Warn(string.format("Attempt %d/%d failed: %s", i, attempts, tostring(err)), tag or "RETRY")
	end
	Module.Error("All " .. attempts .. " attempts failed", tag or "RETRY")
	return false
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	PERFORMANCE PROFILING
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Starts a named performance timer
Module.TimerStart = function(name: string)
	Timers[name] = { start = os.clock(), laps = {} }
end

--► Records a lap time on a running timer
Module.TimerLap = function(name: string, label: string?)
	local t = Timers[name]
	if not t then Module.Warn("Timer '" .. name .. "' not started", "TIMER") return end
	local elapsed = os.clock() - t.start
	table.insert(t.laps, { label = label or ("#" .. #t.laps + 1), time = elapsed })
end

--► Stops a timer and logs the total elapsed time in milliseconds
Module.TimerStop = function(name: string)
	local t = Timers[name]
	if not t then Module.Warn("Timer '" .. name .. "' not started", "TIMER") return end
	local total = (os.clock() - t.start) * 1000
	Module.Info(string.format("Timer '%s': %.3f ms", name, total), "TIMER")
	for _, lap in ipairs(t.laps) do
		Module.Verbose(string.format("  Lap %s: %.3f ms", lap.label, lap.time * 1000), "TIMER")
	end
	Timers[name] = nil
end

--► Measures how long a function takes to run and logs it in milliseconds
-- Returns whatever the function returns
Module.Measure = function(fn: ()->any, label: string?): any
	local start  = os.clock()
	local result = {fn()}
	local ms     = (os.clock() - start) * 1000
	Module.Info(string.format("%s took %.3f ms", label or "Function", ms), "PERF")
	return table.unpack(result)
end

--► Benchmarks a function by running it N times and logs average, min, and max time
Module.Benchmark = function(fn: ()->(), iterations: number, label: string?)
	local times  = table.create(iterations)
	for i = 1, iterations do
		local s = os.clock()
		fn()
		times[i] = (os.clock() - s) * 1000
	end
	local sum, min, max = 0, math.huge, -math.huge
	for _, t in ipairs(times) do
		sum += t
		if t < min then min = t end
		if t > max then max = t end
	end
	local avg = sum / iterations
	Module.Separator(label or "Benchmark")
	Module.Info(string.format("Iterations: %d", iterations), "BENCH")
	Module.Info(string.format("Average:    %.4f ms", avg),   "BENCH")
	Module.Info(string.format("Min:        %.4f ms", min),   "BENCH")
	Module.Info(string.format("Max:        %.4f ms", max),   "BENCH")
end

--► Returns the current memory usage in megabytes
Module.MemoryMB = function(): number
	return collectgarbage("count") / 1024
end

--► Logs a snapshot of memory before and after a function runs
Module.MemoryDelta = function(fn: ()->(), label: string?)
	local before = Module.MemoryMB()
	fn()
	local after  = Module.MemoryMB()
	local delta  = after - before
	Module.Info(string.format(
		"%s — Before: %.2f MB  After: %.2f MB  Delta: %+.2f MB",
		label or "Memory", before, after, delta
		), "MEMORY")
end

--► Starts a rolling FPS tracker and prints average FPS every N seconds
-- Returns a stop function to disconnect it
Module.TrackFPS = function(intervalSeconds: number?): ()->()
	local interval  = intervalSeconds or 3
	local frameCount = 0
	local elapsed   = 0
	local conn      = RunService.Heartbeat:Connect(function(dt)
		frameCount += 1
		elapsed    += dt
		if elapsed >= interval then
			local fps = frameCount / elapsed
			Module.Info(string.format("FPS: %.1f (over %.1fs)", fps, elapsed), "FPS")
			frameCount = 0
			elapsed    = 0
		end
	end)
	return function() conn:Disconnect() end
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	VALUE WATCHING
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Watches a value-returning function and logs when it changes
-- Returns a stop function
--
-- Usage:
--   local stop = Debug.Watch("Health", function() return player.Health end)
--   stop() -- when done
Module.Watch = function(name: string, getter: ()->any, intervalSeconds: number?): ()->()
	local interval = intervalSeconds or 0.1
	local last     = getter()
	local elapsed  = 0

	local conn = RunService.Heartbeat:Connect(function(dt)
		elapsed += dt
		if elapsed < interval then return end
		elapsed = 0
		local current = getter()
		if current ~= last then
			Module.Info(string.format(
				"'%s' changed: %s → %s",
				name, tostring(last), tostring(current)
				), "WATCH")
			last = current
		end
	end)

	Watches[name] = conn
	return function()
		conn:Disconnect()
		Watches[name] = nil
	end
end

--► Watches a value and logs every time it crosses a threshold going upward
Module.WatchThreshold = function(name: string, getter: ()->number, threshold: number): ()->()
	local crossed = false
	local conn    = RunService.Heartbeat:Connect(function()
		local v = getter()
		if v >= threshold and not crossed then
			crossed = true
			Module.Warn(string.format("'%s' crossed threshold %.2f (now %.2f)", name, threshold, v), "THRESHOLD")
		elseif v < threshold then
			crossed = false
		end
	end)
	return function() conn:Disconnect() end
end

--► Stops all active watches
Module.StopAllWatches = function()
	for name, conn in pairs(Watches) do
		conn:Disconnect()
		Watches[name] = nil
	end
end

--► Logs the value of a getter function once right now
Module.Inspect = function(name: string, getter: ()->any)
	Module.Info(string.format("'%s' = %s", name, tostring(getter())), "INSPECT")
end

--► Logs every field of an object using a list of getter functions
-- fields: { fieldName = getterFn }
Module.InspectFields = function(label: string, fields: {[string]: ()->any})
	Module.Separator(label)
	for fieldName, getter in pairs(fields) do
		Module.Info(string.format("  %s = %s", fieldName, tostring(getter())), "INSPECT")
	end
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	VISUAL — DRAW IN WORLD SPACE
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Draws a sphere at a world position for a given duration (0 = forever)
Module.DrawSphere = function(position: Vector3, radius: number?, color: Color3?, duration: number?): Part
	if not IsEnabled then return end
	local r    = radius or 1
	local c    = color  or Module.DefaultColor
	local part = makePart("DebugSphere", Vector3.new(r*2, r*2, r*2), c, CFrame.new(position))
	part.Shape = Enum.PartType.Ball
	if duration and duration > 0 then
		task.delay(duration, function() if part.Parent then part:Destroy() end end)
	end
	return part
end

--► Draws an axis-aligned box at a world position for a given duration
Module.DrawBox = function(position: Vector3, size: Vector3?, color: Color3?, duration: number?): Part
	if not IsEnabled then return end
	local s    = size  or Vector3.new(1, 1, 1)
	local c    = color or Module.DefaultColor
	local part = makePart("DebugBox", s, c, CFrame.new(position))
	if duration and duration > 0 then
		task.delay(duration, function() if part.Parent then part:Destroy() end end)
	end
	return part
end

--► Draws an oriented box matching a CFrame and size
Module.DrawBoxCFrame = function(cf: CFrame, size: Vector3?, color: Color3?, duration: number?): Part
	if not IsEnabled then return end
	local s    = size  or Vector3.new(1, 1, 1)
	local c    = color or Module.DefaultColor
	local part = makePart("DebugBoxCF", s, c, cf)
	if duration and duration > 0 then
		task.delay(duration, function() if part.Parent then part:Destroy() end end)
	end
	return part
end

--► Draws a line between two world-space points
Module.DrawLine = function(from: Vector3, to: Vector3, color: Color3?, duration: number?, thickness: number?): Part
	if not IsEnabled then return end
	local c      = color     or Module.DefaultColor
	local thick  = thickness or 0.05
	local mid    = (from + to) / 2
	local length = (to - from).Magnitude
	local dir    = (to - from).Unit
	local cf     = CFrame.new(mid, mid + dir)
	local part   = makePart("DebugLine", Vector3.new(thick, thick, length), c, cf)
	if duration and duration > 0 then
		task.delay(duration, function() if part.Parent then part:Destroy() end end)
	end
	return part
end

--► Draws a Ray as a line in world space
Module.DrawRay = function(ray: Ray, color: Color3?, duration: number?, length: number?): Part
	if not IsEnabled then return end
	local len = length or ray.Direction.Magnitude
	local to  = ray.Origin + ray.Direction.Unit * len
	return Module.DrawLine(ray.Origin, to, color, duration)
end

--► Draws an arrow from one point to another (line + arrowhead sphere)
Module.DrawArrow = function(from: Vector3, to: Vector3, color: Color3?, duration: number?)
	if not IsEnabled then return end
	local c = color or Module.DefaultColor
	Module.DrawLine(from, to, c, duration)
	Module.DrawSphere(to, 0.15, c, duration)
end

--► Draws a Vector3 direction as an arrow from an origin point
Module.DrawVector = function(origin: Vector3, direction: Vector3, color: Color3?, duration: number?, scale: number?)
	if not IsEnabled then return end
	local s = scale or 1
	Module.DrawArrow(origin, origin + direction * s, color, duration)
end

--► Draws a ring/circle in world space on a given axis plane
Module.DrawCircle = function(center: Vector3, radius: number, axis: Vector3?, color: Color3?, duration: number?, segments: number?)
	if not IsEnabled then return end
	local c    = color    or Module.DefaultColor
	local segs = segments or 24
	local up   = (axis or Vector3.new(0, 1, 0)).Unit
	local right = up:Cross(Vector3.new(0, 0, 1)).Magnitude < 0.01
		and up:Cross(Vector3.new(1, 0, 0)).Unit
		or  up:Cross(Vector3.new(0, 0, 1)).Unit

	local prev = nil
	for i = 0, segs do
		local angle = (i / segs) * math.pi * 2
		local point = center + (right * math.cos(angle) + up:Cross(right) * math.sin(angle)) * radius
		if prev then Module.DrawLine(prev, point, c, duration, 0.04) end
		prev = point
	end
end

--► Draws the three local axes of a CFrame as colored arrows (X=red, Y=green, Z=blue)
Module.DrawAxes = function(cf: CFrame, scale: number?, duration: number?)
	if not IsEnabled then return end
	local s = scale or 2
	local p = cf.Position
	Module.DrawArrow(p, p + cf.RightVector   * s, Color3.fromRGB(255, 60, 60),  duration)
	Module.DrawArrow(p, p + cf.UpVector      * s, Color3.fromRGB(60, 220, 60),  duration)
	Module.DrawArrow(p, p + cf.LookVector    * s, Color3.fromRGB(60, 120, 255), duration)
end

--► Draws a wireframe bounding box around a BasePart using its size and CFrame
Module.DrawPartBounds = function(part: BasePart, color: Color3?, duration: number?)
	if not IsEnabled then return end
	Module.DrawBoxCFrame(part.CFrame, part.Size, color or Module.WarnColor, duration)
end

--► Draws a bounding box around an entire model using its bounding box
Module.DrawModelBounds = function(model: Model, color: Color3?, duration: number?)
	if not IsEnabled then return end
	local cf, size = model:GetBoundingBox()
	Module.DrawBoxCFrame(cf, size, color or Module.WarnColor, duration)
end

--► Draws a point (small sphere) at a world position with an optional label
Module.DrawPoint = function(position: Vector3, color: Color3?, duration: number?, radius: number?): Part
	if not IsEnabled then return end
	return Module.DrawSphere(position, radius or 0.2, color or Module.DefaultColor, duration)
end

--► Draws a path through a list of Vector3 points with connecting lines
Module.DrawPath = function(points: {Vector3}, color: Color3?, duration: number?)
	if not IsEnabled then return end
	local c = color or Module.DefaultColor
	for i = 1, #points - 1 do
		Module.DrawLine(points[i], points[i+1], c, duration)
	end
	for _, p in ipairs(points) do
		Module.DrawPoint(p, c, duration, 0.1)
	end
end

--► Draws a world-space label using a BillboardGui above a position
Module.DrawLabel = function(position: Vector3, text: string, color: Color3?, duration: number?): Part
	if not IsEnabled then return end
	local part = Instance.new("Part")
	part.Name        = "DebugLabel"
	part.Size        = Vector3.new(0.1, 0.1, 0.1)
	part.CFrame      = CFrame.new(position)
	part.Anchored    = true
	part.CanCollide  = false
	part.CanQuery    = false
	part.Transparency = 1
	part.Parent      = getDrawFolder()

	local bb = Instance.new("BillboardGui")
	bb.Size           = UDim2.new(0, 120, 0, 30)
	bb.AlwaysOnTop    = true
	bb.StudsOffset    = Vector3.new(0, 1.5, 0)
	bb.Parent         = part

	local label = Instance.new("TextLabel")
	label.Size               = UDim2.new(1, 0, 1, 0)
	label.BackgroundColor3   = Color3.new(0, 0, 0)
	label.BackgroundTransparency = 0.4
	label.TextColor3         = color or Module.DefaultColor
	label.Text               = text
	label.TextScaled         = true
	label.Font               = Enum.Font.Code
	label.Parent             = bb

	if duration and duration > 0 then
		task.delay(duration, function() if part.Parent then part:Destroy() end end)
	end

	return part
end

--► Draws a velocity vector on a BasePart every frame until stopped
-- Returns a stop function
Module.DrawVelocity = function(part: BasePart, scale: number?, color: Color3?): ()->()
	local s    = scale or 0.1
	local c    = color or Module.WarnColor
	local conn = RunService.Heartbeat:Connect(function()
		if not part.Parent then return end
		Module.DrawVector(part.Position, part.AssemblyLinearVelocity, c, 0.05, s)
	end)
	return function() conn:Disconnect() end
end

--► Draws the bounding box of a part every frame until stopped
-- Returns a stop function
Module.DrawBoundsLive = function(part: BasePart, color: Color3?): ()->()
	local conn = RunService.Heartbeat:Connect(function()
		if not part.Parent then return end
		Module.DrawPartBounds(part, color, 0.05)
	end)
	return function() conn:Disconnect() end
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	ON-SCREEN OVERLAY
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Creates or shows the on-screen debug overlay panel (client only)
Module.ShowOverlay = function()
	if not IsEnabled then return end
	ensureOverlay()
	if OverlayGui then OverlayGui.Enabled = true end
end

--► Hides the on-screen overlay without destroying it
Module.HideOverlay = function()
	if OverlayGui then OverlayGui.Enabled = false end
end

--► Adds a named stat row to the on-screen overlay that auto-updates each frame
-- getter: a function that returns the current value as a string or number
-- Returns a stop function
--
-- Usage:
--   local stop = Debug.OverlayStat("FPS", function()
--       return math.floor(1 / RunService.Heartbeat:Wait())
--   end)
Module.OverlayStat = function(name: string, getter: ()->any, color: Color3?): ()->()
	if not IsEnabled then return function() end end
	ensureOverlay()
	if not OverlayFrame then return function() end end

	local label = Instance.new("TextLabel")
	label.Name               = "Stat_" .. name
	label.Size               = UDim2.new(1, -4, 0, 18)
	label.BackgroundTransparency = 1
	label.TextColor3         = color or Module.InfoColor
	label.TextXAlignment     = Enum.TextXAlignment.Left
	label.Font               = Enum.Font.Code
	label.TextSize           = 13
	label.Text               = name .. ": …"
	label.Parent             = OverlayFrame

	local conn = RunService.Heartbeat:Connect(function()
		local ok, val = pcall(getter)
		label.Text = name .. ": " .. (ok and tostring(val) or "ERR")
	end)

	return function()
		conn:Disconnect()
		label:Destroy()
	end
end

--► Adds a one-time message to the overlay that fades out after a duration
Module.OverlayMessage = function(message: string, duration: number?, color: Color3?)
	if not IsEnabled then return end
	ensureOverlay()
	if not OverlayFrame then return end

	local label = Instance.new("TextLabel")
	label.Size               = UDim2.new(1, -4, 0, 18)
	label.BackgroundTransparency = 1
	label.TextColor3         = color or Module.WarnColor
	label.TextXAlignment     = Enum.TextXAlignment.Left
	label.Font               = Enum.Font.Code
	label.TextSize           = 13
	label.Text               = "▶ " .. message
	label.Parent             = OverlayFrame

	task.delay(duration or 3, function()
		if label.Parent then label:Destroy() end
	end)
end

--► Clears all stat rows from the overlay
Module.ClearOverlay = function()
	if OverlayFrame then
		for _, child in ipairs(OverlayFrame:GetChildren()) do
			if child:IsA("TextLabel") then child:Destroy() end
		end
	end
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	INSTANCE INSPECTION
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Prints a tree of an instance and all its descendants with indentation
Module.PrintTree = function(instance: Instance, depth: number?)
	local d      = depth or 0
	local indent = string.rep("  ", d)
	print(indent .. "▸ " .. instance.Name .. " [" .. instance.ClassName .. "]")
	for _, child in ipairs(instance:GetChildren()) do
		Module.PrintTree(child, d + 1)
	end
end

--► Returns the full dot-separated path from root to an instance
Module.GetPath = function(instance: Instance): string
	local parts = {}
	local current = instance
	while current and current ~= game do
		table.insert(parts, 1, current.Name)
		current = current.Parent
	end
	return table.concat(parts, ".")
end

--► Counts the total number of descendants of an instance
Module.CountDescendants = function(instance: Instance): number
	return #instance:GetDescendants()
end

--► Counts all descendants of a specific class inside an instance
Module.CountByClass = function(instance: Instance, className: string): number
	local count = 0
	for _, d in ipairs(instance:GetDescendants()) do
		if d:IsA(className) then count += 1 end
	end
	return count
end

--► Returns a table of all descendants that are tagged with a given CollectionService tag
Module.GetTagged = function(tag: string): {Instance}
	return CollectionService:GetTagged(tag)
end

--► Logs all CollectionService tags on an instance
Module.LogTags = function(instance: Instance)
	local tags = CollectionService:GetTags(instance)
	if #tags == 0 then
		Module.Info(instance.Name .. " has no tags", "TAGS")
	else
		Module.Info(instance.Name .. " tags: " .. table.concat(tags, ", "), "TAGS")
	end
end

--► Returns true if an instance has a specific attribute set
Module.HasAttribute = function(instance: Instance, name: string): boolean
	return instance:GetAttribute(name) ~= nil
end

--► Logs all attributes and their values on an instance
Module.LogAttributes = function(instance: Instance)
	Module.Separator(instance.Name .. " Attributes")
	for name, value in pairs(instance:GetAttributes()) do
		Module.Info(string.format("  %s = %s (%s)", name, tostring(value), type(value)), "ATTR")
	end
end

--► Returns a summary of the workspace: total parts, models, scripts
Module.WorkspaceSummary = function(): {[string]: number}
	local summary = {Parts = 0, Models = 0, Scripts = 0, Welds = 0}
	for _, d in ipairs(workspace:GetDescendants()) do
		if d:IsA("BasePart")   then summary.Parts   += 1 end
		if d:IsA("Model")      then summary.Models  += 1 end
		if d:IsA("BaseScript") then summary.Scripts += 1 end
		if d:IsA("WeldConstraint") or d:IsA("Weld") then summary.Welds += 1 end
	end
	return summary
end

--► Logs the workspace summary to output
Module.LogWorkspaceSummary = function()
	local s = Module.WorkspaceSummary()
	Module.Separator("Workspace Summary")
	for key, count in pairs(s) do
		Module.Info(string.format("  %-10s %d", key, count), "WORKSPACE")
	end
end

--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
--	UTILITIES & EXTRAS
--■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■

--► Prints a formatted comparison of two values side by side
Module.Compare = function(labelA: string, a: any, labelB: string, b: any)
	Module.Separator("Compare")
	Module.Info(string.format("  %-20s = %s", labelA, tostring(a)), "CMP")
	Module.Info(string.format("  %-20s = %s", labelB, tostring(b)), "CMP")
	Module.Info(string.format("  Equal: %s", tostring(a == b)),     "CMP")
end

--► Logs the type and value of any variable
Module.TypeOf = function(value: any, label: string?)
	Module.Info(string.format(
		"%s: type=%s  value=%s",
		label or "value", type(value), tostring(value)
		), "TYPE")
end

--► Logs a formatted numeric table as a column chart in the output window
-- Useful for visualizing distribution data without leaving the output window
Module.PrintChart = function(data: {[string]: number}, width: number?)
	local maxWidth = width or 30
	local maxVal   = 0
	for _, v in pairs(data) do if v > maxVal then maxVal = v end end
	Module.Separator("Chart")
	for label, value in pairs(data) do
		local bars = maxVal > 0 and math.floor((value / maxVal) * maxWidth) or 0
		print(string.format("  %-15s |%s %g", label, string.rep("█", bars), value))
	end
end

--► Logs a heat-map style grid of numbers to the output window
-- values: a flat array of numbers, columns: how wide the grid is
Module.PrintGrid = function(values: {number}, columns: number, label: string?)
	if label then Module.Separator(label) end
	local row = ""
	for i, v in ipairs(values) do
		row ..= string.format("%6.2f ", v)
		if i % columns == 0 then
			print(row)
			row = ""
		end
	end
	if #row > 0 then print(row) end
end

--► Starts a countdown in the output window (prints each second)
-- Returns a cancel function
Module.OutputCountdown = function(seconds: number, label: string?): ()->()
	local running = true
	local prefix  = label and ("[" .. label .. "] ") or ""
	task.spawn(function()
		for i = seconds, 1, -1 do
			if not running then return end
			print(prefix .. "⏱ " .. i .. "s remaining")
			task.wait(1)
		end
		if running then print(prefix .. "✓ Done!") end
	end)
	return function() running = false end
end

--► Logs a "diff" between two tables, showing added, removed, and changed keys
Module.DiffTables = function(old: {}, new: {}, label: string?)
	Module.Separator(label or "Table Diff")
	for k, v in pairs(new) do
		if old[k] == nil then
			Module.Success(string.format("  + %-20s = %s", tostring(k), tostring(v)), "DIFF")
		elseif old[k] ~= v then
			Module.Warn(string.format("  ~ %-20s: %s → %s", tostring(k), tostring(old[k]), tostring(v)), "DIFF")
		end
	end
	for k in pairs(old) do
		if new[k] == nil then
			Module.Error(string.format("  - %-20s (removed)", tostring(k)), "DIFF")
		end
	end
end

--► Prints a stack trace with an optional message (simulates a soft traceback)
Module.StackTrace = function(message: string?)
	local trace = debug.traceback(message or "Stack Trace", 2)
	Module.Warn(trace, "TRACE")
end

--► Throttles a callback so it can only fire once every N seconds
-- Returns the throttled function
Module.Throttle = function(fn: ()->(), seconds: number): ()->()
	local last = -math.huge
	return function()
		local now = os.clock()
		if now - last >= seconds then
			last = now
			fn()
		end
	end
end

--► Returns a function that only runs the first time it is called (one-shot)
Module.Once = function(fn: ()->()): ()->()
	local fired = false
	return function()
		if not fired then
			fired = true
			fn()
		end
	end
end

--► Logs a message only the first time a code path is hit (suppresses repeats)
-- Useful inside loops or Heartbeat to avoid spam
Module.LogOnce = function(key: string, message: string, tag: string?)
	if not _G.__DebugOnce then _G.__DebugOnce = {} end
	if _G.__DebugOnce[key] then return end
	_G.__DebugOnce[key] = true
	Module.Info(message, tag or "ONCE")
end

--► Resets all LogOnce keys so they can fire again
Module.ResetOnce = function()
	_G.__DebugOnce = {}
end

--► Generates a quick unique debug ID string useful for tagging instances or events
Module.UID = function(): string
	return string.format("DBG-%X-%X", math.floor(os.clock() * 1000), math.random(0xFFFF))
end

return Module