How Helldivers 2 mods work
How can a program the developer never wrote run inside the game? How does it know where the game keeps its data? And how do the modding tools each work? This document follows the process of building the Hive Lord HUD from scratch as its main thread, with one principle per chapter. At the end of each chapter you'll see “where the HUD is at this point”.
The map: a mod, from idea to your screen
Start with the big picture. A mod's life has two halves: it gets built on your PC (build time), and it gets run inside the game (runtime). Each box in the diagram is labelled with the chapter that covers it.
How each chapter is laid out
- This chapter answers: one question. The whole chapter answers it.
- New concepts: terms that appear for the first time in this chapter, five or six at most, explained before they're used.
- Main text: written as “Because …, …” wherever possible, so you can see how the ideas connect.
- Hive Lord HUD at this point: what this principle actually meant while building the HUD.
- In the source: the lines of the real mod that implement this chapter, with a link to the annotated source page.
- Key points and Check yourself: after reading, try to answer first, then expand to see the answer. Anything you can't answer is a connection you haven't built yet.
Source/ folder in the zip is the full source code). This document only covers how mods work.What the game itself is
- Machine code, compiling
- Bytes the CPU can run directly; the process of turning human-written code into machine code.
- .exe, .dll, process
- Files that hold machine code (the main program, libraries); the “living thing” a program becomes once it's running.
- Engine
- The shared foundation games are built on: graphics, physics, file loading, running scripts.
- Resource, archive
- The files a game uses, such as models, textures, sounds and scripts, and the big files they're packed into.
- Hash
- A formula that turns a piece of text into a fixed-length number.
The code: compiled machine code
Arrowhead's engineers write the game in C++ (that's the source code), then a compiler translates it into machine code the CPU can run directly. During that translation, variable names, function names and comments all disappear, leaving only a string of bytes. Because all we have is the machine code, if you want to know what the game is doing, you have to work backwards from the machine code (Chapter 10).
Machine code lives in two kinds of files: the .exe is the main program, and a .dll is a library the main program loads while it runs. In Helldivers 2 the work is split like this:
| File | What it is |
|---|---|
bin/helldivers2.exe | The engine: Autodesk Stingray (formerly Bitsquid). Handles graphics, physics, file loading and running Lua scripts. Many games share this same engine. |
bin/game.dll | Game logic: damage, AI, network sync. Written in C++, and protected by encryption (Chapter 10). |
bin/lua51.dll | LuaJIT: the program that runs Lua scripts (Chapter 2). |
data/ | Resource files: everything else. |
When you hit “Play”, Windows starts helldivers2.exe and it becomes a process, which then loads game.dll and lua51.dll into itself. From that moment on, the engine, the game logic and the scripts all run inside the same process.
Resources: finding files by hash
data/ is full of files with names like 9ba626afa44a3aa3. These are archives, each holding many resources. When the engine wants a resource, it doesn't look it up by a text name. It uses two numbers instead: the type hash and the name hash.
A hash is a formula: the same text always produces the same number, but you can't work back from the number to the text. HD2 uses a formula called murmur64, which produces a 64-bit number. For example, the Hive Lord's model resource is called content/fac_bugs/cha_hive_lord/cha_hive_lord, and its hash is 0xD465D9C7F77A07CB; the hash of the type “lua” is 0xA14E8DFA2CD117E2. Because the files only store the numbers, you can't see any names just by opening an archive; you need a name list someone else has compiled to translate them back (Chapter 8).
The key that makes mods possible: patch files override the originals
There's a special kind of archive: files whose names end in .patch_0, .patch_1, and so on, called patch files. When the engine reads a resource, if a patch file contains a resource with the same type and the same name, it uses the version in the patch file and overrides the original. This mechanism was originally meant for game updates.
Because the engine dutifully reads any patch file placed in data/, and never checks whether its contents have been changed, anyone can make a patch file that swaps a resource for their own version. Every HD2 mod (cosmetic swaps and feature mods alike) is built on this.
- The game is compiled machine code; we don't have the source code or the names.
- Resources are found by the hash of “type + name”; you can't see text names in the files.
- A resource with the same name in a patch file overrides the original, and the game doesn't check. This is the root reason mods can exist.
Check yourself: Why does dropping a .dds texture file straight into data/ do nothing at all?
The engine doesn't recognise loose files; it only looks up resources inside archives, by “type hash + name hash”. To make the game use your texture, you have to pack it into a patch archive with exactly the same name hash as the original, so it overrides the original.
Check yourself: If the game checked a digital signature when reading patch files, could mods still exist?
No (at least not this way). A signature check would notice the patch file isn't official and refuse to load it. HD2 doesn't do this check, and that's the only reason mods have a way in.
How the game runs scripts every frame
- Frame, game loop
- The game endlessly repeats “update → draw”; each repetition is called a frame.
- Function
- A named piece of code that can be called.
- Scripting language, Lua, LuaJIT
- Code that exists as text and is only understood when it runs; the one HD2 uses; its high-speed version.
- Global variable
- A variable the whole script environment can see and change.
- update()
- The Lua function the engine calls every frame.
The game loop
The game keeps repeating: read your input → update the world (enemies move, bullets fly) → draw the screen. Each repetition is called a frame; 60 FPS means 60 frames per second. Everything that “happens over time” happens inside this loop.
The scripting layer inside the engine
The Stingray engine has a built-in Lua runtime, using the LuaJIT version (its syntax is the same as Lua 5.1). Lua is a scripting language: the code exists as plain text, and LuaJIT only reads and runs it at runtime, with no compiling needed beforehand. The engine was originally designed so that most game logic would be written in Lua; but HD2 put almost all of its logic into the C++ game.dll, leaving Lua as just a thin layer of “glue”:
- When the engine starts, it loads a few Lua scripts (these are also resources in
data/, with type lua). - Every frame, the engine calls a Lua function named
update, plus a few other fixed entry points such asrenderandshutdown. - The engine exposes a set of features Lua can call (for example, listing the objects in the scene or drawing things on screen), called the
stingray.*API (Chapter 4).
We measured it: the game's own Lua update takes almost no time per frame (close to 0 ms). In other words, the game itself barely uses this Lua environment, but it is always on and gets called every frame.
Why this is the way in for mods
Lua functions are stored in global variables, and global variables can be changed. Because every frame the engine “goes to the global variable update, takes the function there and calls it”, as soon as someone replaces update with their own function, the engine calls the new one on the next frame:
local original = update -- first, save the game's original update
update = function(...) -- replace it with our function
do_my_stuff() -- do our own stuff first
return original(...) -- then call the original, so the game works as usual
end
This trick of “redirecting a call to yourself, then passing it back to where it was going” is called a hook. These five lines are what let a mod do something every frame.
But there's a catch: this code itself has to be run once first before it can replace update. The game only runs the few Lua scripts it knows about; it won't run an unfamiliar Lua resource. So who runs it that first time? That's the Loader, in the next chapter.
update to run every frame. The HUD's final entry point (H.install()) is just a beefed-up version of this chapter's five lines, with error protection (Chapter 4) and self-timing added.- hive_lord_hud.lua lines 2564–2619 The hook: wrapping the game's update(): The full version of Chapter 2's five lines: keep the original update, replace it, run the HUD under pcall each frame, disarm for good on a fault, and always call the original.
Show this code
function H.install() if H.armed then return true end local original = rawget(_G, 'update') if type(original) ~= 'function' then writer('install refused: no global update()') return false end if not (Gui and Vector2 and Vector3 and Color and World and Unit and App) then writer('install refused: missing engine API') return false end H.text_ok = attempt(App.can_get, 'font', H.FONT) == true and attempt(App.can_get, 'material', H.FONT) == true H.armed = true -- Self-timing: how much frame time the HUD costs (QueryPerformanceCounter around H.update). pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* c); int QueryPerformanceFrequency(int64_t* f);') local qpc, qbuf = nil, ffi.new('int64_t[1]') pcall(function() local k = ffi.load('kernel32') k.QueryPerformanceFrequency(qbuf) local freq = tonumber(qbuf[0]) if freq and freq > 0 then qpc = function() k.QueryPerformanceCounter(qbuf); return tonumber(qbuf[0]) * 1000 / freq end end end) H.cost = {sum = 0, max = 0, n = 0, scan_sum = 0, scan_n = 0} rawset(_G, 'update', function(...) if H.armed then local on = H.option_enabled() if on ~= H.enabled then H.enabled = on -- … (56 lines in total; see the source page for all of it) - plate_fix.lua lines 306–323 A second hook: Plate Fix wraps the global update too, on top of the HUD's wrapper. Each wrapper calls the previous one, so both mods can coexist.
Show this code
function M.install() if M.armed then return true end local original = rawget(_G, 'update') if type(original) ~= 'function' then writer('install refused: no global update()'); return false end M.armed = true rawset(_G, 'update', function(...) if M.armed then if M.hello then writer(M.hello); M.hello = nil end local ok, why = pcall(function() M.poll_option(); M.update() end) if not ok then M.armed = false writer('disarmed after fault: ' .. tostring(why)) end end return original(...) end) return true end
- The engine has Lua built in, and calls the global function
updateevery frame. - Global variables can be changed, so replacing
updatelets you run your own code every frame (a hook). - But someone has to run your code once first, before it gets the chance to replace
update.
Check yourself: What happens if you forget to call original(...) in your hook?
The only thing the engine calls each frame is your function, so nothing the game's own Lua layer should do gets done. At best some features stop working; at worst the game breaks. So you must always call the original function and pass its return value back.
Check yourself: HD2's game logic is almost all in C++, so what can a Lua mod still do?
The Lua layer is thin, but it lives inside the game process, gets called every frame, and can use the APIs the engine exposes. Add the FFI from Chapter 5, and Lua can call Windows functions and read the game's memory directly, so it can do far more than “what the game itself uses Lua for”.
Who runs Lua that “someone else wrote”: Bingus Shared Loader
- Callback
- You hand a function to someone else, and they call it at a certain moment.
- Loader
- The mod whose job is to find all the other mods and run them.
- require
- The Lua instruction that loads another script resource and runs it.
- Mod manager
- Arsenal, HD2MM: tools that write mods as patch files in
data/. - log
- A text file where a program writes down what happened. While a mod runs inside the game, this is your only way to know what is going on.
The key step: borrow a vanilla script that always runs
Put Chapter 1 and Chapter 2 together and the trick appears: when the game starts it always loads certain vanilla Lua scripts, and a patch file can override a vanilla resource with one of the same name. So if you make a patch file containing a Lua resource with the same name as a vanilla script, the game runs our version at startup.
Bingus Shared Loader picks core/wwise/lua/wwise_flow_callbacks: a callback script that the sound system (Wwise) always loads at startup. But the vanilla script handles sound, so if you just replace it, the game goes silent. So the Loader keeps every feature of the vanilla script, and does one extra thing on top: load mods.
- The game's resources live in archives in
data/, located by “type + name hash (murmur64)”. The mod manager (Arsenal / HD2MM) deploys mods as extra9ba626afa44a3aa3.patch_Nfiles, and a resource with the same name inside them overrides the vanilla one. - The Loader's archive holds just one lua resource, with name hash
0x7251FDD9BB62480A. We ran the same murmur64 and it matchescore/wwise/lua/wwise_flow_callbacksexactly: the vanilla script the game's sound system (Wwise) requires at startup. - So as soon as the game boots and loads the sound system, what actually runs is the Loader. It keeps all of the vanilla script's Wwise callbacks (
wwise_trigger_event,wwise_load_bank…), so sound works as normal; the README also points out that it “keeps Wwise callbacks, does not replace boot”. - The Loader itself is compiled LuaJIT bytecode (header
\x1bLJ, about 20 KB). The flow below was pieced together from string constants in the bytecode, the README, and real logs.
What the Loader does after it starts
- Enlarge the LuaJIT JIT cache: every mod shares the game's single LuaJIT, and the default cache is too small, so with many mods installed it keeps getting flushed. v18 raises it right away to 16 MB / 8,000 compiled code pieces (traces), grows it to 64 MB / 16,000 after a flush, and writes a
LuaJIT cache:line to the log. - Create the shared log folder
%LOCALAPPDATA%\CowboyBingus\Helldivers2\Logsand openBingusSharedLoader.log. - Create the global table
CowboyBingusModLoader: it holds the version, the API level, a list of mod states,open_log(filename)(only accepts file names like^[%w_-]+%.log$, so a mod cannot write to some other path), and JIT cache stats (jit.flushesand so on). - Find the game's data folder: use
GetModuleFileNameAto get the executable's path, check that it ends in/bin/helldivers2.exe(if not, stop), and work out/data/from it. - Scan every patch file:
FindFirstFileAlists9ba626afa44a3aa3.patch_*; each one is opened, the archive magic number0xF0000011and the tables are checked, the start of each lua resource is read, and its first line is matched against this pattern:
The bytecode also contains a murmur64 implementation (^%-%- HD2%-Addon: (mods/[A-Za-z0-9_]+/[A-Za-z0-9_/]+)hasher), which looks like it checks that the declared name matches the resource's hash (a guess, not confirmed in source). - Load: for the auto-discovered addons, plus a built-in list of CowboyBingus mods, check each one with
Application.can_get('lua', name): if it does not exist, lognot installed; if it does, runpcall(require, name)and logloadedon success, orload failed: reasonon failure.
An example of BingusSharedLoader.log (excerpt):
Bingus Shared Loader loader-v18; API 1
Discovery: 8 declared entries
LuaJIT cache: expanded 16384 KB / 8000 traces; flushes 0, growth 0; watcher on
mods/cowboybingus/vanilla_plus_megapack: loaded
mods/cowboybingus/mod_options_menu: loaded
mods/hivelordhud/hive_lord_hud: loaded ← our HUD
mods/hivelordhud/plate_fix: loaded ← Dead Plate Fix
mods/cowboybingus/consistent_vaulting: not installed
…
Why there is one “shared” Loader
For any one resource, only one patch can win. If every mod overrode wwise_flow_callbacks on its own, only the last one would take effect and all the others would vanish. So the community uses one shared Loader to hold that entry point, and it loads all the mods in turn. Your mod does not need to override any vanilla resource. It just ships a Lua resource with a new name, and puts -- HD2-Addon: … on the first line so the Loader can find it.
The mod manager (Arsenal, HD2MM) writes each mod as one patch file in data/. Every time it deploys, it reassigns the patch numbers, so you cannot assume your mod always sits at a fixed number (you will run into this again in Chapter 14).
How a mod works with the Loader
| Purpose | How |
|---|---|
| Let the Loader find us | The first line of the source is -- HD2-Addon: mods/hivelordhud/hive_lord_hud. build_hive_lord.py checks this line when packing and rewrites it to the correct resource name. |
| Packing format | One patch file per Arsenal option, with one lua resource inside: name hash = murmur64(resource name), content = (length, 2 = plain text) + source. It must be plain text with no BOM (BOM: 3 bytes some editors quietly add at the start of a file): compiling to bytecode throws away the first-line comment, and then the Loader cannot find it. |
| Starting up | When it is required, the top level of the file runs only once: first a global variable blocks double loading (HD2HiveLordHud / HD2HiveLordPlateFix), then the mod wraps itself around the global update(). From then on, all per-frame work happens inside the wrapper, and the Loader is no longer involved. |
| Writing logs | CowboyBingusModLoader.open_log('HiveLordHud_<time>.log'), so the log sits in the same folder as the other Bingus mods. Without the Loader it falls back to %TEMP%. |
| Settings menu | Mod Options Menu is also a mod loaded through the Loader (bundled with the Megapack), and it provides the global ModOptionsMenu. The HUD only looks for it inside update, because the load order does not guarantee it loads before us. |
| Debugging | When a mod does nothing, first check BingusSharedLoader.log: does it appear at all, and is it loaded or load failed? |
Can it work without the Loader?
No. Our mod is just a lua resource called mods/hivelordhud/hive_lord_hud, and nothing in the game itself ever requires that name. Without the Loader, the resource gets deployed into the game folder but never runs: no error, and no effect at all.
- The only fallback in the code for “no Loader” is writing the log to
%TEMP%instead. But that still assumes the mod was loaded, so in practice it never gets used. - The settings menu disappears too: Mod Options Menu is itself a mod loaded by the Loader.
- In theory you could copy the Loader and replace a vanilla script yourself as an entry point. But only one patch can win per resource, so you would clash with the Loader and other mods, and you would have to maintain the vanilla script's features yourself — so we don't. That is the whole point of the Loader: all mods share one entry point instead of fighting over the same vanilla script.
So the install instructions list Bingus Shared Loader as a required dependency; Mod Options Menu (Megapack) is optional, and without it the HUD runs with default settings.
- The game uses LuaJIT (Lua 5.1 syntax). Things that only arrived in Lua 5.3 or later, like
//integer division,string.packandmath.type, do not work. Offline tests must use the LuaJIT build of lupa (a package for running Lua from Python); otherwise the tests pass and you only getload failedin the game. - All mods share the same Lua VM and the same set of FFI declarations: wrap
ffi.cdefinpcall(another mod may have declared it first), and give global variables unique names. - In Arsenal, the Loader must sit at the bottom of the list (default priority). If you change the order, do Purge + Deploy.
- The Loader is tied to the game version. After a big game update, wait for the Loader to be updated.
mods/hivelordhud/hive_lord_hud. The later Dead Plate Fix is a second resource, mods/hivelordhud/plate_fix, and is a separate option you can tick on its own in Arsenal.- hive_lord_hud.lua lines 1 The line the Loader looks for: The first line is the declaration the Loader uses to find the mod. The name must match the packed resource name exactly, and it must stay plain text: compiling to bytecode drops it.
Show this code
-- HD2-Addon: mods/hivelordhud/hive_lord_hud - hive_lord_hud.lua lines 37–49 Using the Loader's shared log folder: Prefer the open_log the Loader provides so the log sits next to every other mod; fall back to %TEMP% only without the Loader.
Show this code
local function open_own_log() if own_log ~= nil then return own_log end own_log = false local name = 'HiveLordHud_' .. ((os and os.date and os.date('%Y%m%d_%H%M%S')) or 'session') .. '.log' local loader = rawget(_G, 'CowboyBingusModLoader') local ok, f = pcall(function() return loader and loader.open_log and loader.open_log(name) end) if not (ok and f) then local temp = os and os.getenv and os.getenv('TEMP') f = temp and io.open(temp .. '\\' .. name, 'w') end if f then own_log, H.log = f, name end return own_log end - plate_fix.lua lines 1 A second resource name: Dead Plate Fix is a separate resource and a separate Arsenal option, so it can be installed without the HUD.
Show this code
-- HD2-Addon: mods/hivelordhud/plate_fix
- The Loader uses a patch file to override a vanilla script that “the game always loads at startup”, and keeps that script's original features.
- The Loader then scans every patch file, finds mods by their first line
-- HD2-Addon:, and runsrequireon each one. - Only by sharing one entry point do mods avoid overriding each other.
Check yourself: What happens if you compile your mod's Lua to bytecode before packing it?
The Loader cannot recognise it: bytecode drops comments, so the first-line -- HD2-Addon: is gone. The mod gets deployed into the game but never runs, and never shows up in the Loader's log.
Check yourself: When a mod “does nothing”, which file should you check first, and why?
BingusSharedLoader.log. It records whether each mod was found, and whether it is loaded or load failed: reason. If your mod is not in the list at all, the problem is in packing or deployment; if it says load failed, the problem is in the code itself.
What a mod can do inside the game: the engine API
- API
- Functions opened up for others to call. Here it means
stingray.*, which the engine opens up to Lua. - world, unit, bone
- A scene; an object in the scene; a node in a model's skeleton.
- handle
- A ticket number that stands for something, not the thing itself.
- Gui, retained mode
- The engine's feature for drawing 2D interfaces; a drawing style where you “create once, then modify”.
- pcall, assert
- A Lua call that catches errors; a self-check inside the engine that ends the game outright if it fails.
What the engine gives you
The engine opens part of its features to Lua; this is called the API. Mods use these groups the most:
Application: the whole game. For example,Application.worlds()lists every current world (scene: the ship, a mission, and a menu are each one).World/Unit:World.units(world)lists every unit in a scene (a monster, a player, and a camera are all units).Unit.resource_name(u)tells you which model resource it uses (it returns a hash).Unit.node(u, 'head')finds the bone named head, and then you read its position.Camera:Camera.world_to_screenturns a 3D position into screen coordinates.Gui: draws rectangles, text and triangles on screen.
Note: the unit a script gets is only a handle (a ticket number), not the location of its data. You can use it to ask the engine questions, but you cannot go from it straight to the monster's HP data. And the engine does not offer any API like “read this monster's HP”. That fact decides the HUD's path from here on (Chapter 5).
Example: how the HUD finds the Hive Lord
- Every 2 seconds it scans
World.units()for each world inApplication.worlds()and comparesUnit.resource_name. The release build returns a hashed IdString64, so it matches either the path stringhive_lordor the hashd465d9c7f77a07cb(= murmur64 ofcontent/fac_bugs/cha_hive_lord/cha_hive_lord). - A newly appeared world is left alone until it has been stable for 10 seconds.
main_world()changes about every 3 seconds, so you cannot rely on it. - The HUD draws on “the stable world with the fewest units” (the UI world; the game world has thousands of units). Once a world disappears, its Gui disappears with it: just forget it, and never call destroy again.
- A unit value is a handle, not a pointer, so you cannot go from a unit straight to its component memory. HP has to be found another way (next section).
- After the Hive Lord dies, the HUD fades out over 2.5 seconds, and 1.5 seconds later it never touches that corpse again; it remembers the address and skips it in later scans. This avoids a situation that crashes the game (see “Pitfalls we hit”).
Drawing: the retained-mode Gui
The engine's Gui uses retained mode: Gui.rect(...) creates a rectangle and gives you an id; after that you use Gui.update_rect(id, ...) to change its position, size and colour; when you no longer need it, you destroy it. It does not clear the screen and redraw everything each frame.
- Each group fades in and out over 0.35–0.6 seconds, and is only destroyed once the fade-out finishes.
- A primitive is only sent to the Gui when its colour, size, position or text changes; nothing is sent on the frame it is created (text created and updated in the same frame never shows up in the game).
- Arguments must be real engine objects:
Vector3(x, y, layer),Vector2(w, h),Color(a, r, g, b). - Each facet of the low-poly panel is a real
Gui.triangle. Its points must be written asVector3(x, 0, y)(confirmed by disassembling helldivers2.exe: y and z are swapped). If creating a triangle fails, it automatically falls back to building it from 2 px rectangle strips. - The font is
core/performance_hud/debug, which is already in the base archive; bold is a copy of the same text drawn 1 px to the right.
Interactive demo: colour correction
The screen Gui is drawn before the game's exposure / tone mapping, so colours actually show about 3 times brighter and dark tones get crushed (sending a dark red of {96,2,18} shows up as 243,0,0 on screen; greys from 150–240 all turn pure white). Before sending, the HUD does the reverse conversion: sent value = wanted value ÷ gain + black; at 100% on the slider, gain = 3 and black = 18. The demo below simulates the measured boost model (shown ≈ 3 × (sent − 18)):
What happens when you get it wrong: two kinds of errors
- Lua-level errors (for example, adding to nil): these can be caught with
pcall. So all of the mod's per-frame work is wrapped inpcall; on an error it switches itself off for good, and the game carries on. - Errors inside the engine: every engine API has self-checks inside (assert). If an argument is wrong or the object no longer exists, it ends the whole game outright.
pcallcannot catch this, because the error never gets back to Lua.
Because the second kind of error cannot be recovered from, the mod must make sure, before each call, that nothing will go wrong: is the world still there, is the unit still alive, are the arguments in the right format? Almost every one of these rules was learned after a crash (see below).
- v0: called
Gui.rectevery tick to create new rectangles and never destroyed them, piling up about 2,400 per second; the game hard-froze after 1 minute and after 3.5 minutes. Switched to proper retained-mode usage. - v27: drew text with the game's native font; it crashed on the first draw and left a game process that could not be closed, so the only fix was a reboot. From then on, only the debug font from the base resources is used.
- v42–v64: when a player died, or the Hive Lord's corpse was removed after death, the HUD was still calling the API on units that were already gone. Changed to check
Unit.alive(u)before every call, and to never touch the Hive Lord again after it dies. - v40:
Unit.world_position(u, 0)returned the origin for every unit, so distance was always 0 m. Switched to named bones (head, root).
- hive_lord_hud.lua lines 16–21 Lifecycle rules paid for in crashes: Run inside the update wrapper, disarm on the first fault, check the world is live before every call, treat Gui as retained mode, pass real engine objects. Each of these five rules matches a crash in Chapter 4.
Show this code
-- Lifecycle (same as HUD+, which is known to be stable): -- * runs inside a wrapper around the engine's global `update` (main thread, once per frame) and disarms itself -- permanently on the first Lua fault -- * every engine call is gated on the world being live -- * Gui is retained-mode: Gui.rect once -> id, then Gui.update_rect(id); World.destroy_gui on teardown -- * vectors / colours are real engine objects: Vector3(x, y, layer), Vector2(w, h), Color(a, r, g, b) - hive_lord_hud.lua lines 71–79 Check the world is still alive first: A world that has gone takes its Gui with it. Before any engine call, check it is still in the list.
Show this code
local function world_is_live(world) if world == nil or not App then return false end local worlds = attempt(App.worlds) if type(worlds) ~= 'table' then return false end for i = 1, #worlds do if worlds[i] == world then return true end end return false end - hive_lord_hud.lua lines 182–217 Finding the Hive Lord with the engine API: World.units lists every unit; check Unit.alive, then compare Unit.resource_name against the Hive Lord hash. A dead corpse is skipped by address and never even asked alive again.
Show this code
function H.find_hive_lord(worlds, t) if not World or type(World.units) ~= 'function' then return nil end if H.dead_addr and t - (H.dead_addr_t or t) > 300 then H.dead_addr = nil end local total = 0 for _, rec in ipairs(settled_worlds(worlds, t)) do local units = attempt(World.units, rec.w) if type(units) == 'table' then rec.n = #units total = total + #units if not rec.logged then describe_world(rec, units) end local hl, hl_name, divers, cams = nil, nil, {}, {} for i = 1, #units do local u = units[i] -- The game crashed twice ~60 s after a Hive Lord died, while we kept polling its corpse as it was -- despawned (logs 222058 / 233615). Once dead it is never touched again, not even Unit.alive. if H.dead_addr and handle_addr(u) == H.dead_addr then -- skip elseif attempt(Unit.alive, u) then local name = attempt(Unit.resource_name, u) if name ~= nil then local s = tostring(name):lower() if not hl and is_hive_lord(s) then hl, hl_name = u, tostring(name) elseif s:find(AVATAR_HEX, 1, true) then divers[#divers + 1] = u elseif s:find(CAMERA_HEX, 1, true) then cams[#cams + 1] = u end end end end if hl then H.live.units_seen, H.live.divers, H.live.cams = total, divers, cams return hl, rec.w, hl_name -- … (36 lines in total; see the source page for all of it) - hive_lord_hud.lua lines 219–224 The Unit.world_position trap: Node 0 reads (0,0,0) for every unit, so named bones (root, head) are used instead, checked with has_node first because an unknown name triggers an engine assert.
Show this code
-- Root node world position. Index 0 is the root (scene node indices are 0-based); guarded by node count. -- Vector3 fields are unreliable here, so parse tostring 'Vector3(x, y, z)'. -- Node 0 is a static root that reads (0,0,0) for the Hive Lord and every avatar, so named bones are used instead, -- resolved once per unit (Unit.has_node first: Unit.node asserts on unknown names). local BODY_NODES = {'root', 'j_root', 'hips', 'j_hips', 'pelvis', 'j_pelvis', 'spine', 'j_spine', 'head', 'j_head'} local node_cache = setmetatable({}, {__mode = 'k'}) - hive_lord_hud.lua lines 991–1032 Retained-mode prims: Each rect and text gets an id when created and is only modified afterwards; groups handle fades and destruction.
Show this code
local function new_group(name) local g = {name = name, a = 0, prims = {}, born = H.frame} H.grp[name] = g return g end -- Rect prim: c = {a, r, g, b}; w may change later (bars). local function add_rect(g, x, y, w, h, c, layer) local p = {kind = 'rect', x = x, y = y, w = w, h = h, c = c, layer = layer or LAYER} p.id = Gui.rect(H.ui.gui, Vector3(x, y, p.layer), Vector2(w, h), Color(0, c[2], c[3], c[4])) g.prims[#g.prims + 1] = p return p end -- Text prim, created with its real initial string (updates in the creation frame are dropped by the engine). -- align: 'l' (x = left), 'r' (x = right edge), 'c' (x = centre). local function text_pos(p) if p.align == 'l' then return p.x end local w = text_width(p.str, p.size) if p.align == 'r' then return p.x - w end return p.x - w / 2 end -- Bold: the debug font has no bold face, so a text is drawn bold_px(size) + 1 times, each copy 1 px further right (a -- solid thicker stroke; a single copy shifted by several px reads as a double image). Every copy sits on whole pixels -- at a whole-pixel size: at fractional positions each copy is resampled on its own and the stack smears. local function bold_px(size) return math.max(1, math.floor(size / 16 + 0.5)) end local function px(v) return math.floor(v + 0.5) end H.bold_px = bold_px -- … (42 lines in total; see the source page for all of it) - hive_lord_hud.lua lines 1059–1073 Destroy what you no longer use: Once a group has faded out, every prim is removed from the Gui. v0 crashed because it never did this.
Show this code
local function destroy_group(name) local g = H.grp[name] if not g then return end if H.ui.gui then for _, p in ipairs(g.prims) do if p.kind == 'rect' then pcall(Gui.destroy_rect, H.ui.gui, p.id) elseif p.kind == 'tri' then tri_free(p) else pcall(Gui.destroy_text, H.ui.gui, p.id) for _, id in ipairs(p.copies or {}) do pcall(Gui.destroy_text, H.ui.gui, id) end end end end H.grp[name] = nil end - hive_lord_hud.lua lines 1078–1088 Undoing tone mapping: The Gui is drawn before exposure and tone mapping, so colours come out about 3x brighter. Before sending: sent = wanted / gain + black.
Show this code
-- Colour correction. The screen gui is drawn before the game's exposure / tone mapping, so a colour comes out roughly -- 3x brighter with the darks crushed (a {96,2,18} strip showed as 243,0,0; greys of 150-240 as pure white). Colours -- are authored as they should look and mapped back here: sent = shown / gain + black. 'Colour correction' slider: -- 100 = gain 3, black 18; 0 = off; 150 = stronger. function H.color_out(v) local f = (tonumber(H.cfg and H.cfg.color_fix) or 100) / 100 if f <= 0 then return math.floor(v + 0.5) end local gain, black = 1 + 2 * f, 18 * f return math.floor(math.min(255, v / gain + black) + 0.5) end local color_out = H.color_out - hive_lord_hud.lua lines 1090–1121 Only send what changed: Each prim remembers what it last sent; if nothing changed, the Gui is not called, which keeps the per-frame cost low.
Show this code
local function draw(p, ga) local a = math.floor(p.c[1] * ga + 0.5) local k = ga < 1 and (0.15 + 0.85 * ga) or 1 local r, gg, b = color_out(p.c[2] * k), color_out(p.c[3] * k), color_out(p.c[4] * k) if p.kind == 'rect' then local w = ga < 0.04 and 0 or p.w local key = a * 16777216 + r * 65536 + gg * 256 + b + math.floor(w * 4) * 4294967296 local pk = math.floor(p.x) * 65536 + math.floor(p.y) if key == p.key and pk == p.pk and p.h == p.lh then return end p.key, p.pk, p.lh = key, pk, p.h Gui.update_rect(H.ui.gui, p.id, Vector3(p.x, p.y, p.layer or LAYER), Vector2(w, p.h), Color(a, r, gg, b)) elseif p.kind == 'tri' then local key = a * 16777216 + r * 65536 + gg * 256 + b if key == p.key then return end p.key = key tri_free(p) tri_make(p, a, r, gg, b) if H.tri_error and (p.fi or p.tick) then -- a facet / bar stripe failed to re-create: rects from now on H.poly_tri_off, H.tri_error, H.poly_rebuild = H.tri_error, nil, true end else local str = ga < 0.04 and ' ' or p.str local key = string.format('%d,%d,%d,%d|%d,%d|%s', a, r, gg, b, math.floor(p.x), math.floor(p.y), str) if key == p.key then return end p.key = key local tx, ty, col = px(text_pos(p)), px(p.y), Color(a, r, gg, b) Gui.update_text(H.ui.gui, p.id, str, H.font, p.size, H.font_mat, Vector3(tx, ty, p.layer), col) for k, id in ipairs(p.copies or {}) do Gui.update_text(H.ui.gui, id, str, H.font, p.size, H.font_mat, Vector3(tx + k, ty, p.layer), col) end -- … (32 lines in total; see the source page for all of it) - hive_lord_hud.lua lines 1738–1768 Projecting 3D to the screen: Camera.world_to_screen turns the Hive Lord's position into screen coordinates. A log line is written before the first call: if the game crashes, the last line names the culprit.
Show this code
function H.project(wx, wy, wz) local Camera, Quat = api('Camera'), api('Quaternion') if not Camera or type(Camera.world_to_screen) ~= 'function' then return nil end local pc = frame_camera(Camera, Quat) local c = pc.c if c == nil then return nil end if not H.tag_called then H.tag_called = true writer('tag: calling Camera.world_to_screen for the first time (if the game crashes now, this API is bad)') end local ok, v = pcall(Camera.world_to_screen, c, Vector3(wx, wy, wz)) if not ok or v == nil then if not H.tag_err then H.tag_err = true; writer('tag: Camera.world_to_screen failed: ' .. tostring(v)) end return nil end local sx, sy, sz = parse3(v) if not (sx and sy) then return nil end local behind = false if pc.fx and pc.px then behind = (wx - pc.px) * pc.fx + (wy - pc.py) * pc.fy + (wz - pc.pz) * pc.fz < 0 end if H.tag_scale == nil then local rw = type(Gui.render_resolution) == 'function' and attempt(Gui.render_resolution) or nil H.tag_scale = (type(rw) == 'number' and rw > 0) and (H.ui.width / rw) or 1 writer(string.format('tag: first projection raw=(%s) gui=%dx%d render_w=%s scale=%.3f', tostring(v), H.ui.width, H.ui.height, tostring(rw), H.tag_scale)) end sx, sy = sx * H.tag_scale, sy * H.tag_scale if H.tag_flip then sy = H.ui.height - sy end return sx, sy, behind, sz -- … (31 lines in total; see the source page for all of it) - hive_lord_hud.lua lines 2198–2219 Creating and tearing down the Gui: Create a screen Gui on a world; on teardown destroy it only if the world is still alive, and merely forget it if the world is gone.
Show this code
local function build_gui(world) local gui = World.create_screen_gui(world, 'scale', 1, 1) if gui == nil then return false end H.ui.gui, H.ui.world = gui, world H.grp = {} local w, h = Gui.resolution() if type(w) == 'number' and w > 0 and type(h) == 'number' and h > 0 then H.ui.width, H.ui.height = w, h H.ui.scale = math.min(w / 1920, h / 1080) end writer(string.format('overlay up (%dx%d, scale %.2f, text=%s)', H.ui.width, H.ui.height, H.ui.scale, tostring(H.text_ok))) return true end -- Destroy only if the world is still alive; a dead world already took the gui with it. local function teardown() if H.ui.gui ~= nil and world_is_live(H.ui.world) then pcall(World.destroy_gui, H.ui.world, H.ui.gui) end H.ui.gui, H.ui.world, H.grp = nil, nil, {} end - hive_lord_hud.lua lines 2239–2249 Which world to draw on: Pick the settled world with the fewest units (the UI one). main_world flips about every 3 s, so it cannot be used.
Show this code
-- Overlay world: the settled world with the fewest units (the UI world; the game world has thousands). -- main_world flips ~every 3s, so it can't be used to tell them apart. Once chosen, kept while it lives. local t_now = 0 local function pick_overlay_world() local best for _, rec in ipairs(H.worlds_seen) do if t_now - rec.since >= 10 and rec.n ~= nil and (best == nil or rec.n < best.n) then best = rec end end if best then writer(string.format('overlay on world #%d (%d units)', best.idx, best.n)) end return best and best.w end
- The engine API lets a mod look at the scene, find objects and draw on screen, but anything it does not offer (like reading HP) cannot be done.
- Lua errors can be caught; errors inside the engine end the game outright, so you must “check before you call”.
- The Gui is retained mode: create once, modify afterwards, and remember to destroy.
Check yourself: The mod is already wrapped in pcall, so why can the HUD still crash the game?
The crash happens inside the engine (a failed assert, or touching an object that no longer exists). The error never gets back to Lua, so pcall never gets a chance to catch it. The only fix is to stop that kind of call from happening.
Check yourself: The HUD can find the Hive Lord with World.units, so why can't it read its HP?
What it gets is only a handle, not the location of the data, and the engine offers no API for reading HP. HP lives in memory managed by game.dll, and you have to read it yourself using the methods in Chapter 5.
What the engine doesn't expose: LuaJIT FFI
- FFI
- A LuaJIT feature: call C functions directly from Lua and work with memory directly.
- Windows API
- Functions Windows offers to every program, e.g. reading memory, opening files, timing.
- Memory, address
- Where a running program keeps its data; the number of each byte. (Explained in Chapter 6)
What FFI is
LuaJIT has a feature that standard Lua does not: FFI (Foreign Function Interface). It lets Lua call functions written in C directly, including the Windows API, and it can also allocate, read and write a block of memory directly. The game's lua51.dll does not switch this feature off.
local ffi = require('ffi')
pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* counter);') -- tell FFI what the function looks like
local kernel32 = ffi.load('kernel32') -- load the dll that holds the function
local buf = ffi.new('int64_t[1]') -- allocate a block of C memory
kernel32.QueryPerformanceCounter(buf) -- call the Windows API
print(tonumber(buf[0])) -- value of the high-resolution timer
With FFI, what a mod can do suddenly becomes “whatever a normal Windows program can do”:
- For example, it can call the Windows thread API to choose which CPU core each game thread runs on.
- The HUD uses it to call
ReadProcessMemoryand read the game process's own memory.
Why FFI is also dangerous
- No safety net: get a function declaration wrong, pass a wrong argument, or read an address you shouldn't, and the game crashes on the spot.
pcallcan't catch it (Chapter 6 explains why). - All mods share one Lua environment: declaring the same thing twice with
ffi.cdefthrows an error, so every declaration is wrapped inpcall. Keep the object you get fromffi.loadalive, or the dll may be unloaded and its functions stop working. - Same power as a normal program: a malicious Lua mod can also read your files and go online. That is why Chapter 16 says “only install mods you trust”.
ReadProcessMemory to read game memory. But that immediately turns into two questions: where in memory is the HP? And what does the data there look like? To answer them, you first need to understand memory (Chapter 6) and how the game organises its data (Chapter 7).- hive_lord_hud.lua lines 506–515 Declaring Windows APIs with FFI: Tell FFI what ReadProcessMemory and VirtualQuery look like, inside pcall (another mod may have declared them first), then load kernel32.
Show this code
pcall(ffi.cdef, [[ unsigned long long VirtualQuery(const void* addr, void* buf, unsigned long long len); void* GetCurrentProcess(void); int ReadProcessMemory(void* process, const void* addr, void* buf, size_t size, size_t* read); typedef struct { void* BaseAddress; void* AllocationBase; unsigned long AllocationProtect; unsigned short PartitionId; unsigned long long RegionSize; unsigned long State; unsigned long Protect; unsigned long Type; } HLH_MBI; ]]) local k32 = ffi.load('kernel32')
- FFI lets Lua call the Windows API and work with memory directly. It is the key to mods doing “things the engine doesn't expose”.
- It has no safety net: a mistake crashes the game outright.
- So a mod has the same power as a normal program. That is both its strength and its risk.
Check yourself: if the game's LuaJIT had FFI switched off, could the HUD still be built?
Not the HP bars or the panel: the engine has no API for reading HP, so the only way is reading memory through FFI. Parts that use only the engine API, like markers and the emerge warning, would still work.
Memory: where data lives
- byte, hexadecimal, int32
- The smallest unit of memory; the notation starting with
0x; a 4-byte integer. - Virtual memory, page
- Each process's own set of addresses; managed in 4 KB units.
- Access violation
- Reading or writing an address that doesn't exist or isn't allowed; the program crashes on the spot.
- Code section, data section, heap
- The dll's machine code; variables at fixed locations inside the dll; data allocated while the program runs.
- ASLR, base address, RVA
- The dll loads at a different place each time; where it loaded this time; an offset counted from the start of the dll.
- Pointer
- A value that holds another address.
What a number looks like in memory
Memory is one long row of cells. Each cell is 1 byte (0–255), and each cell has a number called its address. Addresses and contents are usually written in hexadecimal (starting with 0x, each digit 0–9, A–F). An integer like HP is usually stored in 4 bytes, called an int32, and the “low byte comes first”, so 150,000 = 0x000249F0 sits in memory as F0 49 02 00:
Every process has its own set of addresses
The addresses a program sees are not the real locations on your RAM sticks. Windows gives every process its own virtual memory, managed in 4 KB pages, which are mapped to physical RAM (or parked on the SSD for a while).
This has three results that matter a lot for mods:
- Outside programs can't touch the game's memory: the same address means different RAM in two processes. To read the game, an outside program must first ask Windows for a handle to the game (
OpenProcess), then ask Windows to read on its behalf. Anti-cheat blocks exactly this step (Chapter 16). But a mod runs inside the game process and uses the game's own addresses, so it never goes through this step. - Reading the wrong address crashes the game: if an address isn't mapped to any page, or the page doesn't allow reading, the CPU raises an access violation and the program ends immediately. This is why Chapter 5 says FFI has “no safety net”.
- Code can't be changed directly: pages that hold code are marked “readable, executable, not writable”. To change them, you first have to call
VirtualProtectto change the permissions.
Going further: RAM itself and caches
RAM is the CPU's workbench. It is about 1,000 times faster than an SSD, but much slower than the CPU's built-in caches:
| Level | Size (your i9-13900H, roughly) | Time to fetch one piece of data (roughly) | Managed by |
|---|---|---|---|
| Registers | A dozen or so 64-bit slots per core | Under 1 ns | The compiler (these are the rax, rcx in assembly language) |
| L1 cache | 48 KB of data per P-core | About 1 ns | CPU, automatically |
| L2 cache | 1.25 MB per P-core | About 4 ns | CPU, automatically |
| L3 cache | 24 MB shared by all cores | About 10–20 ns | CPU, automatically |
| RAM | 16 GB | About 80–100 ns | Allocated by the OS |
| SSD (page file) | Hundreds of GB | About 50–100 µs (about 1,000 times slower than RAM) | The OS moves pages in and out |
For example, on a laptop with a single 16 GB RAM stick (single channel), the game can use 4.7–6.4 GB just on the ship, and the system often has less than 1 GB of free RAM left. When RAM runs low, Windows moves rarely used pages to the SSD. When the game needs them again it has to wait for the SSD to read them back — so you get stutters.
Three kinds of region inside a process
- Code section: the dll's machine code. It doesn't change while running.
- Data section: variables at fixed locations inside the dll (global variables). They stay in the same place for the whole session.
- Heap: data the game allocates while running, such as each enemy's HP. Its location differs every match, and the game may even move it.
Two more things make “finding the HP” harder:
- ASLR: every launch, Windows puts the dll at a different address (to stop attacks). So you can't remember an absolute address like “HP is at 0x7FFF…”. You can only remember “byte number N counted from the start of the dll”, called the RVA. Real address = this launch's base address + RVA.
- Pointers: data on the heap has no fixed location, but a global variable in the data section holds its address. A “value that holds an address” is called a pointer. Start from a fixed global variable, follow the pointers step by step, and you reach data whose location changes every match.
- hive_lord_hud.lua lines 601–618 Base address + RVA: GetModuleHandleA gives this launch's game.dll base; add the RVA 0x3326688 and read the health system's address. The base changes every launch, the RVA does not.
Show this code
function H.game_base() if H.GAME_BASE then return H.GAME_BASE end -- test harness local ok, m = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.C.GetModuleHandleA('game.dll'))) end) if ok and m and m ~= 0 then return m end return nil end local function health_sys() local base = H.game_base() if not base then return nil, 'game.dll not found' end local S = ptr(base + HEALTH_SYS_RVA) if not S then return nil, 'health system not created' end local keys, cap, recs = ptr(S + HS_KEYS), u32(S + HS_CAP), ptr(S + HS_RECS) if not (keys and cap and recs) or cap == 0 or cap > 0x40000 then return nil, string.format('health system layout unexpected (cap=%s)', tostring(cap)) end return {S = S, keys = keys, cap = cap, recs = recs, empty = u32(S + HS_EMPTY)} end - plate_fix.lua lines 140–148 Base + RVA, then validate: Read the system's address from the game.dll base plus the RVA, and check the key table and capacity look sane; if not, do nothing.
Show this code
local function table_header(base_rva, keys_off, cap_off) local base = M.game_base() if not base then return nil end local S = ptr(base + base_rva) if not S then return nil end local keys, cap = ptr(S + keys_off), u32(S + cap_off) if not keys or not cap or cap == 0 or cap > 0x40000 then return nil end return S, keys, cap end
- In memory, a number is just a few bytes. An int32 is 4 bytes, low byte first.
- Every process has its own virtual memory. An outside program has to go through Windows to read it; a mod inside the process doesn't.
- Reading the wrong address crashes the game. The dll's location changes every time, so remember RVAs and follow pointers from global variables.
Check yourself: today game.dll is loaded at 0x7FFFB5F70000, and a global variable's RVA is 0x3326688. What is its real address? What about tomorrow?
Today it is 0x7FFFB5F70000 + 0x3326688 = 0x7FFFB9296688. Tomorrow the base address will be different, but the RVA is still 0x3326688 — just add it to tomorrow's base address. That is why you only remember the RVA.
Check yourself: why can't you just hard-code “HP is at 0x29A60000A64” in your code?
HP lives on the heap, and its location changes every match, and even whenever the game moves it. Next time, a hard-coded address points to something else or doesn't exist at all. Reading it gives you wrong data, or triggers an access violation and crashes the game.
The shape of game data: structs, systems, hash tables
- Struct, offset
- A bundle of data kept together; which byte a field starts at, counted from the beginning.
- Array, stride
- The same kind of data, one record after another; the size of each record.
- ECS: entity, component, system
- An object's ID number; data attached to that ID; code that manages all data of one kind in one place.
- Hash table
- A table that quickly turns an ID into “record number N”.
- Design values, runtime values
- Values written in the game's data files (max HP); values that exist only in memory (current HP).
Structs and offsets
C++ uses a struct to keep related data together. After compiling, the names are gone; all that's left is “this field starts at byte N from the beginning”, called the offset. Many structs of the same kind are laid out one after another as an array. The size of each one is the stride, and record n sits at “start + n × stride”.
struct HealthRecord { // one enemy's HP record
// ...
int32_t main_hp; // main HP
// ...
int32_t armor[39]; // armour per zone
int32_t zone_hp[38]; // HP per zone
int32_t end_marker; // 0xFFFF
};
HealthRecord* records; // many records back to back
records[6].main_hp -= 500; // enemy #6 loses 500 HP// each record is 0x1B8 = 440 bytes, one after another // record n is at: records + n × 0x1B8 +0x014 main HP (4 bytes) +0x05C armour × 39 (4 bytes each) +0x0F8 zone HP × 38 (4 bytes each) +0x190 0xFFFF marker // so "main HP of enemy #6" = // records + 6 × 0x1B8 + 0x14 // this is exactly the formula our mod reads with
Click the blocks below to see what each part of the Hive Lord's HP record (440 bytes) is:
Click a block.
How the game manages enemies: ECS
HD2 uses an architecture called ECS: everything in the level is just an entity ID (e.g. one Hive Lord is 0x22A). Data is attached to that ID in bundles called components (HP component, model component…). Each kind of component is managed in one place by a system. The health system keeps every enemy's HP record in one big array.
To get from an “entity ID” to “record number N in the array”, the health system uses a hash table: a fixed-size table where each slot holds {entity ID, record number}. The ID gives a starting slot; if that slot matches, read it, and if not, check the next slot.
Design values and runtime values
“Hive Lord max HP 150,000, each plate 15,000, armour 5” are design values. They are written in the game data tables under data/, and you can read them without the game running (Chapter 8). “This Hive Lord has 118,350 left right now” is a runtime value, and it exists only in that record in memory.
This split is very useful: design values can become a fingerprint. For example, the Hive Lord's 39 armour values (default zone 5, then 6, 10, 5, 5…) never change during a fight, and the combination is very unusual. So if you see a record in memory whose armour fields are exactly these 39 numbers, you can be almost certain it's the Hive Lord's record.
The Hive Lord's 38 zones (design values)
| slot | Internal name | Zone | HP | Constitution | Armour |
|---|
- hive_lord_hud.lua lines 522–530 Offsets inside the record: Offsets of main HP, armour and the end marker relative to the zone array, plus the per-zone maximums. This is the code version of the memory map in Chapter 7.
Show this code
local OFF_MAIN, OFF_ARMOR0, OFF_MARKER = -0xE4, -0x98, 0x98 local WIN_LO, WIN_HI = 0xF0, 0xA0 -- read [Z - 0xF0, Z + 0xA0) local WIN_LEN = WIN_LO + WIN_HI -- Per-slot max HP / constitution, built from H.GROUPS. local MAXHP, MAXCON = {}, {} for _, g in ipairs(H.GROUPS) do for _, z in ipairs(g.zones) do MAXHP[z.slot], MAXCON[z.slot] = z.hp, z.con or g.con or 0 end end - hive_lord_hud.lua lines 546–567 The fingerprint: 39 armour values: Every record read checks the end marker, the 39 armour values that never change in a fight, and the HP range of every zone. Any mismatch means it is not a Hive Lord.
Show this code
-- Read the record around zone base z into WIN and validate it. Returns true if it still looks like a Hive Lord. -- Armor never changes in a fight, so it is the fingerprint: default zone, then slots 0..37. local ARMOR = {5, 6, 10} for _ = 1, 13 do ARMOR[#ARMOR + 1] = 5 end for _ = 1, 7 do ARMOR[#ARMOR + 1] = 2 end ARMOR[#ARMOR + 1] = 6; ARMOR[#ARMOR + 1] = 6 for _ = 1, 14 do ARMOR[#ARMOR + 1] = 5 end -- HP is only sanity-bounded: destroyed zones (even ones without constitution) go below zero. local function read_record(z) if safe_read(z - WIN_LO, WIN_LEN, WIN) < WIN_LEN then return false end local zb = WIN_LO if i32(WIN, zb + OFF_MARKER) ~= 0xFFFF then return false end for i = 1, #ARMOR do if i32(WIN, zb + OFF_ARMOR0 - 4 + (i - 1) * 4) ~= ARMOR[i] then return false end end for slot = 0, 37 do local v = i32(WIN, zb + slot * 4) if v > MAXHP[slot] or v < -(MAXCON[slot] + MAXHP[slot]) then return false end end return true end - hive_lord_hud.lua lines 621–634 Walking the hash table: Read the whole key table and visit every (entity, index) pair. It does not compute the hash, so it does not need the multiplier the game uses.
Show this code
local function each_key(hs, fn) local per = 0x10000 / 8 for first = 0, hs.cap - 1, per do local n = math.min(per, hs.cap - first) if safe_read(hs.keys + first * 8, n * 8, B8) < n * 8 then return false end local p, list = ffi.cast('uint32_t*', B8), {} for i = 0, n - 1 do local k, v = tonumber(p[i * 2]), tonumber(p[i * 2 + 1]) if k ~= hs.empty and k ~= 0 and v < 0x100000 then list[#list + 1] = {k, v} end end for _, kv in ipairs(list) do if fn(kv[1], kv[2]) then return true end end end return true end - plate_fix.lua lines 126–137 The same fingerprint, plus plate-to-mask mapping: The same 39 armour values recognise a Hive Lord; it also lists which model mask belongs to each plate, and the delay and retry limits.
Show this code
-- The Hive Lord's armour values (default zone, then zones 0..37) never change in a fight: our fingerprint. local ARMOR = {5, 6, 10} for _ = 1, 13 do ARMOR[#ARMOR + 1] = 5 end for _ = 1, 7 do ARMOR[#ARMOR + 1] = 2 end ARMOR[#ARMOR + 1] = 6; ARMOR[#ARMOR + 1] = 6 for _ = 1, 14 do ARMOR[#ARMOR + 1] = 5 end -- Plate zone -> its model mask (GoreComponent) ; constitution 35000 for every plate. local PLATE_MASK = {} for k = 0, 5 do PLATE_MASK[7 - k], PLATE_MASK[14 - k] = 1 + 4 * k, 2 + 4 * k end local PLATE_CON = 35000 local FIX_DELAY, FIX_TRIES, RETRY_S = 1.5, 3, 2 - plate_fix.lua lines 151–164 The same hash-table walk: Read the key table in chunks, skip empty slots, call fn for every (entity, index).
Show this code
local function each_key(keys, cap, fn) local per = 0x10000 / 8 for first = 0, cap - 1, per do local n = math.min(per, cap - first) if safe_read(keys + first * 8, n * 8, BUF) < n * 8 then return false end local p, list = ffi.cast('uint32_t*', BUF), {} for i = 0, n - 1 do local k, v = tonumber(p[i * 2]), tonumber(p[i * 2 + 1]) if k ~= 0xFFFFFFFF and k ~= 0 and v < 0x100000 then list[#list + 1] = {k, v} end end for _, kv in ipairs(list) do if fn(kv[1], kv[2]) then return true end end end return true end
- After compiling, only offsets are left: “record +0x14 is main HP” is all the information there is.
- The game manages each kind of data in one place with a system, and uses a hash table to look up the record number from an entity ID.
- Design values that never change can serve as a fingerprint to confirm you found the right data.
Check yourself: the health system's record array starts at R, each record is 0x1B8 bytes, and the Hive Lord is record #6. At what address is its main HP?
R + 6 × 0x1B8 + 0x14 = R + 0xA50 + 0x14 = R + 0xA64.
Check yourself: why use the “armour values” as the fingerprint instead of “HP”?
HP keeps changing during a fight, so it won't match by the next frame. Armour values stay the same from start to finish, and 39 numbers make a unique combination that rarely collides with anything else. Early on, the HUD used “main HP = 150000” as its anchor, and as soon as the Hive Lord took damage it couldn't be found any more (the v9 failure).
Reading game files offline: FileDiver
- Archive format
- Header, type table, file table, data.
- Name list
- A community-collected lookup table of “hash ↔ original name”.
- datalib, type library
- The game's table of design values; a file that describes which fields each struct has and at which offset.
FileDiver is an open-source unpacking tool written by the community. It builds on Chapter 1 and Chapter 7: archives are binary files with a fixed format, names are hashes, and design values are arrays of structs.
How FileDiver works
How the game stores its resources
- The
data/folder holds a pile of archives named with 16 hex digits. Each archive is split into three files: the main file,.stream(streamed data, e.g. audio) and.gpu_resources(textures, model vertices). - Main file format: header (magic number
0xF0000011, type count, file count) → type table → file table (each file's name hash, type hash, and its offset and size in each of the three files) → data. The patch files we build for our own mods use exactly the same format. - File names are stored only as a murmur64 hash, not as strings. FileDiver ships a list of about 250,000 known names (
hashes/) to turn hashes back into readable paths; anything not in the list can only be shown as a hash. We recovered the Hive Lord's zone names ourselves by brute-forcing the 32-bit hashes (the upper 32 bits of murmur64). - When FileDiver opens
data/, it reads the tables of every archive and builds a “(name, type) → location” index. Later patches override earlier files with the same name, just like the game does.
What it can export
Models (glTF / glb, two common 3D file formats, with skeleton and animations), textures (png), Wwise audio (ogg / wav), text tables (JSON), animation state machines, prefabs (pre-assembled groups of objects) and more. The ones we used:
- Hive Lord model:
filediver.exe -g <game folder> -i content/fac_bugs/cha_hive_lord/cha_hive_lord.unit --model-format glb. From the names and positions of the bones and collision nodes, we confirmed that spine0 is at the tail end, which bone each plate belongs to, and that the plate above the tail doesn't belong to any zone. - Animation state machine (
sm-state-dumper): the state names of the locomotion and action layers used by the emerge warning.
Game data tables (datalib)
- Gameplay values (each enemy's components: health, armour, weapons, explosions…) live in datalib binary blobs (
generated_entities.dl_binand others). A separate type library,dl_library.dl_typelib, describes each struct's field names, types, offsets and sizes. FileDiver's source code includes these blobs and matching Go structs. - Pitfall: FileDiver's hand-written Go structs go stale as the game updates. HealthComponent is actually 22,096 bytes with 51 fields; reading it with the old struct gives garbage. So we switched to reading the current field offsets straight from the type library. Another pitfall: the header in front of each blob (the DLInstance header) is 28 bytes, not 24. Get those 4 bytes wrong and everything after is misaligned.
Tools we added
The source is in tools/filediver-src (local changes, not contributed back to the original project):
| Tool | Purpose |
|---|---|
components/hive-lord-health-dumper | Extracts the Hive Lord's HealthComponent from datalib: the name hash, HP, constitution, armour and main-HP damage ratio of all 38 zones. The HUD's H.GROUPS and the armour fingerprint used by both mods come from here. |
components/entity-probe | Lists which component tables a resource appears in, plus the raw bytes. |
type-layout / type-parents | Prints a struct's field offsets from the type library, replacing the stale Go structs. |
enum-names / enum-lengths, component-dump | Enum value names; a generic component dump. |
explosion-dumper, bot-spread-dumper, bot-weapon-layout | Data-table tools for the explosion and bot-accuracy mods. |
datalib.EntitiesBlob() | A public function added to FileDiver's datalib package so our own tools can read the decompressed entities data. |
- Our added
hive-lord-health-dumperextracted the Hive Lord's health component: main HP 150,000, 38 zones, and each zone's HP, constitution and armour value. The armour values became the fingerprint shared by the HUD and Plate Fix. - FileDiver's built-in struct definitions were stale (the health component is actually 22,096 bytes), so we read the current offsets from the type library instead.
- Exporting the model skeleton showed that spine0, which we had assumed was at the head, is actually at the tail — the panel had drawn the body backwards.
- We extracted the animation state machine; the later emerge warning relies on its state names.
- hive_lord_hud.lua lines 86–106 Design values decoded with FileDiver: Max HP and constitution for all 38 zones, all from the health component decoded offline. These numbers later validate the record in memory.
Show this code
-- ========================================================================= -- Hive Lord data (tools/research/hive_lord_health.json) -- ========================================================================= H.MAIN_HEALTH_MAX = 150000 H.GROUPS = { {label = 'HEAD', color = {255, 230, 140, 40}, zones = { {slot = 0, hp = 150000}, {slot = 1, hp = 150000}, {slot = 22, hp = 10000}, {slot = 23, hp = 10000}}}, {label = 'SPINE', con = 35000, color = {255, 230, 200, 60}, zones = { {slot = 2, hp = 15000}, {slot = 3, hp = 15000}, {slot = 4, hp = 15000}, {slot = 5, hp = 15000}, {slot = 6, hp = 15000}, {slot = 7, hp = 15000}, {slot = 8, hp = 20000}, {slot = 9, hp = 15000}, {slot = 10, hp = 15000}, {slot = 11, hp = 15000}, {slot = 12, hp = 15000}, {slot = 13, hp = 15000}, {slot = 14, hp = 15000}}}, {label = 'BODY', color = {255, 220, 70, 60}, zones = { {slot = 15, hp = 150000}, {slot = 16, hp = 150000}, {slot = 17, hp = 150000}, {slot = 18, hp = 150000}, {slot = 19, hp = 150000}, {slot = 20, hp = 150000}, {slot = 21, hp = 150000}}}, {label = 'FINS', color = {255, 120, 170, 210}, zones = { {slot = 24, hp = 5000}, {slot = 25, hp = 5000}, {slot = 26, hp = 5000}, {slot = 27, hp = 5000}, {slot = 28, hp = 5000}, {slot = 29, hp = 5000}, {slot = 30, hp = 5000}, {slot = 31, hp = 5000}, {slot = 32, hp = 5000}, {slot = 33, hp = 5000}, {slot = 34, hp = 5000}, {slot = 35, hp = 5000}, {slot = 36, hp = 5000}, {slot = 37, hp = 5000}}}, }
- Archives have a fixed format and names are hashes, turned back into names with the community's name list.
- Design values and struct definitions are available offline, and the type library is more reliable than a tool's built-in structs.
- Look up everything you can offline first, and you'll need far fewer trips into the game.
Check yourself: Why does FileDiver show some resources as a string of hex instead of a name?
The files store only the hash of each name. If the name list has no original name for that hash, the tool can only show the hash itself. A hash can't be reversed; the only way is to guess names, hash them and compare.
Observing in-game: Recon
- Recon mod
- A mod that only reads, never writes, and records what it observes in a log.
- PE format, sections
- The file format of an exe / dll; the regions it is split into (code, data…).
- dump
- Saving a block of memory to a file exactly as it is.
- Signature (AOB)
- A sequence of bytes to search for; it can contain wildcards.
The star of this chapter, Recon, is a research mod we wrote for building the HUD and never released. The point is not the tool itself but its principle: anyone can write their own recon mod the same way.
The idea behind Recon is simple: make the observation tool itself a mod. Like the HUD, it is loaded by the Loader and runs inside the game process (Chapter 3), and it uses FFI to read its own process's memory (Chapters 5 and 6) — so it never hits the anti-cheat check that blocks outside access.
| FileDiver | Recon (HD2Recon) | |
|---|---|---|
| What it is | An open-source HD2 resource unpacker (written in the Go programming language, xypwn/filediver), plus small tools we added | A research mod we wrote for this project (never released) |
| Where it runs | On your PC; the game doesn't need to be running | Inside the game process, loaded by the Loader just like the HUD |
| What it looks at | Resource files under data/ on disk, game data tables | Live memory (the unpacked game.dll, the heap) |
| Output | Files: glb models, png, JSON, text reports | HD2Recon_<time>.log and dump files |
| Does it touch the game? | No, it only reads files | Read-only; the one exception is the experimental hook_update (see below) |
How Recon works
GameGuard blocks external debuggers and memory readers, so you can't look at the game directly with tools like Cheat Engine or x64dbg. Recon's approach is to make the “recon” itself a mod: it runs inside the game, reads its own process's memory with LuaJIT FFI, and writes the results to a log.
The usage loop
- Write the commands for this round into
%TEMP%\HD2Recon_cmd.lua(plain Lua, e.g.R.scan('48 8B ?? 20')). - Start the game. Once the Loader loads Recon, it first writes the base address, size and sections of game.dll and helldivers2.exe to the log (
R.about). - It then reads the command file, compiles it with
loadstring, gives it an environment that can callR.*viasetfenv, and runs it insidepcall. An error only writes onerun errorline and doesn't affect the game. - Close the game, read the log, and decide what to check next. Every time you restart the game, the command file runs again.
Main functions
| Function | What it does |
|---|---|
R.about / R.walk | Parses the PE header: module base address, and each section's location and permissions. This is how we found that game.dll is packed with Themida: 7 unnamed sections, plus .winlice (where Themida runs part of the code as its own virtual instructions) and .boot (the part that decrypts the code at startup). |
R.scan / R.find | AOB signature scan (48 8B ?? 20, where ?? is a wildcard). It can scan only executable sections or everything (Themida puts code in unusual sections). |
R.rip | Decodes x64 RIP-relative offsets, computing from one instruction the address of the global variable or function it points to. |
R.dump / R.save | Hex-dumps a block of memory to the log, or saves it as a binary file. The layout of the Hive Lord's health record was worked out from dumps like this. |
R.strings / R.wstrings / R.find_string | Extracts ASCII / UTF-16 strings, or finds where a given string is. |
R.exports | Lists a module's exported functions. game.dll has only one: get_plugin_api. |
R.ldld_census | Scans the whole process's memory for game data-table blocks (starting with LDLD), then uses FileDiver's list of type names to identify which table each one is. Measured: 4.7 GiB in about 23 seconds, 587 blocks, 23 of 24 types identified. |
R.dump_module | Writes the whole game.dll as already unpacked in memory (about 71 MiB) to a file. The game.dll on disk is encrypted; this dump is the version you can disassemble, and all later static reversing (Ghidra, capstone) depends on it. |
R.hook_update / R.on_tick | Experimental. game.dll's get_plugin_api writes 11 callback function pointers into a struct in .data; one of them is update(dt), called on every simulation tick. This feature swaps that pointer for our own Lua function (it first checks that the slot holds the expected value before swapping, and always calls the original function), and measured the game simulation at about 63 Hz. This is the only place Recon writes, and what it writes is a pointer in the data section, not code. Early HUD versions (v0–v2) were driven by it; later ones wrap the global update() instead. |
For safety, before every read it uses VirtualQuery to confirm the memory is allocated, readable and not a guard page. It never calls VirtualProtect and never modifies code.
Its role in this project: the first 69 versions of the HUD shipped bundled with Recon (sharing its log and tick); only at v70 did it become the standalone Hive Lord HUD. Recon made the first dump of the HP record and the unpacked dump of game.dll; the locations of the health system and the visibility-mask system were then found by static analysis of that unpacked dump.
- v7: time-sliced search of the heap for a number sequence like [150000, 150000, 15000×6] (the first two zones, plus the plates' design HP).
- v8: once found, dumped the nearby memory and compared it with FileDiver's design values to work out the record layout: main HP at −0xE4, armour at −0x9C, zone HP starting at 0 (relative to the zone array), ending with 0xFFFF.
- v9 failed, v10 fixed it: using “main HP = 150000” as the anchor meant the record was lost as soon as the Hive Lord took damage; we switched to the armour values, which never change, as the fingerprint.
- hive_lord_hud.lua lines 51–60 A trace of being bundled with Recon: If the Recon research mod is present, lines go into its log. The first 69 HUD builds were packed together with Recon.
Show this code
local function writer(line) local R = recon() H.recent[#H.recent + 1] = line if #H.recent > 3 then table.remove(H.recent, 1) end line = '[HiveLordHud] ' .. line if R and R.write then R.write(line) return end print(line) local f = open_own_log() if f then pcall(function() f:write(line, '\n'); f:flush() end) end end
- The way to observe the game under anti-cheat is to make the observation tool a mod and read from inside the game.
- Control what to check with a command file, write results to a log, and run one round per game restart.
- Dumped memory plus FileDiver's design values lets you work out the data layout.
Check yourself: Recon and Cheat Engine both read game memory. Why is one blocked by anti-cheat and the other isn't?
Cheat Engine is an external program: it first has to ask Windows for a handle to the game, and the anti-cheat driver blocks it at that step. Recon is inside the game process and reads its own memory, so it doesn't need a handle.
Reading machine code: reverse engineering and assembly language
- Packing (Themida)
- Encrypting the code so it is only decrypted in memory at run time.
- Disassembly, assembly language
- Turning machine code into text instructions a human can just about read.
- Register
- One of the dozen or so very fast little slots built into the CPU.
- Cross-reference (xref)
- Finding “who uses this address”.
- Static, dynamic analysis
- Reading machine code without running the program; observing the program while it runs.
Reverse engineering means working out what a program does from its behaviour and machine code, without the source code. This guide uses two approaches:
Static analysis
Don't run the program; read the dumped machine code directly. Turn it into assembly language with a disassembler, then look for: who uses a given offset, who reads a given global variable, who calls a given function (this is called a cross-reference, xref).
Dynamic analysis
Observe the program while it runs: read some memory, watch how values change, count how often a function is called. Because anti-cheat blocks external debuggers, we made the observation tool a mod (Recon).
- Signature (AOB, array of bytes): a byte pattern that can contain wildcards, e.g.
48 8B ?? 20, used to find specific code in memory. - Fingerprint: a set of values that never change and are unique enough to confirm “what I found really is what I want”. For example, the armour values of the Hive Lord's 39 zones, which never change during a fight.
Why dump: packing
game.dll is packed with Themida: the file on disk is encrypted and only decrypted in memory when loaded. So disassembling the game.dll on disk just gives you garbage. You have to use Recon's dump_module to save the whole game.dll as already decrypted in memory (about 71 MiB), then analyse that.
Assembly language basics
A disassembler (capstone, Ghidra) turns machine code into assembly language: one very small action per line. For example, this one line of C++:
hp = std::min(hp, -1); // HP at most -1
mov eax, [r9+rdx*4+0xf8] ; read hp from memory mov ecx, 0xffffffff ; ecx = -1 cmp eax, ecx ; compare cmovl ecx, eax ; keep the smaller one in ecx mov [r9+rdx*4+0xf8], ecx ; write back to memory
- Registers:
rax,rcx,r9… are little slots built into the CPU; data has to be moved into them before any calculation. - Square brackets
[ … ]= read or write memory at this address.[r9+rdx*4+0xf8]= “record start + zone number × 4 + 0xF8”, which is exactly the offset from Chapter 7. - Global variables:
mov rdi, [rip + 0x2aa2ec6]= read the value stored at “address of the next instruction + 0x2aa2ec6”. Work out that offset and you get the global variable's RVA. - Function arguments: the first 4 go in
rcx,rdx,r8,r9;callcalls a function;cmp+je/jnecompare and jump (that's an if).
How the fixed starting point was found
Four steps
- First, learn from the game data which values to look for. FileDiver extracted the Hive Lord's HealthComponent: main HP 150,000, 15,000 per plate, and 39 fixed armour values (5, 6, 10, 5…). This set of numbers is the fingerprint that identifies it in memory.
- Search in-game to find the record. Units are handles, so you can't follow pointers to the component. Early versions (v7–v10) therefore scanned the whole heap in time slices looking for this set of numbers. Once found, we dumped the bytes around it and compared which values changed during a fight to work out the record layout: main HP +0x14, armour +0x5C, 38 zone HPs +0xF8, end marker 0xFFFF, 0x1B8 bytes per record.
- Work backwards from the code to the global variable that manages these records.
- Use the Recon addon to dump the whole game.dll as already unpacked in memory (the file on disk is encrypted by Themida).
- Scan all the code with Python + capstone for instructions that use these offsets (the 0x1B8 stride, +0xF8, +0x14), finding the damage function
0x9235F0and other health-related functions. - All of these functions load the system pointer from the same place,
[rip + X], which works out togame.dll + 0x3326688. Then reading how the functions look up the table gives the offsets of the key table, the capacity and the record array. - The visibility-mask system
+0x3326708was found the same way: from the gore function0x903C70we traced to the mask-setting function0x721D20, which also loads its system pointer from a global variable. The entry layout (applied / override / touched / base) was worked out by reading the code of the setter and the update function0x721680.
- Use it and verify. The program stores relative offsets (RVAs) and computes the real address at run time with
GetModuleHandleA('game.dll') + RVA. Every read re-checks the fingerprint and skips on a mismatch. In-game logs confirmed both are correct: v83 found the record on the first try, and after Plate Fix v3 wrote to it the plate was hidden within 0.1 seconds.
The matching code (hive_lord_plate_fix.lua, excerpt):
local HEALTH_RVA, VIS_RVA = 0x3326688, 0x3326708
local function table_header(base_rva, keys_off, cap_off)
local base = M.game_base() -- GetModuleHandleA('game.dll')
if not base then return nil end
local S = ptr(base + base_rva) -- system pointer stored in the global variable
if not S then return nil end
local keys, cap = ptr(S + keys_off), u32(S + cap_off)
if not keys or not cap or cap == 0 or cap > 0x40000 then return nil end
return S, keys, cap
end
-- ptr / u32 both go through ReadProcessMemory: a bad address just returns nil, no crash
Real assembly: how the game looks up health
This is real code from game.dll that looks up the health system before calling the damage function, along with the C++ reconstructed from it (the names are ours). Read it alongside the hash-table explanation in Chapter 7:
Before calling the damage function 0x9235F0, this code uses the entity number to find the record's index in the health system's hash table. 587 places in the code use the same global variable; this is one of them.
008837BB mov rdi, [rip+0x2aa2ec6] ; rdi = *(game.dll+0x3326688): health system
008837C2 mov ecx, r14d ; i = 0
008837C5 mov r9d, [rdi+0x1038] ; capacity (power of 2)
008837CC mov r10d, [rdi+0x1040] ; hash multiplier
008837D3 imul r10d, edx ; h = multiplier × entity (edx)
008837D7 lea r15d, [r9-1] ; mask = capacity - 1
008837E0 mov r11, [rdi+0x1030] ; key table: {entity, index} × capacity
008837E7 mov ebp, [rdi+0x103c] ; key value meaning “empty slot”
008837F0 mov eax, r15d
008837F3 lea r8d, [rcx+r10]
008837F7 and r8, rax ; slot = (i + h) & mask
008837FA mov eax, [r11+r8*8] ; key = keys[slot].entity
008837FE cmp eax, ebp
00883800 je 0x883860 ; empty slot: no such entity
00883802 cmp eax, edx
00883804 je 0x883864 ; found: index is at [r11+r8*8+4]
00883806 inc ecx ; next slot (linear probing)
00883808 cmp ecx, r9d
0088380B jb 0x8837f0
...
0088387C mov rcx, rdi ; 1st argument = health system
...
00883913 call 0x9235f0 ; damage function// game.dll+0x3326688
extern HealthSystem* g_health_system;
struct KeyValue { uint32_t entity, index; };
struct HealthSystem {
// ...
/* +0x1030 */ KeyValue* keys;
/* +0x1038 */ uint32_t capacity;
/* +0x103C */ uint32_t empty_key;
/* +0x1040 */ uint32_t multiplier;
// ...
/* +0x1058 */ HealthRecord* records; // 0x1B8 bytes each
};
uint32_t find_index(HealthSystem* hs, uint32_t entity) {
uint32_t mask = hs->capacity - 1;
uint32_t h = hs->multiplier * entity;
for (uint32_t i = 0; i < hs->capacity; ++i) {
KeyValue& kv = hs->keys[(i + h) & mask];
if (kv.entity == hs->empty_key) break; // not present
if (kv.entity == entity) return kv.index;
}
return NOT_FOUND;
}
// the damage function receives records[index]
apply_damage(g_health_system, entity, /* ... */);Our Lua uses the same table (HS_KEYS = 0x1030, HS_CAP = 0x1038, HS_RECS = 0x1058), but instead of computing the hash, it reads through the whole key table looking for the entity. It's a bit slower, but it doesn't need to know the details of the hash function, so it won't break if the game changes how it hashes.
game.dll + 0x3326688 (587 places in game.dll read it). From then on the HUD stopped searching the heap and instead follows the fixed path “base + RVA → health system → hash table → index → +0x14”. This change also fixed the problem from Chapter 11.- hive_lord_hud.lua lines 578–599 The health system found by reverse engineering: This comment records Chapter 10's result: how the damage function 0x9235F0 looks the table up, the global's RVA, the key-table and record-array offsets, and why a heap search can follow a frozen stale copy.
Show this code
-- ========================================================================= -- The live HP record straight from the game's health system (a game.dll global; RVA of build 25480438). -- The damage code (game.dll 0x9235F0) indexes it the same way: hash of (entity u32, index u32) at +0x1030, -- capacity at +0x1038, empty key at +0x103C, runtime records at +0x1058, 0x1B8 bytes each; zone HP at +0xF8 -- (our Z), main HP at +0x14. The game reallocates that array and the old copies stay readable and still pass -- our fingerprint, so a heap scan can end up following a frozen copy. Going through the global always lands on the -- live one; the heap scan is only the fallback when the global cannot be read (e.g. after a game update). -- ========================================================================= do local HEALTH_SYS_RVA, HS_KEYS, HS_CAP, HS_EMPTY, HS_RECS = 0x3326688, 0x1030, 0x1038, 0x103C, 0x1058 local REC_STRIDE, REC_Z = 0x1B8, 0xF8 local B8 = ffi.new('uint8_t[?]', 0x10000) local function u32(addr) if safe_read(addr, 4, B8) < 4 then return nil end return tonumber(ffi.cast('uint32_t*', B8)[0]) end local function ptr(addr) if not addr or safe_read(addr, 8, B8) < 8 then return nil end local p = tonumber(ffi.cast('uint64_t*', B8)[0]) if p < 0x10000 or p > USER_END then return nil end return p end - plate_fix.lua lines 4–14 The bug, found by reverse engineering: Zone state (collision) is always synced, but the gore that hides the model only runs on the machine that resolved the killing hit. The addresses in the comment were found with the methods of Chapter 10.
Show this code
-- A Hive Lord plate that is destroyed (zone HP -35000) always loses its collider and its crack overlay: that is zone -- state, synced to everyone (game.dll 0x925F10). The plate model itself is only hidden by gore (0x9057D0 -> 0x903C70), -- which runs only on the machine that resolves the killing hit, and only if that hit passes gore's checks. When -- gore never comes (seen on a client when a teammate's Eagle killed the plate), the plate stays drawn - looking -- intact again - while shots pass through it to the flesh behind. -- -- This module watches every Hive Lord through the game's own tables and, when a plate has been dead for -- FIX_DELAY seconds and is still drawn, writes the same "hidden" override bit that gore would have written. The -- game then updates the drawn mask itself. It only changes what this player sees: no damage, collision, network -- or code is touched. Every read/write goes through Read/WriteProcessMemory, so a stale address fails instead of -- crashing. RE notes: tools/research/hive_lord_armor_break.md. Globals are RVAs of build 25480438. - plate_fix.lua lines 112–124 Table layouts read from assembly: The globals, key tables and entry layouts of the health and visibility-mask systems, all read out of game.dll's assembly.
Show this code
-- ---------- game tables ---------- -- Health system (game.dll+0x3326688): keys (entity u32, index u32) @+0x1030, capacity @+0x1038, empty key @+0x103C, -- records @+0x1058, 0x1B8 bytes each: zone HP i32[38] @+0xF8, armor i32[39] @+0x5C, 0xFFFF marker @+0x190. -- Visibility-mask system (game.dll+0x3326708): keys @+0x18, capacity @+0x20, entries @+0x38 (24 bytes: applied -- mask @+0, override bits @+8, touched bits @+16), base masks @+0x40 (8 bytes each). -- The setters (0x721D20 & co.) push to the material at once only while the unit's render record is live -- ([rec+0x14] bit 0); then they write the base mask. Otherwise they only write override/touched, and the updater -- 0x721680 later pushes (base & ~touched) | (override & touched) and keeps that in the entry's applied mask. -- So "drawn" must not be judged by the base mask alone (the non-live path never changes it): a plate counts as -- hidden when either the base or the applied mask has its bit. Every try logs all four masks. local HEALTH_RVA, VIS_RVA = 0x3326688, 0x3326708 local REC_STRIDE = 0x1B8 local Z_OFF, ARMOR_OFF, MARKER_OFF = 0xF8, 0x5C, 0x190
- A packed program has to be dumped from memory in its unpacked form before you can analyse it.
- In assembly,
[register + offset]is a struct field, and[rip + X]is a global variable. - Find the global variable the game itself uses to find its data, and your mod has a fixed, reliable starting point.
Check yourself: Why look for the global variable “the game itself uses” instead of continuing to search the heap for the fingerprint?
The game's own path always points to the copy of the data the game is actually using. A heap search can find a stale copy that was thrown away but is still sitting in memory (Chapter 11), and searching is also slow and time-consuming.
Reading safely and correctly
- ReadProcessMemory
- The Windows API for reading memory: a bad address just returns a failure.
- Stale copy
- Data the game no longer uses, but that is still sitting in memory.
- Time budget
- How much time the mod is allowed to spend each frame.
Direct reads vs ReadProcessMemory
If you use FFI to turn an address into a pointer and read it directly, a bad address means an access violation and the game crashes (Chapter 6). Recon first asks VirtualQuery “is this range readable?” and then reads. But there is a tiny gap between the “ask” and the “read”, and another game thread might free that memory right in that gap.
ReadProcessMemory has no such gap: if it can't read, it returns a failure instead of crashing. So the HUD and Plate Fix, which players use, always use it to read their own process; Recon is a development tool, so it accepts the small risk of direct reads. The HUD's offline tests actually caught this once: while reading the heap directly, memory was freed between the check and the read, and the test program crashed.
“Freed” does not mean “gone”: stale copies
When a C++ program gives back a block of memory, or an array grows and moves to a new place, the old block is usually just handed back to the program's own allocator. The contents are still there, and still readable — the game just stops updating them. If a mod finds data by “searching memory”, it may find one of these stale copies: the fingerprint is completely correct, but the values are frozen.
Validate every read
- On every read of a record, re-check the fingerprint (39 armour values + the 0xFFFF marker) and the value ranges (each zone's HP can't exceed its maximum). If anything doesn't match, treat it as not found and don't use that data.
- The game moves things around: every frame, re-read the array location from the global variable, and once a second, look up the record index again by entity ID.
- Have a fallback when nothing is found: if the global variable can't be read (for example, its location changed after a game update), fall back to the slower heap search and write the reason to the log.
Time budget
Searching the heap means reading several GB of memory; doing it all at once would freeze that frame for several seconds. So you split it into small pieces and do a little each frame. The HUD first spent 8 ms per frame, which cut FPS in half at 130 FPS; later it was lowered to 4 ms. After switching to going straight through the global variable, the normal per-frame cost dropped to a fraction of a millisecond.
- hive_lord_hud.lua lines 520 Time budget: The heap search may spend at most 4 ms per frame. It used to be 8 ms, which halved the FPS at about 130 FPS.
Show this code
local SLICE_S, CHUNK = 0.004, 1024 * 1024 -- 4 ms per frame (was 8: halved the FPS while it ran at ~130 FPS) - hive_lord_hud.lua lines 532–542 Every read goes through ReadProcessMemory: The comment says it: heap memory can be freed between the check and the read, and a raw read would crash; ReadProcessMemory just fails.
Show this code
-- All reads go through ReadProcessMemory on our own process: heap memory can be freed by another -- thread between a VirtualQuery check and a raw pointer read (that segfaulted in testing); RPM just fails. local SELF = k32.GetCurrentProcess() local NREAD = ffi.new('size_t[1]') local SCRATCH = ffi.new('uint8_t[?]', CHUNK) local WIN = ffi.new('uint8_t[?]', WIN_LEN) local function safe_read(addr, n, dst) if k32.ReadProcessMemory(SELF, ffi.cast('const void*', addr), dst, n, NREAD) == 0 then return 0 end return tonumber(NREAD[0]) end - hive_lord_hud.lua lines 663–689 The fallback: Take the fixed health-system path first; only if the global can't be read (for example after a game update) fall back to the slower heap search, and log why.
Show this code
function H.locator_start() local list, why = H.direct_find() if list then H.loc = {phase = 'done', direct = true, cands = {}, adopted = nil, t0 = now_s(), scanned = 0, anchors = 0, last_poll = -1e9} local ids = {} for _, c in ipairs(list) do H.loc.cands[#H.loc.cands + 1] = {z = c.z, ent = c.ent, idx = c.idx, changed = -1e9} ids[#ids + 1] = string.format('entity 0x%X index %d', c.ent, c.idx) end if list[1] then writer('HP record found via the health system: ' .. table.concat(ids, ', ')) H.direct_waiting = nil else H.loc.retry_at = now_s() + 3 if not H.direct_waiting then writer('health system readable, no Hive Lord record yet - retrying every 3s') end H.direct_waiting = true end return end if not H.direct_fail_logged then writer('direct HP lookup unavailable (' .. tostring(why) .. ') - using the heap scan') H.direct_fail_logged = true end H.loc = {phase = 'scan', addr = 0x10000, rbase = nil, rend = nil, off = 0, scanned = 0, anchors = 0, cands = {}, adopted = nil, t0 = now_s()} writer('heap scan started (time-sliced, ~4ms/frame)') end - hive_lord_hud.lua lines 692–706 Don't find your own copy: The mod's own buffers hold copies of what it just read; the search must exclude them or it finds itself.
Show this code
local OWN = { {tonumber(ffi.cast('uintptr_t', SCRATCH)), CHUNK}, {tonumber(ffi.cast('uintptr_t', WIN)), WIN_LEN}, } local function test_anchor(L, hit) for _, r in ipairs(OWN) do if hit >= r[1] - WIN_LEN and hit < r[1] + r[2] then return end end L.anchors = L.anchors + 1 if #L.cands < 8 and read_record(hit) then for _, c in ipairs(L.cands) do if c.z == hit then return end end L.cands[#L.cands + 1] = {z = hit, sig = signature(), changed = -1e9} end end - hive_lord_hud.lua lines 710–745 Time-sliced search: Each frame searches a small piece and stops when its time is up; the next frame continues.
Show this code
local function scan_step(L) local t_end = clock() + SLICE_S while clock() < t_end do if L.rbase == nil then if L.addr >= USER_END or k32.VirtualQuery(ffi.cast('const void*', L.addr), mbi, MBI_SIZE) == 0 then return true end local m = mbi[0] local rb, rs = tonumber(ffi.cast('uintptr_t', m.BaseAddress)), tonumber(m.RegionSize) if rb + rs <= L.addr then return true end L.addr = rb + rs if m.State == MEM_COMMIT and m.Type == MEM_PRIVATE and m.Protect == PAGE_READWRITE then L.rbase, L.rend, L.off = rb, rb + rs, 0 end else local take = math.min(CHUNK, L.rend - L.rbase - L.off) local cbase = L.rbase + L.off local got = safe_read(cbase, take, SCRATCH) -- Compare aligned int32s in place: building a Lua string would leave stale copies of the -- record in the heap for later scans to find. local ok = got == take if ok then local words = ffi.cast('const int32_t*', SCRATCH) for i = 0, got / 4 - 1 do if words[i] == 6 and words[i + 1] == 10 and i + 1 < got / 4 then test_anchor(L, cbase + i * 4 - OFF_ARMOR0) end end end L.off = L.off + take -- … (36 lines in total; see the source page for all of it) - hive_lord_hud.lua lines 775–878 Re-validate every second: Once a second, find the record again by entity and validate it; if it moved or was freed, search again instead of drifting to some other idle record.
Show this code
function H.read_hp() local hp = {main = nil, zones = {}} local L = H.loc if not L then return hp end if L.phase == 'done' and not L.adopted and L.retry_at and now_s() >= L.retry_at then H.locator_start() return hp end if L.phase ~= 'done' or not L.cands[1] then return hp end local t = now_s() if t - L.last_poll >= 1 then L.last_poll = t local keep = {} for _, c in ipairs(L.cands) do if L.direct then c.z = H.direct_z(c, true) end if c.z and read_record(c.z) then local sig = signature() if sig ~= c.sig then c.sig, c.changed = sig, t end keep[#keep + 1] = c end end L.cands = keep if not keep[1] then writer('HP records moved or freed - rescanning') H.locator_start() return hp end -- The Hive Lord being fought is the record whose HP changed most recently. local still_valid = false for _, c in ipairs(keep) do if c == L.adopted then still_valid = true end end -- … (104 lines in total; see the source page for all of it) - plate_fix.lua lines 51–87 ReadProcessMemory here too: "Never dereference raw pointers: the game frees and moves these tables." The same safe reads as the HUD.
Show this code
-- ---------- memory access (never dereference raw pointers: the game frees and moves these tables) ---------- pcall(ffi.cdef, 'void* GetModuleHandleA(const char* name);') pcall(ffi.cdef, 'void* GetCurrentProcess(void);') pcall(ffi.cdef, 'int ReadProcessMemory(void* process, const void* addr, void* buf, size_t size, size_t* read);') pcall(ffi.cdef, 'int WriteProcessMemory(void* process, void* addr, const void* buf, size_t size, size_t* written);') pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* c); int QueryPerformanceFrequency(int64_t* f);') local k32 = ffi.load('kernel32') local SELF = k32.GetCurrentProcess() local NIO = ffi.new('size_t[1]') local BUF = ffi.new('uint8_t[?]', 0x10000) local REC = ffi.new('uint8_t[?]', 0x1B8) local WB = ffi.new('uint8_t[1]') local USER_END = 0x7FFFFFFFFFFF local function safe_read(addr, n, dst) if k32.ReadProcessMemory(SELF, ffi.cast('const void*', addr), dst, n, NIO) == 0 then return 0 end return tonumber(NIO[0]) end local function u32(addr) if safe_read(addr, 4, BUF) < 4 then return nil end return tonumber(ffi.cast('uint32_t*', BUF)[0]) end local function ptr(addr) if not addr or safe_read(addr, 8, BUF) < 8 then return nil end local p = tonumber(ffi.cast('uint64_t*', BUF)[0]) if p < 0x10000 or p > USER_END then return nil end return p end local function mask(addr) if safe_read(addr, 8, BUF) < 8 then return nil end -- … (37 lines in total; see the source page for all of it)
- Mods for players should always use ReadProcessMemory: a bad address just fails, it doesn't crash.
- Freed memory is still readable, so a search may find a frozen stale copy; starting from the game's own pointers is the reliable way.
- Validate every read, cap the time spent each frame, and have a fallback that writes to the log when things fail.
Check yourself: a stale copy has a “completely correct fingerprint”, so how do you guard against it?
A fingerprint can only prove “this is a Hive Lord record”, not “this is the copy the game is using right now”. So you can't rely on searching; you have to go through the game's own global variable and hash table: whichever copy the game is currently using, that path points to it.
The skeleton of a mod
- Singleton guard
- A unique global variable that makes sure the mod is only loaded once.
- Disarm
- After an error, the mod permanently stops itself so the game carries on as normal.
- Another mod's API
- Functions that a mod such as Mod Options Menu exposes for other mods to call.
The header of the Hive Lord HUD source code lists its sections, and they line up exactly with the principles from the earlier chapters, in order:
| Section | What it does | Matching principle |
|---|---|---|
| Hive Lord data | Design values for the 38 zones | Chapters 7, 8 |
| Detection | Finds the Hive Lord unit, distance, emerge state | Chapter 4 (engine API) |
| Live HP locator | Finds the HP record from the global variable; falls back to searching if not found | Chapters 6, 10, 11 |
| Zone tracking | Compares zone state frame by frame to work out who changed it | Chapter 15 (multiplayer) |
| GUI | Retained-mode primitives, fade in/out, only sends what changed | Chapter 4 |
| Per-frame worker | The main flow for each frame | Chapter 2 |
| Settings | Config file + Mod Options Menu | This chapter |
| Install | Wraps the global update() | Chapters 2, 4 |
Rules every mod should follow
- Singleton guard: at the start, check a unique global variable; if it already exists, don't run again.
- Environment check: if a required API or the Loader is missing, write to the log and stop, instead of forcing it to run.
- Wrap update, and disarm on error: wrap the per-frame work in
pcall; on the first error, permanently shut yourself off, clear the screen, and write down the reason. - Always call the original update.
- Check before you call: the world still exists, the unit is still alive (engine errors can't be recovered from).
- Time budget and self-timing: measure how much time each frame takes and write it to the log regularly.
- Rate-limit the log: only write state changes, not one line per frame.
Working with other mods: the settings menu
Mod Options Menu is also a mod loaded through the Loader. It puts a global ModOptionsMenu table in place; other mods can call register_option to register settings and use get to read values. Because the load order between mods is not guaranteed — so the HUD doesn't look for it at load time; it looks for it inside update, and only registers once it finds it. If it isn't installed, the HUD uses the values from its config file.
- hive_lord_hud.lua lines 6–14 Section index: The order of the whole file, which is exactly the table in Chapter 12: data, detection, HP locator, zone tracking, GUI, per-frame worker, settings, install.
Show this code
-- Sections (in file order): -- Hive Lord data zone layout, max HP / constitution per zone (from the unpacked HealthComponent) -- Detection finding the Hive Lord unit, its distance, surfacing and the emerge / pre-emerge states -- Live HP locator the HP record via the game's health system (heap scan only as a fallback) -- Zone tracking per-frame zone bookkeeping; host state events and the '<' upper-bound marker -- GUI prims and groups: status text, armour panel (poly / pixel), tag, warning, preview, boss bar -- Per-frame worker world selection, gui lifetime, render() -- Settings cfg file and the Bingus Mod Options Menu rows -- Install the wrapper around the engine's global update() - hive_lord_hud.lua lines 24–26 Singleton guard: If it has already been loaded, stop right here. The same global table also holds the whole HUD state.
Show this code
if rawget(_G, 'HD2HiveLordHud') then return end local H = {version = 121, release = 'v3', armed = false, faults = 0, frame = 0} rawset(_G, 'HD2HiveLordHud', H) - hive_lord_hud.lua lines 2251–2331 The per-frame worker: Look for units every 2 s, read HP every frame, handle death and the corpse, update distance and animation state, then render, and time itself.
Show this code
function H.update() H.frame = (H.frame or 0) + 1 -- Lua update rate (the overlay can only move this often), logged every 30 s while a Hive Lord is tracked. local tr = now_s() H.rate_t0 = H.rate_t0 or tr H.rate_n = (H.rate_n or 0) + 1 if tr - H.rate_t0 >= 30 then local c = H.cost if H.live.unit and c then local normal_n = c.n - c.scan_n writer(string.format('update rate: %.0f Hz | HUD cost: avg %.3f ms, max %.2f ms per frame%s', H.rate_n / (tr - H.rate_t0), normal_n > 0 and (c.sum - c.scan_sum) / normal_n or 0, c.max, c.scan_n > 0 and string.format(' | during HP search: %d frames, avg %.2f ms', c.scan_n, c.scan_sum / c.scan_n) or '')) end if c then c.sum, c.max, c.n, c.scan_sum, c.scan_n = 0, 0, 0, 0, 0 end H.rate_t0, H.rate_n = tr, 0 end local L, t = H.live, now_s() t_now = t -- A dead world already took its gui with it: forget, never destroy. if H.ui.gui ~= nil and not world_is_live(H.ui.world) then H.ui.gui, H.ui.world, H.grp = nil, nil, {} end if t - L.last_scan >= 2 then L.last_scan = t local worlds = attempt(App.worlds) if type(worlds) ~= 'table' then return end local u, _, uname = H.find_hive_lord(worlds, t) local a = u and (unit_addr(u) or u) -- … (81 lines in total; see the source page for all of it) - hive_lord_hud.lua lines 2510–2559 Working with another mod: the options menu: Mod Options Menu is a global API provided by another mod. The HUD only looks for it inside update (load order isn't guaranteed), registers its rows when found, and reads the values every 10 frames.
Show this code
function H.options_register(M) local function reg(id, spec) local ok, done, why = pcall(M.register_option, id, spec) if not (ok and done) then writer('mod options: register ' .. id .. ' failed: ' .. tostring(ok and why or done)) end return ok and done end H.option_ok = reg(OPTION_ID, { type = 'toggle', mod = 'Hive Lord HUD', label = 'Enable Hive Lord HUD', default = true, description = 'Turns the whole Hive Lord HUD on or off: HP bar, armor panel, tag, distance and ' .. 'emerge warning. Off stops all of its work.'}) if not H.option_ok then return end local n = 0 for _, o in ipairs(H.OPTIONS) do local spec = {type = o.type, mod = 'Hive Lord HUD', label = o.label, description = o.description, gap = o.gap, default = o.default, choices = o.choices, step = o.step} if o.type == 'choice' then spec.default = to_menu(o, o.default) end if o.type == 'slider' then spec.min, spec.max = SIZE_RANGE[o.key][1], SIZE_RANGE[o.key][2] end o.ok = reg(H.opt_id(o), spec) if o.ok then n = n + 1 end end writer(string.format('mod options: registered %s + %d of %d settings', OPTION_ID, n, #H.OPTIONS)) pcall(M.set, 'hivelordhud.preview', false) -- preview is per session: never comes back on by itself -- cfg.menu: 0 = menu never seen (copy every saved setting into it); 1, 2 = older menu layouts (copy the rows -- added since, see `since`); 3 = the menu is the source. if (H.cfg.menu or 0) < 3 then for _, o in ipairs(H.OPTIONS) do if o.ok and (o.since or 1) > (H.cfg.menu or 0) then pcall(M.set, H.opt_id(o), to_menu(o, H.cfg[o.key])) end end writer('mod options: copied saved settings into the menu (from cfg level ' .. tostring(H.cfg.menu) .. ')') H.cfg.menu = 3 -- … (50 lines in total; see the source page for all of it) - plate_fix.lua lines 287–304 A one-row options menu: With Mod Options Menu present, register one toggle and read it every 10 frames; without it, the fix is always on.
Show this code
function M.poll_option() local MOM = rawget(_G, 'ModOptionsMenu') if type(MOM) ~= 'table' then return end if not M.option_tried then M.option_tried = true local ok, done = pcall(MOM.register_option, OPTION_ID, { type = 'toggle', mod = 'Hive Lord Plate Fix', label = 'Fix dead plates that stay on', default = true, description = "A plate destroyed by damage your game did not resolve itself (e.g. a teammate's Eagle) loses its collision but can stay drawn, looking intact. When a plate has been at 0% for 1.5 s and is still drawn, hide it the way the game would have. Only changes what you see."}) M.option_ok = ok and done if not M.option_ok then writer('mod options: register failed') end end if not M.option_ok or M.frame % 10 ~= 0 then return end local ok, v = pcall(MOM.get, OPTION_ID) if ok and type(v) == 'boolean' and v ~= M.enabled then M.enabled = v writer('fix ' .. (v and 'enabled' or 'disabled') .. ' from Mod Options Menu') end end
- Mod skeleton: singleton guard → environment check → wrap update (pcall + disarm) → per-frame work.
- Each rule matches one of the earlier principles, and most were added after a crash.
- Mods can work together, but you can't assume a load order.
Check yourself: why is “shut down permanently on error” better than “try again next frame on error”?
The same error usually happens again on the next frame. Retrying over and over means an error every frame, a flooded log, and possibly continuing to call the engine API in a bad state until the game crashes. Shutting down permanently only loses this mod's features; the game stays safe, and the log keeps the reason for the first error.
Pretending to be the game on your PC: offline testing
- lupa
- A Python package for running Lua inside Python; it has a LuaJIT build.
- Stub
- A fake function that imitates a game API.
- Mutation testing
- Deliberately breaking the code to confirm the tests fail.
The principle: a mod only interacts with the game through three channels — the engine API, FFI memory reads, and the Loader. If you replace all three with fakes, the mod can't tell whether it's inside the game or not.
- The same Lua: use lupa's LuaJIT build to run the real mod source. With a newer Lua, syntax the game doesn't support, such as
//, passes the tests and only fails to load once you're in the game. - A fake engine: write your own
World,Unit,Gui,Camerathat record what the mod called. You can make them deliberately copy the game's bad habits, such as “crash if called on a dead unit”, to confirm the mod never does that. - Fake memory: put a fake health system, hash table and HP records into real memory, and let the mod find them along the real path. Then add a few decoys that “look right but aren't”, to confirm the validation rejects them.
- Mutation testing: deliberately break the code (for example, remove the fingerprint check); the tests must fail. If they can't catch a bug you planted on purpose, the tests aren't really checking anything.
- You can see the screen offline too: record the
Gui.rectandGui.textcalls the mod makes, draw them as an image with Python, and you can check the layout before going in-game. The Hive Lord HUD's showcase images and mod icon were made this way.
test_v83.py, test_v108.py…), and most versions record “how many mutants were killed”. They caught plenty of bugs that would have broken things in-game, for example: using a table before it was defined (the mod would just disarm in the game), and a diagnostics buffer mistakenly accepted as an HP record (it passed the fingerprint check too). We also hit a pitfall in the tests themselves: a test read and wrote the real config file and changed the player's settings, so after that every test was redirected to a temp folder.- plate_fix.lua lines 100–110 Seams for the tests: Both the clock and the game.dll base can be replaced by tests, so offline tests can run the module against fake memory structures.
Show this code
function M.now() -- seconds; tests replace this k32.QueryPerformanceCounter(QB) return tonumber(QB[0]) / QFREQ end function M.game_base() if M.GAME_BASE then return M.GAME_BASE end -- tests local ok, m = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.C.GetModuleHandleA('game.dll'))) end) if ok and m and m ~= 0 then return m end return nil end
- A mod interacts with the game through only three channels; replace them all with fakes and it runs offline.
- Stubs should copy the game's strictness and bad habits, and decoys should be good enough to fool careless validation.
- Mutation testing confirms that “the tests really are checking something”.
Check yourself: all tests pass, but it fails in the game. What is most likely different?
The stubs differ from the real game: they're too lenient (a call that crashes the real engine doesn't crash the fake one), the data has a different shape (the tests only built one memory layout), or a different Lua version was used. The fix is to add that difference to the stubs, then add a matching mutation test.
Packing and deploying: turning code into a patch file
- manifest.json
- The description file the mod manager reads: name, description, options.
- GUID
- The mod's permanent ID number. It never changes.
- slot
- The numbered position of a patch file inside
data/.
What a patch file looks like
The packing tool simply writes the archive format from Chapter 1 in reverse: a header, a type table (only one type: lua), a file table (name hash, position, size), and finally the data. The data itself is “length + 2 (meaning plain text) + source code”, aligned to 16 bytes. Type a resource name and some code below to see the actual bytes (same algorithm as our packing tool):
The zip and the manifest
What the mod manager imports is a zip: manifest.json (name, description, options), one folder per option (holding a patch file), and Source/ (plain source code, so people can review it). A few key points:
- The GUID never changes: the manager uses it to recognise “this is a new version of the same mod”. Hive Lord HUD's GUID has been
4ef072f2-…from v1 to v3. - Name uses only English letters, digits and spaces: it becomes a folder name, and a single colon makes the import fail.
- Options = separate patch files: Hive Lord HUD has two options, “Hive Lord HUD” and “Dead Plate Fix”. Each is its own patch file, so players can install just one of them.
Deploying: the manager renumbers slots
Arsenal reassigns patch numbers on every deploy, and each option takes its own slot. During development we copy the new patch file straight into data/ to save time — so you must not hard-code the number. Use the file contents (look for your own -- HD2-Addon: line) to confirm which slot is yours, and also update the copy Arsenal keeps for itself, or the next Deploy will put the old version back. You also can't deploy while the game is running: Lua is only loaded at startup.
build_hive_lord.py: Release v1 = internal build 70, v2 = build 87 (added the Dead Plate Fix option), v3 = build 121.- Packing means writing a patch file in the archive format that holds only lua resources, with names hashed by murmur64.
- The manifest's GUID never changes, Name uses only safe characters, and each option is its own patch file.
- The manager renumbers slots, so any direct deploy must first find your own slot by content.
Check yourself: What happens if every release gets a new GUID?
The mod manager treats each version as a different mod. The player's list shows several Hive Lord HUDs at once, old versions are never replaced, and two of them may even load together.
Multiplayer and game updates: the boundaries of a mod
- host, client
- The person who opens the game; the people who join.
- authority
- Who has the final say. The host decides many things; clients only receive the results.
- build
- The game's version number.
A client doesn't always see the whole picture
In a multiplayer game, the host decides many things (enemy AI, whether a zone gets broken), and clients only receive the results. In HD2, each machine simulates the bullets and damage it can see, and the host only sends results like “state changed” or “died”. So the numbers in a client's memory can differ from the host's, and a mod can only read its own machine.
The author always joins other people's games as a client, so the HUD has to be honest about one thing: zone HP on a client isn't necessarily true. From 30+ game logs and analysis of the network code in game.dll:
| Situation | What the client receives | How the HUD shows it |
|---|---|---|
| There from the start | Each machine simulates damage on its own; main HP is roughly accurate, and at the end the host's death event sets main HP to zero | Shown as is |
| Joining mid-fight | Main HP is a snapshot (may be 0–51k out of date); already-cracked plates receive an exact -1; plates with only partial damage start from full HP | Plates and main HP get a “<” prefix (at most this much left); plates that received -1 show <70% |
| Host corrects the state | Zone state (cracked / destroyed) is decided only by the host; when the event arrives, HP is forced to min(HP, -1) or min(HP, -35000) | After the first time we see the host correct us (“we're behind”), every plate % gets a “<” |
Telling who caused a state change (H.state_diag): if HP lands exactly on -1 or -constitution in the same frame and main HP didn't drop → host event; if HP was pushed past the threshold by damage and main HP dropped at the same time → local hit. In the log of one client-side fight, all 14 state changes came from the host, and our HP was behind every time and never ahead — so “<” means an upper bound, not an estimate.
rpc_heal / rpc_synced_health_request). Conclusion: that path doesn't work (Hive Lord isn't in sync system 0x90), and sending network requests would affect other people's games, so for safety we don't do it either.This also decides what a client mod can't do: for example “keep the Hive Lord from running too far”. The AI runs on the host, and a client's mod can't change it.
Game updates change every address
When the game updates, game.dll is recompiled: the locations of functions and global variables (RVAs), struct offsets, and table sizes can all change. Every address in this document is valid only for build 25480438. So a mod should be designed to “stop safely when things don't match”:
- Verify the fingerprint on every read; if it's wrong, don't use it (Chapter 11).
- Have a fallback when the main path fails (the HUD falls back to a heap search; Plate Fix simply stops acting).
- Write clearly in the log “why it stopped”, so when players report it you know the update caused it.
- The Loader itself is also tied to the game version; after a big update, wait for the Loader to update first.
- hive_lord_hud.lua lines 880–944 Who changed this zone?: Compare zone states every frame. HP landing exactly on -1 or -constitution while main HP did not drop means a host event; from the first host correction on, plate % get a '<'.
Show this code
-- ========================================================================= -- Zone tracking -- ========================================================================= -- Per-frame zone bookkeeping for H.state_diag (last zone HP, last main HP), plus one log line for a big one-frame main -- HP drop or the drop to 0 at death (the host's death event snaps a client's main HP to 0). Research logging (per- -- second zone changes, raw record dumps, late-join hit logs) lives in the optional diag module (hive_lord_diag.lua). function H.track_zones(L, hp) local t = now_s() local d = L.diag local id = L.adopted.ent or L.adopted.z -- the direct lookup's address moves with the array; the entity doesn't if not d or d.id ~= id then d = {id = id, last = {}} L.diag = d for slot = 0, 37 do d.last[slot] = hp.zones[slot] end return end H.state_diag(d, hp, t) if hp.main and d.last_main and (d.last_main - hp.main >= 8000 or (hp.main == 0 and d.last_main > 0)) then writer(string.format('MAIN JUMP t=%.1f: %d -> %d in one frame (drop %d)', t, d.last_main, hp.main, d.last_main - hp.main)) end d.last_main = hp.main for slot = 0, 37 do local v = hp.zones[slot] if v ~= nil then d.last[slot] = v end end end -- Who changed a zone's state? Our machine only resolves the hits it simulates; the host sends zone-state -- events, which the state machine applies with HP = min(HP, -1) (cracked) or min(HP, -constitution) (dead). -- So: a state change that lands HP on exactly -1 / -constitution in a frame where our main HP did not drop came from -- … (65 lines in total; see the source page for all of it)
- A client mod only sees data on its own computer and can't change what the host decides.
- Displayed numbers should honestly mark their uncertainty.
- Game updates break addresses; a mod should “stop safely when things don't match”.
Check yourself: Why does the HUD get slower but keep working after a game update, while Plate Fix stops acting entirely?
The HUD only reads data, so when a global variable breaks it can fall back to a heap search that is slower but doesn't depend on addresses. Plate Fix writes the mask, and writing to the wrong place has serious consequences — so whenever the location doesn't match, it writes nothing at all. Better not to fix than to take the risk.
Anti-cheat: the space a mod operates in
- anti-cheat (GameGuard)
- A program that watches for outside programs tampering with the game.
- code-hash check
- Regularly checking whether the code has been modified.
- injection
- Putting your own code into another process and running it there.
What GameGuard does
GameGuard (nProtect) is Helldivers 2's anti-cheat. It consists of a kernel driver and a monitoring process (GameMon) that starts with the game. Its internals aren't public; what follows is pieced together from its behaviour and what we actually ran into:
| What it does | What we actually saw |
|---|---|
| Scans programs on your computer and matches them against signatures of known cheat tools | If your computer has a program it flags as a cheat tool, the game shows “Suspicious program detected” and closes. Tools that claim to bypass anti-cheat are especially dangerous — many of them are trojans themselves. |
| Protects the game process: blocks outside programs from opening a handle to the game to read/write memory or inject (put their own code into the game process and run it), and detects debuggers | After the game crashes, the process can't be killed (Task Manager's End task “succeeds” but the process is still there) — only a reboot helps. |
| Checks code integrity: regularly hashes the code sections | That's why we never modify code bytes and never hook functions. |
| Updates itself on every launch; logs are encrypted and rotated on every launch | You can't read the logs yourself; if there's a problem, all you can do is send them to Arrowhead support. |
Separately, game.dll itself is packed with Themida. That's a different layer of protection, not part of GameGuard. The game.dll on disk is encrypted; it only becomes unpacked code once it's loaded into memory.
- Packing (Themida): encrypts the code and only decrypts it in memory at run time. So the game.dll on disk is unreadable; you have to dump it from the running process's memory to analyse it.
- Zombie process: after the game crashes, the process is still protected by the anti-cheat, Task Manager can't end it, and only a reboot helps. We ran into this several times during development.
- Code-hash check: regularly hashes the code sections (subsection 3); if even 1 byte is changed, the hash won't match. This is also why the mods never modify code.
Why Plate Fix reads and writes memory, yet GameGuard doesn't care
Because GameGuard mainly guards against “outside programs touching the game”, while Plate Fix is the game touching its own memory. From the operating system's point of view, these are two completely different things.
1. No crossing processes
A cheat is usually a separate exe: it calls OpenProcess to get the game's handle, then reads and writes memory. GameGuard's driver intercepts exactly that step. Plate Fix runs inside the game's built-in LuaJIT, and the WriteProcessMemory(GetCurrentProcess(), …) it uses takes the process's “own” pseudo-handle (-1). It never opens any outside handle, so it never goes through the step that gets blocked. To the system, this is the same as the game running *p = x itself, just with the extra protection that a wrong address returns a failure instead of crashing.
2. It writes data, not code
Code doesn't change, so it can be hashed and compared. Data like HP, masks and coordinates is written by the game itself millions of times a second, so there's no way to check whether it was altered. Plate Fix writes only 1 byte in a mask entry, and the value is exactly what gore would write anyway — a state that already appears when the game runs normally.
3. It scans for cheat programs, not game resources
GameGuard matches running programs against known cheat signatures; it doesn't go through the Lua resources the game loads one by one to see what they do. The whole Bingus mod scene works this way. For example, Megapack's Enemy Collision Synchronized calls internal game.dll functions directly through FFI, and first-person mods allocate executable memory and swap function pointers in the Camera API. Both are far more invasive than Plate Fix, and neither gets blocked.
4. Nothing for the server side to notice
Plate Fix doesn't change damage, collision or network packets; it only changes one model mask on your own screen. The host and other players receive no difference at all.
Why the data and Lua we add don't get caught
In one sentence: anti-cheat like GameGuard looks for foreign code and foreign access (injected DLLs, handles opened by outside programs, modified code, known cheat programs). What a mod adds is data, which is then read and run by code the game already has. From the anti-cheat's point of view, it can't be told apart from the game running normally. Layer by layer:
| What we add | Who reads / runs it | Why it isn't treated as cheating |
|---|---|---|
patch filedata/9ba626afa44a3aa3.patch_N | The game's own resource system (Stingray engine) | Overriding original resources with patch files is a mechanism the engine already has. The game doesn't verify resource file signatures at run time (otherwise no mod could load at all); only Steam's “Verify integrity of game files” compares them, and that simply swaps the files back to the originals. The anti-cheat doesn't check the contents of game resource files. |
| Lua source code (plain text) | The game's bundled LuaJIT (bin/lua51.dll, version 2.1) | No new DLL is loaded, no outside thread is created, and not a single byte of code is changed. To the operating system, it's just the game's own interpreter processing some text. |
| Machine code generated by the JIT | LuaJIT compiles frequently run Lua into machine code and puts it in executable memory it allocates itself | The game already does this whenever it runs any Lua (Loader v18 enlarged exactly this cache). If anti-cheat banned “new executable memory appearing in the process”, it would block the game's own Lua too. |
FFI calls to the Windows APIReadProcessMemory etc. | The game process calls kernel32 itself | The caller is the game process itself, using its own pseudo-handle (-1). There is no “outside program touching the game” at all. |
| That 1 written byte (Plate Fix) | The game's mask update function reads it on the next frame | It's written in the data section, with the same value gore would write; the code-hash check only looks at the code sections. |
Compare that with how real cheats usually work: a separate exe opens the game's handle (intercepted by the driver), injects a DLL into the game (an unknown module shows up), or modifies code for infinite bullets (the code hash no longer matches). Mods do none of these three things.
This also explains why GameGuard can block an outside Cheat Engine but not Recon: Recon reads memory the same way Plate Fix does. So the line isn't technical — it's about what you do: our mods only read data and only change things on your own screen. They don't affect damage, collision or networking, and they don't touch other players.
Don't touch code
Never modify any code bytes (so GameGuard's code-hash check is never triggered), never hook functions, never send network packets. Only call the APIs the engine exposes to Lua and the Windows memory read/write APIs.
Exit on error
All the per-frame logic is wrapped in pcall: on the first Lua error the mod disarms permanently (tears down the Gui, writes the log), the original update() keeps running as usual, and the game is unaffected.
Memory access
Only Read/WriteProcessMemory is used, so a wrong address just returns a failure. Every read re-verifies the fingerprint; if anything doesn't match, that step is skipped and the reason is logged.
Engine call rules
- Before every call, confirm the world is still alive (
world_is_live) - Call
Unit.alive(u)before calling anything on a unit - Never touch the Hive Lord's corpse after it dies
- Never call
stingray.Network.*(it makes the engine terminate immediately)
- plate_fix.lua lines 88–96 The only write: one byte: Read the byte, OR in one bit, write back a single byte, keeping the window against the game's own writes tiny. The value is the one the game's gore would write.
Show this code
-- OR one bit into the byte that holds it: a single-byte write keeps the window against the game's own writes tiny. local function set_bit(addr, bit) local a = addr + math.floor(bit / 8) if safe_read(a, 1, WB) < 1 then return false end local v = bitlib.bor(WB[0], bitlib.lshift(1, bit % 8)) if v == WB[0] then return true end WB[0] = v return k32.WriteProcessMemory(SELF, ffi.cast('void*', a), WB, 1, NIO) ~= 0 end - plate_fix.lua lines 225–266 When it writes: If the sanity check fails (masks 32 and 33 must be hidden), nothing is written. Only when a plate has been destroyed for 1.5 s and both the base and applied masks still show it is the hide bit written; at most 3 tries, each logged.
Show this code
function M.check(L, t) local rec = health_index(L.ent) if not rec or not read_hive_record(rec) then return false end -- gone (despawned) if t >= (L.next_vis or 0) or not L.vis then L.vis, L.next_vis = vis_entry(L.ent), t + 1 end local v = L.vis local lo, hi, alo, ahi = nil, nil, nil, nil if v then lo, hi = mask(v.rendered); alo, ahi = mask(v.applied) end -- Same sanity check as the HUD: the two unwired masks (32, 33) are always hidden on a Hive Lord. Anything else -- means the entry now belongs to another unit (the table was compacted): look it up again, write nothing. local sane = hi and bitlib.band(hi, 3) == 3 if v and not sane then L.vis = nil end for slot, pm in pairs(PLATE_MASK) do local hp = i32(Z_OFF + slot * 4) if hp <= -PLATE_CON and not L.dead[slot] then L.dead[slot] = t end local died, f = L.dead[slot], L.fix[slot] if M.enabled and sane and died and not (f and f.gave_up) then local applied_hidden = alo and mbit(alo, ahi, pm) local shown = not mbit(lo, hi, pm) and not applied_hidden if shown and t - died >= FIX_DELAY then f = f or {n = 0} L.fix[slot] = f if t >= (f.next or 0) then if f.n >= FIX_TRIES then f.gave_up = true writer(string.format('entity 0x%X plate slot %d: still drawn after %d tries - giving up (%s)', L.ent, slot, f.n, M.masks(v))) else f.n, f.next, f.at = f.n + 1, t + RETRY_S, t local ok = set_bit(v.override, pm) and set_bit(v.touched, pm) writer(string.format('entity 0x%X plate slot %d: dead %.1f s but still drawn -> hid model mask %d (%s), try %d (%s)', -- … (42 lines in total; see the source page for all of it)
- Anti-cheat looks for “foreign code and foreign access”; a mod is data that the game runs itself.
- Not blocked doesn't mean allowed. The line is what you do: only change your own screen, never affect others.
- A mod can do anything a normal program can, so only install trusted mods that ship with source code.
Check yourself: Could you use the same method to build an “aimbot”? Technically possible? Should you?
Technically, the door really is open (Chapters 1, 2 and 5). But it gives you an unfair advantage, ruins other people's games, and can easily get you banned. The line for the principles in this document has always been “only affect your own screen and your own computer”.
Development history
It started with one sentence: “It should be possible to make a Hive Lord health bar that shows main HP and the HP of every armour plate.” Then came 5 days, 121 internal builds and 3 public releases. The author tested every build in the real game, and the logs were the telemetry.
hive-lord-health-dumper, that pulls the Hive Lord's HealthComponent out of the game's datalib: 38 zones, main HP 150,000, and HP / constitution / armour for every zone. Found that FileDiver's built-in struct definition was out of date (the real size is 22,096 bytes), and that the DLInstance header is 28 bytes, not 24.Gui.rect on every simulation tick hard-froze the game twice, after 1 minute and after 3.5 minutes. Cause: Gui is retained mode, so v0 leaked about 2,400 primitives per second, and the vector argument format was guessed wrong. Rewritten to follow HUD+'s lifecycle.animation_get_state mean. Three “about to emerge” signals were tried (head rising, slot 4 = 3, stopped moving). The first two had too low a hit rate across 26 logged emerges and were dropped; the final one is “stopped and still underground”.Camera.world_to_screen, off-screen arrow; fixed picking the wrong camera and a misjudged auto-correction of the y direction; moved the panel to the left to stay clear of the mission list.0x925F10 and the gore paths 0x9057D0 / 0x903C70 in game.dll, and confirmed that “collision” and “model” are two independent paths. Then analysed the network RPC (0xBDE430, 1,253 call sites) and confirmed that every machine computes damage itself, and the host only sends results.game.dll+0x3326688. No more searching, no more getting stuck, and the CPU cost dropped a lot.Gui.triangle, the low-poly facets switched to real triangles.hostdiag tells whether each state change came from the host or the local machine; once the host has corrected anything, all plate % become upper bounds (“<”). Monochrome became the default.Development method and toolchain
The principle is “offline first”: anything that can be computed and tested on the PC never needs the author to go into the game. Game time is only used to answer questions that can't be answered offline.
1. Answer from game data, offline
- FileDiver (Go): decodes the game's archives, datalib and typelib; small extra tools were written too, such as
hive-lord-health-dumper,entity-probeandsm-state-dumper. - Models exported to glb to see the skeleton and collision node names.
- Hash brute-forcing (
murmur64(name) >> 32) to recover zone names.
2. Static reversing
- With the game running, the Recon addon dumps the already-unpacked game.dll / helldivers2.exe.
- Python + capstone scans for accesses to a specific offset in seconds to find functions; Ghidra helps.
- Every finding goes into research notes.
3. Offline tests
- lupa (Lua embedded in Python) loads the real mod source code, together with a fake engine API (World, Unit, Gui, Camera…) and fake game memory structures.
- Mutation testing: deliberately break the code (for example, remove a sanity check) and confirm the tests fail. Most builds record “killed N mutants”.
- A stub that simulates “calling on a destroyed unit crashes” verifies 0 engine calls within 70 seconds after death.
4. Design review you can see
- The Gui calls recorded during tests are rendered to PNG (PIL), so you can see the layout before going into the game.
- The game's colour boost is simulated to preview the real result.
- The showcase images and the mod icon come from the same renderer.
5. In-game verification
- The author plays normally (always as a client), and the HUD's log records what's needed:
hostdiag,MAIN JUMP,[PlateFix], and the update rate and per-frame cost every 30 seconds. - Every experimental API gets a log line written before its first call: if the game crashes, the last line is the culprit.
6. Build and deploy
- A build script produces a zip Arsenal can import (fixed GUID, one patch per option).
- A deploy script only overwrites slots that carry our addon marker, and refuses to deploy while the game is running.
Tried and dropped
| Approach | Outcome | Why |
|---|---|---|
| Game's native font | Disabled | The first draw crashed the game, leaving a zombie process both times |
| Heap scan to find HP | Demoted to fallback | It can follow a frozen stale copy; now the health system is queried instead |
| Estimating main HP from zone damage | Removed | Misses explosions and default-zone damage, always about 20k too high |
| Predicting emerge from head rising / slot 4 animation value | Removed | 19 of 26 emerges had no warning sign; slot 4 is actually the hit reaction |
| Making the host broadcast real HP (rpc_heal etc.) | Abandoned | The Hive Lord isn't in the sync system, and it would affect other players' games |
| Leashing the Hive Lord within 100 m | Impossible | Enemy AI runs on the host, and the behaviour tree is C++ |
| Armour % labels on the model | Removed in v116 | Author's call; collision nodes don't move, so it would need animated bones |
| F6–F11 hotkeys | Removed in v76 | Everything moved to the menu |
| Monochrome “hollowed-out” plates, 3D subdivided lighting | Rolled back | Author's preference; the final look is the scorch effect |
| Overlaying a bold copy on every character (3 px offset) | Rewritten | Text came apart; now 1 px stepped overlays on whole pixels |
Pitfalls we hit
| Symptom | Cause | Fix |
|---|---|---|
| Crash 1–3 minutes after starting the game | Using retained-mode Gui as if it were immediate mode, so primitives grow without limit | Create once, then only update; forget everything when the world dies |
| Crash about 60 seconds after the Hive Lord dies | Still calling Unit.alive and other API while the corpse is being removed | Never touch it after death; skip it by address |
| Distance is always 0 m | Unit.world_position(u, 0) returns the origin for every unit | Use named bones (head, root) |
| Text stays blank | Text created and updated in the same frame doesn't show | Create it with real content, and don't update it on the frame it's created |
| Dirty spots on the plates | Colour alpha 92%, so the terrain shows through | Anything that must be clear is always opaque |
| Marker upside down | Both sample points were off-screen during auto-correction | Fixed direction; only check when both points are on screen |
| After deploying, the game loads the old version and someone else's mod gets overwritten | Arsenal renumbers patch files on every deploy | The deploy script finds the slot by a content marker |
| After recolouring, every triangle collapses into a line | Gui.triangle swaps y/z, Gui.update_triangle doesn't | Fixed in v113, then rolled back along with the whole experiment |
| Can't add any more locals to the main chunk | LuaJIT allows at most 200 locals per function | Wrap new features in do … end blocks or hang them on the H table |
| The “Armor panel” menu row is always greyed out | The menu puts the HUD on the native DISPLAY page, and borderless window mode greys out row 3 (resolution) | Not a HUD bug, no fix needed |
What's in the download
| File | Contents |
|---|---|
Source/mods/hivelordhud/hive_lord_hud.lua | Full source of the HUD (resource name mods/hivelordhud/hive_lord_hud). The file header indexes its sections: Hive Lord data, Detection, Live HP locator, Zone tracking, GUI, Per-frame worker, Settings, Install, in the same order as the table in Chapter 12. |
Source/mods/hivelordhud/plate_fix.lua | Full source of Dead Plate Fix (mods/hivelordhud/plate_fix); it works on its own. |
options/core/…patch_0, options/platefix/…patch_0 | One patch file per option: what the game actually loads (Chapter 14). |
manifest.json, icon.png | The description file and icon the mod manager reads. |
The build and deploy scripts, offline tests, research notes and the development-only diagnostics mod used during development are not published. This document mentions them to explain the principles behind them.
Installation
- Enable Bingus Shared Loader in Arsenal (optional: the Mod Options Menu from Vanilla Plus Megapack).
- Import
HiveLordHUDv3.zip, tick Hive Lord HUD, Dead Plate Fix or both under Options, then Deploy. - The log is at
%LOCALAPPDATA%\CowboyBingus\Helldivers2\Logs\HiveLordHud_*.log; lines starting with[PlateFix]are fix records.
Package format (brief)
HiveLordHUDv3.zip
├─ manifest.json GUID 4ef072f2-… (fixed; Arsenal uses it to recognise updates of the same mod)
├─ icon.png
├─ options/core/9ba626afa44a3aa3.patch_0 ← the HUD's lua resource
├─ options/platefix/9ba626afa44a3aa3.patch_0 ← Plate Fix's lua resource
└─ Source/… ← plain-text source code
Each patch = a Stingray archive: header + type table (lua) + file table, resource names are murmur64 hashes,
data section aligned to 16 bytes; lua resource = (length, 2 = plain-text source) + source, first line must be -- HD2-Addon: <resource name>
Mini glossary
| Term | Meaning | Chapter |
|---|---|---|
| Machine code / compile | Bytes the CPU can run directly / turning source code into machine code | Chapter 1 |
| .exe / .dll / process | Files that hold machine code / a running program | Chapter 1 |
| Engine | The shared foundation games are built on (HD2 uses Stingray) | Chapter 1 |
| Resource / archive / patch file | A file the game uses / a big file that packs them / an archive whose same-named resources override the originals | Chapter 1 |
| Hash / murmur64 | A formula that turns text into a fixed-length number / the one the game uses | Chapter 1 |
| Frame / game loop | One screen update / the update-and-draw cycle that repeats forever | Chapter 2 |
| Lua / LuaJIT | A scripting language / its fast version, built into HD2 | Chapter 2 |
| Global variable / update() | A variable shared by the whole Lua environment / the function the engine calls every frame | Chapter 2 |
| hook | Redirecting a call to yourself, then passing it back to where it was going | Chapter 2 |
| Callback | A function you hand to someone else so they call it at a certain moment | Chapter 3 |
| Loader / require | The mod that finds and runs all the other mods / how Lua loads another script | Chapter 3 |
| Mod manager | Arsenal, HD2MM: tools that write mods into patch files | Chapter 3 |
| log | A text file where a program writes down what happened | Chapter 3 |
| API | Functions opened up for others to call | Chapter 4 |
| world / unit / bone | The scene / an object in the scene / a node of a model's skeleton | Chapter 4 |
| handle | A number tag that stands for something | Chapter 4 |
| Gui / retained mode | The engine's 2D UI drawing feature / create once, then modify | Chapter 4 |
| pcall / assert | A call that catches Lua errors / the engine's self-check, which ends the game on failure | Chapter 4 |
| FFI | A way to call C functions and work with memory from Lua | Chapter 5 |
| Windows API | Functions Windows opens up for every program to call | Chapter 5 |
| byte / hexadecimal / int32 | The smallest unit of memory / numbers written with 0x / a 4-byte integer | Chapter 6 |
| Virtual memory / page | Each process's own address space / the 4 KB unit it's managed in | Chapter 6 |
| Access violation | Reading or writing an address that doesn't exist or isn't allowed; the program crashes on the spot | Chapter 6 |
| Code section / data section / heap | Machine code / global variables at fixed locations / data allocated while running | Chapter 6 |
| ASLR / base address / RVA | The dll lands somewhere different every time / where it is this time / an offset counted from the start of the dll | Chapter 6 |
| Pointer | A value that holds another address | Chapter 6 |
| Struct / offset / array / stride | A bundle of data / which byte a field starts at / one record after another / the size of each record | Chapter 7 |
| ECS (entity / component / system) | An ID / data attached to an ID / code that manages all data of one kind | Chapter 7 |
| Hash table | A table that turns an ID into a record index | Chapter 7 |
| Design values / runtime values / fingerprint | Values in the data files / values in memory / unchanging values used to confirm you found the right thing | Chapter 7 |
| Name list | A hash ↔ original name lookup table | Chapter 8 |
| datalib / typelib | Design value tables / a file describing struct field offsets | Chapter 8 |
| Recon mod | A read-only mod that writes what it observes to a log | Chapter 9 |
| PE format / section | The file format of exe and dll files / the regions it is split into | Chapter 9 |
| dump | Saving a range of memory to a file | Chapter 9 |
| Signature (AOB) | A string of bytes used for searching | Chapter 9 |
| Packing (Themida) | Code encrypted until it runs | Chapter 10 |
| Disassembly / assembly language / register | Turning machine code into text instructions / those text instructions / small slots inside the CPU | Chapter 10 |
| Cross-reference / static / dynamic analysis | Finding who uses an address / reading the machine code / watching it while it runs | Chapter 10 |
| ReadProcessMemory | The Windows API for reading memory; a bad address just returns failure | Chapter 11 |
| Stale copy | Data the game no longer uses but that's still left in memory | Chapter 11 |
| Time budget | How much time a mod is allowed to spend each frame | Chapter 11 |
| Singleton guard / disarm | Making sure it loads only once / permanently stopping itself after an error | Chapter 12 |
| lupa / stub / mutation testing | Running Lua in Python / fake functions that imitate the game API / breaking code on purpose to confirm the tests fail | Chapter 13 |
| manifest / GUID / slot | The mod description file / the mod's never-changing ID card / the numbered position of a patch file | Chapter 14 |
| Host / client / authority | The player who opens the lobby / players who join / who has the final say | Chapter 15 |
| build | The game's version number | Chapter 15 |
| Anti-cheat / GameGuard | A program that watches for outside programs touching the game / the one HD2 uses | Chapter 16 |
| Code-hash check / injection | Checking whether code has been modified / putting code into another process to run it | Chapter 16 |
Document date 2026-10-07. All addresses match game build 25480438; assembly is taken from the Oct 2 unpacked dump.