--[[
  Arctecto VFX: an effect editor for Roblox Studio.

  An effect is a Model ("vfxEffect" attribute) with an invisible Root part; its layers
  are ParticleEmitters, lights and shakes under Root. The timing lives in attributes on
  each layer (see VFXPlayer), so what this panel edits is plain Roblox: the Properties
  panel still edits every other setting, and the game plays the effect with one line,
  VFXPlayer.play(effect, where).

  The panel: layers on a timeline (drag to move, drag the right edge to lengthen),
  Play with slow motion and loop, a curve editor for size and transparency, flipbooks
  from the Houdini HDA's JSON, presets, and the runtime installed into the place.

  Built by build.py: the VFXPlayer source is pasted below, so the preview and the game
  run the same code.
]]

local VFX_SOURCE = [=====[--[[
  VFXPlayer: plays the effects made with the Arctecto VFX plugin.

  An effect is a plain Model whose PrimaryPart is "Root" (an invisible anchored part).
  Its layers are the ParticleEmitters, lights and shakes under Root; each one carries
  its own timing in attributes, so the effect stays ordinary Roblox and keeps working
  without the plugin:

    vfxStart     seconds after play when the layer fires
    vfxMode      "Burst" or "Stream" (emitters)
    vfxCount     particles a Burst emits
    vfxDuration  seconds a Stream, a light flash or a shake lasts
    vfxPeak      a light's brightness at the flash / a shake's strength in degrees
    vfxFlicker   a light's flicker, 0..1
    vfxRadius    a shake's reach in studs (full at the centre, nothing at the edge)
    vfxMute      true: the layer does not play
    vfxOrder     its row in the plugin's timeline

  A Mesh layer (a MeshPart with vfxType "Mesh", from the VFX Lab's ribbons) shows from vfxStart
  for vfxDuration, turning about the Root's up axis and stepping its flipbook by swapping
  TextureID through uploaded frames (no EditableImage: that needs the experience's Security
  setting, measured 2026-10-30):
    vfxFrames    the frames' ids, "rbxassetid://1,rbxassetid://2,..."
    vfxFps       frames per second
    vfxSpin      degrees per second about the Root's Y
    vfxCenter    the part's centre in the Root's space (the turn goes round the Root)
    vfxOpacity   0..1 at full;  vfxFade  seconds of fade in and out

  Client:  VFX.play(effectOrName, where, opts) -> handle
           handle:Release() (hold) / :Pause() / :Resume() / :SetSpeed(s) / :Stop(),
           handle.time, handle.length, handle.Finished
  Server:  VFX.fire(name, where, opts)  every client plays ReplicatedStorage.VFXEffects[name]
  Client:  VFX.listen()  once; the VFXClient LocalScript the plugin installs does it

  where: CFrame | Vector3 | BasePart | Attachment | Model (nil = where the effect stands)
  opts:  scale (1), timeScale (1, below 1 is slow motion), follow (stick to the part),
         hold (Streams and lights stay on until handle:Release()),
         parent (workspace), players (fire only: who sees it)
]]

local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local VFX = {}
VFX.version = "0.2.0"

local REMOTE_NAME = "VFXRemote"
local LIBRARY_NAME = "VFXEffects"
local LIGHT_RANGE_MAX = 60

-- what a layer is, from the instance carrying the timing
local function kindOf(o: Instance): string?
	if o:IsA("ParticleEmitter") then
		return "Particles"
	elseif o:IsA("Light") then
		return "Light"
	elseif o:IsA("Configuration") and o:GetAttribute("vfxType") == "Shake" then
		return "Shake"
	elseif o:IsA("MeshPart") and o:GetAttribute("vfxType") == "Mesh" then
		return "Mesh"
	end
	return nil
end
VFX.kindOf = kindOf

-- a Mesh layer's frames, in order
local function framesOf(o: Instance): { string }
	local list = {}
	for id in string.gmatch(o:GetAttribute("vfxFrames") or "", "[^,%s]+") do
		table.insert(list, id)
	end
	return list
end
VFX.framesOf = framesOf

function VFX.layersOf(effect: Instance): { Instance }
	local list = {}
	for _, d in ipairs(effect:GetDescendants()) do
		if d:GetAttribute("vfxStart") ~= nil and kindOf(d) then
			table.insert(list, d)
		end
	end
	table.sort(list, function(a, b)
		return (a:GetAttribute("vfxOrder") or 0) < (b:GetAttribute("vfxOrder") or 0)
	end)
	return list
end

-- the moment a layer stops showing anything (particles live past their emitter)
function VFX.layerEnd(o: Instance): number
	local s = o:GetAttribute("vfxStart") or 0
	if o:IsA("ParticleEmitter") then
		local life = o.Lifetime.Max
		if (o:GetAttribute("vfxMode") or "Burst") == "Stream" then
			return s + (o:GetAttribute("vfxDuration") or 1) + life
		end
		return s + life
	end
	return s + (o:GetAttribute("vfxDuration") or 0.3)
end

function VFX.duration(effect: Instance): number
	local t = 0
	for _, o in ipairs(VFX.layersOf(effect)) do
		if not o:GetAttribute("vfxMute") then
			t = math.max(t, VFX.layerEnd(o))
		end
	end
	return t
end

local function scaleSeq(seq: NumberSequence, k: number): NumberSequence
	local kps = {}
	for _, kp in ipairs(seq.Keypoints) do
		table.insert(kps, NumberSequenceKeypoint.new(kp.Time, kp.Value * k, kp.Envelope * k))
	end
	return NumberSequence.new(kps)
end

local function applyScale(model: Instance, k: number)
	if k == 1 then
		return
	end
	for _, d in ipairs(model:GetDescendants()) do
		if d:IsA("ParticleEmitter") then
			d.Size = scaleSeq(d.Size, k)
			d.Speed = NumberRange.new(d.Speed.Min * k, d.Speed.Max * k)
			d.Acceleration *= k
		elseif d:IsA("Attachment") then
			d.Position *= k
		elseif d:IsA("PointLight") or d:IsA("SpotLight") or d:IsA("SurfaceLight") then
			d.Range = math.min(LIGHT_RANGE_MAX, d.Range * k)
		elseif d:IsA("Configuration") and d:GetAttribute("vfxType") == "Shake" then
			d:SetAttribute("vfxRadius", (d:GetAttribute("vfxRadius") or 60) * k)
		elseif d:IsA("MeshPart") and d:GetAttribute("vfxType") == "Mesh" then
			d.Size *= k
			d:SetAttribute("vfxCenter", (d:GetAttribute("vfxCenter") or Vector3.zero) * k)
		end
	end
end

local function frameOf(where: any): CFrame?
	local t = typeof(where)
	if t == "CFrame" then
		return where
	elseif t == "Vector3" then
		return CFrame.new(where)
	elseif t == "Instance" then
		if where:IsA("Attachment") then
			return where.WorldCFrame
		elseif where:IsA("BasePart") then
			return where.CFrame
		elseif where:IsA("Model") then
			return where:GetPivot()
		end
	end
	return nil
end

-- camera shake, client only, applied after the camera scripts every frame
local shakeCount = 0
local function shake(cfg: Instance, at: Vector3, dur: number)
	local cam = workspace.CurrentCamera
	if not cam or not RunService:IsClient() or not RunService:IsRunning() then
		return
	end
	local peak = cfg:GetAttribute("vfxPeak") or 0.6
	local radius = cfg:GetAttribute("vfxRadius") or 60
	local fall = math.clamp(1 - (cam.CFrame.Position - at).Magnitude / radius, 0, 1)
	if fall <= 0 then
		return
	end
	shakeCount += 1
	local name = "VFXShake" .. shakeCount
	local t0 = os.clock()
	local seed = math.random() * 100
	RunService:BindToRenderStep(name, Enum.RenderPriority.Camera.Value + 1, function()
		local t = (os.clock() - t0) / dur
		if t >= 1 then
			RunService:UnbindFromRenderStep(name)
			return
		end
		local a = peak * fall * (1 - t) * (1 - t)
		local n = (os.clock() - t0) * 28
		cam.CFrame = cam.CFrame
			* CFrame.Angles(math.rad(math.noise(n, seed) * a * 2), math.rad(math.noise(seed, n) * a * 2), math.rad(math.noise(n, n + seed) * a))
	end)
end

function VFX.get(name: string): Instance?
	local lib = ReplicatedStorage:FindFirstChild(LIBRARY_NAME)
	return lib and lib:FindFirstChild(name)
end

function VFX.play(effect: any, where: any?, opts: { [string]: any }?)
	local o = opts or {}
	if type(effect) == "string" then
		local found = VFX.get(effect)
		if not found then
			warn("[VFX] no effect named " .. effect .. " in ReplicatedStorage." .. LIBRARY_NAME)
			return nil
		end
		effect = found
	end
	local ts = math.clamp(o.timeScale or 1, 0.01, 1)
	local clone = effect:Clone()
	clone.Archivable = false
	local root = clone.PrimaryPart or clone:FindFirstChild("Root")
	-- every layer starts silent; the schedule below lights them up
	local layers = VFX.layersOf(clone)
	for _, l in ipairs(layers) do
		if l:IsA("ParticleEmitter") then
			l.Enabled = false
			l.TimeScale = ts
		elseif l:IsA("Light") then
			l.Enabled = true
			l.Brightness = 0
		elseif l:IsA("MeshPart") then
			l.Anchored = true
			l.CanCollide = false
			l.CanQuery = false
			l.CanTouch = false
			l.Transparency = 1
		end
	end
	applyScale(clone, o.scale or 1)
	local cf = frameOf(where)
	if cf then
		clone:PivotTo(cf)
	end
	if o.follow and root and typeof(where) == "Instance" and (where:IsA("BasePart") or where:IsA("Attachment")) then
		local part = where:IsA("Attachment") and where.Parent or where
		if part and part:IsA("BasePart") then
			root.Anchored = false
			root.Massless = true
			local w = Instance.new("WeldConstraint")
			w.Part0 = root
			w.Part1 = part
			w.Parent = root
		end
	end
	clone.Parent = o.parent or workspace

	-- the effect's own clock: events fire as it passes them, so it can pause,
	-- change speed and hold a frame (particles freeze at TimeScale 0)
	-- hold: Streams keep going and lights stay lit until handle:Release() (a flamethrower,
	-- a breath held as long as the button is down); what is already in the air then
	-- finishes on its own
	local handle = { model = clone, alive = true, time = 0, paused = false, speed = ts, holding = o.hold == true }
	local finished = Instance.new("BindableEvent")
	handle.Finished = finished.Event
	local events, lights, emitters, streams, meshes = {}, {}, {}, {}, {}
	local function at(t: number, fn: () -> ())
		table.insert(events, { t = t, fn = fn })
	end
	for _, l in ipairs(layers) do
		if l:IsA("ParticleEmitter") then
			table.insert(emitters, l)
		end
		if not l:GetAttribute("vfxMute") then
			local s = l:GetAttribute("vfxStart") or 0
			local dur = l:GetAttribute("vfxDuration") or 0.3
			local kind = kindOf(l)
			if kind == "Particles" then
				local e = l :: ParticleEmitter
				if (e:GetAttribute("vfxMode") or "Burst") == "Stream" then
					table.insert(streams, e)
					at(s, function()
						-- released before this layer began: it never starts
						if not handle.releasedAt and (handle.holding or handle.time < s + dur) then e.Enabled = true end
					end)
					if not handle.holding then
						at(s + dur, function() e.Enabled = false end)
					end
				else
					at(s, function() e:Emit(e:GetAttribute("vfxCount") or 10) end)
				end
			elseif kind == "Light" then
				table.insert(lights, { light = l, s = s, dur = math.max(dur, 1e-3), peak = l:GetAttribute("vfxPeak") or 5,
					flicker = l:GetAttribute("vfxFlicker") or 0, seed = math.random() * 100 })
			elseif kind == "Shake" then
				at(s, function()
					shake(l, root and root.Position or clone:GetPivot().Position, dur / handle.speed)
				end)
			elseif kind == "Mesh" then
				local frames = framesOf(l)
				table.insert(meshes, { part = l, s = s, dur = math.max(dur, 1e-3), frames = frames, fps = l:GetAttribute("vfxFps") or 16,
					spin = l:GetAttribute("vfxSpin") or 0, center = l:GetAttribute("vfxCenter") or Vector3.zero,
					opacity = math.clamp(l:GetAttribute("vfxOpacity") or 1, 0, 1), fade = math.max(0, l:GetAttribute("vfxFade") or 0.15), shown = nil })
				-- fetch the frames now, so the first pass does not flash blank pictures
				if #frames > 0 then
					task.spawn(function()
						pcall(game:GetService("ContentProvider").PreloadAsync, game:GetService("ContentProvider"), frames)
					end)
				end
			end
		end
	end
	table.sort(events, function(a, b) return a.t < b.t end)
	handle.length = VFX.duration(clone)

	local function rate()
		for _, e in ipairs(emitters) do
			e.TimeScale = handle.paused and 0 or handle.speed
		end
	end
	rate()
	local nextEvent = 1
	local conn
	-- a light flashes to its peak and fades out (quad) over its duration; held, it stays
	-- at its peak (flickering by vfxFlicker) and fades from the release
	local function shine()
		for _, L in ipairs(lights) do
			local b
			if handle.holding or handle.releasedAt then
				local from = handle.releasedAt or math.huge
				if handle.time < L.s then
					b = 0
				elseif handle.time < from then
					b = L.peak
				else
					local u = (handle.time - from) / L.dur
					b = u <= 1 and L.peak * (1 - u) * (1 - u) or 0
				end
			else
				local u = (handle.time - L.s) / L.dur
				b = (u >= 0 and u <= 1) and L.peak * (1 - u) * (1 - u) or 0
			end
			if L.flicker > 0 and b > 0 then
				b *= 1 + L.flicker * math.noise(handle.time * 9, L.seed)
			end
			L.light.Brightness = math.max(0, b)
		end
	end
	-- a Mesh layer: hidden outside its span, faded at both ends (held: on until the release),
	-- turned round the Root and stepped through its frames. Transparency never goes below 0.02:
	-- a MeshPart cuts its texture's alpha only above 0 (measured 2026-10-30)
	local function wrap()
		local base = root and root.CFrame or clone:GetPivot()
		for _, M in ipairs(meshes) do
			local u = handle.time - M.s
			local stop = M.dur
			if handle.holding then
				stop = math.huge
			elseif handle.releasedAt then
				stop = math.max(0, handle.releasedAt - M.s) + M.fade
			end
			local a = 0
			if u >= 0 and u <= stop then
				a = M.opacity
				if M.fade > 0 then
					a *= math.clamp(u / M.fade, 0, 1) * math.clamp((stop - u) / M.fade, 0, 1)
				end
			end
			local p = M.part
			p.Transparency = a > 0 and math.max(0.02, 1 - a) or 1
			if a > 0 then
				p.CFrame = base * CFrame.Angles(0, -math.rad(M.spin * u), 0) * CFrame.new(M.center)
				local n = #M.frames
				if n > 0 then
					local k = math.floor(u * M.fps) % n + 1
					if M.shown ~= k then
						M.shown = k
						p.TextureID = M.frames[k]
					end
				end
			end
		end
	end
	local function step(dt: number)
		if not handle.alive then
			return
		end
		if not handle.paused then
			handle.time += dt * handle.speed
		end
		while events[nextEvent] and events[nextEvent].t <= handle.time do
			events[nextEvent].fn()
			nextEvent += 1
		end
		shine()
		wrap()
		if not handle.holding and handle.time >= handle.length + 0.05 and not handle.paused then
			handle:Stop()
		end
	end

	-- stop a held effect: Streams stop emitting, lights fade, the effect ends when the
	-- last particle in the air is gone
	function handle:Release()
		if not handle.holding then
			return
		end
		handle.holding = false
		handle.releasedAt = handle.time
		local tail = 0
		for _, e in ipairs(streams) do
			e.Enabled = false
			tail = math.max(tail, e.Lifetime.Max)
		end
		for _, L in ipairs(lights) do
			tail = math.max(tail, L.dur)
		end
		for _, M in ipairs(meshes) do
			tail = math.max(tail, M.fade)
		end
		handle.length = handle.time + tail
	end

	function handle:Pause()
		handle.paused = true
		rate()
	end
	function handle:Resume()
		handle.paused = false
		rate()
	end
	function handle:SetSpeed(s: number)
		handle.speed = math.clamp(s, 0.01, 1)
		rate()
	end
	function handle:Stop()
		if not handle.alive then
			return
		end
		handle.alive = false
		if conn then
			conn:Disconnect()
		end
		clone:Destroy()
		finished:Fire()
		finished:Destroy()
	end
	conn = RunService.Heartbeat:Connect(step)
	step(0)
	return handle
end

local function remote(): RemoteEvent
	local r = script:FindFirstChild(REMOTE_NAME)
	if not r and RunService:IsServer() then
		r = Instance.new("RemoteEvent")
		r.Name = REMOTE_NAME
		r.Parent = script
	end
	return r or script:WaitForChild(REMOTE_NAME)
end

function VFX.fire(name: string, where: any?, opts: { [string]: any }?)
	local send = {}
	for k, v in pairs(opts or {}) do
		if k ~= "players" then
			send[k] = v
		end
	end
	local r = remote()
	if opts and opts.players then
		for _, p in ipairs(opts.players) do
			r:FireClient(p, name, where, send)
		end
	else
		r:FireAllClients(name, where, send)
	end
end

local listening = false
function VFX.listen()
	if listening then
		return
	end
	listening = true
	remote().OnClientEvent:Connect(function(name, where, opts)
		VFX.play(name, where, opts)
	end)
end

return VFX
]=====]

local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Selection = game:GetService("Selection")
local RunService = game:GetService("RunService")
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local StarterPlayer = game:GetService("StarterPlayer")
local StudioService = game:GetService("StudioService")
local AssetService = game:GetService("AssetService")
local EncodingService = game:GetService("EncodingService")

-- the preview runs the very module the game gets
local playerModule = Instance.new("ModuleScript")
playerModule.Name = "VFXPlayer"
playerModule.Source = VFX_SOURCE
local Player = require(playerModule)

local TX = "rbxasset://textures/particles/"
local MAX_KEYPOINTS = 20
local NAME_W = 156          -- the timeline's name column
local ROW_H = 24
local RULER_H = 20
local INSPECTOR_H = 236     -- stacked under the timeline (a narrow panel)
local INSPECTOR_W = 720     -- beside it (the usual bottom-docked panel)
local SIDE_BY_SIDE = 1100   -- panel width from which the two sit side by side
local SNAP = 0.02           -- seconds

---------------------------------------------------------------- toolbar and widget
local toolbar = plugin:CreateToolbar("Arctecto VFX")
local button = toolbar:CreateButton("VFX Editor", "Effects on a timeline: layers, curves, flipbooks, presets", "rbxassetid://108697746466621")  -- the VFX Lab icon (uploaded 2026-10-29)
button.ClickableWhenViewportHidden = true
local info = DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Bottom, false, false, 760, 460, 560, 380)
local widget = plugin:CreateDockWidgetPluginGui("ArctectoVFXEditor", info)
widget.Title = "Arctecto VFX"
widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
button:SetActive(widget.Enabled)
button.Click:Connect(function()
	widget.Enabled = not widget.Enabled
end)
widget:GetPropertyChangedSignal("Enabled"):Connect(function()
	button:SetActive(widget.Enabled)
end)

---------------------------------------------------------------- state
local state = {
	effect = nil,         -- the Model being edited
	layer = nil,          -- the selected layer instance
	speed = plugin:GetSetting("vfxSpeed") or 1,
	loop = plugin:GetSetting("vfxLoop") or false,
	hold = false,         -- preview a held effect (Release to let go)
	curve = "Size",       -- which curve the inspector shows
	handle = nil,         -- the preview playing
	t0 = 0,
	dragging = false,
	view = nil,           -- seconds shown, frozen while dragging
	sizeMax = {},         -- [layer] = the size curve's top value
	closed = {},          -- inspector sections folded
	colorStop = 1,        -- the colour stop being edited
	seekTo = nil,         -- the moment a seek runs to
}

---------------------------------------------------------------- palette
local C = {}
local FONT = Enum.Font.BuilderSans
local FONT_B = Enum.Font.BuilderSansBold
local function readTheme()
	local T = settings().Studio.Theme
	local function g(c, m)
		return T:GetColor(c, m or Enum.StudioStyleGuideModifier.Default)
	end
	local S = Enum.StudioStyleGuideColor
	C.bg = g(S.MainBackground)
	C.panel = g(S.InputFieldBackground)
	C.row = g(S.Item)
	C.border = g(S.Border)
	C.text = g(S.MainText)
	C.dim = g(S.DimmedText)
	C.button = g(S.Button)
	C.buttonText = g(S.ButtonText)
	C.dark = (C.bg.R + C.bg.G + C.bg.B) < 1.2
	C.accent = Color3.fromRGB(240, 160, 40)
	C.accentText = Color3.fromRGB(25, 18, 8)
	C.kind = {
		Burst = Color3.fromRGB(240, 160, 40),
		Stream = Color3.fromRGB(226, 96, 52),
		Light = Color3.fromRGB(236, 212, 92),
		Shake = Color3.fromRGB(150, 146, 140),
		Mesh = Color3.fromRGB(170, 120, 255),
	}
	C.warn = Color3.fromRGB(236, 120, 60)
	C.grid = C.dark and Color3.fromRGB(70, 70, 76) or Color3.fromRGB(200, 200, 204)
end
readTheme()

---------------------------------------------------------------- small helpers
local function make(class, props, children)
	local o = Instance.new(class)
	local parent = props.Parent
	props.Parent = nil
	for k, v in pairs(props) do
		o[k] = v
	end
	for _, c in ipairs(children or {}) do
		c.Parent = o
	end
	o.Parent = parent
	return o
end

local function label(parent, text, props)
	local p = {
		Parent = parent, BackgroundTransparency = 1, Text = text, Font = FONT, TextSize = 14,
		TextColor3 = C.text, TextXAlignment = Enum.TextXAlignment.Left, Size = UDim2.new(0, 60, 1, 0),
	}
	for k, v in pairs(props or {}) do
		p[k] = v
	end
	return make("TextLabel", p)
end

local function btn(parent, text, onClick, props)
	local p = {
		Parent = parent, Text = text, Font = FONT, TextSize = 14, AutoButtonColor = true,
		BackgroundColor3 = C.button, TextColor3 = C.buttonText, BorderSizePixel = 0,
		Size = UDim2.new(0, math.max(44, #text * 7 + 18), 0, 22),
	}
	for k, v in pairs(props or {}) do
		p[k] = v
	end
	local b = make("TextButton", p, { make("UICorner", { CornerRadius = UDim.new(0, 4) }) })
	b.MouseButton1Click:Connect(onClick)
	return b
end

-- a group of controls on the toolbar: groups wrap to a new line as a whole
local function group(parent, order)
	return make("Frame", { Parent = parent, BackgroundTransparency = 1, AutomaticSize = Enum.AutomaticSize.X, Size = UDim2.new(0, 0, 0, 26), LayoutOrder = order }, {
		make("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 4), VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder }),
	})
end

local function fmt(n)
	return string.format("%.2f", n)
end

local function snap(t)
	return math.max(0, math.floor(t / SNAP + 0.5) * SNAP)
end

local function record(name, fn)
	local id = ChangeHistoryService:TryBeginRecording("VFX: " .. name)
	local ok, err = pcall(fn)
	if id then
		ChangeHistoryService:FinishRecording(id, ok and Enum.FinishRecordingOperation.Commit or Enum.FinishRecordingOperation.Cancel)
	end
	if not ok then
		warn("[Arctecto VFX] " .. name .. ": " .. tostring(err))
	end
end

local function seq(pts)
	local kps = {}
	for _, p in ipairs(pts) do
		table.insert(kps, NumberSequenceKeypoint.new(p[1], p[2]))
	end
	return NumberSequence.new(kps)
end

local function col(a, b)
	if not b then
		return ColorSequence.new(a)
	end
	return ColorSequence.new(a, b)
end

local NR = NumberRange.new
local rgb = Color3.fromRGB

local function kindOf(o)
	local k = Player.kindOf(o)
	if k == "Particles" then
		return (o:GetAttribute("vfxMode") or "Burst") == "Stream" and "Stream" or "Burst"
	end
	return k
end

local function effectOf(inst)
	while inst and inst ~= game do
		if inst:IsA("Model") and inst:GetAttribute("vfxEffect") then
			return inst
		end
		inst = inst.Parent
	end
	return nil
end

local function rootOf(effect)
	return effect.PrimaryPart or effect:FindFirstChild("Root")
end

---------------------------------------------------------------- creating effects and layers
local function spawnPoint()
	local cam = workspace.CurrentCamera
	local params = RaycastParams.new()
	local hit = workspace:Raycast(cam.CFrame.Position, cam.CFrame.LookVector * 300, params)
	if hit then
		return CFrame.new(hit.Position + Vector3.new(0, 3, 0))
	end
	return CFrame.new((cam.CFrame * CFrame.new(0, 0, -30)).Position)
end

local function newEffect(name)
	local model = make("Model", { Name = name, Parent = workspace })
	local root = make("Part", {
		Name = "Root", Parent = model, Anchored = true, CanCollide = false, CanTouch = false, CanQuery = false,
		CastShadow = false, Transparency = 1, Size = Vector3.new(1, 1, 1), CFrame = spawnPoint(),
	})
	model.PrimaryPart = root
	model:SetAttribute("vfxEffect", true)
	return model
end

local function nextOrder(effect)
	local n = 0
	for _, l in ipairs(Player.layersOf(effect)) do
		n = math.max(n, l:GetAttribute("vfxOrder") or 0)
	end
	return n + 1
end

local LAYER_DEFAULTS = {
	Particles = {
		Texture = TX .. "sparkles_main.dds", Rate = 30, Lifetime = NR(0.4, 0.8), Speed = NR(8, 14),
		SpreadAngle = Vector2.new(180, 180), Drag = 2, LightEmission = 1,
		Size = seq({ { 0, 1 }, { 1, 0 } }), Transparency = seq({ { 0, 0 }, { 1, 1 } }),
		Color = col(rgb(255, 196, 110)),
	},
	Light = { Range = 14, Color = rgb(255, 184, 96), Shadows = false },
}

-- kind: "Burst" | "Stream" | "Light" | "Shake"
local function newLayer(effect, kind, name, spec)
	spec = spec or {}
	local root = rootOf(effect)
	local holder = make("Attachment", { Name = name, Parent = root })
	local o
	if kind == "Burst" or kind == "Stream" then
		o = Instance.new("ParticleEmitter")
		for k, v in pairs(LAYER_DEFAULTS.Particles) do
			o[k] = v
		end
		o:SetAttribute("vfxMode", kind)
		if kind == "Burst" then
			o:SetAttribute("vfxCount", spec.count or 20)
		else
			o:SetAttribute("vfxDuration", spec.dur or 1)
		end
		o.Enabled = false
	elseif kind == "Light" then
		o = Instance.new("PointLight")
		for k, v in pairs(LAYER_DEFAULTS.Light) do
			o[k] = v
		end
		o.Brightness = 0
		o.Enabled = false
		o:SetAttribute("vfxPeak", spec.peak or 6)
		o:SetAttribute("vfxDuration", spec.dur or 0.3)
		if spec.flicker then
			o:SetAttribute("vfxFlicker", spec.flicker)
		end
	else
		o = Instance.new("Configuration")
		o:SetAttribute("vfxType", "Shake")
		o:SetAttribute("vfxPeak", spec.peak or 0.6)
		o:SetAttribute("vfxDuration", spec.dur or 0.3)
		o:SetAttribute("vfxRadius", spec.radius or 60)
	end
	o.Name = name
	for k, v in pairs(spec.props or {}) do
		o[k] = v
	end
	o:SetAttribute("vfxStart", spec.start or 0)
	o:SetAttribute("vfxOrder", nextOrder(effect))
	o.Parent = holder
	return o
end

---------------------------------------------------------------- presets (Studio's own textures: nothing to upload)
local PRESETS = {
	{ name = "Impact", layers = {
		{ "Light", "Flash", { dur = 0.25, peak = 8, props = { Range = 16, Color = rgb(255, 200, 120) } } },
		{ "Burst", "Sparks", { count = 28, props = {
			Texture = TX .. "fire_sparks_main.dds", Lifetime = NR(0.25, 0.55), Speed = NR(30, 55), Drag = 6,
			Size = seq({ { 0, 0.5 }, { 1, 0 } }), Transparency = seq({ { 0, 0 }, { 0.7, 0 }, { 1, 1 } }),
			Color = col(rgb(255, 222, 140), rgb(255, 120, 40)), Orientation = Enum.ParticleOrientation.VelocityParallel } } },
		{ "Burst", "Ring", { count = 1, props = {
			Texture = TX .. "explosion01_shockwave_main.dds", Lifetime = NR(0.35), Speed = NR(0), Rotation = NR(0, 360),
			Size = seq({ { 0, 1 }, { 1, 14 } }), Transparency = seq({ { 0, 0.1 }, { 1, 1 } }), Color = col(rgb(255, 214, 160)) } } },
		{ "Burst", "Dust", { start = 0.04, count = 10, props = {
			Texture = TX .. "smoke_main.dds", Lifetime = NR(0.9, 1.4), Speed = NR(5, 10), Drag = 3, Acceleration = Vector3.new(0, 2, 0),
			Size = seq({ { 0, 2 }, { 1, 6 } }), Transparency = seq({ { 0, 0.5 }, { 1, 1 } }), Color = col(rgb(150, 140, 130)),
			LightEmission = 0, LightInfluence = 1, Rotation = NR(0, 360), RotSpeed = NR(-40, 40) } } },
		{ "Shake", "Shake", { dur = 0.25, peak = 0.5 } },
	} },
	{ name = "Explosion", layers = {
		{ "Light", "Flash", { dur = 0.5, peak = 12, props = { Range = 24, Color = rgb(255, 170, 80) } } },
		{ "Burst", "Core", { count = 6, props = {
			Texture = TX .. "explosion01_core_main.dds", Lifetime = NR(0.35, 0.55), Speed = NR(2, 6), Rotation = NR(0, 360), RotSpeed = NR(-60, 60),
			Size = seq({ { 0, 3 }, { 1, 11 } }), Transparency = seq({ { 0, 0 }, { 0.6, 0.2 }, { 1, 1 } }),
			Color = col(rgb(255, 240, 200), rgb(255, 110, 30)) } } },
		{ "Burst", "Sparks", { count = 45, props = {
			Texture = TX .. "fire_sparks_main.dds", Lifetime = NR(0.5, 1), Speed = NR(40, 75), Drag = 4, Acceleration = Vector3.new(0, -30, 0),
			Size = seq({ { 0, 0.6 }, { 1, 0 } }), Color = col(rgb(255, 210, 120), rgb(255, 90, 30)),
			Orientation = Enum.ParticleOrientation.VelocityParallel } } },
		{ "Burst", "Ring", { count = 1, props = {
			Texture = TX .. "explosion01_shockwave_main.dds", Lifetime = NR(0.45), Speed = NR(0), Rotation = NR(0, 360),
			Size = seq({ { 0, 2 }, { 1, 26 } }), Transparency = seq({ { 0, 0.1 }, { 1, 1 } }), Color = col(rgb(255, 200, 140)) } } },
		{ "Burst", "Smoke", { start = 0.1, count = 14, props = {
			Texture = TX .. "explosion01_smoke_main.dds", Lifetime = NR(1.6, 2.6), Speed = NR(6, 14), Drag = 2.5, Acceleration = Vector3.new(0, 4, 0),
			Size = seq({ { 0, 4 }, { 1, 13 } }), Transparency = seq({ { 0, 0.3 }, { 1, 1 } }), Color = col(rgb(90, 80, 75), rgb(50, 48, 46)),
			LightEmission = 0, LightInfluence = 1, Rotation = NR(0, 360), RotSpeed = NR(-25, 25) } } },
		{ "Shake", "Shake", { dur = 0.6, peak = 1.4, radius = 120 } },
	} },
	{ name = "Aura", layers = {
		{ "Stream", "Glow", { dur = 2, props = {
			Texture = TX .. "forcefield_glow_main.dds", Rate = 6, Lifetime = NR(0.8, 1.2), Speed = NR(0),
			Size = seq({ { 0, 3 }, { 0.5, 5 }, { 1, 4 } }), Transparency = seq({ { 0, 1 }, { 0.3, 0.6 }, { 1, 1 } }), Color = col(rgb(255, 190, 90)) } } },
		{ "Stream", "Motes", { dur = 2, props = {
			Texture = TX .. "sparkles_main.dds", Rate = 25, Lifetime = NR(0.8, 1.4), Speed = NR(1, 3), Acceleration = Vector3.new(0, 4, 0),
			Size = seq({ { 0, 0.35 }, { 1, 0 } }), Color = col(rgb(255, 220, 150)) } } },
		{ "Light", "Glow light", { dur = 2, peak = 2, props = { Range = 12, Color = rgb(255, 196, 110) } } },
	} },
	-- a flamethrower / dragon breath: aim the Root (or the attachment it follows) where the
	-- fire goes; it streams out of the front. Play it with { hold = true, follow = true }.
	{ name = "Fire Breath", layers = {
		{ "Stream", "Core", { dur = 2, props = {
			Texture = TX .. "fire_main.dds", EmissionDirection = Enum.NormalId.Front, Rate = 60, Lifetime = NR(0.15, 0.25),
			Speed = NR(45, 55), SpreadAngle = Vector2.new(3, 3), Rotation = NR(0, 360), RotSpeed = NR(-120, 120),
			Size = seq({ { 0, 0.8 }, { 1, 2.5 } }), Transparency = seq({ { 0, 0.2 }, { 1, 1 } }),
			Color = col(rgb(255, 250, 220), rgb(255, 200, 90)) } } },
		{ "Stream", "Flames", { dur = 2, props = {
			Texture = TX .. "fire_main.dds", EmissionDirection = Enum.NormalId.Front, Rate = 80, Lifetime = NR(0.5, 0.8),
			Speed = NR(40, 55), SpreadAngle = Vector2.new(9, 9), Drag = 2.2, Acceleration = Vector3.new(0, 6, 0),
			Rotation = NR(0, 360), RotSpeed = NR(-90, 90),
			Size = seq({ { 0, 1.2 }, { 0.3, 4 }, { 1, 9 } }), Transparency = seq({ { 0, 0.1 }, { 0.6, 0.35 }, { 1, 1 } }),
			Color = ColorSequence.new({ ColorSequenceKeypoint.new(0, rgb(255, 232, 150)), ColorSequenceKeypoint.new(0.45, rgb(255, 140, 40)),
				ColorSequenceKeypoint.new(1, rgb(200, 50, 10)) }) } } },
		{ "Stream", "Embers", { dur = 2, props = {
			Texture = TX .. "fire_sparks_main.dds", EmissionDirection = Enum.NormalId.Front, Rate = 30, Lifetime = NR(0.6, 1.2),
			Speed = NR(30, 60), SpreadAngle = Vector2.new(18, 18), Drag = 1.5, Acceleration = Vector3.new(0, 8, 0),
			Orientation = Enum.ParticleOrientation.VelocityParallel,
			Size = seq({ { 0, 0.25 }, { 1, 0 } }), Color = col(rgb(255, 200, 90), rgb(255, 90, 20)) } } },
		{ "Stream", "Smoke", { start = 0.15, dur = 2, props = {
			Texture = TX .. "smoke_main.dds", EmissionDirection = Enum.NormalId.Front, Rate = 14, Lifetime = NR(1.2, 1.8),
			Speed = NR(20, 28), SpreadAngle = Vector2.new(14, 14), Drag = 3, Acceleration = Vector3.new(0, 4, 0),
			Rotation = NR(0, 360), RotSpeed = NR(-30, 30), LightEmission = 0, LightInfluence = 1,
			Size = seq({ { 0, 3 }, { 1, 10 } }), Transparency = seq({ { 0, 0.7 }, { 1, 1 } }), Color = col(rgb(60, 52, 46), rgb(30, 28, 26)) } } },
		{ "Light", "Fire light", { dur = 2, peak = 5, flicker = 0.35, props = { Range = 30, Color = rgb(255, 150, 60) } } },
	} },
	{ name = "Fire", layers = {
		{ "Stream", "Flames", { dur = 1.5, props = {
			Texture = TX .. "fire_main.dds", Rate = 40, Lifetime = NR(0.5, 0.9), Speed = NR(4, 7), SpreadAngle = Vector2.new(12, 12),
			Acceleration = Vector3.new(0, 6, 0), Rotation = NR(0, 360), RotSpeed = NR(-40, 40), LightEmission = 0.8,
			Size = seq({ { 0, 2 }, { 0.4, 2.6 }, { 1, 0.6 } }), Transparency = seq({ { 0, 0.3 }, { 0.8, 0.5 }, { 1, 1 } }),
			Color = col(rgb(255, 200, 90), rgb(255, 80, 20)) } } },
		{ "Stream", "Embers", { dur = 1.5, props = {
			Texture = TX .. "fire_sparks_main.dds", Rate = 12, Lifetime = NR(1, 1.8), Speed = NR(6, 12), SpreadAngle = Vector2.new(25, 25),
			Acceleration = Vector3.new(0, 3, 0), Size = seq({ { 0, 0.25 }, { 1, 0 } }), Color = col(rgb(255, 170, 60)) } } },
		{ "Stream", "Smoke", { start = 0.3, dur = 1.5, props = {
			Texture = TX .. "smoke_main.dds", Rate = 6, Lifetime = NR(1.5, 2.2), Speed = NR(4, 6), SpreadAngle = Vector2.new(15, 15),
			Size = seq({ { 0, 2 }, { 1, 6 } }), Transparency = seq({ { 0, 0.6 }, { 1, 1 } }), Color = col(rgb(60, 55, 50)),
			LightEmission = 0, LightInfluence = 1 } } },
		{ "Light", "Fire light", { dur = 1.8, peak = 4, props = { Range = 16, Color = rgb(255, 150, 60) } } },
	} },
}

---------------------------------------------------------------- flipbooks (the Houdini HDA's JSON + atlas PNG)
local function pngSize(bytes)
	if #bytes < 24 or bytes:sub(2, 4) ~= "PNG" then
		return nil
	end
	local w, h = string.unpack(">I4I4", bytes, 17)
	return w, h
end

local function importFlipbook()
	if not state.effect then
		warn("[Arctecto VFX] select or create an effect first")
		return
	end
	local ok, files = pcall(function()
		return StudioService:PromptImportFilesAsync({ "json", "png" })
	end)
	if not ok or not files or #files == 0 then
		return
	end
	local meta, png, pngName
	for _, f in ipairs(files) do
		local n = f.Name:lower()
		if n:sub(-5) == ".json" then
			local okj, d = pcall(HttpService.JSONDecode, HttpService, f:GetBinaryContents())
			if okj then
				meta = d
				meta.fileName = f.Name
			else
				warn("[Arctecto VFX] could not read " .. f.Name)
			end
		elseif n:sub(-4) == ".png" then
			png = f
			pngName = f.Name
		end
	end
	if not meta then
		warn("[Arctecto VFX] pick the flipbook's .json (and its .png)")
		return
	end
	local cols, rows = tonumber(meta.columns) or 8, tonumber(meta.rows) or 8
	local frames = tonumber(meta.frames) or cols * rows
	local fps = tonumber(meta.fps) or 24
	local notes = {}
	local name = (meta.fileName:gsub("%.json$", ""))
	record("import flipbook", function()
		local e = newLayer(state.effect, "Burst", name, { count = 1, props = {
			Speed = NR(0), Lifetime = NR(frames / fps), Size = seq({ { 0, 8 }, { 1, 8 } }), Transparency = seq({ { 0, 0 }, { 1, 0 } }),
			Color = col(rgb(255, 255, 255)), LightEmission = 1, SpreadAngle = Vector2.new(0, 0), Drag = 0, Rotation = NR(0),
			FlipbookMode = Enum.ParticleFlipbookMode.OneShot, FlipbookFramerate = NR(fps),
		} })
		if cols == rows and (cols == 2 or cols == 4 or cols == 8) then
			e.FlipbookLayout = Enum.ParticleFlipbookLayout["Grid" .. cols .. "x" .. rows]
		else
			e.FlipbookLayout = Enum.ParticleFlipbookLayout.Custom
			e.FlipbookSizeX = cols
			e.FlipbookSizeY = rows
		end
		if frames ~= cols * rows then
			table.insert(notes, string.format("%d frames in a %dx%d grid: the empty cells play too", frames, cols, rows))
		end
		if png then
			local w, h = pngSize(png:GetBinaryContents())
			if w and (w ~= 1024 or h ~= 1024) then
				table.insert(notes, string.format("%s is %dx%d: Roblox flipbooks need exactly 1024x1024", pngName, w, h))
			end
			e.Texture = png:GetTemporaryId()
			table.insert(notes, "temporary texture: upload " .. pngName .. " and paste its asset id")
		else
			table.insert(notes, "no texture yet: paste the atlas asset id")
		end
		e:SetAttribute("vfxNote", table.concat(notes, "\n"))
		state.layer = e
	end)
end

---------------------------------------------------------------- the runtime in the place
local CLIENT_SOURCE = [[-- Plays the effects the server fires (VFXPlayer.fire). Installed by Arctecto VFX.
require(game:GetService("ReplicatedStorage"):WaitForChild("VFXPlayer")).listen()
]]

local function installRuntime()
	record("install runtime", function()
		local m = ReplicatedStorage:FindFirstChild("VFXPlayer")
		if not m then
			m = make("ModuleScript", { Name = "VFXPlayer", Parent = ReplicatedStorage })
		end
		m.Source = VFX_SOURCE
		if not m:FindFirstChild("VFXRemote") then
			make("RemoteEvent", { Name = "VFXRemote", Parent = m })
		end
		if not ReplicatedStorage:FindFirstChild("VFXEffects") then
			make("Folder", { Name = "VFXEffects", Parent = ReplicatedStorage })
		end
		local sps = StarterPlayer:FindFirstChild("StarterPlayerScripts")
		local c = sps:FindFirstChild("VFXClient")
		if not c then
			c = make("LocalScript", { Name = "VFXClient", Parent = sps })
		end
		c.Source = CLIENT_SOURCE
	end)
	print("[Arctecto VFX] runtime " .. Player.version .. " installed: ReplicatedStorage.VFXPlayer, ReplicatedStorage.VFXEffects, StarterPlayerScripts.VFXClient")
end

local function saveToLibrary()
	local e = state.effect
	if not e then
		return
	end
	record("save to library", function()
		local lib = ReplicatedStorage:FindFirstChild("VFXEffects") or make("Folder", { Name = "VFXEffects", Parent = ReplicatedStorage })
		local old = lib:FindFirstChild(e.Name)
		if old then
			old:Destroy()
		end
		e:Clone().Parent = lib
		-- a library effect needs the runtime that knows its layers (a Mesh layer on a 0.1.0
		-- player stayed still): refresh an older VFXPlayer with this plugin's copy
		local m = ReplicatedStorage:FindFirstChild("VFXPlayer")
		if m and m:IsA("ModuleScript") and m.Source ~= VFX_SOURCE then
			m.Source = VFX_SOURCE
			print("[Arctecto VFX] ReplicatedStorage.VFXPlayer updated to runtime " .. Player.version)
		end
	end)
	print("[Arctecto VFX] " .. e.Name .. " saved to ReplicatedStorage.VFXEffects: VFXPlayer.play(\"" .. e.Name .. "\", where) or VFXPlayer.fire from the server")
end

---------------------------------------------------------------- UI skeleton
local root, timeline, inspector, timeLabel, nameBox, playBtn, loopBtn, holdBtn, speedBtns
local render, renderTimeline, renderInspector -- forward

local watch -- forward

local function stopPreview()
	if state.handle then
		state.handle:Stop()
		state.handle = nil
	end
end

local function startPreview(speed)
	stopPreview()
	state.seekTo = nil
	local e = state.effect
	if not e or not e.Parent then
		return nil
	end
	state.handle = Player.play(e, e:GetPivot(), { timeScale = speed, parent = workspace.CurrentCamera, hold = state.hold })
	return state.handle
end

local function playPreview()
	startPreview(state.speed)
end

-- Play / Pause: a paused preview holds its frame (the particles freeze)
local function togglePlay()
	local h = state.handle
	if h and h.alive and h.holding and not h.paused then
		h:Release()
		return
	end
	if h and h.alive then
		if h.paused then
			state.seekTo = nil
			h:SetSpeed(state.speed)
			h:Resume()
		else
			h:Pause()
		end
	else
		playPreview()
	end
end

-- Go to a moment and hold it there. Particles cannot be stepped forward in an
-- instant, so the preview runs (at full speed) up to that moment and freezes;
-- going back means starting over from zero.
local function seek(t, fresh)
	local e = state.effect
	if not e then
		return
	end
	t = math.clamp(t, 0, math.max(0, Player.duration(e) - 0.01))
	local h = state.handle
	if fresh or not (h and h.alive) or t < h.time then
		h = startPreview(1)
		if not h then
			return
		end
	end
	if t <= h.time then
		h:Pause()
		return
	end
	state.seekTo = t
	h:SetSpeed(1)
	h:Resume()
end

local function setEffect(e, layer)
	if e ~= state.effect then
		stopPreview()
		state.effect = e
		watch(e)
	end
	state.layer = layer
end

local function setSpeed(s)
	state.speed = s
	plugin:SetSetting("vfxSpeed", s)
	render()
end

---------------------------------------------------------------- the Lab link (Arctecto VFX Lab, on this PC)
-- The Lab serves its last texture on 127.0.0.1:7831. While the link is on, the panel polls it
-- and shows a new texture at once on a preview card over the effect's Lab layer: a billboard
-- stepping through the atlas at the flipbook's rate. A ParticleEmitter takes an EditableImage
-- in TextureContent but does not draw it (measured in Studio, 2026-10-29), so the emitter
-- itself gets the texture only from Upload Lab, as an image asset.
local LAB_URL = "http://127.0.0.1:7831/vfxlab/"
local lab = { on = false, id = 0, images = {}, cards = {}, meshes = {}, busy = false }
local labBtn

-- the preview card: not archivable, so it is never saved, cloned by Play or copied to the library
local function labCard(layer, img, grid, fps, size)
	local holder = layer.Parent
	local card = holder:FindFirstChild("LabPreview")
	if not card then
		card = Instance.new("BillboardGui")
		card.Name = "LabPreview"
		card.Archivable = false
		card.LightInfluence = 0
		card.Parent = holder
		local il = Instance.new("ImageLabel")
		il.Name = "Atlas"
		il.BackgroundTransparency = 1
		il.Size = UDim2.fromScale(1, 1)
		il.Parent = card
	end
	local studs = layer.Size.Keypoints[1].Value
	card.Size = UDim2.fromScale(studs, studs)
	local il = card.Atlas
	il.ImageContent = Content.fromObject(img)
	local cell = size / grid
	il.ImageRectSize = Vector2.new(cell, cell)
	lab.cards[layer] = { label = il, grid = grid, fps = fps, cell = cell }
end

-- steps every card through its frames
RunService.Heartbeat:Connect(function()
	for layer, c in pairs(lab.cards) do
		if not layer.Parent or not c.label.Parent then
			lab.cards[layer] = nil
		else
			local frames = c.grid * c.grid
			local k = math.floor(os.clock() * c.fps) % frames
			c.label.ImageRectOffset = Vector2.new((k % c.grid) * c.cell, math.floor(k / c.grid) * c.cell)
		end
	end
end)

-- The Lab's Mesh tab: the ribbon around the character as a MeshPart (EditableMesh made into a
-- part), painted with the flipbook's current frame. Measured in Studio, 2026-10-30: the texture's
-- alpha cuts only when the part's Transparency is above 0 (0.02 is enough); repainting the
-- EditableImage shows at once; UVs past 1 repeat the picture; v = 0 is the picture's top (as in
-- the Lab); the part is centred on its mesh's bounds; the mesh can not be reached after the part
-- is made, so the scroll moves pixels, not UVs. Studio preview only (not archivable).
local function labMeshDrop(layer)
	local s = lab.meshes[layer]
	if s and s.part then
		s.part:Destroy()
	end
	lab.meshes[layer] = nil
end

local function labMesh(layer, img, grid, fps, size, m)
	labMeshDrop(layer)
	local holder = layer.Parent
	local root = holder and holder.Parent
	if not m or not root then
		return
	end
	local pos, nrm, uvs, idx = m.positions, m.normals, m.uvs, m.indices
	local nv = #pos // 3
	if nv == 0 or #idx < 3 or #uvs ~= nv * 2 or #nrm ~= nv * 3 then
		warn("[Arctecto VFX] Lab mesh: arrays do not match")
		return
	end
	local em = AssetService:CreateEditableMesh()
	local vid, nid, uid = table.create(nv), table.create(nv), table.create(nv)
	local lo = Vector3.new(math.huge, math.huge, math.huge)
	local hi = -lo
	for i = 1, nv do
		local p = Vector3.new(pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3])
		vid[i] = em:AddVertex(p)
		nid[i] = em:AddNormal(Vector3.new(nrm[i * 3 - 2], nrm[i * 3 - 1], nrm[i * 3]))
		uid[i] = em:AddUV(Vector2.new(uvs[i * 2 - 1], uvs[i * 2]))
		lo = lo:Min(p)
		hi = hi:Max(p)
	end
	for k = 1, #idx - 2, 3 do
		local a, b, c = idx[k] + 1, idx[k + 1] + 1, idx[k + 2] + 1
		local f = em:AddTriangle(vid[a], vid[b], vid[c])
		em:SetFaceUVs(f, { uid[a], uid[b], uid[c] })
		em:SetFaceNormals(f, { nid[a], nid[b], nid[c] })
	end
	local part = AssetService:CreateMeshPartAsync(Content.fromObject(em))
	local cell = size // grid
	local frame = AssetService:CreateEditableImage({ Size = Vector2.new(cell, cell) })
	part.Name = "LabMesh"
	part.Archivable = false
	part.Anchored = true
	part.CanCollide = false
	part.CanTouch = false
	part.CanQuery = false
	part.CastShadow = false
	part.Transparency = math.max(0.02, 1 - (tonumber(m.opacity) or 1))
	part.TextureContent = Content.fromObject(frame)
	part.Parent = root
	lab.meshes[layer] = {
		part = part, atlas = img, frame = frame, grid = grid, fps = fps, cell = cell, holder = holder, em = em,
		center = (lo + hi) / 2, spin = tonumber(m.spin) or 0, scroll = tonumber(m.scroll) or 0, along = m.along ~= false,
		opacity = math.clamp(tonumber(m.opacity) or 1, 0, 1), key = "", out = buffer.create(cell * cell * 4),
	}
end

-- one cell moved by `sh` pixels along the strip, wrapping (the Lab: st.y + scroll t when the
-- picture's up runs along the strip, st.x - scroll t when it lies across)
local function labShift(src, out, sh, cell, along)
	if sh == 0 then
		return src
	end
	local row = cell * 4
	if along then
		for y = 0, cell - 1 do
			buffer.copy(out, y * row, src, ((y + sh) % cell) * row, row)
		end
	else
		local cut = (cell - sh) * 4
		for y = 0, cell - 1 do
			buffer.copy(out, y * row + sh * 4, src, y * row, cut)
			buffer.copy(out, y * row, src, y * row + cut, row - cut)
		end
	end
	return out
end

-- spins every ribbon and steps its frame (the Lab's clock: spin in degrees per second, scroll in
-- pictures per second along the strip)
RunService.Heartbeat:Connect(function()
	local now = os.clock()
	for layer, s in pairs(lab.meshes) do
		if not layer.Parent or not s.part.Parent or not s.holder.Parent then
			labMeshDrop(layer)
		else
			-- the Lab turns x' = c x - s z, which is Roblox's turn about Y by minus the angle
			local a = math.rad(s.spin * now)
			s.part.CFrame = s.holder.WorldCFrame * CFrame.Angles(0, -a, 0) * CFrame.new(s.center)
			local frames = s.grid * s.grid
			local k = math.floor(now * s.fps) % frames
			local cell = s.cell
			local sh = math.floor(((s.scroll * now) % 1) * cell) % cell
			local key = k .. ":" .. sh
			if key ~= s.key then
				s.key = key
				local src = s.atlas:ReadPixelsBuffer(Vector2.new((k % s.grid) * cell, (k // s.grid) * cell), Vector2.new(cell, cell))
				s.frame:WritePixelsBuffer(Vector2.zero, Vector2.new(cell, cell), labShift(src, s.out, sh, cell, s.along))
			end
		end
	end
end)

local function labGet(path)
	local ok, res = pcall(HttpService.RequestAsync, HttpService, { Url = LAB_URL .. path, Method = "GET" })
	if not ok then
		return nil, tostring(res)
	end
	if not res.Success then
		return nil, "HTTP " .. tostring(res.StatusCode)
	end
	local okj, d = pcall(HttpService.JSONDecode, HttpService, res.Body)
	if not okj then
		return nil, "the Lab sent something that is not JSON"
	end
	return d
end

local function labLayerOf(effect, name)
	for _, o in ipairs(effect:GetDescendants()) do
		if o:IsA("ParticleEmitter") and o:GetAttribute("vfxLab") == name then
			return o
		end
	end
	return nil
end

local function labShowMesh(layer, img, grid, fps, size, m)
	labMesh(layer, img, grid, fps, size, m)
	local card = layer.Parent and layer.Parent:FindFirstChild("LabPreview")
	if card then
		card.Enabled = m == nil
	end
end

local function labApply(d)
	local size = tonumber(d.size) or 1024
	local px = EncodingService:Base64Decode(buffer.fromstring(d.rgba))
	if buffer.len(px) ~= size * size * 4 then
		warn(string.format("[Arctecto VFX] Lab texture: %d bytes, expected %d", buffer.len(px), size * size * 4))
		return
	end
	local img = lab.images[d.name]
	if not img or img.Size.X ~= size then
		img = AssetService:CreateEditableImage({ Size = Vector2.new(size, size) })
		lab.images[d.name] = img
	end
	img:WritePixelsBuffer(Vector2.zero, img.Size, px)
	local grid = tonumber(d.grid) or 1
	local loopLen = tonumber(d.loop) or 1
	local function shape(e)
		e.TextureContent = Content.fromObject(img)
		e.LightEmission = d.additive and 1 or 0
		if grid > 1 then
			e.FlipbookLayout = Enum.ParticleFlipbookLayout["Grid" .. grid .. "x" .. grid]
			e.FlipbookMode = Enum.ParticleFlipbookMode.OneShot
			e.FlipbookFramerate = NR(tonumber(d.fps) or 24)
			e.Lifetime = NR(loopLen)
		else
			e.FlipbookLayout = Enum.ParticleFlipbookLayout.None
		end
	end
	local effect = state.effect
	local layer = effect and labLayerOf(effect, d.name)
	if layer then
		-- the same layer: new pixels and timing, no undo step for every slider move
		shape(layer)
		labCard(layer, img, grid, tonumber(d.fps) or 24, size)
		labShowMesh(layer, img, grid, tonumber(d.fps) or 24, size, d.mesh)
		return
	end
	if not effect then
		record("new effect", function()
			effect = newEffect("VFX_Lab")
		end)
		setEffect(effect, nil)
	end
	record("Lab texture", function()
		layer = newLayer(effect, "Burst", d.name, { count = 1, props = {
			Speed = NR(0), Size = seq({ { 0, 8 }, { 1, 8 } }), Transparency = seq({ { 0, 0 }, { 1, 0 } }),
			Color = col(rgb(255, 255, 255)), SpreadAngle = Vector2.new(0, 0), Drag = 0, Rotation = NR(0), Lifetime = NR(loopLen),
		} })
		layer:SetAttribute("vfxLab", d.name)
		layer:SetAttribute("vfxNote", "Lab preview card only (not saved): Upload Lab puts the texture on the emitter")
		shape(layer)
	end)
	labCard(layer, img, grid, tonumber(d.fps) or 24, size)
	labShowMesh(layer, img, grid, tonumber(d.fps) or 24, size, d.mesh)
	state.layer = layer
	Selection:Set({ layer })
end

local function labPoll()
	while lab.on do
		local d, err = labGet("id")
		if not d then
			labBtn.Text = "Lab: no Lab"
		else
			labBtn.Text = "Lab: on"
			if d.id ~= lab.id then
				local full, err2 = labGet("latest")
				if full then
					lab.id = d.id
					local ok, e = pcall(labApply, full)
					if not ok then
						warn("[Arctecto VFX] Lab texture: " .. tostring(e))
					end
				else
					warn("[Arctecto VFX] Lab: " .. tostring(err2))
				end
			end
		end
		task.wait(0.35)
	end
	if labBtn then
		labBtn.Text = "Lab: off"
	end
end

local function labToggle()
	lab.on = not lab.on
	if lab.on then
		lab.id = 0
		task.spawn(labPoll)
	end
end

-- A ribbon becomes a Mesh layer the game can play: the mesh and up to MESH_FRAMES frames go up as
-- assets (no EditableImage in the game without its Security setting, measured 2026-10-30), and
-- the VFXPlayer swaps the frames. The scroll is baked into the frames, rounded to whole pictures
-- per loop so the last frame meets the first.
local MESH_FRAMES = 16

local function labUploadMesh(l, s)
	local name = l:GetAttribute("vfxLab") or l.Name
	local holder = l.Parent
	local root = holder and holder.Parent
	if not (root and root:IsA("BasePart")) then
		warn("[Arctecto VFX] the Lab layer is not under the effect's Root")
		return
	end
	task.spawn(function()
		local function up(obj, kind, label)
			local ok, res, id = pcall(AssetService.CreateAssetAsync, AssetService, obj, kind, { Name = label, Description = "Arctecto VFX Lab ribbon" })
			if not ok then
				local why = tostring(res)
				-- the API is a Studio beta: off, it answers "not available yet" (2026-10-30)
				if why:find("not available") then
					why ..= ". Turn on File > Beta Features > CreateAssetAsync Luau API and restart Studio"
				end
				error("upload failed: " .. why, 0)
			end
			if res ~= Enum.CreateAssetResult.Success then
				error("upload: " .. tostring(res), 0)
			end
			return "rbxassetid://" .. tostring(id)
		end
		local ok, err = pcall(function()
			print("[Arctecto VFX] uploading the ribbon: mesh")
			local meshId = up(s.em, Enum.AssetType.Mesh, name .. "_ribbon")
			local total = s.grid * s.grid
			local stride = math.max(1, math.ceil(total / MESH_FRAMES))
			local n = math.max(1, total // stride)
			local fps = s.fps / stride
			local loop = n / fps
			local turns = s.scroll * loop
			if s.scroll ~= 0 then
				turns = math.abs(turns) < 0.5 and (turns < 0 and -1 or 1) or math.round(turns)
			end
			local cell = s.cell
			local out = buffer.create(cell * cell * 4)
			local ids = {}
			for i = 0, n - 1 do
				local k = i * stride
				local src = s.atlas:ReadPixelsBuffer(Vector2.new((k % s.grid) * cell, (k // s.grid) * cell), Vector2.new(cell, cell))
				local sh = math.floor(((turns * i / n) % 1) * cell) % cell
				local img = AssetService:CreateEditableImage({ Size = Vector2.new(cell, cell) })
				img:WritePixelsBuffer(Vector2.zero, img.Size, labShift(src, out, sh, cell, s.along))
				table.insert(ids, up(img, Enum.AssetType.Image, string.format("%s_ribbon_%02d", name, i + 1)))
				img:Destroy()
				print(string.format("[Arctecto VFX] uploading the ribbon: frame %d of %d", i + 1, n))
			end
			local part = AssetService:CreateMeshPartAsync(Content.fromUri(meshId))
			local center = holder.Position + s.center
			local effect = effectOf(root)
			record("upload Lab ribbon", function()
				part.Name = name .. "_ribbon"
				part.Anchored = true
				part.CanCollide = false
				part.CanTouch = false
				part.CanQuery = false
				part.CastShadow = false
				part.TextureID = ids[1]
				-- hidden at rest like a disabled emitter: only a play shows it (in the game the
				-- edit copy in Workspace stood still in the middle of everything)
				part.Transparency = 1
				part.CFrame = root.CFrame * CFrame.new(center)
				part:SetAttribute("vfxType", "Mesh")
				part:SetAttribute("vfxStart", l:GetAttribute("vfxStart") or 0)
				part:SetAttribute("vfxDuration", math.max(1, loop * 2))
				part:SetAttribute("vfxFrames", table.concat(ids, ","))
				part:SetAttribute("vfxFps", fps)
				part:SetAttribute("vfxSpin", s.spin)
				part:SetAttribute("vfxCenter", center)
				part:SetAttribute("vfxOpacity", s.opacity)
				part:SetAttribute("vfxFade", 0.15)
				part:SetAttribute("vfxOrder", l:GetAttribute("vfxOrder") or 1)
				part.Parent = root
				-- the Lab's preview emitter is replaced: its texture lived only in this session
				holder:Destroy()
			end)
			labMeshDrop(l)
			lab.cards[l] = nil
			if effect then
				setEffect(effect, part)
			end
			print(string.format("[Arctecto VFX] ribbon %s: mesh %s and %d frames at %.1f fps. New images can show blank until Roblox approves them.", name, meshId, n, fps))
		end)
		if not ok then
			warn("[Arctecto VFX] ribbon upload: " .. tostring(err))
		end
	end)
end

-- the selected Lab layer's texture becomes an image asset on the creator's account
local function labUpload()
	local l = state.layer
	if l and lab.meshes[l] then
		labUploadMesh(l, lab.meshes[l])
		return
	end
	local name = l and l:GetAttribute("vfxLab")
	local img = name and lab.images[name]
	if not img then
		warn("[Arctecto VFX] select a Lab layer whose texture came in this session")
		return
	end
	task.spawn(function()
		local ok, result, id = pcall(AssetService.CreateAssetAsync, AssetService, img, Enum.AssetType.Image, { Name = name, Description = "Arctecto VFX Lab texture" })
		if not ok then
			warn("[Arctecto VFX] upload failed: " .. tostring(result))
			return
		end
		if result ~= Enum.CreateAssetResult.Success then
			warn("[Arctecto VFX] upload: " .. tostring(result))
			return
		end
		local card = l.Parent and l.Parent:FindFirstChild("LabPreview")
		if card then
			card:Destroy()
		end
		record("upload Lab texture", function()
			l.Texture = "rbxassetid://" .. tostring(id)
			l:SetAttribute("vfxLab", nil)
			l:SetAttribute("vfxNote", "Lab texture uploaded: rbxassetid://" .. tostring(id))
		end)
		print("[Arctecto VFX] uploaded " .. name .. " as rbxassetid://" .. tostring(id))
	end)
end

local function build()
	if root then
		root:Destroy()
	end
	root = make("Frame", { Parent = widget, BackgroundColor3 = C.bg, BorderSizePixel = 0, Size = UDim2.fromScale(1, 1) })
	-- one toolbar that wraps when the panel is narrow
	local bar = make("Frame", { Parent = root, BackgroundColor3 = C.bg, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 0), AutomaticSize = Enum.AutomaticSize.Y }, {
		make("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Wraps = true, Padding = UDim.new(0, 18),
			VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder }),
		make("UIPadding", { PaddingLeft = UDim.new(0, 8), PaddingRight = UDim.new(0, 8), PaddingTop = UDim.new(0, 4), PaddingBottom = UDim.new(0, 4) }),
	})

	-- header: the effect's name, new, library, runtime
	local head = group(bar, 1)
	label(head, "Effect", { Size = UDim2.new(0, 40, 1, 0), TextColor3 = C.dim, LayoutOrder = 1 })
	nameBox = make("TextBox", {
		Parent = head, LayoutOrder = 2, Size = UDim2.new(0, 170, 0, 22), BackgroundColor3 = C.panel, BorderSizePixel = 0,
		TextColor3 = C.text, Font = FONT_B, TextSize = 14, Text = "", PlaceholderText = "select an effect", ClearTextOnFocus = false,
		TextXAlignment = Enum.TextXAlignment.Left,
	}, { make("UIPadding", { PaddingLeft = UDim.new(0, 6) }), make("UICorner", { CornerRadius = UDim.new(0, 4) }) })
	nameBox.FocusLost:Connect(function()
		local e, n = state.effect, nameBox.Text
		if e and n ~= "" and n ~= e.Name then
			record("rename effect", function() e.Name = n end)
		end
		render()
	end)
	btn(head, "New", function()
		local e
		record("new effect", function()
			e = newEffect("VFX_New")
			newLayer(e, "Burst", "Sparks", { count = 20 })
		end)
		setEffect(e, nil)
		Selection:Set({ e })
	end, { LayoutOrder = 3 })
	btn(head, "Save to library", saveToLibrary, { LayoutOrder = 4 })
	btn(head, "Install runtime", installRuntime, { LayoutOrder = 5 })

	-- transport
	local tr = group(bar, 2)
	playBtn = btn(tr, "Play", function()
		togglePlay()
		render()
	end, { LayoutOrder = 1, Size = UDim2.new(0, 64, 0, 22), BackgroundColor3 = C.accent, TextColor3 = C.accentText, Font = FONT_B })
	btn(tr, "Stop", function()
		stopPreview()
		state.seekTo = nil
		render()
	end, { LayoutOrder = 1 })
	loopBtn = btn(tr, "Loop", function()
		state.loop = not state.loop
		plugin:SetSetting("vfxLoop", state.loop)
		render()
	end, { LayoutOrder = 2 })
	holdBtn = btn(tr, "Hold", function()
		state.hold = not state.hold
		render()
	end, { LayoutOrder = 2 })
	label(tr, "Speed", { Size = UDim2.new(0, 40, 1, 0), TextColor3 = C.dim, LayoutOrder = 3 })
	speedBtns = {}
	for i, s in ipairs({ { 1, "1x" }, { 0.5, "1/2" }, { 0.25, "1/4" }, { 0.1, "1/10" } }) do
		speedBtns[s[1]] = btn(tr, s[2], function() setSpeed(s[1]) end, { LayoutOrder = 3 + i, Size = UDim2.new(0, 40, 0, 22) })
	end
	timeLabel = label(tr, "", { LayoutOrder = 9, Size = UDim2.new(0, 160, 1, 0), TextColor3 = C.dim, Font = Enum.Font.RobotoMono, TextSize = 13 })

	-- adding layers
	local add = group(bar, 3)
	local function adder(kind, title, order)
		btn(add, title, function()
			if not state.effect then return end
			local l
			record("add " .. kind, function()
				l = newLayer(state.effect, kind, kind == "Shake" and "Shake" or (kind == "Light" and "Flash" or kind))
			end)
			state.layer = l
			Selection:Set({ l })
		end, { LayoutOrder = order })
	end
	adder("Burst", "+ Burst", 1)
	adder("Stream", "+ Stream", 2)
	adder("Light", "+ Light", 3)
	adder("Shake", "+ Shake", 4)
	btn(add, "+ Flipbook...", importFlipbook, { LayoutOrder = 5 })
	labBtn = btn(add, lab.on and "Lab: on" or "Lab: off", labToggle, { LayoutOrder = 5 })
	btn(add, "Upload Lab", labUpload, { LayoutOrder = 5 })
	btn(add, "Duplicate", function()
		local l, e = state.layer, state.effect
		if not (l and e) then return end
		local copy
		record("duplicate layer", function()
			local holder = l.Parent:IsA("Attachment") and l.Parent or l
			local hc = holder:Clone()
			hc.Parent = holder.Parent
			copy = hc:IsA("Attachment") and hc:FindFirstChild(l.Name) or hc
			copy:SetAttribute("vfxOrder", nextOrder(e))
		end)
		state.layer = copy
		Selection:Set({ copy })
	end, { LayoutOrder = 6 })
	btn(add, "Delete", function()
		local l = state.layer
		if not l then return end
		record("delete layer", function()
			local holder = l.Parent
			if holder and holder:IsA("Attachment") and #holder:GetChildren() == 1 then
				holder:Destroy()
			else
				l:Destroy()
			end
		end)
		state.layer = nil
		Selection:Set(state.effect and { state.effect } or {})
	end, { LayoutOrder = 7 })

	-- presets
	local pr = group(bar, 4)
	label(pr, "Presets", { Size = UDim2.new(0, 48, 1, 0), TextColor3 = C.dim, LayoutOrder = 0 })
	for i, p in ipairs(PRESETS) do
		btn(pr, p.name, function()
			local e
			record("preset " .. p.name, function()
				e = newEffect("VFX_" .. p.name)
				for _, spec in ipairs(p.layers) do
					newLayer(e, spec[1], spec[2], spec[3])
				end
			end)
			setEffect(e, nil)
			Selection:Set({ e })
			playPreview()
			render()
		end, { LayoutOrder = i })
	end

	-- timeline and inspector: side by side on a wide panel, stacked on a narrow one
	local body = make("Frame", { Parent = root, BackgroundTransparency = 1, BorderSizePixel = 0 })
	timeline = make("Frame", { Parent = body, BackgroundColor3 = C.panel, BorderSizePixel = 0, ClipsDescendants = true })
	inspector = make("Frame", { Parent = body, BackgroundColor3 = C.bg, BorderSizePixel = 0, ClipsDescendants = true })
	local function arrange()
		local top = bar.AbsoluteSize.Y
		body.Position = UDim2.fromOffset(0, top)
		body.Size = UDim2.new(1, 0, 1, -top)
		if body.AbsoluteSize.X >= SIDE_BY_SIDE then
			timeline.Position = UDim2.fromOffset(0, 0)
			timeline.Size = UDim2.new(1, -INSPECTOR_W, 1, 0)
			inspector.Position = UDim2.new(1, -INSPECTOR_W, 0, 0)
			inspector.Size = UDim2.new(0, INSPECTOR_W, 1, 0)
		else
			local ih = math.min(INSPECTOR_H, math.floor(body.AbsoluteSize.Y * 0.55))
			timeline.Position = UDim2.fromOffset(0, 0)
			timeline.Size = UDim2.new(1, 0, 1, -ih)
			inspector.Position = UDim2.new(0, 0, 1, -ih)
			inspector.Size = UDim2.new(1, 0, 0, ih)
		end
	end
	bar:GetPropertyChangedSignal("AbsoluteSize"):Connect(arrange)
	root:GetPropertyChangedSignal("AbsoluteSize"):Connect(arrange)
	arrange()
	timeline:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() renderTimeline() end)
	inspector:GetPropertyChangedSignal("AbsoluteSize"):Connect(function() renderInspector() end)
	render()
end

---------------------------------------------------------------- dragging (an overlay catches the mouse until release)
local function drag(onMove, onEnd)
	local overlay = make("TextButton", {
		Parent = widget, BackgroundTransparency = 1, Text = "", Size = UDim2.fromScale(1, 1), ZIndex = 100, AutoButtonColor = false,
	})
	state.dragging = true
	local done = false
	local function finish()
		if done then return end
		done = true
		overlay:Destroy()
		state.dragging = false
		if onEnd then onEnd() end
		render()
	end
	overlay.InputChanged:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			onMove(widget:GetRelativeMousePosition())
		end
	end)
	overlay.InputEnded:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseButton1 then
			finish()
		end
	end)
	overlay.MouseLeave:Connect(finish)
end

---------------------------------------------------------------- timeline
local function viewSeconds()
	if state.dragging and state.view then
		return state.view
	end
	local d = state.effect and Player.duration(state.effect) or 1
	state.view = math.max(1, math.ceil(d * 1.15 * 2) / 2)
	return state.view
end

local function niceStep(pps)
	for _, s in ipairs({ 0.05, 0.1, 0.25, 0.5, 1, 2, 5 }) do
		if s * pps >= 48 then
			return s
		end
	end
	return 10
end

local playhead
renderTimeline = function()
	if not timeline then return end
	timeline:ClearAllChildren()
	playhead = nil
	local e = state.effect
	local W = timeline.AbsoluteSize.X - NAME_W - 14
	if W < 50 then return end
	if not e then
		label(timeline, "Select an effect in the Explorer, press New, or start from a preset.", {
			Size = UDim2.new(1, -24, 1, 0), Position = UDim2.fromOffset(12, 0), TextColor3 = C.dim, TextXAlignment = Enum.TextXAlignment.Center,
		})
		return
	end
	local view = viewSeconds()
	local pps = W / view
	local function x(t) return NAME_W + t * pps end
	-- ruler
	local step = niceStep(pps)
	local t = 0
	while t <= view + 1e-6 do
		make("Frame", { Parent = timeline, BackgroundColor3 = C.grid, BorderSizePixel = 0, Size = UDim2.new(0, 1, 1, 0), Position = UDim2.fromOffset(x(t), 0) })
		label(timeline, (step < 1 and string.format("%.2f", t) or string.format("%g", t)) .. "s", {
			Size = UDim2.fromOffset(44, RULER_H), Position = UDim2.fromOffset(x(t) + 3, 0), TextSize = 12, TextColor3 = C.dim, Font = Enum.Font.RobotoMono,
		})
		t += step
	end
	make("Frame", { Parent = timeline, BackgroundColor3 = C.border, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1), Position = UDim2.fromOffset(0, RULER_H) })
	-- the ruler: click or drag to go to a moment and hold it
	local ruler = make("TextButton", {
		Parent = timeline, Text = "", AutoButtonColor = false, BackgroundTransparency = 1, ZIndex = 4,
		Position = UDim2.fromOffset(NAME_W - 6, 0), Size = UDim2.new(1, -(NAME_W - 6), 0, RULER_H),
	})
	local function tAt(mx)
		return snap((mx - timeline.AbsolutePosition.X - NAME_W) / pps)
	end
	ruler.MouseButton1Down:Connect(function()
		seek(tAt(widget:GetRelativeMousePosition().X))
		local last = nil
		drag(function(mp)
			local tt = tAt(mp.X)
			if tt ~= last then
				last = tt
				seek(tt)
			end
		end)
	end)
	label(timeline, "click the ruler to hold a moment", {
		Position = UDim2.fromOffset(8, 0), Size = UDim2.fromOffset(NAME_W - 14, RULER_H), TextSize = 11, TextColor3 = C.dim, TextTruncate = Enum.TextTruncate.AtEnd,
	})

	local rows = make("ScrollingFrame", {
		Parent = timeline, BackgroundTransparency = 1, BorderSizePixel = 0, Position = UDim2.fromOffset(0, RULER_H + 1),
		Size = UDim2.new(1, 0, 1, -RULER_H - 1), ScrollBarThickness = 8, CanvasSize = UDim2.new(0, 0, 0, 0),
		AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollingDirection = Enum.ScrollingDirection.Y,
	})
	local layers = Player.layersOf(e)
	for i, l in ipairs(layers) do
		local kind = kindOf(l)
		local kc = C.kind[kind] or C.dim
		local muted = l:GetAttribute("vfxMute")
		local selected = l == state.layer
		local row = make("TextButton", {
			Parent = rows, Text = "", AutoButtonColor = false, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, ROW_H),
			Position = UDim2.fromOffset(0, (i - 1) * ROW_H),
			BackgroundColor3 = selected and C.row or C.panel, BackgroundTransparency = selected and 0 or (i % 2 == 0 and 0.4 or 1),
		})
		row.MouseButton1Down:Connect(function()
			state.layer = l
			Selection:Set({ l })
			render()
		end)
		if selected then
			make("Frame", { Parent = row, BackgroundColor3 = C.accent, BorderSizePixel = 0, Size = UDim2.new(0, 3, 1, 0) })
		end
		-- mute
		local m = make("TextButton", {
			Parent = row, Text = muted and "o" or "●", Font = FONT_B, TextSize = 12, TextColor3 = muted and C.dim or kc,
			BackgroundTransparency = 1, Size = UDim2.fromOffset(20, ROW_H), Position = UDim2.fromOffset(6, 0),
		})
		m.MouseButton1Click:Connect(function()
			record(muted and "unmute" or "mute", function() l:SetAttribute("vfxMute", (not muted) or nil) end)
		end)
		label(row, l.Name, { Position = UDim2.fromOffset(26, 0), Size = UDim2.new(0, NAME_W - 70, 1, 0), TextTruncate = Enum.TextTruncate.AtEnd,
			TextColor3 = muted and C.dim or C.text, Font = selected and FONT_B or FONT })
		label(row, kind, { Position = UDim2.fromOffset(NAME_W - 46, 0), Size = UDim2.new(0, 42, 1, 0), TextSize = 11, TextColor3 = C.dim })

		local s = l:GetAttribute("vfxStart") or 0
		local dur = l:GetAttribute("vfxDuration") or 0.3
		local alpha = muted and 0.75 or 0
		-- the life after the layer itself (particles still on screen)
		local isP = kind == "Burst" or kind == "Stream"
		local barEnd = (kind == "Burst") and s or s + dur
		if isP then
			local tail = make("Frame", {
				Parent = row, BackgroundColor3 = kc, BorderSizePixel = 0, BackgroundTransparency = 0.78 + alpha * 0.2,
				Position = UDim2.fromOffset(x(barEnd), 8), Size = UDim2.fromOffset(math.max(1, l.Lifetime.Max * pps), ROW_H - 16),
			})
			tail.Active = false
		end
		-- the grab: whole bar moves the start
		local bar
		if kind == "Burst" then
			bar = make("TextButton", {
				Parent = row, Text = "", AutoButtonColor = false, BorderSizePixel = 0, BackgroundColor3 = kc, BackgroundTransparency = alpha,
				Size = UDim2.fromOffset(11, 11), Position = UDim2.fromOffset(x(s), ROW_H / 2), AnchorPoint = Vector2.new(0.5, 0.5), Rotation = 45,
			})
			label(row, "x" .. tostring(l:GetAttribute("vfxCount") or 0), {
				Position = UDim2.fromOffset(x(s) + 10, 0), Size = UDim2.fromOffset(50, ROW_H), TextSize = 12, TextColor3 = C.dim,
			})
		else
			bar = make("TextButton", {
				Parent = row, Text = "", AutoButtonColor = false, BorderSizePixel = 0, BackgroundColor3 = kc, BackgroundTransparency = 0.15 + alpha,
				Size = UDim2.fromOffset(math.max(4, dur * pps), ROW_H - 10), Position = UDim2.fromOffset(x(s), 5),
			}, { make("UICorner", { CornerRadius = UDim.new(0, 3) }) })
			-- the right edge: lengthen
			local edge = make("TextButton", {
				Parent = bar, Text = "", AutoButtonColor = false, BorderSizePixel = 0, BackgroundColor3 = C.text, BackgroundTransparency = 0.6,
				Size = UDim2.new(0, 6, 1, 0), Position = UDim2.new(1, -6, 0, 0), ZIndex = 2,
			})
			edge.MouseButton1Down:Connect(function()
				state.layer = l
				local id = ChangeHistoryService:TryBeginRecording("VFX: duration")
				drag(function(mp)
					local t1 = snap((mp.X - timeline.AbsolutePosition.X - NAME_W) / pps)
					l:SetAttribute("vfxDuration", math.max(SNAP, t1 - (l:GetAttribute("vfxStart") or 0)))
					renderTimeline()
					renderInspector()
				end, function()
					if id then ChangeHistoryService:FinishRecording(id, Enum.FinishRecordingOperation.Commit) end
				end)
			end)
		end
		bar.MouseButton1Down:Connect(function()
			state.layer = l
			Selection:Set({ l })
			local mp0 = widget:GetRelativeMousePosition()
			local s0 = l:GetAttribute("vfxStart") or 0
			local id = ChangeHistoryService:TryBeginRecording("VFX: start")
			drag(function(mp)
				l:SetAttribute("vfxStart", snap(s0 + (mp.X - mp0.X) / pps))
				renderTimeline()
				renderInspector()
			end, function()
				if id then ChangeHistoryService:FinishRecording(id, Enum.FinishRecordingOperation.Commit) end
			end)
		end)
	end
	rows.CanvasSize = UDim2.new(0, 0, 0, #layers * ROW_H)
	playhead = make("Frame", {
		Parent = timeline, BackgroundColor3 = C.accent, BorderSizePixel = 0, Size = UDim2.new(0, 2, 1, 0),
		Position = UDim2.fromOffset(x(0), 0), Visible = state.handle ~= nil, ZIndex = 5,
	})
	playhead:SetAttribute("pps", pps)
end

---------------------------------------------------------------- inspector
local LABEL_W = 92
local FIELD_X = 96
local SWATCHES = {
	rgb(255, 255, 255), rgb(255, 240, 200), rgb(255, 214, 120), rgb(255, 170, 60), rgb(255, 110, 30), rgb(230, 60, 30),
	rgb(150, 28, 20), rgb(255, 120, 180), rgb(190, 120, 255), rgb(150, 230, 255), rgb(90, 170, 255), rgb(120, 240, 140),
	rgb(160, 160, 160), rgb(80, 74, 70), rgb(20, 20, 20),
}

local function rangeText(r)
	return r.Min == r.Max and string.format("%g", r.Min) or string.format("%g, %g", r.Min, r.Max)
end

local function parseRange(text)
	local a, b = text:match("^%s*([%d%.%-]+)%s*[,%s]%s*([%d%.%-]+)%s*$")
	a, b = tonumber(a), tonumber(b)
	if not a then
		a = tonumber(text)
		b = a
	end
	if a and b then
		return NR(math.min(a, b), math.max(a, b))
	end
	return nil
end

local function toHex(c)
	return string.format("#%02X%02X%02X", math.floor(c.R * 255 + 0.5), math.floor(c.G * 255 + 0.5), math.floor(c.B * 255 + 0.5))
end

local function parseColor(text)
	local t = text:gsub("%s", "")
	local h = t:match("^#?(%x%x%x%x%x%x)$")
	if h then
		return Color3.fromHex(h)
	end
	local r, g, b = t:match("^(%d+),(%d+),(%d+)$")
	if r then
		return rgb(math.min(255, tonumber(r)), math.min(255, tonumber(g)), math.min(255, tonumber(b)))
	end
	return nil
end

-- a list of choices under a control; a click anywhere else closes it
local function dropdown(anchor, items, current, onPick)
	local overlay = make("TextButton", {
		Parent = widget, BackgroundTransparency = 1, Text = "", AutoButtonColor = false, Size = UDim2.fromScale(1, 1), ZIndex = 200,
	})
	local ap, as = anchor.AbsolutePosition, anchor.AbsoluteSize
	local h = math.min(#items * 20 + 4, widget.AbsoluteSize.Y - 8)
	local y = ap.Y + as.Y + 2
	if y + h > widget.AbsoluteSize.Y then
		y = math.max(4, ap.Y - h - 2)
	end
	local list = make("ScrollingFrame", {
		Parent = overlay, Position = UDim2.fromOffset(ap.X, y), Size = UDim2.fromOffset(math.max(as.X, 160), h), BackgroundColor3 = C.panel,
		BorderSizePixel = 0, ZIndex = 201, ScrollBarThickness = 4, CanvasSize = UDim2.fromOffset(0, #items * 20 + 4),
	}, {
		make("UIStroke", { Color = C.border }),
		make("UIListLayout", { SortOrder = Enum.SortOrder.LayoutOrder }),
		make("UIPadding", { PaddingTop = UDim.new(0, 2) }),
	})
	for i, it in ipairs(items) do
		local on = it == current
		local b = make("TextButton", {
			Parent = list, LayoutOrder = i, Size = UDim2.new(1, -4, 0, 20), BackgroundColor3 = on and C.accent or C.panel, BackgroundTransparency = on and 0 or 1,
			TextColor3 = on and C.accentText or C.text, Font = FONT, TextSize = 13, Text = "  " .. it, TextXAlignment = Enum.TextXAlignment.Left,
			ZIndex = 202, AutoButtonColor = true, BorderSizePixel = 0,
		})
		b.MouseButton1Click:Connect(function()
			overlay:Destroy()
			onPick(it)
		end)
	end
	overlay.MouseButton1Click:Connect(function()
		overlay:Destroy()
	end)
end

-- rows of labelled controls, top to bottom, in a scrolling column
local function form(parent, width)
	local f = { y = 0, w = width - FIELD_X - 10 }
	local function rowLabel(text)
		label(parent, text, { Position = UDim2.fromOffset(0, f.y), Size = UDim2.fromOffset(LABEL_W, 22), TextColor3 = C.dim, TextSize = 13,
			TextTruncate = Enum.TextTruncate.AtEnd })
	end
	local function box(x, w, text, onSet)
		local b = make("TextBox", {
			Parent = parent, Position = UDim2.fromOffset(x, f.y), Size = UDim2.fromOffset(w, 22), BackgroundColor3 = C.panel, BorderSizePixel = 0,
			TextColor3 = C.text, Font = Enum.Font.RobotoMono, TextSize = 13, Text = text, ClearTextOnFocus = false,
			TextXAlignment = Enum.TextXAlignment.Left, TextTruncate = Enum.TextTruncate.AtEnd,
		}, { make("UIPadding", { PaddingLeft = UDim.new(0, 5) }), make("UICorner", { CornerRadius = UDim.new(0, 4) }) })
		b.FocusLost:Connect(function()
			if b.Text ~= text then
				onSet(b.Text)
			end
			render()
		end)
		return b
	end
	function f.section(title)
		local closed = state.closed[title]
		f.y += 4
		local h = make("TextButton", {
			Parent = parent, Position = UDim2.fromOffset(0, f.y), Size = UDim2.new(1, -8, 0, 20), BackgroundColor3 = C.row, BorderSizePixel = 0,
			AutoButtonColor = true, Text = (closed and "+  " or "-  ") .. title, Font = FONT_B, TextSize = 13, TextColor3 = C.text,
			TextXAlignment = Enum.TextXAlignment.Left,
		}, { make("UIPadding", { PaddingLeft = UDim.new(0, 6) }), make("UICorner", { CornerRadius = UDim.new(0, 3) }) })
		h.MouseButton1Click:Connect(function()
			state.closed[title] = not closed
			renderInspector()
		end)
		f.y += 26
		return not closed
	end
	function f.text(name, value, onSet)
		rowLabel(name)
		box(FIELD_X, f.w, value, onSet)
		f.y += 26
	end
	function f.num(name, value, onSet)
		f.text(name, string.format("%g", value), function(t)
			local n = tonumber(t)
			if n then onSet(n) end
		end)
	end
	function f.range(name, r, onSet)
		f.text(name, rangeText(r), function(t)
			local v = parseRange(t)
			if v then onSet(v) end
		end)
	end
	-- Vector2 / Vector3, one box per component
	function f.vec(name, v, onSet)
		rowLabel(name)
		local comps = typeof(v) == "Vector2" and { v.X, v.Y } or { v.X, v.Y, v.Z }
		local n = #comps
		local w = math.floor((f.w - 4 * (n - 1)) / n)
		for i = 1, n do
			box(FIELD_X + (i - 1) * (w + 4), w, string.format("%g", comps[i]), function(t)
				local x = tonumber(t)
				if not x then return end
				local c = table.clone(comps)
				c[i] = x
				onSet(n == 2 and Vector2.new(c[1], c[2]) or Vector3.new(c[1], c[2], c[3]))
			end)
		end
		f.y += 26
	end
	function f.enum(name, enumType, current, onSet)
		rowLabel(name)
		local b = btn(parent, current.Name .. "  v", function() end, {
			Position = UDim2.fromOffset(FIELD_X, f.y), Size = UDim2.fromOffset(f.w, 22), TextXAlignment = Enum.TextXAlignment.Left,
		})
		make("UIPadding", { Parent = b, PaddingLeft = UDim.new(0, 6) })
		b.MouseButton1Click:Connect(function()
			local names = {}
			for _, it in ipairs(enumType:GetEnumItems()) do table.insert(names, it.Name) end
			dropdown(b, names, current.Name, function(nm) onSet(enumType[nm]) end)
		end)
		f.y += 26
	end
	function f.choice(name, options, current, onSet)
		rowLabel(name)
		local w = math.floor((f.w - 4 * (#options - 1)) / #options)
		for i, o in ipairs(options) do
			btn(parent, o, function() if o ~= current then onSet(o) end end, {
				Position = UDim2.fromOffset(FIELD_X + (i - 1) * (w + 4), f.y), Size = UDim2.fromOffset(w, 22),
				BackgroundColor3 = o == current and C.accent or C.button, TextColor3 = o == current and C.accentText or C.buttonText,
			})
		end
		f.y += 26
	end
	function f.bool(name, v, onSet)
		f.choice(name, { "Off", "On" }, v and "On" or "Off", function(o) onSet(o == "On") end)
	end
	function f.color(name, c, onSet)
		rowLabel(name)
		make("Frame", { Parent = parent, Position = UDim2.fromOffset(FIELD_X, f.y), Size = UDim2.fromOffset(22, 22), BackgroundColor3 = c, BorderSizePixel = 0 },
			{ make("UICorner", { CornerRadius = UDim.new(0, 4) }), make("UIStroke", { Color = C.border }) })
		box(FIELD_X + 26, f.w - 26, toHex(c), function(t)
			local v = parseColor(t)
			if v then onSet(v) end
		end)
		f.y += 26
	end
	function f.note(text, color)
		local lines = 1
		for _ in text:gmatch("\n") do lines += 1 end
		label(parent, text, { Position = UDim2.fromOffset(0, f.y), Size = UDim2.new(1, -8, 0, lines * 30), TextSize = 12, TextColor3 = color or C.dim,
			TextWrapped = true, TextYAlignment = Enum.TextYAlignment.Top })
		f.y += lines * 30 + 2
	end
	return f
end

-- a NumberSequence drawn big: click to add, drag to move, right click to remove
local function curveEditor(parent, l, prop)
	local vmin, vmax = 0, 1
	if prop == "Size" then
		local m = 0
		for _, kp in ipairs(l.Size.Keypoints) do m = math.max(m, kp.Value) end
		vmax = state.sizeMax[l] or math.max(1, math.ceil(m * 1.25))
		state.sizeMax[l] = vmax
	elseif prop == "Squash" then
		vmin, vmax = -3, 3
	end
	local span = vmax - vmin
	local graph = make("TextButton", {
		Parent = parent, Text = "", AutoButtonColor = false, BackgroundColor3 = C.panel, BorderSizePixel = 0,
		Position = UDim2.fromOffset(34, 0), Size = UDim2.new(1, -40, 1, -16), ClipsDescendants = false,
	})
	local W, H = graph.AbsoluteSize.X, graph.AbsoluteSize.Y
	local GP = graph.AbsolutePosition    -- kept: a redraw during a drag replaces the graph
	if W < 20 or H < 20 then
		return
	end
	for i = 0, 4 do
		make("Frame", { Parent = graph, BackgroundColor3 = C.grid, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1), Position = UDim2.fromOffset(0, math.floor(H * i / 4)) })
		make("Frame", { Parent = graph, BackgroundColor3 = C.grid, BorderSizePixel = 0, Size = UDim2.new(0, 1, 1, 0), Position = UDim2.fromOffset(math.floor(W * i / 4), 0) })
		label(parent, string.format("%g", vmin + span * (4 - i) / 4), {
			Position = UDim2.fromOffset(0, math.floor(H * i / 4) - 7), Size = UDim2.fromOffset(30, 14), TextSize = 11, TextColor3 = C.dim,
			TextXAlignment = Enum.TextXAlignment.Right, Font = Enum.Font.RobotoMono,
		})
	end
	label(parent, "particle life  0 .. 1", { Position = UDim2.new(1, -150, 1, -14), Size = UDim2.fromOffset(144, 14), TextSize = 11, TextColor3 = C.dim,
		TextXAlignment = Enum.TextXAlignment.Right })
	local kps = l[prop].Keypoints
	local function pos(kp)
		return Vector2.new(kp.Time * W, (1 - math.clamp((kp.Value - vmin) / span, 0, 1)) * H)
	end
	local kc = C.kind[kindOf(l)] or C.accent
	for i = 1, #kps - 1 do
		local a, b = pos(kps[i]), pos(kps[i + 1])
		local d = b - a
		make("Frame", {
			Parent = graph, BackgroundColor3 = kc, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromOffset((a.X + b.X) / 2, (a.Y + b.Y) / 2), Size = UDim2.fromOffset(d.Magnitude + 1, 2),
			Rotation = math.deg(math.atan2(d.Y, d.X)),
		})
	end
	local readout = label(parent, state.readout or "click: add a point   drag: move   right click: remove", {
		Position = UDim2.new(1, -330, 0, -18), Size = UDim2.fromOffset(324, 16), TextSize = 12,
		TextColor3 = state.readout and C.text or C.dim, TextXAlignment = Enum.TextXAlignment.Right, Font = Enum.Font.RobotoMono,
	})

	local function valueAt(mp)
		local v = vmin + math.clamp(1 - (mp.Y - GP.Y) / H, 0, 1) * span
		return math.floor(v * 100 + 0.5) / 100
	end
	local function write(list)
		table.sort(list, function(p, q) return p.Time < q.Time end)
		l[prop] = NumberSequence.new(list)
	end
	local function dragPoint(index)
		local id = ChangeHistoryService:TryBeginRecording("VFX: " .. prop .. " curve")
		drag(function(mp)
			local list = table.clone(l[prop].Keypoints)
			local v = valueAt(mp)
			local tt
			if index == 1 then
				tt = 0
			elseif index == #list then
				tt = 1
			else
				tt = math.clamp((mp.X - GP.X) / W, list[index - 1].Time + 0.001, list[index + 1].Time - 0.001)
			end
			list[index] = NumberSequenceKeypoint.new(tt, v, list[index].Envelope)
			write(list)
			state.readout = string.format("life %.2f   %s %.2f", tt, prop:lower(), v)
			renderInspector()
		end, function()
			state.readout = nil
			if id then ChangeHistoryService:FinishRecording(id, Enum.FinishRecordingOperation.Commit) end
		end)
	end
	for i, kp in ipairs(kps) do
		local p = pos(kp)
		local dot = make("TextButton", {
			Parent = graph, Text = "", AutoButtonColor = false, BackgroundColor3 = C.text, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0.5),
			Size = UDim2.fromOffset(9, 9), Position = UDim2.fromOffset(p.X, p.Y), ZIndex = 3,
		}, { make("UICorner", { CornerRadius = UDim.new(1, 0) }), make("UIStroke", { Color = kc, Thickness = 2 }) })
		dot.MouseButton1Down:Connect(function() dragPoint(i) end)
		dot.MouseButton2Click:Connect(function()
			if i == 1 or i == #kps then return end
			record("remove point", function()
				local list = table.clone(l[prop].Keypoints)
				table.remove(list, i)
				write(list)
			end)
		end)
		dot.MouseEnter:Connect(function()
			readout.Text = string.format("life %.2f   %s %.2f", kp.Time, prop:lower(), kp.Value)
			readout.TextColor3 = C.text
		end)
	end
	graph.MouseButton1Down:Connect(function()
		local list = table.clone(l[prop].Keypoints)
		if #list >= MAX_KEYPOINTS then
			readout.Text = "Roblox allows 20 points"
			return
		end
		local mp = widget:GetRelativeMousePosition()
		local tt = math.clamp((mp.X - GP.X) / W, 0.001, 0.999)
		table.insert(list, NumberSequenceKeypoint.new(tt, valueAt(mp), 0))
		write(list)
		local index
		for k, q in ipairs(l[prop].Keypoints) do
			if math.abs(q.Time - tt) < 1e-6 then index = k end
		end
		renderInspector()
		if index then dragPoint(index) end
	end)
end

-- the colour over the particle's life: stops on a strip, a hex box and swatches
local function colorAt(cs, t)
	local k = cs.Keypoints
	for i = 1, #k - 1 do
		if t >= k[i].Time and t <= k[i + 1].Time then
			local u = (t - k[i].Time) / math.max(1e-6, k[i + 1].Time - k[i].Time)
			return k[i].Value:Lerp(k[i + 1].Value, u)
		end
	end
	return k[#k].Value
end

local function colorEditor(parent, l)
	local W = parent.AbsoluteSize.X - 16
	if W < 40 then
		return
	end
	local kps = l.Color.Keypoints
	local sel = math.clamp(state.colorStop or 1, 1, #kps)
	state.colorStop = sel
	local function write(list)
		table.sort(list, function(p, q) return p.Time < q.Time end)
		l.Color = ColorSequence.new(list)
	end
	local strip = make("TextButton", {
		Parent = parent, Text = "", AutoButtonColor = false, BackgroundColor3 = Color3.new(1, 1, 1), BorderSizePixel = 0,
		Position = UDim2.fromOffset(8, 4), Size = UDim2.fromOffset(W, 30),
	}, { make("UIGradient", { Color = l.Color }), make("UICorner", { CornerRadius = UDim.new(0, 4) }), make("UIStroke", { Color = C.border }) })
	local SP = strip.AbsolutePosition
	strip.MouseButton1Down:Connect(function()
		if #kps >= MAX_KEYPOINTS then return end
		local t = math.clamp((widget:GetRelativeMousePosition().X - SP.X) / W, 0.001, 0.999)
		local list = table.clone(kps)
		table.insert(list, ColorSequenceKeypoint.new(t, colorAt(l.Color, t)))
		record("add colour stop", function() write(list) end)
		for k, q in ipairs(l.Color.Keypoints) do
			if math.abs(q.Time - t) < 1e-6 then state.colorStop = k end
		end
		renderInspector()
	end)
	for i, kp in ipairs(kps) do
		local m = make("TextButton", {
			Parent = parent, Text = "", AutoButtonColor = false, BackgroundColor3 = kp.Value, BorderSizePixel = 0, AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromOffset(8 + kp.Time * W, 38), Size = UDim2.fromOffset(12, 16), ZIndex = 3,
		}, { make("UICorner", { CornerRadius = UDim.new(0, 3) }), make("UIStroke", { Color = i == sel and C.accent or C.border, Thickness = i == sel and 2 or 1 }) })
		m.MouseButton1Down:Connect(function()
			state.colorStop = i
			renderInspector()
			if i == 1 or i == #kps then return end
			local id = ChangeHistoryService:TryBeginRecording("VFX: colour stop")
			drag(function(mp)
				local list = table.clone(l.Color.Keypoints)
				local t = math.clamp((mp.X - SP.X) / W, list[i - 1].Time + 0.001, list[i + 1].Time - 0.001)
				list[i] = ColorSequenceKeypoint.new(t, list[i].Value)
				write(list)
				renderInspector()
			end, function()
				if id then ChangeHistoryService:FinishRecording(id, Enum.FinishRecordingOperation.Commit) end
			end)
		end)
		m.MouseButton2Click:Connect(function()
			if i == 1 or i == #kps then return end
			local list = table.clone(l.Color.Keypoints)
			table.remove(list, i)
			record("remove colour stop", function() write(list) end)
			state.colorStop = math.max(1, i - 1)
		end)
	end
	local cur = kps[sel]
	local function setStop(c)
		local list = table.clone(l.Color.Keypoints)
		list[sel] = ColorSequenceKeypoint.new(list[sel].Time, c)
		record("colour", function() write(list) end)
	end
	label(parent, string.format("stop %d of %d   life %.2f", sel, #kps, cur.Time), { Position = UDim2.fromOffset(8, 60), Size = UDim2.fromOffset(170, 22), TextSize = 12, TextColor3 = C.dim })
	local hex = make("TextBox", {
		Parent = parent, Position = UDim2.fromOffset(180, 60), Size = UDim2.fromOffset(90, 22), BackgroundColor3 = C.panel, BorderSizePixel = 0,
		TextColor3 = C.text, Font = Enum.Font.RobotoMono, TextSize = 13, Text = toHex(cur.Value), ClearTextOnFocus = false,
	}, { make("UICorner", { CornerRadius = UDim.new(0, 4) }) })
	hex.FocusLost:Connect(function()
		local c = parseColor(hex.Text)
		if c then setStop(c) else render() end
	end)
	for i, c in ipairs(SWATCHES) do
		local s = make("TextButton", {
			Parent = parent, Text = "", AutoButtonColor = true, BackgroundColor3 = c, BorderSizePixel = 0,
			Position = UDim2.fromOffset(8 + (i - 1) * 24, 88), Size = UDim2.fromOffset(20, 20),
		}, { make("UICorner", { CornerRadius = UDim.new(0, 4) }), make("UIStroke", { Color = C.border }) })
		s.MouseButton1Click:Connect(function() setStop(c) end)
	end
	label(parent, "click the strip: add a stop   drag a stop: move   right click: remove", {
		Position = UDim2.fromOffset(8, 114), Size = UDim2.new(1, -16, 0, 16), TextSize = 11, TextColor3 = C.dim,
	})
end

renderInspector = function()
	if not inspector then return end
	inspector:ClearAllChildren()
	make("Frame", { Parent = inspector, BackgroundColor3 = C.border, BorderSizePixel = 0, Size = UDim2.new(1, 0, 0, 1) })
	make("Frame", { Parent = inspector, BackgroundColor3 = C.border, BorderSizePixel = 0, Size = UDim2.new(0, 1, 1, 0) })
	local l = state.layer
	if not (l and l.Parent and state.effect and l:IsDescendantOf(state.effect)) then
		label(inspector, state.effect and "Pick a layer on the timeline." or "", {
			Position = UDim2.fromOffset(12, 8), Size = UDim2.new(1, -24, 0, 22), TextColor3 = C.dim,
		})
		return
	end
	local kind = kindOf(l)
	local isP = kind == "Burst" or kind == "Stream"
	local leftW = isP and 300 or math.min(360, inspector.AbsoluteSize.X - 20)
	local left = make("ScrollingFrame", {
		Parent = inspector, BackgroundTransparency = 1, BorderSizePixel = 0, Position = UDim2.fromOffset(10, 6),
		Size = UDim2.new(0, leftW, 1, -8), ScrollBarThickness = 4, CanvasSize = UDim2.new(0, 0, 0, 0),
		AutomaticCanvasSize = Enum.AutomaticSize.Y, ScrollingDirection = Enum.ScrollingDirection.Y,
	})
	local f = form(left, leftW)
	local holder = l.Parent:IsA("Attachment") and l.Parent or nil
	local function set(name, fn) record(name, fn) end

	if f.section("Timing") then
		f.text("Name", l.Name, function(t)
			if t == "" then return end
			set("rename layer", function()
				l.Name = t
				if holder then holder.Name = t end
			end)
		end)
		f.num("Start (s)", l:GetAttribute("vfxStart") or 0, function(n) set("start", function() l:SetAttribute("vfxStart", math.max(0, n)) end) end)
		if isP then
			f.choice("Mode", { "Burst", "Stream" }, kind, function(m)
				set("mode", function()
					l:SetAttribute("vfxMode", m)
					if m == "Burst" and not l:GetAttribute("vfxCount") then l:SetAttribute("vfxCount", 20) end
					if m == "Stream" and not l:GetAttribute("vfxDuration") then l:SetAttribute("vfxDuration", 1) end
				end)
			end)
			if kind == "Burst" then
				f.num("Count", l:GetAttribute("vfxCount") or 0, function(n) set("count", function() l:SetAttribute("vfxCount", math.max(1, math.floor(n))) end) end)
			else
				f.num("Duration (s)", l:GetAttribute("vfxDuration") or 1, function(n) set("duration", function() l:SetAttribute("vfxDuration", math.max(SNAP, n)) end) end)
				f.num("Rate /s", l.Rate, function(n) set("rate", function() l.Rate = math.max(0, n) end) end)
			end
		elseif kind == "Mesh" then
			f.num("Duration (s)", l:GetAttribute("vfxDuration") or 1, function(n) set("duration", function() l:SetAttribute("vfxDuration", math.max(SNAP, n)) end) end)
			f.num("Spin (deg/s)", l:GetAttribute("vfxSpin") or 0, function(n) set("spin", function() l:SetAttribute("vfxSpin", n) end) end)
			f.num("Frame rate", l:GetAttribute("vfxFps") or 16, function(n) set("frame rate", function() l:SetAttribute("vfxFps", math.max(0.1, n)) end) end)
			f.num("Opacity", l:GetAttribute("vfxOpacity") or 1, function(n) set("opacity", function() l:SetAttribute("vfxOpacity", math.clamp(n, 0, 1)) end) end)
			f.num("Fade (s)", l:GetAttribute("vfxFade") or 0.15, function(n) set("fade", function() l:SetAttribute("vfxFade", math.max(0, n)) end) end)
			f.note(string.format("A VFX Lab ribbon with %d uploaded frames. It turns round the Root: move the Root to place it.", #Player.framesOf(l)))
		else
			f.num("Duration (s)", l:GetAttribute("vfxDuration") or 0.3, function(n) set("duration", function() l:SetAttribute("vfxDuration", math.max(SNAP, n)) end) end)
			f.num(kind == "Light" and "Brightness" or "Strength", l:GetAttribute("vfxPeak") or 1, function(n) set("peak", function() l:SetAttribute("vfxPeak", math.max(0, n)) end) end)
			if kind == "Light" then
				f.num("Range", l.Range, function(n) set("range", function() l.Range = math.clamp(n, 0, 60) end) end)
				f.color("Colour", l.Color, function(c) set("colour", function() l.Color = c end) end)
				f.bool("Shadows", l.Shadows, function(b) set("shadows", function() l.Shadows = b end) end)
				f.num("Flicker", l:GetAttribute("vfxFlicker") or 0, function(n) set("flicker", function() l:SetAttribute("vfxFlicker", math.clamp(n, 0, 1)) end) end)
			else
				f.num("Radius", l:GetAttribute("vfxRadius") or 60, function(n) set("radius", function() l:SetAttribute("vfxRadius", math.max(0, n)) end) end)
				f.note("The shake plays in the game only: it moves the player's camera, full at the centre, nothing at the radius.")
			end
		end
	end
	if holder and f.section("Placement") then
		f.vec("Offset", holder.Position, function(v) set("offset", function() holder.Position = v end) end)
		f.vec("Rotation", holder.Orientation, function(v) set("rotation", function() holder.Orientation = v end) end)
		if isP then
			f.enum("Direction", Enum.NormalId, l.EmissionDirection, function(v) set("direction", function() l.EmissionDirection = v end) end)
			f.vec("Spread", l.SpreadAngle, function(v) set("spread", function() l.SpreadAngle = v end) end)
		end
	end
	if isP then
		if f.section("Motion") then
			f.range("Lifetime", l.Lifetime, function(v) set("lifetime", function() l.Lifetime = v end) end)
			f.range("Speed", l.Speed, function(v) set("speed", function() l.Speed = v end) end)
			f.vec("Acceleration", l.Acceleration, function(v) set("acceleration", function() l.Acceleration = v end) end)
			f.num("Drag", l.Drag, function(n) set("drag", function() l.Drag = n end) end)
			f.range("Rotation", l.Rotation, function(v) set("rotation", function() l.Rotation = v end) end)
			f.range("Spin /s", l.RotSpeed, function(v) set("spin", function() l.RotSpeed = v end) end)
			f.num("Inherit vel.", l.VelocityInheritance, function(n) set("velocity inheritance", function() l.VelocityInheritance = n end) end)
			f.bool("Locked to part", l.LockedToPart, function(b) set("locked", function() l.LockedToPart = b end) end)
		end
		if f.section("Look") then
			f.text("Texture", l.Texture, function(t)
				t = t:gsub("^%s+", ""):gsub("%s+$", "")
				if t:match("^%d+$") then t = "rbxassetid://" .. t end
				set("texture", function()
					l.Texture = t
					if t:match("^rbxassetid://") then l:SetAttribute("vfxNote", nil) end
				end)
			end)
			f.num("Light emission", l.LightEmission, function(n) set("light emission", function() l.LightEmission = math.clamp(n, 0, 1) end) end)
			f.num("Light influence", l.LightInfluence, function(n) set("light influence", function() l.LightInfluence = math.clamp(n, 0, 1) end) end)
			f.num("Brightness", l.Brightness, function(n) set("brightness", function() l.Brightness = math.max(0, n) end) end)
			f.enum("Orientation", Enum.ParticleOrientation, l.Orientation, function(v) set("orientation", function() l.Orientation = v end) end)
			f.num("Z offset", l.ZOffset, function(n) set("z offset", function() l.ZOffset = n end) end)
		end
		if f.section("Flipbook") then
			f.enum("Layout", Enum.ParticleFlipbookLayout, l.FlipbookLayout, function(v) set("flipbook layout", function() l.FlipbookLayout = v end) end)
			if l.FlipbookLayout == Enum.ParticleFlipbookLayout.Custom then
				f.vec("Grid", Vector2.new(l.FlipbookSizeX, l.FlipbookSizeY), function(v)
					set("flipbook grid", function()
						l.FlipbookSizeX = math.max(1, math.floor(v.X))
						l.FlipbookSizeY = math.max(1, math.floor(v.Y))
					end)
				end)
			end
			f.enum("Mode", Enum.ParticleFlipbookMode, l.FlipbookMode, function(v) set("flipbook mode", function() l.FlipbookMode = v end) end)
			f.range("Frames /s", l.FlipbookFramerate, function(v) set("flipbook fps", function() l.FlipbookFramerate = v end) end)
			f.bool("Random start", l.FlipbookStartRandom, function(b) set("flipbook start", function() l.FlipbookStartRandom = b end) end)
			-- (FlipbookIncompatible is Roblox's fixed help text, the same on every emitter: not a status.
			-- The real check is the PNG's size, read when the flipbook is imported.)
			f.note("The atlas must be exactly 1024 x 1024.")
		end
	end
	local note = l:GetAttribute("vfxNote")
	if note then
		f.note(note, C.warn)
	end
	f.y += 8
	left.CanvasSize = UDim2.fromOffset(0, f.y)

	if not isP then
		return
	end
	-- right: the curves and the colour
	local right = make("Frame", { Parent = inspector, BackgroundTransparency = 1, Position = UDim2.fromOffset(leftW + 22, 6), Size = UDim2.new(1, -(leftW + 30), 1, -10) })
	local tabs = make("Frame", { Parent = right, BackgroundTransparency = 1, Size = UDim2.new(1, 0, 0, 24) }, {
		make("UIListLayout", { FillDirection = Enum.FillDirection.Horizontal, Padding = UDim.new(0, 4), SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center }),
	})
	for i, c in ipairs({ "Size", "Transparency", "Squash", "Colour" }) do
		btn(tabs, c, function()
			state.curve = c
			renderInspector()
		end, { LayoutOrder = i, BackgroundColor3 = state.curve == c and C.accent or C.button, TextColor3 = state.curve == c and C.accentText or C.buttonText })
	end
	if state.curve == "Size" then
		label(tabs, "top", { LayoutOrder = 5, Size = UDim2.fromOffset(28, 22), TextColor3 = C.dim, TextXAlignment = Enum.TextXAlignment.Right })
		local mb = make("TextBox", {
			Parent = tabs, LayoutOrder = 6, Size = UDim2.fromOffset(46, 22), BackgroundColor3 = C.panel, BorderSizePixel = 0, TextColor3 = C.text,
			Font = Enum.Font.RobotoMono, TextSize = 13, Text = string.format("%g", state.sizeMax[l] or 1), ClearTextOnFocus = false,
		}, { make("UICorner", { CornerRadius = UDim.new(0, 4) }) })
		mb.FocusLost:Connect(function()
			local n = tonumber(mb.Text)
			if n and n > 0 then state.sizeMax[l] = n end
			renderInspector()
		end)
	end
	if state.curve ~= "Colour" then
		make("Frame", { Parent = tabs, LayoutOrder = 7, Size = UDim2.fromOffset(90, 16), BackgroundColor3 = Color3.new(1, 1, 1), BorderSizePixel = 0 }, {
			make("UIGradient", { Color = l.Color }), make("UICorner", { CornerRadius = UDim.new(0, 4) }),
		})
	end
	local area = make("Frame", { Parent = right, BackgroundTransparency = 1, Position = UDim2.fromOffset(0, 46), Size = UDim2.new(1, 0, 1, -46) })
	if state.curve == "Colour" then
		area.Position = UDim2.fromOffset(0, 28)
		area.Size = UDim2.new(1, 0, 1, -28)
		colorEditor(area, l)
	else
		curveEditor(area, l, state.curve)
	end
end

---------------------------------------------------------------- rendering everything
render = function()
	if not root then return end
	local e = state.effect
	if e and not e.Parent then
		state.effect, state.layer = nil, nil
		e = nil
	end
	if not nameBox:IsFocused() then
		nameBox.Text = e and e.Name or ""
	end
	local h = state.handle
	playBtn.Text = (h and h.alive and h.holding and not h.paused) and "Release" or ((h and h.alive and not h.paused) and "Pause" or "Play")
	holdBtn.BackgroundColor3 = state.hold and C.accent or C.button
	holdBtn.TextColor3 = state.hold and C.accentText or C.buttonText
	loopBtn.BackgroundColor3 = state.loop and C.accent or C.button
	loopBtn.TextColor3 = state.loop and C.accentText or C.buttonText
	for s, b in pairs(speedBtns) do
		b.BackgroundColor3 = s == state.speed and C.accent or C.button
		b.TextColor3 = s == state.speed and C.accentText or C.buttonText
	end
	timeLabel.Text = e and string.format("%s s total", fmt(Player.duration(e))) or ""
	renderTimeline()
	renderInspector()
end

---------------------------------------------------------------- watching the effect and the selection
local effectConns = {}
local dirty = false
watch = function(e)
	for _, c in ipairs(effectConns) do c:Disconnect() end
	effectConns = {}
	if not e then return end
	local function mark() dirty = true end
	-- the Lab's preview card steps its frames every Heartbeat: watching it would redraw the
	-- panel every frame (the inspector jumped to the top and swallowed every edit)
	local function watched(d)
		return d.Name ~= "LabPreview" and not d:FindFirstAncestor("LabPreview") and d.Name ~= "LabMesh"
	end
	table.insert(effectConns, e.DescendantAdded:Connect(function(d) if watched(d) then mark() end end))
	table.insert(effectConns, e.DescendantRemoving:Connect(function(d) if watched(d) then mark() end end))
	table.insert(effectConns, e.AncestryChanged:Connect(mark))
	table.insert(effectConns, e:GetPropertyChangedSignal("Name"):Connect(mark))
	for _, d in ipairs(e:GetDescendants()) do
		if watched(d) then
			table.insert(effectConns, d.Changed:Connect(mark))
			table.insert(effectConns, d.AttributeChanged:Connect(mark))
		end
	end
	table.insert(effectConns, e.DescendantAdded:Connect(function(d)
		if not watched(d) then return end
		table.insert(effectConns, d.Changed:Connect(mark))
		table.insert(effectConns, d.AttributeChanged:Connect(mark))
	end))
end

local function onSelection()
	local first = Selection:Get()[1]
	local e = first and effectOf(first)
	if e then
		-- the layer: the emitter/light/shake picked, or the one inside a picked attachment
		local layer = nil
		if Player.kindOf(first) and first:GetAttribute("vfxStart") ~= nil then
			layer = first
		elseif first:IsA("Attachment") then
			for _, c in ipairs(first:GetChildren()) do
				if Player.kindOf(c) then layer = c end
			end
		elseif e == state.effect then
			layer = state.layer
		end
		setEffect(e, layer)
	end
	render()
end
Selection.SelectionChanged:Connect(onSelection)

RunService.Heartbeat:Connect(function()
	if dirty and not state.dragging then
		dirty = false
		local held = state.handle
		if held and held.alive and held.paused and not state.seekTo and state.effect then
			seek(held.time, true)
		end
		render()
	end
	local h = state.handle
	if h then
		if state.seekTo and h.alive and h.time >= state.seekTo then
			state.seekTo = nil
			h:Pause()
			h:SetSpeed(state.speed)
			render()
		end
		if playhead then
			playhead.Visible = true
			playhead.Position = UDim2.fromOffset(NAME_W + h.time * (playhead:GetAttribute("pps") or 0), 0)
		end
		if timeLabel then
			timeLabel.Text = string.format("%s / %s s%s", fmt(h.time), fmt(h.length), h.paused and "  paused" or "")
		end
		if not h.alive then
			state.handle = nil
			if state.loop and state.effect then
				playPreview()
			end
			render()
		end
	end
end)

settings().Studio.ThemeChanged:Connect(function()
	readTheme()
	build()
end)

plugin.Unloading:Connect(function()
	stopPreview()
	for _, c in ipairs(effectConns) do c:Disconnect() end
end)

build()
onSelection()
print("[Arctecto VFX] loaded, runtime " .. Player.version)
