local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local CollectionService = game:GetService("CollectionService")
local Module = {}
local LogHistory = {}
local Watches = {}
local Timers = {}
local DrawFolder = nil
local OverlayGui = nil
local OverlayFrame = nil
local IsEnabled = true
Module.MaxLogHistory = 500
Module.DefaultColor = Color3.fromRGB(255, 100, 0)
Module.InfoColor = Color3.fromRGB(80, 180, 255)
Module.WarnColor = Color3.fromRGB(255, 220, 0)
Module.ErrorColor = Color3.fromRGB(255, 60, 60)
Module.SuccessColor = Color3.fromRGB(80, 220, 100)
Module.VerboseColor = Color3.fromRGB(180, 180, 180)
Module.VERBOSE = 0
Module.INFO = 1
Module.WARN = 2
Module.ERROR = 3
Module.SILENT = 99
Module.LogLevel = Module.INFO
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
Module.Enable = function()
IsEnabled = true
end
Module.Disable = function()
IsEnabled = false
end
Module.IsEnabled = function(): boolean
return IsEnabled
end
Module.SetLogLevel = function(level: number)
Module.LogLevel = level
end
Module.ClearLog = function()
LogHistory = {}
end
Module.ClearVisuals = function()
if DrawFolder and DrawFolder.Parent then
DrawFolder:ClearAllChildren()
end
end
Module.ClearAll = function()
Module.ClearLog()
Module.ClearVisuals()
end
Module.Verbose = function(message: string, tag: string?)
pushLog(Module.VERBOSE, tag or "VERBOSE", tostring(message), Module.VerboseColor)
end
Module.Info = function(message: string, tag: string?)
pushLog(Module.INFO, tag or "INFO", tostring(message), Module.InfoColor)
end
Module.Warn = function(message: string, tag: string?)
pushLog(Module.WARN, tag or "WARN", tostring(message), Module.WarnColor)
end
Module.Error = function(message: string, tag: string?)
pushLog(Module.ERROR, tag or "ERROR", tostring(message), Module.ErrorColor)
end
Module.Success = function(message: string, tag: string?)
pushLog(Module.INFO, tag or "OK", tostring(message), Module.SuccessColor)
end
Module.Log = function(message: string, tag: string, color: Color3?)
pushLog(Module.INFO, tag, tostring(message), color or Module.DefaultColor)
end
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
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
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
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
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
Module.GetLog = function(): {{}}
return table.clone(LogHistory)
end
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
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
Module.LogMemory = function()
local mb = collectgarbage("count") / 1024
Module.Info(string.format("Memory: %.2f MB", mb), "MEMORY")
end
Module.LogFPS = function()
local fps = math.floor(1 / RunService.Heartbeat:Wait())
Module.Info("FPS: " .. fps, "PERF")
end
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
Module.AssertNotNil = function(value: any, name: string?)
if value == nil then
error("ASSERTION FAILED: " .. (name or "value") .. " must not be nil", 2)
end
end
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
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
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
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
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
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
Module.TimerStart = function(name: string)
Timers[name] = { start = os.clock(), laps = {} }
end
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
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
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
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
Module.MemoryMB = function(): number
return collectgarbage("count") / 1024
end
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
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
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
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
Module.StopAllWatches = function()
for name, conn in pairs(Watches) do
conn:Disconnect()
Watches[name] = nil
end
end
Module.Inspect = function(name: string, getter: ()->any)
Module.Info(string.format("'%s' = %s", name, tostring(getter())), "INSPECT")
end
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Module.ShowOverlay = function()
if not IsEnabled then return end
ensureOverlay()
if OverlayGui then OverlayGui.Enabled = true end
end
Module.HideOverlay = function()
if OverlayGui then OverlayGui.Enabled = false end
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
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
Module.ClearOverlay = function()
if OverlayFrame then
for _, child in ipairs(OverlayFrame:GetChildren()) do
if child:IsA("TextLabel") then child:Destroy() end
end
end
end
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
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
Module.CountDescendants = function(instance: Instance): number
return #instance:GetDescendants()
end
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
Module.GetTagged = function(tag: string): {Instance}
return CollectionService:GetTagged(tag)
end
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
Module.HasAttribute = function(instance: Instance, name: string): boolean
return instance:GetAttribute(name) ~= nil
end
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
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
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
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
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
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
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
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
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
Module.StackTrace = function(message: string?)
local trace = debug.traceback(message or "Stack Trace", 2)
Module.Warn(trace, "TRACE")
end
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
Module.Once = function(fn: ()->()): ()->()
local fired = false
return function()
if not fired then
fired = true
fn()
end
end
end
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
Module.ResetOnce = function()
_G.__DebugOnce = {}
end
Module.UID = function(): string
return string.format("DBG-%X-%X", math.floor(os.clock() * 1000), math.random(0xFFFF))
end
return Module