--- name: lua-plugin-authoring description: Explains how to write, test, and debug HOT-Step Lua plugins (solvers, schedulers, guidance modes, postprocess) including the apg()/post_step() API and UI param flow. Use when adding or modifying a sampling solver, noise scheduler, CFG/guidance mode, or VAE-decode postprocess plugin, or when a plugin fails to load, has no effect, or produces noise. --- # Writing Lua plugins for HOT-Step CPP Solvers, schedulers, guidance modes, and postprocess (VAE-decode replacement) are **Lua plugins (LuaJIT 2.1)** loaded by the C++ engine at startup. Adding one = drop a `.lua` file in the right directory and restart the app. **No C++ rebuild.** Glossary (used throughout): - **DiT** — the diffusion transformer that denoises audio latents over N steps. A **solver** decides how the latent `xt` advances each step given the model's predicted velocity `vt`. A **scheduler** decides the timestep values. A **guidance** mode combines the conditional and unconditional model predictions (classifier-free guidance, CFG). A **postprocess** plugin replaces the built-in tiled VAE (variational autoencoder) latent-to-audio decode. - **FloatArray** — a zero-copy Lua userdata view over a raw C++ `float*`. It is **0-indexed** (`xt[0] … xt[n-1]`), unlike normal Lua tables. `#xt` returns its length. Defined in `engine/src/lua-plugin.h:32-87`. - **`params`** — a Lua global table injected before every plugin call, holding the values the user set in the UI for this plugin's declared parameters. ## Golden rules 1. **The old approach of editing `engine/src/dit-sampler.h` is OBSOLETE.** All sampling routes through `engine/src/hot-step-sampler.h` (included by upstream `pipeline-synth-ops.cpp`). Adding a solver/scheduler/guidance = write a `.lua` plugin. WHY: C++ edits to the old sampler are dead code and waste a rebuild cycle; losing the `hot-step-sampler.h` include during an upstream sync kills every plugin (a linker sentinel `hotstep_sampler_linked_` at `hot-step-sampler.h:1309-1314` turns that into a link error; `engine/verify-hooks.ps1` also checks it). 2. **"Hot-loadable" means no rebuild, NOT live reload.** Plugins are scanned once at ace-server startup (`engine/tools/hot-step-server.cpp:2676`). After editing a `.lua` file you must restart the app. `POST /api/plugins/reload` only clears the Node server's 60-second cache of the plugin list (`server/src/routes/plugins.ts:31-35`) — it does not re-read files. 3. **Never kill `ace-server.exe` directly.** The Node server auto-respawns it on crash, causing an infinite respawn + file-lock loop. The working restart is `Invoke-RestMethod -Method Post http://localhost:3000/api/shutdown/restart` (the shutdown router is mounted at `/api/shutdown` — `server/src/index.ts:77` — so plain `/api/restart` 404s; the loop wrapper relaunches Node, which respawns ace-server and rescans plugins). Alternatively `dev-rebuild.bat` (clean shutdown; its compile is a no-op for Lua-only changes) **then** start again with `dev.bat` — note `dev-rebuild.bat` does NOT relaunch, and re-running `dev.bat` while the app is still up just spawns port-conflicting duplicates. If plugin work escalates into editing any `engine/src/` C++ file (e.g. a new bridge function in `lua-plugin.h`), rebuild via `dev-rebuild.bat` immediately — never `engine/build.cmd` directly, and never `cmake --clean-first` (20+ min CUDA recompile; for stale `.obj` issues delete only `engine/build/acestep-core.dir/` and `engine/build/Release/acestep-core.lib`). 4. **FloatArrays are 0-indexed; Lua tables are 1-indexed.** An out-of-range index raises a Lua error which aborts the call — the engine prints the error and continues, so `xt` never advances and the "successful" generation is pure noise. WHY: this is the single most common plugin bug and it fails silently from the user's perspective. 5. **Put your plugins in repo-root `plugins//`, not `engine/plugins//`.** Both are scanned (engine tier first, then repo root — `engine/src/lua-plugin-registry.h:38-50`); the repo-root tier keeps built-ins clean. Duplicate `name`s: **first loaded wins** (so you cannot shadow a built-in). 6. **In guidance plugins, always route the base combine through `apg()`.** Raw `uncond + w*(cond-uncond)` produces audible artifacts; the native `apg()` bridge adds momentum smoothing, perpendicular projection, and norm thresholding. Customize the *scale* or post-process `result` instead (see `engine/plugins/guidance/cfg_pp.lua`). 7. **Do not visually verify the UI with a browser agent** — ask the user. API checks (`/api/plugins`) are fine. 8. **Never delete generated test audio, even output you believe is noise or broken** — the user verifies plugin results by ear and compares control vs. plugin runs. Leave every test generation in place and ask the user to listen. ## Where plugins live and how they load Scanned at startup, in order (`lua-plugin-registry.h:35-55`): 1. `engine/plugins/{solvers,schedulers,guidance,postprocess}/` — built-ins 2. `/plugins/{solvers,schedulers,guidance,postprocess}/` — user/community drop-ins Missing directories are skipped silently (e.g. `engine/plugins/postprocess/` does not exist — the only shipped postprocess plugin is `plugins/postprocess/md_audio_tiled.lua`). Loader rules (`lua-plugin-registry.h:157-226`): - Only `.lua` files; sorted for deterministic order. - **Companion-file exclusion**: filename stems containing `_constants`, `_math`, or `_data` are never loaded as plugins — they are `require()` targets (e.g. `beta_math.lua`, `stork4_constants.lua`). Stems ending `_core` are skipped only if a sibling without `_core` exists in the same dir (`md_audio_tiled_core` is skipped; `storm_sampler_core` loads as a plugin because no `storm_sampler.lua` exists). - Plugin **type is detected by which global table the file defines**: `solver`, `scheduler`, `guidance`, or `postprocess` (`lua-plugin.h:348-386`). Wrong table for the directory → "declares wrong type, skipping". Empty/missing `name` → skipped. Lua syntax error → `[Plugins] ERROR loading : ` on stderr, plugin absent. - Startup log (in `logs//ace_engine.log`): one line per plugin, then `[Plugins] Loaded N solvers, N schedulers, N guidance, N postprocess`. Sandbox (`lua-plugin.h:180-195, 316-343`): `math`, `string`, `table`, `print`, `pairs`, `ipairs`, `tonumber`, `tostring`, `require` are available. `os`, `io`, `debug`, `dofile`, `loadfile` are removed. `require()` searches ONLY the plugin's own directory; C modules are blocked. Each plugin gets its **own `lua_State` that lives for the whole process** — file-level `local` variables persist across steps AND across generations (that is how stateful solvers work, and why they must self-reset; see Golden rule of state below). ## Procedure: write and test a plugin 1. Create `plugins\\my_thing.lua` (repo root). Start by copying the closest template (see the table in "Worked templates" below). 2. Restart the app. If it is running: `Invoke-RestMethod -Method Post http://localhost:3000/api/restart` (relaunches Node → respawns ace-server → rescans plugins). If it is fully stopped: start with `dev.bat`. Do NOT re-run `dev.bat` over a running app (port conflicts; the old ace-server keeps serving the stale plugin list), and never kill `ace-server.exe` yourself. 3. Confirm it loaded: ```powershell $s = Get-ChildItem logs | Sort-Object Name -Descending | Select-Object -First 1 Select-String -Path "$($s.FullName)\ace_engine.log" -Pattern '\[Plugins\]' ``` Expect a per-plugin line naming yours. A load failure prints `[Plugins] ERROR loading : ` here. 4. Confirm the API sees it: ```powershell Invoke-RestMethod http://localhost:3000/api/plugins | ConvertTo-Json -Depth 6 ``` (or engine-direct `http://localhost:8085/plugins`). If you edited metadata and the list looks stale, bust the Node cache: `Invoke-RestMethod -Method Post http://localhost:3000/api/plugins/reload` — cache only; file changes still need an app restart. 5. Generate once with known-good settings (solver `euler` + scheduler `linear`) as a control, then swap in your plugin. A generation selects plugins via request JSON fields `infer_method` (solver — note the non-obvious name), `scheduler`, `guidance_mode`, and `postprocess_plugin` (UI params `inferMethod` / `scheduler` / `guidanceMode` / `postprocessPlugin`, mapped in `translateParams.ts:58-60`). Runtime confirmation lines in `ace_engine.log`: - `[DiT] Solver: (, N NFE/step, order K)` (`hot-step-sampler.h:503`) - `[DiT] Guidance: () [native APG] [post_step]` (`hot-step-sampler.h:525`) - `[DiT] Custom schedule: (), shift=X` (`engine/src/sampler-schedule.h:151`) - `[Postprocess] Using plugin '' for VAE decode` (`pipeline-synth-ops.cpp:1764`) 6. Debug with `print()` — it goes to engine stdout, captured in `ace_engine.log` and the matching `logs//generations/gen_*.log`. Runtime Lua errors surface as `[Plugins] ERROR in solver '' step(): ` (also `schedule()`/`guide()`/`post_step()`/`process()` variants): ```powershell Select-String -Path "$($s.FullName)\ace_engine.log" -Pattern '\[Plugins\] ERROR' ``` ## The four plugin types — contracts All contracts below are verified against `engine/src/lua-plugin.h` (the single source of truth). Full detail including full-loop solvers, `post_step()`, and postprocess internals: [reference.md](reference.md). ### 1. Solver — `plugins/solvers/` ```lua solver = { name = "my_solver", display = "My Solver", description = "...", nfe = 1, order = 1, -- informational (shown in logs/UI) needs_model = false, -- true => step() gets model_fn + vt_buf stateful = false, stochastic = false, -- informational -- owns_loop = true -- advanced: define sample() instead of step(); see reference.md params = { ... }, -- optional; see "Declared UI params" } -- single-eval (needs_model = false): 5 args function step(xt, vt, t_curr, t_prev, n) -- xt: mutable FloatArray (modify IN PLACE); vt: READ-ONLY FloatArray -- t_curr = current t; t_prev = the NEXT (lower) t despite the name local dt = t_curr - t_prev -- positive for i = 0, n - 1 do xt[i] = xt[i] - vt[i] * dt end end ``` Multi-eval (`needs_model = true`) gets 7 args: `step(xt, vt, t_curr, t_prev, n, model_fn, vt_buf)`. `model_fn(xt_arr, t_val)` runs a full CFG'd forward pass and writes guided velocity into `vt_buf` (a live-memory FloatArray). The `vt` arg is a **snapshot** taken before your step — read the original velocity from `vt`, read fresh model results from `vt_buf` (`hot-step-sampler.h:1179-1195`; this separation fixed the historical "Heun silently becomes Euler" bug). Globals injected per call: `step_index` (0-based), `batch_n`, `n_per` (elements per batch item), `params` (`lua-plugin.h:451-457`). `n` = whole flattened batch (`batch_n * n_per`). Engine invariants: the **final step never calls `step()`** — the engine computes `output = xt - vt * t_curr` itself (`hot-step-sampler.h:1171-1175`). DCW correction and repaint injection are applied after your step by the engine — do not reimplement them. **Stateful solvers must self-reset**: `lua_State` persists across generations, so reset file-locals when `step_index == 0` (see `engine/plugins/solvers/unipc.lua:153-158`). Checking only "did `n` change" is insufficient — two same-length generations back-to-back will bleed state (`plugins/solvers/md_pingpong_simple.lua:242-258` documents the explosion this causes). ### 2. Scheduler — `plugins/schedulers/` ```lua scheduler = { name = "my_sched", display = "My Sched", description = "...", params = {...} } function schedule(output, num_steps, shift) -- write num_steps DESCENDING t values (1.0 -> ~0.0) into output (FloatArray) -- do NOT append a trailing 0 — the engine handles the final x0 step for i = 0, num_steps - 1 do output[i] = 1.0 - i / num_steps end -- apply the standard shift warp yourself (every shipped scheduler does): if shift ~= 1.0 then for i = 0, num_steps - 1 do local t = output[i] output[i] = shift * t / (1.0 + (shift - 1.0) * t) end end end ``` `shift` is NOT taken from the UI — it is **back-calculated** from the upstream schedule's second timestep and clamped (`<0.5 → 1.0`, `>10 → 3.0`) at `sampler-schedule.h:61-74`. Your plugin only runs when the request names a scheduler; empty = upstream default shifted-linear. A `custom_timesteps` CSV in the request overrides all schedulers (`hot-step-sampler.h:91-103`). Composite syntax `composite:A+B:crossover:split` blends two scheduler plugins engine-side (`sampler-schedule.h:79-133`). Name aliases: `karras` → `sgm_uniform`; `power:4.00` falls back to prefix `power` (`lua-plugin-registry.h:72-91`). ### 3. Guidance — `plugins/guidance/` ```lua guidance = { name = "my_guide", display = "My Guide", description = "...", params = {...} } function guide(pred_cond, pred_uncond, guidance_scale, result, Oc, T, norm_threshold) -- pred_cond / pred_uncond: READ-ONLY FloatArrays [Oc*T]; result: mutable [Oc*T] -- customize the scale, then ALWAYS combine via apg(): apg(pred_cond, pred_uncond, guidance_scale, result, Oc, T, norm_threshold) end ``` Called per batch element per model eval. Globals: `step_idx` (note: guidance uses `step_idx`; solvers use `step_index`), `total_steps`, `dt`, `t_curr`, `params`. `apg()` is a C bridge over native `apg_forward()` (`lua-plugin.h:643-671`), registered lazily on the first `guide()` call (`lua-plugin.h:697-704`). Calling it at file top level therefore fails at load with Lua's `attempt to call a nil value (global 'apg')` and the plugin is skipped. Calling it from `post_step()` does NOT error — the `_apg_mbuf` global set before each `guide()` call (`lua-plugin.h:688-689`) is never cleared, so it silently reuses the momentum buffer of whichever batch element was guided last (wrong/stale state). Either way: only call `apg()` inside `guide()`. **Native bypass gotcha**: when the selected guidance is named exactly `"apg"`, the engine takes the native C++ path and never calls Lua `guide()` (`use_apg_native`, `hot-step-sampler.h:524`). Editing `apg.lua`'s body does nothing; it is a documented fallback (`apg.lua:4-5`). To experiment, copy it under a new `name`. Advanced: define a global `post_step(xt, t, n, eval_cond, eval_uncond, vt_cond, vt_uncond)` and the engine calls it after every solver step except the last, only while CFG is active. Each `eval_cond`/`eval_uncond` call is a **full model forward pass** — expensive. Details + gating conditions: [reference.md](reference.md). Real user: `engine/plugins/guidance/cfg_mp.lua:47`. ### 4. Postprocess — `plugins/postprocess/` (replaces tiled VAE decode) ```lua postprocess = { name = "my_pp", display = "My PP", params = {...} } function process(latents, B, C_lat, W, C_aud, final_samples, upscale_factor, vae_decode_fn) -- latents: plain Lua TABLE (1-indexed, NOT a FloatArray), channel-major [C_lat=64, W] -- B is always 1 (engine iterates batch items); upscale_factor = 1920 samples/latent frame -- vae_decode_fn(latent_table, T_latent) -> audio_table, T_audio -- MUST return: audio_table (1-indexed, [2 * T_audio]), T_audio end ``` Selected per request via JSON field `postprocess_plugin` (`engine/src/request.cpp:177-178`; UI param `postprocessPlugin` → `server/src/services/generation/translateParams.ts:181-183`). Unknown name or `T_audio <= 0` → warning + automatic fallback to the built-in tiled decoder (`pipeline-synth-ops.cpp:1730-1734, 1792-1804`). This path uses Lua tables with transposes on both sides — deliberately not zero-copy. Reference implementation: `plugins/postprocess/md_audio_tiled.lua` + `md_audio_tiled_core.lua` (the `require()`-a-`_core`-module pattern). Full contract: [reference.md](reference.md). ## Declared UI params and how values reach your plugin Schema (extracted at `lua-plugin.h:230-291`) — four types: `slider` (`default`/`min`/`max`/`step`), `select` (`default` + `options` as `{value=,label=}` tables or bare strings), `toggle` (bool `default`), `text` (string `default`). Common fields: `key`, `label`, `hint`, `visible_when = { key = "...", equals = "..." }` (string-compared against a sibling param's current value). Field reference + JSON shape: [reference.md](reference.md). Flow: engine `GET /plugins` → Node proxy `GET /api/plugins` (60 s cache, empty lists on engine failure) → UI `PluginControls` (`ui/src/components/global-bar/PluginControls.tsx`) stores flat strings in `{ "pluginName:paramKey": "value" }`, persisted under localStorage key `hs-pluginParams` (`ui/src/stores/globalParamsStore.ts:149`) → request field `plugin_params` (`translateParams.ts:176-178`) → engine parses (`[DIAG] Parsed N plugin_params` in the log) → before every Lua call, `lua_inject_params` (`lua-plugin.h:410-436`) filters by `":"` prefix and sets a fresh `params` global. Coercion: numeric string → number, `"true"`/`"false"` → boolean, else string. Param traps: - **`params` and any key may be nil** (untouched params are absent from the map). Always default: `local x = (params and params.x) or 0.5`. - **The `or` idiom is WRONG for toggles defaulting true**: `(params and params.rms_servo) or true` is `true` even when the user turned it OFF (`false or true == true`). `md_pingpong_simple.lua:267` (`rms_servo_on`) carries exactly this latent bug — do not copy it. Correct: ```lua local v = true if params and params.rms_servo ~= nil then v = params.rms_servo end ``` - Keys are namespaced by plugin **`name`**, not filename. Renaming the plugin silently orphans users' stored values. - **Do not rely on the `transform` schema field** — it is extracted and serialized but `PluginControls.tsx` never applies it; values are sent verbatim. `docs/dev/plugins-authoring.md`'s claim that the UI transforms values is not implemented. - `accent` (UI colorway) must be one of: amber, cyan (default), blue, teal, green, emerald, purple, indigo, orange, pink, rose, sky, violet (`PluginControls.tsx:20-35`). - Fields like `stork_substeps`, `beat_stability`, `apg_momentum` are a **separate legacy sideband** channel (`hot-step-params.h:97-100`, `translateParams.ts:147-155`), not `plugin_params`. New plugins must use declared `params` only. ## Worked templates (all real, in-repo) | Want to write… | Copy from | |---|---| | Solver, single-eval | `engine/plugins/solvers/euler.lua` (21 lines, canonical) | | Solver, multi-eval / stateful | `engine/plugins/solvers/unipc.lua` (`needs_model=true`, history reset on `step_index==0`) | | Solver, full-loop / stochastic / heavy params | `plugins/solvers/md_pingpong_simple.lua` (owns_loop, pure-Lua RNG, hoisted scratch buffers; but see the toggle-bug note above) | | Scheduler, simple | `engine/plugins/schedulers/linear.lua` | | Scheduler with companion `require()` | `engine/plugins/schedulers/beta57.lua` + `beta_math.lua` | | Guidance, scale-modifying | `engine/plugins/guidance/cfg_pp.lua` (21 lines) | | Guidance with `post_step()` | `engine/plugins/guidance/cfg_mp.lua` | | Postprocess | `plugins/postprocess/md_audio_tiled.lua` + `_core` | ## Key files | Path | Role | |---|---| | `engine/src/lua-plugin.h` | The plugin API source of truth: FloatArray, sandbox, all call contracts, param extraction | | `engine/src/lua-plugin-registry.h` | Scan dirs, companion exclusion, name lookup + aliases, JSON for `GET /plugins` | | `engine/src/hot-step-sampler.h` | Sampling loop: solver/guidance dispatch, final-step x0, vt snapshot, post_step gating, linker sentinel | | `engine/src/sampler-schedule.h` | Scheduler dispatch, shift back-calculation, composite schedulers | | `engine/src/pipeline-synth-ops.cpp` | Postprocess plugin caller + fallback (`ops_vae_decode_postprocess`, ~line 1721) | | `engine/src/hot-step-params.h` | `plugin_params` map + legacy sideband params | | `engine/tools/hot-step-server.cpp` | Registry init (~2676), `GET /plugins` (~2716), `plugin_params` JSON parse (~771) | | `server/src/routes/plugins.ts` | Node proxy `/api/plugins` + 60 s cache + `/reload` | | `server/src/services/generation/translateParams.ts` | UI params → engine request JSON (`plugin_params`, `postprocess_plugin`) | | `ui/src/components/global-bar/PluginControls.tsx` | Renders declared params; accent map | | `ui/src/stores/globalParamsStore.ts` | `hs-pluginParams` localStorage persistence | | `engine/plugins/` + `plugins/` | Built-in and user plugin tiers | | `docs/dev/plugins-authoring.md` | Committed authoring guide (mostly accurate; see caveats in reference.md) | | `plugins/README.md` | Community plugins folder README: points at the authoring guide; its full-loop solver section is accurate | ## Failure signatures | Symptom | Cause → fix | |---|---| | Plugin absent from UI dropdown | Lua syntax error at load (grep `[Plugins] ERROR loading` in `ace_engine.log`); wrong global table for its directory; empty `name`; duplicate `name` (first wins); or filename matched companion exclusion (`_constants`/`_math`/`_data`, or `_core` with a non-core sibling) | | All dropdowns empty / fallback lists | Node couldn't reach the engine — `/api/plugins` returned empty lists (`plugins.ts:26-28`). Check engine is up on :8085 | | Output pure noise, generation "succeeds" | `step()` raised (index out of range, nil arithmetic) — error printed each step, `xt` never advanced. Grep `[Plugins] ERROR in` | | `FloatArray is read-only` | Wrote to `vt` / `pred_cond` / `pred_uncond`. Write to `xt` / `result` / scratch tables | | `attempt to call a nil value (global 'apg')` at load | Called `apg()` at file top level — it is only registered lazily inside `guide()` dispatch | | `apg()` from `post_step()` behaves oddly (no error) | Silently reuses the stale momentum buffer from the last `guide()` call — never call `apg()` outside `guide()` | | Multi-eval solver quietly acts like Euler | Expected the original velocity after calling `model_fn` — read original from the snapshot arg `vt`, fresh results from `vt_buf` | | First step of 2nd generation explodes | Stateful solver didn't reset file-locals on `step_index == 0` (an n-change check alone misses same-length runs) | | Toggle "can't be turned off" | `(params and params.key) or true` idiom — see Param traps | | Param changes do nothing | Key mismatch vs schema `key`; edited `apg.lua` (native bypass); or values persisted under an old plugin `name` | | Guidance `post_step` never fires | `guidance_scale <= 1` (no CFG), final step, or past the `cfg_cutoff_ratio` step (CFG turned off) | | Every solver/scheduler/guidance dead after upstream sync | `pipeline-synth-ops.cpp` lost the `hot-step-sampler.h` include — now a link error via the `hotstep_sampler_linked_` sentinel. Run `engine/verify-hooks.ps1` | | Generation very slow with custom guidance | Each `eval_cond`/`eval_uncond` in `post_step` is a full forward pass — budget them | ## Three samplers run these plugins, not one The plugin layer never had an ACE dependency — every `lua_call_*` takes raw `float*`, element counts and a param map. What was ACE-specific was the *sampler*. Three now dispatch into it, and a plugin change affects all three: | Sampler | Call sites | Notes | |---|---|---| | ACE-Step DiT | `hot-step-sampler.h` | the original; time-major `[T][Oc]`, `t` descends 1→0 | | MiniMax-Music3 flow DiT | `minimax/mm3-plugins.h` | sigma ascends, latents channel-major — the bridge flips both | | StableStep / SA3 refine | `sa3-refine.h` | `t` already descends like ACE; latents channel-major | **The layout trap, twice learned:** `apg_project()` (`guidance/apg-core.h`) normalises per channel over time and indexes `[t*Oc + c]` — it is hard-wired to ACE's **time-major** memory. MM3 and SA3 both store latents **channel-major**, so both bridges transpose into the ACE view before any guidance plugin sees a buffer, and back after. Skip that and APG silently normalises scrambled mixtures rather than channels — it compiles, it runs, it sounds subtly wrong. Solvers are exempt: `lua_call_solver_step` is handed only a flat `n`, and `lua_call_solver_loop` uses `T`/`Oc` solely to publish `n_per`, so no solver can index across channels. Per-backend caveats worth knowing before you assume a plugin "works everywhere": - **`owns_loop` solvers**: fine on ACE and SA3, **refused on MM3** — a full-loop solver bypasses MM3's per-step window-overlap blend and breaks every seam. - **Guidance on SA3 is near-decorative.** SA3 was trained at cfg=1 and has no unconditional branch, so the engine passes the cond velocity as *both* predictions. APG-family modes see `diff = 0` and pass through unchanged; only plugins doing cond-side work have any effect. - **Schedulers on SA3 get rescaled.** Schedulers emit `sigma_max = 1.0` for full denoising; the SA3 refine is SDEdit and starts at `strength` (~0.3), so the returned curve is linearly rescaled onto `[strength → 0]` — spacing preserved, starting point not. Note the override array is `steps` long while the loop indexes `sigmas[i+1]`, so the terminal `0.0` must be appended (getting this wrong is an out-of-bounds read, not a compile error). - **`sampler_build_scheduler_override()` reads process-global `g_hotstep_params`**, not its arguments. `/sa3-refine` therefore saves and restores `solver_name`/`scheduler`/`plugin_params` around the call — without that, a refine leaks its picks into the next ACE generation on the same worker. ## Institutional knowledge - **VALIDATED**: `hot-step-sampler.h` replaced `dit-sampler.h` as the sampling path; the include lives in upstream `pipeline-synth-ops.cpp` and its loss during a sync used to be silent (everything compiled, all plugins dead). The linker sentinel now makes it a link error. Always run `engine/verify-hooks.ps1` after touching upstream files. - **VALIDATED**: the engine snapshots `vt` before multi-eval solver steps (`hot-step-sampler.h:1179-1195`) because sharing one buffer between "original velocity" and "model_fn output" silently degraded Heun to Euler. - **VALIDATED**: stateful plugins must reset on `step_index == 0`; persisting `lua_State`s bleed state across generations (documented in-code at `md_pingpong_simple.lua:242-246` — "explosive velocity on step 1"). - **VALIDATED**: `philox_randn` is NOT exposed to Lua. `sde.lua:3-4` mentions it as a required C helper, but it is not registered; the SDE stochastic path is handled C++-side for that specific plugin. Pure-Lua stochastic plugins must roll their own RNG (see the LCG + Box-Muller in `md_pingpong_simple.lua`). - The outdated module-return examples that used to live in `plugins/README.md` were removed on 2026-09-25; the README now points at `docs/dev/plugins-authoring.md`. Trust that guide and `engine/src/lua-plugin.h`. - **UNVERIFIED**: whether the TensorRT sampler variant (`hot-step-sampler-trt.h`) covers solver/guidance plugins identically — it calls the same scheduler override, but its plugin dispatch was not audited. Check before relying on plugins under the TensorRT backend. ## Deeper reading - [reference.md](reference.md) (this folder) — full-loop solver contract, `post_step()` details, postprocess internals, param schema JSON shape, docs-vs-code discrepancy list. - `docs/dev/plugins-authoring.md` — committed authoring guide. Known inaccuracies: says `shift` comes "from UI" (actually back-calculated); documents `transform` as applied by the UI (it is not); omits `owns_loop`/`sample()` and the postprocess type; uses `step_idx` naming loosely (solvers get `step_index`, guidance gets `step_idx`). - `engine/docs/ARCHITECTURE.md` — engine internals, request JSON. - `docs/plans/` — internal design docs, **gitignored and local-only** (may be absent on a fresh clone).