{$mode objfpc}{$H+}
unit uFormationMath;

// CC 20260924 pure ports of the GE's formation math (no LCL, no TGlobe). Ported
//  from C:\Prog\Projects\HC\libge\src\formedit.c and Gci.c; keep behaviour
//  identical to the GE so MapWindow and the GE agree on every station, ring and
//  sector. See plans/Plan-FormationEdit-20260924.md.

interface

uses
  uHCDefines;

const
  // CC 20260924 GE Defines.h: NMD10 = 621 DU per 0.1 NM
  NMD10 = 621;
  DU_PER_NM = NMD10 * 10;

  // CC 20260924 GE Defines.h: formation ring size ceiling
  MAX_FORMATION_RADIUS = 1024;

  // CC 20260924 GE Combat.h: AIR_PATROL_EVENT PatrolType
  ASuW_PATROL = 1;
  ASW_PATROL  = 2;
  AAW_PATROL  = 3;
  AEW_PATROL  = 4;
  EW_PATROL   = 5;

  // CC 20260924 GE Combat.h: AIR_PATROL_EVENT Status
  PATROL_START     = 0;
  PATROL_READY     = 1;
  PATROL_LAUNCH    = 2;
  PATROL_TRANSIT   = 3;
  PATROL_RETURN    = 4;
  PATROL_ONSTATION = 5;
  PATROL_DELETE    = -6;   // GE stores this in Number, not Status

type
  // CC 20260924 all 64 THCGroup.zones slots (decision 7: extended rings); the
  //  GE editor uses only the first UI_FORMATION_ZONES
  TFormationZones = array[0..MAX_FORMATION_ZONES-1] of SmallInt;

  // CC 20260924 what a Formation Editor row / globe marker refers to; the UUID
  //  that goes with it is TUnitSnapshot.UUID, TPatrolSnapshot.EventUUID or
  //  TPlaneAssetSnapshot.UUID respectively
  TFormationItemKind = (fikNone, fikUnit, fikPatrol, fikAsset);

  // CC 20260924 one drawable piece of a patrol region: a band between two radii
  //  spanning one contiguous arc of sectors, clockwise from StartDeg to EndDeg
  TRegionCell = record
    InnerNM, OuterNM: Integer;
    StartDeg, EndDeg: Integer;   // compass degrees; EndDeg may exceed 360 when the arc wraps north
  end;
  TRegionCells = array of TRegionCell;

// --- offsets <-> positions (Gci.c PositionOffsetEx / GetPositionOffsets) ---

// station position of a unit whose group is at (groupSLat, groupSLon)
procedure FormationOffsetToLatLon(groupSLat, groupSLon, xOff, yOff: LongInt;
  out sLat, sLon: LongInt);

// offset (DU) of point (tLat, tLon) from the group centre
procedure LatLonToFormationOffset(groupSLat, groupSLon, tLat, tLon: LongInt;
  out xOff, yOff: LongInt);

// --- rings and sectors (formedit.c FindShipRingSector) ---

// CC 20260924 extended rings (decision 7): a ring is enabled when it is part of
//  the unbroken run from Main Body whose zones are > 0 and strictly increasing
//  (the GE builds every band from zones[i-1]..zones[i]); the first zero or
//  non-increasing zone ends the run. 0 when zones[0] <= 0, at most 64.
function EnabledRingCount(const zones: TFormationZones): Integer;
// bits 0..EnabledRingCount-1 (all 64 bits when every ring is enabled)
function EnabledRingsMask(const zones: TFormationZones): UInt64;
// what FindRingSector returns for a station beyond the last enabled ring:
//  1 shl EnabledRingCount (1 shl 6 = $40 for a standard 6-ring group). When all
//  64 rings are enabled there is no bit left and this is 0 (FindRingSector then
//  returns rings = 0 for such a station).
function OutsideRingFlag(const zones: TFormationZones): UInt64;
// 0..5 'Main Body', 'AAW', 'ASW', 'Picket 1', 'Picket 2', 'Picket 3';
//  6..63 'Ring 7'..'Ring 64'
function RingName(index: Integer): string;

// ring and sector bitmasks for a station offset, given the group's zone sizes
procedure FindRingSector(xOff, yOff: LongInt; const zones: TFormationZones;
  out rings, sectors: UInt64);

// compass bearing (0..359) and range (NM) of an offset from the group centre
function OffsetBearingDeg(xOff, yOff: LongInt): Integer;
function OffsetRangeNM(xOff, yOff: LongInt): Double;

// index 0..63 of the innermost ring set in a bitmask, -1 if none
function LowestRingIndex(rings: UInt64): Integer;

// region cells to draw for a rings/sectors pair: one cell per set ring (0..63)
//  and contiguous sector arc; a Main Body ring (or sectors = 0) is a full
//  circle; a ring whose zones[k] <= zones[k-1] is not drawable and is skipped
function PatrolRegionCells(rings, sectors: UInt64;
  const zones: TFormationZones): TRegionCells;

// --- GE integer trig (Trig.c / Gci.c), exposed for tests and callers that
//  must agree with the GE to the sliver ---

// Trig.c icos: cos(angle slivers) scaled by 32768
function GEIcos(angle: LongInt): LongInt;
// Gci.c iatanl: angle in slivers (0..SLIVER360) of the vector whose x side is
//  x and y side is y, measured from x toward y; iatanl(-yOff, xOff) is a compass
//  bearing
function GEIatanl(x, y: LongInt): LongInt;

// --- display text ---

function FormationStateText(independent: Byte): string;   // FM_* -> 'Slaved', 'Reforming', ...
function PatrolTypeText(patrolType: ShortInt): string;     // 1..5 -> 'ASuW', 'ASW', 'AAW', 'AEW', 'EW'
function PatrolStatusText(status: ShortInt): string;       // PATROL_* -> 'Waiting', 'Launching', ...

// --- phase 2: editing ring sizes ---

// CC 20260924 clamp ring sizes the way the GE's Change Zone Sizes dialog does
//  (formedit.c Dialog04Dlg), generalised from its 6 rings to aRingCount rings:
//  ring i is at most MAX_FORMATION_RADIUS - (aRingCount - 1 - i) so every outer
//  ring still fits, at least i + 1 NM, and each ring at least 1 NM larger than the
//  one inside it. Slots from aRingCount up are cleared (not rings). For
//  aRingCount = 6 the result equals the GE's.
procedure ClampFormationZones(var zones: TFormationZones; aRingCount: Integer);

// CC 20260924 where a station moves when the rings resize: its offset is scaled
//  by the change in its own ring's band, as the GE's formedit.c
//  ResetFormationPositions does, so the unit keeps its ring and sector. Main Body
//  scales by zones[0]; ring k by zones[k-1] + zones[k]; a station beyond the last
//  enabled ring by the outermost band. The GE picks the band with (ring >> 2),
//  which is wrong for Picket 2 (see the plan's GE findings); this uses the ring's
//  index. Offsets are unchanged when either band is zero.
procedure ScaleStationForZones(xOff, yOff: LongInt;
  const oldZones, newZones: TFormationZones; out newXOff, newYOff: LongInt);

// CC 20260924 phase 4: default patrol type for a loadout's Y1 MissionID, as the
//  GE's formedit.c HandleDialog27 intends (ATA -> AAW, ASW -> ASW, strike
//  missions -> ASuW, anything else AEW). The GE's copy never sets its mission
//  variable, so in practice it always lands on AEW (or EW for an EW airframe).
function DefaultPatrolTypeForMission(aMissionID: Integer): ShortInt;

// CC 20260924 cells to OUTLINE an area: like PatrolRegionCells, but consecutive
//  rings (other than Main Body) merge into one band, so only the outer border of
//  a multi-ring area is drawn, not the lines between its rings
function PatrolOutlineCells(rings, sectors: UInt64;
  const zones: TFormationZones): TRegionCells;

// CC 20260924 a random point inside an area, as the GE's Effect2.c
//  FindRandomPatrol picks one for a patrol aircraft: a set ring, a set sector, a
//  random radius within the ring's band and bearing within the sector (Main Body:
//  the centre). Unlike the GE it stays a little inside the band and sector edges,
//  so the point never rounds into a neighbouring cell. Returns False (0, 0) when
//  the area has no drawable ring.
function RandomPointInArea(rings, sectors: UInt64; const zones: TFormationZones;
  out xOff, yOff: LongInt): Boolean;

// CC 20260924 Shift+click area extension (formedit.c MoveAndSetPosition with
//  Shift, and AdjustSector). aClickRings/aClickSectors are the cell that was
//  clicked (FindRingSector). A Main Body click, or a click outside the enabled
//  rings (aClickRings = 0), changes nothing.
//  - rings: added to the set. Extending from a Main Body area fills every ring
//    from AAW out to the clicked one; Main Body is never part of the result.
//    (The GE fills with 2 << ring from the clicked ring's index, which adds one
//    ring beyond the clicked one - see the plan's GE findings; this fills to the
//    clicked ring, as its comment intends.)
//  - sectors: added, then the gap is filled on the shorter side so the area stays
//    one contiguous arc (AdjustSector, ported as-is).
procedure ExtendPatrolArea(var aRings, aSectors: UInt64;
  aClickRings, aClickSectors: UInt64);

// CC 20260924 is a position (offset from the group centre) inside a rings/sectors
//  area? Same idea as the GE's effect4.c OutsidePatrolArea (ring by range, then
//  the sector bit), over the enabled rings. Main Body, or sectors = 0, counts as
//  the whole ring, matching PatrolRegionCells.
function InPatrolArea(xOff, yOff: LongInt; rings, sectors: UInt64;
  const zones: TFormationZones): Boolean;

// CC 20260924 what a patrol is actually doing. HC's own status names are set a
//  step early (Effects.c ProcessAirPatrolEvent): TRANSIT is set when the launch is
//  ordered and ONSTATION as soon as the aircraft is airborne, while it is still
//  flying to its area; READY and LAUNCH are never set. aHasAircraft/aInArea come
//  from the patrol's airborne aircraft.
function PatrolStateText(status: ShortInt; number: Byte;
  aHasAircraft, aInArea: Boolean): string;
function RingsText(rings: UInt64): string;                 // 'Main Body', 'AAW, ASW', ...
function SectorsText(sectors: UInt64): string;             // '000-045, 045-090' or 'All' or '-'

implementation

uses
  SysUtils;

const
  // CC 20260924 GE Intmath.h / Trig.c table geometry
  SINVALS = 2049;         // SinTableLong: sin over 0..90 deg, scaled by 2^24
  TANVALS = 1025;         // TanTableLong: tan over 0..45 deg, scaled by 2^24
  SLIVERSHIFT = 14;       // table index = angle shr SLIVERSHIFT
  SLIVER_SLICE = 16384;   // slivers per table step

var
  // CC 20260924 the GE loads these from its resource file (Setup.c RES1_SIN /
  //  RES1_TAN); rebuilt here from their definition, f(i * 90/2048 deg) * 2^24
  SinTableLong: array[0..SINVALS-1] of LongInt;
  TanTableLong: array[0..TANVALS-1] of LongInt;

procedure InitTables;
var
  i: Integer;
begin
  for i := 0 to SINVALS - 1 do
    SinTableLong[i] := Round(Sin(i * Pi / 4096) * 16777216.0);
  for i := 0 to TANVALS - 1 do
    TanTableLong[i] := Round(Sin(i * Pi / 4096) / Cos(i * Pi / 4096) * 16777216.0);
end;

// CC 20260924 port of Trig.c isinl: table lookup with linear interpolation
function GEIsinl(angle: LongInt): LongInt;
var
  i, value: LongInt;
  negative: Boolean;
begin
  while angle < 0 do Inc(angle, SLIVER360);
  while angle >= SLIVER360 do Dec(angle, SLIVER360);

  if angle > SLIVER180 then
  begin
    negative := True;
    if angle > SLIVER270 then
      angle := SLIVER360 - angle
    else
      angle := angle - SLIVER180;
  end
  else
  begin
    negative := False;
    if angle > SLIVER90 then
      angle := SLIVER180 - angle;
  end;

  i := angle shr SLIVERSHIFT;
  if i > 2047 then
    i := 2047;
  value := SinTableLong[i];
  value := value + LongInt(SarInt64((Int64(SinTableLong[i+1]) - value) *
    (angle and (SLIVER_SLICE - 1)), SLIVERSHIFT));

  if negative then
    Result := -value
  else
    Result := value;
end;

// CC 20260924 port of Trig.c icos (isinl >> 9 gives the 32768 scale)
function GEIcos(angle: LongInt): LongInt;
begin
  Result := SarLongint(GEIsinl(angle + SLIVER90), 9);
end;

// CC 20260924 port of Trig.c looklong: binary search of a monotonic table,
//  returning the interpolated index in slivers. Callers here only pass values
//  in table range (0..2^24), so the C unsigned casts never wrap.
function GELookLong(x: LongInt; const table: array of LongInt): LongInt;
var
  top, bot, mid: LongInt;
begin
  top := System.High(table);
  bot := 0;
  mid := top shr 1;
  repeat
    if table[mid] < x then
    begin
      bot := mid;
      if table[mid+1] > x then top := mid + 1;
    end
    else if table[mid] > x then
    begin
      top := mid;
      if table[mid-1] < x then bot := mid - 1;
    end
    else
      Exit(mid shl SLIVERSHIFT);   // exact match
    mid := (top + bot) shr 1;
  until (top - bot) <= 1;

  if table[top] = table[bot] then   // gw 10/96 div by zero guard
    Result := (bot shl SLIVERSHIFT) + ((x - table[bot]) shl SLIVERSHIFT)
  else
    Result := (bot shl SLIVERSHIFT) +
      LongInt(((Int64(x) - table[bot]) shl SLIVERSHIFT) div (Int64(table[top]) - table[bot]));
end;

// CC 20260924 port of Math.c LongSHL24DivLong (64-bit, C truncating division)
function LongSHL24DivLong(x, y: LongInt): LongInt;
begin
  Result := LongInt((Int64(x) shl 24) div y);
end;

// CC 20260924 port of Gci.c iatanl
function GEIatanl(x, y: LongInt): LongInt;
var
  angle: LongInt;
  xneg, yneg: Boolean;
begin
  xneg := x < 0;
  if xneg then x := -x;
  yneg := y < 0;
  if yneg then y := -y;

  while (x > 16777216) or (y > 16777216) do
  begin
    x := x shr 1;
    y := y shr 1;
  end;

  if y > x then
    angle := SLIVER90 - GELookLong(LongSHL24DivLong(x, y), TanTableLong)
  else if x = 0 then
    angle := 0
  else
    angle := GELookLong(LongSHL24DivLong(y, x), TanTableLong);

  if xneg then
  begin
    if yneg then
      Result := SLIVER180 + angle
    else
      Result := SLIVER180 - angle;
  end
  else
  begin
    if yneg then
      Result := SLIVER360 - angle
    else
      Result := angle;
  end;
end;

// CC 20260924 Gci.c: cos(latitude) * 32768 at pole-based sLat, 0 guarded to 1
function LatCos(sLat: LongInt): LongInt;
begin
  Result := GEIcos(SLIVER90 - sLat);
  if Result = 0 then
    Result := 1;
end;

// CC 20260924 port of Gci.c PositionOffsetEx. +yOff is south (sLat counts down
//  from the north pole); no longitude wrap, as in the GE.
procedure FormationOffsetToLatLon(groupSLat, groupSLon, xOff, yOff: LongInt;
  out sLat, sLon: LongInt);
begin
  sLat := groupSLat + yOff;
  sLon := groupSLon + LongInt((Int64(xOff) * 32768) div LatCos(groupSLat));
end;

// CC 20260924 port of Gci.c GetPositionOffsets
procedure LatLonToFormationOffset(groupSLat, groupSLon, tLat, tLon: LongInt;
  out xOff, yOff: LongInt);
begin
  yOff := tLat - groupSLat;
  xOff := LongInt(((Int64(tLon) - groupSLon) * LatCos(groupSLat)) div 32768);
end;

function EnabledRingCount(const zones: TFormationZones): Integer;
begin
  Result := 0;
  while (Result < MAX_FORMATION_ZONES) and (zones[Result] > 0) and
    ((Result = 0) or (zones[Result] > zones[Result-1])) do
    Inc(Result);
end;

function EnabledRingsMask(const zones: TFormationZones): UInt64;
var
  n: Integer;
begin
  n := EnabledRingCount(zones);
  if n >= 64 then
    Result := System.High(UInt64)   // 1 shl 64 would wrap to 1 shl 0 on x86
  else
    Result := (UInt64(1) shl n) - 1;
end;

function OutsideRingFlag(const zones: TFormationZones): UInt64;
var
  n: Integer;
begin
  n := EnabledRingCount(zones);
  if n >= 64 then
    Result := 0              // no bit left beyond ring 64
  else
    Result := UInt64(1) shl n;
end;

function RingName(index: Integer): string;
const
  GE_RING_NAMES: array[0..UI_FORMATION_ZONES-1] of string = (
    'Main Body', 'AAW', 'ASW', 'Picket 1', 'Picket 2', 'Picket 3');
begin
  if (index >= 0) and (index < UI_FORMATION_ZONES) then
    Result := GE_RING_NAMES[index]
  else
    Result := 'Ring ' + IntToStr(index + 1);
end;

// CC 20260924 port of formedit.c FindShipRingSector.
//  - Each axis is truncated (toward zero, C '/') to whole tenths of NM before
//    squaring, so the ring test is d^2 in (0.1 NM)^2 < zones[i]^2*100 and a
//    station exactly on a ring's radius belongs to the next ring out.
//  - The ring loop runs over the enabled rings (EnabledRingCount) instead of
//    the GE editor's UI_FORMATION_ZONES; for a standard 6-ring group that is
//    the same loop, so results are bit-for-bit the GE's.
//  - Beyond the last enabled ring the loop runs off the end, as in the GE
//    (which stores 1 << 6), giving rings = OutsideRingFlag(zones) plus a normal
//    sector bit. With all 64 rings enabled that flag is 0, so rings = 0; with
//    none enabled (zones[0] <= 0) it is bit 0, so every station is Main Body.
//  - The sector is bearing div 45 deg: a station exactly on a spoke belongs to
//    the sector clockwise of it (due north -> 000-045, due east -> 090-135).
//  - Only for offsets of thousands of NM can iatanl's scaling return SLIVER360,
//    which the GE turns into sector bit 1 << 8; reproduced, never seen in play.
//  The GE squares in 32-bit longs; Int64 here only differs past ~4600 NM.
procedure FindRingSector(xOff, yOff: LongInt; const zones: TFormationZones;
  out rings, sectors: UInt64);
var
  b: LongInt;
  x, y, d2: Int64;
  i, n: Integer;
begin
  if (xOff = 0) and (yOff = 0) then
  begin
    rings := MainBodyFlag;
    sectors := 0;
    Exit;
  end;

  b := GEIatanl(-yOff, xOff);

  x := xOff div NMD10;   // tenths of NM
  y := yOff div NMD10;
  d2 := x * x + y * y;

  n := EnabledRingCount(zones);
  i := 0;
  while i < n do
  begin
    if d2 < Int64(zones[i]) * zones[i] * 100 then
      Break;
    Inc(i);
  end;
  if i < n then
    rings := UInt64(1) shl i
  else
    rings := OutsideRingFlag(zones);

  sectors := UInt64(1) shl (b div SLIVER45);

  if rings = MainBodyFlag then
    sectors := 0;
end;

// CC 20260924 compass bearing via the GE's iatanl, so bearing div 45 always
//  agrees with FindRingSector's sector
function OffsetBearingDeg(xOff, yOff: LongInt): Integer;
begin
  if (xOff = 0) and (yOff = 0) then
    Exit(0);
  Result := Integer((Int64(GEIatanl(-yOff, xOff)) * 360) div SLIVER360) mod 360;
end;

function OffsetRangeNM(xOff, yOff: LongInt): Double;
begin
  Result := Sqrt(Sqr(Double(xOff)) + Sqr(Double(yOff))) / DU_PER_NM;
end;

function LowestRingIndex(rings: UInt64): Integer;
var
  k: Integer;
begin
  for k := 0 to MAX_FORMATION_ZONES - 1 do
    if (rings and (UInt64(1) shl k)) <> 0 then
      Exit(k);
  Result := -1;
end;

// CC 20260924 region cells for a rings/sectors pair. The GE's FramePatrolRegion
//  outlines one ring span and one arc (AdjustSector keeps its selection
//  contiguous); this generalises it so any mask draws correctly: one band per
//  set ring 0..63 (inner edge = the previous ring's radius, as Effect2.c
//  FindRandomPatrol bands them), split into maximal contiguous sector arcs that
//  may wrap north (315-000 + 000-045 -> 315..405). Main Body, no sectors or all
//  8 sectors -> one full circle for the ring. A ring whose outer radius is not
//  above its inner one (a disabled ring) is not drawable and is skipped.
function PatrolRegionCells(rings, sectors: UInt64;
  const zones: TFormationZones): TRegionCells;
var
  k, s, len, n, inner: Integer;
  sec: Byte;

  procedure AddCell(startDeg, endDeg: Integer);
  begin
    if n >= Length(Result) then
      SetLength(Result, n + 4);
    Result[n].InnerNM := inner;
    Result[n].OuterNM := zones[k];
    Result[n].StartDeg := startDeg;
    Result[n].EndDeg := endDeg;
    Inc(n);
  end;

  function SecSet(idx: Integer): Boolean;
  begin
    Result := (sec and (1 shl (idx and 7))) <> 0;
  end;

begin
  Result := nil;
  n := 0;
  sec := Byte(sectors and FormationSectorAll);
  for k := 0 to MAX_FORMATION_ZONES - 1 do
  begin
    if (rings and (UInt64(1) shl k)) = 0 then
      Continue;
    if k = 0 then
      inner := 0
    else
      inner := zones[k-1];
    if zones[k] <= inner then
      Continue;

    if (k = 0) or (sec = 0) or (sec = FormationSectorAll) then
      AddCell(0, 360)
    else
      for s := 0 to 7 do
        // an arc starts at a set sector whose anticlockwise neighbour is clear
        if SecSet(s) and not SecSet(s + 7) then
        begin
          len := 1;
          while SecSet(s + len) do
            Inc(len);
          AddCell(s * 45, (s + len) * 45);
        end;
  end;
  SetLength(Result, n);
end;

function FormationStateText(independent: Byte): string;
begin
  case independent of
    FM_SLAVED:           Result := 'Slaved';
    FM_REFORMING:        Result := 'Reforming';
    FM_PATROLLING:       Result := 'Patrolling';    // = FM_DRIFT_START
    FM_DRIFT_INSIDE:     Result := 'Drifting';
    FM_SPRINT_START,
    FM_SPRINT_INSIDE:    Result := 'Sprinting';
    FM_POSITION_REACHED: Result := 'On station';
    FM_INDEPENDENT:      Result := 'Independent';
    FM_RTB:              Result := 'Returning to base';
  else
    Result := 'State ' + IntToStr(independent);
  end;
end;

function PatrolTypeText(patrolType: ShortInt): string;
begin
  case patrolType of
    ASuW_PATROL: Result := 'ASuW';
    ASW_PATROL:  Result := 'ASW';
    AAW_PATROL:  Result := 'AAW';
    AEW_PATROL:  Result := 'AEW';
    EW_PATROL:   Result := 'EW';
  else
    Result := 'Type ' + IntToStr(patrolType);
  end;
end;

function PatrolStatusText(status: ShortInt): string;
begin
  case status of
    PATROL_START:     Result := 'Waiting to launch';
    PATROL_READY:     Result := 'Ready';
    PATROL_LAUNCH:    Result := 'Launching';
    PATROL_TRANSIT:   Result := 'In transit';
    PATROL_RETURN:    Result := 'Returning';
    PATROL_ONSTATION: Result := 'On station';
    PATROL_DELETE:    Result := 'Cancelled';
  else
    Result := 'Status ' + IntToStr(status);
  end;
end;

procedure ClampFormationZones(var zones: TFormationZones; aRingCount: Integer);
var
  i: Integer;
begin
  if aRingCount < 0 then aRingCount := 0;
  if aRingCount > MAX_FORMATION_ZONES then aRingCount := MAX_FORMATION_ZONES;
  // Dialog04Dlg order: ceiling, then floor from the outside in, then widen outward
  for i := 0 to aRingCount - 1 do
    if zones[i] > MAX_FORMATION_RADIUS - (aRingCount - 1 - i) then
      zones[i] := MAX_FORMATION_RADIUS - (aRingCount - 1 - i);
  for i := aRingCount - 1 downto 0 do
    if zones[i] < i + 1 then
      zones[i] := i + 1;
  for i := 0 to aRingCount - 2 do
    if zones[i] >= zones[i + 1] then
      zones[i + 1] := zones[i] + 1;
  for i := aRingCount to MAX_FORMATION_ZONES - 1 do
    zones[i] := 0;
end;

procedure ScaleStationForZones(xOff, yOff: LongInt;
  const oldZones, newZones: TFormationZones; out newXOff, newYOff: LongInt);
var
  lRings, lSectors: UInt64;
  k, n: Integer;
  lOld, lNew: Int64;
begin
  newXOff := xOff;
  newYOff := yOff;
  if (xOff = 0) and (yOff = 0) then Exit;
  n := EnabledRingCount(oldZones);
  if n = 0 then Exit;
  FindRingSector(xOff, yOff, oldZones, lRings, lSectors);
  k := LowestRingIndex(lRings);
  if (k < 0) or (k >= n) then
    k := n - 1;  // beyond the last enabled ring: outermost band
  if k = 0 then
  begin
    lOld := oldZones[0];
    lNew := newZones[0];
  end
  else
  begin
    lOld := Int64(oldZones[k - 1]) + oldZones[k];
    lNew := Int64(newZones[k - 1]) + newZones[k];
  end;
  if (lOld <= 0) or (lNew <= 0) then Exit;
  // GE LongXLongDivLong: 64-bit product, division truncating toward zero
  newXOff := LongInt((Int64(xOff) * lNew) div lOld);
  newYOff := LongInt((Int64(yOff) * lNew) div lOld);
end;

function DefaultPatrolTypeForMission(aMissionID: Integer): ShortInt;
begin
  if aMissionID = Ord(MISSION_ATA) then
    Result := AAW_PATROL
  else if aMissionID = Ord(MISSION_ASW) then
    Result := ASW_PATROL
  else if (aMissionID = Ord(MISSION_STANDOFF)) or (aMissionID = Ord(MISSION_SEAD)) or
          (aMissionID = Ord(MISSION_GUIDED)) or (aMissionID = Ord(MISSION_UNGUIDED)) or
          (aMissionID = Ord(MISSION_ANTIRUN)) then
    Result := ASuW_PATROL
  else
    Result := AEW_PATROL;
end;

function PatrolOutlineCells(rings, sectors: UInt64;
  const zones: TFormationZones): TRegionCells;
var
  k, j, i, n: Integer;
  lRun: TRegionCells;
begin
  Result := nil;
  n := 0;
  k := 0;
  while k < MAX_FORMATION_ZONES do
  begin
    if (rings and (UInt64(1) shl k)) = 0 then
    begin
      Inc(k);
      Continue;
    end;
    // Main Body stands alone (a full circle); other rings merge while consecutive
    j := k;
    if k > 0 then
      while (j + 1 < MAX_FORMATION_ZONES) and
            ((rings and (UInt64(1) shl (j + 1))) <> 0) and
            (zones[j + 1] > zones[j]) do
        Inc(j);
    lRun := PatrolRegionCells(UInt64(1) shl k, sectors, zones);
    for i := 0 to Length(lRun) - 1 do
    begin
      lRun[i].OuterNM := zones[j];
      SetLength(Result, n + 1);
      Result[n] := lRun[i];
      Inc(n);
    end;
    k := j + 1;
  end;
end;

function RandomPointInArea(rings, sectors: UInt64; const zones: TFormationZones;
  out xOff, yOff: LongInt): Boolean;
var
  lRings: array of Integer;
  lSectors: array of Integer;
  k, i, lMinR, lMaxR, lRadius, lDeg: Integer;
  lSliver: LongInt;
begin
  xOff := 0;
  yOff := 0;
  lRings := nil;
  for i := 0 to MAX_FORMATION_ZONES - 1 do
    if ((rings and (UInt64(1) shl i)) <> 0) and (zones[i] > 0) and
       ((i = 0) or (zones[i] > zones[i - 1])) then
    begin
      SetLength(lRings, Length(lRings) + 1);
      lRings[System.High(lRings)] := i;
    end;
  Result := Length(lRings) > 0;
  if not Result then Exit;
  k := lRings[Random(Length(lRings))];
  if k = 0 then Exit;  // Main Body: the centre, as in the GE

  lSectors := nil;
  if sectors = 0 then sectors := 1;  // GE: no sector means sector 0
  for i := 0 to 7 do
    if (sectors and (UInt64(1) shl i)) <> 0 then
    begin
      SetLength(lSectors, Length(lSectors) + 1);
      lSectors[System.High(lSectors)] := i;
    end;

  // radius in tenths of a NM, kept off the band edges. The inner margin is 0.2 NM
  //  because FindRingSector truncates each axis to tenths before squaring, which
  //  can put a point up to ~0.14 NM short of its true range
  lMinR := zones[k - 1] * 10;
  lMaxR := zones[k] * 10;
  if lMaxR - lMinR > 4 then
    lRadius := lMinR + 2 + Random(lMaxR - lMinR - 3)
  else
    lRadius := (lMinR + lMaxR + 1) div 2;
  // bearing kept off the sector edges
  lDeg := 45 * lSectors[Random(Length(lSectors))] + 1 + Random(43);
  lSliver := LongInt((Int64(lDeg) * SLIVER360) div 360);
  // Effect2.c: xoff = radius * isinl(b) >> 24, yoff = -(radius * icosl(b) >> 24)
  lRadius := lRadius * NMD10;
  xOff := LongInt(SarInt64(Int64(lRadius) * GEIsinl(lSliver), 24));
  yOff := -LongInt(SarInt64(Int64(lRadius) * GEIsinl(lSliver + SLIVER90), 24));
end;

// formedit.c AdjustSector: OR in the new sector(s), then fill the shorter gap
function AdjustSectors(aSectors, aNew: UInt64): UInt64;
var
  lSector: UInt64;
  cc, ccw, lCount: Integer;
  lSet, lLastSet: Boolean;
  lCcFill, lCcwFill, lSeed: UInt64;
begin
  Result := aSectors or aNew;
  lSector := Result;
  cc := 0; ccw := 0;
  lSet := False; lLastSet := False;
  lCcFill := 0; lCcwFill := 0;
  lSeed := 1;
  for lCount := 0 to 7 do
  begin
    if (lSector and 1) <> 0 then
    begin
      if not lLastSet then
      begin
        lSet := not lSet;
        lLastSet := True;
      end;
    end
    else
    begin
      lLastSet := False;
      if lSet then
      begin
        Inc(cc);
        lCcFill := lCcFill or lSeed;
      end
      else
      begin
        Inc(ccw);
        lCcwFill := lCcwFill or lSeed;
      end;
    end;
    lSector := lSector shr 1;
    lSeed := lSeed shl 1;
  end;
  if not lSet then
  begin
    if cc <= ccw then
      Result := Result or lCcFill
    else
      Result := Result or lCcwFill;
  end;
end;

procedure ExtendPatrolArea(var aRings, aSectors: UInt64;
  aClickRings, aClickSectors: UInt64);
var
  k, i: Integer;
begin
  if (aClickRings = 0) or (aClickRings = MainBodyFlag) then Exit;
  if (aRings and not MainBodyFlag) <> 0 then
    aRings := aRings or aClickRings
  else
  begin
    // from Main Body: every ring from AAW out to the clicked one
    k := LowestRingIndex(aClickRings);
    aRings := 0;
    for i := 1 to k do
      aRings := aRings or (UInt64(1) shl i);
  end;
  aRings := aRings and not MainBodyFlag;
  aSectors := AdjustSectors(aSectors, aClickSectors);
end;

function InPatrolArea(xOff, yOff: LongInt; rings, sectors: UInt64;
  const zones: TFormationZones): Boolean;
var
  lRing, lSector: UInt64;
begin
  FindRingSector(xOff, yOff, zones, lRing, lSector);
  Result := (lRing and rings) <> 0;
  if Result and (lRing <> MainBodyFlag) and (sectors <> 0) then
    Result := (lSector and sectors) <> 0;
end;

function PatrolStateText(status: ShortInt; number: Byte;
  aHasAircraft, aInArea: Boolean): string;
begin
  // the GE cancels by storing PATROL_DELETE in Number (formedit.c)
  if ShortInt(number) = PATROL_DELETE then
    Exit('Cancelled');
  case status of
    PATROL_START:
      Result := 'Waiting for aircraft';
    PATROL_READY, PATROL_LAUNCH:
      Result := 'Launching';
    // TRANSIT only turns into ONSTATION when the patrol event next runs (up to
    //  30 s), so once the aircraft is airborne treat the two alike
    PATROL_TRANSIT, PATROL_ONSTATION:
      if (status = PATROL_TRANSIT) and not aHasAircraft then
        Result := 'Launching'
      else if aHasAircraft and not aInArea then
        Result := 'In transit'  // CC 20260924 flying to its area
      else
        Result := 'On station';
    PATROL_RETURN:
      Result := 'In transit';   // CC 20260924 heading home (bingo, relief)
    PATROL_DELETE:
      Result := 'Cancelled';
  else
    Result := 'Status ' + IntToStr(status);
  end;
end;

// CC 20260924 same names and order as uHCUtils HCFormationRings2String for
//  rings 0..5, then 'Ring 7'..'Ring 64' (RingName)
function RingsText(rings: UInt64): string;
var
  k: Integer;
begin
  Result := '';
  for k := 0 to MAX_FORMATION_ZONES - 1 do
    if (rings and (UInt64(1) shl k)) <> 0 then
    begin
      if Result <> '' then
        Result := Result + ', ';
      Result := Result + RingName(k);
    end;
  if Result = '' then
    Result := '-';
end;

// CC 20260924 like uHCUtils HCFormationSectors2String but in the editor's
//  '000-045' form, 'All' for all 8 and '-' for none
function SectorsText(sectors: UInt64): string;
var
  s: Integer;
begin
  sectors := sectors and FormationSectorAll;
  if sectors = 0 then
    Exit('-');
  if sectors = FormationSectorAll then
    Exit('All');
  Result := '';
  for s := 0 to 7 do
    if (sectors and (UInt64(1) shl s)) <> 0 then
    begin
      if Result <> '' then
        Result := Result + ', ';
      Result := Result + Format('%.3d-%.3d', [s * 45, ((s + 1) * 45) mod 360]);
    end;
end;

initialization
  InitTables;

end.
