WIP: tabs, docking, dragdrop, settings, logging, harness
This commit is contained in:
+9
-3
@@ -13,7 +13,7 @@ for dp, _, fn in os.walk(SRC):
|
||||
if f.endswith(".lua"):
|
||||
FILES[f] = open(os.path.join(dp, f)).read()
|
||||
|
||||
ENTRY = ["imgui.lua", "imgui_impl_roblox.lua", "imgui_demo.lua"] # run order
|
||||
ENTRY = ["imgui.lua", "imgui_impl_roblox.lua"] + sorted(f for f in FILES if f.startswith("imgui_demo")) # run order
|
||||
|
||||
names = set()
|
||||
for src in FILES.values():
|
||||
@@ -27,8 +27,10 @@ out = ["--!native", "--!optimize 2", "-- GENERATED by tools/bundle.py from imgui
|
||||
" if r == nil then r = __FILES[name](); if r == nil then r = true end; __LOADED[name] = r end",
|
||||
" return r",
|
||||
"end", ""]
|
||||
linemap = [] # (first bundle line, file) for error -> source mapping (tools/test/run.py)
|
||||
for name, src in sorted(FILES.items()):
|
||||
out.append("__FILES[%r] = function()" % name)
|
||||
linemap.append((sum(x.count("\n") + 1 for x in out) + 1, name))
|
||||
out.append(src.rstrip("\n"))
|
||||
out.append("end")
|
||||
out.append("")
|
||||
@@ -40,6 +42,10 @@ for n in sorted(names):
|
||||
out.append(" %s = %s," % (n, n))
|
||||
out.append("}")
|
||||
|
||||
os.makedirs(os.path.join(ROOT, "build"), exist_ok=True)
|
||||
open(os.path.join(ROOT, "build", "ImGui.luau"), "w").write("\n".join(out) + "\n")
|
||||
import sys
|
||||
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "build")
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
open(os.path.join(OUT, "ImGui.luau"), "w").write("\n".join(out) + "\n")
|
||||
import json
|
||||
json.dump(linemap, open(os.path.join(OUT, "ImGui.linemap.json"), "w"))
|
||||
print("wrote build/ImGui.luau (%d files, %d exports)" % (len(FILES), len(names)))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Porting rules (Dear ImGui docking branch -> this Lua/Luau port)
|
||||
|
||||
Upstream reference: `tools/test/upstream/*.cpp|h` (ocornut/imgui `docking` branch, 1.93 WIP). Port semantics 1:1 from
|
||||
there, but keep this port's existing data structures and conventions. Read the surrounding existing Lua code first.
|
||||
|
||||
## Runtime
|
||||
- Target is **Luau (Roblox)** only. All files are concatenated into one ModuleScript by `tools/bundle.py`; every
|
||||
file shares one global namespace. Globals defined by one file are visible to the others at call time.
|
||||
- Each `imgui/*.lua` file is a function body in the bundle. New files must be loaded with `IM_INCLUDE"file.lua"`
|
||||
from an existing file (files named `imgui_demo*.lua` are loaded automatically, after `imgui.lua`).
|
||||
- No `goto`, no `::labels::`, no `setfenv/getfenv`, no LuaJIT `bit`/`ffi`. Use `bit32`. For `continue` use
|
||||
`repeat ... until true` with `break`, and an explicit flag when the loop itself must break out of the wrapper.
|
||||
- Luau has a limit of 200 locals per function and 255 upvalues; split huge functions (like demo sections) into
|
||||
several local/global functions.
|
||||
|
||||
## Conventions (follow what the port already does)
|
||||
- `ImVector`: 1-based `.Data`, `.Size`, methods `push_back/pop_back/resize/back/iter/erase/...` (imgui_h.lua).
|
||||
Indices that C++ stores as 0-based *values* (e.g. column index, tab order) stay 0-based unless the surrounding
|
||||
port code already shifted them; document `-- 0-based` / `-- 1-based` next to anything ambiguous.
|
||||
- `ImVec2(x, y)` stores `[1],[2]` with `.x/.y` aliases (metatable). Arithmetic operators exist. Prefer
|
||||
`ImVec2_Copy(dst, src)` / `ImVec2_CopyV(dst, x, y)` when C++ assigns into an existing struct field.
|
||||
`ImVec4`, `ImRect` (`.Min/.Max`) likewise.
|
||||
- Structs: constructor function `ImGuiFoo()` returning a table, methods on `MT.ImGuiFoo` with
|
||||
`MT.ImGuiFoo.__index = MT.ImGuiFoo`. `local MT = ImGui.GetMetatables()`.
|
||||
- Flags/enums: tables like `ImGuiTableFlags.Resizable`; combine with `bit32.bor`, test with `bit32.band(...) ~= 0`.
|
||||
- Context: `local g = GImGui` (each file has its own local `GImGui` kept in sync; follow how the file you are in
|
||||
does it). New fields on `ImGuiContext`/`ImGuiWindow`: add them to the constructor in imgui_internal.lua.
|
||||
- Strings: Lua strings + 1-based byte positions; `text_end` is exclusive. Editable text buffers are `char[]`
|
||||
(byte tables), like `ImGui.InputText(label, buf, buf_size)`.
|
||||
- **Output parameters** (C++ `bool* p_open`, `float* v`, `int* current_item`...) become return values:
|
||||
- bool-like widgets: `return pressed, new_v` (Checkbox, RadioButton, Selectable, MenuItem, CheckboxFlags)
|
||||
- scalar widgets: `return new_v, changed` (Slider*/Drag*/Input* scalar, SliderAngle, Combo -> `new_idx, changed`)
|
||||
- multi-component (`float v[3]`, ImVec4 colors, `char[]` buffers): table mutated in place, return `changed`
|
||||
- `Begin(name, p_open)` style: `return open, visible` / follow existing `ImGui.Begin`.
|
||||
When an existing similar function already exists, copy its convention exactly.
|
||||
- `static` locals in C++ become file-level upvalue tables / fields (demo uses a `static` table per section).
|
||||
- `IM_ASSERT(cond, msg)` exists. `IM_COL32(r,g,b,a)`, `ImMax/ImMin/ImClamp/ImLerp/ImFloor/ImTrunc` etc. exist.
|
||||
- Keep function names identical to upstream (`ImGui.TableNextRow`, `ImGui.TabBarLayout`, ...). Internal helpers
|
||||
that are `static` in C++ become `local function` in the file.
|
||||
- Roblox can't do: OS clipboard (internal only), files/URLs (VFS `ImStd.ImFileLoadToMemory` only), multi-viewports,
|
||||
gamepad. Port the code paths anyway where cheap; demo shows "(not supported on Roblox)" text instead of
|
||||
widgets that cannot work.
|
||||
|
||||
## Testing
|
||||
- `python3 tools/test/run.py` — runs main.client.luau 400 frames with scripted random input (mouse, clicks,
|
||||
keys, typing). Errors print with `imgui/<file>.lua:<line>` mapped stack traces.
|
||||
- `--open` forces every CollapsingHeader/TreeNode open (exercises the whole demo). `--big` makes the demo
|
||||
window 900x1000. `--png out.png` writes a screenshot (Read the PNG to look at it). `--check` asserts the
|
||||
incremental renderer is pixel identical to a full redraw. `--main file.luau` runs your own test LocalScript
|
||||
(copy main.client.luau as a template: `local G = require(ReplicatedStorage:WaitForChild("ImGui"))`, all
|
||||
library globals are fields of `G`).
|
||||
- `python3 tools/test/missing.py [tables|widgets|imgui.cpp]` lists upstream functions not yet ported.
|
||||
- Several people edit the repo concurrently. Keep every file syntactically valid after each edit. If a run fails
|
||||
because of an error in a file you don't own, wait a minute and retry instead of editing it.
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lists upstream ImGui:: functions (tools/test/upstream/*.cpp, docking branch) that the Lua port doesn't define.
|
||||
python3 tools/test/missing.py # summary per upstream file
|
||||
python3 tools/test/missing.py tables # names missing from imgui_tables.cpp
|
||||
"""
|
||||
import os, re, sys
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
port = set()
|
||||
for f in os.listdir(os.path.join(ROOT, "imgui")):
|
||||
if f.endswith(".lua"):
|
||||
port.update(re.findall(r"function ImGui[.:](\w+)", open(os.path.join(ROOT, "imgui", f)).read()))
|
||||
filt = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
for f in sorted(os.listdir(os.path.join(HERE, "upstream"))):
|
||||
if not f.endswith(".cpp") or f == "imgui_demo.cpp": continue
|
||||
if filt and filt not in f: continue
|
||||
src = open(os.path.join(HERE, "upstream", f)).read()
|
||||
names = sorted(set(re.findall(r"^[\w:<>*& ]+?\bImGui::(\w+)\(", src, re.M)))
|
||||
miss = [n for n in names if n not in port]
|
||||
print("%-20s %4d/%4d ported, missing %d" % (f, len(names) - len(miss), len(names), len(miss)))
|
||||
if filt: print(" " + "\n ".join(miss))
|
||||
@@ -0,0 +1,245 @@
|
||||
-- Minimal Roblox mock for running the imgui Roblox backend + the real CanvasDraw module in the Luau CLI.
|
||||
local realG = {} -- (no getfenv: it would mark the global env unsafe and deoptimize everything)
|
||||
FAKE_TIME = 0
|
||||
local SCREEN_W, SCREEN_H = 1920, 1080
|
||||
local NFRAMES = tonumber(ARG_FRAMES) or 400
|
||||
local FORCE = ARG_FORCE == true
|
||||
local DUMP_FRAMES = { [60] = true, [200] = true, [NFRAMES] = true }
|
||||
|
||||
local function Signal()
|
||||
local s = { conns = {} }
|
||||
function s:Connect(fn)
|
||||
local c = { fn = fn, Connected = true }
|
||||
function c:Disconnect() c.Connected = false end
|
||||
table.insert(s.conns, c)
|
||||
return c
|
||||
end
|
||||
function s:Fire(...) for _, c in ipairs(s.conns) do if c.Connected then c.fn(...) end end end
|
||||
return s
|
||||
end
|
||||
|
||||
local V2 = {}
|
||||
V2.__index = V2
|
||||
local function v2(x, y) return setmetatable({ X = x or 0, Y = y or 0 }, V2) end
|
||||
V2.__add = function(a, b) return v2(a.X + b.X, a.Y + b.Y) end
|
||||
V2.__sub = function(a, b) return v2(a.X - b.X, a.Y - b.Y) end
|
||||
V2.__mul = function(a, b) if type(b) == "number" then return v2(a.X * b, a.Y * b) end return v2(a.X * b.X, a.Y * b.Y) end
|
||||
V2.__div = function(a, b) if type(b) == "number" then return v2(a.X / b, a.Y / b) end return v2(a.X / b.X, a.Y / b.Y) end
|
||||
V2.__eq = function(a, b) return a.X == b.X and a.Y == b.Y end
|
||||
local Vector2 = { new = v2, zero = v2(0, 0), one = v2(1, 1) }
|
||||
|
||||
-- Enums: KeyCode values as in Roblox; unknown KeyCode names error (catches typos in the backend)
|
||||
local KEYCODES = {
|
||||
Unknown = 0, Backspace = 8, Tab = 9, Return = 13, Escape = 27, Space = 32, Quote = 39, Comma = 44, Minus = 45, Period = 46, Slash = 47,
|
||||
Semicolon = 59, Equals = 61, LeftBracket = 91, BackSlash = 92, RightBracket = 93, Backquote = 96, Delete = 127,
|
||||
KeypadPeriod = 266, KeypadDivide = 267, KeypadMultiply = 268, KeypadMinus = 269, KeypadPlus = 270, KeypadEnter = 271, KeypadEquals = 272,
|
||||
Up = 273, Down = 274, Right = 275, Left = 276, Insert = 277, Home = 278, End = 279, PageUp = 280, PageDown = 281,
|
||||
CapsLock = 301, RightShift = 303, LeftShift = 304, RightControl = 305, LeftControl = 306, RightAlt = 307, LeftAlt = 308,
|
||||
LeftSuper = 311, RightSuper = 312,
|
||||
}
|
||||
local digits = { "Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine" }
|
||||
for i = 0, 9 do KEYCODES[digits[i + 1]] = 48 + i; KEYCODES["Keypad" .. digits[i + 1]] = 256 + i end
|
||||
for i = 0, 25 do KEYCODES[string.char(65 + i)] = 97 + i end
|
||||
for i = 1, 15 do KEYCODES["F" .. i] = 281 + i end
|
||||
|
||||
local Enum = setmetatable({}, { __index = function(t, enum_name)
|
||||
local e = setmetatable({}, { __index = function(e, item)
|
||||
local value = 0
|
||||
if enum_name == "KeyCode" then value = KEYCODES[item]; assert(value, "invalid KeyCode " .. item) end
|
||||
local it = { Name = item, Value = value, EnumType = enum_name }
|
||||
rawset(e, item, it)
|
||||
return it
|
||||
end })
|
||||
rawset(t, enum_name, e)
|
||||
return e
|
||||
end })
|
||||
|
||||
-- Instances
|
||||
local InstMethods = {}
|
||||
local InstMT = {}
|
||||
InstMT.__index = function(t, k)
|
||||
if k == "Parent" then return rawget(t, "_parent") end
|
||||
local m = InstMethods[k]; if m then return m end
|
||||
for _, c in ipairs(rawget(t, "_children")) do if c.Name == k then return c end end
|
||||
return nil
|
||||
end
|
||||
InstMT.__newindex = function(t, k, v)
|
||||
if k == "Parent" then
|
||||
local old = rawget(t, "_parent")
|
||||
if old then local ch = rawget(old, "_children"); for i, c in ipairs(ch) do if c == t then table.remove(ch, i) break end end end
|
||||
rawset(t, "_parent", v)
|
||||
if v then table.insert(rawget(v, "_children"), t) end
|
||||
else
|
||||
rawset(t, k, v)
|
||||
end
|
||||
end
|
||||
local function newInst(class, name, parent)
|
||||
local o = setmetatable({ ClassName = class, Name = name or class, _children = {} }, InstMT)
|
||||
if class == "ScreenGui" or class == "Frame" or class == "ImageLabel" then o.AbsoluteSize = v2(SCREEN_W, SCREEN_H) end
|
||||
o.Visible = true
|
||||
if class == "BindableEvent" then local sig = Signal(); rawset(o, "Event", sig); rawset(o, "Fire", function(_, ...) sig:Fire(...) end) end
|
||||
if parent then o.Parent = parent end
|
||||
return o
|
||||
end
|
||||
function InstMethods:FindFirstChild(name, recursive)
|
||||
for _, c in ipairs(rawget(self, "_children")) do if c.Name == name then return c end end
|
||||
if recursive then for _, c in ipairs(rawget(self, "_children")) do local r = c:FindFirstChild(name, true); if r then return r end end end
|
||||
return nil
|
||||
end
|
||||
function InstMethods:WaitForChild(name) return assert(self:FindFirstChild(name), "WaitForChild would hang: " .. name) end
|
||||
function InstMethods:FindFirstChildOfClass(class) for _, c in ipairs(rawget(self, "_children")) do if c.ClassName == class then return c end end end
|
||||
function InstMethods:GetChildren() return table.clone(rawget(self, "_children")) end
|
||||
function InstMethods:IsA(class)
|
||||
if class == "GuiObject" then return self.ClassName == "Frame" or self.ClassName == "ImageLabel" end
|
||||
return self.ClassName == class
|
||||
end
|
||||
function InstMethods:Destroy() self.Parent = nil end
|
||||
|
||||
-- Services
|
||||
local mouse = v2(0, 0)
|
||||
local keys_down = {}
|
||||
local UIS = {
|
||||
InputBegan = Signal(), InputEnded = Signal(), InputChanged = Signal(), WindowFocused = Signal(), WindowFocusReleased = Signal(),
|
||||
}
|
||||
function UIS:GetMouseLocation() return mouse end
|
||||
function UIS:IsKeyDown(kc) return keys_down[kc] == true end
|
||||
|
||||
local RunService = { Heartbeat = Signal(), Stepped = Signal(), PreAnimation = Signal(), RenderStepped = Signal() }
|
||||
local last_pixels -- last buffer written to an EditableImage
|
||||
local AssetService = {}
|
||||
function AssetService:CreateEditableImage(opts)
|
||||
local img = { Size = opts.Size, Pixels = buffer.create(opts.Size.X * opts.Size.Y * 4) }
|
||||
UPLOADS = 0
|
||||
function img:WritePixelsBuffer(origin, size, buf) buffer.copy(img.Pixels, 0, buf); _G_UPLOADS = (_G_UPLOADS or 0) + 1 end
|
||||
function img:Destroy() end
|
||||
return img
|
||||
end
|
||||
local ReplicatedStorage = newInst("ReplicatedStorage")
|
||||
local PlayerGui = newInst("PlayerGui")
|
||||
local LocalPlayer = newInst("Player"); PlayerGui.Parent = LocalPlayer
|
||||
local services = {
|
||||
UserInputService = UIS, RunService = RunService, AssetService = AssetService, ReplicatedStorage = ReplicatedStorage,
|
||||
Players = { LocalPlayer = LocalPlayer }, HttpService = {}, GuiService = { GetGuiInset = function() return v2(0, 0), v2(0, 0) end },
|
||||
}
|
||||
local game = {}
|
||||
function game:GetService(name) services[name] = services[name] or {}; return services[name] end
|
||||
|
||||
local fakeG = {} -- Roblox's shared _G
|
||||
local cache = {}
|
||||
local ROBLOX
|
||||
local function makeEnv(inst) return setmetatable({ script = inst }, { __index = ROBLOX }) end
|
||||
local GLOBAL_NAMES = { "game", "workspace", "Instance", "Enum", "Vector2", "Vector3", "UDim2", "Color3", "Content", "warn", "require", "script", "os" }
|
||||
-- Load like Roblox does but WITHOUT setfenv (which would disable Luau optimizations): Roblox globals become chunk locals.
|
||||
local function loadAsScript(src_name, inst)
|
||||
local fn = SOURCE_FNS[src_name]
|
||||
local vals = {}
|
||||
for i, n in ipairs(GLOBAL_NAMES) do vals[i] = (n == "script") and inst or ROBLOX[n] end
|
||||
return function() return fn(table.unpack(vals, 1, #GLOBAL_NAMES)) end
|
||||
end
|
||||
local function rbxrequire(inst)
|
||||
assert(type(inst) == "table" and inst.ClassName == "ModuleScript", "require() expects a ModuleScript")
|
||||
if cache[inst] then return cache[inst].v end
|
||||
local r = table.pack(loadAsScript(rawget(inst, "_src"), inst)())
|
||||
assert(r.n == 1, "Module code did not return exactly one value")
|
||||
cache[inst] = { v = r[1] }
|
||||
return r[1]
|
||||
end
|
||||
|
||||
ROBLOX = setmetatable({
|
||||
game = game, workspace = { CurrentCamera = { ViewportSize = v2(SCREEN_W, SCREEN_H) } }, require = rbxrequire, _G = fakeG,
|
||||
Instance = { new = function(class) return newInst(class) end },
|
||||
Enum = Enum, Vector2 = Vector2,
|
||||
Vector3 = { new = function(x, y, z) return { X = x, Y = y, Z = z } end },
|
||||
UDim2 = { new = function(...) return { ... } end, fromScale = function(...) return { ... } end, fromOffset = function(x, y) return { X = x, Y = y } end },
|
||||
Color3 = { new = function(r, g, b) return { R = r or 0, G = g or 0, B = b or 0 } end },
|
||||
Content = { fromObject = function(o) return { Object = o } end },
|
||||
warn = function(...) print("WARN", ...) end,
|
||||
os = { clock = function() return FAKE_TIME end, time = os.time },
|
||||
}, { __index = realG })
|
||||
|
||||
local mod = newInst("ModuleScript", "ImGui", ReplicatedStorage); rawset(mod, "_src", "ImGui.luau")
|
||||
-- Run main.lua as a LocalScript
|
||||
local main = loadAsScript("main.client.luau", newInst("LocalScript", "main"))
|
||||
main()
|
||||
local G = cache[mod].v
|
||||
local render_time = 0
|
||||
local realDemo = G.ImGui.ShowDemoWindow
|
||||
local realCH = G.ImGui.CollapsingHeader
|
||||
G.ImGui.CollapsingHeader = function(...)
|
||||
if ARG_OPEN then G.ImGui.SetNextItemOpen(true, G.ImGuiCond.Always) end
|
||||
return realCH(...)
|
||||
end
|
||||
local realTN = G.ImGui.TreeNode
|
||||
G.ImGui.TreeNode = function(...)
|
||||
if ARG_OPEN then G.ImGui.SetNextItemOpen(true, G.ImGuiCond.Always) end
|
||||
return realTN(...)
|
||||
end
|
||||
G.ImGui.ShowDemoWindow = function(...)
|
||||
if ARG_BIG then G.ImGui.SetNextWindowSize(G.ImVec2(900, 1000), G.ImGuiCond.Always) end
|
||||
return realDemo(...)
|
||||
end
|
||||
local realRender = G.ImGui_ImplRoblox.RenderDrawData
|
||||
G.ImGui_ImplRoblox.RenderDrawData = function(dd)
|
||||
if FORCE then G.ImGui.GetIO().BackendPlatformUserData.ForceRedraw = true end
|
||||
local t0 = os.clock(); realRender(dd); render_time = render_time + os.clock() - t0
|
||||
end
|
||||
|
||||
-- Drive frames with scripted (deterministic) input
|
||||
local seed = 12345
|
||||
local function rnd(n) seed = (seed * 1103515245 + 12345) % 2147483648; return seed % n end
|
||||
local function input(t, kc, pos) return { UserInputType = Enum.UserInputType[t], KeyCode = Enum.KeyCode[kc or "Unknown"], Position = pos or { X = 0, Y = 0, Z = 0 } } end
|
||||
local held = false
|
||||
local typing = { "H", "E", "L", "L", "O", "Space", "One", "Two", "Minus", "Backspace", "Left", "Return" }
|
||||
local hex = {}
|
||||
for i = 0, 255 do hex[i] = string.format("%02x", i) end
|
||||
|
||||
local total = 0
|
||||
for frame = 1, NFRAMES do
|
||||
local r = rnd(100)
|
||||
if r < 40 then
|
||||
mouse = v2(math.clamp(mouse.X + rnd(81) - 40, 0, SCREEN_W - 1), math.clamp(mouse.Y + rnd(81) - 40, 0, SCREEN_H - 1))
|
||||
elseif r < 55 then
|
||||
held = not held
|
||||
if held then UIS.InputBegan:Fire(input("MouseButton1"), false) else UIS.InputEnded:Fire(input("MouseButton1"), false) end
|
||||
elseif r < 60 then
|
||||
UIS.InputChanged:Fire(input("MouseWheel", nil, { X = 0, Y = 0, Z = rnd(3) - 1 }), false)
|
||||
elseif r < 75 then
|
||||
local k = typing[rnd(#typing) + 1]
|
||||
local shift = rnd(4) == 0
|
||||
if shift then keys_down[Enum.KeyCode.LeftShift] = true; UIS.InputBegan:Fire(input("Keyboard", "LeftShift"), false) end
|
||||
UIS.InputBegan:Fire(input("Keyboard", k), false)
|
||||
UIS.InputEnded:Fire(input("Keyboard", k), false)
|
||||
if shift then keys_down[Enum.KeyCode.LeftShift] = nil; UIS.InputEnded:Fire(input("Keyboard", "LeftShift"), false) end
|
||||
elseif r < 90 then
|
||||
mouse = v2(rnd(700), rnd(500))
|
||||
end
|
||||
|
||||
FAKE_TIME = frame / 60
|
||||
local t0 = os.clock()
|
||||
RunService.Stepped:Fire(frame / 60, 1 / 60)
|
||||
local dt = os.clock() - t0
|
||||
if frame > 10 then total = total + dt end
|
||||
|
||||
if frame == 10 then render_time = 0; _G_UPLOADS = 0 end
|
||||
if DUMP_FRAMES[frame] and ARG_DUMP then
|
||||
local W, H = SCREEN_W, SCREEN_H
|
||||
local screen = buffer.create(W * H * 4)
|
||||
local gui = PlayerGui:FindFirstChild("ImGui")
|
||||
for _, label in ipairs(gui:GetChildren()) do
|
||||
if label.Visible then
|
||||
local img, px, py = label.ImageContent.Object, label.Position.X, label.Position.Y
|
||||
local tw, th = img.Size.X, img.Size.Y
|
||||
for y = 0, th - 1 do
|
||||
if py + y < H then
|
||||
local n = math.min(tw, W - px)
|
||||
buffer.copy(screen, ((py + y) * W + px) * 4, img.Pixels, y * tw * 4, n * 4)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local parts = {}
|
||||
for i = 0, W * H * 4 - 1 do parts[#parts + 1] = hex[buffer.readu8(screen, i)] end
|
||||
print(string.format("IMG %d %d %d %s", frame, W, H, table.concat(parts)))
|
||||
end
|
||||
end
|
||||
print(string.format("avg frame total %.2f ms, of which render %.2f ms, uploads/frame %.2f", total / (NFRAMES - 10) * 1000, render_time / (NFRAMES - 10) * 1000, _G_UPLOADS / (NFRAMES - 10)))
|
||||
+36490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless test harness: runs build/ImGui.luau + a main script in the Luau CLI against a mocked Roblox
|
||||
(roblox_mock.luau). Rebuilds the bundle first.
|
||||
|
||||
python3 tools/test/run.py # 400 frames of main.client.luau, scripted random input
|
||||
python3 tools/test/run.py --open # force every CollapsingHeader/TreeNode open (exercises whole demo)
|
||||
python3 tools/test/run.py --big --open --bench # timing, best of 5
|
||||
python3 tools/test/run.py --check # incremental vs forced redraw must be pixel identical
|
||||
python3 tools/test/run.py --png out.png # screenshot of the last frame
|
||||
python3 tools/test/run.py --main my_test.luau # different LocalScript
|
||||
"""
|
||||
import argparse, os, shutil, subprocess, sys, zlib, struct, tempfile
|
||||
TMP = tempfile.mkdtemp(prefix="imgui_test_") # per process: parallel runs must not share files
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
LUAU = shutil.which("luau") or "/nix/store/2m2ja613nndihc8qm10f2rhq6znp1nh4-luau-0.734/bin/luau"
|
||||
G = "game, workspace, Instance, Enum, Vector2, Vector3, UDim2, Color3, Content, warn, require, script, os"
|
||||
|
||||
def build_run(args, flags):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT, "tools", "bundle.py"), TMP], check=True, stdout=subprocess.DEVNULL)
|
||||
out = ["--!native", "--!optimize 2", flags, "local SOURCE_FNS = {}"]
|
||||
def add(key, path):
|
||||
s = "\n".join(l for l in open(path).read().split("\n") if not l.startswith("--!"))
|
||||
out.append("SOURCE_FNS[%r] = function(%s)\n%s\nend" % (key, G, s))
|
||||
add("ImGui.luau", os.path.join(TMP, "ImGui.luau"))
|
||||
add("main.client.luau", args.main)
|
||||
out.append(open(os.path.join(HERE, "roblox_mock.luau")).read())
|
||||
global OFFSET, LAST_LINE
|
||||
LAST_LINE = open(os.path.join(TMP, "ImGui.luau")).read().count("\n")
|
||||
OFFSET = 5 # lines before the ImGui function body in run.luau (flags/header lines + "function(...)" line)
|
||||
path = os.path.join(TMP, "run.luau")
|
||||
open(path, "w").write("\n".join(out))
|
||||
return path
|
||||
|
||||
def run(args, extra=""):
|
||||
flags = "ARG_FRAMES=%d; ARG_BIG=%s; ARG_OPEN=%s; %s" % (args.frames, str(args.big).lower(), str(args.open).lower(), extra)
|
||||
try:
|
||||
p = subprocess.run([LUAU, "-O2", "--codegen", build_run(args, flags)], capture_output=True, text=True, timeout=args.timeout)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return 124, "TIMEOUT (infinite loop?)\n" + remap((e.stdout or b"").decode(errors="replace"))
|
||||
return p.returncode, remap(p.stdout + p.stderr)
|
||||
|
||||
def remap(text):
|
||||
"""run.luau:N -> imgui/<file>.lua:M"""
|
||||
import json, re
|
||||
lm = json.load(open(os.path.join(TMP, "ImGui.linemap.json")))
|
||||
def sub(m):
|
||||
n = int(m.group(1)) - OFFSET # line inside build/ImGui.luau (minus its 2 stripped --! lines)
|
||||
n += 2
|
||||
best = None
|
||||
if n > LAST_LINE: return "<harness>:%d" % n
|
||||
for start, name in lm:
|
||||
if start <= n: best = (start, name)
|
||||
if not best: return m.group(0)
|
||||
return "%s:%d" % (best[1], n - best[0] + 1)
|
||||
return re.sub(r"\S*run\.luau:(\d+)", sub, text)
|
||||
|
||||
def images(out):
|
||||
return [l.split() for l in out.split("\n") if l.startswith("IMG ")]
|
||||
|
||||
def write_png(img, path, crop=None):
|
||||
_, fr, w, h, hx = img; w, h = int(w), int(h); px = bytes.fromhex(hx)
|
||||
W, H = crop or (w, h)
|
||||
raw = bytearray()
|
||||
for y in range(H):
|
||||
raw.append(0)
|
||||
for x in range(W):
|
||||
o = (y * w + x) * 4; r, g, b, a = px[o:o + 4]; k = a / 255
|
||||
raw += bytes([int(r * k + 90 * (1 - k)), int(g * k + 90 * (1 - k)), int(b * k + 90 * (1 - k))])
|
||||
def chunk(t, d): return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff)
|
||||
open(path, 'wb').write(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', W, H, 8, 2, 0, 0, 0))
|
||||
+ chunk(b'IDAT', zlib.compress(bytes(raw))) + chunk(b'IEND', b''))
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--frames", type=int, default=400)
|
||||
ap.add_argument("--main", default=os.path.join(ROOT, "main.client.luau"))
|
||||
ap.add_argument("--big", action="store_true", help="demo window forced to 900x1000")
|
||||
ap.add_argument("--open", action="store_true", help="force all CollapsingHeader/TreeNode open")
|
||||
ap.add_argument("--bench", action="store_true")
|
||||
ap.add_argument("--check", action="store_true")
|
||||
ap.add_argument("--png")
|
||||
ap.add_argument("--crop", default="1100x1080")
|
||||
ap.add_argument("--timeout", type=int, default=120)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.bench:
|
||||
for mode in ["", "ARG_FORCE=true"]:
|
||||
best = None
|
||||
for _ in range(5):
|
||||
code, out = run(args, mode)
|
||||
if code: print(out[-3000:]); sys.exit(1)
|
||||
line = [l for l in out.split("\n") if l.startswith("avg frame")][-1]
|
||||
if best is None or float(line.split()[3]) < float(best.split()[3]): best = line
|
||||
print("%-12s %s" % (mode or "incremental", best))
|
||||
return
|
||||
if args.check:
|
||||
code, a = run(args, "ARG_DUMP=true")
|
||||
code2, b = run(args, "ARG_DUMP=true; ARG_FORCE=true")
|
||||
if code or code2: print((a if code else b)[-3000:]); sys.exit(1)
|
||||
ok = [x[4] for x in images(a)] == [x[4] for x in images(b)]
|
||||
print("PIXELS-IDENTICAL" if ok else "PIXELS-DIFFER"); sys.exit(0 if ok else 1)
|
||||
|
||||
code, out = run(args, "ARG_DUMP=true" if args.png else "")
|
||||
print("\n".join(l if not l.startswith("IMG ") else l[:40] + "..." for l in out.split("\n"))[-4000:])
|
||||
if args.png and images(out):
|
||||
write_png(images(out)[-1], args.png, tuple(map(int, args.crop.split("x"))))
|
||||
print("wrote", args.png)
|
||||
sys.exit(code)
|
||||
|
||||
try:
|
||||
main()
|
||||
finally:
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static check: ImGui.X / ImGui:X / global ImFoo() calls in imgui/*.lua that nothing defines.
|
||||
python3 tools/test/undefined.py
|
||||
"""
|
||||
import os, re, collections
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
SRC = os.path.join(ROOT, "imgui")
|
||||
files = {}
|
||||
for dp, _, fn in os.walk(SRC):
|
||||
for f in fn:
|
||||
if f.endswith(".lua"):
|
||||
files[os.path.relpath(os.path.join(dp, f), SRC)] = open(os.path.join(dp, f)).read()
|
||||
|
||||
def strip(s): # drop comments and strings (roughly) so we don't report text
|
||||
s = re.sub(r"--\[(=*)\[.*?\]\1\]", "", s, flags=re.S)
|
||||
s = re.sub(r"\[(=*)\[.*?\]\1\]", '""', s, flags=re.S)
|
||||
s = re.sub(r'"(\\.|[^"\\\n])*"', '""', s)
|
||||
s = re.sub(r"'(\\.|[^'\\\n])*'", "''", s)
|
||||
return re.sub(r"--[^\n]*", "", s)
|
||||
|
||||
defined_imgui, defined_global, used = set(), set(), collections.defaultdict(list)
|
||||
for name, src in files.items():
|
||||
s = strip(src)
|
||||
defined_imgui.update(re.findall(r"function\s+ImGui[.:](\w+)", s))
|
||||
defined_imgui.update(re.findall(r"\bImGui\.(\w+)\s*=[^=]", s))
|
||||
defined_global.update(re.findall(r"^\s*function\s+([A-Za-z_]\w*)\s*\(", s, re.M))
|
||||
defined_global.update(re.findall(r"^\s*([A-Za-z_]\w*)\s*=[^=]", s, re.M))
|
||||
defined_global.update(re.findall(r"^\s*local\s+function\s+([A-Za-z_]\w*)", s, re.M))
|
||||
defined_global.update(re.findall(r"^\s*local\s+([A-Za-z_][\w, ]*)", s, re.M) and
|
||||
[x.strip() for l in re.findall(r"^\s*local\s+([A-Za-z_][\w, ]*)", s, re.M) for x in l.split(",")])
|
||||
for i, line in enumerate(s.split("\n"), 1):
|
||||
for m in re.findall(r"\bImGui\.(\w+)\s*\(", line):
|
||||
used[("ImGui." + m, m, True)].append("%s:%d" % (name, i))
|
||||
for m in re.findall(r"(?<![.:\w])(Im[A-Z]\w*)\s*\(", line):
|
||||
used[(m, m, False)].append("%s:%d" % (name, i))
|
||||
|
||||
missing = [(k, v) for k, v in used.items() if (k[1] not in defined_imgui if k[2] else k[1] not in defined_global)]
|
||||
for (full, _, _), locs in sorted(missing):
|
||||
print("%-45s %d uses, e.g. %s" % (full, len(locs), ", ".join(locs[:3])))
|
||||
print("total undefined:", len(missing))
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user