Files
MOOSE/Moose Development/Moose/Functional/REDGCI.lua
T
Applevangelist fddac77c79 xx
2026-04-04 17:17:04 +02:00

1712 lines
73 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- **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 1015 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 813km 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 |
-- | | (300600m 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. Pair, 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
-- ─────────────────────────────────────────────────────────────
-- 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.",
-- 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}, BEDROHUNG. 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.",
-- 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}, цель разрешена.",
-- 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="pair", group="group", biggroup="big group" },
de = { single="einzeln", pair="Paar", 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
--- 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) -- Sound.MSRS#MSRS
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
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._msrs:SetSpeakerPiper(self.PilotSRSSpeaker)
end
--self._pilot_queue = MSRSQUEUE:New("REDGCI_PILOT_" .. self.PilotCallsign)
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),
}
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"))
-- 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 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
-------------------------------------------------------------------------------