Documentation / Under the hood
Performance
Achaea combat is decided on round trips, and Mudlet runs every package on one thread. Time Emunah spends is time the client is not drawing, reading or sending, so every hot path is measured, not assumed.
The paths that matter
| Path | Runs on | Whose cost |
|---|---|---|
| Trigger matching | Every line of game text | Mudlet's own, in C++ |
| Trigger callbacks | Every line a pattern matched | Emunah's |
engine.tick() | Every prompt | Emunah's |
| Panel repaints | Every prompt, and every item, affliction and defence change | Emunah's, and Qt's |
A trigger whose pattern matches nearly every line is a per-line Lua handler in all but name. Emunah once had five of them.
What it costs now
Measured with test/bench.lua under a realistic lock: eight afflictions across every balance, herbs in hand, and health and mana below all four healing thresholds. Three runs on 15 October 2026 measured engine.tick() at 77.5 µs, allocating 2,000 bytes. That is up from 67.5 µs on 4 October; the rise came before that day's changes, and has not yet been profiled.
The first pass
| Before | After | |
|---|---|---|
engine.tick() | 124.1 µs | 79.0 µs |
afflist.priority(aff, vector) | 0.58 µs | 0.28 µs |
afflist.curesVia(aff, vector) | 0.22 µs | 0.09 µs |
have.cure(option) | 1.60 µs | 0.87 µs |
| Ticker scroll step (12.5 Hz) | 47.6 µs, 4171 B | 1.7 µs, 424 B |
| Lines that run the name highlighter | 88% | 12% |
Time to send, 3 October 2026
The second pass measured the time from a packet arriving to the cure leaving, and moved every repaint after the send.
| Packet | Before | After |
|---|---|---|
| An affliction lands | 158 µs, 10 draws before the send | 85 µs, none |
| Herb balance returns, then Char.Vitals | 96 µs, 5 draws | 52 µs, none |
engine.tick() | 79.0 µs, 2056 B | 67.5 µs, 2000 B |
engine.has() | 0.17 µs | 0.10 µs |
What was found
The name highlighter ran on nearly every line
Its trigger matched any capitalised word, on the reasoning that this was the cheapest possible filter. It was cheap, but it did not filter: nearly every line of Achaea's text has a capitalised word, so 88% of lines ran a full roster scan. The trigger is now built from the roster itself and rebuilt when the roster changes, and it fires on the 12% of lines that actually name somebody.
The ticker allocated about 140 strings a second
It rebuilt its whole reel at 12.5 Hz, invisible to every per-prompt benchmark. The reel is now compiled once per message change, and each step is one substring and one join.
Panels drew before the cure went out
The vitals strip painted before the engine ran, and the affliction panel painted inside the affliction update. Every panel now paints once, after the packet has been handled, coalesced so two afflictions in one packet paint the panel once.
Questions asked more often than their answers changed
Priorities were re-read 56 times a prompt, names were lowercased again on every read, and per-balance loops re-asked questions that did not depend on the balance. Each is now asked once per prompt, against data that is invalidated when it changes, never on a timer.
Measuring
lua5.1 test/bench.lua [iterations] # time and bytes per call
lua5.1 test/profile.lua [iterations] # sampling profiler: the hottest lines
lua5.1 test/latency.lua # from a packet arriving to the command leaving
lua5.1 test/run.lua # includes the draw-count budgets
Four traps each produced confident, wrong numbers before they were noticed:
- The test mock replaces the clock so cooldowns are deterministic; timing against it measures the fake clock.
- The profiler's absolute times are dominated by its own hook. Read its relative counts and take times from
bench.lua. - Allocation must be measured with the garbage collector stopped, or the figure is whatever happened not to be collected.
- Per-line and repaint costs cannot be timed through the mock, so they are counted instead: callbacks per line, draw calls per repaint.
Rules for changes
- Before adding a trigger, ask what fraction of real text it matches. If the answer is "most", narrow it or build it from data.
- Cache against a counter bumped where the data changes, never against a clock.
- Every cache has a test that fills it first and then proves it is invalidated.
- A panel draws once per repaint, or not at all when nothing it shows has changed.
- Measure before and after, and check that the number is real.
The full record, with every measurement, is docs/performance.md in the repository.