Helldivers 2 · Hive Lord HUD

Hive Lord HUD source, linked to the tutorial

The full source of the two mods exactly as shipped in HiveLordHUDv3.zip (Source/). Highlighted blocks are explained in the tutorial: each one names the chapter it belongs to, and every chapter links back here.

← Back to the tutorial
Index by chapter

Ch. 2 · How the game runs scripts every frame

Ch. 3 · Who runs Lua that “someone else wrote”: Bingus Shared Loader

Ch. 4 · What a mod can do inside the game: the engine API

Ch. 5 · What the engine doesn't expose: LuaJIT FFI

Ch. 6 · Memory: where data lives

Ch. 7 · The shape of game data: structs, systems, hash tables

Ch. 8 · Reading game files offline: FileDiver

Ch. 9 · Observing in-game: Recon

Ch. 10 · Reading machine code: reverse engineering and assembly language

Ch. 11 · Reading safely and correctly

Ch. 12 · The skeleton of a mod

Ch. 13 · Pretending to be the game on your PC: offline testing

Ch. 15 · Multiplayer and game updates: the boundaries of a mod

Ch. 16 · Anti-cheat: the space a mod operates in

hive_lord_hud.luaHive Lord HUD · 2623 lines

Ch. 3The line the Loader looks forThe 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.
1-- HD2-Addon: mods/hivelordhud/hive_lord_hud
2-- Hive Lord HUD: a boss HP bar, an armour-plate panel, an on-screen tag and an emerge warning for the Hive Lord.
3-- Reads game memory only (through ReadProcessMemory, so a stale address fails instead of crashing) and draws with
4-- the engine Gui; it never writes to the game.
5--
Ch. 12Section indexThe 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.
6-- Sections (in file order):
7-- Hive Lord data zone layout, max HP / constitution per zone (from the unpacked HealthComponent)
8-- Detection finding the Hive Lord unit, its distance, surfacing and the emerge / pre-emerge states
9-- Live HP locator the HP record via the game's health system (heap scan only as a fallback)
10-- Zone tracking per-frame zone bookkeeping; host state events and the '<' upper-bound marker
11-- GUI prims and groups: status text, armour panel (poly / pixel), tag, warning, preview, boss bar
12-- Per-frame worker world selection, gui lifetime, render()
13-- Settings cfg file and the Bingus Mod Options Menu rows
14-- Install the wrapper around the engine's global update()
15--
Ch. 4Lifecycle rules paid for in crashesRun 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.
16-- Lifecycle (same as HUD+, which is known to be stable):
17-- * runs inside a wrapper around the engine's global `update` (main thread, once per frame) and disarms itself
18-- permanently on the first Lua fault
19-- * every engine call is gated on the world being live
20-- * Gui is retained-mode: Gui.rect once -> id, then Gui.update_rect(id); World.destroy_gui on teardown
21-- * vectors / colours are real engine objects: Vector3(x, y, layer), Vector2(w, h), Color(a, r, g, b)
22-- Extension points for the optional addons: H.write (log), H.now_s (clock), H.on_record (per-frame record read).
Ch. 12Singleton guardIf it has already been loaded, stop right here. The same global table also holds the whole HUD state.
24if rawget(_G, 'HD2HiveLordHud') then return end
25local H = {version = 121, release = 'v3', armed = false, faults = 0, frame = 0}
26rawset(_G, 'HD2HiveLordHud', H)
28local ffi = require('ffi')
29local sr = rawget(_G, 'stingray') or {}
30local function api(name) return rawget(sr, name) or rawget(_G, name) end
31local App, World, Unit, Gui = api('Application'), api('World'), api('Unit'), api('Gui')
32local Vector2, Vector3, Color = api('Vector2'), api('Vector3'), api('Color')
34local function recon() return rawget(_G, 'HD2Recon') end
35-- Standalone build (no Recon addon): own log file, via the Bingus loader's log dir or %TEMP%.
36local own_log
Ch. 3Using the Loader's shared log folderPrefer the open_log the Loader provides so the log sits next to every other mod; fall back to %TEMP% only without the Loader.
37local function open_own_log()
38 if own_log ~= nil then return own_log end
39 own_log = false
40 local name = 'HiveLordHud_' .. ((os and os.date and os.date('%Y%m%d_%H%M%S')) or 'session') .. '.log'
41 local loader = rawget(_G, 'CowboyBingusModLoader')
42 local ok, f = pcall(function() return loader and loader.open_log and loader.open_log(name) end)
43 if not (ok and f) then
44 local temp = os and os.getenv and os.getenv('TEMP')
45 f = temp and io.open(temp .. '\\' .. name, 'w')
46 end
47 if f then own_log, H.log = f, name end
48 return own_log
49end
50H.recent = {} -- last few log lines, mirrored on screen
Ch. 9A trace of being bundled with ReconIf the Recon research mod is present, lines go into its log. The first 69 HUD builds were packed together with Recon.
51local function writer(line)
52 local R = recon()
53 H.recent[#H.recent + 1] = line
54 if #H.recent > 3 then table.remove(H.recent, 1) end
55 line = '[HiveLordHud] ' .. line
56 if R and R.write then R.write(line) return end
57 print(line)
58 local f = open_own_log()
59 if f then pcall(function() f:write(line, '\n'); f:flush() end) end
60end
62H.write = writer -- the plate-fix module (mods/hivelordhud/plate_fix) logs through this when both are installed
64local function attempt(fn, ...)
65 if type(fn) ~= 'function' then return nil end
66 local ok, v = pcall(fn, ...)
67 if ok then return v end
68 return nil
69end
Ch. 4Check the world is still alive firstA world that has gone takes its Gui with it. Before any engine call, check it is still in the list.
71local function world_is_live(world)
72 if world == nil or not App then return false end
73 local worlds = attempt(App.worlds)
74 if type(worlds) ~= 'table' then return false end
75 for i = 1, #worlds do
76 if worlds[i] == world then return true end
77 end
78 return false
79end
81local function now_s()
82 return attempt(App and App.time_since_launch) or (os.clock and os.clock()) or 0
83end
84H.now_s = now_s -- shared with the diag module (one clock for both)
Ch. 8Design values decoded with FileDiverMax HP and constitution for all 38 zones, all from the health component decoded offline. These numbers later validate the record in memory.
86-- =========================================================================
87-- Hive Lord data (tools/research/hive_lord_health.json)
88-- =========================================================================
89H.MAIN_HEALTH_MAX = 150000
90H.GROUPS = {
91 {label = 'HEAD', color = {255, 230, 140, 40}, zones = {
92 {slot = 0, hp = 150000}, {slot = 1, hp = 150000}, {slot = 22, hp = 10000}, {slot = 23, hp = 10000}}},
93 {label = 'SPINE', con = 35000, color = {255, 230, 200, 60}, zones = {
94 {slot = 2, hp = 15000}, {slot = 3, hp = 15000}, {slot = 4, hp = 15000}, {slot = 5, hp = 15000},
95 {slot = 6, hp = 15000}, {slot = 7, hp = 15000}, {slot = 8, hp = 20000}, {slot = 9, hp = 15000},
96 {slot = 10, hp = 15000}, {slot = 11, hp = 15000}, {slot = 12, hp = 15000}, {slot = 13, hp = 15000},
97 {slot = 14, hp = 15000}}},
98 {label = 'BODY', color = {255, 220, 70, 60}, zones = {
99 {slot = 15, hp = 150000}, {slot = 16, hp = 150000}, {slot = 17, hp = 150000}, {slot = 18, hp = 150000},
100 {slot = 19, hp = 150000}, {slot = 20, hp = 150000}, {slot = 21, hp = 150000}}},
101 {label = 'FINS', color = {255, 120, 170, 210}, zones = {
102 {slot = 24, hp = 5000}, {slot = 25, hp = 5000}, {slot = 26, hp = 5000}, {slot = 27, hp = 5000},
103 {slot = 28, hp = 5000}, {slot = 29, hp = 5000}, {slot = 30, hp = 5000}, {slot = 31, hp = 5000},
104 {slot = 32, hp = 5000}, {slot = 33, hp = 5000}, {slot = 34, hp = 5000}, {slot = 35, hp = 5000},
105 {slot = 36, hp = 5000}, {slot = 37, hp = 5000}}},
109H.live = {
110 world = nil, world_since = 0, last_scan = -1e9,
111 unit = nil, unit_addr = nil, dumped = false,
112 hc_ptr = nil, units_seen = 0,
114H.ui = {gui = nil, world = nil, bars = nil, status = nil, scale = 1, width = 1920, height = 1080}
115H.FONT = 'core/performance_hud/debug' -- ships in the base archive; HUD+ uses it as its fallback font
117-- =========================================================================
118-- Detection
119-- =========================================================================
120-- Release builds may return a hashed IdString64 instead of the path, so match both forms.
121H.MATCH = {'hive_lord', 'd465d9c7f77a07cb'} -- murmur64('content/fac_bugs/cha_hive_lord/cha_hive_lord')
123local function is_hive_lord(name_str)
124 local s = name_str:lower()
125 for _, m in ipairs(H.MATCH) do
126 if s:find(m, 1, true) then return true end
127 end
128 return false
129end
131-- Worlds tracked in a list (compared with ==, not used as table keys, in case userdata wrappers differ).
132H.worlds_seen = {} -- {w = world, since = t, logged = bool, idx = n}
134local function settled_worlds(worlds, t)
135 local keep = {}
136 for i = 1, #worlds do
137 local w, rec = worlds[i], nil
138 for _, r in ipairs(H.worlds_seen) do
139 if r.w == w then rec = r; break end
140 end
141 rec = rec or {w = w, since = t, logged = false}
142 rec.idx = i
143 keep[#keep + 1] = rec
144 end
145 H.worlds_seen = keep
146 local out = {}
147 for _, r in ipairs(keep) do
148 if t - r.since >= 10 then out[#out + 1] = r end
149 end
150 return out
151end
153-- Log unit count + a few resource_name samples the first time each world is scanned.
154local function describe_world(rec, units)
155 rec.logged = true
156 local samples, seen = {}, {}
157 for i = 1, #units do
158 local name = attempt(Unit.resource_name, units[i])
159 local s = name ~= nil and tostring(name) or nil
160 if s and not seen[s] then
161 seen[s] = true
162 samples[#samples + 1] = s
163 if #samples >= 3 then break end
164 end
165 end
166 local sample_type = #units > 0 and type(attempt(Unit.resource_name, units[1])) or '-'
167 writer(string.format('world #%d: %d units, name type=%s, e.g. %s',
168 rec.idx, #units, sample_type, table.concat(samples, ' ; ')))
169end
171-- Scan every settled live world. Returns unit, world.
172local AVATAR_HEX, CAMERA_HEX = '4d1c334d294dfa97', '465f4895f3dc98d1' -- avatar_helldiver / core/units/camera
174-- Scan every settled live world. Returns unit, world, name of the Hive Lord; also records the helldiver avatars
175-- and cameras of that world in H.live.divers / H.live.cams for the proximity warning.
176-- Unit handle -> number without any engine call (used to skip the dead Hive Lord's corpse).
177local function handle_addr(u)
178 local ok, a = pcall(function() return tonumber(ffi.cast('uintptr_t', u)) end)
179 return ok and a or u
180end
Ch. 4Finding the Hive Lord with the engine APIWorld.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.
182function H.find_hive_lord(worlds, t)
183 if not World or type(World.units) ~= 'function' then return nil end
184 if H.dead_addr and t - (H.dead_addr_t or t) > 300 then H.dead_addr = nil end
185 local total = 0
186 for _, rec in ipairs(settled_worlds(worlds, t)) do
187 local units = attempt(World.units, rec.w)
188 if type(units) == 'table' then
189 rec.n = #units
190 total = total + #units
191 if not rec.logged then describe_world(rec, units) end
192 local hl, hl_name, divers, cams = nil, nil, {}, {}
193 for i = 1, #units do
194 local u = units[i]
195 -- The game crashed twice ~60 s after a Hive Lord died, while we kept polling its corpse as it was
196 -- despawned (logs 222058 / 233615). Once dead it is never touched again, not even Unit.alive.
197 if H.dead_addr and handle_addr(u) == H.dead_addr then
198 -- skip
199 elseif attempt(Unit.alive, u) then
200 local name = attempt(Unit.resource_name, u)
201 if name ~= nil then
202 local s = tostring(name):lower()
203 if not hl and is_hive_lord(s) then hl, hl_name = u, tostring(name)
204 elseif s:find(AVATAR_HEX, 1, true) then divers[#divers + 1] = u
205 elseif s:find(CAMERA_HEX, 1, true) then cams[#cams + 1] = u end
206 end
207 end
208 end
209 if hl then
210 H.live.units_seen, H.live.divers, H.live.cams = total, divers, cams
211 return hl, rec.w, hl_name
212 end
213 end
214 end
215 H.live.units_seen = total
216 return nil
217end
Ch. 4The Unit.world_position trapNode 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.
219-- Root node world position. Index 0 is the root (scene node indices are 0-based); guarded by node count.
220-- Vector3 fields are unreliable here, so parse tostring 'Vector3(x, y, z)'.
221-- Node 0 is a static root that reads (0,0,0) for the Hive Lord and every avatar, so named bones are used instead,
222-- resolved once per unit (Unit.has_node first: Unit.node asserts on unknown names).
223local BODY_NODES = {'root', 'j_root', 'hips', 'j_hips', 'pelvis', 'j_pelvis', 'spine', 'j_spine', 'head', 'j_head'}
224local node_cache = setmetatable({}, {__mode = 'k'})
225local function body_node(u, names)
226 if not attempt(Unit.alive, u) then return nil end -- never touch a destroyed unit (engine assert = crash)
227 local key = type(u) == 'table' and u or tostring(u)
228 local c = node_cache[key]
229 if c ~= nil then return c or nil end
230 for _, n in ipairs(names or BODY_NODES) do
231 if attempt(Unit.has_node, u, n) == true then
232 local idx = attempt(Unit.node, u, n)
233 if type(idx) == 'number' and idx > 0 then node_cache[key] = idx; return idx end
234 end
235 end
236 node_cache[key] = false
237 return nil
238end
240local function unit_pos(u, node)
241 if not attempt(Unit.alive, u) then return nil end -- first, before has_node / world_position
242 node = node or body_node(u)
243 if not node then return nil end
244 local n = attempt(Unit.num_scene_graph_items, u)
245 if type(n) ~= 'number' or n <= node then return nil end
246 local v = attempt(Unit.world_position, u, node)
247 if v == nil then return nil end
248 local x, y, z = string.match(tostring(v), '([-%d%.e]+)%s*,%s*([-%d%.e]+)%s*,%s*([-%d%.e]+)')
249 x, y, z = tonumber(x), tonumber(y), tonumber(z)
250 if not (x and y and z) then return nil end
251 return x, y, z
252end
255-- The local player's view: the single camera unit (core/units/camera). Unit.camera index is 1-based.
256local function cam_pos(u)
257 if not attempt(Unit.alive, u) then return nil end
258 local Camera = api('Camera')
259 if not Camera then return nil end
260 local c = attempt(Unit.camera, u, 1)
261 if c == nil then return nil end
262 local v = attempt(Camera.world_position, c)
263 if v == nil then return nil end
264 local x, y, z = string.match(tostring(v), '([-%d%.e]+)%s*,%s*([-%d%.e]+)%s*,%s*([-%d%.e]+)')
265 x, y, z = tonumber(x), tonumber(y), tonumber(z)
266 if not (x and y and z) then return nil end
267 return x, y, z
268end
271-- Horizontal distance from the Hive Lord to the nearest helldiver (cameras if no avatar was found).
272-- H.in_view: within visual range (shown under VIEW_ON, hidden past VIEW_OFF); nil when positions can't be read,
273-- which leaves the UI shown.
274local VIEW_ON, VIEW_OFF = 1000, 1100 -- m: shown within VIEW_ON, hidden past VIEW_OFF (hysteresis)
275local EMERGE_RISE, EMERGE_BELOW_DIVER, EMERGE_HOLD = 6, 6, 3 -- m, m, s
276local EMERGE_WARN_RISE, WARN_DIST, WARN_S = 8, 100, 3.5 -- m above the burrowed pose, m, s on screen
277local EMERGE_ANIM_MAX = 12 -- s: cap (the emerge state lasts ~9.4 s)
278local PRE_MAX = 20 -- s: cap for the pre-emerge stage
279local STILL_SPEED = 1.5 -- m/s: below this it counts as standing still
280function H.update_proximity(t)
281 local L = H.live
282 if not L.unit then H.in_view, H.near_dist = nil, nil; return end
283 if H.dead_since then return end -- dead: no more engine calls on it (corpse despawn crash)
284 -- 10 Hz while it is close (the emerge warning needs it), 4 Hz otherwise.
285 local period = (H.near_dist and H.near_dist < 250) and 0.1 or 0.25
286 if t - (L.last_prox or -1e9) < period then return end
287 L.last_prox = t
288 L.root_idx = L.root_idx or body_node(L.unit, {'root', 'boss', 'spine0'})
289 local hx, hy = unit_pos(L.unit, L.root_idx)
290 if not hx then return end
291 -- Horizontal speed over the last ~1 s. Underground it travels ~12 m/s; before an emerge it stops on one spot
292 -- for 4-12 s and then comes up right there.
293 L.track = L.track or {}
294 local tr = L.track
295 tr[#tr + 1] = {t, hx, hy}
296 while #tr > 2 and t - tr[1][1] > 1.2 do table.remove(tr, 1) end
297 local dt = t - tr[1][1]
298 H.hive_speed = dt >= 0.8 and math.sqrt((hx - tr[1][2]) ^ 2 + (hy - tr[1][3]) ^ 2) / dt or nil
299 local best
300 local list = (L.divers and #L.divers > 0) and L.divers or L.cams or {}
301 local best_z, bx, by
302 -- The local camera = distance to you. With several camera units (one may be parked high above the map) use the
303 -- one closest to a helldiver (the third-person camera sits a few m from its avatar).
304 if t - (L.cam_pick_t or -1e9) >= 1 or (L.main_cam and not attempt(Unit.alive, L.main_cam)) then
305 L.cam_pick_t = t
306 local pick, pick_d
307 local dpos = {}
308 for _, du in ipairs(L.divers or {}) do
309 local x, y, z = unit_pos(du)
310 if x then dpos[#dpos + 1] = {x, y, z} end
311 end
312 for _, u in ipairs(L.cams or {}) do
313 local x, y, z = cam_pos(u)
314 if x then
315 local nd = 1e9
316 for _, p in ipairs(dpos) do
317 nd = math.min(nd, math.sqrt((x - p[1]) ^ 2 + (y - p[2]) ^ 2 + (z - p[3]) ^ 2))
318 end
319 if #dpos == 0 then nd = (x == 0 and y == 0) and 1e8 or 0 end
320 if not pick_d or nd < pick_d then pick, pick_d = u, nd end
321 end
322 end
323 if pick ~= L.main_cam and pick then
324 writer(string.format('camera: using camera unit %d of %d (%.1f m from a helldiver)',
325 (function() for i, u in ipairs(L.cams) do if u == pick then return i end end return 0 end)(), #L.cams, pick_d))
326 end
327 L.main_cam = pick
328 L.tag_cam = pick
329 end
330 if L.main_cam then
331 local x, y, z = cam_pos(L.main_cam)
332 if x then best, best_z, bx, by = math.sqrt((x - hx) ^ 2 + (y - hy) ^ 2), z, x, y end
333 end
334 if not best then
335 for _, u in ipairs(list) do
336 local x, y, z = unit_pos(u)
337 if x then
338 local d = math.sqrt((x - hx) ^ 2 + (y - hy) ^ 2)
339 if not best or d < best then best, best_z, bx, by = d, z, x, y end
340 end
341 end
342 end
343 H.near_dist = best
344 -- Above ground? Underground the body lies flat, so the head sits level with the root (tail end); when it
345 -- surfaces the head rears up. Also accept the head being near the nearest diver's height.
346 if not attempt(Unit.alive, L.unit) then return end
347 if L.head_idx == nil then
348 L.head_idx = false
349 if attempt(Unit.has_node, L.unit, 'head') == true then
350 local idx = attempt(Unit.node, L.unit, 'head')
351 if type(idx) == 'number' then L.head_idx = idx end
352 end
353 writer('head node index: ' .. tostring(L.head_idx))
354 end
355 H.tag_world = {hx, hy, select(3, unit_pos(L.unit, L.root_idx)) or 0}
356 if L.head_idx then
357 local hhx0, hhy0, hz = unit_pos(L.unit, L.head_idx)
358 local _, _, rz = unit_pos(L.unit, L.root_idx)
359 if hz and rz and hz - rz > EMERGE_RISE then H.tag_world = {hhx0, hhy0, hz} end
360 local dz = (hz and best_z) and (hz - best_z) or nil -- head height relative to the nearest diver
361 if hz and rz then
362 local up = (hz - rz > EMERGE_RISE) or (dz ~= nil and dz > -EMERGE_BELOW_DIVER)
363 -- Emerge warning. Burrowed, the head sits a fixed ~71.5 m below the root (logs 201340/212406); when
364 -- it starts to come up the head leaves that pose and rises within ~2 s. Warn on the first rise.
365 local hr = hz - rz
366 local was_up = (H.surfaced_until or 0) > t
367 if not up and not was_up and hr < -40 then L.burrow_base = math.min(L.burrow_base or hr, hr) end
368 if L.burrow_base and not was_up and hr > L.burrow_base + EMERGE_WARN_RISE then
369 if not H.emerge_started or t - H.emerge_started > 6 then
370 H.emerge_started = t
371 local hhx, hhy = unit_pos(L.unit, L.head_idx)
372 local dh = (hhx and bx) and math.sqrt((bx - hhx) ^ 2 + (by - hhy) ^ 2) or best
373 local d = math.min(best or 1e9, dh or 1e9)
374 writer(string.format('EMERGING t=%.1f: head-root %.1f (burrowed %.1f), %s m away%s', t, hr,
375 L.burrow_base, d < 1e9 and string.format('%.0f', d) or '?', (H.anim_off and d <= WARN_DIST) and ' -> WARNING' or ''))
376 -- Fallback only: with the animation probe working, the warning keys on the real emerge state
377 -- (the head also rises for the quick burst-out attacks, which should not be flagged).
378 if H.anim_off and d <= WARN_DIST then H.warn_until, H.warn_dist = t + WARN_S, d end
379 end
380 L.burrow_base = nil
381 end
382 if up then H.surfaced_until = t + EMERGE_HOLD end
383 H.surface_known = true
384 end
385 end
386 if not best then H.in_view = nil; return end
387 local was = H.in_view
388 if best < VIEW_ON then H.in_view = true
389 elseif best > VIEW_OFF then H.in_view = false
390 elseif was == nil then H.in_view = true end
391 if was ~= H.in_view then writer(string.format('Hive Lord %s visual range (%d m)', H.in_view and 'entered' or 'left', math.floor(best))) end
392end
394-- Animation state probe: Unit.animation_get_state returns the state machine's per-layer state indices.
395-- Logged on change with the head pose, so the dirt-spray stage before emerging can be matched to a state
396-- (the state machine groups are in tools/research/hive_lord_state_machine). Read-only, guarded, auto-off.
397-- Names for the two layers that matter (graphml group 0 / group 1 node order); '?' = name not cracked yet.
398H.POS_NAMES = {[0] = 'burrowed', [1] = 'EMERGE', [2] = 'emerged', [3] = 'submerge', [4] = 'dead'}
399H.ACT_NAMES = {[0] = 'acid_01', [1] = 'acid_02', [2] = 'acid_03', [3] = 'acid_projectile', [4] = '?',
400 [5] = '?', [6] = 'charge', [7] = 'charge_end', [8] = 'charge_into', [9] = '?', [10] = '?', [11] = '?',
401 [12] = 'hesitate', [13] = 'hesitate_2', [14] = '-'}
402local function anim_desc(pos, act, hit)
403 return string.format('POS %s %s | ACT %s %s | HIT %s', tostring(pos), H.POS_NAMES[pos] or '?', tostring(act),
404 H.ACT_NAMES[act] or '?', tostring(hit))
405end
406H.anim_desc = anim_desc
407-- Local wall-clock time, to match log lines against a screen recording (os.date may be absent in the sandbox).
408local function wall_clock()
409 local ok, v = pcall(function() return os.date('%H:%M:%S') end)
410 return ok and v or '-'
411end
413function H.anim_probe(t)
414 local L = H.live
415 if H.anim_off or not L.unit or H.dead_since or not H.near_dist or H.near_dist > 400 then return end
416 if t - (L.last_anim or -1e9) < 0.1 then return end
417 L.last_anim = t
418 if not attempt(Unit.alive, L.unit) then return end
419 if L.anim_ok == nil then
420 L.anim_ok = attempt(Unit.has_animation_state_machine, L.unit) == true and type(Unit.animation_get_state) == 'function'
421 writer('anim probe: state machine=' .. tostring(L.anim_ok) ..
422 (L.anim_ok and ' - calling Unit.animation_get_state (if the game crashes now, this API is bad)' or ''))
423 if not L.anim_ok then H.anim_off = true; return end
424 end
425 local r = {pcall(Unit.animation_get_state, L.unit)}
426 if not r[1] then
427 writer('anim probe failed: ' .. tostring(r[2]) .. ' - probe off'); H.anim_off = true; return
428 end
429 local parts = {}
430 for i = 2, math.min(#r, 25) do parts[#parts + 1] = tostring(r[i]) end
431 local key = table.concat(parts, ',')
432 -- First value = position layer: 0 burrowed, 1 emerge (the long
433 -- ~9.4 s dust-spray emergence), 2 emerged idle, 3 submerge, 4 death. Quick burst-out attacks stay at 0 with an
434 -- action value of 4/9/10 in the second slot. Warn only for 0 -> 1, and keep it up while the emerge plays.
435 local pos, act, v4 = tonumber(r[2]), tonumber(r[3]), tonumber(r[5])
436 H.anim_now = {pos = pos, act = act, hit = v4, key = key, t = t}
437 -- Pre-emerge candidate: burrowed, no action, 4th value 3. Every observed emerge was preceded by it (5-12 s
438 -- earlier), but it also precedes some burst-out attacks; each candidate's outcome is logged.
439 -- Also: burrowed and standing still (it parks on the spot it will come up from).
440 -- Only once it has been burrowed with no action for 1.5 s: right after a submerge or a burst-out attack the
441 -- root was fixed, so the 1 s speed window would read 0 although it is about to travel off.
442 if pos ~= 0 or act ~= 14 then L.calm_since = nil elseif not L.calm_since then L.calm_since = t end
443 local still = H.hive_speed ~= nil and H.hive_speed < STILL_SPEED and L.calm_since ~= nil and t - L.calm_since > 1.5
444 -- Parked = the dust-plume stage before an emerge: burrowed, no
445 -- action, standing still on one spot for ~10-14 s, then it emerges right there. (The 4th value / HIT layer
446 -- is not it: it cycles every ~11.7 s even while it races around 250 m away.)
447 H.parked = pos == 0 and act == 14 and still
448 if H.parked and not L.park_on then
449 L.park_on, L.park_t = true, t
450 writer(string.format('PARKED t=%.1f (%s): %.0f m away', t, wall_clock(), H.near_dist))
451 elseif not H.parked and L.park_on then
452 L.park_on = false
453 local outcome = pos == 1 and 'EMERGE' or (act ~= 14 and ('burst attack (action ' .. tostring(act) .. ')'))
454 or string.format('moved off (speed %.1f)', H.hive_speed or -1)
455 writer(string.format('PARKED outcome t=%.1f (%s): %s after %.1f s, %.0f m away', t, wall_clock(), outcome,
456 t - L.park_t, H.near_dist))
457 end
458 if pos == 1 and L.anim_pos ~= 1 then
459 L.emerge_t = t
460 -- Already warned during the pre-emerge stage: keep warning even if it emerges a bit beyond 100 m.
461 -- Already warning for the parked stage: keep going even if it comes up a bit beyond 100 m.
462 L.emerge_warn = H.near_dist <= WARN_DIST or (H.warn_kind == 'pre' and (H.warn_until or 0) > t - 0.5)
463 writer(string.format('EMERGE ANIM t=%.1f: %.0f m away%s', t, H.near_dist, L.emerge_warn and ' -> WARNING' or ''))
464 end
465 if pos == 1 and L.emerge_warn and t - (L.emerge_t or t) < EMERGE_ANIM_MAX then
466 H.warn_until, H.warn_dist, H.warn_kind = t + 0.3, H.near_dist, 'emerge'
467 elseif pos ~= 1 then
468 L.emerge_warn = false
469 end
470 -- The parked (dust plume) stage gets the 'About to Emerge' banner.
471 if L.park_on and H.near_dist <= WARN_DIST and t - L.park_t < PRE_MAX then
472 H.warn_until, H.warn_dist, H.warn_kind = t + 0.3, H.near_dist, 'pre'
473 end
474 L.anim_pos = pos
475 if key ~= L.anim_key then
476 L.anim_key = key
477 local hr = '?'
478 if L.head_idx and L.root_idx then
479 local _, _, hz = unit_pos(L.unit, L.head_idx)
480 local _, _, rz = unit_pos(L.unit, L.root_idx)
481 if hz and rz then hr = string.format('%.1f', hz - rz) end
482 end
483 L.anim_logs = (L.anim_logs or 0) + 1
484 if L.anim_logs <= 400 then
485 writer(string.format('anim t=%.1f (%s): %s [%s] head-root=%s dist=%.0f up=%s speed=%s', t,
486 wall_clock(), anim_desc(pos, act, v4), key, hr, H.near_dist,
487 tostring((H.surfaced_until or 0) > t), H.hive_speed and string.format('%.1f', H.hive_speed) or '?'))
488 end
489 end
490end
492local function unit_addr(u)
493 local ok, a = pcall(function() return tonumber(ffi.cast('uintptr_t', u)) end)
494 return ok and a or nil
495end
497-- =========================================================================
498-- Live HP locator. Unit values are handles, not pointers, so we search the heap.
499-- Runtime layout (confirmed from an in-game dump, 2026-10-03), relative to zone base Z:
500-- Z - 0xE4 int32 main HP
501-- Z - 0x9C int32 armor[39] (default zone, then slots 0..37)
502-- Z + 0 .. +0x94 int32 zone HP[38] in slot order; below 0 = bleeding into constitution
503-- Z + 0x98 int32 0xFFFF end marker
504-- Time-sliced scan for the zone array, then validated on every read.
505-- =========================================================================
Ch. 5Declaring Windows APIs with FFITell FFI what ReadProcessMemory and VirtualQuery look like, inside pcall (another mod may have declared them first), then load kernel32.
506pcall(ffi.cdef, [[
507unsigned long long VirtualQuery(const void* addr, void* buf, unsigned long long len);
508void* GetCurrentProcess(void);
509int ReadProcessMemory(void* process, const void* addr, void* buf, size_t size, size_t* read);
510typedef struct {
511 void* BaseAddress; void* AllocationBase; unsigned long AllocationProtect; unsigned short PartitionId;
512 unsigned long long RegionSize; unsigned long State; unsigned long Protect; unsigned long Type;
513} HLH_MBI;
514]])
515local k32 = ffi.load('kernel32')
516local mbi = ffi.new('HLH_MBI[1]')
517local MBI_SIZE = ffi.sizeof('HLH_MBI')
518local MEM_COMMIT, MEM_PRIVATE, PAGE_READWRITE = 0x1000, 0x20000, 0x04
519local USER_END = 0x7FFFFFFFFFFF
Ch. 11Time budgetThe heap search may spend at most 4 ms per frame. It used to be 8 ms, which halved the FPS at about 130 FPS.
520local SLICE_S, CHUNK = 0.004, 1024 * 1024 -- 4 ms per frame (was 8: halved the FPS while it ran at ~130 FPS)
Ch. 7Offsets inside the recordOffsets 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.
522local OFF_MAIN, OFF_ARMOR0, OFF_MARKER = -0xE4, -0x98, 0x98
523local WIN_LO, WIN_HI = 0xF0, 0xA0 -- read [Z - 0xF0, Z + 0xA0)
524local WIN_LEN = WIN_LO + WIN_HI
526-- Per-slot max HP / constitution, built from H.GROUPS.
527local MAXHP, MAXCON = {}, {}
528for _, g in ipairs(H.GROUPS) do
529 for _, z in ipairs(g.zones) do MAXHP[z.slot], MAXCON[z.slot] = z.hp, z.con or g.con or 0 end
530end
Ch. 11Every read goes through ReadProcessMemoryThe comment says it: heap memory can be freed between the check and the read, and a raw read would crash; ReadProcessMemory just fails.
532-- All reads go through ReadProcessMemory on our own process: heap memory can be freed by another
533-- thread between a VirtualQuery check and a raw pointer read (that segfaulted in testing); RPM just fails.
534local SELF = k32.GetCurrentProcess()
535local NREAD = ffi.new('size_t[1]')
536local SCRATCH = ffi.new('uint8_t[?]', CHUNK)
537local WIN = ffi.new('uint8_t[?]', WIN_LEN)
539local function safe_read(addr, n, dst)
540 if k32.ReadProcessMemory(SELF, ffi.cast('const void*', addr), dst, n, NREAD) == 0 then return 0 end
541 return tonumber(NREAD[0])
542end
544local function i32(buf, off) return tonumber(ffi.cast('const int32_t*', buf + off)[0]) end
Ch. 7The fingerprint: 39 armour valuesEvery 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.
546-- Read the record around zone base z into WIN and validate it. Returns true if it still looks like a Hive Lord.
547-- Armor never changes in a fight, so it is the fingerprint: default zone, then slots 0..37.
548local ARMOR = {5, 6, 10}
549for _ = 1, 13 do ARMOR[#ARMOR + 1] = 5 end
550for _ = 1, 7 do ARMOR[#ARMOR + 1] = 2 end
551ARMOR[#ARMOR + 1] = 6; ARMOR[#ARMOR + 1] = 6
552for _ = 1, 14 do ARMOR[#ARMOR + 1] = 5 end
554-- HP is only sanity-bounded: destroyed zones (even ones without constitution) go below zero.
555local function read_record(z)
556 if safe_read(z - WIN_LO, WIN_LEN, WIN) < WIN_LEN then return false end
557 local zb = WIN_LO
558 if i32(WIN, zb + OFF_MARKER) ~= 0xFFFF then return false end
559 for i = 1, #ARMOR do
560 if i32(WIN, zb + OFF_ARMOR0 - 4 + (i - 1) * 4) ~= ARMOR[i] then return false end
561 end
562 for slot = 0, 37 do
563 local v = i32(WIN, zb + slot * 4)
564 if v > MAXHP[slot] or v < -(MAXCON[slot] + MAXHP[slot]) then return false end
565 end
566 return true
567end
569-- Cheap change detector for a record currently in WIN.
570local function signature()
571 local sum = i32(WIN, WIN_LO + OFF_MAIN)
572 for slot = 0, 37 do sum = sum + i32(WIN, WIN_LO + slot * 4) end
573 return sum
574end
576H.loc = nil
Ch. 10The health system found by reverse engineeringThis 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.
578-- =========================================================================
579-- The live HP record straight from the game's health system (a game.dll global; RVA of build 25480438).
580-- The damage code (game.dll 0x9235F0) indexes it the same way: hash of (entity u32, index u32) at +0x1030,
581-- capacity at +0x1038, empty key at +0x103C, runtime records at +0x1058, 0x1B8 bytes each; zone HP at +0xF8
582-- (our Z), main HP at +0x14. The game reallocates that array and the old copies stay readable and still pass
583-- our fingerprint, so a heap scan can end up following a frozen copy. Going through the global always lands on the
584-- live one; the heap scan is only the fallback when the global cannot be read (e.g. after a game update).
585-- =========================================================================
586do
587local HEALTH_SYS_RVA, HS_KEYS, HS_CAP, HS_EMPTY, HS_RECS = 0x3326688, 0x1030, 0x1038, 0x103C, 0x1058
588local REC_STRIDE, REC_Z = 0x1B8, 0xF8
589local B8 = ffi.new('uint8_t[?]', 0x10000)
590local function u32(addr)
591 if safe_read(addr, 4, B8) < 4 then return nil end
592 return tonumber(ffi.cast('uint32_t*', B8)[0])
593end
594local function ptr(addr)
595 if not addr or safe_read(addr, 8, B8) < 8 then return nil end
596 local p = tonumber(ffi.cast('uint64_t*', B8)[0])
597 if p < 0x10000 or p > USER_END then return nil end
598 return p
599end
Ch. 6Base address + RVAGetModuleHandleA 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.
601function H.game_base()
602 if H.GAME_BASE then return H.GAME_BASE end -- test harness
603 local ok, m = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.C.GetModuleHandleA('game.dll'))) end)
604 if ok and m and m ~= 0 then return m end
605 return nil
606end
608local function health_sys()
609 local base = H.game_base()
610 if not base then return nil, 'game.dll not found' end
611 local S = ptr(base + HEALTH_SYS_RVA)
612 if not S then return nil, 'health system not created' end
613 local keys, cap, recs = ptr(S + HS_KEYS), u32(S + HS_CAP), ptr(S + HS_RECS)
614 if not (keys and cap and recs) or cap == 0 or cap > 0x40000 then
615 return nil, string.format('health system layout unexpected (cap=%s)', tostring(cap))
616 end
617 return {S = S, keys = keys, cap = cap, recs = recs, empty = u32(S + HS_EMPTY)}
618end
620-- Every (entity, index) in the health hash, in table order; calls fn(entity, index) until it returns true.
Ch. 7Walking the hash tableRead 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.
621local function each_key(hs, fn)
622 local per = 0x10000 / 8
623 for first = 0, hs.cap - 1, per do
624 local n = math.min(per, hs.cap - first)
625 if safe_read(hs.keys + first * 8, n * 8, B8) < n * 8 then return false end
626 local p, list = ffi.cast('uint32_t*', B8), {}
627 for i = 0, n - 1 do
628 local k, v = tonumber(p[i * 2]), tonumber(p[i * 2 + 1])
629 if k ~= hs.empty and k ~= 0 and v < 0x100000 then list[#list + 1] = {k, v} end
630 end
631 for _, kv in ipairs(list) do if fn(kv[1], kv[2]) then return true end end
632 end
633 return true
634end
636-- All live Hive Lord records: {ent, idx, z}. nil + reason when the health system can't be read.
637function H.direct_find()
638 local hs, why = health_sys()
639 if not hs then return nil, why end
640 local out = {}
641 if not each_key(hs, function(ent, idx)
642 local z = hs.recs + idx * REC_STRIDE + REC_Z
643 if read_record(z) then out[#out + 1] = {ent = ent, idx = idx, z = z} end
644 end) then return nil, 'health keys unreadable' end
645 return out
646end
648-- Current zone address of candidate c. full = re-resolve its index from the entity (index can change when the
649-- game compacts the array); otherwise just follow the (possibly reallocated) array with the known index.
650function H.direct_z(c, full)
651 local hs = health_sys()
652 if not hs then return nil end
653 if full then
654 local found
655 each_key(hs, function(ent, idx) if ent == c.ent then found = idx; return true end end)
656 if not found then return nil end
657 c.idx = found
658 end
659 return hs.recs + c.idx * REC_STRIDE + REC_Z
660end
661end -- direct HP lookup
Ch. 11The fallbackTake 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.
663function H.locator_start()
664 local list, why = H.direct_find()
665 if list then
666 H.loc = {phase = 'done', direct = true, cands = {}, adopted = nil, t0 = now_s(), scanned = 0, anchors = 0, last_poll = -1e9}
667 local ids = {}
668 for _, c in ipairs(list) do
669 H.loc.cands[#H.loc.cands + 1] = {z = c.z, ent = c.ent, idx = c.idx, changed = -1e9}
670 ids[#ids + 1] = string.format('entity 0x%X index %d', c.ent, c.idx)
671 end
672 if list[1] then
673 writer('HP record found via the health system: ' .. table.concat(ids, ', '))
674 H.direct_waiting = nil
675 else
676 H.loc.retry_at = now_s() + 3
677 if not H.direct_waiting then writer('health system readable, no Hive Lord record yet - retrying every 3s') end
678 H.direct_waiting = true
679 end
680 return
681 end
682 if not H.direct_fail_logged then
683 writer('direct HP lookup unavailable (' .. tostring(why) .. ') - using the heap scan')
684 H.direct_fail_logged = true
685 end
686 H.loc = {phase = 'scan', addr = 0x10000, rbase = nil, rend = nil, off = 0,
687 scanned = 0, anchors = 0, cands = {}, adopted = nil, t0 = now_s()}
688 writer('heap scan started (time-sliced, ~4ms/frame)')
689end
691-- Our own buffers hold copies of whatever we just read; never treat those as the record.
Ch. 11Don't find your own copyThe mod's own buffers hold copies of what it just read; the search must exclude them or it finds itself.
692local OWN = {
693 {tonumber(ffi.cast('uintptr_t', SCRATCH)), CHUNK},
694 {tonumber(ffi.cast('uintptr_t', WIN)), WIN_LEN},
697local function test_anchor(L, hit)
698 for _, r in ipairs(OWN) do
699 if hit >= r[1] - WIN_LEN and hit < r[1] + r[2] then return end
700 end
701 L.anchors = L.anchors + 1
702 if #L.cands < 8 and read_record(hit) then
703 for _, c in ipairs(L.cands) do if c.z == hit then return end end
704 L.cands[#L.cands + 1] = {z = hit, sig = signature(), changed = -1e9}
705 end
706end
708local function clock() return os.clock and os.clock() or 0 end
Ch. 11Time-sliced searchEach frame searches a small piece and stops when its time is up; the next frame continues.
710local function scan_step(L)
711 local t_end = clock() + SLICE_S
712 while clock() < t_end do
713 if L.rbase == nil then
714 if L.addr >= USER_END or k32.VirtualQuery(ffi.cast('const void*', L.addr), mbi, MBI_SIZE) == 0 then
715 return true
716 end
717 local m = mbi[0]
718 local rb, rs = tonumber(ffi.cast('uintptr_t', m.BaseAddress)), tonumber(m.RegionSize)
719 if rb + rs <= L.addr then return true end
720 L.addr = rb + rs
721 if m.State == MEM_COMMIT and m.Type == MEM_PRIVATE and m.Protect == PAGE_READWRITE then
722 L.rbase, L.rend, L.off = rb, rb + rs, 0
723 end
724 else
725 local take = math.min(CHUNK, L.rend - L.rbase - L.off)
726 local cbase = L.rbase + L.off
727 local got = safe_read(cbase, take, SCRATCH)
728 -- Compare aligned int32s in place: building a Lua string would leave stale copies of the
729 -- record in the heap for later scans to find.
730 local ok = got == take
731 if ok then
732 local words = ffi.cast('const int32_t*', SCRATCH)
733 for i = 0, got / 4 - 1 do
734 if words[i] == 6 and words[i + 1] == 10 and i + 1 < got / 4 then
735 test_anchor(L, cbase + i * 4 - OFF_ARMOR0)
736 end
737 end
738 end
739 L.off = L.off + take
740 L.scanned = L.scanned + take
741 if not ok or L.off >= L.rend - L.rbase then L.rbase = nil end
742 end
743 end
744 return false
745end
747function H.locator_step(t)
748 local L = H.loc
749 if not L or L.phase ~= 'scan' then return end
750 if scan_step(L) then
751 L.phase = 'done'
752 writer(string.format('heap scan done in %.0fs: %d MiB, %d anchors, %d records',
753 now_s() - L.t0, L.scanned / 1048576, L.anchors, #L.cands))
754 for i, c in ipairs(L.cands) do writer(string.format(' record %d: zones @0x%X', i, c.z)) end
755 L.last_poll = -1e9
756 if not L.cands[1] then
757 writer('no Hive Lord HP record found - will rescan in 20s')
758 L.retry_at = now_s() + 20
759 end
760 end
761end
763function H.locator_status()
764 local L = H.loc
765 if not L then return nil end
766 if L.phase == 'scan' then
767 return string.format('searching heap for HP: %d MiB', L.scanned / 1048576)
768 elseif L.adopted then
769 return 'LIVE HP'
770 end
771 return 'HP record not found, retrying'
772end
774-- Returns {main, zones[slot]}; zone values below 0 mean bleeding into constitution.
Ch. 11Re-validate every secondOnce 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.
775function H.read_hp()
776 local hp = {main = nil, zones = {}}
777 local L = H.loc
778 if not L then return hp end
779 if L.phase == 'done' and not L.adopted and L.retry_at and now_s() >= L.retry_at then
780 H.locator_start()
781 return hp
782 end
783 if L.phase ~= 'done' or not L.cands[1] then return hp end
784 local t = now_s()
785 if t - L.last_poll >= 1 then
786 L.last_poll = t
787 local keep = {}
788 for _, c in ipairs(L.cands) do
789 if L.direct then c.z = H.direct_z(c, true) end
790 if c.z and read_record(c.z) then
791 local sig = signature()
792 if sig ~= c.sig then c.sig, c.changed = sig, t end
793 keep[#keep + 1] = c
794 end
795 end
796 L.cands = keep
797 if not keep[1] then
798 writer('HP records moved or freed - rescanning')
799 H.locator_start()
800 return hp
801 end
802 -- The Hive Lord being fought is the record whose HP changed most recently.
803 local still_valid = false
804 for _, c in ipairs(keep) do if c == L.adopted then still_valid = true end end
805 if L.adopted and not still_valid then
806 -- The record we were following moved (ECS compaction) or was freed: find it again
807 -- rather than falling back to some other, idle record.
808 writer('followed HP record moved or freed - rescanning')
809 H.locator_start()
810 return hp
811 end
812 local best = L.adopted or keep[1]
813 for _, c in ipairs(keep) do if c.changed > best.changed then best = c end end
814 if best ~= L.adopted then
815 L.adopted = best
816 writer(string.format('ADOPTED zones @0x%X (%d record(s))', best.z, #keep))
817 if read_record(best.z) then
818 local main, full = i32(WIN, WIN_LO + OFF_MAIN), 0
819 for slot = 0, 37 do if i32(WIN, WIN_LO + slot * 4) >= MAXHP[slot] then full = full + 1 end end
820 writer(string.format('adopt state t=%.1f: main=%d, zones at full HP: %d/38', now_s(), main, full))
821 -- Every zone value at adoption, to tell what a mid-fight joiner receives (full sync / only
822 -- broken-or-not / nothing).
823 local vals = {}
824 for slot = 0, 37 do vals[#vals + 1] = string.format('%d:%d', slot, i32(WIN, WIN_LO + slot * 4)) end
825 writer('adopt zones: ' .. table.concat(vals, ' '))
826 -- Joined mid-fight: the client only receives damage dealt after it joined, so the plates read full
827 -- while main HP is already down. Flag the armor as incomplete for this Hive Lord.
828 -- Destroyed parts arrive as exactly -1 (a synced "dead" state), so
829 -- only partially damaged zones count as real local history.
830 local partial = 0
831 for slot = 0, 37 do
832 local v = i32(WIN, WIN_LO + slot * 4)
833 if v < MAXHP[slot] and v ~= -1 then partial = partial + 1 end
834 end
835 if H.armor_unsynced == nil then H.armor_unsynced = (main < H.MAIN_HEALTH_MAX - 2000 and partial <= 2) end
836 if H.armor_unsynced then
837 -- Zones that arrived at full: real HP unknown ('?'). Plates that arrived at exactly -1: the
838 -- host sent only "cracked" (HP crossed 0, like the dead fins), so the reserve left is unknown
839 -- and the shown value is an upper bound ('<70%').
840 H.zone_unknown = {}
841 local cracked = {}
842 for slot = 0, 37 do
843 local v = i32(WIN, WIN_LO + slot * 4)
844 if v >= MAXHP[slot] then H.zone_unknown[slot] = 'full'
845 elseif v == -1 and MAXCON[slot] > 0 then H.zone_unknown[slot] = 'cracked'; cracked[#cracked + 1] = slot end
846 end
847 if cracked[1] then writer('late join: plates already cracked (reserve unknown): ' .. table.concat(cracked, ' ')) end
848 end
849 if H.armor_unsynced then writer('armor data incomplete: joined mid-fight (earlier plate damage not synced)') end
850 end
851 end
852 end
853 if L.direct and L.adopted then
854 L.adopted.z = H.direct_z(L.adopted, false) or L.adopted.z
855 -- Array compacted this frame: the index moved, find it again by entity right away.
856 if not read_record(L.adopted.z) then L.adopted.z = H.direct_z(L.adopted, true) or L.adopted.z end
857 end
858 if not L.adopted or not read_record(L.adopted.z) then return hp end
859 local zb = WIN_LO
860 for slot = 0, 37 do hp.zones[slot] = i32(WIN, zb + slot * 4) end
861 -- Main HP at Z-0xE4 (= record +0x14); it reaches 0 when the Hive Lord is dead. Out-of-range reads keep the last
862 -- good value.
863 local main = i32(WIN, zb + OFF_MAIN)
864 if main >= 0 and main <= H.MAIN_HEALTH_MAX then L.last_main = main end
865 hp.main = L.last_main
866 -- Zone states: 2 bits per zone in the 64-bit words at rec+0x20 (= Z-0xD8), 32 zones per word
867 -- (0 alive, 1 downed/cracked, 2 dead); see 0x925F10 in tools/research/hive_lord_armor_break.md.
868 hp.zst = {}
869 for slot = 0, 37 do
870 local word = zb - 0xD8 + math.floor(slot / 32) * 8 + (slot % 32 >= 16 and 4 or 0)
871 local bits = i32(WIN, word) % 0x100000000
872 hp.zst[slot] = math.floor(bits / 4 ^ (slot % 16)) % 4
873 end
874 pcall(H.track_zones, L, hp)
875 -- Extension point: the optional diag module (mods/hivelordhud/diag) sets H.on_record to see every fresh read.
876 if H.on_record then pcall(H.on_record, L, hp) end
877 return hp
878end
Ch. 15Who 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 '<'.
880-- =========================================================================
881-- Zone tracking
882-- =========================================================================
883-- Per-frame zone bookkeeping for H.state_diag (last zone HP, last main HP), plus one log line for a big one-frame main
884-- 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-
885-- second zone changes, raw record dumps, late-join hit logs) lives in the optional diag module (hive_lord_diag.lua).
886function H.track_zones(L, hp)
887 local t = now_s()
888 local d = L.diag
889 local id = L.adopted.ent or L.adopted.z -- the direct lookup's address moves with the array; the entity doesn't
890 if not d or d.id ~= id then
891 d = {id = id, last = {}}
892 L.diag = d
893 for slot = 0, 37 do d.last[slot] = hp.zones[slot] end
894 return
895 end
896 H.state_diag(d, hp, t)
897 if hp.main and d.last_main and (d.last_main - hp.main >= 8000 or (hp.main == 0 and d.last_main > 0)) then
898 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))
899 end
900 d.last_main = hp.main
901 for slot = 0, 37 do
902 local v = hp.zones[slot]
903 if v ~= nil then d.last[slot] = v end
904 end
905end
907-- Who changed a zone's state? Our machine only resolves the hits it simulates; the host sends zone-state
908-- events, which the state machine applies with HP = min(HP, -1) (cracked) or min(HP, -constitution) (dead).
909-- So: a state change that lands HP on exactly -1 / -constitution in a frame where our main HP did not drop came from
910-- the host; one where our HP crossed the threshold through damage (main dropped too) was our own hit.
911local STATE_NAME = {[0] = 'alive', [1] = 'cracked', [2] = 'dead', [3] = '3?'}
912function H.state_diag(d, hp, t)
913 if not hp.zst then return end
914 local prev = d.zst
915 d.zst = hp.zst
916 if not prev then return end
917 local dmain = (d.last_main and hp.main) and (d.last_main - hp.main) or 0
918 for slot = 0, 37 do
919 local o, n = prev[slot], hp.zst[slot]
920 if o ~= n then
921 local v0, v1 = d.last[slot], hp.zones[slot]
922 local con = MAXCON[slot] or 0
923 local forced = (n == 1 and v1 == -1) or (n == 2 and v1 == -con)
924 local who
925 if n < o then who = 'HOST (revived)'
926 elseif forced and dmain == 0 and not (v0 and v0 == v1) then who = 'HOST (state event, no local damage this frame)'
927 elseif forced and v0 == v1 then who = 'HOST (state event, HP already there)'
928 elseif dmain > 0 or (v0 and v1 and v1 < v0) then who = 'LOCAL hit'
929 else who = 'unclear' end
930 -- On a client every plate state change comes from the host and our HP has always been behind it,
931 -- never ahead, so from the first correction on our plate HP is only an upper bound.
932 if who:sub(1, 4) == 'HOST' and n > o and v0 and v1 and v0 > v1 and (MAXCON[slot] or 0) > 0 and not H.plates_drift then
933 H.plates_drift = true
934 writer(string.format('plates: host corrected zone %d (we were %d behind) -> plate %% shown as upper bounds (<)',
935 slot, v0 - v1))
936 end
937 d.states = (d.states or 0) + 1
938 if d.states <= 200 then
939 writer(string.format('hostdiag t=%.2f (%s): zone %d %s -> %s, hp %s -> %s, main -%d this frame: %s',
940 t, wall_clock(), slot, STATE_NAME[o], STATE_NAME[n], tostring(v0), tostring(v1), dmain, who))
941 end
942 end
943 end
944end
947-- =========================================================================
948-- GUI (retained mode). One gui per settled world. Everything drawn is a "prim" registered in a group:
949-- status = status text lines (Mod Options Menu 'Status text')
950-- panel = Hive Lord silhouette (while a Hive Lord is alive and 'Armor panel' is on)
951-- bar = Souls-style boss bar (while a Hive Lord is alive and 'Boss bar' is on)
952-- Each group fades in/out (alpha) and is destroyed only once fully faded out. Every frame each prim's final colour
953-- is computed and sent to the engine only when it changed.
954-- =========================================================================
955local LAYER = 3
956local MARGIN = 24
957local SW = 200 -- silhouette width (design px); also used by the layout anchor
958local TEXT_SIZE, LINE_H, STATUS_LINES = 16, 18, 4
959local FADE_S = 0.6
960-- Size multipliers from the cfg / Mod Options Menu sliders (percent): status text, boss bar, armor % labels.
961local function size_k(key) return (tonumber(H.cfg and H.cfg[key]) or 100) / 100 end
963-- ---------- font: the engine debug font (the game's own UI font crashed on its first draw, 2026-10-03) ----------
964H.font, H.font_mat = H.FONT, H.FONT
966pcall(ffi.cdef, 'void* GetModuleHandleA(const char* name);')
968-- Same parsing HUD+ uses: Vector2 fields are unreliable, tostring is not.
969local function vector2_x(v)
970 return tonumber(string.match(tostring(v), '^Vector2%(%s*([-%d%.]+)'))
971end
973local function text_width(str, size)
974 size = size or TEXT_SIZE * H.ui.scale
975 if str ~= '' and type(Gui.text_extents) == 'function' then
976 local ok, lo, hi = pcall(Gui.text_extents, H.ui.gui, str, H.font, size)
977 local l, r = ok and vector2_x(lo), ok and vector2_x(hi)
978 if l and r and r > l then return r - l end
979 end
980 return #str * size * 0.6
981end
983local function commas(n)
984 local s = tostring(math.floor(n))
985 local out = s:reverse():gsub('(%d%d%d)', '%1,'):reverse()
986 return (out:gsub('^,', ''))
987end
989-- ---------- prims ----------
990H.grp = {}
Ch. 4Retained-mode primsEach rect and text gets an id when created and is only modified afterwards; groups handle fades and destruction.
991local function new_group(name)
992 local g = {name = name, a = 0, prims = {}, born = H.frame}
993 H.grp[name] = g
994 return g
995end
997-- Rect prim: c = {a, r, g, b}; w may change later (bars).
998local function add_rect(g, x, y, w, h, c, layer)
999 local p = {kind = 'rect', x = x, y = y, w = w, h = h, c = c, layer = layer or LAYER}
1000 p.id = Gui.rect(H.ui.gui, Vector3(x, y, p.layer), Vector2(w, h), Color(0, c[2], c[3], c[4]))
1001 g.prims[#g.prims + 1] = p
1002 return p
1003end
1005-- Text prim, created with its real initial string (updates in the creation frame are dropped by the engine).
1006-- align: 'l' (x = left), 'r' (x = right edge), 'c' (x = centre).
1007local function text_pos(p)
1008 if p.align == 'l' then return p.x end
1009 local w = text_width(p.str, p.size)
1010 if p.align == 'r' then return p.x - w end
1011 return p.x - w / 2
1012end
1014-- 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
1015-- solid thicker stroke; a single copy shifted by several px reads as a double image). Every copy sits on whole pixels
1016-- at a whole-pixel size: at fractional positions each copy is resampled on its own and the stack smears.
1017local function bold_px(size) return math.max(1, math.floor(size / 16 + 0.5)) end
1018local function px(v) return math.floor(v + 0.5) end
1019H.bold_px = bold_px
1021local function add_text(g, str, x, y, size, c, align, layer)
1022 size = math.max(1, px(size))
1023 local p = {kind = 'text', str = str, x = x, y = y, size = size, c = c, align = align or 'l', layer = layer or LAYER}
1024 local tx, ty, col = px(text_pos(p)), px(y), Color(0, c[2], c[3], c[4])
1025 p.id = Gui.text(H.ui.gui, str, H.font, size, H.font_mat, Vector3(tx, ty, p.layer), col)
1026 p.copies = {}
1027 for k = 1, bold_px(size) do
1028 p.copies[k] = Gui.text(H.ui.gui, str, H.font, size, H.font_mat, Vector3(tx + k, ty, p.layer), col)
1029 end
1030 g.prims[#g.prims + 1] = p
1031 return p
1032end
1035-- Triangle prim. Gui.triangle (helldivers2.exe 0x3E12C0) reads a point as screen (v.x, v.z), v.y = depth, so points
1036-- are Vector3(x, 0, y); no material = the gui default (same as Gui.rect). Drawn in both windings (no culling risk).
1037-- Colour changes = destroy + recreate.
1038local function tri_new(pts, col, flip, layer)
1039 local x1, y1, x2, y2, x3, y3 = pts[1], pts[2], pts[3], pts[4], pts[5], pts[6]
1040 if flip then x2, y2, x3, y3 = x3, y3, x2, y2 end
1041 return Gui.triangle(H.ui.gui, Vector3(x1, 0, y1), Vector3(x2, 0, y2), Vector3(x3, 0, y3), layer, col)
1042end
1044local function tri_make(p, a, r, gg, b)
1045 local ok, err = pcall(function()
1046 p.id = tri_new(p.pts, Color(a, r, gg, b), false, p.layer or LAYER)
1047 p.id2 = tri_new(p.pts, Color(a, r, gg, b), true, p.layer or LAYER)
1048 end)
1049 if not ok and not H.tri_error then H.tri_error = tostring(err) end
1050end
1052local function tri_free(p)
1053 if p.id then pcall(Gui.destroy_triangle, H.ui.gui, p.id) end
1054 if p.id2 then pcall(Gui.destroy_triangle, H.ui.gui, p.id2) end
1055 p.id, p.id2 = nil, nil
1056end
1057H.tri_make, H.tri_free = tri_make, tri_free
Ch. 4Destroy what you no longer useOnce a group has faded out, every prim is removed from the Gui. v0 crashed because it never did this.
1059local function destroy_group(name)
1060 local g = H.grp[name]
1061 if not g then return end
1062 if H.ui.gui then
1063 for _, p in ipairs(g.prims) do
1064 if p.kind == 'rect' then pcall(Gui.destroy_rect, H.ui.gui, p.id)
1065 elseif p.kind == 'tri' then tri_free(p)
1066 else
1067 pcall(Gui.destroy_text, H.ui.gui, p.id)
1068 for _, id in ipairs(p.copies or {}) do pcall(Gui.destroy_text, H.ui.gui, id) end
1069 end
1070 end
1071 end
1072 H.grp[name] = nil
1073end
1075-- Push a prim's current state (colour p.c scaled by the group alpha) if anything visible changed.
1076-- Fades ramp brightness as well as alpha (the in-game alpha test showed alpha does blend; the brightness ramp
1077-- just makes the fade easier to see on bright backgrounds).
Ch. 4Undoing tone mappingThe Gui is drawn before exposure and tone mapping, so colours come out about 3x brighter. Before sending: sent = wanted / gain + black.
1078-- Colour correction. The screen gui is drawn before the game's exposure / tone mapping, so a colour comes out roughly
1079-- 3x brighter with the darks crushed (a {96,2,18} strip showed as 243,0,0; greys of 150-240 as pure white). Colours
1080-- are authored as they should look and mapped back here: sent = shown / gain + black. 'Colour correction' slider:
1081-- 100 = gain 3, black 18; 0 = off; 150 = stronger.
1082function H.color_out(v)
1083 local f = (tonumber(H.cfg and H.cfg.color_fix) or 100) / 100
1084 if f <= 0 then return math.floor(v + 0.5) end
1085 local gain, black = 1 + 2 * f, 18 * f
1086 return math.floor(math.min(255, v / gain + black) + 0.5)
1087end
1088local color_out = H.color_out
Ch. 4Only send what changedEach prim remembers what it last sent; if nothing changed, the Gui is not called, which keeps the per-frame cost low.
1090local function draw(p, ga)
1091 local a = math.floor(p.c[1] * ga + 0.5)
1092 local k = ga < 1 and (0.15 + 0.85 * ga) or 1
1093 local r, gg, b = color_out(p.c[2] * k), color_out(p.c[3] * k), color_out(p.c[4] * k)
1094 if p.kind == 'rect' then
1095 local w = ga < 0.04 and 0 or p.w
1096 local key = a * 16777216 + r * 65536 + gg * 256 + b + math.floor(w * 4) * 4294967296
1097 local pk = math.floor(p.x) * 65536 + math.floor(p.y)
1098 if key == p.key and pk == p.pk and p.h == p.lh then return end
1099 p.key, p.pk, p.lh = key, pk, p.h
1100 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))
1101 elseif p.kind == 'tri' then
1102 local key = a * 16777216 + r * 65536 + gg * 256 + b
1103 if key == p.key then return end
1104 p.key = key
1105 tri_free(p)
1106 tri_make(p, a, r, gg, b)
1107 if H.tri_error and (p.fi or p.tick) then -- a facet / bar stripe failed to re-create: rects from now on
1108 H.poly_tri_off, H.tri_error, H.poly_rebuild = H.tri_error, nil, true
1109 end
1110 else
1111 local str = ga < 0.04 and ' ' or p.str
1112 local key = string.format('%d,%d,%d,%d|%d,%d|%s', a, r, gg, b, math.floor(p.x), math.floor(p.y), str)
1113 if key == p.key then return end
1114 p.key = key
1115 local tx, ty, col = px(text_pos(p)), px(p.y), Color(a, r, gg, b)
1116 Gui.update_text(H.ui.gui, p.id, str, H.font, p.size, H.font_mat, Vector3(tx, ty, p.layer), col)
1117 for k, id in ipairs(p.copies or {}) do
1118 Gui.update_text(H.ui.gui, id, str, H.font, p.size, H.font_mat, Vector3(tx + k, ty, p.layer), col)
1119 end
1120 end
1121end
1123-- ---------- zone colours ----------
1124local MAXHP_UI, MAXCON_UI = {}, {}
1125for _, g in ipairs(H.GROUPS) do
1126 for _, z in ipairs(g.zones) do MAXHP_UI[z.slot], MAXCON_UI[z.slot] = z.hp, z.con or g.con or 0 end
1127end
1129-- Fully opaque: alpha does blend in game, and 92% opacity let the terrain show through as dirty speckles.
1130local GREY = {255, 70, 70, 70}
1132-- 100% green, 60% yellow, 30% and below red; purple while bleeding through constitution; grey when broken.
1133-- Remaining durability as a 0..1 fraction. Plates with constitution count HP + constitution as one pool
1134-- (e.g. 15000 + 35000), since the plate only breaks once the zone value reaches -constitution.
1135-- Monochrome tone: a part's base grey
1136-- goes from FACET.live at 100% down to FACET.spent near 0% in 2% steps (single hits rarely re-send the triangles);
1137-- destroyed parts are FACET.dead. Each triangle keeps its fixed low-poly shade (prim.shade) and gets a fixed scorch
1138-- weight (H.scorch): the lower the HP, the more the heavily scorched triangles fall behind, so a damaged plate looks
1139-- unevenly burnt rather than evenly dimmed. No moving light.
1140function H.part_grey(ratio)
1141 local r = math.floor(math.max(0, math.min(1, ratio)) * 50 + 0.5) / 50
1142 local F = H.FACET -- FACET is declared further down (a local here would be nil)
1143 return math.floor(F.spent + (F.live - F.spent) * r + 0.5)
1144end
1145-- Fixed per-triangle scorch weight 0..0.45 (deterministic: same plate looks the same every time).
1146function H.scorch(slot, fi)
1147 return ((fi * 37 + slot * 11) % 9) / 8 * 0.45
1148end
1150local function zone_ratio(slot, v)
1151 if v == nil then return 1 end
1152 local max, con = MAXHP_UI[slot], MAXCON_UI[slot]
1153 return math.max(0, math.min(1, (v + con) / (max + con)))
1154end
1156-- 100% green, 60% yellow, 30% and below red; grey when broken.
1157-- Health tier: 3 = 60%+, 2 = 30-60%, 1 = under 30%, 0 = destroyed (same thresholds as the colour ramp).
1158local function zone_tier(slot, v)
1159 local r = zone_ratio(slot, v)
1160 if r <= 0 then return 0 elseif r >= 0.6 then return 3 elseif r > 0.3 then return 2 end
1161 return 1
1162end
1163H.zone_tier = zone_tier
1164function H.legend2()
1165 if H.cfg and H.cfg.palette == 'mono' then return 'plates: HP + constitution, darkest = destroyed' end
1166 return 'plates: HP + constitution, grey = broken'
1167end
1168function H.legend()
1169 if H.cfg and H.cfg.palette == 'mono' and H.cfg.style == 'pixel' then return 'bright 60%+ > grey > dark under 30%, thick frame = low' end
1170 if H.cfg and H.cfg.palette == 'mono' then return 'parts blacken as they lose HP, thick frame = low' end
1171 return 'green 100% > yellow 60% > red 30%'
1172end
1173-- Monochrome pixel style: three clear grey steps instead of a gradient (gradients of grey are
1174-- hard to tell apart), numbers stay white; frames get thicker as the tier drops (see MONO_FRAME).
1175local MONO_GREY = {[0] = 55, [1] = 110, [2] = 170, [3] = 228}
1176local MONO_FRAME = {[0] = 1.5, [1] = 4, [2] = 2.5, [3] = 1.5} -- frame / edge thickness per tier (design px)
1177H.MONO_FRAME = MONO_FRAME
1178local function mono() return H.cfg and H.cfg.palette == 'mono' end
1179H.mono = mono
1180local function zone_color(slot, v)
1181 if mono() then
1182 local g = MONO_GREY[zone_tier(slot, v)]
1183 return {255, g, g, g}
1184 end
1185 local r = zone_ratio(slot, v)
1186 if r <= 0 then return GREY end
1187 local red, green
1188 if r >= 0.6 then
1189 local k = (1 - r) / 0.4
1190 red, green = 40 + 215 * k, 200 + 20 * k
1191 elseif r > 0.3 then
1192 local k = (0.6 - r) / 0.3
1193 red, green = 255, 220 - 180 * k
1194 else
1195 red, green = 230, 40
1196 end
1197 return {255, red, green, 40}
1198end
1200local UNKNOWN_C = {255, 105, 120, 145} -- slate blue-grey: HP not known
1202local function pct_label(slot, v)
1203 local r = zone_ratio(slot, v)
1204 if r <= 0 then return '0%', false end
1205 return string.format('%d%%', math.max(1, math.floor(r * 100 + 0.5))), true
1206end
1208-- ---------- status group (top-right text) ----------
1209local DOT_COLORS = {
1210 scanning = {255, 60, 200, 80},
1211 found = {255, 230, 40, 40},
1214-- Where the info cluster sits. 'left' (default): left edge below the stratagem button, because the game's mission
1215-- list lives top-right and grows with the objectives (it overlapped our text). 'right': the original top-right spot.
1216-- Set with the 'Panel side' option; saved in the cfg.
1217local LABEL_ROOM = 96 -- px left of the silhouette for the % boxes
1218function H.layout()
1219 local s = H.ui.scale
1220 if H.cfg.pos == 'right' then
1221 return {left = false, sx = H.ui.width - MARGIN * s, top = H.ui.height - 140 * s, ox = H.ui.width - MARGIN * s - SW * s}
1222 end
1223 return {left = true, sx = MARGIN * s, top = H.ui.height - 150 * s, ox = (MARGIN + LABEL_ROOM * size_k('armor_size')) * s}
1224end
1226-- Status text (Mod Options Menu): 'full' = shown, 'off' = hidden. The lines belong to the armor panel, so with the
1227-- panel off there is nothing to show and the status group (dot included) goes away too.
1228function H.status_shown()
1229 local m = H.cfg.status
1230 return m ~= 'off' and H.cfg.panel_show ~= 'hidden'
1231end
1233-- Top edge (y-up) of the silhouette / distance / anim boxes: below the status lines, or in their place when the
1234-- status text is hidden.
1235function H.panel_oy()
1236 local s, lay = H.ui.scale, H.layout()
1237 if not H.status_shown() then return lay.top + 10 * s end
1238 return lay.top - (STATUS_LINES * LINE_H * size_k('status_size') + 12) * s
1239end
1241local function build_status()
1242 local g, s = new_group('status'), H.ui.scale
1243 local k = s * size_k('status_size')
1244 local lay = H.layout()
1245 local right, top = lay.sx, lay.top
1246 g.right, g.top = right, top
1247 g.dot = add_rect(g, lay.left and right or (right - 10 * k), top, 10 * k, 10 * k, DOT_COLORS.scanning)
1248 g.lines = {}
1249 if H.text_ok then
1250 for i = 1, STATUS_LINES do
1251 g.lines[i] = add_text(g, ' ', lay.left and (right + 16 * k) or (right - 16 * k), top - (i - 1) * LINE_H * k,
1252 TEXT_SIZE * k, {255, 235, 235, 235}, lay.left and 'l' or 'r')
1253 end
1254 end
1255 if H.status_last then H.refresh_status() end
1256end
1258local function set_status(state, lines)
1259 H.status_last = {state, lines} -- kept outside the group: a rebuilt status group picks it up at once
1260 local g = H.grp.status
1261 if not g then return end
1262 local out = {}
1263 if H.cfg.panel_show ~= 'hidden' then for i = 1, math.min(#lines, STATUS_LINES - 1) do out[i] = lines[i] end end
1264 lines = out
1265 if DOT_COLORS[state] then g.dot.c = DOT_COLORS[state] end
1266 for i, p in ipairs(g.lines) do
1267 local str = (lines[i] or ''):sub(1, 70)
1268 p.str = str ~= '' and str or ' '
1269 end
1270end
1272-- Re-apply the last status lines (after a setting change rebuilt the status group).
1273function H.refresh_status()
1274 local last = H.status_last
1275 if last and H.grp.status then set_status(last[1], last[2]) end
1276end
1279-- ---------- panel group: Hive Lord silhouette, head at the top ----------
1280-- Modelled on a top-down render of the Hive Lord: wide rounded segment plates with a centre ridge, thick fins
1281-- that curve out and down and taper, a fan of jaw spikes, long horn-like mandibles, a tapering tail with spikes.
1282-- Each part is built from layers: dark outline -> body -> detail (ridge), on increasing gui layers.
1283-- front plate = spineN (slots 2..7, with %), back plate = spineN_back (9..14) peeking out on the right only,
1284-- tail front = lower sternal plate (slot 8, with %), fins l/r_leg0..6, head: crown / jaws / mouth / mandibles.
1285-- Two art styles: 'poly' (H.POLY, one Gui.triangle per facet) and 'pixel' (H.SIL, a pixel grid of rects).
1286-- Design px relative to the silhouette's top-left, y downward; converted in build_panel.
1287local CX = SW / 2
1288local SEG_Y0, SEG_P = 86, 38
1289H.SIL_H = SEG_Y0 + 7 * SEG_P + 34
1290local OUTLINE = 1.6
1291local LABEL_SIZE = 20
1292local SHADE_OUTLINE, SHADE_RIDGE = 0.32, 0.72
1294local function fan(pts) -- convex polygon -> triangles
1295 local out = {}
1296 for i = 2, #pts - 1 do out[#out + 1] = {pts[1], pts[i], pts[i + 1]} end
1297 return out
1298end
1300-- Superellipse ("squircle") centred at cx, cy with half-sizes a, b; n = 2 ellipse, larger = boxier.
1301local function squircle(cx, cy, a, b, n, k)
1302 local pts = {}
1303 k = k or 28
1304 for i = 0, k - 1 do
1305 local t = (i / k) * 2 * math.pi
1306 local c, s = math.cos(t), math.sin(t)
1307 local x = (math.abs(c) ^ (2 / n)) * (c < 0 and -1 or 1)
1308 local y = (math.abs(s) ^ (2 / n)) * (s < 0 and -1 or 1)
1309 pts[#pts + 1] = {cx + a * x, cy + b * y}
1310 end
1311 return fan(pts)
1312end
1314-- Thick curved strip along a centre line, width tapering from w0 to w1.
1315local function strip(line, w0, w1)
1316 local outer, inner = {}, {}
1317 for i, p in ipairs(line) do
1318 local a = line[math.max(1, i - 1)]
1319 local b = line[math.min(#line, i + 1)]
1320 local dx, dy = b[1] - a[1], b[2] - a[2]
1321 local len = math.sqrt(dx * dx + dy * dy)
1322 if len == 0 then len = 1 end
1323 local nx, ny = -dy / len, dx / len
1324 local w = (w0 + (w1 - w0) * (i - 1) / (#line - 1)) / 2
1325 outer[i] = {p[1] + nx * w, p[2] + ny * w}
1326 inner[i] = {p[1] - nx * w, p[2] - ny * w}
1327 end
1328 local tris = {}
1329 for i = 1, #line - 1 do
1330 tris[#tris + 1] = {outer[i], outer[i + 1], inner[i + 1]}
1331 tris[#tris + 1] = {outer[i], inner[i + 1], inner[i]}
1332 end
1333 return tris
1334end
1336local function spike(bx, by, w, tx, ty) return {{{bx - w / 2, by}, {bx + w / 2, by}, {tx, ty}}} end
1338local function mirror(line) local o = {} for i, p in ipairs(line) do o[i] = {2 * CX - p[1], p[2]} end return o end
1339local function concat(...) local o = {} for _, l in ipairs({...}) do for _, t in ipairs(l) do o[#o + 1] = t end end return o end
1341-- parts: {slot = zone slot or 'decor', label = bool, layers = {{tris, shade, layer}, ...}}
1342H.SIL = {}
1343local function part(slot, label, layers, decor_color)
1344 H.SIL[#H.SIL + 1] = {slot = slot, label = label, layers = layers, decor = decor_color}
1345end
1346local function body(outline_tris, body_tris, detail_tris)
1347 local l = {{outline_tris, SHADE_OUTLINE, 0}, {body_tris, 1, 1}}
1348 if detail_tris then l[#l + 1] = {detail_tris, SHADE_RIDGE, 2} end
1349 return l
1350end
1352-- front plate: wide boxy rounded plate with a centre ridge
1353local function front_plate(y)
1354 local cy = y + 17
1355 return body(squircle(CX, cy, 36 + OUTLINE, 16 + OUTLINE, 3.2), squircle(CX, cy, 36, 16, 3.2),
1356 squircle(CX, cy + 1, 1.6, 13, 2, 12))
1357end
1358-- back plate: a rounded tab peeking out on the right of the front plate
1359local function back_plate(y)
1360 local cy = y + 18
1361 return body(squircle(CX + 44, cy, 5 + OUTLINE, 12 + OUTLINE, 2.6), squircle(CX + 44, cy, 5, 12, 2.6))
1362end
1363-- fin: out from the body, then curving down, tapering
1364local function fin_line(side, y)
1365 local x0 = side < 0 and (CX - 39) or (CX + 52)
1366 local d = side < 0 and -1 or 1
1367 return {{x0, y + 12}, {x0 + d * 12, y + 12}, {x0 + d * 22, y + 15}, {x0 + d * 30, y + 21},
1368 {x0 + d * 35, y + 29}, {x0 + d * 37, y + 37}}
1369end
1370local function fin(side, y)
1371 local line = fin_line(side, y)
1372 return body(strip(line, 9 + 2 * OUTLINE, 3 + 2 * OUTLINE), strip(line, 9, 3))
1373end
1374-- tail: tapering last segment (lower sternal plate)
1375local function tail(y)
1376 local pts = {{CX - 33, y}, {CX + 33, y}, {CX + 30, y + 12}, {CX + 22, y + 24}, {CX + 11, y + 32},
1377 {CX, y + 34}, {CX - 11, y + 32}, {CX - 22, y + 24}, {CX - 30, y + 12}}
1378 local outl = {}
1379 for i, p in ipairs(pts) do
1380 local dx, dy = p[1] - CX, p[2] - (y + 14)
1381 local l = math.sqrt(dx * dx + dy * dy); if l == 0 then l = 1 end
1382 outl[i] = {p[1] + dx / l * OUTLINE, p[2] + dy / l * OUTLINE}
1383 end
1384 return body(fan(outl), fan(pts), squircle(CX, y + 13, 1.6, 10, 2, 12))
1385end
1387-- head
1389 local horn = {{CX - 31, 62}, {CX - 46, 61}, {CX - 59, 56}, {CX - 68, 46}, {CX - 73, 33}, {CX - 74, 20}, {CX - 72, 10}}
1390 part(22, false, body(strip(horn, 9 + 2 * OUTLINE, 3 + 2 * OUTLINE), strip(horn, 9, 3)))
1391 part(23, false, body(strip(mirror(horn), 9 + 2 * OUTLINE, 3 + 2 * OUTLINE), strip(mirror(horn), 9, 3)))
1392 local jaws = {}
1393 for i, dx in ipairs({-20, -10, 0, 10, 20}) do
1394 local tip = 46 - (i == 3 and 34 or (i == 2 or i == 4) and 30 or 24)
1395 jaws[#jaws + 1] = spike(CX + dx * 0.6, 48, 9, CX + dx, tip)
1396 end
1397 local jt = concat((table.unpack or unpack)(jaws)) -- and/or would truncate unpack to its first value
1398 part(0, false, {{jt, 1, 1}})
1399 part(1, false, body(squircle(CX, 60, 30 + OUTLINE, 13 + OUTLINE, 2.8), squircle(CX, 60, 30, 13, 2.8),
1400 squircle(CX, 61, 1.6, 10, 2, 12)))
1401 part(21, false, body(squircle(CX, 79, 8 + OUTLINE, 3 + OUTLINE, 2), squircle(CX, 79, 8, 3, 2)))
1402end
1403for k = 0, 6 do
1404 local y = SEG_Y0 + k * SEG_P
1405 if k <= 5 then
1406 local n = 5 - k -- spineN, spine5 next to the head
1407 part(14 - n, false, back_plate(y))
1408 part(26 + 2 * n, false, fin(-1, y))
1409 part(27 + 2 * n, false, fin(1, y))
1410 part(7 - n, {y = y + 6, x = CX - 32}, front_plate(y))
1411 else
1412 part(24, false, fin(-1, y))
1413 part(25, false, fin(1, y))
1414 part(8, {y = y + 6, x = CX - 31}, tail(y))
1415 end
1416end
1418-- Pixel-art rasteriser: a global PIX-sized grid, a cell is filled when its centre
1419-- lies inside any triangle; filled cells are merged into horizontal runs.
1420local PIX = 4
1421local function inside(t, x, y)
1422 local function side(a, b) return (b[1] - a[1]) * (y - a[2]) - (b[2] - a[2]) * (x - a[1]) end
1423 local d1, d2, d3 = side(t[1], t[2]), side(t[2], t[3]), side(t[3], t[1])
1424 local neg = d1 < 0 or d2 < 0 or d3 < 0
1425 local pos = d1 > 0 or d2 > 0 or d3 > 0
1426 return not (neg and pos)
1427end
1428local function pixelate(tris)
1429 local minx, maxx, miny, maxy = 1e9, -1e9, 1e9, -1e9
1430 for _, t in ipairs(tris) do
1431 for i = 1, 3 do
1432 minx, maxx = math.min(minx, t[i][1]), math.max(maxx, t[i][1])
1433 miny, maxy = math.min(miny, t[i][2]), math.max(maxy, t[i][2])
1434 end
1435 end
1436 local out = {}
1437 for gy = math.floor(miny / PIX), math.ceil(maxy / PIX) do
1438 local run
1439 for gx = math.floor(minx / PIX), math.ceil(maxx / PIX) do
1440 local cx, cy = (gx + 0.5) * PIX, (gy + 0.5) * PIX
1441 local hit = false
1442 for _, t in ipairs(tris) do if inside(t, cx, cy) then hit = true; break end end
1443 if hit then
1444 if run then run[3] = run[3] + PIX else run = {gx * PIX, gy * PIX, PIX, PIX} end
1445 elseif run then out[#out + 1] = run; run = nil end
1446 end
1447 if run then out[#out + 1] = run end
1448 end
1449 return out
1450end
1452-- Monochrome greys: FACET.live at full HP, FACET.spent near 0%, FACET.dead when destroyed (see H.part_grey).
1453local FACET = {live = 226, spent = 78, dead = 46}
1454H.FACET = FACET
1456-- ---------- poly style ----------
1457-- Low-poly look: every part is 3-5 big flat facets (a few raised vertices make them face different ways), each lit
1458-- flat from the top-left; shades are quantised so neighbouring facets of one tone merge.
1459-- Each facet is one Gui.triangle. Static RE of helldivers2.exe: the Gui.triangle binding
1460-- (0x3E12C0) takes (gui, p1, p2, p3, layer, [color], [material], [uv x3]) with screen x = v.x, screen y = v.z
1461-- (v.y = depth), and without a material it queues the same command as Gui.rect (0x267990, material id 0xFFFFFF =
1462-- the gui default; Gui.rect is itself 2 triangles there). Fallback: horizontal spans POLY_STEP design px tall.
1463-- H.POLY parts: {slot, label, facets = {{tri, shade}, ...}}
1464H.POLY = {}
1466 local STEP_P = 2
1467 H.POLY_STEP = STEP_P
1468 local LX, LY, LZ = -0.45, -0.6, 0.66
1469 local ll = math.sqrt(LX * LX + LY * LY + LZ * LZ)
1470 LX, LY, LZ = LX / ll, LY / ll, LZ / ll
1471 local function lit(a, b, c)
1472 local ux, uy, uz = b[1] - a[1], b[2] - a[2], (b[3] or 0) - (a[3] or 0)
1473 local vx, vy, vz = c[1] - a[1], c[2] - a[2], (c[3] or 0) - (a[3] or 0)
1474 local nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
1475 local n = math.sqrt(nx * nx + ny * ny + nz * nz)
1476 if n == 0 then return 1 end
1477 if nz < 0 then nx, ny, nz = -nx, -ny, -nz end
1478 local d = math.max(0, (nx * LX + ny * LY + nz * LZ) / n)
1479 return math.min(1, math.floor((0.42 + 0.58 * d) * 25 + 0.5) / 25)
1480 end
1481 H.poly_lit = lit
1482 local function tri(a, b, c) return {{a, b, c}, lit(a, b, c)} end
1483 local function add(slot, label, facets, extra)
1484 local pt = {slot = slot, label = label, facets = facets}
1485 for k, v in pairs(extra or {}) do pt[k] = v end
1486 H.POLY[#H.POLY + 1] = pt
1487 end
1488 -- Armour plate: folded hexagon, 4 facets: the side corners L / R are raised (L
1489 -- higher), so the plate creases along L-R; each half folds once more on a diagonal, giving four tones.
1490 local function fold_hex(slot, label, cx, cy, hw, hh, cut, h)
1491 local L, TL, TR, R = {cx - hw, cy, h}, {cx - hw + cut, cy - hh, 0}, {cx + hw - cut, cy - hh, 0}, {cx + hw, cy, h * 0.35}
1492 local BR, BL = {cx + hw - cut, cy + hh, 0}, {cx - hw + cut, cy + hh, 0}
1493 add(slot, label, {tri(L, TL, TR), tri(L, TR, R), tri(L, R, BR), tri(L, BR, BL)})
1494 end
1495 -- Bent strip, 3 facets (fins, mandibles): base edge A-B, elbow E1 (upper) / E2 (lower), tip T.
1496 local function bent(slot, A, B, E1, E2, T)
1497 add(slot, false, {tri(A, B, E2), tri(A, E2, E1), tri(E1, E2, T)})
1498 end
1499 local function mir(p) return {2 * CX - p[1], p[2], p[3]} end
1500 -- Head, front view: a crest on top, a five-sided face in the middle, four flared
1501 -- petals on the diagonals (jaws) and a long lobe either side (mandibles).
1502 do
1503 local tip, R, bot, L, M = {CX, 0, 0}, {CX + 13, 22, 0}, {CX, 36, 0}, {CX - 13, 22, 0}, {CX - 2, 19, 7}
1504 add(1, false, {tri(M, tip, R), tri(M, R, bot), tri(M, bot, L), tri(M, L, tip)}) -- crest (crown)
1505 local o, P = {{CX, 34, 0}, {CX + 16, 45, 0}, {CX + 10, 64, 0}, {CX - 10, 64, 0}, {CX - 16, 45, 0}}, {CX - 3, 48, 8}
1506 local face = {}
1507 for i = 1, 5 do face[i] = tri(P, o[i], o[i % 5 + 1]) end
1508 add(21, false, face) -- face (mouth)
1509 local tl = {{CX - 12, 42, 2}, {CX - 42, 30, 0}, {CX - 24, 8, 3}}
1510 local bl = {{CX - 12, 60, 2}, {CX - 42, 70, 0}, {CX - 26, 84, 3}}
1511 add(0, false, {tri(tl[1], tl[2], tl[3]), tri(mir(tl[1]), mir(tl[2]), mir(tl[3])), -- jaws: 4 petals
1512 tri(bl[1], bl[2], bl[3]), tri(mir(bl[1]), mir(bl[2]), mir(bl[3]))})
1513 local A, B, Ot, Ob, T = {CX - 16, 44, 2}, {CX - 16, 58, 0}, {CX - 56, 36, 5}, {CX - 62, 60, 0}, {CX - 84, 46, 1}
1514 bent(22, A, B, Ot, Ob, T) -- mandibles
1515 bent(23, mir(A), mir(B), mir(Ot), mir(Ob), mir(T))
1516 end
1517 for k = 0, 6 do
1518 local y = SEG_Y0 + k * SEG_P
1519 local function fin(side, slot)
1520 local x0, d = side < 0 and (CX - 39) or (CX + 52), side < 0 and -1 or 1
1521 bent(slot, {x0, y + 7, 1.5}, {x0, y + 17, 0}, {x0 + d * 26, y + 11, 5}, {x0 + d * 24, y + 21, 0},
1522 {x0 + d * 37, y + 37, 0})
1523 end
1524 fin(-1, k <= 5 and 26 + 2 * (5 - k) or 24)
1525 fin(1, k <= 5 and 27 + 2 * (5 - k) or 25)
1526 if k <= 5 then
1527 local n = 5 - k
1528 -- back plate tab, 4 facets: creased top-to-bottom
1529 local Tp, R1, R2, Bt = {CX + 44, y + 6, 3}, {CX + 49, y + 12, 0}, {CX + 49, y + 24, 0}, {CX + 44, y + 30, 1}
1530 local L2, L1 = {CX + 39, y + 24, 0}, {CX + 39, y + 12, 0}
1531 add(14 - n, false, {tri(Tp, R1, R2), tri(Tp, R2, Bt), tri(Tp, Bt, L2), tri(Tp, L2, L1)})
1532 fold_hex(7 - n, {y = y + 6, x = CX - 32}, CX, y + 17, 36, 16, 9, 7)
1533 else
1534 -- tail plate, 5 facets: a pentagon fanned from a raised point
1535 local P = {CX, y + 12, 7}
1536 local o = {{CX - 33, y + 1, 0}, {CX + 33, y + 1, 0}, {CX + 29, y + 15, 0}, {CX, y + 34, 0}, {CX - 29, y + 15, 0}}
1537 local f = {}
1538 for i = 1, 5 do f[i] = tri(P, o[i], o[i % 5 + 1]) end
1539 add(8, {y = y + 6, x = CX - 31}, f)
1540 end
1541 end
1543 -- x-interval of a polygon (or triangle) at height y, nil when the line misses it.
1544 local function span(pts, y)
1545 local lo, hi
1546 for i = 1, #pts do
1547 local a, b = pts[i], pts[i % #pts + 1]
1548 if (a[2] - y) * (b[2] - y) <= 0 and a[2] ~= b[2] then
1549 local x = a[1] + (y - a[2]) * (b[1] - a[1]) / (b[2] - a[2])
1550 lo, hi = math.min(lo or x, x), math.max(hi or x, x)
1551 end
1552 end
1553 return lo, hi
1554 end
1555 H.poly_span = span
1556 -- Facets -> rows of {a, b, k, fi} spans (fi = facet index, so each facet can be shaded on its own); overlaps
1557 -- are clipped (no double draw).
1558 function H.poly_rows(facets)
1559 local miny, maxy = 1e9, -1e9
1560 for _, f in ipairs(facets) do
1561 for i = 1, 3 do miny, maxy = math.min(miny, f[1][i][2]), math.max(maxy, f[1][i][2]) end
1562 end
1563 local rows = {}
1564 local y0 = math.floor(miny / STEP_P) * STEP_P
1565 while y0 < maxy do
1566 local iv = {}
1567 for fi, f in ipairs(facets) do
1568 local lo, hi = span(f[1], y0 + STEP_P / 2)
1569 if lo and hi - lo > 0.05 then iv[#iv + 1] = {a = lo, b = hi, k = f[2], fi = fi} end
1570 end
1571 table.sort(iv, function(p, q) return p.a < q.a end)
1572 local segs = {}
1573 for _, v in ipairs(iv) do
1574 local last = segs[#segs]
1575 if last and v.a < last.b then v.a = last.b end
1576 if v.b - v.a > 0.05 then segs[#segs + 1] = v end
1577 end
1578 if #segs > 0 then rows[#rows + 1] = {y = y0, segs = segs} end
1579 y0 = y0 + STEP_P
1580 end
1581 return rows
1582 end
1583 -- Create one part's prims in group g; z.frank[fi] = the facet's place in the darkening order (see FACET).
1584 local function poly_tris(g, z, pt, c0, ox, oy, s)
1585 local made = {}
1586 for fi, f in ipairs(pt.facets) do
1587 local k, v = f[2], f[1]
1588 local c = {c0[1], c0[2] * k, c0[3] * k, c0[4] * k}
1589 local pts = {}
1590 for i = 1, 3 do pts[#pts + 1] = ox + v[i][1] * s; pts[#pts + 1] = oy - v[i][2] * s end
1591 local l = {kind = 'tri', pts = pts, c = c, layer = LAYER + 1, shade = k, fi = fi}
1592 H.tri_make(l, 0, c[2], c[3], c[4])
1593 if H.tri_error then
1594 -- Sticky: never try triangles again this session (the legacy shape test below must not see it).
1595 H.poly_tri_off, H.tri_error = H.tri_error, nil
1596 for _, m in ipairs(made) do H.tri_free(m) end
1597 if l.id or l.id2 then H.tri_free(l) end
1598 return false
1599 end
1600 made[#made + 1] = l
1601 end
1602 for _, l in ipairs(made) do g.prims[#g.prims + 1] = l; z.prims[#z.prims + 1] = l end
1603 return true
1604 end
1605 function H.poly_build(g, z, pt, c0, ox, oy, s)
1606 if not H.poly_tri_off and type(Gui.triangle) == 'function' and poly_tris(g, z, pt, c0, ox, oy, s) then
1607 if not H.tri_logged then H.tri_logged = true; writer('poly: facets drawn as triangles') end
1608 else
1609 if not H.tri_fallback_logged then
1610 H.tri_fallback_logged = true
1611 writer('poly: Gui.triangle failed (' .. tostring(H.poly_tri_off) .. '), using 2 px spans')
1612 end
1613 H.poly_spans(g, z, pt, c0, ox, oy, s)
1614 end
1615 H.poly_rank(z, pt)
1616 end
1617 function H.poly_spans(g, z, pt, c0, ox, oy, s)
1618 pt.rows = pt.rows or H.poly_rows(pt.facets)
1619 for _, row in ipairs(pt.rows) do
1620 local y, h = oy - (row.y + STEP_P) * s, STEP_P * s
1621 for _, sg in ipairs(row.segs) do
1622 local c = {c0[1], c0[2] * sg.k, c0[3] * sg.k, c0[4] * sg.k}
1623 local l = add_rect(g, ox + sg.a * s, y, (sg.b - sg.a) * s, h, c, LAYER + 1)
1624 l.shade, l.fi = sg.k, sg.fi
1625 z.prims[#z.prims + 1] = l
1626 end
1627 end
1628 end
1629 function H.poly_rank(z, pt)
1630 local ord = {}
1631 for i in ipairs(pt.facets) do ord[i] = i end
1632 table.sort(ord, function(a, b)
1633 local ka, kb = pt.facets[a][2], pt.facets[b][2]
1634 if ka ~= kb then return ka < kb end
1635 return a < b
1636 end)
1637 z.frank, z.nf = {}, #pt.facets
1638 for j, i in ipairs(ord) do z.frank[i] = j - 1 end
1639 end
1640end
1642local function build_panel()
1643 local g, s = new_group('panel'), H.ui.scale
1644 local ka = size_k('armor_size')
1645 local ox = H.layout().ox
1646 local oy = H.panel_oy()
1647 g.zones = {}
1648 local function SX(x) return ox + x * s end
1649 local function SY(y) return oy - y * s end
1650 local poly = H.cfg.style ~= 'pixel'
1651 g.style = poly and 'poly' or 'pixel'
1652 local ndecor = 0
1653 for _, pt in ipairs(poly and H.POLY or H.SIL) do
1654 if pt.slot == 'decor' then ndecor = ndecor + 1 end
1655 local key = pt.slot == 'decor' and ('decor' .. ndecor) or pt.slot
1656 local z = g.zones[key] or {prims = {}, decor = pt.decor}
1657 g.zones[key] = z
1658 local c0 = pt.decor or zone_color(pt.slot, nil)
1659 if poly then H.poly_build(g, z, pt, c0, ox, oy, s) end
1660 for _, ly in ipairs(poly and {} or pt.layers) do
1661 local tris, shade, layer = ly[1], ly[2], LAYER + ly[3]
1662 local c = {c0[1], c0[2] * shade, c0[3] * shade, c0[4] * shade}
1663 if shade == 1 then -- pixel style: body layer only (no outline / ridge)
1664 ly.pixels = ly.pixels or pixelate(tris)
1665 for _, r in ipairs(ly.pixels) do
1666 local x, y, w, h = SX(r[1]), oy - (r[2] + r[4]) * s, r[3] * s, r[4] * s
1667 local p = add_rect(g, x, y, w, h, c, layer)
1668 p.shade = shade
1669 z.prims[#z.prims + 1] = p
1670 end
1671 end
1672 end
1673 -- % label to the left of the silhouette, joined to its plate by a leader line (+ a dot on the plate).
1674 if pt.label and H.text_ok then
1675 local ly, lx = SY(pt.label.y), SX(pt.label.x)
1676 local x0 = SX(-4)
1677 local line = add_rect(g, x0, ly - 0.75 * s, lx - x0, 1.5 * s, c0, LAYER + 3)
1678 line.shade = 0.85
1679 local dot = add_rect(g, lx - 2 * s, ly - 2 * s, 4 * s, 4 * s, c0, LAYER + 3)
1680 dot.shade = 1
1681 z.prims[#z.prims + 1], z.prims[#z.prims + 2] = line, dot
1682 -- Opaque box behind the label so busy backgrounds can't hide it (translucent fills don't render):
1683 -- a zone-coloured frame (flashes with the zone) around a dark fill, sized for '<100%' (late join).
1684 -- Size follows the 'Armor value size' slider (box, padding and text together).
1685 local lk = s * ka
1686 local bw = text_width('<100%', LABEL_SIZE * lk) + 12 * lk
1687 local bh = LABEL_SIZE * lk + 6 * lk
1688 local bx, by = x0 - bw, ly - bh / 2
1689 local frame = add_rect(g, bx - 1.5 * s, by - 1.5 * s, bw + 3 * s, bh + 3 * s, c0, LAYER + 3)
1690 frame.shade = 1
1691 z.prims[#z.prims + 1] = frame
1692 z.frame, z.box = frame, {bx, by, bw, bh}
1693 add_rect(g, bx, by, bw, bh, {255, 18, 18, 18}, LAYER + 4)
1694 z.label = add_text(g, '100%', x0 - 6 * lk, ly - LABEL_SIZE * lk * 0.35, LABEL_SIZE * lk, c0, 'r', LAYER + 5)
1695 end
1696 end
1697end
1700-- ---------- tag group: on-screen marker at the Hive Lord's position (like the player tags) ----------
1701local function parse3(v)
1702 local x, y, z = string.match(tostring(v), '([-%d%.eE+]+)%s*,%s*([-%d%.eE+]+)%s*,%s*([-%d%.eE+]+)')
1703 return tonumber(x), tonumber(y), tonumber(z)
1704end
1706-- World point -> Gui pixels through the local camera. Returns sx, sy, behind (or nil). Camera.world_to_screen is
1707-- the engine's own projection; the camera's forward vector decides "behind". Screen y orientation and the
1708-- render-vs-gui resolution are checked once and logged (H.tag_flip / H.tag_scale).
1709-- The camera, its forward vector and position are looked up once per frame (the plate labels project 7 points).
1710local function frame_camera(Camera, Quat)
1711 local pc = H.pcache
1712 if pc and pc.frame == H.frame then return pc end
1713 pc = {frame = H.frame}
1714 H.pcache = pc
1715 local L = H.live
1716 local cu = L.main_cam or L.tag_cam
1717 if not cu or not attempt(Unit.alive, cu) then
1718 cu = nil
1719 for _, u in ipairs(L.cams or {}) do
1720 if attempt(Unit.alive, u) and attempt(Unit.camera, u, 1) ~= nil then cu = u; break end
1721 end
1722 L.tag_cam = cu
1723 end
1724 if not cu then return pc end
1725 pc.c = attempt(Unit.camera, cu, 1)
1726 if pc.c ~= nil and Quat and type(Quat.forward) == 'function' and type(Camera.world_rotation) == 'function' then
1727 local q = attempt(Camera.world_rotation, pc.c)
1728 local f = q and attempt(Quat.forward, q)
1729 local cp = attempt(Camera.world_position, pc.c)
1730 if f and cp then
1731 pc.fx, pc.fy, pc.fz = parse3(f)
1732 pc.px, pc.py, pc.pz = parse3(cp)
1733 end
1734 end
1735 return pc
1736end
Ch. 4Projecting 3D to the screenCamera.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.
1738function H.project(wx, wy, wz)
1739 local Camera, Quat = api('Camera'), api('Quaternion')
1740 if not Camera or type(Camera.world_to_screen) ~= 'function' then return nil end
1741 local pc = frame_camera(Camera, Quat)
1742 local c = pc.c
1743 if c == nil then return nil end
1744 if not H.tag_called then
1745 H.tag_called = true
1746 writer('tag: calling Camera.world_to_screen for the first time (if the game crashes now, this API is bad)')
1747 end
1748 local ok, v = pcall(Camera.world_to_screen, c, Vector3(wx, wy, wz))
1749 if not ok or v == nil then
1750 if not H.tag_err then H.tag_err = true; writer('tag: Camera.world_to_screen failed: ' .. tostring(v)) end
1751 return nil
1752 end
1753 local sx, sy, sz = parse3(v)
1754 if not (sx and sy) then return nil end
1755 local behind = false
1756 if pc.fx and pc.px then
1757 behind = (wx - pc.px) * pc.fx + (wy - pc.py) * pc.fy + (wz - pc.pz) * pc.fz < 0
1758 end
1759 if H.tag_scale == nil then
1760 local rw = type(Gui.render_resolution) == 'function' and attempt(Gui.render_resolution) or nil
1761 H.tag_scale = (type(rw) == 'number' and rw > 0) and (H.ui.width / rw) or 1
1762 writer(string.format('tag: first projection raw=(%s) gui=%dx%d render_w=%s scale=%.3f', tostring(v), H.ui.width,
1763 H.ui.height, tostring(rw), H.tag_scale))
1764 end
1765 sx, sy = sx * H.tag_scale, sy * H.tag_scale
1766 if H.tag_flip then sy = H.ui.height - sy end
1767 return sx, sy, behind, sz
1768end
1770-- Screen y orientation: world_to_screen is y-up like the Gui. Deciding it at runtime once flipped the tag upside
1771-- down on a bad sample (both points off-screen), so it is fixed; this only logs a check with both points on screen.
1772H.tag_flip = false
1773function H.tag_calibrate()
1774 if H.tag_checked or not H.surfaced_until or H.surfaced_until <= now_s() then return end
1775 local L = H.live
1776 if not (L.unit and L.head_idx and L.root_idx) or H.dead_since then return end
1777 local hx, hy, hz = unit_pos(L.unit, L.head_idx)
1778 local rx, ry, rz = unit_pos(L.unit, L.root_idx)
1779 if not (hz and rz) or hz - rz < 30 then return end
1780 local _, syh, bh = H.project(hx, hy, hz)
1781 local _, syr, br = H.project(rx, ry, rz)
1782 local function on(y) return y and y >= 0 and y <= H.ui.height end
1783 if on(syh) and on(syr) and not bh and not br and math.abs(syh - syr) > 30 then
1784 H.tag_checked = true
1785 writer(string.format('tag: y check %s (head %.0f vs root %.0f px)', syh > syr and 'OK' or 'INVERTED?', syh, syr))
1786 end
1787end
1789-- Tag in the game's squad-marker style, in red: a small box with a thin red rim, a dark
1790-- inside, a thicker bright bar on the left and 'HL'; distance (and the off-screen arrow) in small text under it.
1791local TAG_SIZE, TAG_DIST_SIZE = 18, 13
1792local function build_tag()
1793 local g, s = new_group('tag'), H.ui.scale
1794 if not H.text_ok then return end
1795 local size = TAG_SIZE * s
1796 g.size, g.dsize = size, TAG_DIST_SIZE * s
1797 g.bw = text_width('HL', size) + 18 * s
1798 g.bh = size + 9 * s
1799 g.frame = add_rect(g, 0, 0, g.bw, g.bh, {255, 230, 58, 58}, LAYER + 9) -- thin rim
1800 g.fill = add_rect(g, 0, 0, g.bw - 3 * s, g.bh - 3 * s, {255, 30, 10, 10}, LAYER + 10) -- dark inside
1801 g.accent = add_rect(g, 0, 0, 3 * s, g.bh, {255, 255, 72, 72}, LAYER + 11) -- left bar
1802 g.text = add_text(g, 'HL', 0, 0, size, {255, 255, 120, 110}, 'c', LAYER + 12)
1803 g.dist = add_text(g, ' ', 0, 0, g.dsize, {255, 255, 150, 140}, 'c', LAYER + 12)
1804end
1806local function update_tag(g)
1807 if not g.text then return end
1808 local s = H.ui.scale
1809 -- Fresh position every frame (the proximity cache is only 4-10 Hz: the tag jumped while it travelled).
1810 local L = H.live
1811 if L.unit and not H.dead_since and L.root_idx then
1812 local up = (H.surfaced_until or 0) > now_s()
1813 local node = (up and L.head_idx) or L.root_idx
1814 local x, y, z = unit_pos(L.unit, node)
1815 if x then H.tag_world = {x, y, z} end
1816 end
1817 local w = H.tag_world
1818 local sx, sy, behind
1819 if w then sx, sy, behind = H.project(w[1], w[2], w[3]) end
1820 if not sx then g.hidden = true; return end
1821 local W, Hh, m = H.ui.width, H.ui.height, 40 * s
1822 local on = not behind and sx >= m and sx <= W - m and sy >= m and sy <= Hh - m
1823 local arrow_l, arrow_r = '', ''
1824 if behind then sx, sy = W - sx, m end -- behind you: mirrored to the bottom edge
1825 if not on then
1826 if sx < m then arrow_l = '< ' elseif sx > W - m then arrow_r = ' >' end
1827 if sy < m and arrow_l == '' and arrow_r == '' then arrow_l = 'v ' elseif sy > Hh - m and arrow_l == '' and arrow_r == '' then arrow_l = '^ ' end
1828 sx = math.max(m + g.bw / 2, math.min(W - m - g.bw / 2, sx))
1829 sy = math.max(m + g.dsize + 4 * s, math.min(Hh - m - g.bh - 8 * s, sy))
1830 end
1831 local d = H.near_dist and string.format('%d m', math.floor(H.near_dist + 0.5)) or ''
1832 g.dist.str = (arrow_l .. d .. arrow_r) ~= '' and (arrow_l .. d .. arrow_r) or ' '
1833 local bx, by = sx - g.bw / 2, sy + 8 * s -- box floats just above the point (y is up)
1834 g.frame.x, g.frame.y = bx, by
1835 g.fill.x, g.fill.y = bx + 1.5 * s, by + 1.5 * s
1836 g.accent.x, g.accent.y = bx, by
1837 g.text.x, g.text.y = sx + 1.5 * s, by + g.bh / 2 - g.size * 0.35
1838 g.dist.x, g.dist.y = sx, by - g.dsize - 3 * s
1839 local up = (H.surfaced_until or 0) > now_s() -- underground: same red, dimmed
1840 g.frame.c = up and {255, 230, 58, 58} or {255, 150, 44, 44}
1841 g.accent.c = up and {255, 255, 72, 72} or {255, 175, 52, 52}
1842 g.text.c = up and {255, 255, 120, 110} or {255, 190, 96, 90}
1843 g.hidden = false
1844end
1847-- ---------- warn group: emerge warning banner (upper centre) ----------
1848-- ---------- prev group: PREVIEW banner with a countdown (upper centre, where the emerge warning goes; the two never
1849-- meet: preview only runs while no Hive Lord is tracked) ----------
1850local PREV_SIZE = 40
1851local function prev_count(t)
1852 local n = math.max(0, math.ceil((H.preview_until or t) - t))
1853 return n == 1 and 'END OF PREVIEW IN 1 SECOND' or string.format('END OF PREVIEW IN %d SECONDS', n)
1854end
1855local function build_prev()
1856 local g, s = new_group('prev'), H.ui.scale
1857 local big, small = PREV_SIZE * s, 20 * s
1858 local bw = math.max(text_width('PREVIEW', big), text_width('END OF PREVIEW IN 15 SECONDS', small)) + 64 * s
1859 local bh = big + small + 36 * s
1860 local bx, by = (H.ui.width - bw) / 2, H.ui.height - 300 * s
1861 g.frame = add_rect(g, bx - 4 * s, by - 4 * s, bw + 8 * s, bh + 8 * s, {255, 90, 210, 255}, LAYER + 6)
1862 add_rect(g, bx, by, bw, bh, {255, 10, 16, 24}, LAYER + 7)
1863 if not H.text_ok then return end
1864 local cx = H.ui.width / 2
1865 g.title = add_text(g, 'PREVIEW', cx, by + bh - 12 * s - big / 2 - big * 0.35, big, {255, 255, 235, 120}, 'c', LAYER + 8)
1866 g.count = add_text(g, prev_count(now_s()), cx, by + 12 * s + small / 2 - small * 0.35, small,
1867 {255, 200, 230, 255}, 'c', LAYER + 8)
1868end
1870local function update_prev(g, t)
1871 local on = math.floor(t * 2) % 2 == 0 -- slow 2 Hz pulse on the frame
1872 g.frame.c = on and {255, 90, 210, 255} or {255, 30, 90, 130}
1873 if g.count and (H.preview_until or 0) > t then g.count.str = prev_count(t) end -- frozen while it fades out
1874end
1876-- Emerge warning in the game's own banner style ("Bug Breach Detected"): a wide dark-crimson
1877-- strip with a thin bright red rim and a small dark tab on top, amber mixed-case text; the rim pulses gently.
1878local WARN_SIZE = 25
1879local WARN_LONGEST = 'Hive Lord About to Emerge - 100 m'
1880local function build_warn()
1881 local g, s = new_group('warn'), H.ui.scale
1882 local size = WARN_SIZE * s
1883 local bw = math.max(text_width(WARN_LONGEST, size) * 1.9, 560 * s)
1884 local bh = size + 22 * s
1885 local bx, by = (H.ui.width - bw) / 2, H.ui.height - 290 * s
1886 g.frame = add_rect(g, bx - 1.5 * s, by - 1.5 * s, bw + 3 * s, bh + 3 * s, {255, 214, 64, 88}, LAYER + 6) -- rim
1887 add_rect(g, bx, by, bw, bh, {255, 96, 2, 18}, LAYER + 7) -- strip
1888 add_rect(g, H.ui.width / 2 - 13 * s, by + bh + 1.5 * s, 26 * s, 6 * s, {255, 70, 0, 12}, LAYER + 7) -- top tab
1889 g.text = add_text(g, 'Hive Lord Emerging', H.ui.width / 2, by + bh / 2 - size * 0.35, size, {255, 238, 180, 84}, 'c',
1890 LAYER + 8)
1891end
1893local function update_warn(g, t)
1894 local k = 0.5 + 0.5 * math.sin(t * 2 * math.pi * 1.5) -- gentle 1.5 Hz pulse on the rim only
1895 g.frame.c = {255, 150 + 80 * k, 36 + 34 * k, 62 + 30 * k}
1896 if g.text then
1897 local what = H.warn_kind == 'pre' and 'About to Emerge' or 'Emerging'
1898 g.text.str = H.warn_dist and string.format('Hive Lord %s - %d m', what, math.floor(H.warn_dist + 0.5))
1899 or ('Hive Lord ' .. what)
1900 end
1901end
1903-- ---------- bar group: Souls-style boss bar ----------
1904local SOULS_HOLD, SOULS_DRAIN = 0.8, 0.45
1905local COMBO_GAP, COMBO_SHOW = 1.2, 2.0 -- hits closer than 1.2 s chain into one combo; number lingers 2 s after
1907local function build_bar()
1908 local g = new_group('bar')
1909 local s = H.ui.scale * size_k('bar_size') -- 'Boss bar size' slider scales bar, frame and its texts
1910 local bw, bh = 960 * s, 12 * s
1911 local bx, by = (H.ui.width - bw) / 2, 120 * H.ui.scale
1912 g.x, g.w, g.front, g.trail, g.hold_until = bx, bw, 0, 0, 0
1913 -- Starts empty with '? / 150,000': main HP is unknown until the HP record is located (10-20 s).
1914 -- g.fronts / g.trails: every rect whose width follows the HP / the trail (update_bar).
1915 local mono = H.mono()
1916 if mono then
1917 -- Helldivers 2 look (the game's own ammo bar): a thin solid white HP line, recent
1918 -- damage (the trail) solid grey, the empty part a row of small slanted ticks; no frame, no backing.
1919 bh = 5 * s
1920 g.trail_p = add_rect(g, bx, by, 0, bh, {255, 140, 140, 140}, LAYER + 1)
1921 g.front_p = add_rect(g, bx, by, 0, bh, {255, 232, 232, 232}, LAYER + 2)
1922 g.fronts, g.trails = {g.front_p}, {g.trail_p}
1923 g.ticks = {}
1924 local step, tw = 6 * s, 2.2 * s
1925 local tri_ok = not H.poly_tri_off and type(Gui.triangle) == 'function'
1926 for i = 0, math.floor(bw / step) - 1 do
1927 local x = bx + i * step + s
1928 local tk = {x = x, tw = tw}
1929 if tri_ok then
1930 -- A '/' parallelogram (2 triangles), like the game's own ammo bar stripes.
1931 local x0, x1, y0, y1 = x, x + tw, by, by + bh
1932 for _, pts in ipairs({{x0, y0, x1, y0, x1 + tw, y1}, {x0, y0, x1 + tw, y1, x0 + tw, y1}}) do
1933 local t = {kind = 'tri', pts = pts, c = {255, 92, 92, 92}, layer = LAYER, tick = true}
1934 H.tri_make(t, 0, 92, 92, 92)
1935 if H.tri_error then
1936 tri_ok, H.poly_tri_off, H.tri_error = false, H.tri_error, nil
1937 for _, old in ipairs(tk) do H.tri_free(old) end
1938 tk = {x = x, tw = tw}
1939 break
1940 end
1941 tk[#tk + 1] = t
1942 end
1943 for _, t in ipairs(tk) do g.prims[#g.prims + 1] = t end
1944 end
1945 if not tk[1] then
1946 tk.lo = add_rect(g, x, by, tw, bh / 2, {255, 92, 92, 92}, LAYER) -- fallback: the upper
1947 tk.hi = add_rect(g, x + tw, by + bh / 2, tw, bh / 2, {255, 92, 92, 92}, LAYER) -- half leans right
1948 end
1949 g.ticks[#g.ticks + 1] = tk
1950 end
1951 else
1952 -- Colour = Elden Ring look: deep crimson HP with a lighter band on top,
1953 -- the recent-damage trail in gold, a dark bronze rim, small tapered gold points past both ends.
1954 local gold = {255, 176, 146, 80}
1955 bh = 9 * s -- thinner, like the Elden Ring bar
1956 g.h = bh
1957 for _, side in ipairs({-1, 1}) do
1958 for k, f in ipairs({0.75, 0.45, 0.2}) do
1959 local w, h = 3 * s, (bh + 6 * s) * f
1960 local x = side < 0 and (bx - 3 * s - k * w) or (bx + bw + 3 * s + (k - 1) * w)
1961 add_rect(g, x, by + bh / 2 - h / 2, w, h, gold, LAYER)
1962 end
1963 end
1964 add_rect(g, bx - 2 * s, by - 2 * s, bw + 4 * s, bh + 4 * s, {255, 92, 76, 50}, LAYER) -- bronze rim
1965 add_rect(g, bx - 1 * s, by - 1 * s, bw + 2 * s, bh + 2 * s, {255, 12, 9, 8}, LAYER + 1) -- dark inner edge
1966 add_rect(g, bx, by, bw, bh, {255, 30, 14, 12}, LAYER + 2) -- empty
1967 local function fill(list, base, hi, lo, layer)
1968 list[#list + 1] = add_rect(g, bx, by, 0, bh, base, layer)
1969 list[#list + 1] = add_rect(g, bx, by + bh * 0.6, 0, bh * 0.4, hi, layer + 1) -- top band (y is up)
1970 list[#list + 1] = add_rect(g, bx, by, 0, bh * 0.22, lo, layer + 1) -- bottom shade
1971 end
1972 g.fronts, g.trails = {}, {}
1973 fill(g.trails, {255, 212, 168, 44}, {255, 236, 198, 80}, {255, 165, 120, 24}, LAYER + 3)
1974 fill(g.fronts, {255, 112, 8, 8}, {255, 140, 18, 16}, {255, 78, 4, 4}, LAYER + 5)
1975 g.front_p, g.trail_p = g.fronts[1], g.trails[1]
1976 end
1977 if H.text_ok then
1978 add_text(g, mono and 'HIVE LORD' or 'Hive Lord', bx, by + bh + 8 * s, TEXT_SIZE * s,
1979 mono and {255, 235, 235, 235} or {255, 236, 228, 210}, 'l')
1980 g.num = add_text(g, '? / 150,000', bx + bw, by - 22 * s, TEXT_SIZE * s, {255, 200, 200, 200}, 'r')
1981 g.hint = add_text(g, ' ', bx, by - 22 * s, TEXT_SIZE * s, {255, 200, 200, 200}, 'l')
1982 -- Elden Ring style combo damage: above the right end of the bar (HP numbers sit below the bar).
1983 g.combo_p = add_text(g, ' ', bx + bw, by + bh + 8 * s, TEXT_SIZE * s, {255, 255, 255, 255}, 'r')
1984 end
1985 g.y, g.h, g.combo, g.last_hit = by, bh, 0, -1e9
1986end
1988local function update_bar(g, main, t, dt)
1989 if not main then
1990 if g.hint then g.hint.str = 'locating HP...' end
1991 return
1992 end
1993 if not g.known then g.known, g.front, g.trail = true, main / H.MAIN_HEALTH_MAX, main / H.MAIN_HEALTH_MAX end
1994 local r = math.max(0, math.min(1, main / H.MAIN_HEALTH_MAX))
1995 if r < g.front - 1e-6 then g.hold_until = t + SOULS_HOLD end
1996 if r > g.trail then g.trail = r end
1997 if t >= g.hold_until and g.trail > r then g.trail = math.max(r, g.trail - SOULS_DRAIN * dt) end
1998 g.front = r
1999 for _, p in ipairs(g.fronts) do p.w = g.w * r end
2000 for _, p in ipairs(g.trails) do p.w = g.w * g.trail end
2001 if g.ticks then -- monochrome: ticks only on the empty part
2002 local edge = g.x + g.w * math.max(r, g.trail)
2003 for _, tk in ipairs(g.ticks) do
2004 local on = tk.x >= edge + 1.5 * tk.tw
2005 if tk.lo then
2006 local w = on and tk.tw or 0
2007 tk.lo.w, tk.hi.w = w, w
2008 else
2009 for _, t in ipairs(tk) do t.c = on and {255, 92, 92, 92} or {0, 92, 92, 92} end
2010 end
2011 end
2012 -- The white HP line turns red at 30% and below, like the game's low-ammo bar.
2013 g.front_p.c = r <= 0.3 and {255, 214, 52, 44} or {255, 232, 232, 232}
2014 end
2015 -- Combo: damage keeps adding up while hits land less than COMBO_GAP apart; shown, then faded out.
2016 if g.last_main and main < g.last_main then
2017 if t - g.last_hit > COMBO_GAP then g.combo = 0 end
2018 g.combo, g.last_hit = g.combo + (g.last_main - main), t
2019 end
2020 g.last_main = main
2021 if g.combo_p then
2022 local since = t - g.last_hit
2023 if g.combo > 0 and since < COMBO_GAP + COMBO_SHOW then
2024 local fade = math.min(1, (COMBO_GAP + COMBO_SHOW - since) / 0.5)
2025 g.combo_p.str = commas(g.combo)
2026 g.combo_p.c = {255 * fade, 255, 255, 255}
2027 else
2028 g.combo_p.str = ' '
2029 end
2030 end
2031 -- Joined mid-fight: the main HP received at join can be stale (it once snapped from ~51k to 0 at death), so it is
2032 -- only an upper bound too.
2033 if g.num then g.num.str = (H.armor_unsynced and main > 0 and '<' or '') .. commas(main) .. ' / 150,000' end
2034 if g.hint then g.hint.str = ' ' end
2035end
2038-- ---------- per-frame render ----------
2039local FLASH_S = 0.35
2040local DEAD_HIDE_S = 2.5
2041H.zstate, H.last_hp = {}, {main = nil, zones = {}}
2043local function update_panel(g, hp, t)
2044 for slot, z in pairs(g.zones) do
2045 if not z.decor then
2046 local v = hp.zones[slot]
2047 local zs = H.zstate[slot]
2048 if not zs then zs = {}; H.zstate[slot] = zs end
2049 if v ~= nil then
2050 if zs.last_v ~= nil and v < zs.last_v then zs.flash_until = t + FLASH_S end
2051 zs.last_v = v
2052 end
2053 -- Unknown: HP not located yet, or joined mid-fight and this zone has not been hit since.
2054 local unknown = (hp.main == nil) or (H.zone_unknown and H.zone_unknown[slot] == 'full' and v ~= nil and v >= (MAXHP_UI[slot] or 0))
2055 local c = unknown and UNKNOWN_C or zone_color(slot, v)
2056 local fl = (zs.flash_until and t < zs.flash_until) and (zs.flash_until - t) / FLASH_S or 0
2057 local function flash(cc)
2058 if fl == 0 then return cc end
2059 return {cc[1], cc[2] + (255 - cc[2]) * fl, cc[3] + (255 - cc[3]) * fl, cc[4] + (255 - cc[4]) * fl}
2060 end
2061 c = flash(c)
2062 -- Monochrome poly: HP shown by the facets' shade (see FACET); leader line, dot and frame keep c.
2063 local fade = z.frank and mono() and not unknown
2064 local ratio = zone_ratio(slot, v)
2065 local shades = {}
2066 if fade then
2067 local base = ratio > 0 and H.part_grey(ratio) or FACET.dead
2068 local hurt = ratio > 0 and math.floor((1 - ratio) * 50 + 0.5) / 50 or 0 -- same 2% steps as the base
2069 for fi in pairs(z.frank) do
2070 local gr = math.floor(base * (1 - H.scorch(slot, fi) * hurt) + 0.5)
2071 shades[fi] = flash({255, gr, gr, gr})
2072 end
2073 end
2074 for _, p in ipairs(z.prims) do
2075 local k = p.shade or 1 -- every triangle keeps its low-poly shade
2076 local cc = (p.fi and shades[p.fi]) or c
2077 p.c = {cc[1], cc[2] * k, cc[3] * k, cc[4] * k}
2078 end
2079 if z.label then
2080 local str, alive = pct_label(slot, v)
2081 if unknown then
2082 -- Still locating: '?'. Joined mid-fight, not hit since: '<100%' (anything up to full).
2083 str, alive = hp.main == nil and '?' or '<100%', true
2084 elseif ((H.zone_unknown and H.zone_unknown[slot]) or (H.plates_drift and (MAXCON[slot] or 0) > 0)) and alive then
2085 -- '<': damage before the join is missing, or the host has corrected a plate this fight, so our
2086 -- plate HP lags the host's - the real HP is at most this.
2087 str = '<' .. str
2088 end
2089 z.label.str = str
2090 if mono() then
2091 z.label.c = alive and {255, 255, 255, 255} or {255, 95, 95, 95} -- numbers white, destroyed dark
2092 else
2093 z.label.c = alive and {255, c[2], c[3], c[4]} or {255, 165, 165, 165} -- broken: light grey, readable
2094 end
2095 if z.frame and mono() and not unknown then
2096 -- Frames stay white in monochrome (a dim grey frame would make the thickest, most urgent one the
2097 -- least visible); thickness carries the tier, the grey plate shape carries the health.
2098 z.frame.c = alive and {255, 255, 255, 255} or {255, 70, 70, 70}
2099 end
2100 if z.frame and z.box then -- monochrome: thicker frame = lower tier
2101 local b = (mono() and not unknown and MONO_FRAME[zone_tier(slot, v)] or 1.5) * H.ui.scale
2102 local bx, by, bw, bh = z.box[1], z.box[2], z.box[3], z.box[4]
2103 z.frame.x, z.frame.y, z.frame.w, z.frame.h = bx - b, by - b, bw + 2 * b, bh + 2 * b
2104 end
2105 end
2106 end
2107 end
2108end
2110H.main_text = nil
2111local last_render_t = nil
2112-- Sample HP for the preview: main 65%, front plates from full to broken (plate % = (HP + 35000 con) / 50000).
2113H.PREVIEW_HP = {main = 97500, zones = {[0] = 90000, [2] = 15000, [3] = 5000, [4] = -10000, [5] = -1, [6] = -25000,
2114 [7] = -35000, [8] = 8000, [24] = -1, [27] = 2000}}
2116local function render(t, live_unit)
2117 local dt = last_render_t and math.max(0, t - last_render_t) or 0
2118 last_render_t = t
2119 local hp = H.last_hp
2120 -- Preview (menu toggle, or 8 s after a size / layout change): no Hive Lord around, so draw the boss bar and armor
2121 -- panel from sample HP to show the chosen sizes. A real Hive Lord always takes precedence.
2122 local preview = not live_unit and (H.preview_until or 0) > t
2123 if preview then hp = H.PREVIEW_HP end
2124 if live_unit then
2125 local fresh = H.dead_since and {zones = {}} or H.read_hp()
2126 if fresh.main then hp.main = fresh.main end
2127 for slot = 0, 37 do if fresh.zones[slot] ~= nil then hp.zones[slot] = fresh.zones[slot] end end
2128 if hp.main then
2129 H.main_text = string.format('MAIN %s%s / 150,000 (%d%%)', H.armor_unsynced and hp.main > 0 and '<' or '', commas(hp.main),
2130 math.floor(hp.main / H.MAIN_HEALTH_MAX * 100 + 0.5))
2131 end
2132 end
2133 -- Main HP 0 = dead: keep the 0 on screen briefly, then fade the silhouette and boss bar out.
2134 if live_unit and hp.main == 0 then
2135 H.dead_since = H.dead_since or t
2136 elseif hp.main and hp.main > 0 then
2137 H.dead_since = nil
2138 end
2139 local alive = live_unit and not (H.dead_since and t - H.dead_since > DEAD_HIDE_S)
2140 -- Each of boss bar / armor panel / tag has its own mode: 'hidden', 'surface' (Above ground: surfaced and within
2141 -- visual range) or 'alive' (Always when alive: found and not dead, underground or far away too).
2142 local above = alive and H.in_view ~= false
2143 if H.surface_known then above = above and (H.surfaced_until or 0) > t end
2144 local function gate(mode)
2145 if mode == 'hidden' then return false end
2146 if mode == 'alive' then return alive end
2147 return above
2148 end
2149 local want = {status = H.status_shown(),
2150 panel = H.cfg.panel_show ~= 'hidden' and (preview or gate(H.cfg.panel_show)),
2151 bar = H.cfg.bar_show ~= 'hidden' and (preview or gate(H.cfg.bar_show)),
2152 warn = alive and H.cfg.warn and (H.warn_until or 0) > t,
2153 tag = gate(H.cfg.tag_show) and not H.dead_since and H.tag_world ~= nil,
2154 prev = preview}
2155 for _, name in ipairs({'status', 'panel', 'bar', 'warn', 'tag', 'prev'}) do
2156 local g = H.grp[name]
2157 if want[name] and not g then
2158 if name == 'status' then build_status() elseif name == 'panel' then build_panel()
2159 elseif name == 'bar' then build_bar() elseif name == 'warn' then build_warn()
2160 elseif name == 'prev' then build_prev() else build_tag() end
2161 g = H.grp[name]
2162 writer(name .. ' shown')
2163 end
2164 if g then
2165 local target = want[name] and 1 or 0
2166 if g.a < target then g.a = math.min(target, g.a + dt / FADE_S)
2167 elseif g.a > target then g.a = math.max(target, g.a - dt / FADE_S) end
2168 if g.a <= 0 and target == 0 then
2169 destroy_group(name)
2170 writer(name .. ' hidden')
2171 else
2172 if name == 'panel' then update_panel(g, hp, t)
2173 elseif name == 'bar' then
2174 update_bar(g, hp.main, t, dt)
2175 if preview and g.hint then g.hint.str = 'PREVIEW' end
2176 elseif name == 'warn' then update_warn(g, t)
2177 elseif name == 'prev' then update_prev(g, t)
2178 elseif name == 'tag' then pcall(H.tag_calibrate); local ok = pcall(update_tag, g); if not ok then g.hidden = true end
2179 end
2180 if g.born ~= H.frame then
2181 local ga = g.hidden and 0 or g.a
2182 for _, p in ipairs(g.prims) do draw(p, ga) end
2183 end
2184 end
2185 end
2186 end
2187 if H.poly_rebuild then
2188 H.poly_rebuild = nil
2189 writer('poly: Gui.triangle failed on update (' .. tostring(H.poly_tri_off) .. '), rebuilding with 2 px spans')
2190 destroy_group('panel')
2191 destroy_group('bar')
2192 end
2193 if not live_unit and not H.grp.panel and not H.grp.bar then
2194 H.last_hp, H.zstate = {main = nil, zones = {}}, {}
2195 end
2196end
Ch. 4Creating and tearing down the GuiCreate 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.
2198local function build_gui(world)
2199 local gui = World.create_screen_gui(world, 'scale', 1, 1)
2200 if gui == nil then return false end
2201 H.ui.gui, H.ui.world = gui, world
2202 H.grp = {}
2203 local w, h = Gui.resolution()
2204 if type(w) == 'number' and w > 0 and type(h) == 'number' and h > 0 then
2205 H.ui.width, H.ui.height = w, h
2206 H.ui.scale = math.min(w / 1920, h / 1080)
2207 end
2208 writer(string.format('overlay up (%dx%d, scale %.2f, text=%s)', H.ui.width, H.ui.height,
2209 H.ui.scale, tostring(H.text_ok)))
2210 return true
2211end
2213-- Destroy only if the world is still alive; a dead world already took the gui with it.
2214local function teardown()
2215 if H.ui.gui ~= nil and world_is_live(H.ui.world) then
2216 pcall(World.destroy_gui, H.ui.world, H.ui.gui)
2217 end
2218 H.ui.gui, H.ui.world, H.grp = nil, nil, {}
2219end
2221-- Master on/off through Bingus Mod Options Menu (escape menu > MODS). Optional: without that menu the HUD is
2222-- always on. Off = overlay removed and the tracked unit + HP record forgotten (handles may go stale while off),
2223-- so no engine calls or memory reads at all until it is switched back on and rediscovers the Hive Lord.
2224-- The other HUD settings (toggles, status text, sizes) are registered next to it; see H.option_enabled
2225-- in the settings section.
2226H.enabled = true
2228function H.disable()
2229 teardown()
2230 local L = H.live
2231 L.unit, L.unit_addr, L.hc_ptr, L.dumped = nil, nil, nil, false
2232 H.loc, H.main_text, H.dead_since, H.tag_world = nil, nil, nil, nil
2233 H.warn_until, H.surfaced_until = nil, nil
2234end
2236-- =========================================================================
2237-- Per-frame worker (main thread)
2238-- =========================================================================
Ch. 4Which world to draw onPick the settled world with the fewest units (the UI one). main_world flips about every 3 s, so it cannot be used.
2239-- Overlay world: the settled world with the fewest units (the UI world; the game world has thousands).
2240-- main_world flips ~every 3s, so it can't be used to tell them apart. Once chosen, kept while it lives.
2241local t_now = 0
2242local function pick_overlay_world()
2243 local best
2244 for _, rec in ipairs(H.worlds_seen) do
2245 if t_now - rec.since >= 10 and rec.n ~= nil and (best == nil or rec.n < best.n) then best = rec end
2246 end
2247 if best then writer(string.format('overlay on world #%d (%d units)', best.idx, best.n)) end
2248 return best and best.w
2249end
Ch. 12The per-frame workerLook for units every 2 s, read HP every frame, handle death and the corpse, update distance and animation state, then render, and time itself.
2251function H.update()
2252 H.frame = (H.frame or 0) + 1
2253 -- Lua update rate (the overlay can only move this often), logged every 30 s while a Hive Lord is tracked.
2254 local tr = now_s()
2255 H.rate_t0 = H.rate_t0 or tr
2256 H.rate_n = (H.rate_n or 0) + 1
2257 if tr - H.rate_t0 >= 30 then
2258 local c = H.cost
2259 if H.live.unit and c then
2260 local normal_n = c.n - c.scan_n
2261 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),
2262 normal_n > 0 and (c.sum - c.scan_sum) / normal_n or 0, c.max,
2263 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 ''))
2264 end
2265 if c then c.sum, c.max, c.n, c.scan_sum, c.scan_n = 0, 0, 0, 0, 0 end
2266 H.rate_t0, H.rate_n = tr, 0
2267 end
2268 local L, t = H.live, now_s()
2269 t_now = t
2270 -- A dead world already took its gui with it: forget, never destroy.
2271 if H.ui.gui ~= nil and not world_is_live(H.ui.world) then
2272 H.ui.gui, H.ui.world, H.grp = nil, nil, {}
2273 end
2275 if t - L.last_scan >= 2 then
2276 L.last_scan = t
2277 local worlds = attempt(App.worlds)
2278 if type(worlds) ~= 'table' then return end
2279 local u, _, uname = H.find_hive_lord(worlds, t)
2280 local a = u and (unit_addr(u) or u)
2281 if not u then
2282 if L.unit then writer('Hive Lord gone') end
2283 L.unit, L.unit_addr, L.hc_ptr, H.loc, H.main_text = nil, nil, nil, nil, nil
2284 -- The '<' markers belong to that fight: clear them so the preview / the next Hive Lord start clean.
2285 H.armor_unsynced, H.zone_unknown, H.plates_drift = nil, nil, nil
2286 elseif a ~= L.unit_addr then
2287 L.unit, L.unit_addr, L.hc_ptr, L.dumped = u, a, nil, false
2288 H.dead_since, H.last_hp, H.zstate = nil, {main = nil, zones = {}}, {}
2289 L.head_idx, L.root_idx, H.surfaced_until, H.surface_known = nil, nil, nil, nil
2290 H.armor_unsynced, H.zone_unknown, H.plates_drift = nil, nil, nil
2291 writer(string.format('Hive Lord found (%s) name=%s', type(a) == 'number' and string.format('0x%X', a) or tostring(a), tostring(uname)))
2292 end
2293 if L.unit and not L.dumped then
2294 L.dumped = true
2295 H.locator_start()
2296 end
2298 if H.ui.gui == nil then
2299 local ow = pick_overlay_world()
2300 if ow == nil or not build_gui(ow) then return end
2301 end
2302 local live = L.unit and H.loc and H.loc.adopted and H.main_text
2303 local head = live and ('HIVE LORD ' .. H.main_text)
2304 or L.unit and ('HIVE LORD: ' .. (H.locator_status() or 'locating HP'))
2305 or string.format('%d worlds, %d units, no Hive Lord', #H.worlds_seen, L.units_seen)
2306 local lines = live and {head, H.legend(), H.legend2()}
2307 or {'HiveLordHUD ' .. (H.release or ('v' .. H.version)) .. ': ' .. head}
2308 set_status(L.unit and 'found' or 'scanning', lines)
2309 end
2311 if L.unit and not H.dead_since then H.locator_step(t) end
2312 -- Dead and faded out: forget the unit for good and skip its corpse in every later scan.
2313 if L.unit and H.dead_since and t - H.dead_since > DEAD_HIDE_S + 1.5 then
2314 H.dead_addr, H.dead_addr_t = L.unit_addr, t
2315 writer('Hive Lord dead: stopped touching its unit (its corpse despawn used to crash the game)')
2316 L.unit, L.unit_addr, L.hc_ptr, H.loc, H.main_text = nil, nil, nil, nil, nil
2317 end
2318 H.update_proximity(t)
2319 if not H.anim_off then
2320 local ok, err = pcall(H.anim_probe, t)
2321 if not ok then writer('anim probe error: ' .. tostring(err) .. ' - probe off'); H.anim_off = true end
2322 end
2323 if H.ui.gui then
2324 render(t, L.unit ~= nil)
2325 -- Main HP text every frame too (prims only reach the Gui when something changed).
2326 if L.unit and H.loc and H.loc.adopted and H.main_text then
2327 set_status('found', {'HIVE LORD ' .. H.main_text, H.legend(),
2328 H.armor_unsynced and '< = joined mid-fight: at most this (earlier damage not synced)' or H.legend2()})
2329 end
2330 end
2331end
2333-- =========================================================================
2334-- Settings: saved to %APPDATA%\hd2_hive_lord_hud.cfg, edited only in the Mod Options Menu (no hotkeys).
2335-- =========================================================================
2336local CFG_PATH = (os.getenv and os.getenv('APPDATA') or '.') .. '/hd2_hive_lord_hud.cfg'
2338-- Size sliders (percent) and their ranges; the Mod Options Menu uses the same ranges.
2339local SIZE_RANGE = {status_size = {50, 200}, bar_size = {50, 150}, armor_size = {50, 150}, color_fix = {0, 150}}
2340local function clamp_size(key, v)
2341 local r = SIZE_RANGE[key]
2342 v = math.floor((tonumber(v) or 100) / 10 + 0.5) * 10
2343 return math.max(r[1], math.min(r[2], v))
2344end
2346local function save_cfg()
2347 pcall(function()
2348 local f = io.open(CFG_PATH, 'w')
2349 if not f then return end
2350 local c = H.cfg
2351 local function b(v) return v and 1 or 0 end
2352 f:write(string.format('tag_show=%s\npanel_show=%s\nbar_show=%s\nwarn=%d\npos=%s\nstatus=%s\nstatus_size=%d\n'
2353 .. 'bar_size=%d\narmor_size=%d\nmenu=%d\npalette=%s\nstyle=%s\ncolor_fix=%d\n', c.tag_show, c.panel_show, c.bar_show, b(c.warn),
2354 c.pos or 'left', c.status or 'full', c.status_size, c.bar_size, c.armor_size, c.menu or 0,
2355 c.palette or 'mono', c.style or 'poly', c.color_fix or 100))
2356 f:close()
2357 end)
2358end
2360local function load_cfg()
2361 -- menu = the settings have been copied into the Mod Options Menu once (from then on the menu is the source).
2362 -- *_show: 'hidden' | 'surface' (above ground, in range) | 'alive' (always when active); filled after the read.
2363 -- anim: the animation-ids debug box, developer only (set true here; not a menu row, not read from the cfg).
2364 H.cfg = {warn = true, pos = 'left',
2365 status = 'full', status_size = 100, bar_size = 100, armor_size = 100, color_fix = 100, menu = 0,
2366 preview = false} -- preview: menu only, never written to the cfg file
2367 local old = {} -- older cfg keys: panel/bar/tag on-off + one shared 'show' mode
2368 pcall(function()
2369 local f = io.open(CFG_PATH, 'r')
2370 if not f then return end
2371 for line in f:lines() do
2372 local k, v = line:match('^([%w_]+)=([%w_]+)')
2373 if k == 'shape' or k == 'anim' or k == 'plabel_show' then -- retired settings, ignored
2374 elseif k == 'pos' then H.cfg.pos = (v == 'right') and 'right' or 'left'
2375 elseif k == 'status' then H.cfg.status = (v == 'off') and 'off' or 'full' -- old 'nohint' -> shown
2376 elseif k == 'menu' then H.cfg.menu = tonumber(v) or 0
2377 elseif k == 'palette' then H.cfg.palette = (v == 'mono') and 'mono' or 'colour'
2378 elseif k == 'style' then H.cfg.style = (v == 'pixel') and 'pixel' or 'poly'
2379 elseif k == 'tag_show' or k == 'panel_show' or k == 'bar_show' then
2380 H.cfg[k] = (v == 'hidden' or v == 'alive') and v or 'surface'
2381 elseif k == 'panel' or k == 'bar' or k == 'tag' then old[k] = v == '1'
2382 elseif k == 'show' then old.show = (v == 'hidden' or v == 'alive') and v or 'surface'
2383 elseif SIZE_RANGE[k] then H.cfg[k] = clamp_size(k, v)
2384 elseif k and type(H.cfg[k]) == 'boolean' then H.cfg[k] = v == '1' end
2385 end
2386 f:close()
2387 end)
2388 -- Older cfg: an on/off per part plus one shared mode -> one mode per part (the tag ignored 'show' before).
2389 H.cfg.palette = H.cfg.palette or 'mono' -- monochrome is the default
2390 H.cfg.style = H.cfg.style or 'poly'
2391 for key, on in pairs({panel = old.show or 'surface', bar = old.show or 'surface', tag = 'alive'}) do
2392 if not H.cfg[key .. '_show'] then H.cfg[key .. '_show'] = old[key] == false and 'hidden' or on end
2393 end
2394end
2395load_cfg()
2396H.save_cfg = save_cfg
2398-- =========================================================================
2399-- Bingus Mod Options Menu (escape menu > MODS > HIVE LORD HUD): the only place to change settings. Without it the
2400-- HUD runs on the cfg file as last saved. Menu changes are picked up by polling ModOptionsMenu.get (they only count
2401-- once the player applies them). The first time the menu is seen, the cfg values are copied into it.
2402-- =========================================================================
2403local OPTION_ID = 'hivelordhud.enabled'
2404H.OPTIONS = {
2405 -- since = cfg.menu level that introduced the row: a row newer than the player's level gets the cfg value copied in.
2406 {key = 'tag_show', since = 3, type = 'choice', label = 'Hive Lord tag', default = 'alive', gap = true,
2407 choices = {'Hidden', 'Above ground', 'Always when alive'}, values = {'hidden', 'surface', 'alive'},
2408 description = 'Marker on the Hive Lord with its distance, like a player tag (arrow on the screen edge when off-screen). Above ground: only while it is surfaced and within 1000 m. Always when alive: from when it is found until it dies, underground too (default).'},
2409 {key = 'panel_show', since = 3, type = 'choice', label = 'Armor panel', default = 'surface',
2410 choices = {'Hidden', 'Above ground', 'Always when alive'}, values = {'hidden', 'surface', 'alive'},
2411 description = 'Hive Lord silhouette with the armor % of each plate, plus the status lines. Above ground: only while it is surfaced and within 1000 m (default). Always when alive: from when it is found until it dies, underground or far away too.'},
2412 {key = 'bar_show', since = 3, type = 'choice', label = 'Boss bar', default = 'surface',
2413 choices = {'Hidden', 'Above ground', 'Always when alive'}, values = {'hidden', 'surface', 'alive'},
2414 description = 'Souls-style main HP bar with combo damage. Above ground: only while it is surfaced and within 1000 m (default). Always when alive: from when it is found until it dies, underground or far away too.'},
2415 {key = 'palette', type = 'choice', label = 'Colour style', default = 'mono',
2416 choices = {'Colour', 'Monochrome'}, values = {'colour', 'mono'},
2417 description = 'Monochrome (default): white numbers, a thicker frame the lower the plate is; in the Poly style each part blackens unevenly, like scorch marks, as it loses HP (Pixel style: three grey steps); the boss bar turns red under 30%. Colour: plates go green > yellow > red.'},
2418 {key = 'style', type = 'choice', label = 'Panel art style', default = 'poly',
2419 choices = {'Poly', 'Pixel'}, values = {'poly', 'pixel'},
2420 description = 'Poly (default): low-poly silhouette, every part cut into 3-5 flat-shaded facets lit from the top-left. Pixel: the earlier blocky pixel-art silhouette.'},
2421 {key = 'pos', type = 'choice', label = 'Panel side', choices = {'Left', 'Right'}, values = {'left', 'right'}, default = 'left',
2422 description = 'Left: below the stratagem list (default, clear of the mission list). Right: top-right corner.'},
2423 {key = 'warn', type = 'toggle', label = 'Emerge warning', default = true,
2424 description = 'Flashing banner when the Hive Lord parks underground or rears up within 100 m.'},
2425 -- id differs from the key: an older row 'hivelordhud.status' had 3 choices, so its saved index means
2426 -- something else now (the cfg value is carried over instead, see options_register).
2427 {key = 'status', id = 'hivelordhud.status_text', since = 2, type = 'choice', label = 'Status text', choices = {'Shown', 'Hidden'},
2428 values = {'full', 'off'}, default = 'full', gap = true,
2429 description = 'Shown: the status lines (HUD state, Hive Lord main HP, colour legend) above the armor panel. Hidden: no status text; the armor panel moves up into its place.'},
2430 {key = 'status_size', type = 'slider', label = 'Status text size (%)', default = 100, step = 10,
2431 description = 'Size of the status lines. 100 = original size.'},
2432 {key = 'bar_size', type = 'slider', label = 'Boss bar size (%)', default = 100, step = 10,
2433 description = 'Width, height and text of the boss bar. 100 = original size.'},
2434 {key = 'armor_size', type = 'slider', label = 'Armor value size (%)', default = 100, step = 10,
2435 description = 'Size of the armor % labels beside the panel. 100 = original size.'},
2436 {key = 'color_fix', type = 'slider', label = 'Colour correction (%)', default = 100, step = 10,
2437 description = 'The game brightens this HUD about 3x (greys turn white, dark red turns bright red). 100 = corrects that (default). Raise it if colours still look washed out, lower it if they look too dark; 0 = off.'},
2438 {key = 'preview', type = 'toggle', label = 'Preview with sample data (15 s)', default = false, gap = true,
2439 description = 'Works like a button: switch ON and apply, the boss bar and armor panel show sample HP for 15 s and this row goes back to OFF by itself. A real Hive Lord replaces it. Changing a size also previews for 8 s.'},
2442function H.opt_id(o) return o.id or ('hivelordhud.' .. o.key) end
2444-- cfg value <-> menu value (toggle: boolean, choice: 1-based index, slider: number).
2445local function to_menu(o, v)
2446 if o.type == 'choice' then
2447 for i, x in ipairs(o.values) do if x == v then return i end end
2448 return 1
2449 end
2450 return v
2451end
2452local function from_menu(o, v)
2453 if o.type == 'toggle' then
2454 if type(v) == 'boolean' then return v end
2455 return nil
2456 end
2457 if type(v) ~= 'number' then return nil end
2458 if o.type == 'choice' then return o.values[v] end
2459 return clamp_size(o.key, v)
2460end
2462-- One place that changes a setting, from the menu ('menu') or code: saves the cfg, rebuilds what moved, refreshes
2463-- the status lines and mirrors a non-menu change into the menu (ModOptionsMenu.set fires no callbacks).
2464local REBUILD = {pos = 'layout', status = 'layout', status_size = 'layout', armor_size = 'layout', bar_size = 'bar', style = 'layout', palette = 'bar'}
2465local PREVIEW_S = 15
2466function H.apply_setting(key, value, source)
2467 -- Preview is a button: ON (applied in the menu) starts a 15 s preview and the row is set back to OFF at once,
2468 -- so it can never stay on. (Menu edits only count once the player applies them; Esc discards them.)
2469 if key == 'preview' then
2470 if value then
2471 H.preview_until = now_s() + PREVIEW_S
2472 writer(string.format('preview for %d s (%s)', PREVIEW_S, source))
2473 local M = rawget(_G, 'ModOptionsMenu')
2474 if type(M) == 'table' and H.option_ok then pcall(M.set, 'hivelordhud.preview', false) end
2475 end
2476 return
2477 end
2478 if value == nil or H.cfg[key] == value then return end
2479 H.cfg[key] = value
2480 save_cfg()
2481 local shown = type(value) == 'boolean' and (value and 'ON' or 'OFF') or tostring(value)
2482 writer(string.format('%s -> %s (%s)', key, shown, source))
2483 if REBUILD[key] then H.preview_until = math.max(H.preview_until or 0, now_s() + 8) end -- show the new size / place
2484 if key == 'color_fix' then -- same prims, new colours: force every prim to re-send
2485 for _, g in pairs(H.grp or {}) do for _, p in ipairs(g.prims) do p.key = nil end end
2486 end
2487 if REBUILD[key] == 'layout' then
2488 for _, name in ipairs({'status', 'panel'}) do destroy_group(name) end
2489 elseif REBUILD[key] == 'bar' then
2490 destroy_group('bar')
2491 end
2492 if H.refresh_status then H.refresh_status() end
2493 if source ~= 'menu' and H.option_ok then
2494 local M = rawget(_G, 'ModOptionsMenu')
2495 for _, o in ipairs(H.OPTIONS) do
2496 if o.key == key and o.ok then pcall(M.set, H.opt_id(o), to_menu(o, value)) end
2497 end
2498 end
2499end
2501function H.options_pull(M)
2502 for _, o in ipairs(H.OPTIONS) do
2503 if o.ok then
2504 local ok, v = pcall(M.get, H.opt_id(o))
2505 if ok then H.apply_setting(o.key, from_menu(o, v), 'menu') end
2506 end
2507 end
2508end
Ch. 12Working with another mod: the options menuMod 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.
2510function H.options_register(M)
2511 local function reg(id, spec)
2512 local ok, done, why = pcall(M.register_option, id, spec)
2513 if not (ok and done) then writer('mod options: register ' .. id .. ' failed: ' .. tostring(ok and why or done)) end
2514 return ok and done
2515 end
2516 H.option_ok = reg(OPTION_ID, {
2517 type = 'toggle', mod = 'Hive Lord HUD', label = 'Enable Hive Lord HUD', default = true,
2518 description = 'Turns the whole Hive Lord HUD on or off: HP bar, armor panel, tag, distance and '
2519 .. 'emerge warning. Off stops all of its work.'})
2520 if not H.option_ok then return end
2521 local n = 0
2522 for _, o in ipairs(H.OPTIONS) do
2523 local spec = {type = o.type, mod = 'Hive Lord HUD', label = o.label, description = o.description, gap = o.gap,
2524 default = o.default, choices = o.choices, step = o.step}
2525 if o.type == 'choice' then spec.default = to_menu(o, o.default) end
2526 if o.type == 'slider' then spec.min, spec.max = SIZE_RANGE[o.key][1], SIZE_RANGE[o.key][2] end
2527 o.ok = reg(H.opt_id(o), spec)
2528 if o.ok then n = n + 1 end
2529 end
2530 writer(string.format('mod options: registered %s + %d of %d settings', OPTION_ID, n, #H.OPTIONS))
2531 pcall(M.set, 'hivelordhud.preview', false) -- preview is per session: never comes back on by itself
2532 -- cfg.menu: 0 = menu never seen (copy every saved setting into it); 1, 2 = older menu layouts (copy the rows
2533 -- added since, see `since`); 3 = the menu is the source.
2534 if (H.cfg.menu or 0) < 3 then
2535 for _, o in ipairs(H.OPTIONS) do
2536 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
2537 end
2538 writer('mod options: copied saved settings into the menu (from cfg level ' .. tostring(H.cfg.menu) .. ')')
2539 H.cfg.menu = 3
2540 save_cfg()
2541 end
2542 H.options_pull(M)
2543end
2545-- Called from the update wrapper every frame (also while the HUD is switched off). Returns the master switch.
2546function H.option_enabled()
2547 local M = rawget(_G, 'ModOptionsMenu')
2548 if type(M) ~= 'table' then return true end
2549 if not H.option_registered then
2550 H.option_registered = true
2551 H.options_register(M)
2552 end
2553 if not H.option_ok then return true end
2554 H.option_frame = (H.option_frame or 0) + 1
2555 if H.option_frame % 10 == 0 then H.options_pull(M) end
2556 local ok, v = pcall(M.get, OPTION_ID)
2557 if ok and type(v) == 'boolean' then return v end
2558 return true
2559end
2561-- =========================================================================
2562-- Install: wrap the engine's global update() (the HUD only ever runs inside it)
2563-- =========================================================================
Ch. 2The 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.
2564function H.install()
2565 if H.armed then return true end
2566 local original = rawget(_G, 'update')
2567 if type(original) ~= 'function' then
2568 writer('install refused: no global update()')
2569 return false
2570 end
2571 if not (Gui and Vector2 and Vector3 and Color and World and Unit and App) then
2572 writer('install refused: missing engine API')
2573 return false
2574 end
2575 H.text_ok = attempt(App.can_get, 'font', H.FONT) == true and attempt(App.can_get, 'material', H.FONT) == true
2576 H.armed = true
2577 -- Self-timing: how much frame time the HUD costs (QueryPerformanceCounter around H.update).
2578 pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* c); int QueryPerformanceFrequency(int64_t* f);')
2579 local qpc, qbuf = nil, ffi.new('int64_t[1]')
2580 pcall(function()
2581 local k = ffi.load('kernel32')
2582 k.QueryPerformanceFrequency(qbuf)
2583 local freq = tonumber(qbuf[0])
2584 if freq and freq > 0 then
2585 qpc = function() k.QueryPerformanceCounter(qbuf); return tonumber(qbuf[0]) * 1000 / freq end
2586 end
2587 end)
2588 H.cost = {sum = 0, max = 0, n = 0, scan_sum = 0, scan_n = 0}
2589 rawset(_G, 'update', function(...)
2590 if H.armed then
2591 local on = H.option_enabled()
2592 if on ~= H.enabled then
2593 H.enabled = on
2594 if not on then pcall(H.disable) end
2595 writer('HUD ' .. (on and 'enabled' or 'disabled') .. ' from Mod Options Menu')
2596 end
2597 end
2598 if H.armed and H.enabled then
2599 local t0 = qpc and qpc()
2600 local ok, why = pcall(H.update)
2601 if t0 then
2602 local ms = qpc() - t0
2603 local c = H.cost
2604 c.sum, c.n = c.sum + ms, c.n + 1
2605 if ms > c.max then c.max = ms end
2606 if H.loc and H.loc.phase == 'scan' then c.scan_sum, c.scan_n = c.scan_sum + ms, c.scan_n + 1 end
2607 end
2608 if not ok then
2609 H.faults = H.faults + 1
2610 H.armed = false
2611 pcall(teardown)
2612 writer('disarmed after fault: ' .. tostring(why))
2613 end
2614 end
2615 return original(...)
2616 end)
2617 writer('installed on global update (text=' .. tostring(H.text_ok) .. ')')
2618 return true
2619end
2621H.install()
2622writer('loaded ' .. (H.release and ('release ' .. H.release .. ', ') or '') .. 'build v' .. H.version .. ', armed=' .. tostring(H.armed))
2623return H

plate_fix.luaDead Plate Fix · 330 lines

Ch. 3A second resource nameDead Plate Fix is a separate resource and a separate Arsenal option, so it can be installed without the HUD.
1-- HD2-Addon: mods/hivelordhud/plate_fix
2-- Hive Lord dead-plate fix (standalone; works with or without the Hive Lord HUD).
3--
Ch. 10The bug, found by reverse engineeringZone 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.
4-- A Hive Lord plate that is destroyed (zone HP -35000) always loses its collider and its crack overlay: that is zone
5-- state, synced to everyone (game.dll 0x925F10). The plate model itself is only hidden by gore (0x9057D0 -> 0x903C70),
6-- which runs only on the machine that resolves the killing hit, and only if that hit passes gore's checks. When
7-- gore never comes (seen on a client when a teammate's Eagle killed the plate), the plate stays drawn - looking
8-- intact again - while shots pass through it to the flesh behind.
9--
10-- This module watches every Hive Lord through the game's own tables and, when a plate has been dead for
11-- FIX_DELAY seconds and is still drawn, writes the same "hidden" override bit that gore would have written. The
12-- game then updates the drawn mask itself. It only changes what this player sees: no damage, collision, network
13-- or code is touched. Every read/write goes through Read/WriteProcessMemory, so a stale address fails instead of
14-- crashing. RE notes: tools/research/hive_lord_armor_break.md. Globals are RVAs of build 25480438.
16if rawget(_G, 'HD2HiveLordPlateFix') then return end
17local M = {version = 3, armed = false, enabled = true, lords = {}, frame = 0}
18rawset(_G, 'HD2HiveLordPlateFix', M)
20local ffi = require('ffi')
21local bitlib = require('bit')
23-- ---------- log: into the HUD's log when the HUD is installed, otherwise our own file ----------
24local own_log
25local function open_own_log()
26 if own_log ~= nil then return own_log end
27 own_log = false
28 local name = 'HiveLordPlateFix_' .. ((os and os.date and os.date('%Y%m%d_%H%M%S')) or 'session') .. '.log'
29 local loader = rawget(_G, 'CowboyBingusModLoader')
30 local ok, f = pcall(function() return loader and loader.open_log and loader.open_log(name) end)
31 if not (ok and f) then
32 local temp = os and os.getenv and os.getenv('TEMP')
33 f = temp and io.open(temp .. '\\' .. name, 'w')
34 end
35 if f then own_log = f end
36 return own_log
37end
38local function writer(line)
39 local hud = rawget(_G, 'HD2HiveLordHud')
40 if type(hud) == 'table' and type(hud.write) == 'function' then
41 pcall(hud.write, '[PlateFix] ' .. line)
42 return
43 end
44 line = '[HiveLordPlateFix] ' .. line
45 print(line)
46 local f = open_own_log()
47 if f then pcall(function() f:write(line, '\n'); f:flush() end) end
48end
49M.write = writer
Ch. 11ReadProcessMemory here too"Never dereference raw pointers: the game frees and moves these tables." The same safe reads as the HUD.
51-- ---------- memory access (never dereference raw pointers: the game frees and moves these tables) ----------
52pcall(ffi.cdef, 'void* GetModuleHandleA(const char* name);')
53pcall(ffi.cdef, 'void* GetCurrentProcess(void);')
54pcall(ffi.cdef, 'int ReadProcessMemory(void* process, const void* addr, void* buf, size_t size, size_t* read);')
55pcall(ffi.cdef, 'int WriteProcessMemory(void* process, void* addr, const void* buf, size_t size, size_t* written);')
56pcall(ffi.cdef, 'int QueryPerformanceCounter(int64_t* c); int QueryPerformanceFrequency(int64_t* f);')
57local k32 = ffi.load('kernel32')
58local SELF = k32.GetCurrentProcess()
59local NIO = ffi.new('size_t[1]')
60local BUF = ffi.new('uint8_t[?]', 0x10000)
61local REC = ffi.new('uint8_t[?]', 0x1B8)
62local WB = ffi.new('uint8_t[1]')
63local USER_END = 0x7FFFFFFFFFFF
65local function safe_read(addr, n, dst)
66 if k32.ReadProcessMemory(SELF, ffi.cast('const void*', addr), dst, n, NIO) == 0 then return 0 end
67 return tonumber(NIO[0])
68end
69local function u32(addr)
70 if safe_read(addr, 4, BUF) < 4 then return nil end
71 return tonumber(ffi.cast('uint32_t*', BUF)[0])
72end
73local function ptr(addr)
74 if not addr or safe_read(addr, 8, BUF) < 8 then return nil end
75 local p = tonumber(ffi.cast('uint64_t*', BUF)[0])
76 if p < 0x10000 or p > USER_END then return nil end
77 return p
78end
79local function mask(addr)
80 if safe_read(addr, 8, BUF) < 8 then return nil end
81 local p = ffi.cast('uint32_t*', BUF)
82 return tonumber(p[0]), tonumber(p[1])
83end
84local function mbit(lo, hi, i)
85 if i < 32 then return bitlib.band(lo, bitlib.lshift(1, i)) ~= 0 end
86 return bitlib.band(hi, bitlib.lshift(1, i - 32)) ~= 0
87end
Ch. 16The only write: one byteRead 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.
88-- OR one bit into the byte that holds it: a single-byte write keeps the window against the game's own writes tiny.
89local function set_bit(addr, bit)
90 local a = addr + math.floor(bit / 8)
91 if safe_read(a, 1, WB) < 1 then return false end
92 local v = bitlib.bor(WB[0], bitlib.lshift(1, bit % 8))
93 if v == WB[0] then return true end
94 WB[0] = v
95 return k32.WriteProcessMemory(SELF, ffi.cast('void*', a), WB, 1, NIO) ~= 0
96end
98local QB = ffi.new('int64_t[1]')
99local QFREQ = (k32.QueryPerformanceFrequency(QB) ~= 0 and tonumber(QB[0])) or 1e7
Ch. 13Seams for the testsBoth the clock and the game.dll base can be replaced by tests, so offline tests can run the module against fake memory structures.
100function M.now() -- seconds; tests replace this
101 k32.QueryPerformanceCounter(QB)
102 return tonumber(QB[0]) / QFREQ
103end
105function M.game_base()
106 if M.GAME_BASE then return M.GAME_BASE end -- tests
107 local ok, m = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.C.GetModuleHandleA('game.dll'))) end)
108 if ok and m and m ~= 0 then return m end
109 return nil
110end
Ch. 10Table layouts read from assemblyThe globals, key tables and entry layouts of the health and visibility-mask systems, all read out of game.dll's assembly.
112-- ---------- game tables ----------
113-- Health system (game.dll+0x3326688): keys (entity u32, index u32) @+0x1030, capacity @+0x1038, empty key @+0x103C,
114-- records @+0x1058, 0x1B8 bytes each: zone HP i32[38] @+0xF8, armor i32[39] @+0x5C, 0xFFFF marker @+0x190.
115-- Visibility-mask system (game.dll+0x3326708): keys @+0x18, capacity @+0x20, entries @+0x38 (24 bytes: applied
116-- mask @+0, override bits @+8, touched bits @+16), base masks @+0x40 (8 bytes each).
117-- The setters (0x721D20 & co.) push to the material at once only while the unit's render record is live
118-- ([rec+0x14] bit 0); then they write the base mask. Otherwise they only write override/touched, and the updater
119-- 0x721680 later pushes (base & ~touched) | (override & touched) and keeps that in the entry's applied mask.
120-- So "drawn" must not be judged by the base mask alone (the non-live path never changes it): a plate counts as
121-- hidden when either the base or the applied mask has its bit. Every try logs all four masks.
122local HEALTH_RVA, VIS_RVA = 0x3326688, 0x3326708
123local REC_STRIDE = 0x1B8
124local Z_OFF, ARMOR_OFF, MARKER_OFF = 0xF8, 0x5C, 0x190
Ch. 7The same fingerprint, plus plate-to-mask mappingThe same 39 armour values recognise a Hive Lord; it also lists which model mask belongs to each plate, and the delay and retry limits.
126-- The Hive Lord's armour values (default zone, then zones 0..37) never change in a fight: our fingerprint.
127local ARMOR = {5, 6, 10}
128for _ = 1, 13 do ARMOR[#ARMOR + 1] = 5 end
129for _ = 1, 7 do ARMOR[#ARMOR + 1] = 2 end
130ARMOR[#ARMOR + 1] = 6; ARMOR[#ARMOR + 1] = 6
131for _ = 1, 14 do ARMOR[#ARMOR + 1] = 5 end
133-- Plate zone -> its model mask (GoreComponent) ; constitution 35000 for every plate.
134local PLATE_MASK = {}
135for k = 0, 5 do PLATE_MASK[7 - k], PLATE_MASK[14 - k] = 1 + 4 * k, 2 + 4 * k end
136local PLATE_CON = 35000
137local FIX_DELAY, FIX_TRIES, RETRY_S = 1.5, 3, 2
138M.FIX_DELAY = FIX_DELAY
Ch. 6Base + RVA, then validateRead 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.
140local function table_header(base_rva, keys_off, cap_off)
141 local base = M.game_base()
142 if not base then return nil end
143 local S = ptr(base + base_rva)
144 if not S then return nil end
145 local keys, cap = ptr(S + keys_off), u32(S + cap_off)
146 if not keys or not cap or cap == 0 or cap > 0x40000 then return nil end
147 return S, keys, cap
148end
150-- Calls fn(entity, index) for every used slot of a (u32 key, u32 value) hash; stops early when fn returns true.
Ch. 7The same hash-table walkRead the key table in chunks, skip empty slots, call fn for every (entity, index).
151local function each_key(keys, cap, fn)
152 local per = 0x10000 / 8
153 for first = 0, cap - 1, per do
154 local n = math.min(per, cap - first)
155 if safe_read(keys + first * 8, n * 8, BUF) < n * 8 then return false end
156 local p, list = ffi.cast('uint32_t*', BUF), {}
157 for i = 0, n - 1 do
158 local k, v = tonumber(p[i * 2]), tonumber(p[i * 2 + 1])
159 if k ~= 0xFFFFFFFF and k ~= 0 and v < 0x100000 then list[#list + 1] = {k, v} end
160 end
161 for _, kv in ipairs(list) do if fn(kv[1], kv[2]) then return true end end
162 end
163 return true
164end
166local function i32(off) return tonumber(ffi.cast('int32_t*', REC + off)[0]) end
167-- Reads a health record into REC and checks it is a Hive Lord's.
168local function read_hive_record(rec)
169 if safe_read(rec, REC_STRIDE, REC) < REC_STRIDE then return false end
170 if i32(MARKER_OFF) ~= 0xFFFF then return false end
171 for i = 1, #ARMOR do if i32(ARMOR_OFF + (i - 1) * 4) ~= ARMOR[i] then return false end end
172 return true
173end
175local function health_index(ent)
176 local S, keys, cap = table_header(HEALTH_RVA, 0x1030, 0x1038)
177 if not S then return nil end
178 local found
179 each_key(keys, cap, function(k, v) if k == ent then found = v; return true end end)
180 return found and (ptr(S + 0x1058) or 0) + found * REC_STRIDE or nil
181end
183local function vis_entry(ent)
184 local S, keys, cap = table_header(VIS_RVA, 0x18, 0x20)
185 if not S then return nil end
186 local found
187 each_key(keys, cap, function(k, v) if k == ent then found = v; return true end end)
188 local entries, rendered = ptr(S + 0x38), ptr(S + 0x40)
189 if not found or not entries or not rendered then return nil end
190 return {applied = entries + found * 24, override = entries + found * 24 + 8, touched = entries + found * 24 + 16,
191 rendered = rendered + found * 8, idx = found}
192end
194-- New Hive Lords: every health record whose armour matches.
195function M.discover(t)
196 local S, keys, cap = table_header(HEALTH_RVA, 0x1030, 0x1038)
197 if not S then return end
198 local recs = ptr(S + 0x1058)
199 if not recs then return end
200 each_key(keys, cap, function(ent, idx)
201 if not M.lords[ent] and read_hive_record(recs + idx * REC_STRIDE) then
202 M.lords[ent] = {ent = ent, dead = {}, fix = {}, seen = t}
203 writer(string.format('tracking Hive Lord entity 0x%X', ent))
204 end
205 end)
206end
208-- All four masks of a visibility entry (hi:lo), plus the render record's live bit, for the log.
209function M.masks(v)
210 local function hx(a)
211 local lo, hi = mask(a)
212 return lo and string.format('%04x:%08x', hi % 0x10000, lo) or '?'
213 end
214 local live = '?'
215 local S = table_header(VIS_RVA, 0x18, 0x20)
216 local recs = S and ptr(S + 0x30)
217 local rec = recs and ptr(recs + v.idx * 8)
218 local f = rec and u32(rec + 0x14)
219 if f then live = tostring(bitlib.band(f, 1) ~= 0) elseif recs then live = 'no record' end
220 return string.format('base=%s applied=%s override=%s touched=%s live=%s',
221 hx(v.rendered), hx(v.applied), hx(v.override), hx(v.touched), live)
222end
224-- One Hive Lord: plate deaths, then the fix.
Ch. 16When it writesIf 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.
225function M.check(L, t)
226 local rec = health_index(L.ent)
227 if not rec or not read_hive_record(rec) then return false end -- gone (despawned)
228 if t >= (L.next_vis or 0) or not L.vis then L.vis, L.next_vis = vis_entry(L.ent), t + 1 end
229 local v = L.vis
230 local lo, hi, alo, ahi = nil, nil, nil, nil
231 if v then lo, hi = mask(v.rendered); alo, ahi = mask(v.applied) end
232 -- Same sanity check as the HUD: the two unwired masks (32, 33) are always hidden on a Hive Lord. Anything else
233 -- means the entry now belongs to another unit (the table was compacted): look it up again, write nothing.
234 local sane = hi and bitlib.band(hi, 3) == 3
235 if v and not sane then L.vis = nil end
236 for slot, pm in pairs(PLATE_MASK) do
237 local hp = i32(Z_OFF + slot * 4)
238 if hp <= -PLATE_CON and not L.dead[slot] then L.dead[slot] = t end
239 local died, f = L.dead[slot], L.fix[slot]
240 if M.enabled and sane and died and not (f and f.gave_up) then
241 local applied_hidden = alo and mbit(alo, ahi, pm)
242 local shown = not mbit(lo, hi, pm) and not applied_hidden
243 if shown and t - died >= FIX_DELAY then
244 f = f or {n = 0}
245 L.fix[slot] = f
246 if t >= (f.next or 0) then
247 if f.n >= FIX_TRIES then
248 f.gave_up = true
249 writer(string.format('entity 0x%X plate slot %d: still drawn after %d tries - giving up (%s)',
250 L.ent, slot, f.n, M.masks(v)))
251 else
252 f.n, f.next, f.at = f.n + 1, t + RETRY_S, t
253 local ok = set_bit(v.override, pm) and set_bit(v.touched, pm)
254 writer(string.format('entity 0x%X plate slot %d: dead %.1f s but still drawn -> hid model mask %d (%s), try %d (%s)',
255 L.ent, slot, t - died, pm, ok and 'ok' or 'write failed', f.n, M.masks(v)))
256 end
257 end
258 elseif not shown and f and f.at and not f.done then
259 f.done = true
260 writer(string.format('entity 0x%X plate slot %d: hidden %.1f s after the fix, in the %s mask (%s)', L.ent, slot,
261 t - f.at, applied_hidden and 'applied' or 'base', M.masks(v)))
262 end
263 end
264 end
265 return true
266end
268function M.update()
269 local t = M.now()
270 M.frame = M.frame + 1
271 if t >= (M.next_discover or 0) then
272 M.next_discover = t + 2
273 M.discover(t)
274 end
275 if t < (M.next_check or 0) then return end -- 10 Hz is plenty for a 1.5 s delay
276 M.next_check = t + 0.1
277 for ent, L in pairs(M.lords) do
278 if not M.check(L, t) then
279 M.lords[ent] = nil
280 writer(string.format('Hive Lord entity 0x%X gone', ent))
281 end
282 end
283end
285-- ---------- Bingus Mod Options Menu: one on/off row (without the menu: always on) ----------
286local OPTION_ID = 'hivelordplatefix.enabled'
Ch. 12A one-row options menuWith Mod Options Menu present, register one toggle and read it every 10 frames; without it, the fix is always on.
287function M.poll_option()
288 local MOM = rawget(_G, 'ModOptionsMenu')
289 if type(MOM) ~= 'table' then return end
290 if not M.option_tried then
291 M.option_tried = true
292 local ok, done = pcall(MOM.register_option, OPTION_ID, {
293 type = 'toggle', mod = 'Hive Lord Plate Fix', label = 'Fix dead plates that stay on', default = true,
294 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."})
295 M.option_ok = ok and done
296 if not M.option_ok then writer('mod options: register failed') end
297 end
298 if not M.option_ok or M.frame % 10 ~= 0 then return end
299 local ok, v = pcall(MOM.get, OPTION_ID)
300 if ok and type(v) == 'boolean' and v ~= M.enabled then
301 M.enabled = v
302 writer('fix ' .. (v and 'enabled' or 'disabled') .. ' from Mod Options Menu')
303 end
304end
Ch. 2A second hookPlate Fix wraps the global update too, on top of the HUD's wrapper. Each wrapper calls the previous one, so both mods can coexist.
306function M.install()
307 if M.armed then return true end
308 local original = rawget(_G, 'update')
309 if type(original) ~= 'function' then writer('install refused: no global update()'); return false end
310 M.armed = true
311 rawset(_G, 'update', function(...)
312 if M.armed then
313 if M.hello then writer(M.hello); M.hello = nil end
314 local ok, why = pcall(function() M.poll_option(); M.update() end)
315 if not ok then
316 M.armed = false
317 writer('disarmed after fault: ' .. tostring(why))
318 end
319 end
320 return original(...)
321 end)
322 return true
323end
325M.install()
326-- Logged on the first frame instead of now: the HUD may load after us, and its log is where this should go
327-- (logging now opened a separate one-line HiveLordPlateFix_*.log every session).
328M.hello = 'loaded plate fix v' .. M.version .. ', armed=' .. tostring(M.armed)
329if not M.armed then writer(M.hello); M.hello = nil end
330return M