Allow custom scripts to run in a plugin?

I currently have a plugin that allows a user to simulate the physics of a list of parts in edit mode.

Currently it doesn’t run scripts inside of the simulated assemblies. I want to add the option to select which scripts to run during simulation and actually run them without the user needing to modify the scripts beforehand, but i dont know how to. i want to run it in such a way that i can run, stop and restart when the user start/stops the simulation. how can i achieve this?

2 Likes

Since plugins have access to script.source, I’d imagine you could automatically replace any "script.Parent"s with game.Workspace.blahblah.doodad, and then use the (incredibly insecure for normal use!!!) loadstring() function. Dunno how exactly it would have to be put together, but I hope this helps nonetheless.

isnt loadstring() retricted? plugins using it cant be dsitributed.

1 Like

Maybe. I haven’t looked too much into it until just now be honest.

I just looked at another thread (Script Runner: Run scripts without running the game) and OP said the plugin got removed.

Look at this, too:

Nevermind what I said before lol because you definitely can’t upload plugins that use loadtring() or otherwise execute scripts for any reason, BUT it seems like you can use it personally as a local plugin. if you’re unsure probably CTRL+F the Terms Of Service for plugin stuff.

im looking into this atm, the only issue is that if a script is run, and has connections inside of it, even if you were to delete the script, those connections still persist. im looking for a way to completely stop a script from running when needed.

I’m not sure, then. I’m sorry I can’t help much more. I hope you find something that works!!

1 Like

thats fine thanks for the little help. currently i decided to take the LONG route and created a function that reads through the source of a script, stores every connection into a non local variable, insert them into a table, and returns it at the end:

Original Test Code

-- Test script for connection detection
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

RunService.Heartbeat:Connect(function()
	print("Heartbeat")
end)

local mouseConnection = UserInputService.InputBegan:Connect(function(input)
	if input.UserInputType == Enum.UserInputType.MouseButton1 then
		print("Mouse clicked")
	end
end)

local keyboardConnection = UserInputService.InputEnded:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.Space then
		print("Space released")
	elseif input.KeyCode == Enum.KeyCode.W then
		print("W released")
	end
end)

local function playerAdded(player)
	print(player.Name .. " joined the game")

	player.CharacterAdded:Connect(function(character)
		print(player.Name .. "'s character spawned")
	end)
end

Players.PlayerAdded:Connect(playerAdded)

if true then
	local conditionalConnection = RunService.RenderStepped:Connect(function()
		print("RenderStepped")
	end)
end

Modified Code

local SCRIPTCONNECTIONS = {}

-- Test script for connection detection
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

connection_7 = RunService.Heartbeat:Connect(function()
	print("Heartbeat")
end)

mouseConnection = UserInputService.InputBegan:Connect(function(input)
	if input.UserInputType == Enum.UserInputType.MouseButton1 then
		print("Mouse clicked")
	end
end)

keyboardConnection = UserInputService.InputEnded:Connect(function(input)
	if input.KeyCode == Enum.KeyCode.Space then
		print("Space released")
	elseif input.KeyCode == Enum.KeyCode.W then
		print("W released")
	end
end)

local function playerAdded(player)
	print(player.Name .. " joined the game")

	connection_28 = player.CharacterAdded:Connect(function(character)
		print(player.Name .. "'s character spawned")
	end)
end

connection_33 = Players.PlayerAdded:Connect(playerAdded)

if true then
	conditionalConnection = RunService.RenderStepped:Connect(function()
		print("RenderStepped")
	end)
end


-- Collect all connections
table.insert(SCRIPTCONNECTIONS, connection_7)
table.insert(SCRIPTCONNECTIONS, mouseConnection)
table.insert(SCRIPTCONNECTIONS, keyboardConnection)
table.insert(SCRIPTCONNECTIONS, connection_28)
table.insert(SCRIPTCONNECTIONS, connection_33)
table.insert(SCRIPTCONNECTIONS, conditionalConnection)

return SCRIPTCONNECTIONS

with this i should theoretically be able to disconnect these remotely when needed. Hopefully…

the method works! i created a test plugin to show it working. It converts any script into a module that returns its connections, so that you can disconnect them afterwards.

code:

local env = script.Parent
local gui = env.Frame

local startButton = gui.Start
local stopButton = gui.Stop

local toolbar = plugin:CreateToolbar("Script Runner")
local openButton = toolbar:CreateButton("toggleOpen", "", "", "Open")

local currentScript = nil
local connections = {}

-- Simple connection processor
local function processScriptConnections(scriptSource)
	-- Create a new script with the SCRIPTCONNECTIONS table at the top
	local newSource = "local SCRIPTCONNECTIONS = {}\n\n"

	-- Split the script into lines
	local lines = {}
	local connectionVars = {}

	for line in scriptSource:gmatch("([^\n]*)\n?") do
		table.insert(lines, line)
	end

	-- Process each line
	for i, line in ipairs(lines) do
		-- Look for connections
		if line:find(":[Cc]onnect%(") then
			-- Is this already a variable assignment?
			if line:match("^%s*local%s+([%w_]+)%s*=") then
				-- It's a local variable, change to non-local
				local varName = line:match("^%s*local%s+([%w_]+)%s*=")
				newSource = newSource .. line:gsub("^%s*local%s+", "") .. "\n"
				table.insert(connectionVars, varName)
			elseif line:match("^%s*([%w_]+)%s*=") then
				-- Already a non-local variable, keep as is
				local varName = line:match("^%s*([%w_]+)%s*=")
				newSource = newSource .. line .. "\n"
				table.insert(connectionVars, varName)
			else
				-- Inline connection, create a non-local variable
				local varName = "connection_" .. i
				local indent = line:match("^(%s*)")
				newSource = newSource .. indent .. varName .. " = " .. line:gsub("^%s*", "") .. "\n"
				table.insert(connectionVars, varName)
			end
		else
			-- Not a connection, keep the line as is
			newSource = newSource .. line .. "\n"
		end
	end

	-- Add code to collect connections at the end
	newSource = newSource .. "\n-- Collect all connections\n"

	for _, varName in ipairs(connectionVars) do
		newSource = newSource .. "table.insert(SCRIPTCONNECTIONS, " .. varName .. ")\n"
	end

	-- Add return statement
	newSource = newSource .. "\nreturn SCRIPTCONNECTIONS"

	-- Create the module script
	local moduleScript = Instance.new("ModuleScript")
	moduleScript.Name = "ConnectionsModule"
	moduleScript.Source = newSource

	return moduleScript
end


local dockWidgetInfo = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Float,
	false,
	false, 
	100, 
	100, 
	100, 
	100
)

local dockWidget = plugin:CreateDockWidgetPluginGui("Script Loader", dockWidgetInfo)
gui.Parent = dockWidget

openButton.Click:Connect(function()
	dockWidget.Enabled = not dockWidget.Enabled
end)

startButton.Activated:Connect(function()
	local originalScript = game.Selection:Get()[1]
	if not originalScript:IsA("Script") and not originalScript:IsA("LocalScript") then return end
	
	local processedScript = processScriptConnections(originalScript.Source)
	processedScript.Name = originalScript.Name .. "_PLUGIN"
	processedScript.Parent = originalScript.Parent
	
	currentScript = processedScript
	connections = require(processedScript)
end)

stopButton.Activated:Connect(function()
	for _, connection in connections do
		connection:Disconnect()
		connection = nil
	end
	currentScript:Destroy()
end)

the proccessScriptConnections is what detects the connections in the source and stores them into a table.

Example:

Caveats:
while it can run scripts with a while true loop, it is unable to stop it, so avoid doing that.

Cool! But can’t you just do

local epic = true
while epic do
     --code
end

function cutthatout()
     epic = false --stops it
end

i did, wanted to also handle scripts with while true loops, but i found out that code under while loops will never run, since the while loops runs forever, so a module with it never returns anything, the script will still run but you would wouldnt be able to stop it.

Heres an example of a free model script after being converted with the function:

local SCRIPTCONNECTIONS = {}
local SCRIPTINSTANCES = {}

local success, errorMessage = pcall(function()
b = script.Parent
x = 0

while true do

    b.Color = Color3.fromHSV(x,1,1)
    x = x + 1/255 -- you can change the increment to speed/slow up the changing effect (eg: 3/500, 10/255, 0.5/255, etc.)
    if x >= 1 then
        x = 0
    end
    wait()
end

end)

--code after this will never run, so the module will never return.

return {Connections = SCRIPTCONNECTIONS, Instances = SCRIPTINSTANCES, ErrorMsg = errorMessage}

im sure there IS a way to handle this, but currently i dont know a good method.

Is it not possible for the plugin to convert any "while true do"s to "function blahblah()"s?

that is possible, but what after? return it to recreate the while true outside of the module?

Ok, so thanks to that i was able to figure something out. Like you said i detect the while true loop and convert it into a function.

Original Example Script:

Modified Script:

Using the returned function i spawn a thread that recreates the loop, storing the thread.
image_2025-03-15_222752405

then simply calling task.cancel on the thread to stop it from running.
image_2025-03-15_222837835

Result:

I turned this into a module script incase anyone else needs this for their plugins, i have also added tracking for instances created in the script with either Instance.New() or Instance:Clone(). They get destroyed upon cleanup.

local ScriptRuntime = {}
ScriptRuntime.__index = ScriptRuntime


local RuntimeScript = {}
RuntimeScript.__index = RuntimeScript

local function ParseScript(scriptSource)
	-- Create a new script with the tracking tables and loop function at the top
	local newSource = "local SCRIPTCONNECTIONS = {}\n"
	newSource = newSource .. "local SCRIPTINSTANCES = {}\n"
	newSource = newSource .. "local LOOP_FUNCTION = nil\n\n"
	newSource = newSource .. "local success, errorMessage = pcall(function()\n"

	-- Split the script into lines
	local lines = {}
	local connectionVars = {}
	local instanceVars = {}
	local insideLoop = false
	local loopStart = 0
	local loopEnd = 0
	local indentLevel = ""

	for line in scriptSource:gmatch("([^\n]*)\n?") do
		table.insert(lines, line)
	end

	-- First pass to find while true loop
	for i, line in ipairs(lines) do
		if line:match("%s*while%s+true%s+do%s*") and not insideLoop then
			insideLoop = true
			loopStart = i
			indentLevel = line:match("^(%s*)")
		elseif insideLoop and line:match("^" .. indentLevel .. "end%s*") then
			insideLoop = false
			loopEnd = i
			break -- Only process the first while true loop
		end
	end

	-- Helper function to process connections and instances
	local function processLine(line, lineIndex)
		local processedLine = line

		-- Look for connections
		if line:find(":[Cc]onnect%(") then
			if line:match("^%s*local%s+([%w_]+)%s*=") then
				local varName = line:match("^%s*local%s+([%w_]+)%s*=")
				processedLine = line:gsub("^%s*local%s+", "")
				table.insert(connectionVars, varName)
			elseif line:match("^%s*([%w_]+)%s*=") then
				local varName = line:match("^%s*([%w_]+)%s*=")
				table.insert(connectionVars, varName)
			else
				local varName = "connection_" .. lineIndex
				local indent = line:match("^(%s*)")
				processedLine = indent .. varName .. " = " .. line:gsub("^%s*", "")
				table.insert(connectionVars, varName)
			end
			-- Look for Instance.new calls OR Clone() calls
		elseif line:find("Instance%.new%(") or line:find(":[Cc]lone%(") then
			if line:match("^%s*local%s+([%w_]+)%s*=") then
				local varName = line:match("^%s*local%s+([%w_]+)%s*=")
				processedLine = line:gsub("^%s*local%s+", "")
				table.insert(instanceVars, varName)
			elseif line:match("^%s*([%w_]+)%s*=") then
				local varName = line:match("^%s*([%w_]+)%s*=")
				table.insert(instanceVars, varName)
			else
				local varName = "instance_" .. lineIndex
				local indent = line:match("^(%s*)")
				processedLine = indent .. varName .. " = " .. line:gsub("^%s*", "")
				table.insert(instanceVars, varName)
			end
		end

		return processedLine
	end

	-- Process each line
	for i, line in ipairs(lines) do
		-- Special handling for loop
		if i == loopStart and loopStart > 0 then
			-- Convert while true to LOOP_FUNCTION definition
			newSource = newSource .. indentLevel .. "LOOP_FUNCTION = function()\n"
		elseif i == loopEnd and loopEnd > 0 then
			-- Close the function definition 
			newSource = newSource .. indentLevel .. "end\n"
		elseif i > loopStart and i < loopEnd and loopStart > 0 then
			-- Process lines inside the loop
			local processedLine = processLine(line, i)
			newSource = newSource .. processedLine .. "\n"
		else
			-- Process lines outside the loop
			local processedLine = processLine(line, i)
			newSource = newSource .. processedLine .. "\n"
		end
	end

	-- Add code to collect connections at the end (still inside pcall)
	newSource = newSource .. "\n-- Collect all connections\n"
	for _, varName in ipairs(connectionVars) do
		newSource = newSource .. "table.insert(SCRIPTCONNECTIONS, " .. varName .. ")\n"
	end

	-- Add code to collect instances at the end (still inside pcall)
	newSource = newSource .. "\n-- Collect all instances\n"
	for _, varName in ipairs(instanceVars) do
		newSource = newSource .. "table.insert(SCRIPTINSTANCES, " .. varName .. ")\n"
	end

	-- Close the pcall function
	newSource = newSource .. "end)\n\n"

	-- Add return statement (outside pcall)
	newSource = newSource .. "return {Connections = SCRIPTCONNECTIONS, Instances = SCRIPTINSTANCES, LoopFunction = LOOP_FUNCTION, ErrorMsg = errorMessage}"

	-- Create the module script
	local moduleScript = Instance.new("ModuleScript")
	moduleScript.Name = "ConnectionsModule"
	moduleScript.Source = newSource
	return moduleScript
end


function ScriptRuntime.NewScript(originalScript)
	local self = setmetatable({}, RuntimeScript)
	
	--Parse Script
	local success, processedScript = pcall(function()
		return ParseScript(originalScript.Source)
	end)

	if not success then
		warn("Failed to process the script: " .. tostring(processedScript))
		return
	end
	
	processedScript.Name = originalScript.Name .. "_RUNTIME"
	processedScript.Parent = originalScript.Parent
	self.Script = processedScript
	
	return self
end


function RuntimeScript:Execute()
	-- Try to run the script
	local runSuccess, result = pcall(function()
		return require(self.Script)
	end)

	if not runSuccess then
		warn("Error running the processed script: " .. tostring(result))
	else
		if result then
			self.Connections = result.Connections or {}
			self.Instances = result.Instances or {}
			self.LoopFunction = result.LoopFunction
			
			
			if result.ErrorMsg then
				warn("Script reported an error: " .. tostring(result.ErrorMsg))
				self:Terminate()
			end
			
			if self.LoopFunction then
				self.LoopThread = task.spawn(function()
					while true do
						self.LoopFunction()
					end
				end)
			end
		else
			warn("Script didn't return an expected result")
		end
	end
end


function RuntimeScript:Terminate()
	if not self.Connections and not self.Instances and not self.LoopFunction then
		return
	end
	
	for _, connection in pairs(self.Connections) do
		connection:Disconnect()
		connection = nil
	end
	table.clear(self.Connections)
	
	for _, instance in pairs(self.Instances) do
		instance:Destroy()
		instance = nil
	end
	
	if self.LoopThread then
		task.cancel(self.LoopThread)
		self.LoopThread = nil
	end
	
	table.clear(self.Instances)
end

function RuntimeScript:Destroy()
	self:Terminate()
	
	self.Script:Destroy()
	self.Script = nil
end

return ScriptRuntime

1 Like

That’s awesome dude!! I’m glad you figured something out. I didn’t think to do task.cancel but that makes so much sense.

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.