H7ModEngine development guide¶
This guide is for people working on the engine itself: the C++ DLL, the Lua runtime and bindings, and the bundled mods. If you want to write mods, read API.md instead.
- Setup
- Building and deploying
- The development loop
- Architecture
- Source tour
- Hooks in detail
- The Lua side
- Testing in game
- Finding things in the game
- Reverse-engineering notes
- Working with the game's Flash UI
- Extending the API
- Conventions
- CI
- Troubleshooting
Setup¶
Required
- Windows 10/11 x64.
- Visual Studio 2022 or the Build Tools for Visual Studio, with the C++ x64 build tools workload component.
build.batfinds any edition withvswhere. - Git. The repository uses a submodule for Lua, so clone with
--recursive, or rungit submodule update --initafter a normal clone. - Might & Magic Heroes VII v2.2.1 (Ubisoft Connect, 64-bit). The engine is only tested against this build.
Useful
- Git Bash:
tests/run_ingame.shis a bash script. - Python 3: the scripts in
re/. cdb.exe(Debugging Tools for Windows, part of the Windows SDK): static disassembly of the unpacked game image and attaching to the running game.- The decompiled UnrealScript sources of the game (
<Package>\Classes\*.uc, e.g. from the H7 SDK / UDK export). They are the fastest way to find the function you want to hook; see Finding things in the game.
Building and deploying¶
builds bin\dinput8.dll (and bin\dinput8.pdb). It compiles Lua 5.4.7 from third_party\lua as C++
(/TP /EHsc), so a Lua error raised inside engine code unwinds as a C++ exception through the C++ frames
instead of longjmp-ing over their destructors. The engine is C++20, /MT, linked with src\proxy\dinput8.def.
builds and then installs into the game folder:
| Source | Destination | Notes |
|---|---|---|
bin\dinput8.dll |
<Game>\Binaries\Win64\dinput8.dll |
replaced every time |
dist\H7ModEngine.ini |
<Game>\H7ModEngine\H7ModEngine.ini |
only if there is none yet: your local settings survive |
dist\mods\ModManager, dist\mods\DebugConsole |
<Game>\H7ModEngine\mods\... |
overwritten: edits you made in the game copy are lost. build.bat deploy examples also installs the example mods |
dist\lib\* |
<Game>\H7ModEngine\lib\ |
shared Lua libraries, if any |
The game folder defaults to N:\Ubisoft\games\Might and Magic Heroes VII; set H7_GAME_DIR to yours:
The DLL can't be replaced while the game runs (Windows keeps it loaded). Lua files can: see the next section.
The development loop¶
build.bat deploywith the game closed.- Start the game through Ubisoft Connect. Starting
MMH7Game-Win64-Shipping.exedirectly makes it exit and relaunch through Connect, so a short-lived first process is normal; tools that wait for the game must not treat that first exit as the end of the run. - Follow the log:
<Game>\H7ModEngine\logs\engine.log(the previous run is inengine.prev.log).
A healthy start looks like this:
H7ModEngine starting; game image 0000000140000000-0000000146B94000
Script loaded: 27743 names, 12792 objects
Calibrated: Struct{Super=78 Children=80 Size=88} Prop{...} Func{...} Class{CDO=1d4} Enum{Names=68}
Script functions: 18472, bound to ProcessInternal(game+266de40): 18471, unbound: 1, other: 0
Tick hook on Function Engine.GameViewportClient.PostRender: ok
Init complete; Lua starts on the next frame
Lua 5.4 ready (H7ModEngine 0.2.1)
Loaded mod HelloWorld 1.0 by H7ModEngine
Map: h7logoentry
Map: councilhub
- Change Lua without restarting:
- Mods hot-reload. Edit the game copy in
<Game>\H7ModEngine\mods\<Mod>\; within a second the engine unloads the mod (removing its hooks, handlers and timers) and loads it again. Copy the change back todist\mods\when it works, or edit indist\and copy over;deployoverwrites the game copies. - The exec folder runs a script once in the running game: drop
something.luainto<Game>\H7ModEngine\exec\. It runs on the next check (about once a second), its return values are logged asexec: => ..., and the file is renamed to.done(even when it fails, and overwriting an older.doneof the same name, so keep the source intests\and copy it in). Exec scripts use the global environment, not a mod's;_Gis shared with mods, which is handy for poking at a mod's state. - Only C++ changes need a restart.
The game pauses its main loop while its window is in the background: ticks, timers, hot reload and the exec folder stop until it is focused again. While it runs but is covered by other windows it keeps ticking but stops drawing.
Architecture¶
MMH7Game-Win64-Shipping.exe
└─ loads dinput8.dll (ours, from Binaries\Win64)
├─ proxy: forwards DirectInput exports to the system dinput8.dll
└─ InitThread
1. wait for GNames / GObjects (the exe is packed and unpacks itself first)
2. wait for script packages (Engine.Actor.Touch, MMH7Game.H7Creature exist)
3. Calibrate(): learn UObject/UStruct/UProperty/UFunction/UClass layouts from known objects
4. wait for the GameViewportClient, find UObject::ProcessInternal
5. hook Engine.GameViewportClient.PostRender ──► OnGameTick() every frame, on the game thread
├─ first tick: create the Lua state, load mods
├─ timers, tick event, mod toggles
└─ once a second: hot reload, map change, exec
Everything after step 5 runs on the game thread, inside the engine's own frame. Lua is single-threaded and never touched from the init thread.
No code patching. The executable is protected and packed, so the engine never writes to its code. It only
changes heap data the engine itself reads: UFunction::Func pointers, UFunction::Script bytecode arrays, and a
few engine objects it creates (a cloned native UFunction). This is why hooks are built from the engine's own
mechanisms (see Hooks in detail).
Discovery instead of offsets. Global tables are found by scanning the module for structures that look like
TArray<FNameEntry*> and TArray<UObject*> and checking them against known names. Field offsets are then
measured on objects whose values are known (for example, the size and parameter count of Engine.Actor.Touch).
The results are logged in the Calibrated: line. When calibration fails, the engine writes dumps\probe.txt
and stays out of the way.
Confirmed layout (v2.2.1, x64)¶
Engine structs are packed to 4 bytes: pointers are not always 8-aligned (for example UStruct::Script is at
0x8C). Scan with a step of 4.
| Type | Field | Offset |
|---|---|---|
| UObject | VfTable / ObjectFlags / InternalIndex / Outer / Name / Class / Archetype | 0x00 / 0x10 / 0x38 / 0x40 / 0x48 / 0x50 / 0x58 (size 0x60) |
| UField | Next | 0x60 |
| UStruct | SuperField / Children / PropertySize / Script | 0x78 / 0x80 / 0x88 / 0x8C (size 0xD0) |
| UFunction | FunctionFlags / iNative / FriendlyName / NumParms / ParmsSize / ReturnValueOffset / Func | 0xD0 / 0xD4 / 0xD8 / 0xE1 / 0xE2 / 0xE4 / 0xF0 (size 0xF8) |
| UProperty | ArrayDim / ElementSize / PropertyFlags / Offset | 0x68 / 0x6C / 0x70 / 0x8C (size 0xA8) |
| UBoolProperty | BitMask | 0xA8 |
| UEnum | Names (TArray<FName>) |
0x68 |
| UClass | ClassDefaultObject | 0x1D4 (calibrated) |
| FFrame | Node / Object / Code / Locals / OutParms | +0x14 / +0x1C / +0x24 / +0x2C / +0x3C |
Other facts: ObjectFlags 0x200 = class default object, 0x400 = archetype. ProcessEvent is vtable[70],
found at runtime by watching who calls a hooked event. The engine allocator is tbbmalloc: strings and arrays the
engine may free later are allocated with scalable_realloc (ue3::EngineRealloc).
Source tour¶
| Path | What it does |
|---|---|
src/proxy/ |
dinput8.def and proxy.cpp: export forwarding to the system DLL |
src/engine/dllmain.cpp |
DllMain and the init thread (the sequence above); finds the viewport and installs the tick hook |
src/engine/paths.* |
<Game>\H7ModEngine\ paths and H7ModEngine.ini access |
src/core/log.* |
The engine log (shared-write, since two game processes overlap briefly at launch) |
src/core/memory.* |
SEH-guarded reads/writes (mem::Read, mem::ReadOr), module bounds, region scans, .pdata function starts |
src/ue3/core.* |
GNames/GObjects discovery, names, object iteration, FindObject/FindClass |
src/ue3/layout.h |
The calibrated offsets (ue3::L) |
src/ue3/reflection.* |
Calibration; fields, properties, functions, enums, Describe(prop), EngineRealloc |
src/ue3/hooks.* |
ProcessEvent discovery and calls; script and native function hooks |
src/ue3/factory.* |
Creating objects: StaticConstructObject, FName(TCHAR*, FNAME_Add) and GetTransientPackage at fixed v2.2.1 addresses, each checked against its first bytes before use |
src/ue3/dumper.* |
names.txt, objects.txt, probe.txt and the unpacked image dump ([Dev] settings) |
src/lua/runtime.* |
Lua state, mod discovery/order/environments, hot reload, mod toggles, events, timers, saved data, exec folder |
src/lua/bindings.cpp |
The h7 table and the object/struct/array proxies: marshaling between Lua and engine memory, calls, hooks |
[Dev] settings in H7ModEngine.ini: DumpSDK=1 writes the object index modders use; DumpProbe=1 writes
raw layout snapshots (calibration work); DumpImage=1 writes the unpacked executable (~100 MB, for static
analysis).
Hooks in detail¶
UnrealScript functions are called two ways, and a hook has to catch both:
- C++ → script:
ProcessEvent(obj, fn, params)→fn->Func(for script functions,UObject::ProcessInternal). - Script → script: the VM calls
ProcessInternaldirectly;UFunction::Funcis never consulted.
So swapping Func only works for natives and C++-invoked events. Script functions get a bytecode trampoline
instead (ue3/hooks.cpp):
- The function's
Scriptarray is replaced by[original optional-parameter entries] 04 1C <fake native UFunction*> 16 04 0B 53.ProcessInternalsteps statements untilEX_Return(0x04), then evaluates one expression as the return value; that expression is anEX_FinalFunction(0x1C) call to a fake native: a clone ofCore.Object.Sin(final, native, iNative 0) whoseFuncisScriptHookNative. ScriptHookNativeruns the pre listeners (any can block), then runs the original body withProcessInternalon a copy of the frame whoseNodeis a shadowUFunctionholding the originalScriptand whoseCodepoints just past the optional-parameter entries, then runs the post listeners (which can replace the return value).- Optional parameters: the engine steps one entry per optional parameter at the start of
Scriptwhile binding the call, beforeProcessInternalruns, so those entries must stay at the front. An entry is eitherEX_DefaultParmValue(49 <u16 len> <expr> 15) or a loneEX_Nothing(0B) for a parameter without a default. The parser (DefaultParmBlocks) accepts exactly these and otherwise falls back to aFuncswap and logs it; it never guesses, because a wrong guess crashes the VM withUnknown code token. - Out parameters of script → script calls live in the caller's frame.
FFrame::OutParms(+0x3C) is a list of{UProperty*, PropAddr, Next}; hookargsresolve out parameters through it (hooks::OutParmAddr). - Native, latent and iterator functions use the
Funcswap. Hooks on natives see no arguments and can't block.
Several listeners can share one hooked function; they are removed with their mod. Opcode handlers are in
GNatives (game+0x2352220); disassemble with cdb -z <unpacked exe> -c "u <address>; q".
The Lua side¶
Proxies. Engine objects are full userdata (ObjRef) holding the UObject*; __index looks the name up as a
property or function of the object's class (cached per class). Structs (StructRef) and dynamic arrays
(ArrayRef) are live views into engine memory. Values that point into a hook's parameters carry a frame id and
become invalid when the hook returns. Function return values that are structs or arrays are copied to Lua
tables, because the parameter buffer is gone after the call.
Marshaling goes through PushElem (engine → Lua) and WriteElem (Lua → engine), keyed by property kind:
numbers, bools (bitmask), names (must already exist in the name table), strings (FString via the engine
allocator), objects (type-checked), structs (another struct or a table of fields), arrays (a table replaces the
contents), delegates ({object=, func=}).
Calls (CallUFunction): parameters are written into a zeroed buffer of ParmsSize, missing arguments stay
zero (not UnrealScript defaults), the call goes through ProcessEvent, then the return value and out
parameters are pushed, and strings and arrays left in the buffer are freed.
Mods (runtime.cpp): every folder in mods\ is discovered; enabled ones load alphabetically, then after
their load_after entries. Each mod runs main.lua in its own environment (globals stay in the mod, reads fall
through to the shared globals). Everything a mod registers — hooks, event handlers, timers — is tagged with the
mod's id and removed when it unloads. Callbacks run inside ModScope(mod), so anything they register belongs to
the same mod, and their log lines carry its name. h7.set_mod_enabled writes the ini and the load or unload
happens on the next tick, never inside the caller.
Errors inside callbacks are caught with a traceback (Report) and logged; the hook or handler stays
installed.
Testing in game¶
There are no offline unit tests: almost everything depends on the live engine. Tests are Lua scripts in tests\
that run through the exec folder and report to the log.
| Script | Run it | Checks |
|---|---|---|
selftest.lua |
at the main menu | lookups, reads/writes, structs, arrays (incl. writing them and array arguments), calls, hooks |
hooktest.lua |
at the main menu | pre-hook blocking, post-hook return override, arguments, script → script calls, unhook |
sigdump.lua |
any map | logs signatures of the gameplay functions the example mods use |
daytrace.lua |
adventure map | call-count tracer for ~117 calendar/combat functions: end a day, fight a battle, read which ran |
modwindow_autotest.lua |
main menu | drives the Mods window: open, enable/disable DailyGold, back, then checks the game's map selection was restored |
swfdump.lua |
main menu | copies every loaded Flash movie to dumps\swf\ |
menulab.lua, modlistlab.lua |
main menu | the experiments the Mods menu was built from |
Run one by copying it into exec\ and reading the log, e.g. a successful self-test ends with
[selftest] array argument OK first,2.0
[selftest] call function OK <player name>
exec: => selftest dispatched
[selftest] hook fired 154 times in 2s
tests/run_ingame.sh automates this and takes screenshots:
It copies the script into exec\, prints the log lines it produces, saves <dir>\<name>.png for every line
ending in SHOT <name> (via tests/shot.ps1, which captures the game window with PrintWindow, on any display)
and stops at a line ending in DONE. A test drives the game with h7.after steps, logs the state it checks,
and writes SHOT/DONE lines; see modwindow_autotest.lua. Screenshots only show a current frame while the
game window is visible (it stops drawing when covered), so assert on logged state and use pictures to look.
Before a pull request that touches the engine, run at least selftest.lua and hooktest.lua at the main menu,
plus a test for what you changed, and note the results in the PR.
Finding things in the game¶
- Search the object index. With
DumpSDK=1,dumps\objects.txtlists every class, function, property and object loaded at startup (Function MMH7Game.H7Calendar.NextDay, ...), andnames.txtevery name. - Read the decompiled UnrealScript. Grep the
.ucsources for the behaviour you want and follow callers; it is much faster than tracing at runtime. Prefer hooking script functions (bodies in the sources) over natives (native function), since native hooks get no arguments. - Ask the engine.
h7.signature("MMH7Game.H7Calendar.NextDay"),h7.functions(obj),h7.properties("MMH7Game.H7AdventureHero"),obj:get_class(); run them through the exec folder. - Trace. Copy
daytrace.luaand adapt its function list to see which candidates actually run, and how often, while you play.
Known good targets are listed in RESUME.md (new day, damage, combat XP, movement points).
Reverse-engineering notes¶
- The executable is packed (stub entry in
.text1,.xtextdecrypted in place at startup), so the file on disk is useless for static analysis. SetDumpImage=1, start the game once, and use<Game>\H7ModEngine\dumps\MMH7Game_unpacked.exe. Keep dumps out of git (re/dump/is ignored). cdb -z <unpacked exe> -c "u <address>; q"disassembles; addresses in the log are given asgame+<rva>.re/scripts/pe_scan.py <exe>: sections, anchor strings and UE3 native-function tables (execFoopairs).re/swfinfo.py <movie.swf> [--strings]: tags, exported symbols and AS3 classes with their instance and static members, for the Scaleform movies dumped bytests/swfdump.lua.- A Ghidra project may live in
re/ghidra_proj/(ignored). h7.dev(engine development only, not for mods):read(addr, n),write(addr, bytes),u32/u64(addr),alloc(n)(engine allocator) andgame_base(). SEH-guarded, so a bad address returnsnil/false. Combine withobj:get_address()andh7.properties(...).offsetto dump native records from Lua without rebuilding the DLL (tests/onlinelab.luafound the LAN session record this way).- Size native records from their destructor, not from a memory dump: the bytes after an object may be the next heap block. The session record looked like 24 bytes in a dump; its destructor touches up to +0x30 (0x38 bytes), and building it too small crashed the game when it was deleted.
- Crash reports: the game writes
Documents\My Games\Might & Magic Heroes VII\MMH7Game\Logs\Launch.log(stack addresses, low 32 bits ofgame+rva, plus engine frames by file and line whenbin\dinput8.pdbis present) and a minidump next to it (cdb -z <dmp> -c ".ecxr; k"). A crashed game can stay alive without a window and keepbin\dinput8.pdblocked (link error LNK1201): end the process. RESUME.mdis the running notebook: what was tried, what was verified in which in-game run, and why.
Working with the game's Flash UI¶
The menus are Scaleform (GFx) movies driven by UnrealScript controllers (H7MainMenuCntl, H7MapSelectCntl,
...). Lua reaches them through the controllers' GFxObject fields and GetObject, SetString, Invoke,
AttachMovie. Lessons from building the Mods menu (dist/mods/ModManager):
- Wrappers are garbage collected. The
GFxObjectreturned byGetObject/AttachMovieis a UObject that the engine frees when no UnrealScript variable holds it; the Flash clip stays. Look clips up by name each time. GetObjectreaches display objects and Unreal-created objects only. Members holding arrays or objects that ActionScript created (list rows, dictionaries, static class members) come backnil.- Clicks: a button added with
AttachMoviecan call a controller function throughAddEventListener("CLIK_click", {object = cntl, func = "Options"}), but the event data arrives empty; hook the function and checkmouseX/mouseYagainst your button. - Reusing a game window is often better than building one: fill it through the same calls its controller
makes (
SetObject("mPolledMaps", ...),Invoke("AddMaps")) and hook the controller functions its buttons call. Remember every value you change and put it back when the window closes, since the game reuses the window. - Translations are cached per movie for the whole session (
Loca.mLocaDictionary, filled throughH7FlashMovieCntl.FlashLocalize). Overriding a key changes it everywhere; values in ALL CAPS are treated as keys and shown as[VALUE]when unknown. H7Label.htmlTextresets the label's font size; prefertext.
Extending the API¶
- Implement the function in
src/lua/bindings.cpp(object and engine access) orsrc/lua/runtime.cpp(mods, events, timers) and add it to the registration table at the end of the file ({"name", H_name}). Object helper methods (obj:get_name()style) go in theH7ObjectMethodstable; they are snake_case so they never collide with UnrealScript members. - Read engine memory with
mem::Read/mem::ReadOr(SEH-guarded), never raw pointers; check objects withCheckValidObject. Report misuse withluaL_errorand a message that says what was expected. - Allocate anything the engine may free with
ue3::EngineRealloc. - Document it in
docs/API.md(the section and the reference table) and in the full reference underdocs/reference/(signature, parameters, return values, errors), and add a check totests/selftest.lua.
Conventions¶
- C++20, 4-space indent, lines up to about 120 columns. Comments explain why (what the engine does, what was verified), not what the next line does. Engine facts that were measured in game say so.
- Names:
PascalCasefunctions and types,g_globals,kconstants; Lua API names are lowercase (h7.find_all,obj:get_name()), UnrealScript names keep their own spelling. - Lua (mods and tests): 4-space indent,
localeverything, oneh7.logprefix per mod or test. - Keep
docs/API.mdanddocs/reference/in step with the API, andRESUME.mdwith findings that cost time to learn. - Commits: one topic each, imperative subject line, body explaining what and why. Work on a branch and open a
pull request into
main; CI must be green. - Line endings:
.batfiles are CRLF and.shfiles LF (.gitattributes); cmd and bash each break on the other.
CI¶
.github/workflows/build.yml runs on pushes to main and on pull requests: it checks out with submodules on
windows-latest, runs build.bat, and uploads an H7ModEngine artifact laid out like the game folder:
That artifact is for testing main; players install releases.
.github/workflows/release.yml runs when a GitHub release is published (or by Run workflow for an existing
tag). It builds the tag and attaches to the release:
dinput8.dll, the whole install:build.batembedsdist/H7ModEngine.ini,dist/mods/ModManager/*.luaanddist/mods/DebugConsole/*.luaas resources, andsrc/engine/bundle.cppwrites them into<Game>\H7ModEngine\at startup (the ini only if it is missing; the two mods when missing or when the DLL carries a different version than it last installed, tracked indata\bundled.txt, so a newer DLL updates the Mods menu but edits to the game copy survive restarts).H7ModEngine-examples.zip, the other mods indist/mods/plusAPI.md, laid out like the game folder.
The version string is kVersion in src/lua/runtime.cpp; bump it before tagging. The release job fails unless
the tag is v<kVersion> (or <kVersion>).
.github/workflows/docs.yml publishes the documentation site (https://whaileee.github.io/H7ModEngine/) to
GitHub Pages with MkDocs Material. The site is built from the
docs/ folder of a release tag, so it always documents a released version:
- Publishing a release (not a prerelease) builds the docs at its tag and deploys them.
- Run workflow on the Docs workflow rebuilds them for a tag you enter (default: the latest release).
- Pull requests that change
docs/,mkdocs.ymlortools/docs/build the site with--strict, so broken links and anchors fail the check.
The navigation is in mkdocs.yml; add new pages there.
The Game classes section (sdk/, one page per class) is generated at build time by tools/docs/sdk_pages.py
from tools/docs/sdk/*.json. That JSON holds the declarations (no function bodies) of every class the game loads,
extracted from the decompiled UnrealScript and checked against the running game's object dump. Regenerate it when
the targeted game version changes, and commit it:
python tools/docs/extract_sdk.py --src N:/UDK/Custom/Development/Src --dump "<Game>/H7ModEngine/dumps/objects.txt"
tools/docs/hooks.py puts the release tag in the site
title and turns links that leave docs/ (such as ../src/lua/bindings.cpp) into GitHub links at that tag.
To preview locally:
One-time repository setup: Settings → Pages → Source: GitHub Actions, and in Settings → Environments →
github-pages, allow deployments from tags (add a rule for v*), since release runs deploy from the tag.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
No logs\engine.log at all |
dinput8.dll not in Binaries\Win64\, or another dinput8.dll there (e.g. a dangling link) |
Log stops at Timed out waiting for ... |
A different game version, or the game didn't finish loading |
Engine layout calibration failed |
Unknown build: send dumps\probe.txt |
Init complete but no Lua ... ready |
The tick hook doesn't fire (window minimized or in the background) |
| Nothing happens for a while, then it all runs | The game was in the background and paused its loop |
Unknown code token crash in a hooked function |
The trampoline misread the function's optional-parameter entries: report the function and its signature |
engine object ... is no longer valid |
A cached Flash GFxObject was garbage collected: look it up again |
| Mod changes don't take effect | You edited dist\ but not the game copy (or ran deploy, which overwrote the game copy) |