Files
4DBug 81c5ad9e43 Roblox backend: hidden TextBox proxy for text input
A single off-screen TextBox holds focus while an imgui text field is active, so the OS
delivers typing (any layout/unicode), paste and copy/cut. Its text mirrors imgui selection
(fully selected) so Ctrl+C/X copy exactly that; deletions apply as an imgui Backspace;
Ctrl is released around pasted characters. Focus is captured after the click is released,
kept on clicks inside imgui, ends on clicks outside. KeyCode text kept as fallback.
SetTextProxyDebug for logging.
2026-10-08 19:31:22 -05:00

249 lines
13 KiB
Luau

debug = setmetatable({ profilebegin = function() end, profileend = function() end }, { __index = debug })
-- 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 pv = rawget(t, "_props")[k]; if pv ~= nil then return pv 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
local props = rawget(t, "_props")
local old = props[k]
props[k] = v
local sigs = rawget(t, "_propsigs")
if sigs and sigs[k] and old ~= v then sigs[k]:Fire() end
end
end
local function newInst(class, name, parent)
local o = setmetatable({ _children = {}, _props = { ClassName = class, Name = name or class } }, InstMT)
if class == "ScreenGui" or class == "Frame" or class == "ImageLabel" then o.AbsoluteSize = v2(SCREEN_W, SCREEN_H) end
o.Visible = true
if class == "TextBox" then rawget(o, "_props").FocusLost = Signal() end
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
local focused_box = nil
function InstMethods:GetPropertyChangedSignal(prop)
local sigs = rawget(self, "_propsigs") or {}; rawset(self, "_propsigs", sigs)
sigs[prop] = sigs[prop] or Signal(); return sigs[prop]
end
function InstMethods:CaptureFocus() focused_box = self end
function InstMethods:_loseFocus() if focused_box == self then focused_box = nil; local fl = rawget(self, "_props").FocusLost; if fl then fl:Fire(false) end end end
function InstMethods:ReleaseFocus() self:_loseFocus() 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:GetFocusedTextBox() return focused_box 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)
assert(buffer.len(buf) == size.X * size.Y * 4, "WritePixelsBuffer: buffer size mismatch")
assert(origin.X >= 0 and origin.Y >= 0 and origin.X + size.X <= opts.Size.X and origin.Y + size.Y <= opts.Size.Y, "WritePixelsBuffer: out of bounds")
for y = 0, size.Y - 1 do buffer.copy(img.Pixels, ((origin.Y + y) * opts.Size.X + origin.X) * 4, buf, y * size.X * 4, size.X * 4) end
_G_UPLOADS = (_G_UPLOADS or 0) + 1; _G_UPLOAD_PX = (_G_UPLOAD_PX or 0) + size.X * size.Y
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", "UDim", "CFrame", "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 },
UDim = { new = function(s, o) return { Scale = s, Offset = o } end },
CFrame = { new = function(...) return { ... } end, lookAt = function(...) return { ... } end, Angles = function(...) return { ... } end },
Content = { fromObject = function(o) return { Object = o } end },
warn = function(...) print("WARN", ...) end,
os = { clock = function() return FAKE_TIME end, time = os.time, realclock = os.clock },
}, { __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 == true or (type(ARG_OPEN) == "table" and 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 == true or (type(ARG_OPEN) == "table" and ARG_OPEN[(...)]) then G.ImGui.SetNextItemOpen(true, G.ImGuiCond.Always) end
return realTN(...)
end
if realDemo then G.ImGui.ShowDemoWindow = function(...)
if ARG_BIG then G.ImGui.SetNextWindowSize(G.ImVec2(900, 1000), G.ImGuiCond.Always) end
return realDemo(...)
end 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 = ARG_QUIET and 100 or 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
end
print(string.format("avg frame total %.2f ms, of which render %.2f ms, uploads/frame %.2f, upload KB/frame %.0f", total / (NFRAMES - 10) * 1000, render_time / (NFRAMES - 10) * 1000, _G_UPLOADS / (NFRAMES - 10), (_G_UPLOAD_PX or 0) * 4 / 1024 / (NFRAMES - 10)))