From 04bea414005d51a3df59a493e2ff7c539cbb2d7b Mon Sep 17 00:00:00 2001 From: Thomas <72444570+Applevangelist@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:04:57 +0200 Subject: [PATCH] Create RedGCI.lua --- Moose Development/Moose/Functional/RedGCI.lua | 1801 +++++++++++++++++ 1 file changed, 1801 insertions(+) create mode 100644 Moose Development/Moose/Functional/RedGCI.lua diff --git a/Moose Development/Moose/Functional/RedGCI.lua b/Moose Development/Moose/Functional/RedGCI.lua new file mode 100644 index 000000000..ebb182b76 --- /dev/null +++ b/Moose Development/Moose/Functional/RedGCI.lua @@ -0,0 +1,1801 @@ +--- **Functional** - Enhanced Warsaw Pact GCI. +-- +-- ## Main Features: +-- +-- * Guide AI and human pilots in Warsaw Pact Style. Single flight engagements. +-- * Advanced Tactics for Groups. +-- * Many additional events that the mission designer can hook into. +-- +-- === +-- +-- ## Example Missions: +-- +-- Demo missions can be found on [GitHub](https://github.com/FlightControl-Master/MOOSE_MISSIONS/). +-- +-- === +-- +-- ### Author: **Applevangelist** +-- +-- === +-- @module Functional.RedGCI +-- @image Func_RedGCI.png + +--- +-- # RedGCI — Soviet GCI Doctrine & Player Guide +-- +-- ## Philosophy: Централизованное управление (Centralized Control) +-- +-- The fundamental difference between Soviet and NATO GCI is **who makes the tactical decision**. +-- +-- In NATO doctrine, the GCI controller provides situational awareness — bearing, range, altitude, aspect — and the pilot decides how to prosecute the intercept. The pilot is an autonomous tactician. +-- GCI is an advisor. +-- +-- In Soviet doctrine, the GCI controller **directs**. The pilot executes. The controller selects the intercept geometry, assigns the heading, manages the radar, calls weapons free, and coordinates +-- multi-ship tactics. The pilot's job is to fly the numbers and shoot when told. This is not a flaw — it is the system working as designed. Soviet fighter pilots were trained to be precise executors +-- of GCI instructions, not independent tacticians. The ground radar network (PVO) was the brain; the aircraft was the weapon. +-- +-- RedGCI models this philosophy faithfully. +-- +-- --- +-- +-- ## What to Expect as a Player +-- +-- ### You will not be asked what you want to do. +-- +-- There are no "recommend a vector" calls, no "at your discretion" callouts. The controller tells you your heading, your altitude, and your task. Your acknowledgement is assumed. +-- +-- ### The controller manages your radar. +-- +-- You do not decide when to turn your radar on. The GCI will tell you when to switch on (`локатор` / `Radar on`). Before that call, you fly cold and silent. This preserves your emissions +-- discipline and prevents the target from getting an early RWR spike. +-- +-- ### Weapons free is a controlled event. +-- +-- You do not engage until the controller clears you (`цель разрешена` / `WEAPONS FREE`). The controller determines when geometry, range, and aspect are favorable. Shooting early breaks the +-- coordinated intercept and may compromise your wingman's attack. +-- +-- ### Radio calls are short and military. +-- +-- Soviet GCI brevity is terse by design. Expect calls like: +-- +-- - `"Сокол, курс 170, высота 4500."` — vector, altitude +-- - `"Сокол, цель, пара, истребитель. Локатор."` — picture call on commit: count, type, radar on +-- - `"Сокол, захват. Дальность 20. Цель разрешена."` — lock confirmed, range, weapons free +-- - `"Сокол, молодец. Домой."` — good kill, RTB +-- +-- There are no "BOGEY DOPE" requests, no "BRAA" calls, no "DECLARE" queries. The controller has already done that work. You fly the vector. +-- +-- --- +-- +-- ## State Flow — What the GCI is Doing Behind the Scenes +-- +-- RedGCI manages a state machine that progresses through six phases. Understanding these phases helps you anticipate what call is coming next. +-- +-- ``` +-- VECTOR → COMMIT → RADAR_CONTACT → VISUAL → MERGE → (SPLASH / ABORT / RTB) +-- ``` +-- +-- ### VECTOR +-- The controller has a track. You are being vectored onto an intercept geometry. **Your radar is off**. The controller is solving a collision course and updating your heading every tick. +-- Altitude calls reflect the intercept geometry — you may be sent below the target (classic Soviet shoot-up doctrine for radar-limited types) or level/above (MiG-29/Su-27 lookdown geometry). +-- Expect heading updates every 10–15 seconds. +-- +-- **What you should do:** Fly the heading. Don't deviate. **Don't turn your radar on yet**. Speed is expected at 900kph TAS (depending on airframe) +-- +-- ### COMMIT +-- Range has closed to approximately 30km. The controller calls the picture: count and type. Your radar comes on. You are now committed to the intercept — turning away is no longer the +-- default option. The controller is building your radar geometry toward a lock. +-- +-- **What you should do:** Activate your radar. Acquire the target. Do not fire yet. +-- +-- ### RADAR_CONTACT +-- You have radar lock (or the AI has achieved it). The controller confirms lock and calls range. If geometry and range are favorable, weapons free follows immediately. If not — for example +-- if aspect angle is unfavorable for a stern conversion — the controller holds fire and waits for better geometry. +-- +-- **What you should do:** Maintain lock. Track the target. Wait for the weapons free call. +-- +-- ### VISUAL +-- Range has closed to approximately 5km — visual conditions. Weapons free is automatic at this point. You are now in the merge envelope. +-- +-- **What you should do:** Engage. +-- +-- ### MERGE +-- Inside 2km. The GCI transitions to merge control: bearing to target, overshoot calls, separation instructions, reattack vectors. At this range the controller cannot see fine-grained +-- geometry — merge calls are based on relative bearing and closure. +-- +-- **What you should do:** Fight. Listen for overshoot, separation, and reattack calls. +-- +-- ### SPLASH / ABORT / RTB +-- - `SPLASH` — kill confirmed, RTB +-- - `ABORT (THREAT)` — your RWR is spiked or a threat geometry has developed; break off immediately on the given heading +-- - `ABORT (BINGO)` — fuel state critical; break off and return +-- +-- --- +-- +-- ## Multi-Ship (2v2) Tactics (REDGCI2v2) +-- +-- When two fighters are dispatched against a threat, the GCI selects a tactic automatically based on the tactical situation. The tactic is applied at COMMIT — until then, both +-- fighters are vectored together toward the intercept midpoint. +-- +-- | Tactic | Description | +-- |--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +-- | **PINCER** | Classic bracket. F1 and F2 split left and right, attacking from opposite angles simultaneously. Forces the target to choose which threat to react to. | +-- | **HIGH-LOW** | Vertical split. One fighter attacks from below (radar up, clean sky background), one from above. Degrades the target's ability to acquire both simultaneously. | +-- | **STAGGER** | BVR timing offset. F1 fires first at long range, F2 follows 8–13km behind to engage a maneuvering or defending target. | +-- | **TRAIL** | Close trail. F1 is the shooter, F2 is support — ready to engage if F1 overshoots or is defeated. | +-- | **GIRAFFE** | *(Historical — Iraq/Iran War, Mirage F1 vs F-14A)* F1 attacks at normal altitude, binding the AWG-9 radar. F2 flies nap-of-earth | +-- | | (300–600m AGL) using ground clutter to degrade radar detection, then pulls up and fires from close range. | +-- +-- During a tactic split, you may receive a heading that seems unusual — a large lateral offset or an unexpected altitude change. **Trust the vector.** The controller is positioning you for +-- the tactic geometry. The merge point will bring you back onto the target. +-- +-- --- +-- +-- ## Dispatcher & CAP Flow (REDGCI_DISPATCHER) +-- +-- When using the dispatcher layer, the full operational flow is: +-- +-- ``` +-- Spawn at homeplate +-- → Taxi and takeoff (template-controlled) +-- → Transit to CAP zone +-- → Orbit in assigned zone (radar cold, weapons safe) +-- ↓ INTEL detects threat cluster +-- → "Attention, radar contact. Two, fighter, 45 kilometers." (all CAP fighters) +-- ↓ Dispatcher assigns pair +-- → "101 102, intercept. Pair, fighter." (dispatched pair) +-- → VECTOR → COMMIT → RADAR_CONTACT → VISUAL → MERGE → SPLASH +-- ↓ Engagement complete +-- → AI: RTB waypoint → land → despawn → respawn after delay +-- → Human: "101, mission complete. RTB, refuel and rearm." +-- ↓ After RespawnDelay +-- → New AI pair spawns into same CAP zone +-- ``` +-- +-- Human players are dispatched first when available. If a human and AI are both in the CAP pool, the human is always assigned to the next intercept. AI fills gaps. The dispatcher does not +-- send a single fighter if a pair is available — pairing is always preferred. +-- +-- --- +-- +-- ## Key Differences from NATO GCI at a Glance +-- +-- | | Soviet (RedGCI) | NATO | +-- |--------------------|--------------------------------------|------------------------------------| +-- | Tactical decision | Controller | Pilot | +-- | Radar management | Controller-commanded | Pilot-initiated | +-- | Weapons free | Controller-called | Pilot-discretion (after WF) | +-- | Heading calls | Prescriptive | Advisory | +-- | Brevity style | Terse, military, positional | Standardized (BRAA, DECLARE, etc.) | +-- | Multi-ship tactics | Centrally planned, applied at COMMIT | Mutually briefed, pilot-executed | +-- | Pilot autonomy | Low (by design) | High | +-- +-- **The Soviet system is not inferior** — it is optimized for a different kind of pilot and a different operational context. Mass interception of large NATO strike packages over defended +-- Soviet airspace demanded centralized, efficient, high-throughput GCI control. RedGCI brings that experience to DCS. +-- +-- @field #REDGCI +REDGCI = {} + +--- Class name +REDGCI.ClassName = "REDGCI" + +--- Version +REDGCI.version = "2.0.0" + +--- Waypoint lookahead: WP is clamped to this factor × speed × tick_interval (minimum 15 km) +-- @field #number WP_DISTANCE_FACTOR +REDGCI.WP_DISTANCE_FACTOR = 5.0 + +--- +--@field #table RadioChannels +REDGCI.RadioChannels = { + [1] = 125, + [2] = 125.5, + [3] = 126, + [4] = 126.5, + [5] = 127, + [6] = 127.5, + [7] = 128, + [8] = 128.5, + [9] = 129, + [10] = 129.5, +} + +-- ───────────────────────────────────────────────────────────── +-- Localized messages — embedded (gci_messages.lua no longer needed) +-- ───────────────────────────────────────────────────────────── + +--- @type REDGCI.Messages +-- @field #string en +-- @field #string de +-- @field #string ru +REDGCI.Messages = { + + -- ── ENGLISH ────────────────────────────────────────────── + en = { + -- GCI → Pilot + VECTOR = "{CALLSIGN}, vector {HDG}, {ALT}.", + VECTOR_WITH_TTI = "{CALLSIGN}, vector {HDG}, {ALT}. Target in {TTI_M}.", + COMMIT_FIRST = "{CALLSIGN}, target, {COUNT}, {TYPE}. Radar on.", + COMMIT_NO_LOCK = "{CALLSIGN}, correction: bearing {ASPECT}, {RNG} kilometers. No lock?", + COMMIT_NUDGE = "{CALLSIGN}, {DIR_LR} ten degrees.", + RADAR_LOCK_WF = "{CALLSIGN}, lock confirmed. {RNG} kilometers. WEAPONS FREE.", + RADAR_LOCK_HOLD = "{CALLSIGN}, lock confirmed. {RNG} kilometers. Hold fire.", + RADAR_WF_NOW = "{CALLSIGN}, WEAPONS FREE.", + VISUAL_CONFIRM = "{CALLSIGN}, visual confirmed. WEAPONS FREE.", + NOTCH_ENTRY = "{CALLSIGN}, target maneuvering. Standby.", + NOTCH_UPDATE = "{CALLSIGN}, target {DIR_RL}, {RNG} kilometers.", + ABORT_BINGO = "{CALLSIGN}, BINGO. Break off. Course {HDG}.", + ABORT_THREAT = "{CALLSIGN}, THREAT. Break off. Course {HDG}.", + MERGE_ENTRY = "{CALLSIGN}, contact {DIR_RL}, {ASPECT} degrees. FIGHT.", + MERGE_OVERSHOOT = "{CALLSIGN}, overshoot. Break {DIR_LR}.", + MERGE_SEPARATION = "{CALLSIGN}, separate. Climb and reset.", + MERGE_REATTACK = "{CALLSIGN}, reattack. Target {DIR_RL}.", + MERGE_LOST = "{CALLSIGN}, blind. Heading {HDG}.", + MERGE_SPLASH = "{CALLSIGN}, good kill. RTB.", + RADAR_ON = "{CALLSIGN}, radar on.", + WEAPONS_FREE = "{CALLSIGN}, WEAPONS FREE.", + RADIO_SWITCH_CHANNEL = "{CALLSIGN}, switch to channel {CHNL} for GCI.", + -- Pilot → GCI acknowledgements + ACK_VECTOR = "Copy, {HDG}.", + ACK_COMMIT = "Copy.", + ACK_WEAPONS_FREE = "Copy. Engaging.", + ACK_ABORT = "Copy. Breaking off.", + ACK_SPLASH = "Splash. RTB.", + }, + + -- ── GERMAN ─────────────────────────────────────────────── + de = { + -- GCI → Pilot + VECTOR = "{CALLSIGN}, Kurs {HDG}, {ALT}.", + VECTOR_WITH_TTI = "{CALLSIGN}, Kurs {HDG}, {ALT}. Ziel in {TTI_M}.", + COMMIT_FIRST = "{CALLSIGN}, Ziel, {COUNT}, {TYPE}. Radar an.", + COMMIT_NO_LOCK = "{CALLSIGN}, Korrektur: Peilung {ASPECT}, {RNG} Kilometer. Kein Lock?", + COMMIT_NUDGE = "{CALLSIGN}, {DIR_LR} zehn Grad.", + RADAR_LOCK_WF = "{CALLSIGN}, Lock bestätigt. {RNG} Kilometer. Feuer frei.", + RADAR_LOCK_HOLD = "{CALLSIGN}, Lock bestätigt. {RNG} Kilometer. Warten.", + RADAR_WF_NOW = "{CALLSIGN}, Feuer frei.", + VISUAL_CONFIRM = "{CALLSIGN}, Sichtkontakt. Feuer frei.", + NOTCH_ENTRY = "{CALLSIGN}, Ziel manövriert. Warten.", + NOTCH_UPDATE = "{CALLSIGN}, Ziel {DIR_RL}, {RNG} Kilometer.", + ABORT_BINGO = "{CALLSIGN}, BINGO. Abbruch. Kurs {HDG}.", + ABORT_THREAT = "{CALLSIGN}, GEFAHR. Abbruch. Kurs {HDG}.", + MERGE_ENTRY = "{CALLSIGN}, Kontakt {DIR_RL}, {ASPECT} Grad. Angriff.", + MERGE_OVERSHOOT = "{CALLSIGN}, Überschuss. {DIR_LR} ausbrechen.", + MERGE_SEPARATION = "{CALLSIGN}, trennen. Steigen und neu ansetzen.", + MERGE_REATTACK = "{CALLSIGN}, neu angreifen. Ziel {DIR_RL}.", + MERGE_LOST = "{CALLSIGN}, BLIND. Kurs {HDG}.", + MERGE_SPLASH = "{CALLSIGN}, Treffer. Heimkurs.", + RADAR_ON = "{CALLSIGN}, Radar an.", + WEAPONS_FREE = "{CALLSIGN}, Feuer frei.", + RADIO_SWITCH_CHANNEL = "{CALLSIGN}, umschalten auf Kanal {CHNL} für Anweisungen.", + -- Pilot → GCI acknowledgements + ACK_VECTOR = "Verstanden, Kurs {HDG}.", + ACK_COMMIT = "Verstanden.", + ACK_WEAPONS_FREE = "Verstanden. Greife an.", + ACK_ABORT = "Verstanden. Abbruch.", + ACK_SPLASH = "Treffer. Heimkurs.", + }, + + -- ── RUSSIAN ─────────────────────────────────────────────── + ru = { + -- GCI → Pilot + VECTOR = "{CALLSIGN}, курс {HDG}, высота {ALT}.", + VECTOR_WITH_TTI = "{CALLSIGN}, курс {HDG}, высота {ALT}. До цели {TTI_M} минут.", + COMMIT_FIRST = "{CALLSIGN}, цель, {COUNT}, {TYPE}. Локатор.", + COMMIT_NO_LOCK = "{CALLSIGN}, поправка: азимут {ASPECT}, дальность {RNG}. Захват?", + COMMIT_NUDGE = "{CALLSIGN}, довернись {DIR_LR}.", + RADAR_LOCK_WF = "{CALLSIGN}, захват. Дальность {RNG}. Цель разрешена.", + RADAR_LOCK_HOLD = "{CALLSIGN}, захват. Дальность {RNG}. Жди.", + RADAR_WF_NOW = "{CALLSIGN}, цель разрешена.", + VISUAL_CONFIRM = "{CALLSIGN}, визуальный. Цель разрешена.", + NOTCH_ENTRY = "{CALLSIGN}, цель маневрирует. Жди.", + NOTCH_UPDATE = "{CALLSIGN}, цель {DIR_RL}, дальность {RNG}.", + ABORT_BINGO = "{CALLSIGN}, топливо. Прекрати. Курс {HDG}.", + ABORT_THREAT = "{CALLSIGN}, угроза. Прекрати. Курс {HDG}.", + MERGE_ENTRY = "{CALLSIGN}, контакт {DIR_RL}, {ASPECT} градусов. Бой.", + MERGE_OVERSHOOT = "{CALLSIGN}, перелёт. Разворот {DIR_LR}.", + MERGE_SEPARATION = "{CALLSIGN}, выход. Высота, повтори.", + MERGE_REATTACK = "{CALLSIGN}, повтори. Цель {DIR_RL}.", + MERGE_LOST = "{CALLSIGN}, потеря. Курс {HDG}.", + MERGE_SPLASH = "{CALLSIGN}, молодец. Домой.", + RADAR_ON = "{CALLSIGN}, локатор.", + WEAPONS_FREE = "{CALLSIGN}, цель разрешена.", + RADIO_SWITCH_CHANNEL = "{CALLSIGN}, переключись на канал {CHNL} для указаний.", + -- Pilot → GCI acknowledgements (kurz und militärisch) + ACK_VECTOR = "Понял, курс {HDG}.", + ACK_COMMIT = "Понял.", + ACK_WEAPONS_FREE = "Понял. Атакую.", + ACK_ABORT = "Понял. Прекращаю.", + ACK_SPLASH = "Цель поражена. Домой.", + }, +} + +--- @type REDGCI.DirTokens +-- @field #string en +-- @field #string de +-- @field #string ru +REDGCI.DirTokens = { + en = { left="left", right="right", ahead="ahead", behind="behind", low="low", high="high" }, + de = { left="links", right="rechts", ahead="voraus", behind="hinten", low="tief", high="hoch" }, + ru = { left="влево", right="вправо", ahead="впереди", behind="сзади", low="ниже", high="выше" }, +} + +--- ───────────────────────────────────────────────────────────── +-- Picture tokens — COUNT and TYPE +-- COUNT: 1=single, 2=pair, 3-4=group, 5+=big group +-- TYPE: RCS-based fighter/bomber/machines (machines = unknown/medium) +-- ───────────────────────────────────────────────────────────── + +--- @type REDGCI.CountTokens +-- @field #string en +-- @field #string de +-- @field #string ru +REDGCI.CountTokens = { + en = { single="single", pair="two", group="group", biggroup="big group" }, + de = { single="einzel", pair="zwei", group="Gruppe", biggroup="große Gruppe" }, + ru = { single="одиночная", pair="пара", group="группа", biggroup="большая группа"}, +} + +--- @type REDGCI.TypeTokens +-- @field #string en +-- @field #string de +-- @field #string ru +REDGCI.TypeTokens = { + en = { fighter="fighter", bomber="bomber", machines="machines" }, + de = { fighter="Jäger", bomber="Bomber", machines="Maschinen" }, + ru = { fighter="истребитель", bomber="бомбардировщик", machines="машины" }, +} + +--- +-- @field #number RCS_FIGHTER_MAX +-- @field #number RCS_BOMBER_MIN +-- RCS thresholds für automatische Typ-Erkennung aus INTEL +REDGCI.RCS_FIGHTER_MAX = 6.0 -- < 6 m² → Jäger +REDGCI.RCS_BOMBER_MIN = 20.0 -- > 20 m² → Bomber + -- dazwischen → Maschinen (unbekannt) + +-- ───────────────────────────────────────────────────────────── +-- Constructor +-- ───────────────────────────────────────────────────────────── + +--- Create a new REDGCI instance. +-- @param #REDGCI self +-- @param #string FighterGroupName DCS group name of the interceptor(s) +-- @param #string TargetGroupName DCS group name of the target(s) +-- @param #string Callsign Radio callsign string (e.g. "Сокол-1") +-- @param #number Coalition coalition.side.RED or coalition.side.BLUE +-- @return #REDGCI self +function REDGCI:New(FighterGroupName, TargetGroupName, Callsign, Coalition) + local self = BASE:Inherit(self, FSM:New()) --#REDGCI + + -- Log prefix + self.lid = string.format("REDGCI (%s) | ", Callsign or "GCI") + + -- ── Core identity ───────────────────────────────────────── + self.FighterGroupName = FighterGroupName or "Mig-29A" + self.TargetGroupName = TargetGroupName or "Target" + self.Callsign = Callsign or "Сокол 1" + self.Coalition = Coalition or coalition.side.RED + + -- ── Defaults ───────────────────────────────────────────── + self.Locale = "ru" + self.TickInterval = 10.0 + self.TxRepeatInterval = 30.0 + self.SubtitleTime = 8 + self.IsAIPlane = true + self.HomeBaseName = nil + self.HomeBase = nil -- Vec2 {x,y} + self.Debug = false + self.WFRange = 20000 -- metres: AI weapons-free range (C kernel wf=false workaround) + self.AltOffset = -700 -- metres relative to target altitude for intercept waypoint. + self.ContactLostTimeout = 3 -- ticks without contact before declaring target gone + self._contact_lost_ticks = 0 -- internal counter + -- Negative = below target (Shootup geometry, classic Soviet doctrine + -- for radar-limited types like MiG-21/early MiG-23). + -- Zero or positive = Lookdown/Shoot-Down geometry (MiG-29, Su-27). + -- Use SetAltOffset() to override. + self._target_count = 1 + self._target_type = "FIGHTER" + + -- ── SRS defaults (GCI controller voice) ────────────────── + self.SRSPath = nil + self.SRSFreq = 251 + self.SRSMod = radio.modulation.AM + self.SRSCulture = "ru-RU" + self.SRSVoice = MSRS.Voices.Google.Standard.ru_RU_Standard_D + self.SRSPort = 5002 + + -- ── Pilot SRS defaults (separate voice for acknowledgements) ── + self.PilotCallsign = nil -- if nil, no pilot ACKs are transmitted + self.PilotSRSCulture = "ru-RU" + self.PilotSRSVoice = MSRS.Voices.Google.Standard.ru_RU_Standard_B + self._pilot_msrs = nil + self._pilot_queue = nil + + -- ── Internal state (per instance) ───────────────────────── + self._pilot_flags = { radar=false, visual=false, threat=false } + self._prev_state = nil + self._prev_wf = false + self._prev_radar = false + self._last_tx = { text="", time=0 } + self._msrs = nil + self._srs_queue = nil + self._gettext = nil + self._wp_override = nil -- { x, z, y, ticks } set by REDGCI2v2 for tactic geometry + self._missilerangeflag = 2 -- 1, HALF_WAY_RMAX_NEZ = 2, TARGET_THREAT_EST = 3, RANDOM_RANGE = 4. Defaults to 2. + + -- FSM state tracking for REDGCI_KERNEL (no C DLL needed) + self._ticks_in_state = 0 + self._merge_phase = "ENTRY" + self._merge_phase_prev = "ENTRY" + self._merge_ticks = 0 + self._merge_pass_count = 0 + + -- ── FSM transitions ─────────────────────────────────────── + self:SetStartState("Stopped") + self:AddTransition("Stopped", "Start", "Running") + self:AddTransition("Running", "Status", "Running") + self:AddTransition("Running", "Stop", "Stopped") + + self:I(self.lid .. "v" .. REDGCI.version .. " created. Fighter=" .. + self.FighterGroupName .. " Target=" .. self.TargetGroupName) + + return self +end + +-- ───────────────────────────────────────────────────────────── +-- User API — configuration (all return self for chaining) +-- ───────────────────────────────────────────────────────────── + +--- Set the locale for radio messages. +-- @param #REDGCI self +-- @param #string Locale "en", "de", or "ru" (default "ru") +-- @return #REDGCI self +function REDGCI:SetLocale(Locale) + self.Locale = Locale or "ru" + return self +end + +--- Enable or disable AI waypoint and radar management. +-- @param #REDGCI self +-- @param #boolean IsAI true = push waypoints + control radar +-- @param #string HomeBaseName AIRBASE name for RTB (e.g. AIRBASE.Caucasus.Nalchik) +-- @return #REDGCI self +function REDGCI:SetAIMode(IsAI, HomeBaseName) + self.IsAIPlane = IsAI ~= false + if HomeBaseName then + self.HomeBaseName = HomeBaseName + local ab = AIRBASE:FindByName(HomeBaseName) + if ab then + self.HomeBase = ab:GetVec2() + else + self:E(self.lid .. "SetAIMode: AIRBASE '" .. tostring(HomeBaseName) .. "' not found!") + end + end + return self +end + +--- Configure SRS radio output. +-- @param #REDGCI self +-- @param #string Path Path to SRS (or nil to use MSRS default) +-- @param #number Frequency MHz, e.g. 251 +-- @param #number Modulation radio.modulation.AM or FM (default AM) +-- @param #string Culture BCP-47 culture string, e.g. "ru-RU" +-- @param #string Voice MSRS voice constant +-- @param #number Port SRS port (default 5002) +-- @param #number Speed Speach speed, default 1. +-- @return #REDGCI self +function REDGCI:SetSRS(Path, Frequency, Modulation, Culture, Voice, Port, Speed) + self.SRSPath = Path + self.SRSFreq = Frequency or self.SRSFreq + self.SRSMod = Modulation or self.SRSMod + self.SRSCulture = Culture or self.SRSCulture + self.SRSVoice = Voice or self.SRSVoice + self.SRSPort = Port or self.SRSPort + self.SRSSpeed = Speed or 1 + self:I({F=Frequency,V=Voice}) + return self +end + +--- Enable SRS autotranslation, do not forget to set voices according to language! Requires HOUND as SRS backend! +-- @param #REDGCI self +-- @param #string languagecode Language to translate to, defaults to "fr". Takes [ISO 639-1](https://en.wikipedia.org/wiki/ISO_639-1) language codes. +-- @param #string provider (optional) Translation provider, defaults to `MSRS.Provider.GOOGLE` +-- @return #REDGCI2v2 self +function REDGCI:EnableSRSAutoTranslate(languagecode,provider) + self.translateEnabled = true + self.translateLanguage = languagecode or "fr" + self.translateProvider = provider or MSRS.Provider.GOOGLE + return self +end + +--- Configure available channel numbers and frequencies for pilots. +-- @param #REDGCI self +-- @param #table RadioTable Table of available channel numbers and their frequencies, indexed by channel number +-- @return #REDGCI self +-- @usage +-- Use as follows, e.g. +-- local RadioTable = { +-- [1] = 125, +-- [2] = 125.5, +-- [3] = 126, +-- [4] = 126.5, +-- [5] = 127, +-- [6] = 127.5, +-- [7] = 128, +-- [8] = 128.5, +-- [9] = 129, +-- [10] = 129.5, +-- } +-- dispatch:SetRadioChannelList(RadioTable) +function REDGCI:SetRadioChannelList(RadioTable) + self.RadioChannels = RadioTable + return self +end + +--- Set SRS Provider +--@param #REDGCI self +--@param #string Provider +function REDGCI:SetSRSProvider(Provider) + self:T(self.lid.."SetSRSProvider "..tostring(Provider)) + self.SRSProvider = Provider or MSRS.Provider.GOOGLE + return self +end + +--- Set SRS Voice Speaker for Hound/Piper +--@param #REDGCI self +--@param #number Speaker Speaker number, e.g. 11 for Speaker "318 (11)" +function REDGCI:SetSRSPiperSpeaker(Speaker) + self:T(self.lid.."SetSRSPiperSpeaker "..tostring(Speaker)) + self.SRSSpeaker = Speaker + return self +end + +--- Configure the pilot voice for radio acknowledgements. +-- The pilot uses the same frequency/modulation as the GCI controller but +-- a distinct voice so the two can be told apart on the radio. +-- Set PilotCallsign to nil (default) to disable pilot ACKs entirely. +-- @param #REDGCI self +-- @param #string PilotCallsign Pilot's callsign (e.g. "Сокол-1"), or nil to disable ACKs. +-- @param #string Culture BCP-47 culture string (default same as GCI) +-- @param #string Voice MSRS voice constant (default ru_RU_Standard_B) +-- @param #number Speaker (Optional) MSRS Speaker for Hound/Piper Voices, e.g. 11 for "318 (11)" +-- @return #REDGCI self +function REDGCI:SetPilotSRS(PilotCallsign, Culture, Voice, Speaker) + self:T({CS=PilotCallsign,CU=Culture,VO=Voice,SP=Speaker}) + self.PilotCallsign = PilotCallsign + self.PilotSRSCulture = Culture or self.SRSCulture + self.PilotSRSVoice = Voice or MSRS.Voices.Google.Standard.ru_RU_Standard_B + self.PilotSRSSpeaker = Speaker + return self +end + + + +--- Set the target count manually (used when no INTEL source is attached). +-- Overridden automatically when INTEL is active. +-- @param #REDGCI self +-- @param #number Count Number of targets (1=single, 2=pair, 3-4=group, 5+=big group) +-- @return #REDGCI self +function REDGCI:SetTargetCount(Count) + self._target_count = Count or 1 + return self +end + +--- Set the target type manually (used when no INTEL source is attached). +-- @param #REDGCI self +-- @param #string Type "FIGHTER", "BOMBER", or "MACHINES" (default) +-- @return #REDGCI self +function REDGCI:SetTargetType(Type) + self._target_type = Type or "MACHINES" + return self +end + +--- Set the GCI tick interval in seconds. +-- @param #REDGCI self +-- @param #number Seconds Default 10.0 +-- @return #REDGCI self +function REDGCI:SetTickInterval(Seconds) + self.TickInterval = Seconds or 10.0 + return self +end + +--- Set minimum seconds between identical transmissions. +-- @param #REDGCI self +-- @param #number Seconds Default 30.0 +-- @return #REDGCI self +function REDGCI:SetTxRepeatInterval(Seconds) + self.TxRepeatInterval = Seconds or 30.0 + return self +end + +--- Set the AI weapons-free range threshold in metres. +-- Weapons free is declared when the C kernel wf flag is true OR (AI mode AND +-- state is RADAR_CONTACT AND range <= WFRange). Set to 0 to disable the +-- Lua-side override and rely solely on the C kernel. +-- @param #REDGCI self +-- @param #number Meters Default 20000 +-- @return #REDGCI self +function REDGCI:SetWFRange(Meters) + self.WFRange = Meters or 20000 + return self +end + +--- Enable or disable debug logging. +-- @param #REDGCI self +-- @param #boolean OnOff true = verbose logging +-- @return #REDGCI self +function REDGCI:SetDebug(OnOff) + self.Debug = OnOff ~= false + return self +end + +--- Set the altitude offset applied to intercept waypoints relative to the target altitude. +-- Models the preferred attack geometry of the fighter type: +-- +-- Negative offset (below target) = classic Soviet Shootup doctrine. +-- The fighter is vectored below the target so its radar looks up +-- against a clean sky background, avoiding ground clutter. +-- Appropriate for MiG-21, early MiG-23 (limited or no LDSD capability). +-- Typical value: -700 m (default). +-- +-- Zero or positive offset (at or above target) = Lookdown/Shoot-Down. +-- The fighter has a modern pulse-Doppler radar capable of suppressing +-- ground clutter and shooting downward. +-- Appropriate for MiG-29 (N019), Su-27 (N001), MiG-31 (Zaslon). +-- Typical value: 0 (level) to +300 m (slight high perch). +-- +-- The offset is only applied during VECTOR and COMMIT states. +-- In RADAR_CONTACT and beyond, exact geometry is driven by the C kernel. +-- @param #REDGCI self +-- @param #number Meters Altitude offset in metres (default -700). +-- @return #REDGCI self +function REDGCI:SetAltOffset(Meters) + self.AltOffset = Meters or -700 + return self +end + +--- Set how many consecutive ticks without a target contact are tolerated +-- before the GCI declares the intercept over. +-- +-- During a contact gap the GCI transmits NOTCH_ENTRY on the first missing +-- tick and then stays silent, holding the last known vector, until either +-- the contact is re-acquired (counter resets) or the timeout is reached +-- (MERGE_SPLASH + Stop). +-- +-- One tick = TickInterval seconds (default 10 s), so the default of 3 ticks +-- means ~30 s of tolerance — enough to cover a typical notch manoeuvre or +-- brief Doppler blind spot without falsely declaring a kill. +-- @param #REDGCI self +-- @param #number Ticks Number of ticks (default 3). +-- @return #REDGCI self +function REDGCI:SetContactLostTimeout(Ticks) + self.ContactLostTimeout = Ticks or 3 + return self +end + +--- Signal that the pilot has achieved radar lock. +-- Equivalent to pressing "Radar Lock" in the F10 menu. +-- @param #REDGCI self +-- @param #boolean OnOff +-- @return #REDGCI self +function REDGCI:SetPilotRadarLock(OnOff) + self._pilot_flags.radar = OnOff ~= false + return self +end + +--- Signal that the pilot has visual contact. +-- @param #REDGCI self +-- @param #boolean OnOff +-- @return #REDGCI self +function REDGCI:SetPilotVisual(OnOff) + self._pilot_flags.visual = OnOff ~= false + return self +end + +--- Set range on which AI will prefer to fire missiles. +-- MAX_RANGE = 0, NEZ_RANGE = 1, HALF_WAY_RMAX_NEZ = 2, TARGET_THREAT_EST = 3, RANDOM_RANGE = 4. Defaults to 2. +-- @param #REDGCI self +-- @param #number Flag The behavior to set. +-- @return #REDGCI self +function REDGCI:SetMissileFiringFlag(Flag) + self._missilerangeflag = Flag or 2 + return self +end + +--- Signal that the pilot's RWR is active (threat warning). +-- @param #REDGCI self +-- @param #boolean OnOff +-- @return #REDGCI self +function REDGCI:SetPilotThreat(OnOff) + self._pilot_flags.threat = OnOff ~= false + return self +end + +--- Reset FSM state and pilot flags (e.g. after a splash or new intercept). +-- @param #REDGCI self +-- @return #REDGCI self +function REDGCI:Reset() + self._pilot_flags = { radar=false, visual=false, threat=false } + self._prev_state = nil + self._prev_wf = false + self._prev_radar = false + self._last_tx = { text="", time=0 } + self._contact_lost_ticks = 0 + self._wp_override = nil -- { x, z, y, ticks } set by REDGCI2v2 for tactic geometry + RedGCI.reset(self.Callsign) + self:_SetRadar(false) + self:_SetWeaponsFree(false) + self:T(self.lid .. "Reset.") + return self +end + +-- ───────────────────────────────────────────────────────────── +-- Internal helpers +-- ───────────────────────────────────────────────────────────── + +--- [INTERNAL] +--- @param #REDGCI self +function REDGCI:_Log(msg) + if self.Debug then + env.info(self.lid .. msg) + end +end + +--- [INTERNAL] Initialize TEXTANDSOUND localization from embedded Messages table. +-- @param #REDGCI self +function REDGCI:_InitLocalization() + self._gettext = TEXTANDSOUND:New("REDGCI_" .. self.Callsign, "en") + for locale, entries in pairs(REDGCI.Messages) do + local loc = string.lower(tostring(locale)) + for id, text in pairs(entries) do + self._gettext:AddEntry(loc, tostring(id), text) + end + end +end + +--- [INTERNAL] Initialize MSRS + queue. +-- @param #REDGCI self +function REDGCI:_InitSRS() + -- GCI controller voice + self._msrs = MSRS:New(self.SRSPath, self.SRSFreq, self.SRSMod) + self._msrs:SetPort(self.SRSPort) + self._msrs:SetLabel("GCI") + self._msrs:SetCulture(self.SRSCulture) + self._msrs:SetVoice(self.SRSVoice) + self._msrs:SetCoalition(self.Coalition) + if self.SRSProvider then + self._msrs:SetProvider(self.SRSProvider) + end + if self.SRSSpeaker then + self._msrs:SetSpeakerPiper(self.SRSSpeaker) + end + if self.translateEnabled == true then + self._msrs:SetAutoTranslate(self.translateProvider,self.translateLanguage) + end + self._srs_queue = MSRSQUEUE:New("REDGCI_" .. self.Callsign) -- Sound.MSRS#MSRSQUEUE + + -- Pilot voice (only when a pilot callsign has been configured) + if self.PilotCallsign then + self._pilot_msrs = MSRS:New(self.SRSPath, self.SRSFreq, self.SRSMod) + self._pilot_msrs:SetPort(self.SRSPort) + self._pilot_msrs:SetLabel("PILOT") + self._pilot_msrs:SetCulture(self.PilotSRSCulture) + self._pilot_msrs:SetVoice(self.PilotSRSVoice) + self._pilot_msrs:SetCoalition(self.Coalition) + if self.SRSProvider then + self._pilot_msrs:SetProvider(self.SRSProvider) + end + if self.SRSSpeaker then + self._pilot_msrs:SetSpeakerPiper(self.PilotSRSSpeaker) + end + if self.translateEnabled == true then + self._pilot_msrs:SetAutoTranslate(self.translateProvider,self.translateLanguage) + end + self._pilot_queue = self._srs_queue + self:_Log("Pilot SRS ready: " .. self.PilotCallsign) + end +end + +--- [INTERNAL] Get live unit data from a DCS group (returns first alive unit). +-- @param #REDGCI self +-- @param #string GroupName +-- @return #table { x, y, z, spd, vx, vy, vz, hdg, fuel } or nil +function REDGCI:_GetUnitData(GroupName) + if not GroupName then return end + local grp = Group.getByName(GroupName) + if not grp then return nil end + for _, u in ipairs(grp:getUnits()) do + if u and u:isExist() and u:isActive() then + local pos3 = u:getPosition() + local p = pos3.p + local fwd = pos3.x + local v = u:getVelocity() + return { + x = p.x, + y = p.y, + z = p.z, + spd = math.sqrt(v.x*v.x + v.z*v.z), + vx = v.x, + vy = v.y, + vz = v.z, + hdg = math.deg(math.atan2(fwd.z, fwd.x)) % 360, + fuel = u:getFuel(), + } + end + end + return nil +end + +--- [INTERNAL] Parse a pipe-separated token string from the C kernel. +-- Input: "VECTOR|hdg=165|alt=4500|tti_m=8|wf=false" +-- Output: { key="VECTOR", hdg=165, alt=4500, tti_m=8, wf=false } +-- @param #REDGCI self +function REDGCI:_ParseTokens(TokenStr) + self:T("_ParseTokens "..TokenStr) + if not TokenStr or TokenStr == "" then return nil end + local parts = {} + for part in string.gmatch(TokenStr, "[^|]+") do + parts[#parts + 1] = part + end + local result = { key = parts[1] } + for i = 2, #parts do + local k, v = string.match(parts[i], "^([%w_]+)=(.+)$") + if k and v then + if v == "true" then result[k] = true + elseif v == "false" then result[k] = false + elseif tonumber(v) then result[k] = tonumber(v) + else result[k] = v + end + end + end + UTILS.PrintTableToLog(result) + return result +end + +--- [INTERNAL] Fill {PLACEHOLDER} tokens in a template string. +-- @param #REDGCI self +function REDGCI:_FillTemplate(Template, Vars) + return (string.gsub(Template, "{([%w_]+)}", function(key) + return tostring(Vars[key] or "") + end)) +end + +--- [INTERNAL] Resolve COUNT token to localized word. +-- @param #REDGCI self +-- @param #number Count +-- @return #string +function REDGCI:_CountToken(Count) + local t = REDGCI.CountTokens[self.Locale] or REDGCI.CountTokens["en"] + local n = Count or 1 + if n == 1 then return t.single + elseif n == 2 then return t.pair + elseif n <= 4 then return t.group + else return t.biggroup + end +end + +--- [INTERNAL] Resolve TYPE token to localized word. +-- @param #REDGCI self +-- @param #string TypeKey "FIGHTER", "BOMBER", "MACHINES" +-- @return #string +function REDGCI:_TypeToken(TypeKey) + local t = REDGCI.TypeTokens[self.Locale] or REDGCI.TypeTokens["en"] + local k = string.lower(TypeKey or "machines") + return t[k] or t.machines +end + +--- [INTERNAL] Derive count and type from INTEL contact. +-- Updates self._target_count and self._target_type. +-- @param #REDGCI self +function REDGCI:_UpdatePictureFromIntel() + if not self.Intel or not self.Intel:Is("Running") then return end + local contacts = self.Intel:GetContactTable() + if not contacts then return end + + local count = 0 + local rcs_sum = 0.0 + local rcs_n = 0 + + for _, contact in pairs(contacts) do + if contact.ctype == INTEL.Ctype.AIRCRAFT then + if not (self.IntelTargetFilter and + not string.find(contact.groupname or "", self.IntelTargetFilter, 1, true)) then + -- Anzahl Units in der Kontaktgruppe + local grp = GROUP:FindByName(contact.groupname) + if grp then + count = count + grp:CountAliveUnits() + else + count = count + 1 + end + -- RCS aus contact wenn verfügbar + if contact.rcs then + rcs_sum = rcs_sum + contact.rcs + rcs_n = rcs_n + 1 + end + end + end + end + + if count > 0 then + self._target_count = count + end + + -- Type aus durchschnittlichem RCS + if rcs_n > 0 then + local avg_rcs = rcs_sum / rcs_n + if avg_rcs < REDGCI.RCS_FIGHTER_MAX then self._target_type = "FIGHTER" + elseif avg_rcs > REDGCI.RCS_BOMBER_MIN then self._target_type = "BOMBER" + else self._target_type = "MACHINES" + end + end +end + +--- [INTERNAL] Resolve a direction key to a localized word. +-- @param #REDGCI self +-- @param #string Key "left", "right", "ahead", "behind", "low", "high" +-- @return #string +function REDGCI:_DirToken(Key) + local t = REDGCI.DirTokens[self.Locale] or REDGCI.DirTokens["en"] + return t[Key] or Key +end + +--- [INTERNAL] Derive "left"/"right" turn instruction from aspect angle. +-- @param #REDGCI self +function REDGCI:_DeriveDirLR(AspectAngle) + return (AspectAngle > 5.0) and "right" or "left" +end + +--- [INTERNAL] Derive target position relative to fighter ("ahead"/"behind"/"left"/"right"). +-- @param #REDGCI self +-- @param #table Fighter unit data +-- @param #table Target unit data +-- @return #string +function REDGCI:_DeriveDirRL(Fighter, Target) + local dx = Target.x - Fighter.x + local dz = Target.z - Fighter.z + local f_hdg = math.atan2(Fighter.vx, Fighter.vz) + local to_tgt = math.atan2(dx, dz) + local rel = math.deg(to_tgt - f_hdg) % 360 + if rel < 45 or rel > 315 then return "ahead" + elseif rel < 135 then return "right" + elseif rel < 225 then return "behind" + else return "left" + end +end + +--- [INTERNAL] Build and dispatch a radio transmission. +-- @param #REDGCI self +-- @param #string TokenStr Pipe-separated token string from C kernel +-- @param #string DirLR "left"/"right" for manoeuvre cues +-- @param #string DirRL "ahead"/"behind"/"left"/"right" for target position +-- @param #number Priority +function REDGCI:_Transmit(TokenStr, DirLR, DirRL, Priority) + local tok = self:_ParseTokens(TokenStr) + if not tok then + self:_Log("_Transmit: empty token string") + return + end + + -- Look up template + local template = self._gettext:GetEntry(tok.key, self.Locale) + if not template then + self:_Log("_Transmit: no template for key=" .. tostring(tok.key) .. + " locale=" .. self.Locale) + return + end + + tok.aspect = tok.aspect or tok.brg -- Merge nutzt brg statt aspect + + -- Build variable table + local vars = { + CALLSIGN = string.gsub(self.Callsign, "-", " "), + HDG = tok.hdg and string.format("%03d", tok.hdg) or "", + ALT = tok.alt and tostring(math.floor(tok.alt)) or "", + RNG = tok.rng and tostring(math.floor(tok.rng)) or "", + TTI_M = tok.tti_m and tostring(tok.tti_m) or "", + TTI_S = tok.tti_s and tostring(tok.tti_s) or "", + ASPECT = tok.aspect and string.format("%03d", tok.aspect) or "", + DIR_LR = self:_DirToken(DirLR or "right"), + DIR_RL = self:_DirToken(DirRL or "ahead"), + BRG = tok.brg and string.format("%03d", tok.brg) or "", + COUNT = self:_CountToken(self._target_count), + TYPE = self:_TypeToken(self._target_type), + CHNL = tok.channel or 1, + } + + local text = self:_FillTemplate(template, vars) + + -- Throttle: same text repeated within TxRepeatInterval → suppress + local now = timer.getTime() + if text == self._last_tx.text and + (now - self._last_tx.time) < self.TxRepeatInterval then + self:_Log("[SRS/THROTTLED/" .. tok.key .. "] " .. text) + return + end + self._last_tx.text = text + self._last_tx.time = now + + self:_Log(string.format("[SRS/%s/%s] %s", self.Locale, tok.key, text)) + + local srstext = string.gsub(text,"%.",";") + + if self._srs_queue and self._msrs then + local delay = tok.delay or 3.0 + --MSRSQUEUE:NewTransmission(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate,speed,speaker,priority) + self._srs_queue:NewTransmission( + srstext, -- message text + nil, -- duration (auto) + self._msrs, -- MSRS instance + delay, -- start delay + 2, -- interval + {GROUP:FindByName(RedGCI.FIGHTER_GROUP)}, -- Subgroups (Subtitle) + text, -- subtitle + self.SubtitleTime,-- subtitle duration + nil, nil, -- channel/mod (from msrs) + nil, nil, nil, -- gender/culture/voice (from msrs) + nil, -- volume + "GCI", -- label + nil, -- coordinate + self.SRSSpeed, -- speed + nil, -- speaker + Priority -- priority + ) + else + -- Fallback: on-screen text + trigger.action.outText(text, self.SubtitleTime, false) + end + + if self.Debug then + trigger.action.outText(text, self.SubtitleTime, false) + end +end + +--- [INTERNAL] Dispatch a pilot acknowledgement transmission. +-- Uses the pilot's MSRS voice on the same frequency as GCI. +-- The ACK key is looked up in the Messages table under the pilot locale; +-- vars are filled identically to _Transmit so {HDG} etc. work. +-- The ACK fires after a short realistic reaction delay (GCI_delay + ~2s). +-- No-op when PilotCallsign is nil or pilot SRS is not initialised. +-- @param #REDGCI self +-- @param #string AckKey Message key, e.g. "ACK_VECTOR" +-- @param #table Vars Variable table (same format as _Transmit vars) +-- @param #number GciDelay Delay of the preceding GCI transmission (seconds) +function REDGCI:_TransmitPilot(AckKey, Vars, GciDelay) + if not self.IsAIPlane then return end + if not self.PilotCallsign then return end + if not self._pilot_queue or not self._pilot_msrs then return end + + local template = self._gettext:GetEntry(AckKey, self.Locale) + if not template then + self:_Log("_TransmitPilot: no template for key=" .. AckKey) + return + end + + -- Inject pilot callsign into vars + --local v = Vars or {} + --v.CALLSIGN = self.PilotCallsign + + --local text = self:_FillTemplate(template, v) + local text = self.PilotCallsign + -- Pilot speaks ~2-3 s after GCI finishes (GCI delay + estimated GCI speech + reaction) + local pilot_delay = (GciDelay or 3.0) + + self:_Log(string.format("[PILOT/%s/%s] %s", self.Locale, AckKey, text)) + --(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate,speed,speaker) + self._pilot_queue:NewTransmission( + text, + nil, -- duration + self._pilot_msrs, --msrs + pilot_delay, --tstrat + 1, --interval + nil, --{GROUP:FindByName(self.FighterGroupName)}, --subgroups + nil, --text, subtitle + nil, --self.SubtitleTime, subduration + nil, nil, --frequency, modulation, + nil, nil, self.PilotSRSVoice, --gender, culture, voice + nil, --volume + text -- label + ) + + if self.Debug then + trigger.action.outText("[PILOT] " .. text, self.SubtitleTime, false) + end +end + +--- [INTERNAL] Push a waypoint +-- @param #REDGCI self +-- @param #number wx DCS x coord (North) +-- @param #number wz DCS z coord (East) +-- @param #number wy altitude MSL metres +-- @param #number SpeedMps airspeed in m/s +-- @param #boolean LandHome true = set waypoint type to LAND at HomeBase +function REDGCI:_PushWaypoint(wx, wz, wy, SpeedMps, LandHome) + if not self.IsAIPlane then return end + + local grp = GROUP:FindByName(self.FighterGroupName) + if not grp then return end + + -- Terrain floor + 300 m minimum clearance + local terrain_floor = land.getHeight({ x=wx, y=wz }) + 300 + local safe_alt = math.max(wy, terrain_floor) + local kmph = UTILS.MpsToKmph(SpeedMps) + local speed_tas = UTILS.IasToTas(kmph,math.max(wy, safe_alt)) + + local tsk = grp:TaskAerobatics() + tsk = grp:TaskAerobaticsStraightFlight(tsk,1,math.max(wy, safe_alt),speed_tas,UseSmoke,StartImmediately,10) + + local startpoint = grp:GetCoordinate() + local wp0 = startpoint:WaypointAir( + COORDINATE.WaypointAltType.BARO, + COORDINATE.WaypointType.TurningPoint, + COORDINATE.WaypointAction.FlyoverPoint, + speed_tas, true, nil, {}, "VECTOR") + + local endpoint = COORDINATE:New(wx, safe_alt, wz) + local wp1 + if LandHome then + grp:SetOptionLandingOverheadBreak() + grp:SetOptionLandingForcePair() + local ab = self.HomeBaseName and AIRBASE:FindByName(self.HomeBaseName) or nil + wp1 = endpoint:WaypointAir( + COORDINATE.WaypointAltType.BARO, + COORDINATE.WaypointType.Land, + COORDINATE.WaypointAction.Landing, + speed_tas, true, ab, {}, "HOME") + else + wp1 = endpoint:WaypointAir( + COORDINATE.WaypointAltType.BARO, + COORDINATE.WaypointType.TurningPoint, + COORDINATE.WaypointAction.FlyoverPoint, + speed_tas, true, nil, tsk, "VECTOR") + end + + grp:Route({ wp0, wp1 }, 2) + self:_Log(string.format("WP → x=%.0f z=%.0f alt=%.0fm spd=%.0f kph TAS=%.0f kph", + wx, wz, safe_alt, kmph, speed_tas)) +end + +--- [INTERNAL] Resolve intercept target point, honouring any active tactic override. +-- When REDGCI2v2 has set _wp_override, that point is used instead of the +-- C-kernel intercept point for N ticks, then reverts to normal guidance. +-- @param #REDGCI self +-- @param #number ip_x Normal intercept x (DCS North, AltOffset already applied) +-- @param #number ip_z Normal intercept z (DCS East) +-- @param #number ip_y Normal intercept altitude +-- @return #number tx, tz, ty +function REDGCI:_ResolveTarget(ip_x, ip_z, ip_y) + local ov = self._wp_override + if ov and ov.ticks > 0 then + ov.ticks = ov.ticks - 1 + self:_Log(string.format( + "Override active (%d ticks left) → x=%.0f z=%.0f y=%.0f", + ov.ticks + 1, ov.x, ov.z, ov.y)) + if ov.ticks == 0 then + self._wp_override = nil + self:_Log("Override expired — resuming normal intercept") + end + return ov.x, ov.z, ov.y, ov.absolute_alt + end + return ip_x, ip_z, ip_y, false +end + +--- [INTERNAL] Clamp an intercept point to max_dist from the fighter. +-- Prevents the AI from overshooting on a distant waypoint. +-- @param #REDGCI self +-- @param #table Fighter unit data +-- @param #number ip_x intercept x (DCS North) +-- @param #number ip_z intercept z (DCS East) +-- @param #number ip_y intercept altitude +-- @return #number, #number, #number clamped wx, wz, wy +function REDGCI:_ComputeRollingWaypoint(Fighter, ip_x, ip_z, ip_y) + local dx = ip_x - Fighter.x + local dz = ip_z - Fighter.z + local dist = math.sqrt(dx*dx + dz*dz) + + local max_dist = math.max( + Fighter.spd * self.TickInterval * REDGCI.WP_DISTANCE_FACTOR, + 15000) -- minimum 15 km lookahead + + if dist <= max_dist or dist < 1 then + return ip_x, ip_z, ip_y + end + + local nx = dx / dist + local nz = dz / dist + return Fighter.x + nx * max_dist, + Fighter.z + nz * max_dist, + ip_y +end + +--- [INTERNAL] Toggle radar emission on the fighter group. +-- @param #REDGCI self +-- @param #boolean On +-- @param #number Delay +function REDGCI:_SetRadar(On,Delay) + if not self.IsAIPlane then return end + if Delay then + self:ScheduleOnce(Delay,REDGCI._SetRadar,self,On) + return + end + local grp = GROUP:FindByName(self.FighterGroupName) + if not grp then return end + if On == true then + grp:SetOptionRadarUsingForContinousSearch() + --grp:OptionROEWeaponFree() + --grp:OptionAlarmStateRed() + --grp:OptionAAAttackRange(1) + grp:OptionECM_DetectedLockByRadar() + grp:SetOptionJettisonEmptyTanks(true) + else + grp:SetOptionRadarUsingNever() + --grp:OptionROEHoldFire() + --grp:OptionAlarmStateAuto() + --grp:OptionAAAttackRange(3) + grp:OptionECM_Never() + end + self:_Log("Radar " .. (On and "ON" or "OFF")) +end + +--- [INTERNAL] Toggle weapons free on the fighter group. +-- @param #REDGCI self +-- @param #boolean On +-- @param #number Delay Delay in seconds +function REDGCI:_SetWeaponsFree(On,Delay) + if not self.IsAIPlane then return end + if Delay then + self:ScheduleOnce(Delay,REDGCI._SetWeaponsFree,self,On) + return + end + -- Externer Gate-Check — REDGCI2v2 kann das überschreiben + if On and self._wf_gate and not self._wf_gate(self) then + self:_Log("WF geblockt — Gate nicht offen") + return + end + local grp = GROUP:FindByName(self.FighterGroupName) + if not grp then return end + if On == true then + --grp:SetOptionRadarUsingForContinousSearch() + grp:OptionROEWeaponFree() + grp:OptionAlarmStateRed() + grp:OptionAAAttackRange(self._missilerangeflag) + grp:OptionECM_DetectedLockByRadar() + grp:SetOptionJettisonEmptyTanks(true) + else + --grp:SetOptionRadarUsingNever() + grp:OptionROEHoldFire() + grp:OptionAlarmStateAuto() + grp:OptionAAAttackRange(3) + grp:OptionECM_Never() + end + self:_Log("Weapons " .. (On and "ON" or "OFF")) +end + +--- [INTERNAL] Register F10 coalition menu entries. +-- @param #REDGCI self +function REDGCI:_SetupF10Menu() + local root = missionCommands.addSubMenuForCoalition(self.Coalition, "GCI") + + missionCommands.addCommandForCoalition(self.Coalition, "Radar Lock", root, + function() + self._pilot_flags.radar = true + self._pilot_flags.visual = false + self:_Log("Pilot: Radar Lock") + end) + + missionCommands.addCommandForCoalition(self.Coalition, "Visual Contact", root, + function() + self._pilot_flags.visual = true + self:_Log("Pilot: Visual Contact") + end) + + missionCommands.addCommandForCoalition(self.Coalition, "Threat (RWR)", root, + function() + self._pilot_flags.threat = true + self:_Log("Pilot: Threat") + end) + + missionCommands.addCommandForCoalition(self.Coalition, "Splash / Kill", root, + function() + self:_Transmit("MERGE_SPLASH|delay=1.5", nil, nil) + self:Reset() + self:_Log("Splash — Reset") + end) + + missionCommands.addCommandForCoalition(self.Coalition, "Reset GCI", root, + function() + self:Reset() + self:_Log("GCI Reset via F10 menu") + end) + + missionCommands.addCommandForCoalition(self.Coalition, "Toggle AI Mode", root, + function() + self.IsAIPlane = not self.IsAIPlane + local status = self.IsAIPlane and "ON" or "OFF" + trigger.action.outTextForCoalition( + self.Coalition, "[GCI] AI mode " .. status, 3) + self:_Log("AI mode: " .. status) + end) +end + +--- +-- ══════════════════════════════════════════════════════════════════ +-- Part 2 — REDGCI Intel source integration +-- +-- When an INTEL source is attached, REDGCI derives the intercept target +-- from the INTEL contact table instead of polling a fixed group name. +-- Selection criterion: highest threat-level aircraft contact. +-- Tie-break: closest to the fighter. +-- +-- The INTEL contact's position/velocity are used directly, so the GCI +-- works even after the real unit is lost from DCS sensor view (INTEL +-- keeps a prediction window of up to 10 min for aircraft). +-- ══════════════════════════════════════════════════════════════════ + +--- Attach an INTEL object as the target source for this GCI instance. +-- When set, REDGCI picks the highest-threat aircraft contact from INTEL +-- on every tick instead of polling a fixed target group name. +-- The GCI continues to work with INTEL's predicted positions during +-- contact gaps (up to INTEL's configured forget window). +-- @param #REDGCI self +-- @param Ops.Intel#INTEL Intel INTEL instance (must be Started/Running). +-- @param #string Filter (optional) Only consider contacts whose group name +-- contains this substring (case-sensitive). +-- @return #REDGCI self +function REDGCI:SetIntelSource(Intel, Filter) + self.Intel = Intel + self.IntelTargetFilter = Filter + self:T(self.lid .. "Intel source set: " .. + (Intel and Intel.alias or "nil") .. + (Filter and (" filter='" .. Filter .. "'") or "")) + return self +end + +--- [INTERNAL] Derive intercept target data from the attached INTEL. +-- Returns a unit-data table identical in structure to _GetUnitData(), +-- or nil when no suitable contact is available. +-- @param #REDGCI self +-- @param #table FighterData Fighter unit data (for proximity tie-break). +-- @return #table or nil +function REDGCI:_GetTargetFromIntel(FighterData) + if not self.Intel or not self.Intel:Is("Running") then return nil end + + local contacts = self.Intel:GetContactTable() + if not contacts then return nil end + + local best = nil + local bestScore = -math.huge + + for _, contact in pairs(contacts) do --#INTEL.Contact + + -- Aircraft contacts only + if contact.ctype == INTEL.Ctype.AIRCRAFT then + + -- Optional name filter + if self.IntelTargetFilter and + not string.find(contact.groupname, self.IntelTargetFilter, 1, true) then + -- goto continue + end + + if not contact.position then end --goto continue end + + local pos = contact.position + local dx = pos.x - FighterData.x + local dz = pos.z - FighterData.z + local rng = math.sqrt(dx * dx + dz * dz) + + -- Score: threat level primary; range as tie-break (closer = higher) + local score = (contact.threatlevel or 0) * 100000 - rng + + if score > bestScore then + bestScore = score + best = contact + end + end + end + + if not best then return nil end + + local pos = best.position + local vel = best.velocity or { x = 0, y = 0, z = 0 } + local alt = best.altitude or (pos and pos.y) or 0 + + return { + x = pos.x, + y = alt, + z = pos.z, + spd = best.speed or 0, + vx = vel.x or 0, + vy = vel.y or 0, + vz = vel.z or 0, + hdg = best.heading or 0, + fuel = 1.0, -- not available from INTEL + } +end + +-- ───────────────────────────────────────────────────────────── +-- FSM handlers +-- ───────────────────────────────────────────────────────────── + + +-- ───────────────────────────────────────────────────────────── +-- FSM state machine (pure Lua, replaces RedGCI.fsmUpdate) +-- ───────────────────────────────────────────────────────────── + +--- [INTERNAL] Compute next FSM state from range/aspect/pilot flags. +-- Mirrors gci_fsm_transition() from intercept_fsm.c. +-- @param #REDGCI self +-- @param #string current Current state string +-- @param #number range Distance to target (m) +-- @param #number aspect Aspect angle (deg) +-- @param #boolean has_radar Pilot has radar lock +-- @param #boolean has_visual Pilot has visual +-- @param #boolean threat RWR threat +-- @param #number fuel Fuel fraction 0-1 +-- @return #string next state +function REDGCI:_FsmTransition(current, range, aspect, has_radar, has_visual, threat, fuel) + local C = REDGCI_KERNEL.C + + -- Abort always wins + if (fuel or 1.0) < C.FUEL_BINGO then return "ABORT" end + if threat and range > C.RANGE_VISUAL then return "ABORT" end + + if current == "VECTOR" then + if range <= C.RANGE_COMMIT then return "COMMIT" end + if aspect > C.ASPECT_NOTCH_MIN and + aspect < C.ASPECT_NOTCH_MAX then return "NOTCH" end + return "VECTOR" + + elseif current == "COMMIT" then + if has_radar then return "RADAR_CONTACT" end + if range <= C.RANGE_VISUAL and not has_radar then return "VISUAL" end + return "COMMIT" + + elseif current == "RADAR_CONTACT" then + if not has_radar then return "COMMIT" end + if has_visual or range <= C.RANGE_VISUAL then return "VISUAL" end + return "RADAR_CONTACT" + + elseif current == "VISUAL" then + if range <= C.RANGE_MERGE then return "MERGE" end + return "VISUAL" + + elseif current == "NOTCH" then + if aspect < C.ASPECT_NOTCH_MIN or + aspect > C.ASPECT_NOTCH_MAX then return "VECTOR" end + return "NOTCH" + + else + -- MERGE / ABORT / RTB are terminal — handled elsewhere + return current + end +end + +--- [INTERNAL] Called when Start event fires (Stopped → Running). +-- Initializes localization, SRS, context, F10 menu, and schedules first tick. +-- @param #REDGCI self +function REDGCI:onafterStart(From, Event, To) + self:T(self.lid .. "Starting...") + + self:_InitLocalization() + self:_InitSRS() + self:_SetRadar(false) + self:_SetWeaponsFree(false) + + RedGCI.getCtxId(self.Callsign) + if self.Debug == true and self.Callsign == "101" then + self:_SetupF10Menu() + end + + local mode_str = self.IsAIPlane and " [AI]" or " [Human]" + trigger.action.outTextForCoalition( + self.Coalition, + "[GCI] Системы готовы. Жду цель." .. mode_str, 5) + + self:T(self.lid .. "Ready. Fighter='" .. self.FighterGroupName .. + "' Target='" .. self.TargetGroupName .. + "' Mode=" .. (self.IsAIPlane and "AI" or "Human")) + + local function GetChannel(Freq) + for key,val in pairs(self.RadioChannels) do + if tonumber(val)==tonumber(Freq) then + return key + end + end + return 1 + end + + local channel = 1 + if self.RadioChannels then channel = GetChannel(self.SRSFreq) end + + self:_Transmit("RADIO_SWITCH_CHANNEL|channel="..channel,nil,nil,100) + + -- Schedule first tick after short delay + self:__Status(-2) +end + +--- [INTERNAL] Main tick — called every TickInterval seconds while Running. +-- @param #REDGCI self +function REDGCI:onafterStatus(From, Event, To) + local f = self:_GetUnitData(self.FighterGroupName) + local t = self.Intel and self:_GetTargetFromIntel(f) + or self:_GetUnitData(self.TargetGroupName) + + if not f then + self:T(self.lid .. "Fighter '" .. self.FighterGroupName .. "' not found — stopping.") + self:Stop() + return + end + + if not t then + self._contact_lost_ticks = self._contact_lost_ticks + 1 + self:T(self.lid .. "Contact lost — tick " .. self._contact_lost_ticks .. + "/" .. self.ContactLostTimeout) + + if self._contact_lost_ticks == 1 then + -- First missing tick: tell pilot to stand by, keep last heading + self:_Transmit("NOTCH_ENTRY|delay=1.5", nil, nil) + elseif self._contact_lost_ticks >= self.ContactLostTimeout then + -- Timeout expired: target is genuinely gone (destroyed or escaped) + self:T(self.lid .. "Contact timeout — declaring target gone.") + if self._prev_radar then + self:_SetRadar(false) + end + self:_Transmit("MERGE_SPLASH|delay=1.5", nil, nil) + if self.IsAIPlane and self.HomeBase then + self:_PushWaypoint( + self.HomeBase.x, self.HomeBase.y, + 1000, f.spd * 0.8, true) + end + self:Stop() + return + end + -- Ticks 2…(timeout-1): stay silent, hold course + self:__Status(-self.TickInterval) + return + end + + -- Contact (re-)acquired — reset counter + self._contact_lost_ticks = 0 + + -- Picture call aktualisieren (INTEL → count/type, sonst manuell gesetzt) + self:_UpdatePictureFromIntel() + + -- ── 1. Intercept geometry (C kernel) ────────────────────── + local hdg, tti, mode, wf, range, aspect, ip_x, ip_z, ip_y = REDGCI_KERNEL.computeInterceptDCS(f, t) + --RedGCI.computeIntercept(f, t) + --UTILS.PrintTableToLog({hdg, tti, mode, wf, range, aspect, ip_x, ip_z, ip_y},indent,noprint,maxDepth,seen) + + if self.Debug then + env.info(string.format( + self.lid .. "hdg=%d tti=%d mode=%s wf=%s range=%d aspect=%d ip_x=%d ip_z=%d ip_y=%d", + hdg, tti, mode, tostring(wf), range, aspect, ip_x, ip_z, ip_y)) + end + + if mode == "NONE" then + self:_Log("No intercept solution (fighter too slow?)") + self:__Status(-self.TickInterval) + return + end + + -- ── 2. Closure rate ─────────────────────────────────────── + local dx = t.x - f.x + local dz = t.z - f.z + local dist = math.sqrt(dx*dx + dz*dz) + local closure = 0 + if dist > 1 then + closure = -((t.vx - f.vx) * dx/dist + (t.vz - f.vz) * dz/dist) + end + + -- ── 3. AI radar lock at COMMIT range ───────────────────── + local ai_radar_lock = self.IsAIPlane and (range < 30000) + + -- ── 4. FSM update (C kernel) ────────────────────────────── + local current_state = self._prev_state or "VECTOR" + local state = self:_FsmTransition( + current_state, range, aspect, + self._pilot_flags.radar or ai_radar_lock, + self._pilot_flags.visual, + self._pilot_flags.threat, + f.fuel) + + -- Track ticks_in_state + if state ~= current_state then + self._ticks_in_state = 0 + else + self._ticks_in_state = self._ticks_in_state + 1 + end + + -- ── 5. State-transition side effects ───────────────────── + if state ~= self._prev_state then + self:T(self.lid .. "State: " .. (self._prev_state or "START") .. " → " .. state) + + if state == "COMMIT" then + self:_Transmit("RADAR_ON|delay=1.5", nil, nil,75) + self:_TransmitPilot("ACK_COMMIT", {}, 1.5) + elseif state == "ABORT" or state == "RTB" then + self:_SetRadar(false) + self._prev_radar = false + if self.HomeBase then + self:_PushWaypoint( + self.HomeBase.x, self.HomeBase.y, + 1000, f.spd * 0.8, true) + end + -- Pilot ACK for abort includes RTB heading + local rtb_hdg = "" + if self.HomeBase then + local dx_h = self.HomeBase.x - f.x + local dz_h = self.HomeBase.y - f.z + rtb_hdg = string.format("%03d", math.floor(math.deg(math.atan2(dz_h, dx_h)) % 360)) + end + self:_TransmitPilot("ACK_ABORT", { HDG = rtb_hdg }, 3.0) + end + end + + -- ── 6. Waypoint + radar per state (every tick) ──────────── + if state == "VECTOR" then + self:_SetRadar(false) + local cruise_spd = math.max(f.spd, 200) + local tx, tz, ty, abs_alt = self:_ResolveTarget(ip_x, ip_z, ip_y + self.AltOffset) + local final_ty = abs_alt and ty or (ty + self.AltOffset) + local wx, wz, wy = self:_ComputeRollingWaypoint(f, tx, tz, final_ty) + self:_PushWaypoint(wx, wz, wy, cruise_spd) + + elseif state == "COMMIT" or state == "RADAR_CONTACT" then + if self._prev_radar == false then + self:_SetRadar(true,2) + self:_SetWeaponsFree(true,2) + self._prev_radar = true + end + local tx, tz, ty, abs_alt = self:_ResolveTarget(ip_x, ip_z, ip_y + self.AltOffset) + local final_ty = abs_alt and ty or (ty + self.AltOffset) + local wx, wz, wy = self:_ComputeRollingWaypoint(f, tx, tz, final_ty) + self:_PushWaypoint(wx, wz, wy, f.spd) + + elseif state == "NOTCH" then + if self._prev_radar == true then + self:_SetRadar(false) + self._prev_radar = false + end + end + + -- ── 7. Merge phase (Lua kernel) ─────────────────────────── + if state == "MERGE" then + local f_hdg = math.atan2(f.vx, f.vz) + local to_tgt = math.atan2(t.x - f.x, t.z - f.z) + local rel_brg = math.deg(to_tgt - f_hdg) % 360 + + -- Build merge context + local merge_ctx = { + phase = self._merge_phase, + ticks_in_phase = self._merge_ticks, + range = range, + bearing_to_target = rel_brg, + closure_rate = closure, + altitude_delta = t.y - f.y, + pass_count = self._merge_pass_count, + radar_lost = false, + } + local merge_prev = { phase = self._merge_phase_prev } + + -- Transition + local new_phase = REDGCI_KERNEL.mergeTransition(merge_ctx, merge_prev) + if new_phase ~= merge_ctx.phase then + self._merge_phase_prev = merge_ctx.phase + self._merge_phase = new_phase + self._merge_ticks = 0 + if new_phase == "SEPARATION" then + self._merge_pass_count = self._merge_pass_count + 1 + end + else + self._merge_ticks = self._merge_ticks + 1 + end + merge_ctx.phase = self._merge_phase + merge_ctx.ticks_in_phase = self._merge_ticks + + local tx_merge = REDGCI_KERNEL.buildMergeTransmission(merge_ctx, merge_prev) + + if not tx_merge.silence then + local dir_lr = self:_DeriveDirLR(aspect) + local dir_rl = self:_DeriveDirRL(f, t) + self:_Transmit(tx_merge.token_str, dir_lr, dir_rl,tx_merge.priority) + end + + self:_Log(string.format("[MERGE] range=%.0fm → %s", + range, tx_merge.silence and "SILENCE" or tx_merge.token_str)) + + self._prev_state = state + + if not self._prev_radar or not self._prev_wf then + self:_SetRadar(true, 2) + self:_SetWeaponsFree(true, 2) + self._prev_radar = true + self._prev_wf = true + end + + self:__Status(-self.TickInterval) + return + end + + -- ── 8. Build and send transmission ──────────────────────── + -- Effective WF: C kernel flag OR Lua-side override when AI has lock inside WEZ. + -- (C kernel currently always returns wf=false; remove override once fixed.) + local effective_wf = wf or ( + self.IsAIPlane and + self.WFRange > 0 and + state == "RADAR_CONTACT" and + range <= self.WFRange) + + -- ctx/prev for buildTransmission + local ctx = { + state = state, + range = range, + aspect_angle = aspect, + closure_rate = closure, + ticks_in_state = self._ticks_in_state, + fuel_fraction = f.fuel, + } + local prev_ctx = { state = self._prev_state or "VECTOR" } + + -- sol: VECTOR reports ip_y (intercept geometry), others report t.y (real target alt) + local report_alt + if state == "VECTOR" then + local ov = self._wp_override + if ov and ov.ticks > 0 then + report_alt = ov.y -- taktische Override-Höhe direkt + else + report_alt = ip_y + self.AltOffset + end + else + report_alt = t.y + end + local sol = { + heading_deg = hdg, + time_to_intercept = tti, + target_alt = report_alt, + range = range, + aspect_angle = aspect, + weapons_free = effective_wf, + } + + local tx = REDGCI_KERNEL.buildTransmission(ctx, prev_ctx, sol) + + if not tx.silence then + local dir_lr = self:_DeriveDirLR(aspect) + local dir_rl = self:_DeriveDirRL(f, t) + self:_Transmit(tx.token_str, dir_lr, dir_rl,tx.priority) + end + + -- Weapons free edge: fire once on first transition + if tx.weapons_free and not self._prev_wf then + if not self._prev_radar == true then + self:_Transmit("RADAR_ON|delay=1.5", nil, nil) + end + self:_Transmit("WEAPONS_FREE|delay=1.5", nil, nil,75) + self:_TransmitPilot("ACK_WEAPONS_FREE", {}, 1.0,100) + self:_SetRadar(true, 2) + self:_SetWeaponsFree(true, 2) + self._prev_radar = true + self._prev_wf = true + end + self._prev_wf = tx.weapons_free or false + + self:_Log(string.format( + "[%s] HDG:%d TTI:%ds MODE:%d WF:%s RANGE:%.0fm ASPECT:%.1f° → %s", + state, math.floor(hdg), math.floor(tti or 0), mode, + tostring(tx.weapons_free), range, aspect, + tx.silence and "SILENCE" or tostring(tx.token_str))) + + self._prev_state = state + self:__Status(-self.TickInterval) +end + +--- [INTERNAL] Called when Stop event fires. +-- @param #REDGCI self +function REDGCI:onafterStop(From, Event, To) + self:T(self.lid .. "Stopped.") +end + +------------------------------------------------------------------------------- +-- END of Class +-------------------------------------------------------------------------------