-
Notifications
You must be signed in to change notification settings - Fork 1
Simulator
The Input Simulator allows you to programmatically drive microcontroller inputs during testing and development. By attaching a simulator module through your script's onAttatch() function, you can automate input generation for testing, create synthetic signals, or reproduce specific scenarios.
- Overview
- Quick Start
- Simulator Forms
- Context API
- Configuration
- Debug Canvas Integration
- Visual Feedback
- Hot Reloading
- Module Resolution
- Best Practices
- Examples
Input simulators run before your microcontroller's onTick() function on each tick, allowing them to set up input values that your script will then process. This is particularly useful for:
- Automated testing: Reproduce specific input sequences to verify behavior
- Signal generation: Create complex waveforms or patterns without manual input
- Development: Test edge cases and boundary conditions systematically
- Debugging: Isolate issues by controlling exact input values
Create a simulator module:
-- simulators/test_input.lua
---@param ctx SimulatorCtx
return function(ctx)
-- This runs every tick before onTick()
ctx.input.setBool(1, true)
ctx.input.setNumber(1, 0.5)
endAttach it in your microcontroller script:
function onAttatch()
return {
input_simulator = require('simulators.test_input')
}
endRun with the UI:
:MicroProject uiThe simulator will now control inputs 1 (boolean) and 1 (number) automatically.
There are two ways to structure an input simulator: function form (simple, stateless) and table form (advanced, with lifecycle hooks).
The simplest form returns a function that will be called every tick:
-- simulators/my_simple_sim.lua
---@param ctx SimulatorCtx
return function(ctx)
-- Called every tick before onTick()
local t = love.timer.getTime()
ctx.input.setNumber(1, math.sin(t * 2 * math.pi))
ctx.input.setBool(1, math.sin(t * 2 * math.pi) > 0)
endPros: Straightforward, minimal boilerplate Cons: No initialization hook, no debug drawing, harder to manage complex state
The table form provides lifecycle hooks and debug canvas support:
-- simulators/my_advanced_sim.lua
---@class MyAdvancedSim : InputSimulatorTable
local M = {
-- Internal state
time = 0,
frequency = 1.0,
}
---@param ctx SimulatorCtx
---@param cfg table|nil
function M.onInit(ctx, cfg)
-- Optional: runs once on load and after hot reload
M.frequency = (cfg and tonumber(cfg.freq_hz)) or 1.0
M.targetNumCh = (cfg and tonumber(cfg.num_ch)) or 1
M.targetBoolCh = (cfg and tonumber(cfg.bool_ch)) or 1
M.time = 0
end
---@param ctx SimulatorCtx
function M.onTick(ctx)
-- Required: called every tick before onTick()
local dt = ctx.time.getDelta()
M.time = M.time + dt
local value = math.sin(2 * math.pi * M.frequency * M.time)
ctx.input.setNumber(M.targetNumCh, value)
ctx.input.setBool(M.targetBoolCh, value > 0)
end
function M.onDebugDraw()
-- Optional: draws to debug canvas (if enabled)
dbg.setColor(0, 255, 0)
local w, h = dbg.getWidth(), dbg.getHeight()
dbg.drawLine(0, h/2, w, h/2)
-- Draw waveform history
local phase = (M.time * M.frequency) % 1
local x = phase * w
local y = (1 - math.sin(2 * math.pi * phase)) * h/2
dbg.drawCircleFill(x, y + h/4, 3)
end
return MPros: Initialization support, debug drawing, cleaner state management Cons: More boilerplate
The simulator context (ctx) provides access to input/output state and timing information.
-- Set boolean input (channel 1-32)
ctx.input.setBool(channel, value)
-- Set number input (channel 1-32, any numeric value)
ctx.input.setNumber(channel, value)
-- Read current boolean input
local value = ctx.input.getBool(channel)
-- Read current number input
local value = ctx.input.getNumber(channel)Important: setNumber does not clamp values. You can write any numeric range. The UI input sliders still operate in the 0..1 range for manual control, but simulator-driven values can be any number.
-- Read-only access to microcontroller properties
local myProp = ctx.properties["PropertyName"]Properties are defined in your microcontroller's onAttatch() return value under the properties key.
-- Get delta time since last tick (in seconds)
local dt = ctx.time.getDelta()Useful for time-based animations, integration, or rate limiting.
Pass configuration to table-form simulators via input_simulator_config:
function onAttatch()
return {
input_simulator = require('simulators.configurable_wave'),
input_simulator_config = {
freq_hz = 0.5, -- Custom parameters
amplitude = 2.0,
num_ch = 2,
bool_ch = 3,
}
}
endThe config table is passed to onInit(ctx, cfg) when the simulator loads.
Enable a dedicated 512x512 debug canvas for visualization:
function onAttatch()
return {
input_simulator = require('simulators.my_sim'),
debugCanvas = true,
debugCanvasSize = { w = 320, h = 180 }, -- Optional custom size
}
endThen implement onDebugDraw() in your table-form simulator:
function M.onDebugDraw()
-- Drawing API (dbg.*) - similar to Stormworks screen API
dbg.setColor(r, g, b) -- RGB 0-255
dbg.drawLine(x1, y1, x2, y2)
dbg.drawRect(x, y, w, h)
dbg.drawRectFill(x, y, w, h)
dbg.drawCircle(x, y, radius)
dbg.drawCircleFill(x, y, radius)
dbg.drawText(x, y, "text")
local w = dbg.getWidth()
local h = dbg.getHeight()
endLaunch with:
:MicroProject ui --user-debug trueSimulator-controlled inputs are visually distinguished in the UI:
- Darker background: Indicates the input is simulator-driven
-
"S" indicator: Replaces the channel number
- White "S": Input is active (true/non-zero)
- Orange "S": Input is inactive (false/zero)
This makes it easy to see at a glance which inputs are automated vs. manual.
The LÖVE2D UI supports hot reloading:
- Edit your microcontroller script or simulator module
- Press
Rin the UI to reload - For table-form simulators,
onInit(ctx, cfg)is called again after reload
This enables rapid iteration without restarting the entire UI.
Simulator modules are resolved using Lua's require(). The following paths are automatically whitelisted:
- The microcontroller script's directory
- The project root (where
.microprojectis located) - Bundled common libraries
To add additional library paths:
:MicroProject add /path/to/your/simulator/library
:MicroProject ui --lib /path/to/simulator/libraryIf you don't need initialization or debug drawing, use the function form:
return function(ctx)
ctx.input.setNumber(1, math.random())
endFor table-form simulators, use onInit to set up state:
function M.onInit(ctx, cfg)
M.time = 0
M.config = cfg or {}
endWhile technically possible, mixing simulator-controlled and manually-controlled inputs on the same channel can be confusing. Choose one or the other per channel.
Visualizing simulator behavior helps with development and debugging:
function M.onDebugDraw()
-- Show current state, signal plots, etc.
dbg.setColor(255, 255, 255)
dbg.drawText(10, 10, string.format("Freq: %.2f Hz", M.frequency))
endYour simulator should work correctly at various tick rates:
:MicroProject ui --tick 30 # 30 ticks/second
:MicroProject ui --tick 60 # 60 ticks/secondUse ctx.time.getDelta() for time-based logic instead of assuming a fixed tick rate.
The plugin includes type stubs for lua-language-server:
---@param ctx SimulatorCtx
function M.onTick(ctx)
-- Autocomplete and type checking available
ctx.input.setBool(1, true)
endType stubs are located in:
lua/common/chromatischer/LspHinting/simulator.lualua/common/chromatischer/LspHinting/love.lua
-- simulators/sine_wave.lua
---@param ctx SimulatorCtx
return function(ctx)
local t = love.timer.getTime()
local value = math.sin(t * 2 * math.pi * 0.5) -- 0.5 Hz
ctx.input.setNumber(1, value)
ctx.input.setBool(1, value > 0)
end-- simulators/square_wave.lua
---@class SquareWaveSim : InputSimulatorTable
local M = { time = 0, state = false }
---@param ctx SimulatorCtx
---@param cfg table|nil
function M.onInit(ctx, cfg)
M.frequency = (cfg and cfg.freq_hz) or 1.0
M.channel = (cfg and cfg.channel) or 1
M.time = 0
M.state = false
end
---@param ctx SimulatorCtx
function M.onTick(ctx)
local dt = ctx.time.getDelta()
M.time = M.time + dt
local period = 1.0 / M.frequency
local phase = (M.time % period) / period
M.state = phase < 0.5
ctx.input.setBool(M.channel, M.state)
end
return MAttach:
function onAttatch()
return {
input_simulator = require('simulators.square_wave'),
input_simulator_config = {
freq_hz = 2.0,
channel = 5
}
}
end-- simulators/multi_channel.lua
---@class MultiChannelSim : InputSimulatorTable
local M = {
time = 0,
channels = {},
}
---@param ctx SimulatorCtx
---@param cfg table|nil
function M.onInit(ctx, cfg)
M.time = 0
M.channels = {
{ freq = 0.5, amp = 1.0, phase = 0 },
{ freq = 1.0, amp = 0.5, phase = math.pi/4 },
{ freq = 2.0, amp = 0.25, phase = math.pi/2 },
}
end
---@param ctx SimulatorCtx
function M.onTick(ctx)
local dt = ctx.time.getDelta()
M.time = M.time + dt
for i, ch in ipairs(M.channels) do
local value = ch.amp * math.sin(2 * math.pi * ch.freq * M.time + ch.phase)
ctx.input.setNumber(i, value)
end
end
function M.onDebugDraw()
local w, h = dbg.getWidth(), dbg.getHeight()
local colors = {{255,0,0}, {0,255,0}, {0,0,255}}
-- Draw waveforms
for i, ch in ipairs(M.channels) do
dbg.setColor(colors[i][1], colors[i][2], colors[i][3])
local y_base = i * h / (#M.channels + 1)
for x = 0, w - 1 do
local t = M.time + (x / w) * 2 -- 2 second window
local v = ch.amp * math.sin(2 * math.pi * ch.freq * t + ch.phase)
local y = y_base - v * 30
dbg.drawCircleFill(x, y, 1)
end
dbg.drawText(5, y_base - 40, string.format("Ch%d: %.1fHz", i, ch.freq))
end
end
return M-- simulators/ramp.lua
---@class RampSim : InputSimulatorTable
local M = {
elapsed = 0,
duration = 10, -- seconds
}
---@param ctx SimulatorCtx
---@param cfg table|nil
function M.onInit(ctx, cfg)
M.duration = (cfg and cfg.duration) or 10
M.channel = (cfg and cfg.channel) or 1
M.elapsed = 0
end
---@param ctx SimulatorCtx
function M.onTick(ctx)
local dt = ctx.time.getDelta()
M.elapsed = M.elapsed + dt
-- Linear ramp from 0 to 1 over duration, then restart
local progress = (M.elapsed % M.duration) / M.duration
ctx.input.setNumber(M.channel, progress)
end
function M.onDebugDraw()
dbg.setColor(255, 255, 255)
local progress = (M.elapsed % M.duration) / M.duration
dbg.drawText(10, 10, string.format("Ramp: %.1f%%", progress * 100))
-- Visual progress bar
local w, h = dbg.getWidth(), dbg.getHeight()
dbg.drawRect(10, 30, w - 20, 20)
dbg.setColor(0, 255, 0)
dbg.drawRectFill(10, 30, (w - 20) * progress, 20)
end
return M-- simulators/step_sequence.lua
---@class StepSequenceSim : InputSimulatorTable
local M = {
currentStep = 1,
stepTime = 0,
steps = {},
}
---@param ctx SimulatorCtx
---@param cfg table|nil
function M.onInit(ctx, cfg)
-- Define test sequence
M.steps = {
{ duration = 2, inputs = { N1 = 0.0, B1 = false } },
{ duration = 3, inputs = { N1 = 0.5, B1 = true } },
{ duration = 2, inputs = { N1 = 1.0, B1 = true } },
{ duration = 3, inputs = { N1 = 0.0, B1 = false } },
}
M.currentStep = 1
M.stepTime = 0
end
---@param ctx SimulatorCtx
function M.onTick(ctx)
local dt = ctx.time.getDelta()
M.stepTime = M.stepTime + dt
local step = M.steps[M.currentStep]
if not step then return end
-- Apply current step inputs
ctx.input.setNumber(1, step.inputs.N1 or 0)
ctx.input.setBool(1, step.inputs.B1 or false)
-- Advance to next step when duration elapsed
if M.stepTime >= step.duration then
M.currentStep = M.currentStep + 1
M.stepTime = 0
-- Loop back to start
if M.currentStep > #M.steps then
M.currentStep = 1
end
end
end
function M.onDebugDraw()
dbg.setColor(255, 255, 255)
local step = M.steps[M.currentStep]
if step then
dbg.drawText(10, 10, string.format("Step: %d/%d", M.currentStep, #M.steps))
dbg.drawText(10, 30, string.format("Time: %.1f/%.1f", M.stepTime, step.duration))
dbg.drawText(10, 50, string.format("N1=%.2f B1=%s", step.inputs.N1, tostring(step.inputs.B1)))
end
end
return MInput simulators provide a powerful way to automate testing and development of Stormworks microcontroller scripts. Choose the function form for simple cases or the table form for advanced features like initialization hooks and debug visualization. Use the context API to control inputs and read state, and leverage hot reloading for rapid iteration.
For more information, see the Main or explore the example simulators in the tests/fixtures/ directory.