| name | mwse-lua-event |
| description | Add new events to the MWSE Lua API. Covers the full end-to-end process: C++ event class (header + source), memory hook registration in LuaManager, autocomplete definition file, example scripts, and documentation generation. Use when the user wants to hook a new Morrowind engine call and expose it to Lua mod authors. |
MWSE Lua Event Development
This skill covers the complete workflow for adding a new event to MWSE's Lua API.
Overview
Every MWSE Lua event follows the same pattern:
- C++ header – declare the event class in
MWSE/Lua<Name>Event.h
- C++ source – implement constructor and
createEventTable() in MWSE/Lua<Name>Event.cpp
- Hook callback – add a static callback function and memory patch in
MWSE/LuaManager.cpp
- Registration –
#include the header and call genCallEnforced inside LuaManager::hook()
- Disableable event registration – register the event in
MWSE/LuaDisableableEventManager.cpp
- Event string constant – add the string constant to
misc/package/Data Files/MWSE/core/lib/tes3/event.lua
- Autocomplete definition – create
autocomplete/definitions/events/standard/<eventName>.lua
- Examples – add Lua example scripts in
autocomplete/definitions/events/standard/<eventName>/
- Rebuild docs – run the autocomplete builder; docs are auto-generated
Step 1 – C++ Header (MWSE/Lua<Name>Event.h)
Most events inherit from ObjectFilteredEvent (which gives per-object filtering) and the DisableableEvent<T> mixin (which adds an enabled/disabled flag so the hook only runs when there is at least one Lua listener).
If no per-object filter is needed, inherit from GenericEvent + DisableableEvent<T> instead.
Header template
#pragma once
#include "LuaObjectFilteredEvent.h"
#include "LuaDisableableEvent.h"
namespace mwse::lua::event {
class MyEvent : public ObjectFilteredEvent, public DisableableEvent<MyEvent> {
public:
MyEvent(TES3::Reference* reference, );
sol::table createEventTable();
protected:
TES3::Reference* m_Reference;
};
}
Class hierarchy reference:
| Class | Purpose |
|---|
BaseEvent | Pure virtual root – getEventName(), createEventTable(), getEventOptions() |
GenericEvent | Stores event name string (m_EventName) |
ObjectFilteredEvent | Adds m_EventFilter (TES3::BaseObject*) and implements getEventOptions() |
DisableableEvent<T> | Template mixin – static m_EventEnabled bool; gates the event so it only fires when a listener is registered |
The DisableableEvent mixin adds:
static bool getEventEnabled() – the C++ hook checks this before firing
static void setEventEnabled(bool) – called by the Lua event system when a listener is registered/removed
When to use GenericEvent only (no filter)
If there is no meaningful object to filter on (e.g. a frame event, a weather event), inherit from GenericEvent directly:
class MyEvent : public GenericEvent, public DisableableEvent<MyEvent> {
public:
MyEvent();
sol::table createEventTable();
};
Step 2 – C++ Source (MWSE/Lua<Name>Event.cpp)
#include "LuaMyEvent.h"
#include "LuaManager.h"
#include "LuaUtil.h"
#include "TES3Reference.h"
namespace mwse::lua::event {
MyEvent::MyEvent(TES3::Reference* reference, ) :
ObjectFilteredEvent("myEvent", reference),
m_Reference(reference)
{
}
sol::table MyEvent::createEventTable() {
const auto stateHandle = LuaManager::getInstance().getThreadSafeStateHandle();
auto& state = stateHandle.getState();
auto eventData = state.create_table();
eventData["reference"] = m_Reference;
return eventData;
}
}
Key rules for createEventTable():
- Always get the state via
LuaManager::getInstance().getThreadSafeStateHandle() – this acquires the recursive mutex.
- Fields with no
readOnly = true in the autocomplete definition are expected to be writable by Lua. Read them back after triggerEvent returns.
- Store all data you need as protected member variables in the constructor; don't read engine memory inside
createEventTable() if those pointers might have become stale.
Step 3 – Hook Callback Patterns (in LuaManager.cpp)
There are four main patterns for hook callbacks. All are static functions defined in LuaManager.cpp's anonymous scope, typically near related hooks.
Pattern A – __fastcall delegate (thiscall override)
Use when replacing a virtual/non-virtual thiscall method with a wrapper that calls the engine method that was already wrapped to fire the event internally.
void __fastcall OnMyEvent(TES3::SomeClass* obj, DWORD _UNUSED_, ) {
obj->myMethod();
}
The DWORD _UNUSED_ is required by __fastcall: MSVC passes the first argument in ecx (= this) and the second in edx (unused for thiscall). The dummy parameter absorbs edx.
Pattern B – Fire event after calling original
Use when the event observes what the engine did, rather than gating it.
void __fastcall OnMyEvent(TES3::SomeClass* obj, DWORD _UNUSED_) {
const auto TES3_OriginalFunction =
reinterpret_cast<void(__thiscall*)(TES3::SomeClass*)>(0x12345678);
TES3_OriginalFunction(obj);
if (event::MyEvent::getEventEnabled()) {
LuaManager::getInstance().getThreadSafeStateHandle()
.triggerEvent(new event::MyEvent(obj->reference));
}
}
Pattern C – Fire event before calling original (blockable)
Use when Lua should be able to prevent the engine action from happening.
signed char __cdecl OnMyEvent() {
if (event::MyEvent::getEventEnabled()) {
const auto stateHandle =
mwse::lua::LuaManager::getInstance().getThreadSafeStateHandle();
sol::object response = stateHandle.triggerEvent(new event::MyEvent());
if (response.get_type() == sol::type::table) {
sol::table eventData = response;
if (eventData.get_or("block", false)) {
return 0;
}
}
}
return reinterpret_cast<signed char(__cdecl*)()>(0xDEADBEEF)();
}
Pattern D – __declspec(naked) + __stdcall handler
Use when the hook site is in the middle of a function and registers need to be saved/restored, or when arguments are in unusual registers.
__declspec(naked) void patchMyHook() {
__asm {
push eax
push esi
push ebp
}
}
const size_t patchMyHook_size = 0x3;
void __stdcall OnMyHookHandler(
TES3::SomeClass* target, TES3::MobileActor* attacker, float value)
{
if (event::MyEvent::getEventEnabled()) {
auto& luaManager = mwse::lua::LuaManager::getInstance();
const auto stateHandle = luaManager.getThreadSafeStateHandle();
sol::table eventData =
stateHandle.triggerEvent(new event::MyEvent(target, attacker, value));
if (eventData.valid()) {
value = eventData["value"];
}
}
const auto TES3_OriginalFn =
reinterpret_cast<void(__thiscall*)(TES3::SomeClass*, float)>(0x12345678);
TES3_OriginalFn(target, value);
}
Then in hook(), install it as:
writePatchCodeUnprotected(ADDRESS, (BYTE*)&patchMyHook, patchMyHook_size);
genCallUnprotected(ADDRESS + offsetInPatch, reinterpret_cast<DWORD>(OnMyHookHandler));
Reading back mutable fields (calc events)
For "calc" events where Lua may change a value:
sol::table eventData = stateHandle.triggerEvent(new event::MyCalcEvent(value));
if (eventData.valid()) {
value = eventData.get_or("value", value);
}
Step 4 – Registration in LuaManager.cpp
4a – Add the #include
Find the block of event header includes (~line 200 in LuaManager.cpp, all #include "Lua*Event.h" lines) and add yours in lexicographic order with the others:
#include "LuaMyEvent.h"
4b – Install the hook in LuaManager::hook()
Inside the hook() function (near the end of LuaManager.cpp), add a genCallEnforced call:
genCallEnforced(0xCALLSITE, 0xORIGINAL_FN, reinterpret_cast<DWORD>(OnMyEvent));
genCallEnforced signature:
bool genCallEnforced(DWORD address, DWORD previousTo, DWORD to);
address – the address of the E8 CALL instruction to replace
previousTo – the address the original CALL pointed to (safety check; the patch is skipped if it doesn't match)
to – the address of your new callback
Multiple call sites pointing to the same original function each need their own genCallEnforced line. The same hook callback function can be reused.
Other patching utilities (from SharedSE/MemoryUtil.h):
| Function | Use |
|---|
genCallEnforced(addr, prev, to) | Replace a CALL instruction |
genJumpEnforced(addr, prev, to) | Replace a JMP instruction |
genNOPUnprotected(addr, size) | NOP out bytes |
overrideVirtualTableEnforced(vtable, offset, prev, to) | Patch a vtable slot |
writePatchCodeUnprotected(addr, bytes, size) | Write raw bytes (for naked patches) |
genCallUnprotected(addr, to) | Write a CALL without the safety check |
Finding hook addresses
Hook addresses (0xCALLSITE, 0xORIGINAL_FN) come from reverse-engineering the Morrowind engine binary. Tools:
- IDA Pro / Ghidra – disassemble
Morrowind.exe to find call sites
- x32dbg – dynamic analysis; set breakpoints to confirm execution paths
- Look at existing
genCallEnforced calls in LuaManager::hook() for nearby addresses as orientation points
Step 5 – Disableable Event Registration (MWSE/LuaDisableableEventManager.cpp)
The event needs to be registered in MWSE/LuaDisableableEventManager.cpp.
Step 6 – Event String Constant (misc/package/Data Files/MWSE/core/lib/tes3/event.lua)
The string constant of the event needs to be added to misc/package/Data Files/MWSE/core/lib/tes3/event.lua.
Step 7 – Autocomplete Definition (autocomplete/definitions/events/standard/<eventName>.lua)
Create the file at autocomplete/definitions/events/standard/myEvent.lua. The filename (minus .lua) becomes the Lua event name string used in event.register("myEvent", callback).
Full format
return {
type = "event",
description = [[
Multi-line description of when and why this event fires.
Supports Markdown and wiki-style links.
]],
related = { "otherRelatedEvent" },
eventData = {
["reference"] = {
type = "tes3reference",
readOnly = true,
description = "The reference involved in the event.",
},
["value"] = {
type = "number",
description = "A value that Lua scripts can modify.",
},
["source"] = {
type = "string",
description = "Where this event originated.",
readOnly = true,
},
},
filter = "reference",
blockable = true,
examples = {
["BasicUsage"] = {
title = "Listen for the event and print a message",
},
},
links = {
["xSomeFunction"] = "mwscript/functions/actor/xSomeFunction",
},
}
Field reference:
| Field | Required | Notes |
|---|
type | yes | Always "event" |
description | yes | Markdown description |
eventData | yes | Map of field name → {type, readOnly?, description} |
filter | no | Key in eventData used as the per-object filter |
blockable | no | true if returning false from a callback blocks the engine action |
related | no | Array of other event name strings |
examples | no | Map of example key → {title} |
links | no | Map of link name → doc path |
Type strings use MWSE named types: tes3reference, tes3mobileActor, tes3mobilePlayer, tes3spell, number, boolean, string, nil, etc. Browse autocomplete/definitions/namedTypes/ for available types.
Step 8 – Example Scripts
For each key in examples, create a Lua file at:
autocomplete/definitions/events/standard/<eventName>/<ExampleKey>.lua
The file is pure Lua – no wrapper, just code. Example:
local event = require("event")
local function onMyEvent(e)
mwse.log("myEvent fired! Reference: %s", e.reference.id)
end
event.register(tes3.event.myEvent, onMyEvent)
Step 9 – Rebuild Documentation
Docs are auto-generated — never edit docs/source/events/*.md by hand.
After editing the .lua definition files, regenerate:
cd autocomplete
lua builders/emmy.lua
lua builders/mkdocs.lua
Output: docs/source/events/<eventName>.md is created/updated automatically.
Complete Checklist
When adding a new event, verify all of the following:
Common Mistakes
- Missing
DWORD _UNUSED_ in __fastcall callbacks: the second parameter in __fastcall maps to edx, which thiscall doesn't use. Omitting the dummy causes argument misalignment.
- Calling
triggerEvent without checking getEventEnabled(): this fires the event every frame even if nobody is listening, causing a performance regression.
- Forgetting the
#include in LuaManager.cpp: the linker won't know about the event class and you'll get a link error or ODR problem.
- Editing generated docs directly: always edit the
.lua definition file, then rebuild.
- Storing raw TES3 pointers past the hook return: TES3 objects can be freed by the game at any time. Only store them in the event class long enough to populate the event table. Do not cache them in static variables between frames.
- Not including required TES3 headers in the
.cpp: include every TES3 type you use (e.g. TES3Reference.h, TES3MobileActor.h) to avoid incomplete-type errors.
- Wrong filter object: the
filter field in the autocomplete definition should match the m_EventFilter passed to ObjectFilteredEvent(...) in the constructor. These must be consistent so per-object filtering works correctly.