Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
35a20f2
Implement custom mission support with CampaignTagSelector
SadPencil May 6, 2025
6d0945d
Merge branch 'develop' into feature-custom-mission
Starkku May 28, 2025
c5ec4fb
Disable fade between CampaignTagSelector/CampaignSelector
Starkku May 28, 2025
8959f55
Migrate CampaignSelector to INItializableWindow
Starkku May 28, 2025
2f022d7
Add return to campaign tag selector button to CampaignSelector
Starkku May 28, 2025
34701a0
Adjust the mission config section names and decouple game mission con…
Starkku May 28, 2025
f8e604c
Enforce all-caps scenario names, game makes assumptions that they are
Starkku May 28, 2025
a5ac652
Allow staying on mission selector on game launch and generalize missi…
Starkku May 28, 2025
cbc73e2
Fix some remaining issues
Starkku Jun 2, 2025
a3e5eb8
Revert "Migrate CampaignSelector to INItializableWindow"
Starkku Jun 3, 2025
a93ea06
Merge branch 'develop' into feature-custom-mission
Starkku Jul 23, 2025
82ac513
Rename lbCampaignListMissions to selectedMissions
SadPencil Aug 16, 2025
b051a1a
Create hard links for supplemental mission files
SadPencil Aug 16, 2025
34a5675
Allow customized custom mission supplemental files
SadPencil Aug 16, 2025
bc67a62
Define no supplement files by default
SadPencil Aug 16, 2025
f9d2234
Add document for custom mission configurations
SadPencil Aug 16, 2025
a20c59d
make the compiler happy
SadPencil Aug 16, 2025
73284e7
Add campaign tag selector documentation
SadPencil Aug 16, 2025
75e85fa
make the compiler happy
SadPencil Aug 16, 2025
92ea96f
Merge branch 'develop' into feature-custom-mission
Starkku Aug 18, 2025
3da03c0
Use ls/pal file names from supplementary file definitions as defaults…
Starkku Aug 18, 2025
adfa44a
Allow use of disabled, placeholder campaign tag buttons
Starkku Aug 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions ClientCore/ClientConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -509,9 +509,8 @@ public List<string> GetIRCServers()
public bool DiscordIntegrationGloballyDisabled => string.IsNullOrWhiteSpace(DiscordAppId) || DisableDiscordIntegration;

public string CustomMissionPath => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionPath", "Maps/CustomMissions");
public string CustomMissionCsfName => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionCsfName", "stringtable99.csf");
public string CustomMissionPalName => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionPalName", "custommission.pal");
public string CustomMissionShpName => clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionShpName", "custommission.shp");
public string CustomMissionSupplementDefinition // e.g., "csf|stringtable99.csf|pal|custommission.pal|shp|custommission.shp"
=> clientDefinitionsIni.GetStringValue(SETTINGS, "CustomMissionSupplementDefinition", string.Empty);

public OSVersion GetOperatingSystemVersion()
{
Expand Down
42 changes: 35 additions & 7 deletions DXMainClient/DXGUI/Generic/CampaignSelector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
private DiscordHandler discordHandler;
private CampaignTagSelector campaignTagSelector;

private List<Mission> lbCampaignListMissions = new List<Mission>();
private List<Mission> selectedMissions = [];
private XNAListBox lbCampaignList;
private XNAClientButton btnLaunch;
private XNAClientButton btnCancel;
Expand Down Expand Up @@ -251,7 +251,7 @@
return;
}

Mission mission = lbCampaignListMissions[lbCampaignList.SelectedIndex];
Mission mission = selectedMissions[lbCampaignList.SelectedIndex];

if (string.IsNullOrEmpty(mission.Scenario))
{
Expand Down Expand Up @@ -287,7 +287,7 @@

int selectedMissionId = lbCampaignList.SelectedIndex;

Mission mission = lbCampaignListMissions[selectedMissionId];
Mission mission = selectedMissions[selectedMissionId];

if (!ClientConfiguration.Instance.ModMode &&
(!Updater.IsFileNonexistantOrOriginal(mission.Scenario) || AreFilesModified()))
Expand Down Expand Up @@ -422,11 +422,39 @@
{
// copy an IniSection
IniSection spawnIniMissionIniSection = new(mission.Scenario.ToUpperInvariant());
string loadingScreenName = string.Empty;
string loadingScreenPalName = string.Empty;
foreach (var kvp in mission.GameMissionConfigSection.Keys)
{
if (string.IsNullOrEmpty(kvp.Value))
{
if (kvp.Key.Equals("LS640BkgdName", StringComparison.InvariantCulture) || kvp.Key.Equals("LS800BkgdName", StringComparison.InvariantCulture))
loadingScreenName = kvp.Value;
else if (kvp.Key.Equals("LS800BkgdPal", StringComparison.InvariantCulture))
loadingScreenPalName = kvp.Value;
}

spawnIniMissionIniSection.AddKey(kvp.Key, kvp.Value);
}

if (string.IsNullOrEmpty(loadingScreenName))
{
string lsFilename = CustomMissionHelper.CustomMissionSupplementDefinition.FirstOrDefault(x => x.extension.Equals("shp", StringComparison.InvariantCultureIgnoreCase)).filename;

if (!string.IsNullOrEmpty(lsFilename))
{
spawnIniMissionIniSection.AddOrReplaceKey("LS640BkgdName", lsFilename);
spawnIniMissionIniSection.AddOrReplaceKey("LS800BkgdName", lsFilename);
}
}
if (string.IsNullOrEmpty(loadingScreenPalName))
{
string palFilename = CustomMissionHelper.CustomMissionSupplementDefinition.FirstOrDefault(x => x.extension.Equals("pal", StringComparison.InvariantCultureIgnoreCase)).filename;

if (!string.IsNullOrEmpty(palFilename))
spawnIniMissionIniSection.AddOrReplaceKey("LS800BkgdPal", palFilename);
}

// append the new IniSection
spawnIni.AddSection(spawnIniMissionIniSection);
spawnIni.SetStringValue("Settings", "ReadMissionSection", "Yes");
Expand Down Expand Up @@ -530,7 +558,7 @@
if (clientMissionDataSection is null)
continue;

IniSection? gameMissionDataSection = mapFile.GetSection("GameMissionConfig");

Check warning on line 561 in DXMainClient/DXGUI/Generic/CampaignSelector.cs

View workflow job for this annotation

GitHub Actions / build-clients

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

string filename = new FileInfo(mapFilePath).Name;
string scenario = SafePath.CombineFilePath(ClientConfiguration.Instance.CustomMissionPath, filename);
Expand All @@ -555,7 +583,7 @@
return false;
}

if (lbCampaignListMissions.Count > 0)
if (selectedMissions.Count > 0)
{
throw new InvalidOperationException("Loading multiple Battle*.ini files is not supported anymore.");
}
Expand Down Expand Up @@ -590,7 +618,7 @@
/// <param name="loadCustomMissions">True means show official missions. False means show custom missions.</param>
public void LoadMissionsWithFilter(ISet<string> selectedTags, bool disableCustomMissions = true, bool disableOfficialMissions = false)
{
lbCampaignListMissions.Clear();
selectedMissions.Clear();

lbCampaignList.IsChangingSize = true;

Expand Down Expand Up @@ -622,10 +650,10 @@

if (selectedTags != null)
missions = missions.Where(mission => mission.Tags.Intersect(selectedTags).Any()).ToList();
lbCampaignListMissions = missions.ToList();
selectedMissions = missions.ToList();

// Update lbCampaignList with selected missions
foreach (Mission mission in lbCampaignListMissions)
foreach (Mission mission in selectedMissions)
{
var item = new XNAListBoxItem();
item.Text = mission.GUIName;
Expand Down
96 changes: 78 additions & 18 deletions DXMainClient/Domain/CustomMissionHelper.cs
Original file line number Diff line number Diff line change
@@ -1,51 +1,111 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using ClientCore;
using ClientCore.Extensions;

using Rampastring.Tools;

namespace DTAClient.Domain;
internal static class CustomMissionHelper
{
public static List<(string extension, string filename)>? CustomMissionSupplementDefinition { get; private set; }

private static bool IsValidExtension(string extension) => extension == extension.ToWin32FileName() && extension.IndexOfAny(new char[] { '.', ' ' }) == -1;

private static bool IsValidFileName(string filename) => filename == filename.ToWin32FileName();

public static void Initialize()
{
CustomMissionSupplementDefinition = GetCustomMissionSupplementDefinition();
}

public static List<(string extension, string filename)> GetCustomMissionSupplementDefinition()
{
string rawDefinition = ClientConfiguration.Instance.CustomMissionSupplementDefinition;
string[] definitionItems = rawDefinition.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries);
int fileCount = definitionItems.Length / 2;

HashSet<string> extensions = [];

List<(string extension, string filename)> ret = [];

for (int i = 0; i < fileCount; i++)
{
string extension = definitionItems[2 * i];
string filename = definitionItems[2 * i + 1];

if (!IsValidExtension(extension))
{
throw new Exception(string.Format("Invalid extension {0}", extension));
}

if (!IsValidFileName(filename))
{
throw new Exception(string.Format("Invalid file name {0}", filename));
}

if (extensions.Contains(extension))
{
throw new Exception(string.Format("Extension {0} already exists", extension));
}

extensions.Add(extension);

ret.Add((extension, filename));
}

return ret;
}

public static void DeleteSupplementalMissionFiles()
{
Debug.Assert(CustomMissionSupplementDefinition != null, "CustomMissionHelper must be initialized.");

IEnumerable<string> filenames = CustomMissionSupplementDefinition.Select(def => def.filename);
DirectoryInfo gameDirectory = SafePath.GetDirectory(ProgramConstants.GamePath);
foreach (string filename in new string[]
{
ClientConfiguration.Instance.CustomMissionCsfName,
ClientConfiguration.Instance.CustomMissionPalName,
ClientConfiguration.Instance.CustomMissionShpName,
})
foreach (string filename in filenames)
{
gameDirectory.EnumerateFiles(filename).SingleOrDefault()?.Delete();
FileInfo? fileInfo = gameDirectory.EnumerateFiles(filename).SingleOrDefault();
if (fileInfo?.Exists ?? false)
{
fileInfo.IsReadOnly = false;
fileInfo.Delete();
}
}
}

public static void CopySupplementalMissionFiles(Mission mission)
{
Debug.Assert(CustomMissionSupplementDefinition != null, "CustomMissionHelper must be initialized.");

DeleteSupplementalMissionFiles();

if (mission.IsCustomMission)
{
string mapExtension = "." + ClientConfiguration.Instance.MapFileExtension; // e.g., ".map"

string missionFileName = mission.Scenario;
Debug.Assert(missionFileName.EndsWith(".map", StringComparison.InvariantCultureIgnoreCase), "Mission file should have the extension \".map\".");
Debug.Assert(missionFileName.EndsWith(mapExtension, StringComparison.InvariantCultureIgnoreCase), string.Format("Mission file should have the extension \"{0}\".", mapExtension));

// copy the CSF file if exists
foreach ((string ext, string filename) in new (string, string)[]
foreach ((string ext, string filename) in CustomMissionSupplementDefinition!)
{
("csf", ClientConfiguration.Instance.CustomMissionCsfName),
("pal", ClientConfiguration.Instance.CustomMissionPalName),
("shp", ClientConfiguration.Instance.CustomMissionShpName),
})
{
string sourceFileName = missionFileName[..^".map".Length] + "." + ext;
if (SafePath.GetFile(SafePath.CombineFilePath(ProgramConstants.GamePath, sourceFileName)).Exists)
File.Copy(SafePath.CombineFilePath(ProgramConstants.GamePath, sourceFileName), SafePath.CombineFilePath(ProgramConstants.GamePath, filename));
string sourceFileName = missionFileName[..^mapExtension.Length] + "." + ext;
string sourceFilePath = SafePath.CombineFilePath(ProgramConstants.GamePath, sourceFileName);
if (SafePath.GetFile(sourceFilePath).Exists)
{
string targetFilePath = SafePath.CombineFilePath(ProgramConstants.GamePath, filename);

FileHelper.CreateHardLinkFromSource(sourceFilePath, targetFilePath);
new FileInfo(targetFilePath).IsReadOnly = true;
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion DXMainClient/PreStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ public static void Initialize(StartupParams parameters)
Logger.Log("Failed to generate the translation stub: " + ex.ToString());
}

// Delete custom mission files
// Custom mission initialization
CustomMissionHelper.Initialize();
CustomMissionHelper.DeleteSupplementalMissionFiles();

// Delete obsolete files from old target project versions
Expand Down
15 changes: 15 additions & 0 deletions Docs/INISystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,3 +536,18 @@ TrustedDomains= ; comma-separated list of strings,
; domain names to match links and prevent the message box from appearing before they open by default browser
; example: cncnet.org,github.com,moddb.com
```

```ini
[Settings]
CustomMissionPath=Maps/CustomMissions ; path to the folder containing fan-made maps
CustomMissionSupplementDefinition=csf|stringtable99.csf|pal|custommission.pal|shp|custommission.shp ; supplement files that are supposed to be copied to the game folder when a custom mission is played
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason to introduce new syntax like this? Can't you do the same with , as a separator or simple as separate keys?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be the only place where the list is processed as pairs. Therefore I want to use a different separator to notify modders. And | representing pairs is also used in Win32 OpenFileDialog

Copy link
Contributor

@Starkku Starkku Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more confusing part is that the pairs are also separated from other pairs by |

The entire system also hinges on not defining more than one pair with same file extension. It cannot currently handle that.

Copy link
Member Author

@SadPencil SadPencil Aug 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more confusing part is that the pairs are also separated from other pairs by |

The "pairs are also separated from other pairs" matches the same convention in https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.filedialog.filter
e.g.,

dlg.Filter = "Word Documents|*.doc|Excel Worksheets|*.xls|PowerPoint Presentations|*.ppt" +
             "|Office Files|*.doc;*.xls;*.ppt" +
             "|All Files|*.*";

The entire system also hinges on not defining more than one pair with same file extension. It cannot currently handle that.

This can be ignored for now, left as another PR. The TS resolution things require a special handling so we can leave it as a future work.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think non-IT modders ever encountered that, so that diminishes the example. Why not define those all as separate keys?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think non-IT modders ever encountered that, so that diminishes the example. Why not define those all as separate keys?

Yeah, right. Regex is friendly to non-IT modders but "|" symbol is not.

Whatever. So what's the prefered way to express the setting? csf,stringtable99.csf,pal,custommission.pal,shp,custommission.shp or something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, right. Regex is friendly to non-IT modders but "|" symbol is not.

I never claimed it is, regex is hostile to anyone, even IT people lol. In that case it provided flexbility though, in this case it doesn't provide flexibility.

I thought of something like

CustomMissionFile0Extension=csf
CustomMissionFile0CopyAs=stringtable99.csf
CustomMissionFile1Extension=...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, right. Regex is friendly to non-IT modders but "|" symbol is not.

I never claimed it is, regex is hostile to anyone, even IT people lol. In that case it provided flexbility though, in this case it doesn't provide flexibility.

I thought of something like

CustomMissionFile0Extension=csf
CustomMissionFile0CopyAs=stringtable99.csf
CustomMissionFile1Extension=...

I would prefer the following, still making the setting in oneline

csf:stringtable99.csf, pal:custommission.pal

: or =

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is somewhat better because it's possible to add new parameters without breaking backwards compatibility, but I am still not a fan of cramming everything in one line, it's not like we're constrained on storage space.

```

```ini
[Settings]
ReturnToMainMenuOnMissionLaunch=true ; whether or not client returns to main menu when launching a mission
```

```ini
CampaignTagSelectorEnabled=false ; turns on the campaign tag selector, showing a window to let users choose which group of missions to play
```
Loading