Skip to content
RoRoScripts
open source pillar chase HUB preview
Unknown

open source pillar chase HUB

Script
[Legacy]RezurXscripts
Views
203
Likes
0
Dislikes
0
Added
Script
--[[
	Pillar Chase - Role Highlight Hub  (v2)
	Place 121255181515820  (Pillar Chase 2 / Halloween PT1)

	UI
	  Dark "Product Faker"-style window: Roboto Condensed typography, near
	  black panels, a centred floating window with shadow + hairline stroke,
	  a bottom icon rail with a sliding indicator, toast notifications and
	  scramble / fade / scale animations. Seven tabs: Roles, Visuals, HUD,
	  Tasks, Teleport, Extras, Info.

	FEATURES
	  * Role highlights: Monster=red, Survivor=green, Sentinel=amber (+Unknown)
	  * See-through-walls, fill opacity, per-role colour presets
	  * Name / role / distance labels
	  * Off-screen direction arrows, tracers, monster proximity alert
	  * Always-on status HUD (survivors alive, monster distance, your role)
	  * Tasks: objective ESP on every map + auto-collect.  It scans the map
	    and round FIRST; if you are not in a game (no map) or you are dead it
	    does nothing and the readout says "Not in game.".  Collection handles
	    both touch shards and ProximityPrompt objectives (snow, trash, blood,
	    keypads, valves, generators).
	  * Utility: anti-AFK, fullbright, no fog, noclip, FOV zoom, exit ESP,
	    crosshair, fps/ping
	  * Unlocks the mouse while the window is open (the game force-locks it,
	    which is why buttons were unclickable), restored automatically

	SAFETY
	  Everything here is LOCAL: Highlight, BillboardGui and the overlay are
	  never replicated, the server never sees them, and no game logic,
	  movement or hit detection is touched. No hooks, no remotes.

	ROLE DETECTION (verified live)
	  Character:GetAttribute("Type") == "Monster" / "Survivor" / "Sentinel"
	  Player:GetAttribute("EndCondition") == "MONSTER" (backup)
	  Anything else -> "Unknown" (never silently hidden).
]]

if game.PlaceId ~= 121255181515820 then
	return
end

-- single instance: if a previous build of this hub is still running (e.g. an
-- autoexec copy plus a manual run), tear it down first so two windows never
-- fight over the same key and the same scratch folders.
do
	local prev = getgenv and getgenv().PillarChaseHub
	if prev and type(prev.destroy) == "function" then
		pcall(prev.destroy)
	end
end

local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local CoreGui = game:GetService("CoreGui")
local Workspace = workspace

local LP = Players.LocalPlayer
local genv = (type(getgenv) == "function" and getgenv()) or _G

-- replace a previous instance from this session (never stack on re-run)
local previous = genv.PillarChaseHub
if type(previous) == "table" and type(previous.destroy) == "function" then
	pcall(previous.destroy)
end

-- ==========================================================================
-- state
-- ==========================================================================

local DEFAULTS = {
	enabled = true,
	throughWalls = true,
	fillTransparency = 0.55,
	outlineTransparency = 0.05,
	labels = true,
	showRole = true,
	showDistance = true,
	maxDist = 5000,
	self = false,
	freeMouse = true,
	hud = true,
	tracers = false,
	offscreen = true,
	monsterAlert = true,
	alertDist = 150,
	shardEsp = true,
	shardColor = { 90, 240, 255 },
	autoCollect = false,
	collectDelay = 0.1,
	collectHeight = 2,
	-- utility
	infiniteStamina = true,
	antiAfk = true,
	fullbright = false,
	noFog = false,
	noclip = false,
	speedMult = 1,
	cameraFov = 70,
	exitEsp = true,
	exitColor = { 255, 140, 40 },
	crosshair = false,
	crosshairColor = { 120, 255, 160 },
	fpsCounter = true,
	pingCounter = true,
	roles = {
		Monster = true,
		Survivor = true,
		Sentinel = true,
		Unknown = false,
	},
	colors = {
		Monster = { 255, 45, 60 },
		Survivor = { 60, 220, 110 },
		Sentinel = { 255, 190, 40 },
		Unknown = { 170, 175, 190 },
		Self = { 80, 200, 255 },
	},
	keybind = "K",
}

local STATE = {}
for k, v in pairs(DEFAULTS) do
	if type(v) == "table" then
		local t = {}
		for k2, v2 in pairs(v) do
			t[k2] = (type(v2) == "table") and { v2[1], v2[2], v2[3] } or v2
		end
		STATE[k] = t
	else
		STATE[k] = v
	end
end

local CONFIG_FILE = "pillar-chase-hub.json"

local function deepCopy(t)
	local out = {}
	for k, v in pairs(t) do
		out[k] = (type(v) == "table") and deepCopy(v) or v
	end
	return out
end

local function saveConfig()
	pcall(function()
		if type(writefile) ~= "function" then
			return
		end
		local function ser(v)
			local tv = type(v)
			if tv == "number" or tv == "boolean" then
				return tostring(v)
			elseif tv == "string" then
				return string.format("%q", v)
			elseif tv == "table" then
				local parts = {}
				for k, vv in pairs(v) do
					-- numeric keys (e.g. colour channels {255,45,60}) must stay
					-- numeric, otherwise they reload as c["1"] and every colour
					-- silently breaks.
					local key
					if type(k) == "number" then
						key = string.format("[%d]", k)
					else
						key = string.format("[%q]", tostring(k))
					end
					parts[#parts + 1] = key .. "=" .. ser(vv)
				end
				return "{" .. table.concat(parts, ",") .. "}"
			end
			return "nil"
		end
		writefile(CONFIG_FILE, "return " .. ser(STATE))
	end)
end

local function loadConfig()
	pcall(function()
		if type(isfile) ~= "function" or type(readfile) ~= "function" then
			return
		end
		if not isfile(CONFIG_FILE) then
			return
		end
		local fn = loadstring(readfile(CONFIG_FILE))
		if not fn then
			return
		end
		local saved = fn()
		if type(saved) ~= "table" then
			return
		end
		for k, v in pairs(saved) do
			if type(v) == "table" and type(STATE[k]) == "table" then
				for k2, v2 in pairs(v) do
					-- heal old configs where numeric keys were saved as strings
					local key = k2
					if type(k2) == "string" then
						local num = tonumber(k2)
						if num and tostring(num) == k2 then
							key = num
						end
					end
					STATE[k][key] = v2
				end
			else
				STATE[k] = v
			end
		end
	end)
end

loadConfig()

-- ==========================================================================
-- UI toolkit  ·  dark "Product Faker"-style skin
--   * Roboto Condensed typography, near-black panels
--   * centred floating window with shadow + hairline stroke
--   * bottom icon rail with a sliding active indicator
--   * toast notifications, scramble text, fade + scale animations
-- ==========================================================================

-- --------------------------------------------------------------------------
-- The UI layer is wrapped in a `do` block: its many one-off helpers must not
-- stay alive for the whole chunk (Luau allows 200 locals per function). Only
-- the names below are needed by the engines further down.
-- --------------------------------------------------------------------------
local THEME, rgb, new, corner, stroke, parentGui
local gui, win, Main, topbar, statusDot, selectTab, showWindow, hideWindow, closeNotice
local connections, colorSyncers, collectOneShard, hud
local infoStatus, infoMap, infoObj1, infoObj2, infoExit, infoShards
local infoRole, infoMonster, infoSurvivors, infoTracked, infoMouse
local keybindBtn, keybindCapturing, bannerText, bannerMap, bannerDot
local TP
do

local TweenService = game:GetService("TweenService")
local TextService = game:GetService("TextService")

THEME = {
	bg = Color3.fromRGB(8, 8, 10),
	panel = Color3.fromRGB(16, 16, 19),
	panel2 = Color3.fromRGB(26, 26, 30),
	line = Color3.fromRGB(255, 255, 255),
	accent = Color3.fromRGB(236, 238, 243),
	text = Color3.fromRGB(240, 242, 246),
	dim = Color3.fromRGB(150, 153, 162),
	icon = Color3.fromRGB(192, 195, 204),
	button = Color3.fromRGB(30, 30, 35),
	buttonHi = Color3.fromRGB(48, 49, 56),
	cardHover = Color3.fromRGB(22, 22, 26),
	active = Color3.fromRGB(70, 72, 80),
	on = Color3.fromRGB(236, 238, 243),
	off = Color3.fromRGB(40, 42, 48),
	danger = Color3.fromRGB(255, 72, 72),
	blue = Color3.fromRGB(150, 170, 210),
	yellow = Color3.fromRGB(238, 240, 200),
	-- legacy names still used by the world-space labels / overlay
	font = Enum.Font.Gotham,
	fontBold = Enum.Font.GothamBold,
}

local FONT = Font.new("rbxasset://fonts/families/RobotoCondensed.json", Enum.FontWeight.Regular, Enum.FontStyle.Normal)
local FONT_BOLD = Font.new("rbxasset://fonts/families/RobotoCondensed.json", Enum.FontWeight.Bold, Enum.FontStyle.Normal)
local FONT_ITALIC = Font.new("rbxasset://fonts/families/RobotoCondensed.json", Enum.FontWeight.Regular, Enum.FontStyle.Italic)

function rgb(c)
	if type(c) ~= "table" then
		return Color3.fromRGB(170, 175, 190)
	end
	local function ch(i)
		local v = c[i]
		if type(v) ~= "number" then
			v = c[tostring(i)]
		end
		if type(v) ~= "number" then
			v = 0
		end
		return math.floor(v + 0.5)
	end
	return Color3.fromRGB(ch(1), ch(2), ch(3))
end

function new(class, props, parent)
	local o = Instance.new(class)
	for k, v in pairs(props or {}) do
		if k ~= "Parent" then
			o[k] = v
		end
	end
	if parent then
		o.Parent = parent
	end
	return o
end

local function onClick(button, fn)
	-- connect every click signal a GuiButton can emit, but never run twice for
	-- one physical press (a mouse click fires MouseButton1Click AND Activated).
	local last = 0
	local function fire()
		local now = os.clock()
		if now - last < 0.05 then
			return
		end
		last = now
		fn()
	end
	pcall(function() button.MouseButton1Click:Connect(fire) end)
	pcall(function() button.Activated:Connect(fire) end)
	pcall(function() button.TouchTap:Connect(fire) end)
end

function corner(parent, radius)
	return new("UICorner", { CornerRadius = UDim.new(0, radius or 10) }, parent)
end

function stroke(parent, colour, thickness, transparency)
	return new("UIStroke", {
		Color = colour or Color3.fromRGB(255, 255, 255),
		Thickness = thickness or 1,
		Transparency = transparency or 0.86,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}, parent)
end

function parentGui(g)
	local ok = pcall(function()
		g.Parent = CoreGui
	end)
	if not ok or g.Parent == nil then
		g.Parent = LP:WaitForChild("PlayerGui")
	end
end

local function tween(obj, t, props, style, dir)
	local tw = TweenService:Create(obj, TweenInfo.new(t, style or Enum.EasingStyle.Quint, dir or Enum.EasingDirection.Out), props)
	tw:Play()
	return tw
end

-- every connection we make goes here so destroy() can tear them all down
connections = {}
local function bind(signal, fn)
	local c = signal:Connect(fn)
	table.insert(connections, c)
	return c
end

-- fade: group transparency animation used for open / close / tab changes ----
local fadeProps = {
	{ "GuiObject", "BackgroundTransparency" },
	{ "TextLabel", "TextTransparency" },
	{ "TextButton", "TextTransparency" },
	{ "TextBox", "TextTransparency" },
	{ "ImageLabel", "ImageTransparency" },
	{ "ImageButton", "ImageTransparency" },
	{ "ScrollingFrame", "ScrollBarImageTransparency" },
	{ "UIStroke", "Transparency" },
}
local originals = {}
local fadeExclude = {}

local function getOriginal(inst)
	local o = originals[inst]
	if o then
		return o
	end
	o = {}
	for _, pair in ipairs(fadeProps) do
		local okIs, is = pcall(inst.IsA, inst, pair[1])
		if okIs and is then
			local ok, v = pcall(function()
				return inst[pair[2]]
			end)
			if ok and type(v) == "number" then
				o[pair[2]] = v
			end
		end
	end
	originals[inst] = o
	inst.Destroying:Connect(function()
		originals[inst] = nil
	end)
	return o
end

local function isExcluded(inst)
	for _, root in ipairs(fadeExclude) do
		if inst == root or inst:IsDescendantOf(root) then
			return true
		end
	end
	return false
end

local function fade(root, alpha, t)
	local list = root:GetDescendants()
	table.insert(list, root)
	for _, inst in ipairs(list) do
		if not isExcluded(inst) then
			local goal
			for prop, v in pairs(getOriginal(inst)) do
				goal = goal or {}
				goal[prop] = v + (1 - v) * (1 - alpha)
			end
			if goal then
				if t and t > 0 then
					tween(inst, t, goal, Enum.EasingStyle.Quad)
				else
					for p, v in pairs(goal) do
						pcall(function()
							inst[p] = v
						end)
					end
				end
			end
		end
	end
end

-- scramble text reveal ------------------------------------------------------
local glyphs = "!<>-_\\/[]{}=+*^?#@$%&"
local scrambleIds = {}

local function scramble(lbl, text, duration)
	local id = (scrambleIds[lbl] or 0) + 1
	scrambleIds[lbl] = id
	task.spawn(function()
		local start, len = os.clock(), #text
		while lbl.Parent and scrambleIds[lbl] == id do
			local p = (os.clock() - start) / duration
			if p >= 1 then
				break
			end
			local revealed = math.floor(p * len)
			local out = table.create(len)
			for i = 1, len do
				local ch = text:sub(i, i)
				if i <= revealed or ch == " " then
					out[i] = ch
				else
					local r = math.random(1, #glyphs)
					out[i] = glyphs:sub(r, r)
				end
			end
			lbl.Text = table.concat(out)
			task.wait(0.03)
		end
		if scrambleIds[lbl] == id then
			lbl.Text = text
		end
	end)
end

-- hover / press feedback ----------------------------------------------------
local function fxGoal(btn, goal)
	local out = {}
	for k, v in pairs(goal) do
		if k ~= "ImageColor3" or btn:IsA("ImageLabel") or btn:IsA("ImageButton") then
			out[k] = v
		end
	end
	return out
end

local idleFx = { BackgroundColor3 = Color3.fromRGB(56, 56, 56), TextColor3 = Color3.fromRGB(193, 193, 193), ImageColor3 = Color3.fromRGB(193, 193, 193) }
local hoverFx = { BackgroundColor3 = Color3.fromRGB(72, 72, 72), TextColor3 = Color3.fromRGB(255, 255, 255), ImageColor3 = Color3.fromRGB(255, 255, 255) }
local activeFx = { BackgroundColor3 = Color3.fromRGB(84, 84, 84), TextColor3 = Color3.fromRGB(255, 255, 255), ImageColor3 = Color3.fromRGB(255, 255, 255) }

local function buttonFx(btn, normal, hovered, isActive)
	normal = normal or idleFx
	hovered = hovered or hoverFx
	local scale = btn:FindFirstChildOfClass("UIScale") or new("UIScale", {}, btn)
	local function active()
		return isActive and isActive()
	end
	btn.MouseEnter:Connect(function()
		if not active() then
			tween(btn, 0.15, fxGoal(btn, hovered))
		end
	end)
	btn.MouseLeave:Connect(function()
		tween(scale, 0.2, { Scale = 1 })
		if not active() then
			tween(btn, 0.2, fxGoal(btn, normal))
		end
	end)
	btn.MouseButton1Down:Connect(function()
		tween(scale, 0.08, { Scale = 0.92 })
	end)
	btn.MouseButton1Up:Connect(function()
		tween(scale, 0.35, { Scale = 1 }, Enum.EasingStyle.Back)
	end)
end

-- ==========================================================================
-- window
-- ==========================================================================

local TITLE = "PILLAR CHASE  ·  ROLE HUB"

gui = new("ScreenGui", {
	Name = "PillarChaseHub",
	ResetOnSpawn = false,
	ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
	IgnoreGuiInset = true,
	DisplayOrder = 10000,
	Enabled = false,
})
parentGui(gui)

win = new("Frame", {
	Name = "Main",
	AnchorPoint = Vector2.new(0.5, 0.5),
	Position = UDim2.fromScale(0.5, 0.5),
	Size = UDim2.fromOffset(468, 548),
	BackgroundColor3 = THEME.bg,
	BorderSizePixel = 0,
	Visible = false,
	ZIndex = 2,
}, gui)
win.Active = true
corner(win, 16)
new("UIShadow", { Transparency = 0.5, BlurRadius = UDim.new(0, 30) }, win)
stroke(win, THEME.line, 2, 0.7)
new("UIGradient", {
	Rotation = 90,
	Color = ColorSequence.new(THEME.panel, THEME.bg),
}, win)
new("UISizeConstraint", { MinSize = Vector2.new(400, 470), MaxSize = Vector2.new(680, 820) }, win)
local MainScale = new("UIScale", { Scale = 1 }, win)
Main = win

topbar = new("Frame", {
	Name = "TopBar",
	Size = UDim2.new(1, 0, 0, 46),
	BackgroundColor3 = THEME.panel,
	BorderSizePixel = 0,
	ZIndex = 4,
}, win)
new("UICorner", {
	CornerRadius = UDim.new(0, 16),
	BottomLeftRadius = UDim.new(0, 0),
	BottomRightRadius = UDim.new(0, 0),
}, topbar)
-- hairline of white along the bottom of the header
local topAccent = new("Frame", {
	Name = "Accent",
	AnchorPoint = Vector2.new(0.5, 1),
	Position = UDim2.new(0.5, 0, 1, 0),
	Size = UDim2.new(1, 0, 0, 2),
	BackgroundColor3 = THEME.line,
	BorderSizePixel = 0,
	ZIndex = 6,
}, topbar)
new("UIGradient", {
	Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(0.5, 0.2),
		NumberSequenceKeypoint.new(1, 1),
	}),
}, topAccent)

-- logo mark
local logo = new("Frame", {
	Name = "Logo",
	AnchorPoint = Vector2.new(0, 0.5),
	Position = UDim2.new(0, 16, 0.5, 0),
	Size = UDim2.fromOffset(20, 20),
	BackgroundColor3 = THEME.line,
	BorderSizePixel = 0,
	ZIndex = 6,
}, topbar)
corner(logo, 6)
new("TextLabel", {
	BackgroundTransparency = 1,
	Size = UDim2.fromScale(1, 1),
	FontFace = FONT_BOLD,
	Text = "❯",
	TextSize = 15,
	TextColor3 = THEME.bg,
	ZIndex = 7,
}, logo)

local Title = new("TextLabel", {
	Name = "Title",
	BackgroundTransparency = 1,
	Position = UDim2.fromOffset(44, 0),
	Size = UDim2.new(1, -180, 1, 0),
	FontFace = FONT_BOLD,
	Text = TITLE,
	TextSize = 15,
	TextColor3 = THEME.text,
	TextXAlignment = Enum.TextXAlignment.Left,
	ZIndex = 5,
}, topbar)
new("UIGradient", {
	Rotation = 0,
	Color = ColorSequence.new({
		ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 255)),
		ColorSequenceKeypoint.new(0.55, Color3.fromRGB(214, 217, 228)),
		ColorSequenceKeypoint.new(1, Color3.fromRGB(138, 143, 160)),
	}),
}, Title)

statusDot = new("TextLabel", {
	Name = "Dot",
	AnchorPoint = Vector2.new(1, 0.5),
	Position = UDim2.new(1, -52, 0.5, 0),
	Size = UDim2.fromOffset(38, 20),
	BackgroundTransparency = 1,
	FontFace = FONT_BOLD,
	Text = "ON",
	TextSize = 12,
	TextColor3 = THEME.on,
	TextXAlignment = Enum.TextXAlignment.Center,
	ZIndex = 5,
}, topbar)

local closeBtn = new("TextButton", {
	Name = "CloseButton",
	AnchorPoint = Vector2.new(1, 0.5),
	Position = UDim2.new(1, -12, 0.5, 0),
	Size = UDim2.fromOffset(30, 30),
	BackgroundColor3 = THEME.button,
	BorderSizePixel = 0,
	FontFace = FONT_BOLD,
	Text = "×",
	TextSize = 18,
	TextColor3 = THEME.icon,
	AutoButtonColor = false,
	ZIndex = 6,
}, topbar)
corner(closeBtn, 8)
stroke(closeBtn, Color3.fromRGB(255, 255, 255), 1, 0.9)
new("UIScale", {}, closeBtn)
buttonFx(closeBtn,
	{ BackgroundColor3 = THEME.button, TextColor3 = THEME.icon },
	{ BackgroundColor3 = Color3.fromRGB(150, 40, 40), TextColor3 = Color3.fromRGB(255, 255, 255) })

local bottomBar = new("Frame", {
	Name = "BottomBar",
	AnchorPoint = Vector2.new(0, 1),
	Position = UDim2.fromScale(0, 1),
	Size = UDim2.new(1, 0, 0, 56),
	BackgroundColor3 = THEME.panel,
	BorderSizePixel = 0,
	ZIndex = 4,
}, win)
new("UICorner", {
	CornerRadius = UDim.new(0, 16),
	TopLeftRadius = UDim.new(0, 0),
	TopRightRadius = UDim.new(0, 0),
}, bottomBar)
-- matching hairline along the top of the rail
local botAccent = new("Frame", {
	Name = "Accent",
	AnchorPoint = Vector2.new(0.5, 0),
	Position = UDim2.new(0.5, 0, 0, 0),
	Size = UDim2.new(1, 0, 0, 2),
	BackgroundColor3 = THEME.line,
	BorderSizePixel = 0,
	ZIndex = 6,
}, bottomBar)
new("UIGradient", {
	Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 1),
		NumberSequenceKeypoint.new(0.5, 0.2),
		NumberSequenceKeypoint.new(1, 1),
	}),
}, botAccent)

local buttonContainer = new("Frame", {
	Name = "ButtonContainer",
	AnchorPoint = Vector2.new(0.5, 0.5),
	Position = UDim2.fromScale(0.5, 0.42),
	Size = UDim2.new(1, -20, 0, 40),
	BackgroundTransparency = 1,
	ZIndex = 5,
}, bottomBar)
new("UIListLayout", {
	FillDirection = Enum.FillDirection.Horizontal,
	HorizontalAlignment = Enum.HorizontalAlignment.Center,
	VerticalAlignment = Enum.VerticalAlignment.Center,
	Padding = UDim.new(0, 6),
	SortOrder = Enum.SortOrder.LayoutOrder,
}, buttonContainer)

local indicator = new("Frame", {
	Name = "TabIndicator",
	AnchorPoint = Vector2.new(0.5, 1),
	Position = UDim2.new(0, 0, 1, -6),
	Size = UDim2.fromOffset(22, 3),
	BackgroundColor3 = THEME.line,
	BorderSizePixel = 0,
	ZIndex = 6,
}, bottomBar)
corner(indicator, 3)
new("UIStroke", {
	Color = THEME.line,
	Thickness = 3,
	Transparency = 0.55,
	ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
}, indicator)

local sections = new("Frame", {
	Name = "Sections",
	Position = UDim2.fromOffset(10, 86),
	Size = UDim2.new(1, -20, 1, -148),
	BackgroundTransparency = 1,
	ClipsDescendants = true,
	ZIndex = 2,
}, win)

-- live round banner -----------------------------------------------------------
-- always-visible strip under the header: a state dot, the current phase
-- (lobby / in round / dead / round over) and the map's name, refreshed by the
-- task cadence so it never lies about where you are.
local banner = new("Frame", {
	Name = "RoundBanner",
	Position = UDim2.fromOffset(10, 50),
	Size = UDim2.new(1, -20, 0, 30),
	BackgroundColor3 = THEME.panel2,
	BorderSizePixel = 0,
	ZIndex = 2,
}, win)
corner(banner, 8)
local bannerStroke = stroke(banner, THEME.line, 1, 0.85)
new("UIGradient", {
	Rotation = 0,
	Color = ColorSequence.new(Color3.fromRGB(38, 39, 45), Color3.fromRGB(24, 24, 28)),
}, banner)
bannerDot = new("Frame", {
	Name = "Dot",
	AnchorPoint = Vector2.new(0, 0.5),
	Position = UDim2.fromOffset(10, 15),
	Size = UDim2.fromOffset(7, 7),
	BackgroundColor3 = THEME.off,
	BorderSizePixel = 0,
	ZIndex = 3,
}, banner)
corner(bannerDot, 4)
new("UIStroke", {
	Color = THEME.on,
	Thickness = 2,
	Transparency = 0.5,
	ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
}, bannerDot)
bannerText = new("TextLabel", {
	Name = "Phase",
	BackgroundTransparency = 1,
	Position = UDim2.fromOffset(24, 0),
	Size = UDim2.new(0.5, -30, 1, 0),
	FontFace = FONT_BOLD,
	Text = "Not in game.",
	TextSize = 12,
	TextColor3 = THEME.dim,
	TextXAlignment = Enum.TextXAlignment.Left,
	ZIndex = 3,
}, banner)
bannerMap = new("TextLabel", {
	Name = "Map",
	BackgroundTransparency = 1,
	AnchorPoint = Vector2.new(1, 0.5),
	Position = UDim2.new(1, -10, 0.5, 0),
	Size = UDim2.new(0.5, -30, 1, 0),
	FontFace = FONT,
	Text = "",
	TextSize = 11,
	TextColor3 = THEME.dim,
	TextXAlignment = Enum.TextXAlignment.Right,
	TextTruncate = Enum.TextTruncate.AtEnd,
	ZIndex = 3,
}, banner)

local NAV_LABEL = { Roles = "Roles", Visuals = "Visuals", HUD = "HUD", Tasks = "Tasks", Teleport = "Teleport", Extras = "Extras", Info = "Info" }
local nav = {}
local currentTab = nil

-- toast ---------------------------------------------------------------------
local ToastStroke = new("UIStroke", {
	Color = Color3.fromRGB(255, 255, 255),
	Transparency = 1,
	ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
})
local Toast = new("TextLabel", {
	Name = "Toast",
	AnchorPoint = Vector2.new(0.5, 1),
	Position = UDim2.new(0.5, 0, 1, -64),
	Size = UDim2.fromOffset(0, 28),
	AutomaticSize = Enum.AutomaticSize.X,
	BackgroundColor3 = THEME.panel,
	BackgroundTransparency = 1,
	BorderSizePixel = 0,
	FontFace = FONT,
	TextSize = 14,
	TextColor3 = Color3.fromRGB(255, 255, 255),
	TextTransparency = 1,
	Text = "",
	ZIndex = 30,
}, win)
corner(Toast, 13)
new("UIPadding", { PaddingLeft = UDim.new(0, 12), PaddingRight = UDim.new(0, 12) }, Toast)
ToastStroke.Parent = Toast
table.insert(fadeExclude, Toast)

local toastId = 0
local function toast(text, color)
	toastId += 1
	local id = toastId
	Toast.TextColor3 = color or Color3.fromRGB(255, 255, 255)
	Toast.Position = UDim2.new(0.5, 0, 1, -56)
	tween(Toast, 0.3, { BackgroundTransparency = 0, TextTransparency = 0, Position = UDim2.new(0.5, 0, 1, -64) })
	tween(ToastStroke, 0.3, { Transparency = 0.8 })
	scramble(Toast, text, 0.25)
	task.delay(1.8, function()
		if id ~= toastId then
			return
		end
		tween(Toast, 0.3, { BackgroundTransparency = 1, TextTransparency = 1 })
		tween(ToastStroke, 0.3, { Transparency = 1 })
	end)
end

-- ascii backdrop -------------------------------------------------------------
-- The signature "Product Faker V2" look: an animated ASCII field breathing
-- behind the window, driven by noise plus a sine shimmer and a ripple that
-- follows the mouse.  Three stacked labels at different transparencies keep it
-- cheap (one label per brightness band instead of per-character RichText).
local AsciiHolder = new("Frame", {
	Name = "AsciiBackdrop",
	AnchorPoint = Vector2.new(0.5, 0.5),
	Position = UDim2.fromScale(0.5, 0.5),
	BackgroundTransparency = 1,
	ClipsDescendants = true,
	Visible = false,
	ZIndex = 1,
}, gui)
table.insert(fadeExclude, AsciiHolder)

local asciiCfg = {
	textSize = 13,
	padding = 110,
	fps = 30,
	ramp = " .,:;-=+*x#%@",
	color = Color3.fromRGB(255, 255, 255),
	mouse = true,
}
local ascii = {}
local asciiAlpha = new("NumberValue", { Value = 0, Name = "AsciiAlpha" })

do
	local font = Enum.Font.Code
	local huge = Vector2.new(1e5, 1e5)
	local size = asciiCfg.textSize

	local cw = TextService:GetTextSize(string.rep("M", 40), size, font, huge).X / 40
	local h1 = TextService:GetTextSize("M", size, font, huge).Y
	local h11 = TextService:GetTextSize(string.rep("M\n", 10) .. "M", size, font, huge).Y
	local ch = (h11 - h1) / 10
	if cw <= 0 then
		cw = size * 0.6
	end
	if ch <= 0 then
		ch = size
	end

	local layers = {
		{ min = 0.1, transparency = 0.82 },
		{ min = 0.4, transparency = 0.55 },
		{ min = 0.7, transparency = 0.22 },
	}
	local labels = {}
	for i in ipairs(layers) do
		labels[i] = new("TextLabel", {
			Name = "Layer" .. i,
			BackgroundTransparency = 1,
			Font = font,
			TextSize = size,
			TextColor3 = asciiCfg.color,
			TextTransparency = 1,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextYAlignment = Enum.TextYAlignment.Top,
			TextWrapped = false,
			RichText = false,
			Text = "",
			ZIndex = 1,
		}, AsciiHolder)
	end

	local ramp = {}
	for i = 1, #asciiCfg.ramp do
		ramp[i] = asciiCfg.ramp:sub(i, i)
	end
	local rampLen = #ramp

	local cols, rows, offX, offY = 0, 0, 0, 0
	local glow = {}
	local bufs = { {}, {}, {} }
	local lines = { {}, {}, {} }

	function ascii.rebuild(area, inner)
		cols = math.max(1, math.floor(area.X / cw))
		rows = math.max(1, math.floor(area.Y / ch))
		offX = (area.X - cols * cw) / 2
		offY = (area.Y - rows * ch) / 2
		for _, l in ipairs(labels) do
			l.Position = UDim2.fromOffset(offX, offY)
			l.Size = UDim2.fromOffset(cols * cw + 4, rows * ch + 4)
		end
		local cx, cy = area.X / 2, area.Y / 2
		local hw, hh = inner.X / 2, inner.Y / 2
		local pad = asciiCfg.padding
		table.clear(glow)
		for y = 0, rows - 1 do
			local qy = math.max(math.abs(offY + (y + 0.5) * ch - cy) - hh, 0)
			for x = 0, cols - 1 do
				local qx = math.max(math.abs(offX + (x + 0.5) * cw - cx) - hw, 0)
				local i = y * cols + x + 1
				if qx == 0 and qy == 0 then
					glow[i] = -1
				else
					local g = math.clamp(1 - math.sqrt(qx * qx + qy * qy) / pad, 0, 1)
					glow[i] = g * g * (3 - 2 * g)
				end
			end
		end
		for _, b in ipairs(bufs) do
			table.clear(b)
		end
	end

	local noise, sin, sqrt, exp, floor = math.noise, math.sin, math.sqrt, math.exp, math.floor
	local min1, min2, min3 = layers[1].min, layers[2].min, layers[3].min
	local aspect = ch / cw

	function ascii.render(t, alpha, mx, my)
		local mcx, mcy
		if mx then
			mcx, mcy = (mx - offX) / cw, (my - offY) / ch
		end
		local b1, b2, b3 = bufs[1], bufs[2], bufs[3]
		local l1, l2, l3 = lines[1], lines[2], lines[3]
		for y = 0, rows - 1 do
			local base = y * cols
			local ny = y * 0.11 - t * 0.35
			local wy = math.cos(y * 0.21 - t * 0.7)
			for x = 0, cols - 1 do
				local g = glow[base + x + 1]
				local c1, c2, c3 = " ", " ", " "
				if g > 0 then
					local v = 0.45 + noise(x * 0.055, ny, t * 0.12) * 1.3 + sin(x * 0.12 + t * 1.1) * wy * 0.18
					if mcx then
						local dx, dy = x - mcx, (y - mcy) * aspect
						local d = sqrt(dx * dx + dy * dy)
						v += sin(d * 0.45 - t * 7) * exp(-d * 0.09) * 0.45
					end
					v *= g * alpha
					if v >= min1 then
						local idx = math.clamp(floor(v * rampLen) + 1, 2, rampLen)
						local glyph = ramp[idx]
						if v >= min3 then
							c3 = glyph
						elseif v >= min2 then
							c2 = glyph
						else
							c1 = glyph
						end
					end
				end
				b1[x + 1], b2[x + 1], b3[x + 1] = c1, c2, c3
			end
			l1[y + 1] = table.concat(b1, "", 1, cols)
			l2[y + 1] = table.concat(b2, "", 1, cols)
			l3[y + 1] = table.concat(b3, "", 1, cols)
		end
		labels[1].Text = table.concat(l1, "\n", 1, rows)
		labels[2].Text = table.concat(l2, "\n", 1, rows)
		labels[3].Text = table.concat(l3, "\n", 1, rows)
		for i, layer in ipairs(layers) do
			labels[i].TextTransparency = 1 - (1 - layer.transparency) * alpha
		end
	end
end

do
	local acc, lastArea = 0, nil
	local start = os.clock()
	bind(RunService.RenderStepped, function(dt)
		AsciiHolder.Position = Main.Position
		local alpha = asciiAlpha.Value
		if alpha <= 0.001 or not Main.Visible then
			return
		end
		acc += dt
		if acc < 1 / asciiCfg.fps then
			return
		end
		acc = 0
		local s = MainScale.Scale > 0 and MainScale.Scale or 1
		local abs = Main.AbsoluteSize
		local inner = Vector2.new(math.floor(abs.X / s + 0.5), math.floor(abs.Y / s + 0.5))
		local area = inner + Vector2.new(asciiCfg.padding * 2, asciiCfg.padding * 2)
		if area ~= lastArea then
			lastArea = area
			AsciiHolder.Size = UDim2.fromOffset(area.X, area.Y)
			ascii.rebuild(area, inner)
		end
		local mx, my
		if asciiCfg.mouse then
			local m = UserInputService:GetMouseLocation() - AsciiHolder.AbsolutePosition
			if m.X >= 0 and m.Y >= 0 and m.X <= area.X and m.Y <= area.Y then
				mx, my = m.X, m.Y
			end
		end
		ascii.render(os.clock() - start, alpha, mx, my)
	end)
end

-- open / close animation ----------------------------------------------------
local moveIndicator -- forward declaration

function showWindow()
	if gui.Enabled then
		return
	end
	gui.Enabled = true
	Main.Visible = true
	AsciiHolder.Visible = true
	MainScale.Scale = 0.9
	fade(Main, 0, 0)
	tween(MainScale, 0.4, { Scale = 1 }, Enum.EasingStyle.Back)
	fade(Main, 1, 0.3)
	tween(asciiAlpha, 0.5, { Value = 1 }, Enum.EasingStyle.Sine)
	scramble(Title, TITLE, 0.5)
	task.defer(function()
		if moveIndicator then
			moveIndicator(true)
		end
	end)
end

function hideWindow(cb)
	if not gui.Enabled then
		if cb then
			cb()
		end
		return
	end
	if closeNotice then
		pcall(closeNotice)
	end
	tween(MainScale, 0.18, { Scale = 0.9 }, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
	fade(Main, 0, 0.18)
	tween(asciiAlpha, 0.3, { Value = 0 }, Enum.EasingStyle.Quad)
	task.delay(0.2, function()
		Main.Visible = false
		gui.Enabled = false
		AsciiHolder.Visible = false
		if cb then
			cb()
		end
	end)
end

onClick(closeBtn, function()
	hideWindow()
end)

-- ==========================================================================
-- rows
-- ==========================================================================

local function section(page, text, order)
	local holder = new("Frame", {
		Size = UDim2.new(1, 0, 0, 20),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		ZIndex = 1,
	}, page)
	local bar = new("Frame", {
		Name = "Accent",
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, 2, 0.5, 0),
		Size = UDim2.fromOffset(3, 11),
		BackgroundColor3 = THEME.line,
		BorderSizePixel = 0,
		ZIndex = 1,
	}, holder)
	corner(bar, 2)
	new("UIStroke", {
		Color = THEME.line,
		Thickness = 2,
		Transparency = 0.7,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}, bar)
	new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(12, 0),
		Size = UDim2.new(1, -12, 1, 0),
		FontFace = FONT_BOLD,
		Text = string.upper(text),
		TextSize = 11,
		TextColor3 = THEME.dim,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 1,
	}, holder)
	return holder
end

local function card(page, order)
	local c = new("Frame", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundColor3 = THEME.panel,
		BorderSizePixel = 0,
		LayoutOrder = order,
		ZIndex = 1,
	}, page)
	corner(c, 10)
	local cStroke = stroke(c, Color3.fromRGB(255, 255, 255), 1, 0.9)
	new("UIListLayout", {
		Padding = UDim.new(0, 2),
		SortOrder = Enum.SortOrder.LayoutOrder,
	}, c)
	new("UIPadding", {
		PaddingTop = UDim.new(0, 6),
		PaddingBottom = UDim.new(0, 6),
		PaddingLeft = UDim.new(0, 6),
		PaddingRight = UDim.new(0, 6),
	}, c)
	-- a little life: the panel warms and its hairline brightens on hover
	bind(c.MouseEnter, function()
		tween(c, 0.15, { BackgroundColor3 = THEME.cardHover })
		tween(cStroke, 0.15, { Transparency = 0.6 })
	end)
	bind(c.MouseLeave, function()
		tween(c, 0.2, { BackgroundColor3 = THEME.panel })
		tween(cStroke, 0.2, { Transparency = 0.9 })
	end)
	return c
end

local function toggleRow(parent, order, text, get, set)
	local row = new("TextButton", {
		Size = UDim2.new(1, 0, 0, 34),
		BackgroundTransparency = 1,
		Text = "",
		AutoButtonColor = false,
		LayoutOrder = order,
		ZIndex = 2,
	}, parent)
	local label = new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 0),
		Size = UDim2.new(1, -70, 1, 0),
		FontFace = FONT,
		Text = text,
		TextSize = 13,
		TextColor3 = THEME.text,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 2,
	}, row)

	local sw = new("Frame", {
		Position = UDim2.new(1, -50, 0.5, -10),
		Size = UDim2.fromOffset(44, 20),
		BackgroundColor3 = THEME.off,
		BorderSizePixel = 0,
		ZIndex = 2,
	}, row)
	corner(sw, 10)
	stroke(sw, Color3.fromRGB(255, 255, 255), 1, 0.86)
	local knob = new("Frame", {
		Size = UDim2.fromOffset(16, 16),
		Position = UDim2.fromOffset(2, 2),
		BackgroundColor3 = Color3.fromRGB(240, 243, 250),
		BorderSizePixel = 0,
		ZIndex = 3,
	}, sw)
	corner(knob, 8)
	local knobGlow = new("UIStroke", {
		Color = THEME.bg,
		Thickness = 2,
		Transparency = 1,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}, knob)

	local function refresh()
		local on = get()
		tween(sw, 0.22, { BackgroundColor3 = on and THEME.on or THEME.off })
		knob.BackgroundColor3 = on and THEME.bg or Color3.fromRGB(240, 243, 250)
		knob.Position = on and UDim2.new(1, -18, 0, 2) or UDim2.fromOffset(2, 2)
		knobGlow.Transparency = on and 0.6 or 1
	end
	refresh()
	onClick(row, function()
		local on = not get()
		set(on)
		-- spring the knob into place for a tactile feel
		refresh()
		local goal = on and UDim2.new(1, -18, 0, 2) or UDim2.fromOffset(2, 2)
		knob.Position = on and UDim2.fromOffset(2, 2) or UDim2.new(1, -18, 0, 2)
		tween(knob, 0.28, { Position = goal }, Enum.EasingStyle.Back)
		saveConfig()
	end)
	bind(row.MouseEnter, function()
		tween(label, 0.15, { TextColor3 = Color3.fromRGB(255, 255, 255) })
	end)
	bind(row.MouseLeave, function()
		tween(label, 0.2, { TextColor3 = THEME.text })
	end)
	return refresh
end

local function buttonRow(parent, order, text, callback)
	local b = new("TextButton", {
		Size = UDim2.new(1, 0, 0, 32),
		BackgroundColor3 = THEME.button,
		BorderSizePixel = 0,
		FontFace = FONT_BOLD,
		Text = text,
		TextSize = 13,
		TextColor3 = THEME.text,
		AutoButtonColor = false,
		LayoutOrder = order,
		ZIndex = 2,
	}, parent)
	corner(b, 9)
	stroke(b, Color3.fromRGB(255, 255, 255), 1, 0.9)
	new("UIScale", {}, b)
	buttonFx(b,
		{ BackgroundColor3 = THEME.button, TextColor3 = THEME.text },
		{ BackgroundColor3 = THEME.buttonHi, TextColor3 = Color3.fromRGB(255, 255, 255) })
	onClick(b, callback)
	return b
end

local function sliderRow(parent, order, text, min, max, get, set, fmt, snap)
	local row = new("Frame", {
		Size = UDim2.new(1, 0, 0, 46),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		ZIndex = 2,
	}, parent)
	new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 2),
		Size = UDim2.new(1, -12, 0, 18),
		FontFace = FONT,
		Text = text,
		TextSize = 13,
		TextColor3 = THEME.text,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 2,
	}, row)
	local valLbl = new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 2),
		Size = UDim2.new(1, -12, 0, 18),
		FontFace = FONT_BOLD,
		Text = "",
		TextSize = 12,
		TextColor3 = THEME.accent,
		TextXAlignment = Enum.TextXAlignment.Right,
		ZIndex = 2,
	}, row)

	local track = new("Frame", {
		Position = UDim2.fromOffset(6, 30),
		Size = UDim2.new(1, -12, 0, 8),
		BackgroundColor3 = THEME.panel2,
		BorderSizePixel = 0,
		ZIndex = 2,
	}, row)
	corner(track, 4)
	stroke(track, Color3.fromRGB(255, 255, 255), 1, 0.92)
	local fill = new("Frame", {
		Size = UDim2.fromScale(0, 1),
		BackgroundColor3 = THEME.accent,
		BorderSizePixel = 0,
		ZIndex = 2,
	}, track)
	corner(fill, 4)
	new("UIStroke", {
		Color = THEME.accent,
		Thickness = 2,
		Transparency = 0.5,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}, fill)
	local knob = new("Frame", {
		Size = UDim2.fromOffset(15, 15),
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.new(0, 0, 0.5, 0),
		BackgroundColor3 = THEME.bg,
		BorderSizePixel = 0,
		ZIndex = 3,
	}, track)
	corner(knob, 8)
	new("UIStroke", {
		Color = Color3.fromRGB(255, 255, 255),
		Thickness = 1.5,
		Transparency = 0.15,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}, knob)

	local function render()
		local v = get()
		local a = math.clamp((v - min) / (max - min), 0, 1)
		fill.Size = UDim2.fromScale(a, 1)
		knob.Position = UDim2.new(a, 0, 0.5, 0)
		valLbl.Text = (fmt and fmt(v)) or string.format("%.2f", v)
	end
	render()

	local dragging = false
	local function apply(input)
		local rel = math.clamp((input.Position.X - track.AbsolutePosition.X) / math.max(1, track.AbsoluteSize.X), 0, 1)
		local v = min + rel * (max - min)
		if snap and snap > 0 then
			v = math.floor(v / snap + 0.5) * snap
		else
			v = math.floor(v * 100 + 0.5) / 100
		end
		set(v)
		render()
	end

	local hit = new("TextButton", {
		Position = UDim2.fromOffset(0, 20),
		Size = UDim2.new(1, 0, 0, 26),
		BackgroundTransparency = 1,
		Text = "",
		AutoButtonColor = false,
		ZIndex = 4,
	}, row)
	bind(hit.InputBegan, function(input)
		if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
			dragging = true
			apply(input)
		end
	end)
	bind(hit.InputEnded, function(input)
		if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
			dragging = false
			saveConfig()
		end
	end)
	bind(UserInputService.InputChanged, function(input)
		if dragging and (input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch) then
			apply(input)
		end
	end)
	return render
end

local PALETTE = {
	{ 255, 45, 60 },   -- red
	{ 255, 140, 40 },  -- orange
	{ 255, 210, 60 },  -- amber
	{ 96, 255, 148 },  -- green
	{ 60, 210, 110 },  -- green
	{ 60, 220, 220 },  -- cyan
	{ 80, 200, 255 },  -- sky
	{ 94, 160, 255 },  -- blue
	{ 200, 90, 255 },  -- violet
	{ 255, 105, 180 }, -- pink
	{ 245, 245, 250 }, -- white
	{ 150, 160, 175 }, -- grey
}

-- every colorRow registers a refresher here so its swatch / hex / sliders stay
-- in sync when the value is changed from anywhere (palette, sliders, config).
colorSyncers = {}

local function colorRow(parent, order, text, get, set)
	-- read a colour channel whether the table uses numeric (1) or string ("1")
	-- keys, so a stale saved config can never wedge the picker.
	local function comp(c, i)
		local v = c[i]
		if type(v) ~= "number" then
			v = c[tostring(i)]
		end
		if type(v) ~= "number" then
			v = 0
		end
		return math.clamp(v, 0, 255)
	end

	local refresh -- forward declaration (palette callbacks call it)

	local row = new("Frame", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		LayoutOrder = order,
		ZIndex = 2,
	}, parent)
	new("UIListLayout", {
		Padding = UDim.new(0, 4),
		SortOrder = Enum.SortOrder.LayoutOrder,
	}, row)

	-- header: name + preview + quick palette
	local head = new("Frame", {
		Size = UDim2.new(1, 0, 0, 30),
		BackgroundTransparency = 1,
		LayoutOrder = 1,
		ZIndex = 2,
	}, row)
	new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 0),
		Size = UDim2.new(0, 90, 1, 0),
		FontFace = FONT,
		Text = text,
		TextSize = 13,
		TextColor3 = THEME.text,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 2,
	}, head)

	local preview = new("TextButton", {
		Position = UDim2.fromOffset(92, 4),
		Size = UDim2.fromOffset(42, 22),
		BackgroundColor3 = rgb(get()),
		BorderSizePixel = 0,
		Text = "",
		AutoButtonColor = false,
		ZIndex = 3,
	}, head)
	corner(preview, 6)
	stroke(preview, Color3.fromRGB(255, 255, 255), 1, 0.45)
	new("UIStroke", {
		Color = THEME.accent,
		Thickness = 2,
		Transparency = 0.6,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	}, preview)

	for i, c in ipairs(PALETTE) do
		local s = new("TextButton", {
			Position = UDim2.fromOffset(140 + (i - 1) * 22, 5),
			Size = UDim2.fromOffset(18, 18),
			BackgroundColor3 = rgb(c),
			BorderSizePixel = 0,
			Text = "",
			AutoButtonColor = false,
			ZIndex = 3,
		}, head)
		corner(s, 5)
		s.MouseEnter:Connect(function()
			tween(s, 0.1, { Size = UDim2.fromOffset(20, 20) })
		end)
		s.MouseLeave:Connect(function()
			tween(s, 0.15, { Size = UDim2.fromOffset(18, 18) })
		end)
		onClick(s, function()
			set({ c[1], c[2], c[3] })
			saveConfig()
			refresh()
		end)
	end

	-- expandable custom picker: red / green / blue sliders + hex readout
	local picker = new("Frame", {
		Size = UDim2.new(1, 0, 0, 0),
		AutomaticSize = Enum.AutomaticSize.Y,
		BackgroundTransparency = 1,
		LayoutOrder = 2,
		Visible = false,
		ZIndex = 2,
	}, row)
	new("UIListLayout", {
		Padding = UDim.new(0, 2),
		SortOrder = Enum.SortOrder.LayoutOrder,
	}, picker)

	local hexRow = new("Frame", {
		Size = UDim2.new(1, 0, 0, 22),
		BackgroundTransparency = 1,
		LayoutOrder = 0,
		ZIndex = 2,
	}, picker)
	new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 0),
		Size = UDim2.new(0, 30, 1, 0),
		FontFace = FONT_BOLD,
		Text = "HEX",
		TextSize = 11,
		TextColor3 = THEME.dim,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 2,
	}, hexRow)
	local hexBox = new("TextBox", {
		Position = UDim2.fromOffset(38, 1),
		Size = UDim2.new(0, 96, 0, 20),
		BackgroundColor3 = THEME.panel2,
		BorderSizePixel = 0,
		FontFace = FONT_BOLD,
		Text = "#FFFFFF",
		PlaceholderText = "#RRGGBB",
		TextSize = 12,
		TextColor3 = THEME.text,
		TextXAlignment = Enum.TextXAlignment.Center,
		ClearTextOnFocus = false,
		ZIndex = 3,
	}, hexRow)
	corner(hexBox, 5)
	stroke(hexBox, Color3.fromRGB(255, 255, 255), 1, 0.85)
	local bigPrev = new("Frame", {
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -2, 0, 1),
		Size = UDim2.fromOffset(44, 20),
		BackgroundColor3 = rgb(get()),
		BorderSizePixel = 0,
		ZIndex = 3,
	}, hexRow)
	corner(bigPrev, 6)
	stroke(bigPrev, Color3.fromRGB(255, 255, 255), 1, 0.5)

	local function parseHex(str)
		if type(str) ~= "string" then
			return nil
		end
		local s = str:gsub("%s", ""):gsub("^#", "")
		if #s ~= 6 or not s:match("^%x%x%x%x%x%x$") then
			return nil
		end
		return { tonumber(s:sub(1, 2), 16), tonumber(s:sub(3, 4), 16), tonumber(s:sub(5, 6), 16) }
	end

	local rR = sliderRow(picker, 1, "Red", 0, 255,
		function() local c = get(); return comp(c, 1) end,
		function(v) local c = get(); set({ v, comp(c, 2), comp(c, 3) }) end,
		function(v) return string.format("%d", v) end, 1)
	local rG = sliderRow(picker, 2, "Green", 0, 255,
		function() local c = get(); return comp(c, 2) end,
		function(v) local c = get(); set({ comp(c, 1), v, comp(c, 3) }) end,
		function(v) return string.format("%d", v) end, 1)
	local rB = sliderRow(picker, 3, "Blue", 0, 255,
		function() local c = get(); return comp(c, 3) end,
		function(v) local c = get(); set({ comp(c, 1), comp(c, 2), v }) end,
		function(v) return string.format("%d", v) end, 1)

	function refresh()
		local c = get()
		preview.BackgroundColor3 = rgb(c)
		bigPrev.BackgroundColor3 = rgb(c)
		hexBox.Text = string.format("#%02X%02X%02X",
			math.floor(comp(c, 1) + 0.5), math.floor(comp(c, 2) + 0.5), math.floor(comp(c, 3) + 0.5))
		pcall(rR)
		pcall(rG)
		pcall(rB)
	end
	colorSyncers[#colorSyncers + 1] = refresh
	refresh()

	-- typed hex commits on Enter or when the box loses focus; bad input snaps back
	bind(hexBox.FocusLost, function()
		local c = parseHex(hexBox.Text)
		if c then
			set(c)
			saveConfig()
		end
		refresh()
	end)

	onClick(preview, function()
		picker.Visible = not picker.Visible
		refresh()
	end)

	return refresh
end

local function infoRow(parent, order, text)
	local row = new("Frame", {
		Size = UDim2.new(1, 0, 0, 22),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		ZIndex = 1,
	}, parent)
	local lbl = new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 0),
		Size = UDim2.new(1, -12, 1, 0),
		FontFace = FONT,
		Text = text,
		TextSize = 12,
		TextColor3 = THEME.dim,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 1,
	}, row)
	return lbl
end

-- ==========================================================================
-- tabs (bottom rail + sliding indicator)
-- ==========================================================================

moveIndicator = function(instant)
	if not currentTab then
		return
	end
	local btn = nav[currentTab].btn
	local s = MainScale.Scale > 0 and MainScale.Scale or 1
	local x = (btn.AbsolutePosition.X + btn.AbsoluteSize.X / 2 - bottomBar.AbsolutePosition.X) / s
	local pos = UDim2.new(0, x, 1, -6)
	if instant then
		indicator.Position = pos
	else
		tween(indicator, 0.3, { Position = pos })
	end
end

-- staggered entrance: each section/card fades in a beat after the last so a tab
-- switch feels alive instead of snapping.  Runs once per switch, not per frame.
local function entrance(screen)
	local kids = {}
	for _, c in ipairs(screen:GetChildren()) do
		if c:IsA("GuiObject") then
			kids[#kids + 1] = c
		end
	end
	table.sort(kids, function(a, b)
		return (a.LayoutOrder or 0) < (b.LayoutOrder or 0)
	end)
	for i, c in ipairs(kids) do
		fade(c, 0, 0)
		task.delay((i - 1) * 0.03, function()
			if c.Parent then
				fade(c, 1, 0.22)
			end
		end)
	end
	-- restore the frame chrome (scrollbar) that lives on the page itself
	local so = getOriginal(screen)
	for prop, v in pairs(so) do
		pcall(function()
			screen[prop] = v
		end)
	end
end

local function setTab(index, instant)
	if index == currentTab then
		return
	end
	local prev = currentTab
	local dir = (prev and index < prev) and -1 or 1
	currentTab = index

	for i, tab in ipairs(nav) do
		local goal = (i == index) and activeFx or idleFx
		if instant then
			for k, v in pairs(fxGoal(tab.btn, goal)) do
				tab.btn[k] = v
			end
		else
			tween(tab.btn, 0.2, fxGoal(tab.btn, goal))
		end
	end
	moveIndicator(instant)

	local screen = nav[index].page
	if prev then
		local old = nav[prev].page
		if instant then
			old.Visible = false
		else
			fade(old, 0, 0.12)
			tween(old, 0.2, { Position = UDim2.fromScale(-0.06 * dir, 0) })
			task.delay(0.2, function()
				if nav[currentTab] and nav[currentTab].page ~= old then
					old.Visible = false
				end
			end)
		end
	end

	screen.Visible = true
	if instant then
		screen.Position = UDim2.new()
		fade(screen, 1, 0)
	else
		screen.Position = UDim2.fromScale(0.06 * dir, 0)
		tween(screen, 0.3, { Position = UDim2.new() })
		entrance(screen)
	end
	if TP then
		TP.active = (nav[index].name == "Teleport")
		if TP.active and TP.refresh then
			task.defer(TP.refresh)
		end
	end
end

selectTab = function(idx)
	-- accept a tab name as well as an index, so scripts can call
	-- PillarChaseHub.setTab("Teleport") without knowing the ordering
	if type(idx) == "string" then
		for i, t in ipairs(nav) do
			if t.name == idx or string.lower(t.name) == string.lower(idx) then
				idx = i
				break
			end
		end
	end
	if type(idx) ~= "number" then
		return
	end
	setTab(idx, not gui.Enabled)
end

local function makeTab(name, order)
	local btn = new("TextButton", {
		Name = name,
		Size = UDim2.fromOffset(58, 38),
		BackgroundColor3 = THEME.button,
		BorderSizePixel = 0,
		FontFace = FONT_BOLD,
		Text = NAV_LABEL[name] or name,
		TextSize = 10,
		TextColor3 = THEME.icon,
		AutoButtonColor = false,
		LayoutOrder = order,
		ZIndex = 6,
	}, buttonContainer)
	corner(btn, 10)
	stroke(btn, Color3.fromRGB(255, 255, 255), 1, 0.9)
	new("UIScale", {}, btn)

	local page = new("ScrollingFrame", {
		Name = name .. "Page",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ScrollBarThickness = 3,
		ScrollBarImageColor3 = THEME.button,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		ScrollingDirection = Enum.ScrollingDirection.Y,
		Visible = false,
		ZIndex = 1,
	}, sections)
	new("UIListLayout", {
		Padding = UDim.new(0, 8),
		SortOrder = Enum.SortOrder.LayoutOrder,
	}, page)
	new("UIPadding", {
		PaddingTop = UDim.new(0, 2),
		PaddingBottom = UDim.new(0, 12),
		PaddingLeft = UDim.new(0, 2),
		PaddingRight = UDim.new(0, 2),
	}, page)

	nav[#nav + 1] = { btn = btn, page = page, name = name }
	local idx = #nav
	buttonFx(btn, idleFx, hoverFx, function()
		return currentTab == idx
	end)
	onClick(btn, function()
		setTab(idx)
	end)
	return page
end

bind(bottomBar:GetPropertyChangedSignal("AbsoluteSize"), function()
	moveIndicator(true)
end)
bind(bottomBar:GetPropertyChangedSignal("AbsolutePosition"), function()
	moveIndicator(true)
end)

-- ==========================================================================
-- pages
-- ==========================================================================

-- forward declaration: the task engine (defined after the highlight engine)
-- assigns to this local, so the Tasks tab button can call it.

local espPage = makeTab("Roles", 1)
local stylePage = makeTab("Visuals", 2)
local hudPage = makeTab("HUD", 3)
local tasksPage = makeTab("Tasks", 4)
local tpPage = makeTab("Teleport", 5)
local extrasPage = makeTab("Extras", 6)
local infoPage = makeTab("Info", 7)

-- Highlights
section(espPage, "highlights", 1)
local c1 = card(espPage, 1)
toggleRow(c1, 1, "Enable highlights", function()
	return STATE.enabled
end, function(v)
	STATE.enabled = v
end)

section(espPage, "roles to show", 2)
local c2 = card(espPage, 2)
for i, role in ipairs({ "Monster", "Survivor", "Sentinel", "Unknown" }) do
	toggleRow(c2, i, role, function()
		return STATE.roles[role]
	end, function(v)
		STATE.roles[role] = v
	end)
end
toggleRow(c2, 5, "Highlight yourself", function()
	return STATE.self
end, function(v)
	STATE.self = v
end)

section(espPage, "detection range", 3)
local c3 = card(espPage, 3)
sliderRow(c3, 1, "Max distance", 50, 10000, function()
	return STATE.maxDist
end, function(v)
	STATE.maxDist = v
end, function(v)
	return string.format("%d studs", v)
end, 50)

section(espPage, "name labels", 4)
local c4 = card(espPage, 4)
toggleRow(c4, 1, "Show name / role labels", function()
	return STATE.labels
end, function(v)
	STATE.labels = v
end)
toggleRow(c4, 2, "Show distance", function()
	return STATE.showDistance
end, function(v)
	STATE.showDistance = v
end)

-- Style
section(stylePage, "highlight style", 1)
local s1 = card(stylePage, 1)
toggleRow(s1, 1, "See through walls", function()
	return STATE.throughWalls
end, function(v)
	STATE.throughWalls = v
end)
sliderRow(s1, 2, "Fill opacity", 0, 1, function()
	return STATE.fillTransparency
end, function(v)
	STATE.fillTransparency = v
end, function(v)
	return string.format("%d%% solid", math.floor((1 - v) * 100 + 0.5))
end)

section(stylePage, "role colours", 2)
local s2 = card(stylePage, 2)
for i, role in ipairs({ "Monster", "Survivor", "Sentinel", "Unknown", "Self" }) do
	colorRow(s2, i, role, function()
		return STATE.colors[role]
	end, function(c)
		STATE.colors[role] = c
	end)
end

section(stylePage, "window look", 3)
local s3 = card(stylePage, 3)
toggleRow(s3, 1, "Unlock mouse while open", function()
	return STATE.freeMouse
end, function(v)
	STATE.freeMouse = v
end)
local s3note = infoRow(s3, 2, "The game locks your cursor to screen centre for")
local s3note2 = infoRow(s3, 3, "shiftlock. This frees it ONLY while the window is")
local s3note3 = infoRow(s3, 4, "open, then restores shiftlock exactly as it was.")

section(stylePage, "world lighting", 4)
local s4 = card(stylePage, 4)
toggleRow(s4, 1, "Fullbright", function()
	return STATE.fullbright
end, function(v)
	STATE.fullbright = v
end)
toggleRow(s4, 2, "No fog", function()
	return STATE.noFog
end, function(v)
	STATE.noFog = v
end)
sliderRow(s4, 3, "Field of view", 70, 120, function()
	return STATE.cameraFov
end, function(v)
	STATE.cameraFov = v
end, function(v)
	return string.format("%d deg", v)
end, 1)
infoRow(s4, 4, "Fullbright, no fog and zoom are local only -")
infoRow(s4, 5, "they change what you see, never the map.")

-- HUD
section(hudPage, "hud overlay", 1)
local h1 = card(hudPage, 1)
toggleRow(h1, 1, "Status HUD", function()
	return STATE.hud
end, function(v)
	STATE.hud = v
end)
toggleRow(h1, 2, "Off-screen arrows", function()
	return STATE.offscreen
end, function(v)
	STATE.offscreen = v
end)
toggleRow(h1, 3, "Tracers", function()
	return STATE.tracers
end, function(v)
	STATE.tracers = v
end)
toggleRow(h1, 4, "FPS counter", function()
	return STATE.fpsCounter
end, function(v)
	STATE.fpsCounter = v
end)
toggleRow(h1, 5, "Ping (ms)", function()
	return STATE.pingCounter
end, function(v)
	STATE.pingCounter = v
end)

section(hudPage, "hud position", 2)
local hpos = card(hudPage, 2)
infoRow(hpos, 1, "While the hub is open you can drag the status")
infoRow(hpos, 2, "box anywhere - it is saved for next time.")
buttonRow(hpos, 3, "Reset HUD position", function()
	STATE.hudPos = nil
	if hud then
		hud.AnchorPoint = Vector2.new(1, 0)
		hud.Position = UDim2.new(1, -12, 0, 32)
	end
	saveConfig()
end)

section(hudPage, "monster alert", 3)
local h2 = card(hudPage, 2)
toggleRow(h2, 1, "Monster proximity alert", function()
	return STATE.monsterAlert
end, function(v)
	STATE.monsterAlert = v
end)
sliderRow(h2, 2, "Alert distance", 20, 400, function()
	return STATE.alertDist
end, function(v)
	STATE.alertDist = v
end, function(v)
	return string.format("%d studs", v)
end, 5)

-- Tasks
section(tasksPage, "objectives", 1)
local tk1 = card(tasksPage, 1)
toggleRow(tk1, 1, "Highlight objectives", function()
	return STATE.shardEsp
end, function(v)
	STATE.shardEsp = v
end)
toggleRow(tk1, 2, "Auto tasks  (teleport + interact)", function()
	return STATE.autoCollect
end, function(v)
	STATE.autoCollect = v
end)
sliderRow(tk1, 3, "Teleport delay", 0.05, 0.5, function()
	return STATE.collectDelay
end, function(v)
	STATE.collectDelay = v
end, function(v)
	return string.format("%.2fs / object", v)
end, 0.05)
sliderRow(tk1, 4, "Collect height offset", 0, 6, function()
	return STATE.collectHeight
end, function(v)
	STATE.collectHeight = v
end, function(v)
	return string.format("%d studs", v)
end, 1)

section(tasksPage, "objective colour", 2)
local tk2 = card(tasksPage, 2)
colorRow(tk2, 1, "Colour", function()
	return STATE.shardColor
end, function(c)
	STATE.shardColor = c
end)

section(tasksPage, "other objectives", 3)
local tk3 = card(tasksPage, 3)
infoRow(tk3, 1, "Every 'ObjectiveInteract*' folder is scanned and")
infoRow(tk3, 2, "highlighted; 'Auto tasks' walks you to each one.")
infoRow(tk3, 3, "Touch objectives (shards) clear by standing on")
infoRow(tk3, 4, "them. Prompt / click objectives (lockpicking,")
infoRow(tk3, 5, "cleaning, keypads, valves) are server-side: firing")
infoRow(tk3, 6, "them from the client does NOT advance the count,")
infoRow(tk3, 7, "so the hub moves on instead of looping on one.")
infoRow(tk3, 8, "Off = it never moves you anywhere.")

section(tasksPage, "progress", 4)
local tk4 = card(tasksPage, 4)
infoStatus = infoRow(tk4, 1, "Status: -")
infoMap = infoRow(tk4, 2, "Map: -")
infoObj1 = infoRow(tk4, 3, "Objective 1: -")
infoObj2 = infoRow(tk4, 4, "Objective 2: -")
infoExit = infoRow(tk4, 5, "Exit located: -")
infoShards = infoRow(tk4, 6, "Objectives left: -")

section(tasksPage, "how it works", 5)
local tk5 = card(tasksPage, 5)
infoRow(tk5, 1, "Step 1: work out the round. If there's no map,")
infoRow(tk5, 2, "you're in the lobby, or you died and are now")
infoRow(tk5, 3, "spectating, nothing runs - status shows it.")
infoRow(tk5, 4, "Step 2: objective ESP marks every live task")
infoRow(tk5, 5, "object (shards, snow, trash, valves, keypads).")
infoRow(tk5, 6, "'Auto tasks' (OFF by default) does the real")
infoRow(tk5, 7, "interaction, not a fake poke: it walks you onto")
infoRow(tk5, 8, "touch shards, fires live prompts, and on the")
infoRow(tk5, 9, "bone/firepit task grabs each bone and carries it")
infoRow(tk5, 10, "onto the fire for real. Off = never moves you.")
buttonRow(tk5, 11, "Collect nearest objective now", function()
	collectOneShard()
end)

-- Teleport
TP = {}
section(tpPage, "where you are", 1)
local tp1 = card(tpPage, 1)
TP.status = infoRow(tp1, 1, "Status: -")
TP.map = infoRow(tp1, 2, "Map: -")
TP.obj1 = infoRow(tp1, 3, "Objective 1: -")
TP.obj2 = infoRow(tp1, 4, "Objective 2: -")
TP.exit = infoRow(tp1, 5, "Exit located: -")
buttonRow(tp1, 6, "Rescan now", function()
	if TP.refresh then
		TP.refresh()
	end
end)

section(tpPage, "objective teleports", 2)
TP.list = card(tpPage, 2)

section(tpPage, "quick jump", 3)
local tp3 = card(tpPage, 3)
buttonRow(tp3, 1, "Nearest available task", function()
	if TP.goNearest then
		TP.goNearest()
	end
end)
buttonRow(tp3, 2, "Exit", function()
	if TP.goExit then
		TP.goExit()
	end
end)
infoRow(tp3, 3, "Moves your character onto the objective.")
infoRow(tp3, 4, "Only works in a live round - nothing happens")
infoRow(tp3, 5, "in the lobby or while you are dead and")
infoRow(tp3, 6, "spectating.")

section(tpPage, "player teleports", 4)
TP.players = card(tpPage, 4)

section(tpPage, "how it works", 5)
local tp4 = card(tpPage, 5)
infoRow(tp4, 1, "The scan works out where you are first (lobby,")
infoRow(tp4, 2, "in round, dead, round over) and which map.")
infoRow(tp4, 3, "Each row below is one of the game's own task")
infoRow(tp4, 4, "groups, with how many objects are still live.")
infoRow(tp4, 5, "This is movement + the game's own interaction,")
infoRow(tp4, 6, "so the server just sees a normal player moving.")

-- Extras
section(extrasPage, "session tools", 1)
local x1 = card(extrasPage, 1)
toggleRow(x1, 1, "Anti-AFK", function()
	return STATE.antiAfk
end, function(v)
	STATE.antiAfk = v
end)
infoRow(x1, 2, "Stops Roblox's 20-minute idle kick by tapping")
infoRow(x1, 3, "the movement keys virtually. Safe and local.")

section(extrasPage, "exit / escape", 2)
local x2 = card(extrasPage, 2)
toggleRow(x2, 1, "Highlight the exit", function()
	return STATE.exitEsp
end, function(v)
	STATE.exitEsp = v
end)
colorRow(x2, 2, "Exit", function()
	return STATE.exitColor
end, function(c)
	STATE.exitColor = c
end)
infoRow(x2, 3, "Draws an outline + EXIT tag over the escape")
infoRow(x2, 4, "doors once the map has located them.")

section(extrasPage, "crosshair", 3)
local x3 = card(extrasPage, 3)
toggleRow(x3, 1, "Crosshair", function()
	return STATE.crosshair
end, function(v)
	STATE.crosshair = v
end)
colorRow(x3, 2, "Colour", function()
	return STATE.crosshairColor
end, function(c)
	STATE.crosshairColor = c
end)

section(extrasPage, "movement", 4)
local x5 = card(extrasPage, 4)
toggleRow(x5, 1, "Infinite stamina (ON)", function()
	return STATE.infiniteStamina
end, function(v)
	STATE.infiniteStamina = v
end)
infoRow(x5, 2, "Pins your stamina at max and cancels the drain")
infoRow(x5, 3, "while you sprint. Client-side / cosmetic on most")
infoRow(x5, 4, "servers, so it may not register on the server.")
toggleRow(x5, 5, "Noclip (walk through walls)", function()
	return STATE.noclip
end, function(v)
	STATE.noclip = v
end)
infoRow(x5, 6, "Turns off collision on your own character parts.")
infoRow(x5, 7, "Moving the character is normal; the server just")
infoRow(x5, 8, "sees you where you are, like any other step.")
sliderRow(x5, 9, "Speed multiplier", 1, 10, function()
	return STATE.speedMult
end, function(v)
	STATE.speedMult = v
end, function(v)
	return string.format("%.1fx", v)
end, 0.5)
infoRow(x5, 10, "Scales walk + sprint together through the game's")
infoRow(x5, 11, "own movement. 1.0x is off; slide up to 10.0x.")

section(extrasPage, "hotkeys", 5)
local x4 = card(extrasPage, 5)
infoRow(x4, 1, "K  -  open / close the hub")
infoRow(x4, 2, "1 - 7  -  jump to a tab")
infoRow(x4, 3, "H  -  panic: instantly hide hub + overlay")
infoRow(x4, 4, "Press H again to bring everything back.")
keybindBtn = buttonRow(x4, 5, "Toggle key: " .. tostring(STATE.keybind) .. "   (click to rebind)", function()
	keybindCapturing = true
	keybindBtn.Text = "Press any key…"
end)

-- rebind capture: while armed, the next real key press becomes the toggle key.
bind(UserInputService.InputBegan, function(input)
	if not keybindCapturing then
		return
	end
	if input.UserInputType ~= Enum.UserInputType.Keyboard then
		return
	end
	local name = input.KeyCode and input.KeyCode.Name or nil
	keybindCapturing = false
	if name and name ~= "Unknown" and Enum.KeyCode[name] then
		STATE.keybind = name
		saveConfig()
		keybindBtn.Text = "Toggle key: " .. name .. "   (click to rebind)"
		toast("Toggle key set to " .. name, THEME.on)
	else
		keybindBtn.Text = "Toggle key: " .. tostring(STATE.keybind) .. "   (click to rebind)"
		toast("That key can't be used.", THEME.danger)
	end
end)

-- Info
section(infoPage, "live stats", 1)
local i1 = card(infoPage, 1)
infoRole = infoRow(i1, 1, "Your role: -")
infoMonster = infoRow(i1, 2, "Monster: -")
infoSurvivors = infoRow(i1, 3, "Survivors: -")
infoTracked = infoRow(i1, 4, "Highlighted: -")
infoMouse = infoRow(i1, 5, "Mouse: -")

section(infoPage, "about", 2)
local i2 = card(infoPage, 2)
infoRow(i2, 1, "Place 121255181515820 · Pillar Chase")
infoRow(i2, 2, "Highlights are local; the server never sees them.")
infoRow(i2, 3, "Tabs: keys 1-7 · Window: " .. tostring(STATE.keybind) .. " · Panic: H")
infoRow(i2, 4, "Anti-AFK keeps you in the round while idle.")

section(infoPage, "settings", 3)
local i3 = card(infoPage, 3)
buttonRow(i3, 1, "Save config now", saveConfig)
buttonRow(i3, 2, "Reset to defaults", function()
	for k, v in pairs(DEFAULTS) do
		STATE[k] = (type(v) == "table") and deepCopy(v) or v
	end
	saveConfig()
end)

selectTab(1)
showWindow()
end

-- ==========================================================================
-- overlay (HUD, arrows, tracers, alert) - kept BELOW the window
-- ==========================================================================

local overlay = new("ScreenGui", {
	Name = "PillarChaseOverlay",
	ResetOnSpawn = false,
	ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
	IgnoreGuiInset = true,
	DisplayOrder = 9999,
})
parentGui(overlay)

-- close notice: lives on the overlay ScreenGui (which stays enabled when the hub
-- window hides itself) so closing the hub leaves the "press K" hint on screen.
local closeNote = new("TextLabel", {
	Name = "CloseNotice",
	AnchorPoint = Vector2.new(0.5, 1),
	Position = UDim2.new(0.5, 0, 1, -48),
	Size = UDim2.fromOffset(0, 30),
	AutomaticSize = Enum.AutomaticSize.X,
	BackgroundColor3 = THEME.panel,
	BackgroundTransparency = 1,
	BorderSizePixel = 0,
	FontFace = FONT,
	TextSize = 14,
	TextColor3 = THEME.text,
	TextTransparency = 1,
	Text = "",
	Visible = false,
	ZIndex = 40,
}, overlay)
corner(closeNote, 15)
new("UIPadding", { PaddingLeft = UDim.new(0, 14), PaddingRight = UDim.new(0, 14) }, closeNote)
local closeNoteStroke = stroke(closeNote, THEME.line, 1, 1)

local closeNoteId = 0
function closeNotice()
	-- grab the service here: this chunk is right at Luau's local-variable
	-- limit, so the file-level `TweenService` local is not reliably captured
	-- as an upvalue down here (it silently resolves to a nil global).
	local TS = game:GetService("TweenService")
	closeNoteId += 1
	local id = closeNoteId
	closeNote.Text = "Press  K  to reopen the hub"
	closeNote.Visible = true
	closeNote.Position = UDim2.new(0.5, 0, 1, -40)
	TS:Create(closeNote, TweenInfo.new(0.2), { BackgroundTransparency = 0.05, TextTransparency = 0, Position = UDim2.new(0.5, 0, 1, -48) }):Play()
	TS:Create(closeNoteStroke, TweenInfo.new(0.2), { Transparency = 0.5 }):Play()
	task.delay(1.6, function()
		if id ~= closeNoteId then
			return
		end
		TS:Create(closeNote, TweenInfo.new(0.25), { BackgroundTransparency = 1, TextTransparency = 1 }):Play()
		TS:Create(closeNoteStroke, TweenInfo.new(0.25), { Transparency = 1 }):Play()
		task.delay(0.3, function()
			if id ~= closeNoteId then
				return
			end
			closeNote.Visible = false
			closeNote.Text = ""
		end)
	end)
end

-- monster alert: pulsing edge + banner
local edge = new("Frame", {
	Name = "Edge",
	Size = UDim2.fromScale(1, 1),
	BackgroundTransparency = 1,
	Active = false,
	Visible = false,
	ZIndex = 1,
}, overlay)
local edgeStroke = new("UIStroke", {
	Color = THEME.danger,
	Thickness = 6,
	Transparency = 1,
	ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
}, edge)

local alert = new("TextLabel", {
	Name = "Alert",
	Size = UDim2.fromOffset(420, 40),
	Position = UDim2.new(0.5, -210, 0, 70),
	BackgroundColor3 = Color3.fromRGB(120, 18, 24),
	BackgroundTransparency = 0.15,
	BorderSizePixel = 0,
	Font = THEME.fontBold,
	Text = "",
	TextSize = 16,
	TextColor3 = Color3.fromRGB(255, 235, 235),
	Visible = false,
	ZIndex = 3,
}, overlay)
corner(alert, 9)

-- tracer + arrow holders (never capture input)
local tracerHolder = new("Frame", {
	Name = "Tracers",
	Size = UDim2.fromScale(1, 1),
	BackgroundTransparency = 1,
	Active = false,
	ZIndex = 2,
}, overlay)
local arrowHolder = new("Frame", {
	Name = "Arrows",
	Size = UDim2.fromScale(1, 1),
	BackgroundTransparency = 1,
	Active = false,
	ZIndex = 3,
}, overlay)

-- status hud ---------------------------------------------------------------
-- A single horizontal strip docked to the right edge.  The old layout stacked
-- three lines vertically; this reads left-to-right like a scoreboard - role,
-- then the survivor/sentinel tally, then the monster distance - so it is one
-- row tall and never buries the game's own bottom-right UI.  It is draggable
-- and remembers where you put it, and is interactive only while the menu is
-- open (so you can grab it, but it never eats your clicks mid-fight).
hud = new("Frame", {
	Name = "HUD",
	AnchorPoint = Vector2.new(1, 0),
	Position = UDim2.new(1, -12, 0, 32),
	Size = UDim2.new(0, 0, 0, 30),
	AutomaticSize = Enum.AutomaticSize.X,
	BackgroundColor3 = THEME.bg,
	BackgroundTransparency = 0.22,
	BorderSizePixel = 0,
	Active = false,
	ZIndex = 4,
}, overlay)
corner(hud, 8)
local hudStroke = stroke(hud, THEME.line, 1)
new("UIListLayout", {
	FillDirection = Enum.FillDirection.Horizontal,
	VerticalAlignment = Enum.VerticalAlignment.Center,
	HorizontalAlignment = Enum.HorizontalAlignment.Left,
	Padding = UDim.new(0, 9),
	SortOrder = Enum.SortOrder.LayoutOrder,
}, hud)
new("UIPadding", {
	PaddingLeft = UDim.new(0, 11),
	PaddingRight = UDim.new(0, 12),
	PaddingTop = UDim.new(0, 6),
	PaddingBottom = UDim.new(0, 6),
}, hud)
if STATE.hudPos and type(STATE.hudPos) == "table" and type(STATE.hudPos[1]) == "number" then
	hud.AnchorPoint = Vector2.new(0, 0)
	hud.Position = UDim2.fromOffset(STATE.hudPos[1], STATE.hudPos[2])
end
-- a small coloured role dot that mirrors the highlight colour of your own role
local hudDot = new("Frame", {
	Name = "RoleDot",
	LayoutOrder = 1,
	Size = UDim2.fromOffset(8, 8),
	BackgroundColor3 = THEME.dim,
	BorderSizePixel = 0,
	ZIndex = 5,
}, hud)
corner(hudDot, 4)
local function hudSep(order)
	local s = new("Frame", {
		Name = "Sep",
		LayoutOrder = order,
		Size = UDim2.fromOffset(1, 14),
		BackgroundColor3 = THEME.line,
		BorderSizePixel = 0,
		BackgroundTransparency = 0.4,
		ZIndex = 5,
	}, hud)
	return s
end
local function hudLabel(order, font, size, colour)
	return new("TextLabel", {
		BackgroundTransparency = 1,
		LayoutOrder = order,
		AutomaticSize = Enum.AutomaticSize.X,
		Size = UDim2.new(0, 0, 1, 0),
		Font = font,
		Text = "",
		TextSize = size,
		TextColor3 = colour,
		TextXAlignment = Enum.TextXAlignment.Left,
		TextYAlignment = Enum.TextYAlignment.Center,
		ZIndex = 5,
	}, hud)
end
hudRole = hudLabel(2, THEME.fontBold, 13, THEME.text)
local hudSep1 = hudSep(3)
hudLine1 = hudLabel(4, THEME.font, 12, THEME.dim)
local hudSep2 = hudSep(5)
hudLine2 = hudLabel(6, THEME.font, 12, THEME.dim)

-- centre reticle
local crosshair = new("Frame", {
	Name = "Crosshair",
	AnchorPoint = Vector2.new(0.5, 0.5),
	Position = UDim2.fromScale(0.5, 0.5),
	Size = UDim2.fromOffset(6, 6),
	BackgroundColor3 = Color3.fromRGB(120, 255, 160),
	BorderSizePixel = 0,
	Visible = false,
	ZIndex = 5,
}, overlay)
corner(crosshair, 3)
new("UIStroke", {
	Color = Color3.fromRGB(0, 0, 0),
	Thickness = 1,
	Transparency = 0.4,
}, crosshair)

-- top-right stats (fps / ping)
local statsLbl = new("TextLabel", {
	Name = "Stats",
	AnchorPoint = Vector2.new(1, 0),
	Position = UDim2.new(1, -12, 0, 10),
	Size = UDim2.fromOffset(180, 18),
	BackgroundTransparency = 1,
	Font = THEME.fontBold,
	Text = "",
	TextSize = 12,
	TextColor3 = THEME.dim,
	TextXAlignment = Enum.TextXAlignment.Right,
	ZIndex = 5,
}, overlay)

-- ==========================================================================
-- highlight engine
-- ==========================================================================

local espFolder = new("Folder", { Name = "PCHub_ESP" }, Workspace)
local labelHolder = new("Folder", { Name = "PCHub_Labels" }, gui)

local tracked = {} --- [model] = { hl=, bb=, txt=, head= }
local targets = {} --- array of {model, head, pos, role, name, dist, colour, isSelf, dead}
local fx = {} --- [model] = { tracer=, arrow=, role= }

local ROLE_LABEL = { Monster = "MONSTER", Survivor = "SURVIVOR", Sentinel = "SENTINEL", Unknown = "?" }
local HUD_ROLE = { Monster = "Monster", Survivor = "Survivor", Sentinel = "Sentinel" }

local function colourFor(role, isSelf)
	if isSelf then
		return STATE.colors.Self
	end
	return STATE.colors[role] or STATE.colors.Unknown
end

local function roleOf(char, plr)
	local t = char:GetAttribute("Type")
	if t == "Monster" or t == "Survivor" or t == "Sentinel" then
		return t
	end
	if plr and plr:GetAttribute("EndCondition") == "MONSTER" then
		return "Monster"
	end
	return "Unknown"
end

local function ensureRecord(model)
	local rec = tracked[model]
	if rec and rec.hl and rec.hl.Parent then
		return rec
	end
	local hl = new("Highlight", {
		Name = "PCHub_HL",
		FillColor = Color3.fromRGB(255, 255, 255),
		OutlineColor = Color3.fromRGB(255, 255, 255),
		FillTransparency = STATE.fillTransparency,
		OutlineTransparency = STATE.outlineTransparency,
		Adornee = model,
	}, espFolder)

	local bb = new("BillboardGui", {
		Name = "PCHub_LBL",
		Size = UDim2.fromOffset(190, 30),
		StudsOffsetWorldSpace = Vector3.new(0, 3.2, 0),
		AlwaysOnTop = true,
		MaxDistance = math.huge,
		LightInfluence = 0,
		Enabled = false,
	}, labelHolder)
	local txt = new("TextLabel", {
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Font = Enum.Font.GothamBold,
		Text = "",
		TextSize = 14,
		TextColor3 = Color3.fromRGB(255, 255, 255),
		TextStrokeTransparency = 0.35,
		TextStrokeColor3 = Color3.fromRGB(0, 0, 0),
	}, bb)

	rec = { hl = hl, bb = bb, txt = txt, head = nil }
	tracked[model] = rec
	return rec
end

local counts = { Monster = 0, Survivor = 0, Sentinel = 0, Unknown = 0 }
local trackedCount = 0
local monsterDist = nil

local function stepHighlights()
	counts = { Monster = 0, Survivor = 0, Sentinel = 0, Unknown = 0 }
	trackedCount = 0
	monsterDist = nil
	local newTargets = {}

	if not STATE.enabled then
		for model, rec in pairs(tracked) do
			if rec.hl then
				rec.hl:Destroy()
			end
			if rec.bb then
				rec.bb:Destroy()
			end
			tracked[model] = nil
		end
		targets = newTargets
		return
	end

	local cam = Workspace.CurrentCamera
	local origin = cam and cam.CFrame.Position or Vector3.new(0, 0, 0)
	local seen = {}

	for _, plr in ipairs(Players:GetPlayers()) do
		local char = plr.Character
		if char and char.Parent then
			local isSelf = (plr == LP)
			local role = roleOf(char, plr)
			counts[role] = (counts[role] or 0) + 1

			local wantRole
			if isSelf then
				wantRole = STATE.self
			else
				wantRole = STATE.roles[role] == true
			end

			local hrp = char:FindFirstChild("HumanoidRootPart") or char.PrimaryPart
			local head = char:FindFirstChild("Head") or hrp
			local hum = char:FindFirstChildOfClass("Humanoid")
			local dead = hum ~= nil and hum.Health <= 0
			local dist = hrp and (hrp.Position - origin).Magnitude or 99999

			if (role == "Monster") and hrp and not isSelf then
				if monsterDist == nil or dist < monsterDist then
					monsterDist = dist
				end
			end

			local visible = wantRole and dist <= STATE.maxDist
			if visible then
				local rec = ensureRecord(char)
				seen[char] = true
				trackedCount = trackedCount + 1

				local colour = rgb(colourFor(role, isSelf))
				rec.hl.FillColor = colour
				rec.hl.OutlineColor = colour
				rec.hl.Adornee = char
				rec.hl.Enabled = true
				rec.hl.FillTransparency = STATE.fillTransparency
				rec.hl.OutlineTransparency = STATE.outlineTransparency
				rec.hl.DepthMode = STATE.throughWalls and Enum.HighlightDepthMode.AlwaysOnTop
					or Enum.HighlightDepthMode.Occluded

				if head ~= rec.head then
					rec.bb.Adornee = head
					rec.head = head
				end

				if STATE.labels then
					local bits = { plr.Name }
					if STATE.showRole then
						bits[#bits + 1] = "(" .. (ROLE_LABEL[role] or role) .. ")"
					end
					if STATE.showDistance then
						bits[#bits + 1] = string.format("%dm", math.floor(dist + 0.5))
					end
					if isSelf then
						bits[#bits + 1] = "· you"
					end
					if dead then
						bits[#bits + 1] = "· dead"
					end
					rec.txt.Text = table.concat(bits, " ")
					rec.txt.TextColor3 = colour
					rec.bb.Enabled = true
				else
					rec.bb.Enabled = false
				end

				if hrp then
					newTargets[#newTargets + 1] = {
						model = char,
						head = head,
						pos = hrp.Position,
						role = role,
						name = plr.Name,
						dist = dist,
						colour = colour,
						isSelf = isSelf,
						dead = dead,
					}
				end
			end
		end
	end

	for model, rec in pairs(tracked) do
		if not seen[model] then
			if rec.hl then
				rec.hl:Destroy()
			end
			if rec.bb then
				rec.bb:Destroy()
			end
			tracked[model] = nil
		end
	end

	targets = newTargets
end

-- ==========================================================================
-- task engine  ·  "Collect soul shards" (Objectives1) + trash (Objectives2)
-- ==========================================================================
--
-- Verified live (place 121255181515820):
--   * The survivor task is "Collect soul shards".  workspace.Map.Objectives1
--     goes 0 -> 100 (Max).  Each of the 100 Shard models under
--     Map.ObjectiveInteract1 exposes a HumanoidRootPart that is "available"
--     while hrp.CanTouch == true and hrp.Transparency < 1.
--   * Map.ScriptHandler.SetupObjectives.ClientTaskScript owns a handle on the
--     shard's Touched event; on touch it bumps Objectives1 locally and fires
--     its no-argument AddStuff RemoteEvent.  The server then re-validates.
--   * Firing AddStuff directly is REJECTED (we tested: count unchanged), and a
--     distant touch is ignored.  Moving the character ONTO a shard collects it
--     reliably (tested 16 -> 38).  That is exactly what auto-collect does, so
--     the game's own code does the collecting - we only reposition you.

local shardFolder = new("Folder", { Name = "PCHub_Shards" }, Workspace)
local shardRecs = {} --- [model] = Highlight
local shardAvail = 0
local shardLastTotal = 0

local function mapNow()
	local m = Workspace:FindFirstChild("Map")
	if m and m.Parent == Workspace then
		return m
	end
	return nil
end

local function shardContainer(map)
	return map and map:FindFirstChild("ObjectiveInteract1") or nil
end

-- every "ObjectiveInteract*" folder in the current map.  Names vary by map:
-- ObjectiveInteract1 = soul shards, ObjectiveInteract = valves,
-- ObjectiveInteract2 = trash / generators.
local function objectiveFolders(map)
	local list = {}
	if not map then
		return list
	end
	for _, c in ipairs(map:GetChildren()) do
		if type(c.Name) == "string" and string.sub(c.Name, 1, 17) == "ObjectiveInteract" then
			list[#list + 1] = c
		end
	end
	return list
end

-- A "task" prompt is an enabled ProximityPrompt that advances an objective.
-- Most actions are the task ("clean debris", "shovel snow", "to grab/drop" on
-- an EnergyCell).  The one prop that shares this shape but is NOT an objective
-- is the trash map's TrashBag, whose grab/drop prompt lives inside the same
-- ObjectiveInteract2 folder - counting or firing it would grab a prop instead
-- of doing the task, so it is excluded by name.
local function isTaskPrompt(d)
	if not (d:IsA("ProximityPrompt") and d.Enabled) then
		return false
	end
	local p = d.Parent
	if p and (p.Name == "TrashBag" or p:FindFirstAncestor("TrashBag")) then
		return false
	end
	return true
end

local function hasEnabledPrompt(model)
	for _, d in ipairs(model:GetDescendants()) do
		if isTaskPrompt(d) then
			return d
		end
	end
	return nil
end

local function isShardAvailable(hrp)
	return hrp ~= nil and hrp.CanTouch == true and hrp.Transparency < 1
end

-- ---------------------------------------------------------------------------
-- bone / firepit task ("Burn bones in firepit")
--
-- This one is entirely LOCAL: the map's Bone.DragHandler clones a DragDetector
-- plus an enabled ProximityPrompt onto every bone in ObjectiveInteract1, and the
-- bone's own Touched handler does the objective work the instant a HELD bone
-- touches the map's FireBox part (it bumps Objectives1 locally, then fires
-- AddStuff).  So the genuine interaction is two steps - grab the bone, carry it
-- onto the fire - and that is exactly what the collector reproduces below;
-- firing the prompt alone only grabs/drops it and never scores.
-- ---------------------------------------------------------------------------
local function findFireBox(map)
	local fb = map and map:FindFirstChild("FireBox", true)
	if fb and fb:IsA("BasePart") then
		return fb
	end
	return nil
end

local function boneDragParts(model, map)
	local dd = model:FindFirstChild("DragDetector")
	local prompt = model:FindFirstChild("ProximityPrompt")
	if not (dd and prompt) then
		return nil
	end
	local fb = findFireBox(map)
	if not fb then
		return nil
	end
	return dd, fb
end

-- grab a bone and carry it onto the fire; the game's Touched code completes it
local function carryBoneToFire(char, model, dd, fb)
	if not (char and char.Parent and dd and dd.Parent and model.Parent) then
		return false
	end
	-- stand at the firepit: the game's own drag watch drops a held bone once it
	-- is 9+ studs from the holder, so we hold it right on top of the fire.
	pcall(function()
		char:PivotTo(CFrame.new(fb.Position + Vector3.new(0, 3, 0)))
	end)
	-- grab exactly the way Bone.DragHandler's prompt handler does
	pcall(function()
		dd.ReferenceInstance = char
		char:SetAttribute("HoldingItem", true)
	end)
	-- drive the bone onto the fire (a real touch, not a fake prompt fire)
	pcall(function()
		model.CFrame = fb.CFrame
	end)
	pcall(function()
		if type(firetouchinterest) == "function" then
			firetouchinterest(model, fb, 1)
		end
	end)
	return true
end

-- ---------------------------------------------------------------------------
-- round / map awareness
--
-- Step one is ALWAYS to work out whether we are actually in a live round on a
-- real map, and who/what we are.  Nothing (ESP, auto-collect) runs until that
-- is true, so the hub never behaves as if it were mid-game when it is not.
-- ---------------------------------------------------------------------------

local function mapObjectName(map)
	local o1 = map and map:FindFirstChild("Objectives1")
	local n = o1 and o1:FindFirstChild("ObjName")
	if n and type(n.Value) == "string" and n.Value ~= "" then
		return n.Value
	end
	local o2 = map and map:FindFirstChild("Objectives2")
	local n2 = o2 and o2:FindFirstChild("ObjName")
	if n2 and type(n2.Value) == "string" and n2.Value ~= "" then
		return n2.Value
	end
	return nil
end

-- latest round state, refreshed on the task cadence and read by the HUD
local ROUND = { inRound = false, status = "Not in game.", map = nil }

-- Which phase are we in?  The old version guessed from the LobbyGUI frames, but
-- ResultsScreen is Visible for most of a round too, so it reported "over" while
-- you were alive.  These are the signals the GAME itself drives (verified from
-- its own scripts):
--   LobbyGUI.Lobby.Visible               the pre-round menu is open
--   workspace.Map.GameOver               a child appears when the round ends
--   workspace.Server.Timer.Value         counts down while a round runs
--   char.Aspects.Health.Value            the REAL health (Humanoid is cosmetic)
--   char.Aspects.Escaped.Value           you reached the exit
--   Player:GetAttribute("EndCondition")  set once you are out ("You died.")
local function uiPhase()
	local map = mapNow()
	if not map then
		return "nogame"
	end

	local pg = LP:FindFirstChildOfClass("PlayerGui")
	local lg = pg and pg:FindFirstChild("LobbyGUI")
	local lobby = lg and lg:FindFirstChild("Lobby")
	if lobby and lobby.Visible then
		return "lobby"
	end

	if map:FindFirstChild("GameOver") then
		return "over"
	end

	local char = LP.Character
	if not char or not char:FindFirstChildOfClass("Humanoid") then
		return "load"
	end
	local asp = char:FindFirstChild("Aspects")
	local esc = asp and asp:FindFirstChild("Escaped")
	if esc and esc.Value == true then
		return "escaped"
	end
	local h = asp and asp:FindFirstChild("Health")
	local hum = char:FindFirstChildOfClass("Humanoid")
	local hp = (h and h.Value) or (hum and hum.Health) or 0
	if hp <= 0 then
		return "dead"
	end
	return "round"
end

-- returns (inRound, statusText, mapName)
local function roundInfo()
	local map = mapNow()
	if not (map and map.Parent) then
		return false, "Not in game.", nil
	end
	local phase = uiPhase()
	if phase == "nogame" then
		return false, "Not in game.", nil
	end
	if phase == "lobby" then
		return false, "Waiting for a round...", mapObjectName(map)
	end
	if phase == "over" then
		return false, "Round over.", mapObjectName(map)
	end
	if phase == "escaped" then
		return false, "You escaped!", mapObjectName(map)
	end
	if phase == "dead" then
		return false, "Dead - spectating.", mapObjectName(map)
	end
	if phase == "load" then
		return false, "Loading into the round...", mapObjectName(map)
	end
	return true, "In round.", mapObjectName(map)
end

-- available = touchable root part (shards) or a live interaction prompt
-- (valves, generators, snow piles, trash).
local function isObjectiveAvailable(model)
	local hrp = model:FindFirstChild("HumanoidRootPart")
	if hrp and isShardAvailable(hrp) then
		return true
	end
	return hasEnabledPrompt(model) ~= nil
end

-- ---------------------------------------------------------------------------
-- objective auto-collect (unified)
--
-- Two collection styles exist in Pillar Chase:
--   * touch shards - Map.ObjectiveInteract1, each model owns a touchable
--     HumanoidRootPart; standing on it makes the game's own Touched code
--     collect it (verified live).
--   * prompts      - snow piles (ObjectiveInteract: Twigs + a SnowPile part)
--     and trash (ObjectiveInteract2: TrashPile debris).  Every part exposes a
--     ProximityPrompt and the game's client task script does the work when the
--     prompt fires (trash: 3 fires per debris part, 12 parts per pile).
--
-- The tick picks the nearest available objective across every
-- ObjectiveInteract* folder and does whichever style applies.
-- ---------------------------------------------------------------------------

-- world position to stand on for an objective model
local function objectivePosition(model)
	local hrp = model:FindFirstChild("HumanoidRootPart")
	if hrp and hrp:IsA("BasePart") then
		return hrp.Position
	end
	for _, name in ipairs({ "SnowPile", "TrashPile", "Trash", "Union" }) do
		local p = model:FindFirstChild(name, true)
		if p and p:IsA("BasePart") then
			return p.Position
		end
	end
	for _, d in ipairs(model:GetDescendants()) do
		if d:IsA("BasePart") then
			return d.Position
		end
	end
	return nil
end

-- fire every live prompt under a model (trash needs 3 fires per part; the tick
-- re-fires until the part is gone)
local function fireObjectivePrompts(model)
	local fired = 0
	for _, d in ipairs(model:GetDescendants()) do
		if isTaskPrompt(d) then
			fired = fired + 1
			local hold = d.HoldDuration
			pcall(function()
				if type(fireproximityprompt) == "function" then
					fireproximityprompt(d, hold + 0.05)
				end
			end)
		end
	end
	return fired
end

-- containers this session services.
--
-- There is ONE master switch (STATE.autoCollect, shown as "Auto tasks"): when
-- it is off this returns nothing, so the hub never teleports you anywhere on
-- its own.  It is off by default and is fully independent of the ESP.
local function collectorFolders(map, force)
	local list = {}
	if not (STATE.autoCollect or force) then
		return list
	end
	for _, c in ipairs(objectiveFolders(map)) do
		list[#list + 1] = c
	end
	return list
end

-- `skip` (optional) is a set of models the auto-collector has already found it
-- cannot clear (e.g. a drag-to-turn valve). They are ignored while their
-- cooldown is live so the tick moves on to something it can actually finish.
local function findNearestObjective(origin, force, skip)
	local map = mapNow()
	local now = os.clock()
	local best, bestD, bestPos
	for _, container in ipairs(collectorFolders(map, force)) do
		for _, model in ipairs(container:GetChildren()) do
			local skipped = skip and skip[model] and skip[model] > now
			if not skipped and isObjectiveAvailable(model) then
				local p = objectivePosition(model)
				if p then
					local d = origin and (p - origin).Magnitude or 0
					if not bestD or d < bestD then
						best, bestD, bestPos = model, d, p
					end
				end
			end
		end
	end
	return best, bestD, bestPos
end

-- objective ESP + progress readout (runs on the task cadence, not per frame)
local function stepShardEsp()
	local inRound, status, mapName = roundInfo()
	ROUND.inRound = inRound
	ROUND.status = status
	ROUND.map = mapName

	local map = mapNow()
	local seen = {}
	local avail = 0

	if inRound and map then
		for _, container in ipairs(objectiveFolders(map)) do
			for _, model in ipairs(container:GetChildren()) do
				if isObjectiveAvailable(model) then
					avail = avail + 1
					if STATE.shardEsp then
						seen[model] = true
						local hl = shardRecs[model]
						if not (hl and hl.Parent) then
							hl = new("Highlight", {
								Name = "PCHub_ShardHL",
								FillTransparency = 0.6,
								OutlineTransparency = 0.05,
								DepthMode = Enum.HighlightDepthMode.AlwaysOnTop,
								Adornee = model,
							}, shardFolder)
							shardRecs[model] = hl
						end
						local colour = rgb(STATE.shardColor)
						hl.FillColor = colour
						hl.OutlineColor = colour
						hl.Adornee = model
						hl.Enabled = true
					end
				end
			end
		end
	end

	for model, hl in pairs(shardRecs) do
		if not seen[model] then
			hl:Destroy()
			shardRecs[model] = nil
		end
	end

	shardAvail = avail

	-- shared readout (also consumed by the Info tab and the round banner)
	pcall(function()
		if infoStatus then
			infoStatus.Text = "Status: " .. status
			infoStatus.TextColor3 = inRound and THEME.on or THEME.dim
		end
		if infoMap then
			infoMap.Text = "Map: " .. (mapName or "-")
		end
		if bannerText then
			bannerText.Text = status
			bannerText.TextColor3 = inRound and THEME.on or THEME.dim
		end
		if bannerMap then
			bannerMap.Text = mapName or ""
		end
		if bannerDot then
			bannerDot.BackgroundColor3 = inRound and THEME.on or THEME.off
		end
	end)

	if not (inRound and map) then
		pcall(function()
			infoObj1.Text = "Objective 1: -"
			infoObj2.Text = "Objective 2: -"
			infoExit.Text = "Exit located: -"
			infoShards.Text = "Objectives left: -"
		end)
		return
	end

	pcall(function()
		local o1 = map:FindFirstChild("Objectives1")
		local o2 = map:FindFirstChild("Objectives2")
		local exit = map:FindFirstChild("ExitLocated")
		local n1 = o1 and o1:FindFirstChild("ObjName")
		local n2 = o2 and o2:FindFirstChild("ObjName")
		if o1 then
			local mx = o1:FindFirstChild("Max")
			infoObj1.Text = string.format("%s: %d / %d",
				(n1 and n1.Value ~= "" and n1.Value) or "Objective 1", o1.Value, mx and mx.Value or 100)
		else
			infoObj1.Text = "Objective 1: -"
		end
		if o2 then
			local mx = o2:FindFirstChild("Max")
			infoObj2.Text = string.format("%s: %d / %d",
				(n2 and n2.Value ~= "" and n2.Value) or "Objective 2", o2.Value, mx and mx.Value or 10)
		else
			infoObj2.Text = "Objective 2: -"
		end
		infoExit.Text = "Exit located: " .. (exit and tostring(exit.Value) or "-")
		infoShards.Text = "Objectives left: " .. avail
		if bannerMap then
			local function prog(o)
				if not o then
					return nil
				end
				local mx = o:FindFirstChild("Max")
				return string.format("%d/%d", o.Value, mx and mx.Value or 0)
			end
			local p1, p2 = prog(o1), prog(o2)
			local parts = { mapName or "" }
			if p1 then
				parts[#parts + 1] = p1
			end
			if p2 then
				parts[#parts + 1] = p2
			end
			bannerMap.Text = table.concat(parts, "   ·   ")
		end
	end)
end

-- manual collection (Tasks tab button + handle.collectOneShard)
function collectOneShard()
	local inRound = roundInfo()
	if not inRound then
		return false
	end
	local char = LP.Character
	local hrp = char and char:FindFirstChild("HumanoidRootPart")
	if not char or not hrp then
		return false
	end
	local model, _, pos = findNearestObjective(hrp.Position, true)
	if not model or not pos then
		return false
	end
	local dd, fb = boneDragParts(model, mapNow())
	if dd and fb then
		return carryBoneToFire(char, model, dd, fb)
	end
	pcall(function()
		char:PivotTo(CFrame.new(pos + Vector3.new(0, STATE.collectHeight, 0)))
	end)
	fireObjectivePrompts(model)
	return true
end

-- ---------------------------------------------------------------------------
-- Teleport tab engine
--
-- Scan-first: work out where we are (lobby / in round / dead / over) and which
-- map, THEN list each of the game's own task groups with the number of objects
-- still live.  Teleporting is just moving the character and letting the game's
-- own prompt fire - the server sees an ordinary player.
-- ---------------------------------------------------------------------------

-- ExitLocations is a direct child of Map on most maps, but on a few it is nested
-- deeper or wrapped in a folder, so find it by name recursively.  A map's exit
-- can be a single part, a model, or a folder holding either.
local function exitLocationFolder(map)
	if not map then
		return nil
	end
	return map:FindFirstChild("ExitLocations") or map:FindFirstChild("ExitLocations", true)
end

local function collectExitParts(container, out, depth)
	depth = depth or 0
	if depth > 3 or #out >= 24 then
		return
	end
	if container:IsA("BasePart") then
		out[#out + 1] = container
	elseif container:IsA("Model") then
		if container:FindFirstChildWhichIsA("BasePart", true) then
			out[#out + 1] = container
		end
	else
		for _, c in ipairs(container:GetChildren()) do
			collectExitParts(c, out, depth + 1)
		end
	end
end

-- a real exit carries the game's own escape prompt; a decorative twin (some maps
-- ship two Exit models) does not, so we can use that to put the tag on the right one.
local function hasEscapePrompt(inst)
	for _, d in ipairs(inst:GetDescendants()) do
		if d:IsA("ProximityPrompt") then
			if d.Name == "EscapePrompt" or string.find(string.lower(tostring(d.ActionText)), "escape") then
				return true
			end
		end
	end
	return false
end

-- the instances that represent the exit (for the ESP)
local function exitTargets(map)
	local all = {}
	local loc = exitLocationFolder(map)
	if not loc then
		return all
	end
	if loc:IsA("BasePart") or loc:IsA("Model") then
		all[#all + 1] = loc
	else
		for _, c in ipairs(loc:GetChildren()) do
			collectExitParts(c, all)
		end
	end
	-- prefer the exit(s) that actually carry the escape prompt, so the outline
	-- and EXIT tag sit on the real escape rather than a look-alike model
	local real = {}
	for _, t in ipairs(all) do
		if hasEscapePrompt(t) then
			real[#real + 1] = t
		end
	end
	if #real > 0 then
		return real
	end
	return all
end

local function exitPosition(map)
	for _, t in ipairs(exitTargets(map)) do
		if t:IsA("BasePart") then
			return t.Position
		elseif t:IsA("Model") then
			local primary = t.PrimaryPart or t:FindFirstChildWhichIsA("BasePart", true)
			if primary then
				return primary.Position
			end
		end
	end
	return nil
end

local function nearestInFolder(folder, origin)
	local best, bestD, bestPos
	for _, model in ipairs(folder:GetChildren()) do
		if isObjectiveAvailable(model) then
			local p = objectivePosition(model)
			if p then
				local d = origin and (p - origin).Magnitude or 0
				if not bestD or d < bestD then
					best, bestD, bestPos = model, d, p
				end
			end
		end
	end
	return best, bestD, bestPos
end

local function teleportTo(pos)
	if not pos then
		return false
	end
	local char = LP.Character
	local hrp = char and char:FindFirstChild("HumanoidRootPart")
	if not char or not hrp then
		return false
	end
	return pcall(function()
		char:PivotTo(CFrame.new(pos + Vector3.new(0, STATE.collectHeight, 0)))
	end)
end

local function clearGuiList(list)
	if not (list and list.Parent) then
		return
	end
	for _, c in ipairs(list:GetChildren()) do
		if c:IsA("GuiObject") then
			c:Destroy()
		end
	end
end

local function clearTPList()
	clearGuiList(TP and TP.list)
	clearGuiList(TP and TP.players)
end

local function addTPRow(order, text, enabled, action, parent)
	parent = parent or (TP and TP.list)
	if not (TP and parent) then
		return
	end
	local row = new("Frame", {
		Size = UDim2.new(1, 0, 0, 28),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		ZIndex = 2,
	}, parent)
	new("TextLabel", {
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(6, 0),
		Size = UDim2.new(1, -74, 1, 0),
		Font = Enum.Font.Gotham,
		Text = text,
		TextSize = 12,
		TextColor3 = enabled and THEME.text or THEME.dim,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 2,
	}, row)
	if enabled and action then
		local go = new("TextButton", {
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -2, 0.5, 0),
			Size = UDim2.fromOffset(58, 22),
			BackgroundColor3 = THEME.button,
			BorderSizePixel = 0,
			Font = Enum.Font.GothamBold,
			Text = "GO",
			TextSize = 12,
			TextColor3 = THEME.accent,
			AutoButtonColor = false,
			ZIndex = 3,
		}, row)
		corner(go, 6)
		stroke(go, THEME.accent, 1, 0.8)
		go.MouseEnter:Connect(function()
			go.BackgroundColor3 = THEME.buttonHi
		end)
		go.MouseLeave:Connect(function()
			go.BackgroundColor3 = THEME.button
		end)
		go.MouseButton1Click:Connect(function()
			action()
		end)
	end
	return row
end

local function liveOrigin()
	local char = LP.Character
	local hrp = char and char:FindFirstChild("HumanoidRootPart")
	return hrp and hrp.Position or nil
end

TP = TP or {}
TP.active = false
TP.refresh = function()
	if not (TP.list and TP.list.Parent) then
		return
	end
	local inRound, status, mapName = roundInfo()
	local map = mapNow()

	if TP.status then
		TP.status.Text = "Status: " .. status
		TP.status.TextColor3 = inRound and THEME.on or THEME.dim
	end
	if TP.map then
		TP.map.Text = "Map: " .. (mapName or "-")
	end

	clearTPList()

	if not (inRound and map) then
		if TP.obj1 then
			TP.obj1.Text = "Objective 1: -"
		end
		if TP.obj2 then
			TP.obj2.Text = "Objective 2: -"
		end
		if TP.exit then
			TP.exit.Text = "Exit located: -"
		end
		addTPRow(1, "Not in a live round right now.", false)
		if TP.players then
			addTPRow(1, "Players list only fills in a live round.", false, nil, TP.players)
		end
		return
	end

	local o1 = map:FindFirstChild("Objectives1")
	local o2 = map:FindFirstChild("Objectives2")
	local ex = map:FindFirstChild("ExitLocated")
	local n1 = o1 and o1:FindFirstChild("ObjName")
	local n2 = o2 and o2:FindFirstChild("ObjName")
	if TP.obj1 then
		local mx = o1 and o1:FindFirstChild("Max")
		TP.obj1.Text = o1
				and string.format("%s: %d / %d", (n1 and n1.Value ~= "" and n1.Value) or "Objective 1", o1.Value, mx and mx.Value or 100)
			or "Objective 1: -"
	end
	if TP.obj2 then
		local mx = o2 and o2:FindFirstChild("Max")
		TP.obj2.Text = o2
				and string.format("%s: %d / %d", (n2 and n2.Value ~= "" and n2.Value) or "Objective 2", o2.Value, mx and mx.Value or 10)
			or "Objective 2: -"
	end
	if TP.exit then
		TP.exit.Text = "Exit located: " .. (ex and (ex.Value and "Yes" or "Not yet") or "-")
	end

	local order = 0
	for _, folder in ipairs(objectiveFolders(map)) do
		order += 1
		local count = 0
		for _, model in ipairs(folder:GetChildren()) do
			if isObjectiveAvailable(model) then
				count += 1
			end
		end
		local label = string.format("%s  -  %d left", folder.Name, count)
		if count == 0 then
			addTPRow(order, label .. "  (cleared)", false)
		else
			addTPRow(order, label, true, function()
				local _, _, pos = nearestInFolder(folder, liveOrigin())
				teleportTo(pos)
			end)
		end
	end
	if order == 0 then
		addTPRow(1, "No task groups found on this map yet.", false)
		order = 1
	end

	order += 1
	local epos = exitPosition(map)
	local located = ex ~= nil and ex.Value == true
	if epos then
		addTPRow(order, located and "Exit  -  teleport to the exit" or "Exit  -  teleport to the exit (not revealed yet)", true, function()
			teleportTo(epos)
		end)
	else
		addTPRow(order, "Exit  -  no exit found on this map", false)
	end

	-- players: nearest first, with role + distance, tap GO to drop onto them
	if TP.players then
		clearGuiList(TP.players)
		local char = LP.Character
		local h0 = char and char:FindFirstChild("HumanoidRootPart")
		local origin = h0 and h0.Position or nil
		local rows = {}
		for _, p in ipairs(Players:GetPlayers()) do
			if p ~= LP then
				local c = p.Character
				local hrp = c and c:FindFirstChild("HumanoidRootPart")
				if hrp then
					local d = origin and (hrp.Position - origin).Magnitude or 0
					rows[#rows + 1] = { name = p.Name, pos = hrp.Position, d = d, role = roleOf(c, p) }
				end
			end
		end
		table.sort(rows, function(a, b)
			return a.d < b.d
		end)
		local po = 0
		for _, r in ipairs(rows) do
			po += 1
			local label = string.format("%s  -  %s  -  %d studs", r.name, tostring(r.role), math.floor(r.d + 0.5))
			addTPRow(po, label, true, function()
				teleportTo(r.pos)
			end, TP.players)
		end
		if po == 0 then
			addTPRow(1, "No other players in the round right now.", false, nil, TP.players)
		end
	end
end

TP.goNearest = function()
	local inRound = roundInfo()
	if not inRound then
		return false
	end
	local _, _, pos = findNearestObjective(liveOrigin(), true)
	return teleportTo(pos)
end

TP.goExit = function()
	local map = mapNow()
	if not map then
		return false
	end
	return teleportTo(exitPosition(map))
end

-- auto-collect state machine: lock onto one objective, keep touching / firing
-- until it clears, pause collectDelay, then move on.  A target that survives
-- the whole dwell without clearing is remembered in `skip` for a while: some
-- tasks (the "Turn valve" panels) are drag gestures that no prompt fire can
-- satisfy, so re-locking onto them forever would stall everything else.
local AC = { target = nil, started = 0, nextAt = 0, touch = false, skip = {} }

local function autoCollectTick(now)
	if not STATE.autoCollect then
		AC.target = nil
		return
	end
	-- never act outside a live round (no map, dead, loading)
	local inRound = roundInfo()
	if not inRound then
		AC.target = nil
		return
	end
	local char = LP.Character
	local hrp = char and char:FindFirstChild("HumanoidRootPart")
	if not char or not hrp then
		AC.target = nil
		return
	end
	local map = mapNow()

	if AC.target then
		local t = AC.target
		if not t.Parent or not isObjectiveAvailable(t) then
			-- cleared (or gone): brief pause, then find the next one
			AC.target = nil
			AC.skip[t] = nil
			AC.nextAt = now + STATE.collectDelay
			pcall(function()
				char:SetAttribute("HoldingItem", nil)
			end)
			return
		end
		if now - AC.started > 2.5 then
			-- not progressing (unreachable, or a gesture we cannot fire):
			-- sideline it for a while so the tick can service other targets
			AC.skip[t] = now + 20
			AC.target = nil
			AC.nextAt = now + STATE.collectDelay
			return
		end
		-- bone / firepit: the real grab-then-carry, driven by the game's touch
		local dd, fb = boneDragParts(t, map)
		if dd and fb then
			if roleOf(char, LP) ~= "Monster" then
				pcall(function()
					carryBoneToFire(char, t, dd, fb)
				end)
			end
			return
		end
		local pos = objectivePosition(t)
		if pos then
			pcall(function()
				char:PivotTo(CFrame.new(pos + Vector3.new(0, STATE.collectHeight, 0)))
			end)
			if not AC.touch then
				fireObjectivePrompts(t)
			end
		end
		return
	end

	if now < AC.nextAt then
		return
	end
	local model, _, pos = findNearestObjective(hrp.Position, nil, AC.skip)
	if model then
		AC.target = model
		AC.started = now
		local shrp = model:FindFirstChild("HumanoidRootPart")
		AC.touch = shrp ~= nil and isShardAvailable(shrp)
		if pos then
			pcall(function()
				char:PivotTo(CFrame.new(pos + Vector3.new(0, STATE.collectHeight, 0)))
			end)
			if not AC.touch then
				fireObjectivePrompts(model)
			end
		end
	else
		-- nothing available (or everything is on a skip cooldown); prune dead
		-- skip entries while we wait, then re-check shortly
		for m, until_ in pairs(AC.skip) do
			if not m.Parent or until_ < now then
				AC.skip[m] = nil
			end
		end
		AC.nextAt = now + 0.5
	end
end

-- forward declaration: defined with the utility engine below, but called here.
local stepExitEsp

-- shared run flag (declared here so taskLoop below can capture it as an upvalue)
local running = true

local function taskLoop()
	local lastScan = 0
	local lastTP = 0
	while running do
		local now = os.clock()
		-- objective/exit scan is comparatively heavy (descendant walks), so
		-- run it on a slower cadence; auto-collect stays responsive.
		if now - lastScan >= 0.6 then
			lastScan = now
			pcall(stepShardEsp)
			pcall(stepExitEsp)
		end
		pcall(autoCollectTick, now)
		-- refresh the Teleport readout once a second, only while it is shown
		if TP and TP.active and TP.refresh and gui.Enabled and now - lastTP >= 1.0 then
			lastTP = now
			pcall(TP.refresh)
		end
		task.wait(0.05)
	end
end

-- ==========================================================================
-- utility engine  ·  anti-AFK, fullbright, FOV, exit ESP, stats, panic
-- ==========================================================================

local Lighting = game:GetService("Lighting")
local VirtualUser = game:GetService("VirtualUser")
local Stats = game:GetService("Stats")

-- exit ESP ----------------------------------------------------------------
-- ExitLocations children are named differently per map ("ExitDoors", "Exit",
-- ...), so highlight every Model / BasePart directly under ExitLocations.
local exitFolder = new("Folder", { Name = "PCHub_Exit" }, Workspace)
local exitRecs = {} --- [instance] = { hl=, bb=, txt= }
local exitLocated = false

local function clearExit()
	for inst, rec in pairs(exitRecs) do
		if rec.hl then
			rec.hl:Destroy()
		end
		if rec.bb then
			rec.bb:Destroy()
		end
		exitRecs[inst] = nil
	end
end

local function makeExitRec(inst)
	local hl = new("Highlight", {
		Name = "PCHub_ExitHL",
		FillTransparency = 0.6,
		OutlineTransparency = 0.05,
		DepthMode = Enum.HighlightDepthMode.AlwaysOnTop,
		Adornee = inst,
	}, exitFolder)
	local bb = new("BillboardGui", {
		Name = "PCHub_ExitLBL",
		Size = UDim2.fromOffset(120, 22),
		StudsOffsetWorldSpace = Vector3.new(0, 4, 0),
		AlwaysOnTop = true,
		MaxDistance = math.huge,
		LightInfluence = 0,
		Enabled = true,
		Adornee = inst,
	}, exitFolder)
	local txt = new("TextLabel", {
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Font = Enum.Font.GothamBold,
		Text = "EXIT",
		TextSize = 14,
		TextColor3 = Color3.fromRGB(255, 255, 255),
		TextStrokeTransparency = 0.35,
		TextStrokeColor3 = Color3.fromRGB(0, 0, 0),
	}, bb)
	return { hl = hl, bb = bb, txt = txt }
end

function stepExitEsp()
	local map = mapNow()
	local flag = map and map:FindFirstChild("ExitLocated")
	exitLocated = flag ~= nil and flag.Value == true

	local want = {}
	if STATE.exitEsp and map then
		for _, inst in ipairs(exitTargets(map)) do
			want[inst] = true
		end
	end

	for inst, rec in pairs(exitRecs) do
		if not (want[inst] and inst.Parent) then
			rec.hl:Destroy()
			rec.bb:Destroy()
			exitRecs[inst] = nil
		end
	end

	local colour = rgb(STATE.exitColor)
	for inst in pairs(want) do
		local rec = exitRecs[inst]
		if not (rec and rec.hl.Parent) then
			rec = makeExitRec(inst)
			exitRecs[inst] = rec
		end
		rec.hl.Adornee = inst
		rec.hl.FillColor = colour
		rec.hl.OutlineColor = colour
		rec.hl.Enabled = true
		rec.bb.Adornee = inst
		rec.bb.Enabled = true
		rec.txt.TextColor3 = colour
		rec.txt.Text = exitLocated and "EXIT" or "EXIT ?"
	end
end

-- fullbright (local lighting only) -----------------------------------------
local fbSaved = nil

local function applyFullbright()
	if STATE.fullbright then
		if not fbSaved then
			fbSaved = {
				Ambient = Lighting.Ambient,
				OutdoorAmbient = Lighting.OutdoorAmbient,
				Brightness = Lighting.Brightness,
			}
		end
		-- Brighten, but leave ClockTime and GlobalShadows ALONE.
		--
		-- The game grades every round: on spawn it tweens the map to night
		-- (ClockTime 0) and desaturates it, then restores daytime in the lobby.
		-- Forcing ClockTime = 14 here lit the match at noon while the sky stayed
		-- night, and turning global shadows off flattened it: together that is
		-- the washed-grey, wrong-hue "inverted" look that only ever appeared
		-- during a match.  Lifting the two ambient colours and brightness is
		-- enough to see by and keeps the map's own time of day and shading.
		Lighting.Ambient = Color3.fromRGB(150, 150, 150)
		Lighting.OutdoorAmbient = Color3.fromRGB(150, 150, 150)
		Lighting.Brightness = 3
	elseif fbSaved then
		for k, v in pairs(fbSaved) do
			pcall(function()
				Lighting[k] = v
			end)
		end
		fbSaved = nil
	end
end

-- no fog (independent of fullbright) ---------------------------------------
local fogSaved = nil

local function applyNoFog()
	if STATE.noFog then
		local atm = Lighting:FindFirstChildOfClass("Atmosphere")
		if not fogSaved then
			fogSaved = {
				FogEnd = Lighting.FogEnd,
				FogStart = Lighting.FogStart,
				FogColor = Lighting.FogColor,
				atm = atm,
				atmDensity = atm and atm.Density or nil,
			}
		end
		Lighting.FogEnd = 100000
		Lighting.FogStart = 100000
		local a = fogSaved.atm
		if a and a.Parent then
			pcall(function()
				a.Density = 0
			end)
		end
	elseif fogSaved then
		pcall(function()
			Lighting.FogEnd = fogSaved.FogEnd
			Lighting.FogStart = fogSaved.FogStart
			Lighting.FogColor = fogSaved.FogColor
		end)
		local a = fogSaved.atm
		if a and a.Parent and fogSaved.atmDensity then
			pcall(function()
				a.Density = fogSaved.atmDensity
			end)
		end
		fogSaved = nil
	end
end

-- noclip (local collision only) --------------------------------------------
local noclipParts = {}

local function restoreNoclip()
	for p in pairs(noclipParts) do
		if p.Parent then
			pcall(function()
				p.CanCollide = true
			end)
		end
	end
	noclipParts = {}
end

local function applyNoclip()
	local char = LP.Character
	if not char then
		noclipParts = {}
		return
	end
	if not STATE.noclip then
		if next(noclipParts) then
			restoreNoclip()
		end
		return
	end
	-- prune parts from a previous life, then disable collision on this one
	for p in pairs(noclipParts) do
		if not p.Parent then
			noclipParts[p] = nil
		end
	end
	for _, p in ipairs(char:GetDescendants()) do
		if p:IsA("BasePart") and p.CanCollide and not noclipParts[p] then
			noclipParts[p] = true
			pcall(function()
				p.CanCollide = false
			end)
		end
	end
end

-- field of view ------------------------------------------------------------
local function applyFov()
	local cam = Workspace.CurrentCamera
	if cam and math.abs(cam.FieldOfView - STATE.cameraFov) > 0.1 then
		pcall(function()
			cam.FieldOfView = STATE.cameraFov
		end)
	end
end

-- anti-AFK -----------------------------------------------------------------
local antiAfkConn = nil

local function ensureAntiAfk()
	if antiAfkConn or type(LP.Idled) ~= "RBXScriptSignal" then
		return
	end
	antiAfkConn = LP.Idled:Connect(function()
		if not STATE.antiAfk then
			return
		end
		pcall(function()
			VirtualUser:CaptureController()
			VirtualUser:ClickButton2(Vector2.new(0, 0))
		end)
	end)
end

-- infinite stamina ----------------------------------------------------------
-- The LOCAL player's Aspects live on LocalPlayer.Character, not on the round
-- rig under workspace.Map (those rigs are the OTHER players, named by display
-- name).  Stamina is a NumberValue with Max / DrainAmount / DrainSpeed /
-- RegenAmount / Delay children.  The game's own Control script stops a sprint
-- only when Stamina.Value <= 0, so we (a) zero the drain tuning once and (b)
-- re-pin the value to Max the instant the game lowers it, via a change signal
-- AND the 0.4s status poll so a mapped round can never run the bar down.
-- This is client-side; the server keeps its own copy, so treat it as cosmetic.
local applyStamina
do
	local staminaBound = nil
	local staminaConn = nil
	local staminaRestore = nil

	local function currentStamina()
		local char = LP.Character
		local aspects = char and char:FindFirstChild("Aspects")
		if not aspects then
			local map = Workspace:FindFirstChild("Map")
			local rig = map and (map:FindFirstChild(LP.Name) or map:FindFirstChild(LP.DisplayName))
			aspects = rig and rig:FindFirstChild("Aspects")
		end
		return aspects and aspects:FindFirstChild("Stamina") or nil
	end

	-- pin the bar at Max and keep the sprint gate open
	local function pin(st)
		local m = st:FindFirstChild("Max")
		local cap = (m and m:IsA("ValueBase")) and m.Value or st.Value
		if st.Value ~= cap then
			pcall(function()
				st.Value = cap
			end)
		end
		local aspects = st.Parent
		local cs = aspects and aspects:FindFirstChild("CanSprint")
		if cs and cs:IsA("BoolValue") and not cs.Value then
			pcall(function()
				cs.Value = true
			end)
		end
	end

	-- put the tuning back the way the game had it
	local function restore(st)
		if not staminaRestore then
			return
		end
		for n, val in pairs(staminaRestore) do
			local v = st:FindFirstChild(n)
			if v and v:IsA("ValueBase") then
				pcall(function()
					v.Value = val
				end)
			end
		end
		staminaRestore = nil
	end

	applyStamina = function()
		local st = currentStamina()
		if st ~= staminaBound then
			if staminaConn then
				pcall(function()
					staminaConn:Disconnect()
				end)
				staminaConn = nil
			end
			if staminaBound and staminaBound.Parent then
				restore(staminaBound)
			end
			staminaRestore = nil
			staminaBound = st
		end
		if not st then
			return
		end
		if STATE.infiniteStamina then
			if not staminaRestore then
				staminaRestore = {}
				for _, n in ipairs({ "DrainAmount", "DrainSpeed" }) do
					local v = st:FindFirstChild(n)
					if v and v:IsA("ValueBase") then
						staminaRestore[n] = v.Value
						pcall(function()
							v.Value = 0
						end)
					end
				end
			end
			pin(st)
			if not staminaConn then
				staminaConn = st:GetPropertyChangedSignal("Value"):Connect(function()
					if STATE.infiniteStamina and st.Parent then
						pin(st)
					end
				end)
			end
		else
			restore(st)
			if staminaConn then
				pcall(function()
					staminaConn:Disconnect()
				end)
				staminaConn = nil
			end
		end
	end
end

-- speed booster --------------------------------------------------------------
-- The game derives movement from the character's own Aspects: walking writes
-- Humanoid.WalkSpeed = Aspects.WalkSpeed.Value and sprinting writes
-- Humanoid.WalkSpeed = Aspects.SprintSpeed.Value * SpeedValue.  So scaling the
-- two Aspects values scales both gaits THROUGH the game's own code, with no
-- per-frame movement loop fighting it.  The base is captured once per character
-- and put straight back the moment the slider returns to 1.0x.
local applySpeed
do
	local speedBound = nil
	local speedBase = nil

	local function restore(asp, base)
		for n, v in pairs(base) do
			local o = asp:FindFirstChild(n)
			if o and o:IsA("ValueBase") and v then
				pcall(function()
					o.Value = v
				end)
			end
		end
	end

	applySpeed = function()
		local char = LP.Character
		local asp = char and char:FindFirstChild("Aspects")
		local mult = math.clamp(tonumber(STATE.speedMult) or 1, 1, 10)

		-- character switched (respawn / role change): drop the old base
		if asp ~= speedBound then
			if speedBound and speedBound.Parent and speedBase then
				restore(speedBound, speedBase)
			end
			speedBound = asp
			speedBase = nil
		end
		if not asp then
			return
		end

		-- 1.0x = fully off: restore the game's own numbers, then leave it alone
		if mult <= 1 then
			if speedBase then
				restore(asp, speedBase)
				speedBase = nil
				local hum = asp.Parent and asp.Parent:FindFirstChildOfClass("Humanoid")
				local w = asp:FindFirstChild("WalkSpeed")
				if hum and w then
					pcall(function()
						hum.WalkSpeed = w.Value
					end)
				end
			end
			return
		end

		local walk = asp:FindFirstChild("WalkSpeed")
		local sprint = asp:FindFirstChild("SprintSpeed")
		if not speedBase then
			speedBase = {
				WalkSpeed = walk and walk.Value or nil,
				SprintSpeed = sprint and sprint.Value or nil,
			}
		end
		local tw = speedBase.WalkSpeed and speedBase.WalkSpeed * mult or nil
		local ts = speedBase.SprintSpeed and speedBase.SprintSpeed * mult or nil
		if walk and tw and math.abs(walk.Value - tw) > 0.001 then
			pcall(function()
				walk.Value = tw
			end)
		end
		if sprint and ts and math.abs(sprint.Value - ts) > 0.001 then
			pcall(function()
				sprint.Value = ts
			end)
		end
		-- make walking feel instant; still leave an active sprint to the game
		local hum = asp.Parent and asp.Parent:FindFirstChildOfClass("Humanoid")
		if hum and tw and speedBase.WalkSpeed and hum.WalkSpeed <= speedBase.WalkSpeed + 0.5 then
			pcall(function()
				hum.WalkSpeed = tw
			end)
		end
	end
end

-- fps / ping ---------------------------------------------------------------
local frameCount = 0
local fps = 0
local fpsClock = os.clock()
local pingMs = nil

local function tickFps()
	frameCount = frameCount + 1
	local now = os.clock()
	if now - fpsClock >= 1 then
		fps = frameCount / (now - fpsClock)
		frameCount = 0
		fpsClock = now
		local ok, v = pcall(function()
			return Stats.Network.ServerStatsItem["Data Ping"]:GetValue()
		end)
		pingMs = ok and v or nil
	end
end

-- panic hide ---------------------------------------------------------------
local panicked = false

local function setPanic(hidden)
	panicked = hidden
	overlay.Enabled = not hidden
	if hidden then
		-- instant hide (no animation) so H is reliable for a screenshot
		Main.Visible = false
		gui.Enabled = false
	else
		showWindow()
		selectTab(1)
	end
end

-- ==========================================================================
-- overlay engine (runs per frame for smooth arrows / tracers)
-- ==========================================================================

local function fxFor(model)
	local f = fx[model]
	if not f then
		local tracer = new("Frame", {
			Name = "Tracer",
			AnchorPoint = Vector2.new(0, 0.5),
			BackgroundColor3 = Color3.fromRGB(255, 255, 255),
			BorderSizePixel = 0,
			Visible = false,
			ZIndex = 2,
		}, tracerHolder)
		local arrow = new("TextLabel", {
			Name = "Arrow",
			Size = UDim2.fromOffset(26, 26),
			AnchorPoint = Vector2.new(0.5, 0.5),
			BackgroundTransparency = 1,
			Font = THEME.fontBold,
			Text = "▲",
			TextSize = 26,
			TextColor3 = Color3.fromRGB(255, 255, 255),
			Visible = false,
			ZIndex = 3,
		}, arrowHolder)
		f = { tracer = tracer, arrow = arrow }
		fx[model] = f
	end
	return f
end

local pulse = 0

local function stepOverlay(dt)
	local cam = Workspace.CurrentCamera
	if not cam then
		return
	end
	local vp = cam.ViewportSize
	local cx, cy = vp.X * 0.5, vp.Y * 0.5
	local alive = {}

	for _, t in ipairs(targets) do
		alive[t.model] = true
		local f = fxFor(t.model)
		local sp, onScreen = cam:WorldToViewportPoint(t.pos)
		local on = onScreen and sp.Z > 0

		if STATE.tracers and on and not t.isSelf then
			local dx, dy = sp.X - cx, vp.Y - sp.Y
			local len = math.sqrt(dx * dx + dy * dy)
			local ang = math.deg(math.atan2(dy, dx))
			f.tracer.Position = UDim2.fromOffset(cx, vp.Y)
			f.tracer.Size = UDim2.fromOffset(len, 2)
			f.tracer.Rotation = ang
			f.tracer.BackgroundColor3 = t.colour
			f.tracer.BackgroundTransparency = t.dead and 0.7 or 0.25
			f.tracer.Visible = true
		else
			f.tracer.Visible = false
		end

		if STATE.offscreen and not on and not t.isSelf then
			local dirX = sp.X - cx
			local dirY = sp.Y - cy
			if math.abs(dirX) < 0.01 and math.abs(dirY) < 0.01 then
				dirX = 1
			end
			local margin = 40
			local scale = math.min(
				(vp.X * 0.5 - margin) / math.max(1e-3, math.abs(dirX)),
				(vp.Y * 0.5 - margin) / math.max(1e-3, math.abs(dirY))
			)
			local ax = cx + dirX * scale
			local ay = cy + dirY * scale
			f.arrow.Position = UDim2.fromOffset(ax, ay)
			f.arrow.Rotation = math.deg(math.atan2(dirY, dirX)) + 90
			f.arrow.TextColor3 = t.colour
			f.arrow.TextTransparency = t.dead and 0.7 or 0
			f.arrow.Visible = true
		else
			f.arrow.Visible = false
		end
	end

	for model, f in pairs(fx) do
		if not alive[model] then
			f.tracer:Destroy()
			f.arrow:Destroy()
			fx[model] = nil
		end
	end

	-- HUD (horizontal right-side strip)
	if STATE.hud then
		hud.Visible = true
		hud.Active = gui.Enabled
		if not ROUND.inRound then
			hudDot.BackgroundColor3 = THEME.off
			hudRole.Text = ROUND.map and ROUND.map or "Not in game."
			hudRole.TextColor3 = THEME.dim
			hudLine1.Text = string.upper(ROUND.status or "Idle")
			hudLine1.TextColor3 = THEME.dim
			hudLine2.Text = ""
		else
			local myRole = (LP.Character and LP.Character:GetAttribute("Type")) or "-"
			local rc = STATE.colors and STATE.colors[myRole]
			local col = rc and rgb(rc) or THEME.text
			hudDot.BackgroundColor3 = col
			hudRole.Text = HUD_ROLE[myRole] or tostring(myRole)
			hudRole.TextColor3 = col
			hudLine1.Text = string.format("Survivor %d  ·  Sentinel %d", counts.Survivor, counts.Sentinel)
			hudLine1.TextColor3 = THEME.dim
			if counts.Monster > 0 and monsterDist then
				hudLine2.Text = string.format("%d m", math.floor(monsterDist + 0.5))
				hudLine2.TextColor3 = monsterDist <= STATE.alertDist and THEME.danger or THEME.dim
			else
				hudLine2.Text = "no monster"
				hudLine2.TextColor3 = THEME.dim
			end
		end
	else
		hud.Visible = false
	end

	-- monster alert
	local alerting = STATE.monsterAlert and monsterDist ~= nil and monsterDist <= STATE.alertDist
	if alerting then
		pulse += dt or 0.016
		local a = (math.sin(pulse * 6) + 1) * 0.5
		alert.Visible = true
		alert.Text = string.format("MONSTER NEARBY  ·  %dm", math.floor(monsterDist + 0.5))
		edge.Visible = true
		edgeStroke.Transparency = 0.35 + a * 0.5
		edgeStroke.Thickness = 5 + a * 5
	else
		alert.Visible = false
		edge.Visible = false
		edgeStroke.Transparency = 1
	end

	-- crosshair
	if STATE.crosshair then
		crosshair.Visible = true
		crosshair.BackgroundColor3 = rgb(STATE.crosshairColor)
	else
		crosshair.Visible = false
	end

	-- fps / ping
	if STATE.fpsCounter or STATE.pingCounter then
		local parts = {}
		if STATE.fpsCounter then
			parts[#parts + 1] = string.format("%d fps", math.floor(fps + 0.5))
		end
		if STATE.pingCounter then
			parts[#parts + 1] = pingMs and string.format("%d ms", math.floor(pingMs + 0.5)) or "-- ms"
		end
		statsLbl.Text = table.concat(parts, "   ")
		statsLbl.Visible = true
	else
		statsLbl.Visible = false
	end
end

-- ==========================================================================
-- window chrome
-- ==========================================================================

local function makeDraggable(frame, handle)
	local dragging, dragStart, startPos
	handle.InputBegan:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
			dragging = true
			dragStart = input.Position
			startPos = frame.Position
		end
	end)
	handle.InputEnded:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
			dragging = false
		end
	end)
	UserInputService.InputChanged:Connect(function(input)
		if dragging and (input.UserInputType == Enum.UserInputType.MouseMovement or input.UserInputType == Enum.UserInputType.Touch) then
			local delta = input.Position - dragStart
			frame.Position = UDim2.new(
				startPos.X.Scale,
				startPos.X.Offset + delta.X,
				startPos.Y.Scale,
				startPos.Y.Offset + delta.Y
			)
		end
	end)
end
makeDraggable(win, topbar)

-- HUD drag: only while the menu is open, clamped to the screen, position saved
-- into the config so it comes back where you left it.
local hudDragging, hudDragStart, hudStartPos = false, nil, nil
local function hudSave()
	STATE.hudPos = { hud.Position.X.Offset, hud.Position.Y.Offset }
	saveConfig()
end
hud.InputBegan:Connect(function(input)
	if not gui.Enabled then
		return
	end
	if input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch then
		hudDragging = true
		hudDragStart = input.Position
		-- normalise to a plain top-left offset so clamping is simple
		local ap = hud.AbsolutePosition
		hud.AnchorPoint = Vector2.new(0, 0)
		hud.Position = UDim2.fromOffset(ap.X, ap.Y)
		hudStartPos = hud.Position
	end
end)
UserInputService.InputChanged:Connect(function(input)
	if not hudDragging then
		return
	end
	if input.UserInputType ~= Enum.UserInputType.MouseMovement and input.UserInputType ~= Enum.UserInputType.Touch then
		return
	end
	local delta = input.Position - hudDragStart
	local screen = overlay.AbsoluteSize
	local maxX = math.max(0, screen.X - hud.AbsoluteSize.X)
	local maxY = math.max(0, screen.Y - hud.AbsoluteSize.Y)
	local x = math.clamp(hudStartPos.X.Offset + delta.X, 0, maxX)
	local y = math.clamp(hudStartPos.Y.Offset + delta.Y, 0, maxY)
	hud.Position = UDim2.fromOffset(x, y)
end)
UserInputService.InputEnded:Connect(function(input)
	if hudDragging and (input.UserInputType == Enum.UserInputType.MouseButton1 or input.UserInputType == Enum.UserInputType.Touch) then
		hudDragging = false
		hudSave()
	end
end)

-- the keybind (default K) toggles the whole window. `panicked` (H) always
-- wins: if we are hidden by panic, the toggle brings everything back first.
local function toggleWindow()
	if panicked then
		setPanic(false)
		return
	end
	if gui.Enabled then
		hideWindow()
	else
		showWindow()
	end
end

-- ==========================================================================
-- loops
-- ==========================================================================

ensureAntiAfk()

local function highlightLoop()
	while running do
		local ok = pcall(stepHighlights)
		pcall(function()
			if not ROUND.inRound then
				infoRole.Text = "Status: " .. (ROUND.status or "Not in game.")
				infoMonster.Text = ROUND.map and ("Map: " .. ROUND.map) or "Map: -"
				infoSurvivors.Text = "Survivors: -"
				infoTracked.Text = "Highlighted: -"
				return
			end
			local lr = LP.Character and LP.Character:GetAttribute("Type")
			infoRole.Text = "Your role: " .. tostring(lr or "-")
			infoMonster.Text = "Monster: "
				.. (counts.Monster > 0 and (monsterDist and string.format("%dm", math.floor(monsterDist + 0.5)) or "present") or "none")
			infoSurvivors.Text = "Survivors: " .. tostring(counts.Survivor)
			infoTracked.Text = "Highlighted: " .. tostring(trackedCount)
		end)
		task.wait(ok and 0.12 or 0.4)
	end
end
task.spawn(highlightLoop)
task.spawn(taskLoop)

RunService.RenderStepped:Connect(function(dt)
	if running then
		pcall(tickFps)
		pcall(applyFov)
		pcall(applyNoclip)
		pcall(applySpeed)
		pcall(stepOverlay, dt)
	end
end)

-- free the cursor only while the hub window is open -------------------------
-- The game's shiftlock does NOT just set UserInputService.MouseBehavior: it
-- goes through PlayerModule.CameraModule.CameraUtils, which remembers the
-- value it displaced and puts it back later.  Writing MouseBehavior directly
-- (as this used to) beat the game's own bookkeeping out of step, which is what
-- left shiftlock "disturbed" after closing the hub.
--
-- So we push our unlock through that SAME utility instead of around it:
--   open  -> CameraUtils.setMouseBehaviorOverride(Default)
--   close -> CameraUtils.restoreMouseBehavior()  (pops our override, then we
--            settle on exactly the value the game had when we opened).
-- Shiftlock's own state therefore stays consistent and keeps working.
local cameraUtils
do
	local ok, mod = pcall(function()
		local pm = LP.PlayerScripts:FindFirstChild("PlayerModule")
		local cm = pm and pm:FindFirstChild("CameraModule")
		local cu = cm and cm:FindFirstChild("CameraUtils")
		return cu and require(cu)
	end)
	if ok and type(mod) == "table" and type(mod.setMouseBehaviorOverride) == "function" then
		cameraUtils = mod
	end
end

local mouseHeld = false
local savedMouseBehavior = nil
local savedMouseIcon = nil
local mouseGuard = nil
local iconGuard = nil

-- ask for a free cursor through the game's own camera utility when we can, so
-- its override bookkeeping stays correct; fall back to a plain write if the
-- module is not there (lobby / not loaded yet).
local function wantFreeCursor()
	if cameraUtils then
		local ok = pcall(cameraUtils.setMouseBehaviorOverride, Enum.MouseBehavior.Default)
		if ok then
			return
		end
	end
	pcall(function()
		UserInputService.MouseBehavior = Enum.MouseBehavior.Default
	end)
end

local function holdMouse()
	if mouseHeld then
		return
	end
	mouseHeld = true
	savedMouseBehavior = UserInputService.MouseBehavior
	savedMouseIcon = UserInputService.MouseIconEnabled
	wantFreeCursor()
	pcall(function()
		UserInputService.MouseIconEnabled = true
	end)
	if mouseGuard then
		mouseGuard:Disconnect()
	end
	mouseGuard = UserInputService:GetPropertyChangedSignal("MouseBehavior"):Connect(function()
		if mouseHeld and UserInputService.MouseBehavior ~= Enum.MouseBehavior.Default then
			wantFreeCursor()
		end
	end)
	if iconGuard then
		iconGuard:Disconnect()
	end
	iconGuard = UserInputService:GetPropertyChangedSignal("MouseIconEnabled"):Connect(function()
		if mouseHeld and not UserInputService.MouseIconEnabled then
			pcall(function()
				UserInputService.MouseIconEnabled = true
			end)
		end
	end)
end

local function giveMouseBack()
	if not mouseHeld then
		return
	end
	mouseHeld = false
	if mouseGuard then
		mouseGuard:Disconnect()
		mouseGuard = nil
	end
	if iconGuard then
		iconGuard:Disconnect()
		iconGuard = nil
	end
	-- pop our override the game's way first (keeps its state consistent)...
	if cameraUtils then
		pcall(cameraUtils.restoreMouseBehavior)
	end
	-- ...then make sure we end on exactly the behaviour the game had before.
	if savedMouseBehavior ~= nil and UserInputService.MouseBehavior ~= savedMouseBehavior then
		pcall(function()
			UserInputService.MouseBehavior = savedMouseBehavior
		end)
	end
	if savedMouseIcon ~= nil then
		pcall(function()
			UserInputService.MouseIconEnabled = savedMouseIcon
		end)
	end
	savedMouseBehavior = nil
	savedMouseIcon = nil
end

local function stepMouse()
	local want = gui.Enabled and STATE.freeMouse and not panicked
	if want then
		holdMouse()
		if UserInputService.MouseBehavior ~= Enum.MouseBehavior.Default then
			wantFreeCursor()
		end
		if not UserInputService.MouseIconEnabled then
			UserInputService.MouseIconEnabled = true
		end
	else
		giveMouseBack()
	end
end

RunService.RenderStepped:Connect(stepMouse)

local function statusLoop()
	while running do
		pcall(applyFullbright)
		pcall(applyNoFog)
		pcall(applyStamina)
		pcall(applySpeed)
		pcall(function()
			for _, fn in ipairs(colorSyncers) do
				fn()
			end
		end)
		pcall(function()
			statusDot.Text = STATE.enabled and "ON" or "OFF"
			statusDot.TextColor3 = STATE.enabled and THEME.on or THEME.off
			infoMouse.Text = "Mouse: " .. tostring(UserInputService.MouseBehavior):gsub("Enum.MouseBehavior.", "")
		end)
		task.wait(0.4)
	end
end
task.spawn(statusLoop)

-- keybinds
local TAB_KEYS = {
	[Enum.KeyCode.One] = 1,
	[Enum.KeyCode.Two] = 2,
	[Enum.KeyCode.Three] = 3,
	[Enum.KeyCode.Four] = 4,
	[Enum.KeyCode.Five] = 5,
	[Enum.KeyCode.Six] = 6,
	[Enum.KeyCode.Seven] = 7,
}

UserInputService.InputBegan:Connect(function(input, gameProcessed)
	if gameProcessed or keybindCapturing then
		return
	end
	if input.UserInputType ~= Enum.UserInputType.Keyboard and input.UserInputType ~= Enum.UserInputType.Gamepad1 then
		return
	end
	local kb = STATE.keybind and Enum.KeyCode[STATE.keybind]
	if kb and input.KeyCode == kb then
		toggleWindow()
		return
	end
	if input.KeyCode == Enum.KeyCode.H then
		setPanic(not panicked)
		return
	end
	local tab = TAB_KEYS[input.KeyCode]
	if tab and gui.Enabled and not panicked then
		selectTab(tab)
	end
end)

-- ==========================================================================
-- session handle
-- ==========================================================================

local handle = {}
handle.gui = gui
handle.overlay = overlay
handle.cameraUtils = cameraUtils
handle.state = STATE
handle.setTab = selectTab
handle.toggleWindow = toggleWindow
handle.collectOneShard = collectOneShard
handle.autoTasks = function(on)
	STATE.autoCollect = on ~= false
	saveConfig()
	return STATE.autoCollect
end
handle.autoCollect = handle.autoTasks
handle.getCounts = function()
	return counts, trackedCount, monsterDist
end
handle.roundInfo = roundInfo
handle.uiPhase = uiPhase
handle.setPanic = setPanic
handle.closeNotice = function()
	local ok, err = pcall(closeNotice)
	return ok, tostring(err)
end
handle.closeNote = closeNote
-- internal engines, exposed for testing / scripting
handle.runShardEsp = stepShardEsp
handle.runHighlights = stepHighlights
handle.runExitEsp = stepExitEsp
handle.roleOf = roleOf
handle.teleportNearest = function()
	return TP and TP.goNearest and TP.goNearest()
end
handle.teleportExit = function()
	return TP and TP.goExit and TP.goExit()
end
handle.tp = TP
handle.mapNow = mapNow
handle.antiAfk = function(on)
	STATE.antiAfk = on ~= false
	saveConfig()
	ensureAntiAfk()
	return STATE.antiAfk
end
handle.noclip = function(on)
	STATE.noclip = on ~= false
	if not STATE.noclip then
		restoreNoclip()
	end
	saveConfig()
	return STATE.noclip
end
handle.noFog = function(on)
	STATE.noFog = on ~= false
	applyNoFog()
	saveConfig()
	return STATE.noFog
end
handle.infiniteStamina = function(on)
	STATE.infiniteStamina = on ~= false
	applyStamina()
	saveConfig()
	return STATE.infiniteStamina
end
handle.speed = function(mult)
	STATE.speedMult = math.clamp(tonumber(mult) or 1, 1, 10)
	applySpeed()
	saveConfig()
	return STATE.speedMult
end
handle.setFreeMouse = function(on)
	STATE.freeMouse = on ~= false
	if not STATE.freeMouse then
		giveMouseBack()
	end
	saveConfig()
	return STATE.freeMouse
end
handle.releaseMouse = giveMouseBack
handle.destroy = function()
	running = false
	pcall(giveMouseBack)
	if antiAfkConn then
		pcall(function()
			antiAfkConn:Disconnect()
		end)
		antiAfkConn = nil
	end
	pcall(function()
		if fbSaved then
			for k, v in pairs(fbSaved) do
				Lighting[k] = v
			end
		end
	end)
	pcall(restoreNoclip)
	pcall(function()
		STATE.infiniteStamina = false
		applyStamina()
	end)
	pcall(function()
		STATE.speedMult = 1
		applySpeed()
	end)
	pcall(function()
		STATE.noFog = false
		applyNoFog()
	end)
	pcall(function()
		for _, rec in pairs(tracked) do
			if rec.hl then
				rec.hl:Destroy()
			end
			if rec.bb then
				rec.bb:Destroy()
			end
		end
	end)
	pcall(function()
		for _, f in pairs(fx) do
			f.tracer:Destroy()
			f.arrow:Destroy()
		end
	end)
	pcall(function()
		clearExit()
	end)
	pcall(function()
		espFolder:Destroy()
	end)
	pcall(function()
		shardFolder:Destroy()
	end)
	pcall(function()
		exitFolder:Destroy()
	end)
	pcall(function()
		gui:Destroy()
	end)
	pcall(function()
		overlay:Destroy()
	end)
	pcall(function()
		for _, c in ipairs(connections) do
			c:Disconnect()
		end
	end)
end
handle.cleanup = handle.destroy

genv.PillarChaseHub = handle

print("[PCHub] v2 loaded. Monster=red, Survivor=green, Sentinel=amber.")
print(string.format("[PCHub] %s = window, keys 1-7 = tabs, H = panic hide. Cursor frees while the window is open and is handed straight back on close. %d players seen.", tostring(STATE.keybind), #Players:GetPlayers()))
print("[PCHub] Extras: infinite stamina, speed booster, anti-AFK, fullbright, no fog, noclip, FOV zoom, exit ESP, crosshair, fps/ping.")
print("[PCHub] Tasks: round-aware. Objective ESP on all maps; 'Auto tasks' is one OFF-by-default toggle. Lobby/dead = idle.")

Features

  • Find out after you execute (no its not a virus lol)