Skip to content

Simulator

Chromatischer edited this page Jan 12, 2026 · 4 revisions

Input 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.

Table of Contents

Overview

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

Quick Start

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)
end

Attach it in your microcontroller script:

function onAttatch()
  return {
    input_simulator = require('simulators.test_input')
  }
end

Run with the UI:

:MicroProject ui

The simulator will now control inputs 1 (boolean) and 1 (number) automatically.

Simulator Forms

There are two ways to structure an input simulator: function form (simple, stateless) and table form (advanced, with lifecycle hooks).

Function Form (Simple)

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)
end

Pros: Straightforward, minimal boilerplate Cons: No initialization hook, no debug drawing, harder to manage complex state

Table Form (Advanced)

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 M

Pros: Initialization support, debug drawing, cleaner state management Cons: More boilerplate

Context API

The simulator context (ctx) provides access to input/output state and timing information.

Input Methods

-- 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.

Properties

-- 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.

Time

-- Get delta time since last tick (in seconds)
local dt = ctx.time.getDelta()

Useful for time-based animations, integration, or rate limiting.

Configuration

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,
    }
  }
end

The config table is passed to onInit(ctx, cfg) when the simulator loads.

Debug Canvas Integration

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
  }
end

Then 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()
end

Launch with:

:MicroProject ui --user-debug true

Visual Feedback

Simulator-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.

Hot Reloading

The LÖVE2D UI supports hot reloading:

  • Edit your microcontroller script or simulator module
  • Press R in 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.

Module Resolution

Simulator modules are resolved using Lua's require(). The following paths are automatically whitelisted:

  1. The microcontroller script's directory
  2. The project root (where .microproject is located)
  3. Bundled common libraries

To add additional library paths:

:MicroProject add /path/to/your/simulator/library
:MicroProject ui --lib /path/to/simulator/library

Best Practices

1. Use Function Form for Simple Cases

If you don't need initialization or debug drawing, use the function form:

return function(ctx)
  ctx.input.setNumber(1, math.random())
end

2. Initialize State in onInit

For table-form simulators, use onInit to set up state:

function M.onInit(ctx, cfg)
  M.time = 0
  M.config = cfg or {}
end

3. Don't Mix Manual and Simulated Inputs

While technically possible, mixing simulator-controlled and manually-controlled inputs on the same channel can be confusing. Choose one or the other per channel.

4. Use Debug Canvas for Visualization

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))
end

5. Test with Different Tick Rates

Your simulator should work correctly at various tick rates:

:MicroProject ui --tick 30   # 30 ticks/second
:MicroProject ui --tick 60   # 60 ticks/second

Use ctx.time.getDelta() for time-based logic instead of assuming a fixed tick rate.

6. Leverage LSP Type Hints

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)
end

Type stubs are located in:

  • lua/common/chromatischer/LspHinting/simulator.lua
  • lua/common/chromatischer/LspHinting/love.lua

Examples

Example 1: Simple Sine Wave

-- 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

Example 2: Square Wave with Configuration

-- 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 M

Attach:

function onAttatch()
  return {
    input_simulator = require('simulators.square_wave'),
    input_simulator_config = {
      freq_hz = 2.0,
      channel = 5
    }
  }
end

Example 3: Multi-Channel with Debug Visualization

-- 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

Example 4: Ramp Generator for Testing

-- 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

Example 5: Step Sequence (Automated Test)

-- 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 M

Summary

Input 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 README or explore the example simulators in the tests/fixtures/ directory.

Clone this wiki locally