Guest User

Untitled

a guest
Nov 2nd, 2026
4
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 17.47 KB | None | 0 0
  1. --[==[
  2. ================================================================================
  3. GeorgeBridge.lua - tiny "cockpit device action" executor for DCS AH-64D George
  4. ================================================================================
  5. What it does
  6. Listens on a localhost UDP port for text datagrams sent by george_hotkeys.py
  7. and performs the requested cockpit-device actions, i.e.
  8.  
  9. GetDevice(87):performClickableAction(<command>, <value>)
  10.  
  11. This is exactly what VAICOM does for George (device 87 = George AI,
  12. 3003 = "Up", 1.0 = press, 0.0 = release). VAICOM runs it from inside a
  13. patched core UI file; here it runs from the normal Export.lua mechanism, so
  14. NO core DCS file needs to be modified and DCS updates don't wipe it.
  15.  
  16. Install
  17. 1. Copy this file to:
  18. %USERPROFILE%\Saved Games\DCS\Scripts\GeorgeBridge\GeorgeBridge.lua
  19. (use "DCS.openbeta" instead of "DCS" if that is your profile folder)
  20. 2. Add these lines at the END of
  21. %USERPROFILE%\Saved Games\DCS\Scripts\Export.lua
  22. (create the file if it doesn't exist):
  23.  
  24. local lfs = require('lfs')
  25. local ok, err = pcall(dofile, lfs.writedir() .. [[Scripts\GeorgeBridge\GeorgeBridge.lua]])
  26. if not ok then log.write("GEORGE_BRIDGE", log.ERROR, tostring(err)) end
  27.  
  28. 3. Start a mission in the AH-64D and look for "GEORGE_BRIDGE" in
  29. %USERPROFILE%\Saved Games\DCS\Logs\dcs.log
  30.  
  31. Datagram format (plain text, no JSON dependency)
  32. <seat>|<device>,<command>,<value>,<delay_ms>;<device>,<command>,<value>,<delay_ms>;...
  33. seat pilot | cpg | any seat the *player* must be in (pilot = George is CPG)
  34. delay_ms wait AFTER this action before running the next one (same meaning as VAICOM)
  35. Special datagram "diag" logs a status line (socket, device 87, seat) and does nothing else.
  36.  
  37. Safety
  38. * Only loopback is bound.
  39. * Only devices in CFG.allowed_devices may be driven (George only by default),
  40. so a stray packet can't flip arbitrary cockpit switches.
  41. ================================================================================
  42. ]==]
  43.  
  44. package.path = package.path .. ";.\\LuaSocket\\?.lua"
  45. package.cpath = package.cpath .. ";.\\LuaSocket\\?.dll"
  46.  
  47. do
  48. local LOG_NAME = "GEORGE_BRIDGE"
  49.  
  50. local CFG = {
  51. host = "127.0.0.1",
  52. port = 42087, -- must match DEFAULT_PORT in george_hotkeys.py
  53. allowed_devices = { [87] = true }, -- 87 = George AI (as used by VAICOM)
  54. max_actions_per_message = 16,
  55. max_queue = 64,
  56. max_datagrams_per_frame = 8,
  57. rebind_retry_s = 5,
  58. verbose = true, -- log every accepted command
  59. }
  60.  
  61. ----------------------------------------------------------------------------
  62. -- logging (never let logging break the export chain)
  63. ----------------------------------------------------------------------------
  64. local function write_log(level, msg)
  65. pcall(function() log.write(LOG_NAME, log[level], msg) end)
  66. end
  67. local function info(msg) write_log("INFO", msg) end
  68. local function warn(msg) write_log("WARNING", msg) end
  69.  
  70. local warned = {}
  71. local function warn_once(key, msg)
  72. if not warned[key] then
  73. warned[key] = true
  74. warn(msg)
  75. end
  76. end
  77.  
  78. local ok_socket, socket = pcall(require, "socket")
  79. if not ok_socket then
  80. write_log("ERROR", "LuaSocket not available, bridge disabled: " .. tostring(socket))
  81. return
  82. end
  83.  
  84. ----------------------------------------------------------------------------
  85. -- state
  86. ----------------------------------------------------------------------------
  87. local udp = nil
  88. local next_bind_try = 0
  89. local queue = {} -- pending actions: {at=, device=, command=, value=}
  90. local queue_free_at = 0 -- wall-clock time the last queued action is "done"
  91.  
  92. local function ensure_socket(now)
  93. if udp then return true end
  94. if now < next_bind_try then return false end -- only set after a FAILED attempt
  95.  
  96. local s = socket.udp()
  97. if not s then
  98. next_bind_try = now + CFG.rebind_retry_s
  99. warn("socket.udp() failed")
  100. return false
  101. end
  102. s:settimeout(0) -- never block the sim frame
  103. local ok, err = s:setsockname(CFG.host, CFG.port)
  104. if not ok then
  105. next_bind_try = now + CFG.rebind_retry_s
  106. warn(string.format("cannot bind %s:%d (%s); retrying in %ds",
  107. CFG.host, CFG.port, tostring(err), CFG.rebind_retry_s))
  108. s:close()
  109. return false
  110. end
  111. next_bind_try = 0
  112. udp = s
  113. info(string.format("listening on udp://%s:%d", CFG.host, CFG.port))
  114. return true
  115. end
  116.  
  117. local function close_socket()
  118. if udp then
  119. pcall(function() udp:close() end)
  120. udp = nil
  121. end
  122. queue = {}
  123. queue_free_at = 0
  124. end
  125.  
  126. ----------------------------------------------------------------------------
  127. -- seat detection: SEAT param is 0 = pilot, 1 = CPG (same source VAICOM uses)
  128. ----------------------------------------------------------------------------
  129. local function read_seat()
  130. local ok, v = pcall(function() return get_param_handle("SEAT"):get() end)
  131. if ok and type(v) == "number" then return v end
  132. return nil
  133. end
  134.  
  135. local function seat_allows(required)
  136. if required == "any" then return true end
  137. local want = (required == "pilot") and 0 or 1
  138. local seat = read_seat()
  139. if seat == nil then
  140. -- Can't tell. Don't silently swallow the command; warn once and go ahead.
  141. warn_once("seat", "could not read SEAT param in the Export environment; " ..
  142. "seat check skipped (commands are NOT being seat-filtered)")
  143. return true
  144. end
  145. return seat == want, seat
  146. end
  147.  
  148. ----------------------------------------------------------------------------
  149. -- parsing
  150. ----------------------------------------------------------------------------
  151. local function parse(data)
  152. local seat, body = data:match("^%s*(%a+)%s*|(.*)$")
  153. if not seat then return nil, "missing '<seat>|' header" end
  154. seat = seat:lower()
  155. if seat ~= "pilot" and seat ~= "cpg" and seat ~= "any" then
  156. return nil, "unknown seat '" .. seat .. "'"
  157. end
  158.  
  159. local actions = {}
  160. for dev, cmd, val, dly in body:gmatch("(%d+)%s*,%s*(%d+)%s*,%s*(%-?[%d%.]+)%s*,%s*(%d+)") do
  161. if #actions >= CFG.max_actions_per_message then
  162. return nil, "too many actions"
  163. end
  164. actions[#actions + 1] = {
  165. device = tonumber(dev),
  166. command = tonumber(cmd),
  167. value = tonumber(val),
  168. delay = tonumber(dly) / 1000,
  169. }
  170. end
  171. if #actions == 0 then return nil, "no actions found" end
  172. return seat, actions
  173. end
  174.  
  175. ----------------------------------------------------------------------------
  176. -- queue + execution
  177. ----------------------------------------------------------------------------
  178. local function enqueue(actions, now)
  179. if #queue + #actions > CFG.max_queue then
  180. warn("queue full, dropping message")
  181. return
  182. end
  183. local t = math.max(now, queue_free_at) -- keep messages strictly in order
  184. for _, a in ipairs(actions) do
  185. queue[#queue + 1] = { at = t, device = a.device, command = a.command, value = a.value }
  186. t = t + a.delay
  187. end
  188. queue_free_at = t
  189. end
  190.  
  191. local function run_action(a)
  192. local ok, err = pcall(function()
  193. local dev = GetDevice(a.device)
  194. local t = type(dev)
  195. if (t ~= "table" and t ~= "userdata") or dev.performClickableAction == nil then
  196. error("GetDevice(" .. a.device .. ") is not a usable device: " .. tostring(dev))
  197. end
  198. dev:performClickableAction(a.command, a.value)
  199. end)
  200. if not ok then
  201. warn(string.format("action dev=%s cmd=%s val=%s failed: %s",
  202. tostring(a.device), tostring(a.command), tostring(a.value), tostring(err)))
  203. end
  204. end
  205.  
  206. local function diag()
  207. local ok, dev = pcall(function() return GetDevice(87) end)
  208. local t = ok and type(dev) or "error"
  209. local usable = (t == "table" or t == "userdata") and dev.performClickableAction ~= nil
  210. info(string.format("diag: socket=%s GetDevice(87)=%s performClickableAction=%s SEAT=%s queued=%d",
  211. udp and "bound" or "NOT bound", t, tostring(usable), tostring(read_seat()), #queue))
  212. end
  213.  
  214. local function handle_datagram(data, now)
  215. if data:match("^%s*diag%s*$") then
  216. diag()
  217. return
  218. end
  219.  
  220. local seat, actions_or_err = parse(data)
  221. if not seat then
  222. warn("rejected datagram (" .. actions_or_err .. "): " .. data:sub(1, 80))
  223. return
  224. end
  225. local actions = actions_or_err
  226.  
  227. for _, a in ipairs(actions) do
  228. if not CFG.allowed_devices[a.device] then
  229. warn("rejected datagram: device " .. a.device .. " is not in allowed_devices")
  230. return
  231. end
  232. end
  233.  
  234. local allowed, seat_val = seat_allows(seat)
  235. if not allowed then
  236. warn(string.format("blocked: command needs the %s seat but SEAT=%s", seat, tostring(seat_val)))
  237. return
  238. end
  239.  
  240. enqueue(actions, now)
  241. if CFG.verbose then
  242. info(string.format("queued %d action(s) [%s seat]", #actions, seat))
  243. end
  244. end
  245.  
  246. local function step()
  247. local now = socket.gettime()
  248.  
  249. if ensure_socket(now) then
  250. for _ = 1, CFG.max_datagrams_per_frame do
  251. local data = udp:receive()
  252. if not data then break end
  253. handle_datagram(data, now)
  254. end
  255. end
  256.  
  257. while queue[1] and queue[1].at <= now do
  258. run_action(table.remove(queue, 1))
  259. end
  260. end
  261.  
  262. ----------------------------------------------------------------------------
  263. -- Export.lua hooks, chained so DCS-BIOS / other export scripts keep working
  264. ----------------------------------------------------------------------------
  265. local prev_start = LuaExportStart
  266. local prev_stop = LuaExportStop
  267. local prev_before = LuaExportBeforeNextFrame
  268.  
  269. local function call_prev(key, fn)
  270. if fn then
  271. local ok, err = pcall(fn)
  272. if not ok then warn_once(key, "previous " .. key .. " handler failed: " .. tostring(err)) end
  273. end
  274. end
  275.  
  276. function LuaExportStart()
  277. call_prev("LuaExportStart", prev_start)
  278. pcall(function() ensure_socket(socket.gettime()) end)
  279. info("GeorgeBridge loaded")
  280. end
  281.  
  282. function LuaExportStop()
  283. call_prev("LuaExportStop", prev_stop)
  284. close_socket()
  285. info("GeorgeBridge stopped")
  286. end
  287.  
  288. function LuaExportBeforeNextFrame()
  289. call_prev("LuaExportBeforeNextFrame", prev_before)
  290. local ok, err = pcall(step)
  291. if not ok then warn_once("step", "step failed: " .. tostring(err)) end
  292. end
  293. end
  294.  
  295.  
  296.  
  297.  
  298. #!/usr/bin/env python3
  299. """
  300. george_hotkeys.py - hotkey -> George AI (DCS AH-64D) without VoiceAttack/VAICOM.
  301.  
  302. How it works (mirrors VAICOM, minus the voice recognition and core-file patching):
  303.  
  304. hotkey --> this script --UDP--> GeorgeBridge.lua (DCS Export.lua)
  305. --> GetDevice(87):performClickableAction(3003, 1.0 / 0.0)
  306.  
  307. The macro table below is the equivalent of VAICOM's CPGCommandHandler.cs.
  308. Device 87 is "George AI"; the button IDs are the ones VAICOM uses.
  309.  
  310. Setup: pip install keyboard
  311. Run: python george_hotkeys.py (hotkey listener)
  312. Test: python george_hotkeys.py --diag (does the DCS side answer? see dcs.log)
  313. python george_hotkeys.py --send PHS_SEARCH (fire the macro without a hotkey)
  314.  
  315. Your joystick button just needs to emit LCtrl+LShift+A (via your stick's own
  316. software, JoyToKey, etc.).
  317. """
  318. from __future__ import annotations
  319.  
  320. import argparse
  321. import socket
  322. import sys
  323. import time
  324. from dataclasses import dataclass
  325. from enum import IntEnum
  326.  
  327. DEFAULT_HOST = "127.0.0.1"
  328. DEFAULT_PORT = 42087 # must match CFG.port in GeorgeBridge.lua
  329. DEVICE_GEORGE = 87 # AH-64D "George AI" cockpit device (same ID VAICOM uses)
  330.  
  331. # In-game, <0.5 s is a "short" press and >0.5 s is a "long" press on George's interface.
  332. # VAICOM uses 1200 ms for long presses; short presses are released immediately.
  333. SHORT_PRESS_MS = 50
  334. LONG_PRESS_MS = 1200
  335.  
  336.  
  337. class GeorgeButton(IntEnum):
  338. """George AI device commands, taken from VAICOM's AH64GeorgeButton enum."""
  339. REQUEST_CONTROL = 3001
  340. MENU = 3002 # show/hide interface
  341. UP = 3003
  342. DOWN = 3004
  343. LEFT = 3005
  344. RIGHT = 3006
  345. MULTIFUNCTION = 3008 # "center"
  346. STORE_TARGET = 3009
  347.  
  348.  
  349. @dataclass(frozen=True)
  350. class Action:
  351. device: int
  352. command: int
  353. value: float # 1.0 = press, 0.0 = release
  354. delay_ms: int = 0 # wait AFTER this action before the next one
  355.  
  356.  
  357. def press(button: GeorgeButton, hold_ms: int = SHORT_PRESS_MS) -> tuple[Action, ...]:
  358. """Press, hold for hold_ms, release. Use hold_ms=LONG_PRESS_MS for a long press."""
  359. return (
  360. Action(DEVICE_GEORGE, int(button), 1.0, hold_ms),
  361. Action(DEVICE_GEORGE, int(button), 0.0, 0),
  362. )
  363.  
  364.  
  365. @dataclass(frozen=True)
  366. class Macro:
  367. name: str
  368. seat: str # seat the PLAYER must be in: "pilot" | "cpg" | "any"
  369. actions: tuple[Action, ...]
  370.  
  371. def encode(self) -> str:
  372. body = ";".join(f"{a.device},{a.command},{a.value:g},{a.delay_ms}" for a in self.actions)
  373. return f"{self.seat}|{body}"
  374.  
  375.  
  376. # --- Macro table (add more George commands here) -----------------------------
  377. # VAICOM: "wMsgGeorgeMacroPHSsearch" -> AddGeorgeButton(AH64GeorgeButton.Up)
  378. # PHS SEARCH = "Up Short": George (as CPG) drops what he's doing and searches
  379. # where the pilot's helmet sight (PHS) is looking. Needs the player in the PILOT seat.
  380. MACROS: dict[str, Macro] = {
  381. m.name: m
  382. for m in (
  383. Macro("PHS_SEARCH", seat="pilot", actions=press(GeorgeButton.UP)),
  384. )
  385. }
  386.  
  387. # --- Hotkeys -----------------------------------------------------------------
  388. HOTKEYS: dict[str, str] = {
  389. "left ctrl+left shift+a": "PHS_SEARCH",
  390. }
  391.  
  392.  
  393. class Bridge:
  394. def __init__(self, host: str, port: int) -> None:
  395. self.addr = (host, port)
  396. self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  397.  
  398. def send_raw(self, text: str) -> None:
  399. self.sock.sendto(text.encode("ascii"), self.addr)
  400.  
  401. def send_macro(self, name: str) -> None:
  402. macro = MACROS[name]
  403. payload = macro.encode()
  404. self.send_raw(payload)
  405. print(f"[{time.strftime('%H:%M:%S')}] {name} -> {payload}", flush=True)
  406.  
  407.  
  408. def run_hotkeys(bridge: Bridge, suppress: bool, cooldown_ms: int) -> None:
  409. try:
  410. import keyboard # imported here so --send / --diag work without it
  411. except ImportError:
  412. sys.exit("Missing dependency: run pip install keyboard")
  413.  
  414. last_fired: dict[str, float] = {}
  415.  
  416. def fire(name: str) -> None:
  417. # Debounce key auto-repeat; a deliberate re-press after the cooldown still works.
  418. now = time.monotonic()
  419. if (now - last_fired.get(name, 0.0)) * 1000 < cooldown_ms:
  420. return
  421. last_fired[name] = now
  422. bridge.send_macro(name)
  423.  
  424. for combo, name in HOTKEYS.items():
  425. keyboard.add_hotkey(combo, fire, args=(name,), suppress=suppress)
  426. print(f" {combo:<28} -> {name} ({MACROS[name].seat} seat)")
  427.  
  428. print(f"Sending to udp://{bridge.addr[0]}:{bridge.addr[1]} | Ctrl+C to quit", flush=True)
  429. try:
  430. while True:
  431. time.sleep(0.5) # sleep (not keyboard.wait) so Ctrl+C works on Windows
  432. except KeyboardInterrupt:
  433. pass
  434. finally:
  435. keyboard.unhook_all_hotkeys()
  436.  
  437.  
  438. def main() -> None:
  439. ap = argparse.ArgumentParser(description="Hotkeys for DCS AH-64D George AI (no VAICOM).")
  440. ap.add_argument("--host", default=DEFAULT_HOST)
  441. ap.add_argument("--port", type=int, default=DEFAULT_PORT)
  442. ap.add_argument("--suppress", action="store_true",
  443. help="swallow the hotkey so DCS never sees it (default: pass through)")
  444. ap.add_argument("--cooldown-ms", type=int, default=250,
  445. help="ignore repeat triggers of the same hotkey within this time")
  446. ap.add_argument("--send", metavar="MACRO", choices=sorted(MACROS),
  447. help="send one macro now and exit (testing)")
  448. ap.add_argument("--diag", action="store_true",
  449. help="ask GeorgeBridge.lua to log a status line to dcs.log, then exit")
  450. args = ap.parse_args()
  451.  
  452. bridge = Bridge(args.host, args.port)
  453.  
  454. if args.diag:
  455. bridge.send_raw("diag")
  456. print("Sent diag. Look for 'GEORGE_BRIDGE: diag:' in Saved Games\\DCS\\Logs\\dcs.log")
  457. elif args.send:
  458. bridge.send_macro(args.send)
  459. else:
  460. print("George hotkeys active:")
  461. run_hotkeys(bridge, args.suppress, args.cooldown_ms)
  462.  
  463.  
  464. if __name__ == "__main__":
  465. main()
Advertisement
Add Comment
Please, Sign In to add comment