Important
The 32-bit build replaces the DLL of any earlier WarcraftXL framework installed beside the client: the file name, the export and the patch section are the same, so only one can be present. Move the old one aside before copying this one over.
One codebase, built twice, so a mod written against the SDK runs on both the client you own and the client we compile.
bin/x86/WarcraftXL.dll |
bin/x64/WarcraftXL.dll |
|
|---|---|---|
| loads into | the stock Wow.exe 3.3.5a, build 12340 |
WarcraftXL/client, the recompiled client |
| gets in by | an import and an entry trampoline the patcher adds | the client calling WarcraftXLInit |
| attaches to the game by | a detour at a resolved address | the client's own source calling in |
Above that line the two are the same logic: the mod host, manifests and admission, the event bus, the lifecycle, the log.
- Loads before the client does. The patcher points the executable's entry at a 19-byte
trampoline, so mods load and
WXL_STAGE_EARLYruns on the main thread before the client's first instruction β early enough to change what the client reads while starting. - Chained detours. Several mods may hook the same function;
originalwalks the chain and any link can detach, including the first, while calls are in flight. - Byte patches that refuse to be wrong.
wxl.patchtakes the bytes it expects to find and declines, with a log line, when memory says otherwise. Every write is journalled and reversible. - Addresses that fail at compile time. Nothing is hardcoded: sites resolve by name against tables generated from the decompiled client, so a renamed function breaks the build instead of the game.
- Seams declared once. Every function the runtime attaches to is one row in
include/wxl/seams/, whose body is written once: the x86 build generates a detour around the function from the row, the x64 build exports the entry points for the recompiled client to call from inside it. A row is observing (Before,After, or only one of them) or gated (Gate, which can stop the call and rewrite its arguments). - An unbuffered log. One write per line, so a crash keeps everything up to it.
How the pieces fit, and which files a new seam, fact or event touches: ARCHITECTURE.md.
- Overview
- Building
- Installing
- What this runtime publishes
- The mods it ships
- Layout
- Troubleshooting
- License
- Visual Studio 2022 or newer, with the x86 and x64 toolsets
- CMake 3.21 or newer
- A checkout of
WarcraftXL/sdkbeside this one, or-DWXL_SDK_DIR=<path>
cmake -S . -B build -G "Visual Studio 18 2026"
cmake --build build --config ReleaseNo -A: the top level is a driver that configures the real build once per architecture and
collects the result in build/dist/:
build/dist/
bin/x86/WarcraftXL.dll
bin/x86/wxl-core-patcher.exe
bin/x64/WarcraftXL.dll
Mods/wxl.glue-unlock/{mod.json,bin/x86/glue-unlock.dll}
Mods/wxl.named-archives/{mod.json,bin/x86/named-archives.dll}
cmake -S . -B build/x86 -G "Visual Studio 18 2026" -A Win32 -DWXL_CORE_ARCH=win-x86WXL_CORE_TARGETS selects which architectures the driver builds; WXL_CORE_DIST moves the
collection directory.
- Close the game.
- Copy
build/dist/bin/x86/WarcraftXL.dllnext toWow.exe. - Patch the executable, once:
It writes
wxl-core-patcher.exe Wow.exe
Wow.exe.origbeside it and is idempotent: run it again and it says so, byte for byte. Pass a second path to write elsewhere and leave the input alone. - Copy the mods you want into
Mods/, each as its own folder with amod.jsonandbin/x86/<name>.dll. - Launch. The log is
Logs/WarcraftXL.log, beside the executable.
A client already patched by an earlier WarcraftXL only needs the trampoline added; the patcher
detects that and says upgraded. Without the trampoline the runtime still works, on its own
thread, and says so in the log.
The client builds this directory as a subdirectory of its own tree (-DWXL_CORE_DIR when it is
not beside it), links the DLL and calls WarcraftXLInit from
include/wxl/core/init.h, handing over the accessors of
wxl_core_init: one per row of include/wxl/core/binding.def, the
client binding, which client/WarcraftXL/Binding.cpp defines against the client's own classes.
Its functions call the seams' entry points from
include/wxl/seams/seams.h. Mods are read from Mods/ beside the
executable, with bin/x64/ binaries.
What a mod can reach today, by build:
| x86 | x64 | |
|---|---|---|
wxl.hook (named points) |
yes: the seams and the points of src/hook/Points.def, detourable |
no: a seam is a call the client makes, not a function a detour can replace |
wxl.hook.raw (raw addresses) |
yes | never: no fixed addresses in a program we compile |
wxl.patch (byte patches) |
yes | never: nothing to patch in a program we compile |
wxl.object (world objects) |
yes | yes |
wxl.script (the client's Lua) |
yes: the client's own lua_* and FrameScript_* called at resolved addresses |
yes: through the script rows of the binding |
wxl.storage |
yes: exists, size, read, open/read_at/close, mount_archive | the same |
wxl.data |
yes: a client database served from a mod's own file | the same |
wxl.net, wxl.ui |
not yet; their events are raised, the service tables are not published | the same |
| events raised | the ones the seam table derives (below) | the same list |
| lifecycle stages | EARLY, STORAGE, GRAPHICS, SCRIPT, READY, SHUTDOWN, then the unload |
the same |
The client runs one Lua state at a time and rebuilds it at every change of interface: the login
glue and the game each get a fresh context, and both are created and destroyed more than once in
a session (log in, log out, log in again, ReloadUI). ScriptVmReady is raised from
FrameScript.Initialize with context set to WXL_SCRIPT_CONTEXT_GAME when
CGGameUI::m_loggingIn or CGGameUI::m_initialized is set at that moment and to
WXL_SCRIPT_CONTEXT_GLUE otherwise; ScriptVmClosing is raised from FrameScript.Destroy
naming the same context. Two contexts the client creates and flushes before any interface loads
-- the one InitializeClient makes at process start, and the one CGGameUI::ShutdownGame makes
on logout -- are announced as glue.
Nothing registered survives a context. The Lua state is closed with it, which takes every
global and every event listener. The service forgets what it was told at ScriptVmClosing and
refuses register_function, register_event, fire_event and run with WXL_E_FAILED until
the next ScriptVmReady. A mod registers its functions and declares its events from a
ScriptVmReady handler, every time, and keeps nothing it got from the previous context.
Every call from Lua into a registered function runs under the crash guard (below): a function
that faults answers the script with an error naming the fault, and its mod is disabled.
register_event works by installing the client's own event table again with the declared names
appended behind it, so each has an index fire_event can signal through FrameScript_SignalEvent;
on the stock client that is the same FrameScript_CreateEvents call the client makes, reissued.
Every call from the runtime into a mod -- wxl_mod_query, wxl_mod_load, wxl_mod_stage,
wxl_mod_unload, every event handler and every script function -- runs under a structured
exception handler (sdk/host/guard.cpp, MSVC only; a plain call elsewhere). A mod whose code
faults is logged with its id, what it was doing and the fault, and disabled: its event
subscriptions are dropped (the rest of the dispatch in progress included), its remaining stages
and its unload are skipped, the script functions it registered are taken back, and its binary is
left mapped rather than unloaded. One that faults in wxl_mod_query or wxl_mod_load is refused
instead. The client goes on.
What the guard does not do: it does not unwind the faulting frames (destructors do not run, locks stay held), it does not withdraw the services the mod published (another mod may hold the pointer, and calls into them will now reach code that faulted once), and it does not repair anything the mod wrote before it faulted, in its own state or in the client's. Disabling a mod is a judgement that the rest of the process is more likely intact than not; it is not a proof.
A mod that requires a service this runtime does not publish is refused before it loads, with the service and version named in the log. One that merely asks gets nothing and carries on.
attach is refused on x64 because a seam there is two calls the client makes from inside a
function it compiled, not a function a detour can replace: there is no original to hand the mod
and nothing it could suppress. A mod that wants to sit at a seam subscribes to the events the seam
raises; one that needs a detour requires wxl.hook or wxl.hook.raw and is refused on x64 at the door, before it loads.
One table per domain under include/wxl/seams/, one row per function.
A row is one of four shapes:
| Shape | Entry points | What it can do |
|---|---|---|
WXL_SEAM_BEFORE |
Before |
watch the call start |
WXL_SEAM_AFTER |
After |
watch the call finish |
WXL_SEAM |
Before, After |
both |
WXL_SEAM_GATE |
Gate |
watch the call start, stop it and answer in its place, and rewrite what the function is given |
A row declares only the sides its body uses: on the recompiled client each side is a call in the
function's source, and an After is one at every return.
A gated row is used only where a promise of the contract needs it -- suppressing a message, redirecting a file -- because it costs the recompiled client a real decision point in its own source. Everything else observes.
| Point | Function | Raises |
|---|---|---|
World.Preload |
World::Preload(const wxl_vec3* position, int32_t show_loading) |
WorldLeave before, WorldEnter after, only when show_loading is set: the far-sight, cinematic and game-object preloads reach the same function with it clear |
Event.DispatchUpdate |
IEvtDispatchUpdate(void* context, uint32_t curr_time, float elapsed_sec) (regparm: EDI) |
Update before |
ObjectMgr.InitObject |
InitObject(void* object, const void* create, uint32_t time) (regparm: ESI, EAX, ECX) |
ObjectCreate after |
ObjectMgr.ObjDelete |
ObjDelete(void* object) (regparm: EAX) |
ObjectDestroy before |
ObjectMgr.DestroyRemovedObject |
DestroyRemovedObject(void* object) (regparm: ESI) |
ObjectDestroy before, for an object still tracked |
Unit.OnDeath |
CGUnit_C::OnDeath() |
UnitDeath after |
Unit.OnAuraAdded, Unit.OnAuraRemoved |
CGUnit_C::OnAuraAdded/OnAuraRemoved(uint32_t index, uint8_t a3, void* aura, int32_t spell_id) |
UnitAuraChanged after |
Player.UpdateInventoryComponent |
CGPlayer_C::UpdateInventoryComponent(int32_t slot, const void* old_item) |
EquipmentChanged after, when the item id moved |
GameUI.SendTarget, GameUI.ClearTarget |
CGGameUI::SendTarget(uint64_t guid), CGGameUI::ClearTarget(uint64_t guid, int32_t a2) |
TargetChanged after, once per change of m_lockedTarget |
MapArea.PrepareChunk |
CMapArea::PrepareChunk(int32_t chunk_x, int32_t chunk_y) |
TerrainChunkBuild before |
FrameScript.Initialize |
FrameScript_Initialize(int32_t profile) |
ScriptVmReady after, with the context CGGameUI's state implies and the new lua_State* |
FrameScript.Destroy |
FrameScript_Destroy() |
ScriptVmClosing before, naming the context Initialize announced |
FrameScript.CreateEvents |
FrameScript_CreateEvents(const char** names, uint32_t count) |
nothing: after, the installed table is handed to wxl.script, which reissues it with the declared events appended |
NetClient.ProcessMessage (gated) |
NetClient::ProcessMessage(uint32_t time_received, CDataStore* message, int32_t) |
MessageReceived at the gate; suppressing it hides the message from the client entirely, which is how a mod serves an opcode the client does not know |
NetClient.Send (gated) |
NetClient::Send(CDataStore* message) |
MessageSent at the gate; suppressing it stops the message being sent |
NetClient.HandleConnect |
NetClient::HandleConnect() |
Connected after, once the client holds itself connected |
NetClient.HandleDisconnect |
NetClient::HandleDisconnect() |
Disconnected before, while the connection is still the one being lost |
Input.WindowProc (gated) |
OsWindowProc(void* window, uint32_t message, uintptr_t wparam, intptr_t lparam) |
Input at the gate; writing handled answers 0 and the client's own procedure never sees the message |
Storage.OpenFile (gated) |
SFile::OpenEx(SArchive*, const char* name, uint32_t flags, SFileImpl** result) |
FileOpen at the gate; a path written into redirect is what the client opens instead. FileOpened is not raised: it carries the result, and this function has a dozen returns |
Sound.PlaySoundKit (gated) |
SI2::PlaySoundKit(int32_t kit, ...) |
SoundPlay at the gate; suppressing it keeps the sound from starting. Both of the client's overloads of this name end here, so a sound named by path arrives with the id its name resolved to |
Client.Destroy |
WowClientDestroy() |
nothing, but it is where WXL_STAGE_SHUTDOWN and the unload run: the last moment at which a mod can be told anything |
ConsoleDevice.Initialize |
ConsoleDeviceInitialize(const char* title, bool, bool) |
nothing, but its return is WXL_STAGE_GRAPHICS: the device is made after the first database is read and before the script context exists, which is the order the stages promise |
Scrn.OnPaint |
OnPaint(const void* data, void* param), the paint handler |
FrameBegin before, only when the device has a context and the window can be seen -- the test the paint makes itself |
Gx.ScenePresent |
GxScenePresent() |
FrameEnd before, for the frame Scrn.OnPaint opened and only for it: the loading screen presents frames of its own, which raise nothing. The last draw list is flushed after it |
SimpleTop.OnLayerRender |
CSimpleTop::OnLayerRender() |
UiRenderEnd after: every stratum of the interface is drawn, the world inside it |
WorldFrame.RenderWorld |
CGWorldFrame::RenderWorld(void* world_frame) |
WorldRenderBegin before, WorldRenderEnd after. Run from inside the interface's batches, before anything of the interface over it; not reached where there is no world |
Gx.ReleaseD3dResources |
CGxDeviceD3d::IReleaseD3dResources(int32_t) |
SwapchainLost before, with the size about to go: at both of the D3D9 device's resets, and when it is destroyed. A client on another device does not reach it |
Gx.NotifyOnDeviceRestored |
CGxDevice::NotifyOnDeviceRestored() |
SwapchainRestored before, with the size the device came back at |
Spell.Packet_Group_30 |
Packet_Group_30(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg), the handler of SMSG_SPELL_START and SMSG_SPELL_GO |
SpellCastStart or SpellCastGo before, by the opcode, read off the message before the client parses it: the caster, the object target, the cast time or the hit and miss counts |
Spell.Packet_SMSG_SPELL_FAILURE |
Packet_SMSG_SPELL_FAILURE(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg) |
SpellCastInterrupted before, for any caster in view, with the reason the server sent |
Spell.SpellFailed |
Spell_C_SpellFailed(PendingSpellCast* cast, const SpellRec* spell, int32_t reason, int32_t misc1, int32_t misc2, bool reset_cooldown) |
SpellCastFailed before: the local player's cast refused by the server or by the client's own checks |
Spell.CastSpell (gated) |
Spell_C_CastSpell(CGUnit_C* caster, int32_t spell_id, const CGItem_C* item, uint64_t target, PendingSpellCast* cast, bool confirmed) |
SpellCastRequest at the gate; refusing it answers false and nothing is sent |
Item.UseItem (gated) |
CGItem_C::UseItem(const uint64_t* target, int32_t confirmed) |
ItemUseRequest at the gate; refusing it answers 0 and the item's spell is never asked for |
FrameScript.vSignalEvent |
vFrameScript_SignalEvent(uint32_t index, const char* format, va_list args) |
UiEvent before, when a mod listens: the event's name, its format, and its arguments rendered as text, one entry per % |
FrameScript.HandleError |
FrameScript_HandleError(lua_State* state), the error handler of every protected call |
ScriptError before, with the error value as the VM left it |
Spell.Packet_MSG_CHANNEL_START |
Packet_MSG_CHANNEL_START(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg) |
SpellChannelStart before, for a channel of some length |
Spell.Packet_MSG_CHANNEL_UPDATE |
Packet_MSG_CHANNEL_UPDATE(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg) |
SpellChannelUpdate before, or SpellChannelStop when no time is left, with the channel's spell as the client holds it on the unit |
Spell.Packet_SMSG_UNIT_SPELLCAST_START |
Packet_SMSG_UNIT_SPELLCAST_START(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg), the handler of opcode 0x14D |
SpellCastResume before: a cast already running when its caster came into view |
Spell.Packet_SMSG_SPELL_DELAYED |
Packet_SMSG_SPELL_DELAYED(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg) |
SpellCastDelayed before, with the cast the client holds on the unit |
Spell.Packet_SMSG_SPELL_COOLDOWN, Spell.Packet_SMSG_ITEM_COOLDOWN |
Packet_SMSG_SPELL_COOLDOWN(...), Packet_SMSG_ITEM_COOLDOWN(...), the four handler arguments |
SpellCooldown before, once per spell of the message, or once for the item |
Spell.Packet_Group_31 |
Packet_Group_31(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg), the handler of SMSG_COOLDOWN_EVENT, SMSG_CLEAR_COOLDOWN and SMSG_MODIFY_COOLDOWN |
SpellCooldownChanged before, by the opcode |
Spell.CancelSpell |
Spell_C_CancelSpell(PendingSpellCast* cast, bool stop_recast, bool notify_server, int32_t reason), where every cancel of the player's own cast ends |
SpellCastCancel before, with the cast's caster, spell and count, the reason, and whether the server may be told |
Combat.Packet_Group_29 |
Packet_Group_29(void* param, uint32_t msg_id, uint32_t time, CDataStore* msg), the handler of SMSG_ATTACKSTART, SMSG_ATTACKSTOP, SMSG_ATTACKERSTATEUPDATE and SMSG_ENVIRONMENTALDAMAGELOG |
CombatAttackStart, CombatAttackStop, CombatDamage or CombatMiss before, by the opcode and whether the round landed |
Combat.HandleSpellLogMiss |
CombatLog::HandleSpellLogMiss(CDataStore* msg, int32_t time_offset), where SMSG_SPELLLOGMISS ends on its own or inside SMSG_COMBAT_LOG_MULTIPLE |
CombatMiss before, once per target |
Combat.HandleSpellNonMeleeDamageLog |
CombatLog::HandleSpellNonMeleeDamageLog(CDataStore* msg, int32_t time_offset) |
CombatDamage before |
Combat.HandleSpellHealLog |
CombatLog::HandleSpellHealLog(CDataStore* msg, int32_t time_offset) |
CombatHeal before |
Combat.HandlePeriodicAuraLog |
CombatLog::HandlePeriodicAuraLog(CDataStore* msg, int32_t time_offset) |
CombatDamage or CombatHeal before, per damage or heal entry, up to where the client stops |
The frame's slots -- WorldRenderBegin, WorldRenderEnd, UiRenderEnd -- carry the index of the
frame they are drawn in, and are not raised outside one. FrameBegin and FrameEnd come in pairs:
the client presents more often than it paints a frame, so the runtime keeps the pairing rather
than trusting the client to.
A seam is named after the function it attaches to, never after what it means; the name is part of the ABI and is appended, never renamed or dropped.
Two mods that do what a patcher used to do, so the patcher only has to bootstrap. Both are
32-bit only and both declare wxl.patch as required, so any other runtime refuses them cleanly.
| Mod | What it does |
|---|---|
wxl.glue-unlock |
bypasses the Lua/XML signature check, so custom interface code loads |
wxl.named-archives |
widens the client's patch-?.MPQ scan to patch-*.MPQ, so archives and folders with real names are mounted |
Both read memory before writing: if the bytes are already in the patched state they log
already present and do nothing.
include/wxl/core/init.h the 64-bit entry point, for the client to include
include/wxl/core/binding/ the client binding: the facts read out of the client, one .def per domain
include/wxl/seams/ the seam table (one .def per domain) and seams.h, their entry points
include/wxl/offsets/ site.hpp, layout.hpp and the generated tables
src/runtime/ host bring-up, stages, frames, the per-architecture attachment
src/binding/ the binding realised: memory reads (x86), the client's accessors (x64)
src/seams/ the seam bodies, one file per domain (both); the x86 detours and thunks
src/objects/ wxl.object over the binding
src/services/ wxl.script, wxl.storage, wxl.data (both); wxl.hook, wxl.hook.raw, wxl.patch (x86)
src/hook/ the detour engine and the named points (x86)
src/patcher/ PE import and entry trampoline (x86)
src/common/ the log
tests/seam_harness.cpp every seam body against a fake client; --bench times the hot paths
mods/ the mods above (x86)
deps/minhook/ MinHook, BSD-2-Clause, unmodified (x86)
tools/seams/ check the seam table against the fork and against Wow.exe
tools/offsets/, tools/layouts/ regenerate the tables from the decompiled client
| Symptom | Cause |
|---|---|
The game does not start, 0xc0000142 |
WarcraftXL.dll is missing beside the executable, or is the wrong architecture |
| No log at all | the executable carries no WarcraftXL import: run the patcher |
the executable was patched by an older patcher |
it has the import but no trampoline: run the patcher again |
| A mod is missing from the log | wrong folder shape; it needs Mods/<id>/mod.json and Mods/<id>/bin/<arch>/<name>.dll |
refused β¦ requires service β¦ |
the mod needs something this build does not publish; see the table above |
not Wow.exe build 12340 |
the addresses do not apply to this executable; named points and seams stay off |
attach 'World.Preload': refused (x64) |
a seam cannot be detoured on the recompiled client; subscribe to its events |
the client and this runtime were built from different binding tables (x64) |
the client and WarcraftXL.dll come from different trees; the line names the first row of binding.def that differs. Rebuild both from one tree |
the client handed no <row> accessor; what reads it answers absent (x64) |
the client was built against an older init.h than this runtime; the row of binding.def it names answers its absent value (map -1, no position, ...) until the client passes one |
GPLv3. See LICENSE.
MinHook, under deps/minhook/, is BSD-2-Clause and keeps its own license file.