Lua × SWMM5 Playground engine loading…
Write Lua 5.3, drive a real EPA SWMM 5.2.4 engine compiled to WebAssembly, watch the hydrographs stream out — all inside this one file.
booting…

Lua script

Type swmm. for completions · ↑↓ pick · Tab accepts · Ctrl+Enter runs

Live hydrographs

Console — bright: Lua print() · dim: engine stdout

In-memory workspace — the engine reads and writes here; nothing touches your disk

Drop files anywhere on this card (64 MB per file). Scripts see them by name: swmm.run("yourmodel.inp", "yourmodel.rpt", "yourmodel.out"). Each Run works on a snapshot taken when you press Run; files you add mid-run join the next one.

How the binding works

Your Lua 5.3 scriptcalls swmm.open(), swmm.step(), swmm.set()…
Fengari VMa Lua 5.3 implementation in JavaScript; each bridge function is registered with lua_pushjsfunction — the same C-API pattern a native binding uses
Toolkit callsstrings and doubles are marshalled through malloc/free into wasm linear memory, then the exported swmm_* functions run
EPA SWMM 5.2.4the untouched solver, compiled wasm32-wasi; its fopen/fprintf land in…
In-memory FSa WASI filesystem shim: the workspace you see on the Playground tab is the engine's working directory
This is the browser twin of the LuaJIT-FFI approach on the Desktop Lua tab: same lifecycle, same property codes, same error discipline — so a script sketched here ports to a desktop LuaJIT + swmm5 shared library almost line for line.

swmm.* — the binding, function by function

LuaC toolkit call (swmm5.h, 5.2.4)Notes
swmm.version()int swmm_getVersion(void) 52004 for this build
swmm.run(inp, rpt, out)int swmm_run(f1, f2, f3) batch: opens, simulates, reports and closes by itself
swmm.open(inp, rpt, out)int swmm_open(f1, f2, f3) begin the step lifecycle
swmm.start(save)int swmm_start(int saveFlag) save = 1 writes the binary .out
swmm.step()int swmm_step(double *elapsed) returns elapsed decimal days; 0 means finished
swmm.stride(n)int swmm_stride(int, double *) n routing steps at once
swmm.finish() · swmm["end"]()int swmm_end(void) end is a Lua keyword, so dot-syntax needs finish
swmm.report()int swmm_report(void) writes the .rpt summary tables
swmm.close()int swmm_close(void) only for the open/start/step lifecycle — see rules below
swmm.mass_bal()int swmm_getMassBalErr(float*, float*, float*) three returns: runoff %, routing %, quality %
swmm.warnings()int swmm_getWarnings(void)
swmm.error_message()int swmm_getError(char*, int) the bridge also raises it for you on any nonzero code
swmm.count(objType)int swmm_getCount(int)
swmm.index(objType, name)int swmm_getIndex(int, const char*) 0-based, exactly like the C API; raises if not found
swmm.name(objType, i)void swmm_getName(int, int, char*, int)
swmm.get(prop, i)double swmm_getValue(int property, int index) index optional for SYSTEM codes
swmm.set(prop, i, v)void swmm_setValue(int, int, double) only the codes in the “settable” table act
swmm.saved(prop, i, period)double swmm_getSavedValue(int, int, int) period starts at 1; window: after finish, before close
swmm.writeline(s)void swmm_writeLine(const char*) drops a line into the .rpt
swmm.decode_date(d)void swmm_decodeDate(double, int*×7) 7 returns: y, mo, d, h, mi, s, dow
swmm.elapsed()swmm_getValue(ELAPSEDTIME, 0) convenience

Playground extras (not part of the C API)

files.read / write / exists / list the in-memory workspace; write an .inp, then open it by name
plot.series(id, label, unit) · plot.point(id, x, y) stream values to the live chart; unit "flow" or "depth" picks the panel

Property codes (swmm5.h, verbatim)

Object types

constantvalue
swmm.GAGE0
swmm.SUBCATCH1
swmm.NODE2
swmm.LINK3
swmm.SYSTEM100

System (index ignored)

constantvalue
STARTDATE0
CURRENTDATE1
ELAPSEDTIME2
ROUTESTEP3
MAXROUTESTEP4
REPORTSTEP5
TOTALSTEPS6
NOREPORT7
FLOWUNITS8

Gage / subcatchment

constantvalue
GAGE_RAINFALL100
SUBCATCH_AREA200
SUBCATCH_RAINGAGE201
SUBCATCH_RAINFALL202
SUBCATCH_EVAP203
SUBCATCH_INFIL204
SUBCATCH_RUNOFF205
SUBCATCH_RPTFLAG206

Node

constantvalue
NODE_TYPE300
NODE_ELEV301
NODE_MAXDEPTH302
NODE_DEPTH303
NODE_HEAD304
NODE_VOLUME305
NODE_LATFLOW306
NODE_INFLOW307
NODE_OVERFLOW308
NODE_RPTFLAG309

Link

constantvalue
LINK_TYPE400
LINK_NODE1401
LINK_NODE2402
LINK_LENGTH403
LINK_SLOPE404
LINK_FULLDEPTH405
LINK_FULLFLOW406
LINK_SETTING407
LINK_TIMEOPEN408
LINK_TIMECLOSED409
LINK_FLOW410
LINK_DEPTH411
LINK_VELOCITY412
LINK_TOPWIDTH413
LINK_RPTFLAG414

What swmm.set() actually accepts

Read straight from swmm_setValue() in swmm5.c (5.2.4) — anything else is silently ignored:

codeeffectwhen
GAGE_RAINFALLoverride a gage's rainfall intensity mid-run
NODE_LATFLOWset a node's lateral inflow mid-run
NODE_HEADset an outfall's stage mid-run
LINK_SETTINGopen/close/throttle a link — the RTC workhorse (sample 03)mid-run
ROUTESTEPchange the routing time step mid-run
REPORTSTEP · NOREPORT · *_RPTFLAGreporting control before swmm.start()

Three lifecycle rules this playground enforces

1 · Never swmm.close() after swmm.run(). swmm_run closes the project itself; a second close double-fcloses the report file. On glibc that is quiet undefined behaviour — in WebAssembly it is a hard trap (“null function or function signature mismatch”), measured on this exact build. The bridge turns it into a friendly Lua error instead.
2 · swmm.saved() has a window. It reads the binary .out only while the project is open and the run has ended: after swmm.finish(), before swmm.close(). Sample 05 lives in that window. The same applies to swmm.mass_bal() — after swmm.run() it reads zeros by design, so batch mode parses the .rpt (sample 01).
3 · swmm.step() speaks decimal days. Multiply by 24 for hours; a return of 0 means the simulation is finished — break before plotting it.

Which approach do you need?

ApproachBest forNeeds
1 · Call the executablebatch runs, Monte Carlo, sensitivity sweeps, .inp generationany Lua 5.x + the runswmm/swmm5 CLI
2 · LuaJIT FFI on the shared libraryreal-time control, step-by-step coupling, live parameter changesLuaJIT + swmm5.dll / libswmm5.so (5.2+)
3 · This playgroundteaching, sharing runnable snippets, trying RTC rules with zero installa browser tab
4 · Lua as the model generatortables → [SECTIONS] → .inp, feeding any of the above (sample 04)any Lua

Approach 1 — batch mode with plain Lua

-- run the official CLI and read the report back (any Lua 5.x)
local inp, rpt, out = "model.inp", "model.rpt", "model.out"

-- Windows:  runswmm.exe (ships with the EPA installer)
-- Linux:    runswmm from your own build (see the build card below)
local cmd = ('runswmm "%s" "%s" "%s"'):format(inp, rpt, out)
local ok = os.execute(cmd)
assert(ok, "SWMM run failed")

-- pull both continuity errors out of the .rpt
local f = assert(io.open(rpt, "r"))
local text = f:read("*a"); f:close()
for v in text:gmatch("Continuity Error %(%%%)[%s%.]*(-?%d+%.%d+)") do
  print("continuity:", v .. " %")
end
For hundreds of runs, generate each .inp from Lua tables (sample 04 shows the pattern), spawn with a process library (luaposix, lua-subprocess) instead of os.execute, and read the binary .out with a small reader — its layout is the classic SWMM output format, magic number 516114522.

Approach 2 — LuaJIT FFI on the official shared library

Header check first. A widely-circulated snippet declares swmm_getNodeResult() / SM_* enums — those belong to the OpenWaterAnalytics extended toolkit (toolkit.h), which the official EPA swmm5.dll does not export. The functions below are the EPA 5.2+ API from swmm5.h — the same ones this playground binds — and they work on the DLL that ships with the EPA installer.
local ffi = require("ffi")   -- LuaJIT

ffi.cdef[[
  int    swmm_run(const char *f1, const char *f2, const char *f3);
  int    swmm_open(const char *f1, const char *f2, const char *f3);
  int    swmm_start(int saveFlag);
  int    swmm_step(double *elapsedTime);
  int    swmm_stride(int strideStep, double *elapsedTime);
  int    swmm_end(void);
  int    swmm_report(void);
  int    swmm_close(void);
  int    swmm_getMassBalErr(float *runoffErr, float *flowErr, float *qualErr);
  int    swmm_getVersion(void);
  int    swmm_getError(char *errMsg, int msgLen);
  int    swmm_getWarnings(void);
  int    swmm_getCount(int objType);
  void   swmm_getName(int objType, int index, char *name, int size);
  int    swmm_getIndex(int objType, const char *name);
  double swmm_getValue(int property, int index);
  void   swmm_setValue(int property, int index, double value);
  double swmm_getSavedValue(int property, int index, int period);
  void   swmm_writeLine(const char *line);
  void   swmm_decodeDate(double date, int *y, int *mo, int *d,
                         int *h, int *mi, int *s, int *dow);
]]

local swmm = ffi.load("swmm5")        -- swmm5.dll / libswmm5.so on the path

-- property codes (swmm5.h): NODE=2, LINK=3,
-- NODE_DEPTH=303, LINK_FLOW=410, LINK_SETTING=407
local NODE, LINK = 2, 3
local NODE_DEPTH, LINK_FLOW, LINK_SETTING = 303, 410, 407

assert(swmm.swmm_open("model.inp", "model.rpt", "model.out") == 0)
assert(swmm.swmm_start(1) == 0)

local pond = swmm.swmm_getIndex(NODE, "POND")
local gate = swmm.swmm_getIndex(LINK, "OR1")
local elapsed = ffi.new("double[1]")

while true do
  if swmm.swmm_step(elapsed) ~= 0 or elapsed[0] <= 0 then break end
  local d = swmm.swmm_getValue(NODE_DEPTH, pond)
  -- the same rule sample 03 runs in your browser:
  swmm.swmm_setValue(LINK_SETTING, gate, d > 6.5 and 1.0 or 0.15)
end

swmm.swmm_end(); swmm.swmm_report(); swmm.swmm_close()
LuaJIT implements Lua 5.1; the playground runs Lua 5.3 (Fengari). Watch the usual gaps when porting: integer division (//), bitwise operators, and goto. The swmm.* call sequence itself is identical.

Building the shared library

# Linux / macOS — one command, from the EPA source tree (tag v5.2.4):
gcc -O2 -fPIC -shared -fopenmp \
    src/solver/*.c -Isrc/solver -Isrc/solver/include -lm \
    -o libswmm5.so

# Windows — the EPA installer already ships swmm5.dll (5.2.x exports the
# full swmm_getValue/setValue API); or build with CMake from the same repo:
#   cmake -B build ; cmake --build build --config Release
Source: github.com/USEPA/Stormwater-Management-Model. The simplified toolkit API used on this page (getValue / setValue / getCount…) landed in 5.2.0 — a 5.1-era DLL only has the run/open/step lifecycle.

Verified on this build

ClaimMeasured
Engine identityswmm_getVersion() = 52004; .rpt header “EPA STORM WATER MANAGEMENT MODEL — VERSION 5.2”
Demo model (8 h storm, dynamic wave, 5 s steps) 5,760 routing steps · 480 report periods · 0 errors · 0 warnings · routing continuity −0.025 %
Sample 03 real-time controlgate wide open: peak outflow 5.04 cfs, pond 2.01 ft → Lua-controlled: 0.68 cfs, pond 3.20 ft — an 86 % peak reduction with 4.8 ft of freeboard left
Sample 06 dwell controlunder pulsed rainfall a bare threshold rule moves the gate 41 times; the persistence + minimum-dwell controller (reading LINK_TIMEOPEN / LINK_TIMECLOSED) does the same job in 9 movements at a similar peak depth (2.00 vs 2.28 ft)
Determinismrun A → run B → run A in one engine instance: A and the repeat of A produce byte-identical .out files
Runaway scriptswhile true do end stopped by the instruction-count hook in < 3 s; Stop rebuilds the worker
Speedthe full 5,760-step demo simulates in ≈ 0.4 s (node, this wasm)

Corrections to the source note

This app grew out of an AI-written note on using Lua with SWMM5. The note's architecture (batch → FFI → hybrid) is sound; three details needed fixing:

Claim in the noteStatus
FFI example uses swmm_getNodeResult(index, 5, …) with SM_* codescorrected — that is the OWA extended toolkit; the official EPA DLL exports swmm_getValue(property, index) instead (Desktop Lua tab has the working cdef)
“The SWMM5 Rosetta Stone project includes a ~200 KB Lua engine implementation” unverified — no such Lua engine is known to exist in that project; treat the claim as hallucinated unless a source appears
LuaJIT recommended for the FFI pathkept — with the note that LuaJIT is Lua 5.1 while this playground is Lua 5.3 (Fengari)

Provenance

PieceExactly what it is
EngineEPA Stormwater-Management-Model, tag v5.2.4, src/solver/*.c unmodified, plus a 2-function compatibility file (mkstemp, realpath — both compiled out of wasi-libc; SWMM needs the first for its scratch rainfall file and the second only to echo a path)
Compileclang 18, --target=wasm32-wasi -mexec-model=reactor -O2, all 20 toolkit entry points + malloc/free exported; payload checksum (SHA-256) edf4f4eefc21dd862b89a183371d2934b998c324628cff38a8a7e35e4a811c7e — re-hashed in your browser at load and the engine refuses to start on a mismatch. Being baked into the same file, it proves integrity in transit, not authorship; the independent reference hash lives in the swmm5.org post for this app
WASI runtime@bjorn3/browser_wasi_shim 0.4.2 — a pure-JS WASI with an in-memory filesystem; the Playground workspace is its preopened directory
LuaFengari 0.1.4 (fengari-web bundle) — Lua 5.3 semantics, implemented in JavaScript; bridge functions registered through the Lua C API (lua_pushjsfunction)
Verification68-check node suite over this exact core: engine identity, model census, lifecycle rules, both RTC comparisons, unit detection (CFS and CMS models), rainfall override, generator round-trip, saved-results window, reuse determinism, timeout and error paths — plus a 51-check end-to-end suite that extracts the payloads back out of this very file and reruns the samples through them

Honest limits

Lua 5.3, not LuaJIT — no ffi, no JIT speed; the engine does the heavy lifting, so sample runs still finish in under a second.
io.open is not wired to the workspace — use files.read / files.write. os.time and friends work.
Scripts get 120 s per run before the instruction-count hook stops them; the Stop button is the hard cancel (kills and rebuilds the worker).
One engine instance per script run. Within a run, repeated open→close cycles are fine — verified byte-identical — but a wasm trap ends the instance, exactly as the run-discipline notes for browser SWMM engines predict.
Everything is in-memory: reload the page and the workspace resets to demo.inp. Download anything you want to keep. Uploads are capped at 64 MB per file — bigger models belong on the desktop toolkit.
The simulator is fully self-contained — it runs offline and from file:// (where the checksum badge falls back to the build-time value, since crypto.subtle needs a secure origin). Only the GIF recorder loads cdnjs scripts.