ð@xâÑ,ú�N¡¢,ŽÜ‰ZâÑ,ú�N¡Àxwpw-- HD2-Addon: mods/hd2mods/td110_driver_laser -- -- TD110Arms v1 - read-only by default. -- -- Everything below was extracted from the released addon -- "TD-110 Co-Op" (mods/dsh/tank_storm_coop, GUID 8a4f2c61-...), -- whose own header and constants document this vehicle completely: -- -- TD-110 (tank_storm / "Storm") entity hash 0xB0C9FAF4AF8903F9 -- TD-220 (bastion) entity hash 0x16474112801385B6 -- MountComponentData LDLD type 0x3845B1E0 record = 120 bytes (5 x 24) -- TurretComponentData LDLD type 0x1EBA7593 record = 76 bytes -- -- A block starts with 'LDLD' + u32 version(1) + u32 type + u32 size, and the -- data area begins 24 bytes in: -- [ index area ][ record array ] -- index entry 16 bytes = { u64 entity hash, u32 record index, u32 pad == 0 } -- record array start = the largest multiple of 16 where -- (size - start) % stride == 0 and every index entry's index < n -- => the record for an entity is found by its hash, not by a fixed index. -- -- One mount record, 24 bytes per slot: -- +0 slot0 item 0xD58AE6A04EDB10DE (gatling) node 0xE30C5711 -- +24 slot1 (gunner) node 0xB7E9B43D -- +48 slot2 (driver) node 0x79CC4582 <- smoke slot (this is the DRIVER slot) -- +72 slot3 item 0x8AFF7F0793A5BCED (VLS rockets) node 0x9DEE49C0 -- +96 slot4 item 0x8AFF7F0793A5BCED (VLS rockets) node 0x27DB9EB5 -- each slot: [0..7] item hash, [8..11] node, [12..23] flags/residue. -- The two inventory items that addon assigns: -- SMOKE 0x3A061009AA31E9CB (smoke launcher) -- LASER 0xC36B5B37C058DBDD (laser designator) -- -- âš  The mount record is read when the vehicle is spawned, so a patch has to be -- in place BEFORE summoning the vehicle (the turret table is read live). -- -- This probe: finds both tables, resolves the TD-110 record from the index, and -- prints all five slot items (hex + exact decimal) with the raw record hex, plus -- every other copy of the record in memory. enable=1 additionally writes the -- slot items listed in the cfg (fingerprint-verified, read back, rolled back). -- -- Press the scan key (F8) to run. local KEY = 'Hd2Td110DriverLaser' if rawget(_G, KEY) then return end local REVISION = 'td110-laser-v1' local state = { frame = 0, lines = 0, scanned = 0, tables = 0, records = 0, copies = 0, writes = 0 } rawset(_G, KEY, state) local ok_ffi, ffi = pcall(require, 'ffi') if not ok_ffi or not ffi then return end ffi.cdef [[ void *GetModuleHandleA(const char *name); void *GetProcAddress(void *module, const char *name); ]] local kernel = ffi.load('kernel32') local RPM, WPM, VQ, VP, CD, KEYSTATE = nil, nil, nil, nil, nil, nil do local function own(mod, name, sig) local h = kernel.GetModuleHandleA(mod) if h == nil then return nil end local p = kernel.GetProcAddress(h, name) if p == nil then return nil end return ffi.cast(sig, p) end CD = own('kernel32', 'CreateDirectoryA', 'int (*)(const char *, void *)') RPM = own('kernel32', 'ReadProcessMemory', 'int (*)(void *, const void *, void *, size_t, void *)') WPM = own('kernel32', 'WriteProcessMemory', 'int (*)(void *, void *, const void *, size_t, void *)') VQ = own('kernel32', 'VirtualQuery', 'size_t (*)(const void *, void *, size_t)') VP = own('kernel32', 'VirtualProtect', 'int (*)(void *, size_t, uint32_t, uint32_t *)') KEYSTATE = own('user32', 'GetAsyncKeyState', 'int16_t (*)(int)') end if not RPM or not VQ then return end local PROC = ffi.cast('void *', -1) local PAGE_READWRITE = 0x04 local buf = ffi.new('uint8_t[65536]') local got = ffi.new('size_t[1]') local mbi = ffi.new('uint8_t[48]') -- ------------------------------------------------------------------ output -- local OUT_DIR = nil do local b = os.getenv('LOCALAPPDATA') if b and CD then local d = b .. '\\Hd2Td110DriverLaser' CD(d, nil) local t = io.open(d .. '\\.probe', 'w') if t then t:write('x'); t:close(); os.remove(d .. '\\.probe'); OUT_DIR = d end end end local function log(line) if not OUT_DIR then return end state.lines = state.lines + 1 if state.lines > 4000 then return end local f = io.open(OUT_DIR .. '\\Td110DriverLaser.log', 'a') if f then f:write(os.date('%H:%M:%S') .. ' ' .. line .. '\n'); f:close() end end -- ------------------------------------------------------------------ memory -- local function rd(a, n) if type(a) ~= 'number' or a < 65536 or a >= 0x800000000000 then return nil end if n < 1 or n > 65536 then return nil end got[0] = 0 if RPM(PROC, ffi.cast('const void *', a), buf, n, got) == 0 then return nil end if tonumber(got[0]) ~= n then return nil end return ffi.string(buf, n) end local chunk = ffi.new('uint8_t[?]', 1048576) local cgot = ffi.new('size_t[1]') local function read_chunk(a, n) if n < 1 or n > 1048576 then return nil end cgot[0] = 0 if RPM(PROC, ffi.cast('const void *', a), chunk, n, cgot) == 0 then return nil end if (tonumber(cgot[0]) or 0) < n then return nil end return ffi.string(chunk, n) end local function u32(s, o) if type(s) ~= 'string' then return nil end local a, b, c, d = s:byte(o + 1, o + 4) if not a or not b or not c or not d then return nil end return a + b * 256 + c * 65536 + d * 16777216 end local function i32(s, o) local v = u32(s, o) if not v then return nil end if v >= 2147483648 then v = v - 4294967296 end return v end local f32box = ffi.new('uint32_t[1]') local f32view = ffi.cast('float *', f32box) local function f32(s, o) local v = u32(s, o) if not v then return nil end f32box[0] = v return tonumber(f32view[0]) end local function region(a) if VQ(ffi.cast('const void *', a), mbi, 48) ~= 48 then return nil end local raw = ffi.string(mbi, 48) local base = u32(raw, 0) if not base then return nil end local hb = u32(raw, 4) if hb then base = base + hb * 4294967296 end local size = tonumber(ffi.cast('uint64_t', ffi.cast('size_t *', ffi.cast('uint8_t *', mbi) + 24)[0])) return { base = base, size = size or 0, state = u32(raw, 32), protect = u32(raw, 36), kind = u32(raw, 40) } end -- value of a u64 stored little-endian in 8 bytes, printed as 16 hex digits local function hex_of_le8(b) local t = {} for k = 8, 1, -1 do t[#t + 1] = string.format('%02X', b:byte(k)) end return table.concat(t) end -- exact decimal of the same u64 (a Lua number would round it) local function dec_of_le8(b) local d = { 0 } for i = 8, 1, -1 do local carry = b:byte(i) for k = 1, #d do local v = d[k] * 256 + carry d[k] = v % 10 carry = math.floor(v / 10) end while carry > 0 do d[#d + 1] = carry % 10 carry = math.floor(carry / 10) end end local out = {} for k = #d, 1, -1 do out[#out + 1] = tostring(d[k]) end return table.concat(out) end local function hex(s) local t = {} for k = 1, #s do t[#t + 1] = string.format('%02X', s:byte(k)) end return table.concat(t) end local function safe_hash(b) if type(b) ~= 'string' or #b ~= 8 then return '(unreadable)' end return hex_of_le8(b) end local function hex_to_le8(txt) -- "3A061009AA31E9CB" -> the 8 little-endian bytes (reversed pairs) if type(txt) ~= 'string' or #txt ~= 16 or txt:find('[^0-9A-Fa-f]') ~= nil then return nil end local out = {} for k = 8, 1, -1 do local v = tonumber(txt:sub(k * 2 - 1, k * 2), 16) if v == nil then return nil end out[#out + 1] = string.char(v) end return table.concat(out) end -- ------------------------------------------------------------------- data --- local MAGIC = 'LDLD' local DATA_OFF = 24 local TYPE_RACK, TYPE_TURRET = 0x3845B1E0, 0x1EBA7593 local RACK_SR, TURRET_SR = 120, 76 local MIN_SIZE, MAX_SIZE = 1024, 4194304 local DATA_CAP = 262144 local MAX_COPIES = 8 local SIG_RACK = MAGIC .. string.char(1, 0, 0, 0) .. string.char(TYPE_RACK % 256, math.floor(TYPE_RACK / 256) % 256, math.floor(TYPE_RACK / 65536) % 256, math.floor(TYPE_RACK / 16777216) % 256) local SIG_TURRET = MAGIC .. string.char(1, 0, 0, 0) .. string.char(TYPE_TURRET % 256, math.floor(TYPE_TURRET / 256) % 256, math.floor(TYPE_TURRET / 65536) % 256, math.floor(TYPE_TURRET / 16777216) % 256) local TD110 = string.char(0xF9, 0x03, 0x89, 0xAF, 0xF4, 0xFA, 0xC9, 0xB0) local RACK_PREFIX = string.char(0xDE, 0x10, 0xDB, 0x4E, 0xA0, 0xE6, 0x8A, 0xD5, 0x11, 0x57, 0x0C, 0xE3, 0x02, 0x00, 0x00, 0x00, 0x57, 0xFA, 0x79, 0xCB, 0x01, 0x01, 0x00, 0x00) local GUNNER_NODE = string.char(0x3D, 0xB4, 0xE9, 0xB7) local SMOKE_NODE = string.char(0x82, 0x45, 0xCC, 0x79) local SILO_ITEM = string.char(0xED, 0xBC, 0xA5, 0x93, 0x07, 0x7F, 0xFF, 0x8A) local SILO_NODE_A = string.char(0xC0, 0x49, 0xEE, 0x9D) local SILO_NODE_B = string.char(0xB5, 0x9E, 0xDB, 0x27) local SMOKE_ITEM = string.char(0xCB, 0xE9, 0x31, 0xAA, 0x09, 0x10, 0x06, 0x3A) local LASER_ITEM = string.char(0xDD, 0xDB, 0x58, 0xC0, 0x37, 0x5B, 0x6B, 0xC3) local TURRET_ENTITY = LASER_ITEM -- the laser weapon owns the turret record local SELF = {} for _, s in ipairs({ SIG_RACK, SIG_TURRET, RACK_PREFIX, TD110, SMOKE_ITEM, LASER_ITEM, SILO_ITEM }) do local ok, p = pcall(function() return tonumber(ffi.cast('uintptr_t', ffi.cast('const char *', s))) end) if ok and p and p ~= 0 then SELF[#SELF + 1] = p end end local function is_self(a) for i = 1, #SELF do local d = a - SELF[i] if d > -8192 and d < 8192 then return true end end return false end -- ------------------------------------------------------------- table logic --- local function validate_table(magic, want_type) local head = rd(magic, DATA_OFF) if not head then return nil end if head:sub(1, 4) ~= MAGIC then return nil end if u32(head, 4) ~= 1 then return nil end if u32(head, 8) ~= want_type then return nil end local size = u32(head, 12) if not size or size < MIN_SIZE or size > MAX_SIZE then return nil end return size end -- record array start: the largest multiple of 16 that divides, with every index -- entry pointing inside the array local function find_rec_base(data, size, stride) local base, n = nil, nil local i = 1 while true do local b = i * 16 if b + stride > size then break end local rem = size - b if rem % stride == 0 then local cnt = rem / stride local ok = true local k = 0 while k + 16 <= b do local pad = u32(data, k + 12) local ix = u32(data, k + 8) if pad ~= 0 or not ix or ix >= cnt then ok = false; break end k = k + 16 end if ok and cnt >= 1 then base, n = b, cnt end end i = i + 1 end return base, n end -- index entry = 16 bytes { u64 hash, u32 recIdx, u32 pad }; returns rec offset local function index_lookup(data, base, cnt, nh, stride) local from = 1 while true do local p = data:find(nh, from, true) if not p then return nil end from = p + 1 local off = p - 1 if off % 16 == 0 and off + 16 <= base then local ix = u32(data, off + 8) local pad = u32(data, off + 12) if pad == 0 and ix and ix < cnt then return base + ix * stride, ix end end end end local function dump_slots(rec, tag, extra) local head = rd(rec, RACK_SR) if not head then log((' %s @0x%X unreadable'):format(tag, rec)); return end log((' %s @0x%X first-24-byte fingerprint %s'):format(tag, rec, (head:sub(1, 24) == RACK_PREFIX) and 'MATCH(TD-110)' or ('DIFFER ' .. hex(head:sub(1, 24))))) for s = 0, 4 do local off = s * 24 local item = head:sub(off + 1, off + 8) local node = head:sub(off + 9, off + 12) local label = ({ 'slot0(+0)' , 'slot1(+24 gunner)', 'slot2(+48 driver)', 'slot3(+72)', 'slot4(+96)' })[s + 1] local note = '' if item == SMOKE_ITEM then note = ' <== SMOKE' elseif item == LASER_ITEM then note = ' <== LASER' elseif item == SILO_ITEM then note = ' <== VLS' end log((' %-20s item=0x%s = %s node=0x%s%s'):format( label, hex_of_le8(item), dec_of_le8(item), hex(node), note)) end log(' raw: ' .. hex(head)) if extra then log(' ' .. extra) end end local rack_done, turret_done = false, false local rack_seen, turret_seen = {}, {} -- every place a TD-110 mount record lives: the table's own record plus the -- copies the engine keeps elsewhere (the reference addon patches those too) local targets, target_seen = {}, {} local function note_target(addr, why) if target_seen[addr] then return end target_seen[addr] = true targets[#targets + 1] = addr log((' write target #%d 0x%X (%s)'):format(#targets, addr, why)) end local function on_rack_table(magic) local size = validate_table(magic, TYPE_RACK) if not size then return end if rack_seen[magic] then return end rack_seen[magic] = true state.tables = state.tables + 1 log(('mount table MountComponentData @0x%X size=%d'):format(magic, size)) local data = rd(magic + DATA_OFF, math.min(size, DATA_CAP)) if not data then log(' data area unreadable (over 256 KB?)'); return end local base, n = find_rec_base(data, math.min(size, DATA_CAP), RACK_SR) if not base then log(' record array start not derivable'); return end log((' record array starts at magic+%d (data+%d), %d records, %d bytes each'):format( DATA_OFF + base, base, n, RACK_SR)) local rec, ix = index_lookup(data, base, n, TD110, RACK_SR) if rec then log((' TD-110 (0xB0C9FAF4AF8903F9) found in the index: id=%s record=0x%X'):format(tostring(ix), magic + DATA_OFF + rec)) state.records = state.records + 1 dump_slots(magic + DATA_OFF + rec, 'TD-110 mount record (in table)') note_target(magic + DATA_OFF + rec, 'table record') else log(' TD-110 hash absent from the index - falling back to the 24-byte fingerprint') local from = 1 while true do local p = data:find(RACK_PREFIX, from, true) if not p then break end from = p + 1 dump_slots(magic + DATA_OFF + (p - 1), 'TD-110 mount record (fingerprint hit)') note_target(magic + DATA_OFF + (p - 1), 'in-table fingerprint hit') state.records = state.records + 1 break end end rack_done = true end local function on_turret_table(magic) local size = validate_table(magic, TYPE_TURRET) if not size then return end if turret_seen[magic] then return end turret_seen[magic] = true state.tables = state.tables + 1 log(('turret table TurretComponentData @0x%X size=%d'):format(magic, size)) local data = rd(magic + DATA_OFF, math.min(size, DATA_CAP)) if not data then return end local base, n = find_rec_base(data, math.min(size, DATA_CAP), TURRET_SR) if not base then log(' record array start not derivable'); return end log((' record array starts at magic+%d (data+%d), %d records, %d bytes each'):format( DATA_OFF + base, base, n, TURRET_SR)) local rec, ix = index_lookup(data, base, n, TURRET_ENTITY, TURRET_SR) if not rec then log(' the laser hash 0xC36B5B37C058DBDD is not in the index'); turret_done = true; return end local addr = magic + DATA_OFF + rec local bytes = rd(addr, TURRET_SR) if bytes then log((' laser turret record id=%s @0x%X: yaw min(+28)=%.1f max(+32)=%.1f'):format( tostring(ix), addr, f32(bytes, 28) or -999, f32(bytes, 32) or -999)) log(' raw: ' .. hex(bytes)) end turret_done = true end -- ------------------------------------------------------------------- scan --- local region_list, ri, roff = nil, 1, 0 local scan_on, scan_done = false, false local pass, next_pass_frame = 0, 0 local RESCAN_FRAMES = 300 -- 5 s between passes while nothing has been found local MAX_PASSES = 40 local SCAN_CHUNK, OVERLAP = 262144, 1024 local copies_seen = {} local function collect_regions() local list = {} local a = 65536 while a < 0x800000000000 do local r = region(a) if not r or not r.size or r.size <= 0 then break end if r.state == 0x1000 and r.size >= 65536 and (r.protect == 2 or r.protect == 4 or r.protect == 8 or r.protect == 32 or r.protect == 64 or r.protect == 128) and (r.kind == 0x20000 or r.kind == 0x40000) then list[#list + 1] = { base = r.base, size = r.size } end local nx = r.base + r.size if nx <= a then break end a = nx end table.sort(list, function(x, y) return x.size > y.size end) return list end -- The mount/turret tables only exist once the game has mapped its mission -- data, so a region list taken while still on the ship finds nothing. Each pass -- re-collects it; passes repeat until the mount table is found. local function start_pass() pass = pass + 1 region_list = collect_regions() ri, roff = 1, 0 state.scanned = 0 scan_done = false log(('pass %d: regions=%d (previous passes scanned %.0f MB)'):format( pass, #region_list, state.scanned_total or 0)) end local function scan_step(budget) local deadline = os.clock() + budget while os.clock() < deadline do if scan_done then return end local r = region_list[ri] if not r then scan_done = true state.scanned_total = (state.scanned_total or 0) + state.scanned log(('pass %d done: %d MB, mount table %s, turret table %s, %d record(s), %d copy(ies)'):format( pass, math.floor(state.scanned / 1048576), rack_done and 'found' or 'not found', turret_done and 'found' or 'not found', state.records, state.copies)) if not rack_done then log('no mount table this pass - the mission data is probably not mapped yet; retry with a fresh region list in 5 s') end return end if roff >= r.size then ri = ri + 1 roff = 0 else local want = math.min(SCAN_CHUNK, r.size - roff) local data = read_chunk(r.base + roff, want) if not data then ri = ri + 1 roff = 0 else state.scanned = state.scanned + want local base = r.base + roff local from = 1 while true do local i = data:find(SIG_RACK, from, true) if not i then break end if not is_self(base + i - 1) then pcall(on_rack_table, base + i - 1) end from = i + 1 end from = 1 while true do local i = data:find(SIG_TURRET, from, true) if not i then break end if not is_self(base + i - 1) then pcall(on_turret_table, base + i - 1) end from = i + 1 end -- other copies of the TD-110 mount record (read when the vehicle spawns) from = 1 while true do local i = data:find(RACK_PREFIX, from, true) if not i then break end local abs = base + i - 1 from = i + 1 if not is_self(abs) and not copies_seen[abs] and state.copies < MAX_COPIES then copies_seen[abs] = true state.copies = state.copies + 1 pcall(dump_slots, abs, 'TD-110 mount record copy') pcall(note_target, abs, 'memory copy') end end roff = roff + want if roff < r.size then roff = roff - OVERLAP end end end end end -- -------------------------------------------------------------- cfg / write -- local CFG_PATH = (OUT_DIR or '.') .. '/td110_driver_laser.cfg' local cfg = { key = 119, autostart = 0, budget = 0.008, enable = 0, s0 = 'auto', s1 = 'auto', s2 = 'auto', s3 = 'auto', s4 = 'auto' } local function write_default_cfg() local f = io.open(CFG_PATH, 'w') if not f then return end f:write(table.concat({ 'cfgver=2', '# ============================================================', '# TD-110 mount editor - this file is written from the preset on', '# every launch, then re-read every 3 s while the game runs, so you', '# can still test values live (they last for the session).', '#', '# Preset of this build: driver (+48) = laser designator, gunner (+24) = smoke launcher', '# ============================================================', 'enable=1', 's0=auto', 's1=3A061009AA31E9CB', 's2=C36B5B37C058DBDD', 's3=auto', 's4=auto', '# slot offsets: s0=+0 s1=+24 (gunner) s2=+48 (driver) s3=+72 s4=+96', '# item hashes are written as 16 hex digits; auto = leave that slot alone.', '#', '# Vanilla TD-110 slots (dumped live):', '# s0 gatling D58AE6A04EDB10DE node 11570CE3', '# s1 laser designator C36B5B37C058DBDD node 3DB4E9B7', '# s2 smoke launcher 3A061009AA31E9CB node 8245CC79', '# s3 VLS rocket 8AFF7F0793A5BCED node C049EE9D', '# s4 VLS rocket 8AFF7F0793A5BCED node B59EDB27', '#', '# This build: s1=3A061009AA31E9CB (gunner smoke) s2=C36B5B37C058DBDD (driver laser)', '#', '# Untested candidates: Bastion main gun 0DC7A18342B62BEC,', '# Bastion secondary C57F85252B7D853B, turret LMG B8580CAC32214FC0,', '# turret cannon MK3 BB37F12D0F46690F', 'autostart=1', 'key=119', 'budget=0.004', }, '\n') .. '\n') f:close() log('cfg: wrote the preset to ' .. CFG_PATH) end local function read_cfg() local f = io.open(CFG_PATH, 'r') if not f then write_default_cfg(); return end for line in f:lines() do local k, v = line:match('^%s*([%a%d]+)%s*=%s*([%w%.%-]+)') if k and v then local n = tonumber(v) if k == 'key' and n then cfg.key = math.floor(n) elseif k == 'autostart' and n then cfg.autostart = math.floor(n) elseif k == 'budget' and n then cfg.budget = n elseif k == 'enable' and n then cfg.enable = math.floor(n) elseif k == 's0' then cfg.s0 = v elseif k == 's1' then cfg.s1 = v elseif k == 's2' then cfg.s2 = v elseif k == 's3' then cfg.s3 = v elseif k == 's4' then cfg.s4 = v end end end f:close() end local function unprotect(address, size) local r = region(address) if not r then return nil end if r.protect == PAGE_READWRITE or r.protect == 8 then return 0 end if not VP then return nil end local last = r.base + r.size if address + size > last then size = last - address end if size <= 0 then return nil end local old = ffi.new('uint32_t[1]') if VP(ffi.cast('void *', address), size, PAGE_READWRITE, old) ~= 0 then return tonumber(old[0]) end return nil end local function reprotect(address, size, old) if not old or old == 0 or not VP then return end local ign = ffi.new('uint32_t[1]') VP(ffi.cast('void *', address), size, old, ign) end local function write_bytes(address, bytes) local r = region(address) if not r or r.state ~= 0x1000 then return false end local old = unprotect(address, #bytes) if old == nil then log(('write refused: 0x%X cannot change page protection'):format(address)); return false end local n = #bytes local w = ffi.new('size_t[1]') local ok = WPM and WPM(PROC, ffi.cast('void *', address), bytes, n, w) ~= 0 and tonumber(w[0]) == n reprotect(address, n, old) if not ok then log(('write failed: 0x%X'):format(address)); return false end if rd(address, n) ~= bytes then log(('read-back mismatch: 0x%X'):format(address)) return false end return true end local writes_done = false local function cfg_signature() return table.concat({ tostring(cfg.enable), cfg.s0, cfg.s1, cfg.s2, cfg.s3, cfg.s4, tostring(cfg.key), tostring(cfg.autostart) }, '|') end local cfg_sig = nil local CFG_WATCH = 180 -- frames between cfg re-reads (~3 s) local function apply_slots() if writes_done or cfg.enable ~= 1 then return end -- wait for the whole scan so every copy of the record is known if not rack_done or not scan_done then return end local wanted = {} for s = 0, 4 do local txt = cfg['s' .. s] local bytes = (txt and txt ~= 'auto') and hex_to_le8(txt:upper()) or nil if bytes then wanted[#wanted + 1] = { slot = s, bytes = bytes } end end if #wanted == 0 then log('enable=1 but s0..s4 are all auto - nothing to write') writes_done = true return end if #targets == 0 then log('write: mount table found but no record passed the fingerprint check') return end writes_done = true log(('writing: %d record location(s) x %d slot(s)'):format(#targets, #wanted)) for _, target in ipairs(targets) do local prefix = rd(target, 24) if prefix ~= RACK_PREFIX then log(('skipping 0x%X: first-24-byte fingerprint mismatch (not TD-110)'):format(target)) else for _, w in ipairs(wanted) do local addr = target + w.slot * 24 local cur = rd(addr, 8) if cur == w.bytes then log((' 0x%X slot%d: already %s'):format(addr, w.slot, hex_of_le8(w.bytes))) elseif write_bytes(addr, w.bytes) then state.writes = state.writes + 1 log((' 0x%X slot%d: %s -> %s'):format(addr, w.slot, safe_hash(cur), hex_of_le8(w.bytes))) end end end end log(('done (%d slot write(s)). The mount record is read once when the vehicle spawns - **summon / re-summon it now**'):format(state.writes)) end -- ------------------------------------------------------------------- boot --- write_default_cfg() -- the preset always wins at launch read_cfg() log('================ ' .. REVISION .. ' ================') log('td110-arms loaded; key=' .. tostring(cfg.key) .. ' (F8=119) enable=' .. tostring(cfg.enable) .. ' autostart=' .. tostring(cfg.autostart) .. ' budget=' .. tostring(cfg.budget)) log('read-only unless enable=1; with enable=1 the s0..s4 slots are written') log('target: TD-110 tank_storm 0xB0C9FAF4AF8903F9; mount table type 0x3845B1E0; turret table type 0x1EBA7593') cfg_sig = cfg_signature() log(('cfg in effect: enable=%d s0=%s s1=%s s2=%s s3=%s s4=%s autostart=%d'):format( cfg.enable, cfg.s0, cfg.s1, cfg.s2, cfg.s3, cfg.s4, cfg.autostart)) local key_down, started, heartbeat = false, false, 0 local function tick() if KEYSTATE then local down = KEYSTATE(cfg.key) < 0 if down and not key_down then scan_on = not scan_on started = true log('scan: ' .. (scan_on and 'ON' or 'PAUSED')) end key_down = down end if cfg.autostart == 1 and not started then started, scan_on = true, true log('scan: ON (autostart)') end if scan_on then if not region_list then start_pass() elseif scan_done then -- keep going until the mount table is found (or we give up) if not rack_done and pass < MAX_PASSES and state.frame >= next_pass_frame then next_pass_frame = state.frame + RESCAN_FRAMES start_pass() end else pcall(scan_step, cfg.budget) end end if (state.frame % CFG_WATCH) == 0 then local before = cfg_signature() pcall(read_cfg) local after = cfg_signature() if after ~= before then cfg_sig = after writes_done = false log('cfg changed -> re-checking the slots (' .. after .. ')') end end if scan_done then pcall(apply_slots) end pcall(verify_targets) if scan_done and (state.frame % 600) == 0 and heartbeat < 4 then heartbeat = heartbeat + 1 log(('idle: tables %d, records %d, copies %d, writes %d'):format(state.tables, state.records, state.copies, state.writes)) end end local next_verify = 0 local VERIFY_FRAMES = 300 -- ~5 s local function verify_targets() if not writes_done or cfg.enable ~= 1 or #targets == 0 then return end if state.frame < next_verify then return end next_verify = state.frame + VERIFY_FRAMES local wanted = {} for s = 0, 4 do local txt = cfg['s' .. s] local b = (txt and txt ~= 'auto') and hex_to_le8(txt:upper()) or nil if b then wanted[#wanted + 1] = { slot = s, bytes = b } end end if #wanted == 0 then return end for _, target in ipairs(targets) do for _, w in ipairs(wanted) do local addr = target + w.slot * 24 local cur = rd(addr, 8) if cur and cur ~= w.bytes then log(('maintain: 0x%X slot%d reverted to %s, rewriting %s'):format( addr, w.slot, safe_hash(cur), hex_of_le8(w.bytes))) write_bytes(addr, w.bytes) end end end end local original_update = update if type(original_update) == 'function' then function update(...) state.frame = state.frame + 1 if state.frame >= 60 then local ok, err = pcall(tick) if not ok then log('!! tick error: ' .. tostring(err)) end end return original_update(...) end else log('global update unavailable') end return { revision = REVISION, state = state }