unit uHCLBattleSets;

{$mode objfpc}{$H+}

/// <summary>
/// uHCLBattleSets — battleset numbers, abbreviations and names for the GE's
/// <c>-b</c> option.
///
/// <c>-b N</c> makes the GE load <c>aszBattlesets[N]</c> (HC
/// <c>Common/SRC/globals.c</c>): 1 = <c>GIUK.RES</c>, 2 = <c>NACV.RES</c>, ...,
/// 6 = <c>HDS1.RES</c>, ..., 35 = <c>HDSU.RES</c>.  The name shown for each is
/// read from that file in the HC directory, exactly as the GE's own battleset
/// screen does: the first entry of the scenario index (resource 26) into the
/// scenario text (resource 27), or, in the newer BSBuilder format that has no
/// index, the start of the text.  Slots 27-35 hold whatever BSBuilder
/// battleset a player installed there, so no fixed table can name them
/// reliably; the built-in names are only a fallback.
///
/// Zero UI dependency.
/// </summary>

interface

uses
  SysUtils;

type
  /// <summary>What the launcher knows about one battleset number.</summary>
  THCLBattleSetInfo = record
    /// <summary>The <c>-b</c> value.</summary>
    Number: Integer;
    /// <summary>The GE's file for it, without extension, e.g. <c>HDS1</c>; '' when none.</summary>
    FileStem: String;
    /// <summary>Short name: the file's own "GIUK -- ..." prefix, else the file stem.</summary>
    Abbrev: String;
    /// <summary>Long name, e.g. "Greenland - Iceland - United Kingdom Gap".</summary>
    Name: String;
    /// <summary>True when the battleset's .res file is in the HC directory.</summary>
    Installed: Boolean;
    /// <summary>True when Name was read from that file rather than the built-in table.</summary>
    NameFromFile: Boolean;
  end;

/// <summary>The GE's battleset file for a <c>-b</c> number.</summary>
/// <param name="ANumber">A battleset number.</param>
/// <returns>e.g. <c>GIUK</c> for 1, <c>HDS1</c> for 6; '' for 5 and anything outside 1..35.</returns>
function HCLBattleSetFileStem(ANumber: Integer): String;

/// <summary>
/// Splits a battleset description into abbreviation and name.
/// </summary>
/// <param name="ARaw">The description as stored, e.g. "GIUK -- Greenland ..." or
/// "Western Pacific BattleSet  WestPac"; may hold line breaks and runs of
/// padding spaces.</param>
/// <param name="AFileStem">Used as the abbreviation when the text has none.</param>
/// <param name="AAbbrev">Receives the abbreviation.</param>
/// <param name="AName">Receives the name, with padding runs turned into " - ".</param>
/// <returns>False when there is no text.</returns>
function HCLSplitBattleSetTitle(const ARaw, AFileStem: String;
  out AAbbrev, AName: String): Boolean;

/// <summary>
/// Reads a battleset's description from its .res file.
/// </summary>
/// <param name="AResPath">Full path to the battleset's .res file.</param>
/// <param name="ATitle">Receives the raw description (UTF-8), up to its NUL.</param>
/// <returns>False when the file is missing, unreadable or malformed; never raises.</returns>
function HCLReadBattleSetTitle(const AResPath: String; out ATitle: String): Boolean;

/// <summary>
/// Describes one battleset number, reading its name from the HC directory
/// when the file is there.
/// </summary>
/// <param name="AHCDir">The HC game directory; '' to use only the built-in names.</param>
/// <param name="ANumber">A battleset number.</param>
/// <returns>The description; Name falls back to the built-in table.</returns>
function HCLBattleSetInfo(const AHCDir: String; ANumber: Integer): THCLBattleSetInfo;

/// <summary>
/// The battleset a scenario (or saved game) file belongs to, from its extension.
/// </summary>
/// <param name="AFileName">e.g. "PINGER.SCm", "WestPac1.0.scq", "x.hp1".</param>
/// <returns>The <c>-b</c> number the GE uses for it: the extension's last
/// character is the battleset number in base 36 (1-9, then a = 10 ... z = 35),
/// and 0 means the USNI battleset, 120 (HC <c>libge/src/Loadsave.c</c>,
/// <c>Common/SRC/Initscen.c</c>).  0 when the character is none of these.</returns>
/// <remarks>
/// For slots 27-35 this is the slot, holding whatever BSBuilder battleset the
/// player installed there, which is exactly what the GE itself loads for the
/// file; it does not say which custom battleset that is.
/// </remarks>
function HCLScenarioBattleSetNumber(const AFileName: String): Integer;

/// <summary>One line for a battleset picker.</summary>
/// <param name="AInfo">From <see cref="HCLBattleSetInfo"/>.</param>
/// <returns>e.g. "1  GIUK: Greenland - Iceland - United Kingdom Gap", with
/// " (not installed)" appended when the file is missing.</returns>
function HCLBattleSetCaption(const AInfo: THCLBattleSetInfo): String;

implementation

uses
  Classes, uCICHCResContainer;

const
  /// <summary>aszBattlesets[1..35] (HC Common/SRC/globals.c); 5 is unused.</summary>
  BS_FILE_STEMS: array[1..35] of String = (
    'GIUK', 'NACV', 'MEDC', 'IOPG', '',
    'HDS1', 'HDS2', 'HDS3', 'HDS4', 'HDS5', 'HDS6', 'HDS7', 'HDS8', 'HDS9',
    'HDSA', 'HDSB', 'HDSC', 'HDSD', 'HDSE', 'HDSF', 'HDSG', 'HDSH', 'HDSI',
    'HDSJ', 'HDSK', 'HDSL', 'HDSM', 'HDSN', 'HDSO', 'HDSP', 'HDSQ', 'HDSR',
    'HDSS', 'HDST', 'HDSU');

  /// <summary>
  /// Fallback names: the stock battlesets' own descriptions (as the
  /// 2025-era HC install stores them) and, for BSBuilder slots, the
  /// battleset HC shipped there, if any.
  /// </summary>
  BS_FALLBACK_NAMES: array[1..35] of String = (
    'Greenland - Iceland - United Kingdom Gap',
    'North Atlantic Convoys',
    'The Mediterranean Conflict',
    'The Indian Ocean Battleset',
    '',
    'HARPOON Designers'' Series BattleSet # 1 - Enhanced GIUK',
    'Harpoon Designers'' Series BattleSet # 2 - Enhanced NACV',
    'Harpoon Designers'' Series BattleSet # 3 - Enhanced MEDC',
    'Harpoon Designers'' Series BattleSet # 4 - Enhanced IOPG',
    'Harpoon Designer''s Series II Set # 1 - GIUK Gap',
    'Harpoon Designer''s Series II Set # 2 - North Atlantic',
    'Harpoon Designer''s Series II Set # 3 - Mediterranean Sea',
    'Harpoon Designer''s Series II Set # 4 - Indian Ocean/Persian Gulf',
    'Harpoon Designer''s Series III - GIUK',
    'HARPOON DESIGNER''S SERIES III - NACV',
    'HARPOON DESIGNER''S SERIES III - MEDC',
    'HARPOON DESIGNER''S SERIES III - IOPG',
    'EC2000 Battle for the Norwegian Sea',
    'EC2000 Battle for the Atlantic Ocean',
    'EC2000 Battle for the Mediterranean',
    'EC2000 Battle for the Gulf of Oman',
    'EC2003 Battle for the GIUK GAP',
    'EC2003 Battle for the Atlantic Ocean',
    'EC2003 Battle for the Mediterranean',
    'EC2003 Battle for the IOPG',
    'Western Pacific BattleSet - WestPac',
    'BSBuilder battleset',
    'Caribbean Basin - The Americas',
    'South Atlantic - The Americas',
    'South Africa - Africa',
    'The Middle East - IOPG',
    'Malacca Straits - South East Asia',
    'BSBuilder battleset',
    'BSBuilder battleset',
    'BSBuilder battleset');

  /// <summary>Longest description kept; the GE's own list shows far less.</summary>
  MAX_TITLE_LEN = 200;

function HCLBattleSetFileStem(ANumber: Integer): String;
begin
  if (ANumber >= Low(BS_FILE_STEMS)) and (ANumber <= High(BS_FILE_STEMS)) then
    Result := BS_FILE_STEMS[ANumber]
  else
    Result := '';
end;

/// <summary>Latin-1 bytes to UTF-8 (the files predate UTF-8; the C1 range is dropped).</summary>
function Latin1ToUTF8(const ABytes: RawByteString): String;
var
  I: Integer;
  B: Byte;
begin
  Result := '';
  for I := 1 to Length(ABytes) do
  begin
    B := Byte(ABytes[I]);
    if B < $80 then
      Result := Result + Chr(B)
    else if B >= $A0 then
      Result := Result + Chr($C0 or (B shr 6)) + Chr($80 or (B and $3F))
    else
      Result := Result + '?';
  end;
end;

function HCLSplitBattleSetTitle(const ARaw, AFileStem: String;
  out AAbbrev, AName: String): Boolean;
var
  S, Part: String;
  P, I: Integer;
  Parts: TStringList;
begin
  AAbbrev := AFileStem;
  AName := '';

  // Line breaks are layout, not content.
  S := StringReplace(StringReplace(ARaw, #13, ' ', [rfReplaceAll]), #10, ' ', [rfReplaceAll]);
  S := Trim(S);
  if S = '' then
    Exit(False);

  // "GIUK -- Greenland ...": the battleset names itself.
  P := Pos(' -- ', S);
  if (P > 1) and (Pos(' ', Trim(Copy(S, 1, P - 1))) = 0) then
  begin
    AAbbrev := Trim(Copy(S, 1, P - 1));
    S := Trim(Copy(S, P + 4, MaxInt));
  end;

  // Runs of padding separate a title from its subtitle or region:
  // "Western Pacific BattleSet  WestPac" -> "Western Pacific BattleSet - WestPac".
  Parts := TStringList.Create;
  try
    Part := '';
    I := 1;
    while I <= Length(S) do
    begin
      if (S[I] = ' ') and (I < Length(S)) and (S[I + 1] = ' ') then
      begin
        if Trim(Part) <> '' then
          Parts.Add(Trim(Part));
        Part := '';
        while (I <= Length(S)) and (S[I] = ' ') do
          Inc(I);
        Continue;
      end;
      Part := Part + S[I];
      Inc(I);
    end;
    if Trim(Part) <> '' then
      Parts.Add(Trim(Part));

    for I := 0 to Parts.Count - 1 do
      if I = 0 then
        AName := Parts[I]
      else
        AName := AName + ' - ' + Parts[I];
  finally
    Parts.Free;
  end;

  Result := (AName <> '') or (AAbbrev <> '');
end;

function HCLReadBattleSetTitle(const AResPath: String; out ATitle: String): Boolean;
var
  C: TCICResContainer;
  Index, Text, Raw: RawByteString;
  Off: LongWord;
  Len, Nul: Integer;
begin
  ATitle := '';
  Result := False;
  if not FileExists(AResPath) then
    Exit;
  try
    C := TCICResContainer.FromFile(AResPath);
    try
      if C.SlotCount <= HCRES1_SCENARIO_TEXT then
        Exit;
      Text := C.Resources[HCRES1_SCENARIO_TEXT].Data;
      Index := C.Resources[HCRES1_SCENARIO_INDEX].Data;
    finally
      C.Free;
    end;

    // Entry 0 of the index (u32 offset, u16 length) is the battleset's own
    // description; the newer BSBuilder format has no index and starts with it.
    if Length(Index) >= 6 then
    begin
      Off := LongWord(Byte(Index[1])) or (LongWord(Byte(Index[2])) shl 8) or
             (LongWord(Byte(Index[3])) shl 16) or (LongWord(Byte(Index[4])) shl 24);
      Len := Byte(Index[5]) or (Byte(Index[6]) shl 8);
      if Off >= LongWord(Length(Text)) then
        Exit;
      Raw := Copy(Text, Off + 1, Len);
    end
    else
      Raw := Copy(Text, 1, MAX_TITLE_LEN * 2);

    Nul := Pos(#0, Raw);
    if Nul > 0 then
      SetLength(Raw, Nul - 1);
    if Length(Raw) > MAX_TITLE_LEN then
      SetLength(Raw, MAX_TITLE_LEN);
    ATitle := Trim(Latin1ToUTF8(Raw));
    Result := ATitle <> '';
  except
    // A damaged or unexpected file only means falling back to the table.
    on E: Exception do
      Result := False;
  end;
end;

/// <summary>Finds a file in ADir whatever its case (the stock files mix "GIUK.RES" and "hdsl.res").</summary>
function FindResFile(const ADir, AStem: String): String;
var
  SR: TSearchRec;
begin
  Result := '';
  if (ADir = '') or (AStem = '') then
    Exit;
  if FindFirst(IncludeTrailingPathDelimiter(ADir) + '*', faAnyFile and not faDirectory, SR) = 0 then
  try
    repeat
      if SameText(SR.Name, AStem + '.res') then
        Exit(IncludeTrailingPathDelimiter(ADir) + SR.Name);
    until FindNext(SR) <> 0;
  finally
    FindClose(SR);
  end;
end;

function HCLBattleSetInfo(const AHCDir: String; ANumber: Integer): THCLBattleSetInfo;
var
  Path, Title, Abbrev, Name: String;
begin
  Result := Default(THCLBattleSetInfo);
  Result.Number := ANumber;
  Result.FileStem := HCLBattleSetFileStem(ANumber);
  Result.Abbrev := Result.FileStem;
  if Result.FileStem = '' then
    Exit;
  Result.Name := BS_FALLBACK_NAMES[ANumber];
  // The four originals name themselves "GIUK -- ...", so their abbreviation
  // is their file stem either way.

  Path := FindResFile(AHCDir, Result.FileStem);
  Result.Installed := Path <> '';
  if Result.Installed and HCLReadBattleSetTitle(Path, Title) and
     HCLSplitBattleSetTitle(Title, Result.FileStem, Abbrev, Name) and (Name <> '') then
  begin
    Result.Abbrev := Abbrev;
    Result.Name := Name;
    Result.NameFromFile := True;
  end;
end;

function HCLScenarioBattleSetNumber(const AFileName: String): Integer;
var
  Ext: String;
  C: Char;
begin
  Result := 0;
  Ext := ExtractFileExt(AFileName);
  if Length(Ext) < 2 then
    Exit;
  C := Ext[Length(Ext)];
  if C in ['A'..'Z'] then
    C := Chr(Ord(C) + 32);
  case C of
    '0': Result := 120;
    '1'..'9': Result := Ord(C) - Ord('0');
    'a'..'z': Result := Ord(C) - Ord('a') + 10;
  end;
end;

function HCLBattleSetCaption(const AInfo: THCLBattleSetInfo): String;
begin
  Result := Format('%d  %s', [AInfo.Number, AInfo.Abbrev]);
  if AInfo.Name <> '' then
    Result := Result + ': ' + AInfo.Name;
  if not AInfo.Installed then
    Result := Result + ' (not installed)';
end;

end.
