diff --git a/.github/scripts/prepare-release.ps1 b/.github/scripts/prepare-release.ps1
new file mode 100644
index 000000000..d72de295c
--- /dev/null
+++ b/.github/scripts/prepare-release.ps1
@@ -0,0 +1,113 @@
+param (
+ [string] $BuildDirectory = "bin",
+ [string] $ReleaseDirectory = "openkh",
+ [string] $Configuration = "Release"
+)
+
+$ErrorActionPreference = "Stop"
+
+if (-not (Test-Path -LiteralPath $BuildDirectory -PathType Container)) {
+ throw "Build directory '$BuildDirectory' does not exist."
+}
+
+if (Test-Path -LiteralPath $ReleaseDirectory) {
+ throw "Release directory '$ReleaseDirectory' already exists."
+}
+
+New-Item -ItemType Directory -Path $ReleaseDirectory | Out-Null
+
+$applicationsDirectory = Join-Path $ReleaseDirectory "Apps"
+$modManagerDirectory = Join-Path $applicationsDirectory "ModManager"
+New-Item -ItemType Directory -Path $modManagerDirectory -Force | Out-Null
+
+$legacyFileManifest = Join-Path $applicationsDirectory "legacy-release-files.txt"
+$legacyDirectoryManifest = Join-Path $applicationsDirectory "legacy-release-directories.txt"
+Get-ChildItem -LiteralPath $BuildDirectory -File |
+ Select-Object -ExpandProperty Name |
+ Sort-Object |
+ Set-Content -LiteralPath $legacyFileManifest -Encoding UTF8
+Get-ChildItem -LiteralPath $BuildDirectory -Directory |
+ Select-Object -ExpandProperty Name |
+ Sort-Object |
+ Set-Content -LiteralPath $legacyDirectoryManifest -Encoding UTF8
+
+dotnet publish `
+ "OpenKh.Tools.Launcher/OpenKh.Tools.Launcher.csproj" `
+ --configuration $Configuration `
+ --runtime win-x64 `
+ --self-contained false `
+ --output $ReleaseDirectory `
+ /p:PublishSingleFile=true `
+ /p:DebugType=None `
+ /p:DebugSymbols=false
+
+if ($LASTEXITCODE -ne 0) {
+ throw "Publishing OpenKH Launcher failed with exit code $LASTEXITCODE."
+}
+
+$compatibilityExecutable = Join-Path $ReleaseDirectory "OpenKh.Tools.ModsManager.exe"
+Copy-Item `
+ -LiteralPath (Join-Path $ReleaseDirectory "OpenKh.Launcher.exe") `
+ -Destination $compatibilityExecutable
+(Get-Item -LiteralPath $compatibilityExecutable).Attributes += "Hidden"
+
+dotnet publish `
+ "OpenKh.Tools.ModsManager/OpenKh.Tools.ModsManager.csproj" `
+ --configuration $Configuration `
+ --output $modManagerDirectory `
+ /p:DebugType=None `
+ /p:DebugSymbols=false
+
+if ($LASTEXITCODE -ne 0) {
+ throw "Publishing Mods Manager failed with exit code $LASTEXITCODE."
+}
+
+$referencedCommandArtifacts = Get-ChildItem -LiteralPath $modManagerDirectory -File | Where-Object {
+ $_.Name -like "OpenKh.Command.*" -and $_.Extension -ne ".dll"
+}
+
+foreach ($referencedCommandArtifact in $referencedCommandArtifacts) {
+ Remove-Item -LiteralPath $referencedCommandArtifact.FullName
+}
+
+$panaceaFiles = @(
+ "OpenKH.Panacea.dll",
+ "avcodec-vgmstream-59.dll",
+ "avformat-vgmstream-59.dll",
+ "avutil-vgmstream-57.dll",
+ "bass.dll",
+ "bass_vgmstream.dll",
+ "libatrac9.dll",
+ "libcelt-0061.dll",
+ "libcelt-0110.dll",
+ "libg719_decode.dll",
+ "libmpg123-0.dll",
+ "libspeex-1.dll",
+ "libvorbis.dll",
+ "swresample-vgmstream-4.dll"
+)
+
+foreach ($fileName in $panaceaFiles) {
+ $sourcePath = Join-Path $BuildDirectory $fileName
+ if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
+ throw "Required Panacea file '$sourcePath' does not exist."
+ }
+
+ Copy-Item -LiteralPath $sourcePath -Destination $modManagerDirectory
+}
+
+Copy-Item -LiteralPath "distribution/README-FIRST.txt" -Destination $ReleaseDirectory
+Copy-Item -LiteralPath "LICENSE" -Destination $ReleaseDirectory
+Copy-Item -LiteralPath "NOTICE" -Destination $ReleaseDirectory
+
+$advancedToolsDirectory = Join-Path $ReleaseDirectory "AdvancedTools"
+Move-Item -LiteralPath $BuildDirectory -Destination $advancedToolsDirectory
+
+$duplicateApplicationFiles = Get-ChildItem -LiteralPath $advancedToolsDirectory -File | Where-Object {
+ $_.Name -like "OpenKh.Launcher.*" -or
+ $_.Name -like "OpenKh.Tools.ModsManager.*"
+}
+
+foreach ($duplicateFile in $duplicateApplicationFiles) {
+ Remove-Item -LiteralPath $duplicateFile.FullName
+}
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index f0afbad26..f2349c5a7 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -27,7 +27,7 @@ jobs:
- name: build.ps1
run: powershell -ExecutionPolicy Unrestricted ./build.ps1
shell: pwsh
-
+
- name: setup-msbuild
uses: microsoft/setup-msbuild@v1.1.3
- name: msbuild panacea
@@ -35,23 +35,39 @@ jobs:
msbuild OpenKh.Research.Panacea\OpenKh.Research.Panacea.vcxproj /p:Configuration=Release /p:Platform=x64
xcopy "OpenKh.Research.Panacea\Release\*.dll" bin\
xcopy "OpenKh.Research.Panacea\Dependencies\*.dll" bin\
-
+
+ - name: Organize release for mod users
+ run: powershell -ExecutionPolicy Unrestricted ./.github/scripts/prepare-release.ps1
+ shell: pwsh
+
- name: create openkh-release
shell: bash
env:
RELEASE_TAG: "release2-${{github.run_number}}"
run: |
- echo $RELEASE_TAG > bin/openkh-release
-
- - name: bin → openkh
- run: ren bin openkh
- shell: pwsh
+ echo $RELEASE_TAG > openkh/openkh-release
+
- name: zip
uses: TheDoctor0/zip-release@0.6.2
with:
filename: openkh.zip
path: openkh
+ - name: validate update archive
+ shell: pwsh
+ run: |
+ $archiveListing = (7z l openkh.zip) -join "`n"
+ $requiredEntries = @(
+ "openkh\OpenKh.Launcher.exe",
+ "openkh\OpenKh.Tools.ModsManager.exe",
+ "openkh\Apps\ModManager\OpenKh.Tools.ModsManager.exe"
+ )
+ foreach ($entry in $requiredEntries) {
+ if ($archiveListing -notmatch [regex]::Escape($entry)) {
+ throw "Required update entry '$entry' is missing from openkh.zip."
+ }
+ }
+
- name: "GitHub release latest"
if: ${{ github.ref_name == 'master' }}
uses: "marvinpinto/action-automatic-releases@latest"
diff --git a/.gitignore b/.gitignore
index 9824a46a3..1b7b4c9fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,9 @@
# Project specific files
.tests/
+/openkh/
+/openkh-*/
+/OpenKh-*-release.zip
# User-specific files
*.suo
diff --git a/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs b/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs
new file mode 100644
index 000000000..5fd143ab5
--- /dev/null
+++ b/OpenKh.Tests.ModsManager/OpenkhInstallationTest.cs
@@ -0,0 +1,21 @@
+using OpenKh.Tools.ModsManager.Services;
+using Xunit;
+
+namespace OpenKh.Tests.ModsManager
+{
+ public class OpenkhInstallationTest
+ {
+ [Theory]
+ [InlineData(@"C:\OpenKh", @"C:\OpenKh")]
+ [InlineData(@"C:\OpenKh\Apps\ModManager", @"C:\OpenKh")]
+ [InlineData(@"C:\OpenKh\apps\modmanager", @"C:\OpenKh")]
+ public void GetDirectoryReturnsInstallationRoot(string applicationDirectory, string expectedDirectory)
+ {
+ Assert.Equal(
+ Path.GetFullPath(expectedDirectory),
+ OpenkhInstallation.GetDirectory(applicationDirectory),
+ ignoreCase: true
+ );
+ }
+ }
+}
diff --git a/OpenKh.Tools.Launcher/App.xaml b/OpenKh.Tools.Launcher/App.xaml
new file mode 100644
index 000000000..9e16e5150
--- /dev/null
+++ b/OpenKh.Tools.Launcher/App.xaml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/OpenKh.Tools.Launcher/App.xaml.cs b/OpenKh.Tools.Launcher/App.xaml.cs
new file mode 100644
index 000000000..631bf9eca
--- /dev/null
+++ b/OpenKh.Tools.Launcher/App.xaml.cs
@@ -0,0 +1,20 @@
+using System.Windows;
+
+namespace OpenKh.Tools.Launcher;
+
+public partial class App : Application
+{
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ base.OnStartup(e);
+
+ if (LegacyInstallationMigration.TryStartModManager())
+ {
+ Shutdown();
+ return;
+ }
+
+ LegacyInstallationMigration.ScheduleCleanupIfNeeded();
+ new MainWindow().Show();
+ }
+}
diff --git a/OpenKh.Tools.Launcher/DesktopShortcutService.cs b/OpenKh.Tools.Launcher/DesktopShortcutService.cs
new file mode 100644
index 000000000..89a76d3b7
--- /dev/null
+++ b/OpenKh.Tools.Launcher/DesktopShortcutService.cs
@@ -0,0 +1,46 @@
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace OpenKh.Tools.Launcher;
+
+internal static class DesktopShortcutService
+{
+ public static string CreateModManagerShortcut(string targetPath, string? shortcutDirectory = null)
+ {
+ shortcutDirectory ??= Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
+ var shortcutPath = Path.Combine(shortcutDirectory, "OpenKH Mod Manager.lnk");
+ var shellType = Type.GetTypeFromProgID("WScript.Shell")
+ ?? throw new InvalidOperationException("Windows Script Host is not available.");
+ object? shell = null;
+ object? shortcut = null;
+
+ try
+ {
+ shell = Activator.CreateInstance(shellType)
+ ?? throw new InvalidOperationException("Windows Script Host could not be started.");
+ shortcut = shellType.InvokeMember(
+ "CreateShortcut",
+ System.Reflection.BindingFlags.InvokeMethod,
+ null,
+ shell,
+ new object[] { shortcutPath }
+ ) ?? throw new InvalidOperationException("The shortcut could not be created.");
+
+ var shortcutType = shortcut.GetType();
+ shortcutType.InvokeMember("TargetPath", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { targetPath });
+ shortcutType.InvokeMember("WorkingDirectory", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { Path.GetDirectoryName(targetPath)! });
+ shortcutType.InvokeMember("Description", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { "Open OpenKH Mod Manager" });
+ shortcutType.InvokeMember("IconLocation", System.Reflection.BindingFlags.SetProperty, null, shortcut, new object[] { $"{targetPath},0" });
+ shortcutType.InvokeMember("Save", System.Reflection.BindingFlags.InvokeMethod, null, shortcut, null);
+ }
+ finally
+ {
+ if (shortcut != null && Marshal.IsComObject(shortcut))
+ Marshal.FinalReleaseComObject(shortcut);
+ if (shell != null && Marshal.IsComObject(shell))
+ Marshal.FinalReleaseComObject(shell);
+ }
+
+ return shortcutPath;
+ }
+}
diff --git a/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs b/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs
new file mode 100644
index 000000000..014336362
--- /dev/null
+++ b/OpenKh.Tools.Launcher/LegacyInstallationMigration.cs
@@ -0,0 +1,198 @@
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Windows;
+
+namespace OpenKh.Tools.Launcher;
+
+internal static class LegacyInstallationMigration
+{
+ private const string LauncherExecutableName = "OpenKh.Launcher.exe";
+ private const string CompatibilityExecutableName = "OpenKh.Tools.ModsManager.exe";
+
+ private static readonly string[] FallbackLegacyResourceDirectories =
+ {
+ "cs-CZ",
+ "de",
+ "es",
+ "fr",
+ "hu",
+ "it",
+ "ja-JP",
+ "pt-BR",
+ "resources",
+ "ro",
+ "ru",
+ "runtimes",
+ "sv",
+ "zh-Hans",
+ };
+
+ public static bool TryStartModManager()
+ {
+ var processPath = Environment.ProcessPath;
+ if (string.IsNullOrWhiteSpace(processPath)
+ || !Path.GetFileName(processPath).Equals(CompatibilityExecutableName, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var installationDirectory = AppContext.BaseDirectory.TrimEnd(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ );
+ var modManagerPath = Path.Combine(
+ installationDirectory,
+ "Apps",
+ "ModManager",
+ CompatibilityExecutableName
+ );
+
+ if (!File.Exists(modManagerPath))
+ {
+ MessageBox.Show(
+ "The updated Mod Manager could not be found. Extract the latest OpenKH release again.",
+ "OpenKH Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ return true;
+ }
+
+ try
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = modManagerPath,
+ WorkingDirectory = Path.GetDirectoryName(modManagerPath),
+ UseShellExecute = true,
+ };
+
+ foreach (var argument in Environment.GetCommandLineArgs().Skip(1))
+ startInfo.ArgumentList.Add(argument);
+
+ Process.Start(startInfo);
+
+ ScheduleCleanupIfNeeded(installationDirectory);
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ $"OpenKH could not complete the update.\n\n{exception.Message}",
+ "OpenKH Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+
+ return true;
+ }
+
+ public static void ScheduleCleanupIfNeeded()
+ {
+ var installationDirectory = AppContext.BaseDirectory.TrimEnd(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ );
+ ScheduleCleanupIfNeeded(installationDirectory);
+ }
+
+ private static void ScheduleCleanupIfNeeded(string installationDirectory)
+ {
+ var legacyFiles = GetLegacyApplicationFiles(installationDirectory)
+ .Where(File.Exists)
+ .ToArray();
+ var legacyDirectories = GetLegacyResourceDirectories(installationDirectory)
+ .Select(directoryName => Path.Combine(installationDirectory, directoryName))
+ .Where(Directory.Exists)
+ .ToArray();
+
+ if (legacyFiles.Length == 0 && legacyDirectories.Length == 0)
+ return;
+
+ var batchPath = Path.Combine(Path.GetTempPath(), $"openkh-migrate-{Guid.NewGuid():N}.bat");
+ var batch = new StringBuilder();
+
+ batch.AppendLine("@echo off");
+ batch.AppendLine("chcp 65001 > nul");
+ batch.AppendLine(":wait_for_launcher");
+ batch.AppendLine($"tasklist /fi \"PID eq {Environment.ProcessId}\" 2>nul | find \"{Environment.ProcessId}\" >nul");
+ batch.AppendLine("if not errorlevel 1 (");
+ batch.AppendLine(" timeout /t 1 /nobreak >nul");
+ batch.AppendLine(" goto wait_for_launcher");
+ batch.AppendLine(")");
+
+ foreach (var filePath in legacyFiles)
+ {
+ var escapedPath = EscapeBatchPath(filePath);
+ batch.AppendLine($"attrib -h -r {escapedPath} 2>nul");
+ batch.AppendLine($"del /f /q {escapedPath} 2>nul");
+ }
+
+ foreach (var directoryPath in legacyDirectories)
+ {
+ batch.AppendLine($"rmdir /s /q {EscapeBatchPath(directoryPath)} 2>nul");
+ }
+
+ batch.AppendLine("del /f /q \"%~f0\"");
+ File.WriteAllText(batchPath, batch.ToString(), new UTF8Encoding(false));
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = batchPath,
+ UseShellExecute = true,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ });
+ }
+
+ private static IEnumerable GetLegacyApplicationFiles(string installationDirectory)
+ {
+ var manifestPath = Path.Combine(installationDirectory, "Apps", "legacy-release-files.txt");
+ if (!File.Exists(manifestPath))
+ {
+ return Directory.EnumerateFiles(installationDirectory, "*", SearchOption.TopDirectoryOnly)
+ .Where(IsLegacyApplicationFile);
+ }
+
+ var legacyFileNames = File.ReadAllLines(manifestPath)
+ .Where(fileName => !string.IsNullOrWhiteSpace(fileName))
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return Directory.EnumerateFiles(installationDirectory, "*", SearchOption.TopDirectoryOnly)
+ .Where(filePath =>
+ legacyFileNames.Contains(Path.GetFileName(filePath))
+ || Path.GetFileName(filePath).StartsWith("OpenKh.Tools.ModsManager.", StringComparison.OrdinalIgnoreCase)
+ )
+ .Where(filePath => !Path.GetFileName(filePath).Equals(LauncherExecutableName, StringComparison.OrdinalIgnoreCase))
+ .Where(filePath => !Path.GetFileName(filePath).Equals(CompatibilityExecutableName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static IEnumerable GetLegacyResourceDirectories(string installationDirectory)
+ {
+ var manifestPath = Path.Combine(installationDirectory, "Apps", "legacy-release-directories.txt");
+ return File.Exists(manifestPath)
+ ? File.ReadAllLines(manifestPath).Where(directoryName => !string.IsNullOrWhiteSpace(directoryName))
+ : FallbackLegacyResourceDirectories;
+ }
+
+ private static bool IsLegacyApplicationFile(string filePath)
+ {
+ var fileName = Path.GetFileName(filePath);
+ if (fileName.Equals(LauncherExecutableName, StringComparison.OrdinalIgnoreCase)
+ || fileName.Equals(CompatibilityExecutableName, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ if (Path.GetExtension(fileName).Equals(".dll", StringComparison.OrdinalIgnoreCase))
+ return true;
+
+ if (!fileName.StartsWith("OpenKh.", StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)
+ || fileName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)
+ || fileName.EndsWith(".deps.json", StringComparison.OrdinalIgnoreCase)
+ || fileName.EndsWith(".runtimeconfig.json", StringComparison.OrdinalIgnoreCase)
+ || fileName.EndsWith(".config", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string EscapeBatchPath(string path) => $"\"{path.Replace("\"", "\"\"")}\"";
+}
diff --git a/OpenKh.Tools.Launcher/MainWindow.xaml b/OpenKh.Tools.Launcher/MainWindow.xaml
new file mode 100644
index 000000000..26ed356fa
--- /dev/null
+++ b/OpenKh.Tools.Launcher/MainWindow.xaml
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenKh.Tools.Launcher/MainWindow.xaml.cs b/OpenKh.Tools.Launcher/MainWindow.xaml.cs
new file mode 100644
index 000000000..d006155dd
--- /dev/null
+++ b/OpenKh.Tools.Launcher/MainWindow.xaml.cs
@@ -0,0 +1,364 @@
+using OpenKh.Tools.ModsManager.Services;
+using System.Diagnostics;
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+
+namespace OpenKh.Tools.Launcher;
+
+public partial class MainWindow : Window
+{
+ private const string ModManagerExecutable = "OpenKh.Tools.ModsManager.exe";
+ private const string ApplicationsDirectory = "Apps";
+ private const string ModManagerDirectory = "ModManager";
+ private const string AdvancedToolsDirectory = "AdvancedTools";
+
+ private static readonly IReadOnlyDictionary ToolDescriptions =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["BarEditor"] = "Inspect and edit BAR archives.",
+ ["BbsEventTableEditor"] = "Edit Birth by Sleep event tables.",
+ ["BbsMapStudio"] = "Create and inspect Birth by Sleep maps.",
+ ["ImageViewer"] = "View textures and supported game images.",
+ ["IdxImg"] = "Browse and manage IDX/IMG game archives.",
+ ["Kh2BattleEditor"] = "Edit Kingdom Hearts II battle data.",
+ ["Kh2MapStudio"] = "Create and inspect Kingdom Hearts II maps.",
+ ["Kh2MdlxEditor"] = "Inspect and edit Kingdom Hearts II models.",
+ ["Kh2MsetEditor"] = "Inspect Kingdom Hearts II animation sets.",
+ ["Kh2ObjectEditor"] = "Edit Kingdom Hearts II object data.",
+ ["Kh2SystemEditor"] = "Edit Kingdom Hearts II system data.",
+ ["Kh2TextEditor"] = "Edit game messages and text resources.",
+ ["LayoutEditor"] = "Edit 2D layouts and interface assets.",
+ ["MissionEditor"] = "Edit mission data.",
+ ["ObjentryEditor"] = "Edit object entry tables.",
+ };
+
+ private readonly List _allTools = new();
+ private OpenkhUpdateCheckerService.CheckResult? _availableUpdate;
+ private string BaseDirectory => AppContext.BaseDirectory;
+ private string ModManagerPath
+ {
+ get
+ {
+ var packagedPath = Path.Combine(
+ BaseDirectory,
+ ApplicationsDirectory,
+ ModManagerDirectory,
+ ModManagerExecutable
+ );
+
+ return File.Exists(packagedPath)
+ ? packagedPath
+ : Path.Combine(BaseDirectory, ModManagerExecutable);
+ }
+ }
+ private string AdvancedToolsPath => Path.Combine(BaseDirectory, AdvancedToolsDirectory);
+ private string CompatibilityModManagerPath => Path.Combine(BaseDirectory, ModManagerExecutable);
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ }
+
+ private void Window_Loaded(object sender, RoutedEventArgs e)
+ {
+ var version = FileVersionInfo.GetVersionInfo(Environment.ProcessPath!).ProductVersion;
+ VersionText.Text = string.IsNullOrWhiteSpace(version) ? string.Empty : $"Version {version}";
+
+ var modManagerAvailable = File.Exists(ModManagerPath);
+ LaunchModManagerButton.IsEnabled = modManagerAvailable;
+ CheckForUpdatesButton.IsEnabled = true;
+ CreateShortcutButton.IsEnabled = File.Exists(CompatibilityModManagerPath);
+ ModManagerStatusText.Text = modManagerAvailable ? string.Empty : "Mod Manager was not found";
+ ModManagerStatusText.Visibility = modManagerAvailable ? Visibility.Collapsed : Visibility.Visible;
+
+ LoadTools();
+ _ = RefreshUpdateAvailabilityAsync(showErrors: false, showProgress: false);
+ }
+
+ private void LoadTools()
+ {
+ _allTools.Clear();
+
+ if (Directory.Exists(AdvancedToolsPath))
+ {
+ _allTools.AddRange(
+ Directory.EnumerateFiles(AdvancedToolsPath, "OpenKh.Tools.*.exe", SearchOption.TopDirectoryOnly)
+ .Where(path => !Path.GetFileName(path).Equals(ModManagerExecutable, StringComparison.OrdinalIgnoreCase))
+ .Select(CreateToolEntry)
+ .OrderBy(tool => tool.DisplayName, StringComparer.CurrentCultureIgnoreCase)
+ );
+ }
+
+ ToolCountText.Text = _allTools.Count == 0
+ ? "Tools are installed with the full OpenKH package"
+ : $"{_allTools.Count} tools available";
+
+ ApplyToolFilter();
+ }
+
+ private static ToolEntry CreateToolEntry(string executablePath)
+ {
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(executablePath);
+ var shortName = fileNameWithoutExtension.StartsWith("OpenKh.Tools.", StringComparison.OrdinalIgnoreCase)
+ ? fileNameWithoutExtension["OpenKh.Tools.".Length..]
+ : fileNameWithoutExtension;
+ var displayName = HumanizeName(shortName);
+ var description = ToolDescriptions.TryGetValue(shortName, out var knownDescription)
+ ? knownDescription
+ : "Open a specialized OpenKH modding utility.";
+
+ return new ToolEntry(displayName, description, executablePath);
+ }
+
+ private static string HumanizeName(string value)
+ {
+ var result = Regex.Replace(value, "(?<=[a-z0-9])(?=[A-Z])", " ");
+ return result
+ .Replace("Kh1", "KH1", StringComparison.OrdinalIgnoreCase)
+ .Replace("Kh2", "KH2", StringComparison.OrdinalIgnoreCase)
+ .Replace("Bbs", "BBS", StringComparison.OrdinalIgnoreCase)
+ .Replace("Idx", "IDX", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void ApplyToolFilter()
+ {
+ var query = SearchBox?.Text?.Trim() ?? string.Empty;
+ var filteredTools = string.IsNullOrWhiteSpace(query)
+ ? _allTools
+ : _allTools
+ .Where(tool => tool.DisplayName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
+ || tool.Description.Contains(query, StringComparison.CurrentCultureIgnoreCase))
+ .ToList();
+
+ if (ToolsList != null)
+ ToolsList.ItemsSource = filteredTools;
+
+ if (ToolsStatusText != null)
+ {
+ ToolsStatusText.Text = !Directory.Exists(AdvancedToolsPath)
+ ? "The AdvancedTools folder is not available in this installation."
+ : $"Showing {filteredTools.Count} of {_allTools.Count} tools";
+ }
+ }
+
+ private void LaunchModManager_Click(object sender, RoutedEventArgs e) => Launch(ModManagerPath);
+
+ private async void CheckForUpdates_Click(object sender, RoutedEventArgs e)
+ {
+ CheckForUpdatesButton.IsEnabled = false;
+
+ try
+ {
+ var checkResult = _availableUpdate?.HasUpdate == true
+ ? _availableUpdate
+ : await RefreshUpdateAvailabilityAsync(showErrors: true, showProgress: true);
+ if (checkResult == null)
+ return;
+
+ if (!checkResult.HasUpdate)
+ {
+ var message = string.IsNullOrWhiteSpace(checkResult.CurrentVersion)
+ ? "No OpenKH update is currently available."
+ : $"The latest version '{checkResult.CurrentVersion}' is already installed.";
+ MessageBox.Show(this, message, "OpenKH Update", MessageBoxButton.OK, MessageBoxImage.Information);
+ return;
+ }
+
+ var updateMessage = "A new version of OpenKH is available.\n" +
+ $"Current: {checkResult.CurrentVersion}\n" +
+ $"Latest: {checkResult.NewVersion}\n\n" +
+ "Do you want to download and install it now?";
+ if (MessageBox.Show(
+ this,
+ updateMessage,
+ "OpenKH Update",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question
+ ) != MessageBoxResult.Yes)
+ {
+ return;
+ }
+
+ CheckForUpdatesButton.Content = "Downloading Update...";
+ var launcherPath = Path.Combine(OpenkhInstallation.Directory, "OpenKh.Launcher.exe");
+ await new OpenkhUpdateProceederService().UpdateAsync(
+ checkResult.DownloadZipUrl,
+ rate => Dispatcher.Invoke(() =>
+ CheckForUpdatesButton.Content = $"Downloading {rate:P0}"),
+ CancellationToken.None,
+ launcherPath
+ );
+
+ Application.Current.Shutdown();
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ this,
+ $"OpenKH could not check for or install updates.\n\n{exception.Message}",
+ "OpenKH Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+ finally
+ {
+ CheckForUpdatesButton.IsEnabled = true;
+ SetUpdateAvailability(_availableUpdate?.HasUpdate == true);
+ }
+ }
+
+ private async Task RefreshUpdateAvailabilityAsync(
+ bool showErrors,
+ bool showProgress
+ )
+ {
+ if (showProgress)
+ CheckForUpdatesButton.Content = "Checking for Updates...";
+
+ try
+ {
+ var checkResult = await new OpenkhUpdateCheckerService().CheckAsync(CancellationToken.None);
+ _availableUpdate = checkResult;
+ SetUpdateAvailability(checkResult.HasUpdate);
+ return checkResult;
+ }
+ catch (Exception exception)
+ {
+ _availableUpdate = null;
+ SetUpdateAvailability(false);
+
+ if (showErrors)
+ {
+ MessageBox.Show(
+ this,
+ $"OpenKH could not check for updates.\n\n{exception.Message}",
+ "OpenKH Update",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+
+ return null;
+ }
+ }
+
+ private void SetUpdateAvailability(bool updateAvailable)
+ {
+ CheckForUpdatesButton.Content = updateAvailable ? "Update Available" : "Check for Updates";
+ CheckForUpdatesButton.Foreground = new SolidColorBrush(updateAvailable
+ ? Color.FromRgb(127, 220, 173)
+ : Color.FromRgb(143, 185, 248));
+ }
+
+ private void CreateShortcut_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var shortcutPath = DesktopShortcutService.CreateModManagerShortcut(CompatibilityModManagerPath);
+ MessageBox.Show(
+ $"The OpenKH Mod Manager shortcut was created on your desktop.\n\n{shortcutPath}",
+ "Shortcut created",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information
+ );
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ $"OpenKH could not create the desktop shortcut.\n\n{exception.Message}",
+ "Unable to create shortcut",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+ }
+
+ private void ShowTools_Click(object sender, RoutedEventArgs e)
+ {
+ LoadTools();
+ HomePanel.Visibility = Visibility.Collapsed;
+ ToolsPanel.Visibility = Visibility.Visible;
+ SearchBox.Focus();
+ }
+
+ private void ShowHome_Click(object sender, RoutedEventArgs e)
+ {
+ ToolsPanel.Visibility = Visibility.Collapsed;
+ HomePanel.Visibility = Visibility.Visible;
+ }
+
+ private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) => ApplyToolFilter();
+
+ private void LaunchTool_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button { Tag: ToolEntry tool })
+ Launch(tool.ExecutablePath);
+ }
+
+ private void ToolsList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
+ {
+ if (ToolsList.SelectedItem is ToolEntry tool)
+ Launch(tool.ExecutablePath);
+ }
+
+ private void OpenToolsFolder_Click(object sender, RoutedEventArgs e)
+ {
+ if (!Directory.Exists(AdvancedToolsPath))
+ {
+ ShowMissingItem("The AdvancedTools folder was not found.");
+ return;
+ }
+
+ Launch(AdvancedToolsPath);
+ }
+
+ private void OpenDocumentation_Click(object sender, RoutedEventArgs e) => Launch("https://openkh.dev/");
+
+ private static void Launch(string target, params string[] arguments)
+ {
+ try
+ {
+ var workingDirectory = File.Exists(target)
+ ? Path.GetDirectoryName(target)
+ : AppContext.BaseDirectory;
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = target,
+ WorkingDirectory = workingDirectory,
+ UseShellExecute = true,
+ };
+
+ foreach (var argument in arguments)
+ startInfo.ArgumentList.Add(argument);
+
+ Process.Start(startInfo);
+ }
+ catch (Exception exception)
+ {
+ MessageBox.Show(
+ $"OpenKH could not open this item.\n\n{exception.Message}",
+ "Unable to open item",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error
+ );
+ }
+ }
+
+ private static void ShowMissingItem(string message)
+ {
+ MessageBox.Show(message, "Item not found", MessageBoxButton.OK, MessageBoxImage.Information);
+ }
+
+ private sealed record ToolEntry(string DisplayName, string Description, string ExecutablePath)
+ {
+ public string Initial => DisplayName.Length == 0 ? "?" : DisplayName[..1].ToUpperInvariant();
+ }
+}
diff --git a/OpenKh.Tools.Launcher/OpenKh.Tools.Launcher.csproj b/OpenKh.Tools.Launcher/OpenKh.Tools.Launcher.csproj
new file mode 100644
index 000000000..c0a4da0e3
--- /dev/null
+++ b/OpenKh.Tools.Launcher/OpenKh.Tools.Launcher.csproj
@@ -0,0 +1,33 @@
+
+
+
+ WinExe
+ net8.0-windows
+ true
+ enable
+ enable
+ OpenKh.Launcher
+ OpenKh.Tools.Launcher
+ OpenKH Launcher
+ OpenKH Launcher
+ OpenKH contributors
+ OpenKH
+ Start OpenKH applications from one organized place.
+ ..\images\openKH_Old.ico
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenKh.Tools.ModsManager/App.xaml.cs b/OpenKh.Tools.ModsManager/App.xaml.cs
index 85069278b..12a0e87de 100644
--- a/OpenKh.Tools.ModsManager/App.xaml.cs
+++ b/OpenKh.Tools.ModsManager/App.xaml.cs
@@ -32,7 +32,7 @@ private void LogUnhandledException(Exception exception, string source)
// Save the error to a log file
try
{
- string logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "crash_log.txt");
+ string logPath = Path.Combine(Services.OpenkhInstallation.Directory, "crash_log.txt");
File.AppendAllText(logPath, $"[{DateTime.Now}] {errorMessage}\n\n");
}
catch
diff --git a/OpenKh.Tools.ModsManager/Services/ConfigurationService.cs b/OpenKh.Tools.ModsManager/Services/ConfigurationService.cs
index 43cb69d80..e16cd1db4 100644
--- a/OpenKh.Tools.ModsManager/Services/ConfigurationService.cs
+++ b/OpenKh.Tools.ModsManager/Services/ConfigurationService.cs
@@ -74,7 +74,7 @@ public static Config Open(string fileName)
}
}
- private static string StoragePath = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
+ private static string StoragePath = OpenkhInstallation.Directory;
private static string ConfigPath = Path.Combine(StoragePath, "mods-manager.yml");
private static string EnabledModsPathKH1 = Path.Combine(StoragePath, "mods-KH1.txt");
private static string EnabledModsPathKH2 = Path.Combine(StoragePath, "mods-KH2.txt");
diff --git a/OpenKh.Tools.ModsManager/Services/OpenkhInstallation.cs b/OpenKh.Tools.ModsManager/Services/OpenkhInstallation.cs
new file mode 100644
index 000000000..92fe6a458
--- /dev/null
+++ b/OpenKh.Tools.ModsManager/Services/OpenkhInstallation.cs
@@ -0,0 +1,44 @@
+using System;
+using System.IO;
+
+namespace OpenKh.Tools.ModsManager.Services
+{
+ public static class OpenkhInstallation
+ {
+ private const string ModManagerExecutableName = "OpenKh.Tools.ModsManager.exe";
+
+ public static string Directory => GetDirectory(AppContext.BaseDirectory);
+
+ public static string GetDirectory(string applicationBaseDirectory)
+ {
+ var applicationDirectory = new DirectoryInfo(
+ Path.GetFullPath(applicationBaseDirectory).TrimEnd(
+ Path.DirectorySeparatorChar,
+ Path.AltDirectorySeparatorChar
+ )
+ );
+ var appsDirectory = applicationDirectory.Parent;
+ var installationDirectory = appsDirectory?.Parent;
+
+ return applicationDirectory.Name.Equals("ModManager", StringComparison.OrdinalIgnoreCase)
+ && appsDirectory?.Name.Equals("Apps", StringComparison.OrdinalIgnoreCase) == true
+ && installationDirectory != null
+ ? installationDirectory.FullName
+ : applicationDirectory.FullName;
+ }
+
+ public static string GetModManagerExecutable(string installationDirectory)
+ {
+ var packagedPath = Path.Combine(
+ installationDirectory,
+ "Apps",
+ "ModManager",
+ ModManagerExecutableName
+ );
+
+ return File.Exists(packagedPath)
+ ? packagedPath
+ : Path.Combine(installationDirectory, ModManagerExecutableName);
+ }
+ }
+}
diff --git a/OpenKh.Tools.ModsManager/Services/OpenkhUpdateCheckerService.cs b/OpenKh.Tools.ModsManager/Services/OpenkhUpdateCheckerService.cs
index 353e1c598..f187083be 100644
--- a/OpenKh.Tools.ModsManager/Services/OpenkhUpdateCheckerService.cs
+++ b/OpenKh.Tools.ModsManager/Services/OpenkhUpdateCheckerService.cs
@@ -44,7 +44,7 @@ public async Task CheckAsync(CancellationToken cancellation)
var remoteReleaseTag = latestAsset.Release.TagName;
- var localReleaseTagFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "openkh-release");
+ var localReleaseTagFile = Path.Combine(OpenkhInstallation.Directory, "openkh-release");
var localReleaseTag = File.Exists(localReleaseTagFile)
? File.ReadAllLines(localReleaseTagFile).First()
: "(Unknown version)";
diff --git a/OpenKh.Tools.ModsManager/Services/OpenkhUpdateProceederService.cs b/OpenKh.Tools.ModsManager/Services/OpenkhUpdateProceederService.cs
index bc996b9dc..b9a7c8905 100644
--- a/OpenKh.Tools.ModsManager/Services/OpenkhUpdateProceederService.cs
+++ b/OpenKh.Tools.ModsManager/Services/OpenkhUpdateProceederService.cs
@@ -11,7 +11,12 @@ namespace OpenKh.Tools.ModsManager.Services
{
public class OpenkhUpdateProceederService
{
- public async Task UpdateAsync(string downloadZipUrl, Action progress, CancellationToken cancellation)
+ public async Task UpdateAsync(
+ string downloadZipUrl,
+ Action progress,
+ CancellationToken cancellation,
+ string executableToRestart = ""
+ )
{
var tempId = Guid.NewGuid().ToString("N");
var tempZipFile = Path.Combine(Path.GetTempPath(), $"openkh-{tempId}.zip");
@@ -40,13 +45,26 @@ public async Task UpdateAsync(string downloadZipUrl, Action progress, Can
File.Delete(tempZipFile);
var tempBatFile = Path.Combine(Path.GetTempPath(), $"openkh-{tempId}.bat");
- var copyTo = AppDomain.CurrentDomain.BaseDirectory;
-
- await CreateBatchFileAsync(
- tempBatFile: tempBatFile,
- copyFrom: Path.Combine(tempZipDir, "openkh"),
- copyTo: copyTo,
- execAfter: $"start \"\" \"{Path.Combine(copyTo, "OpenKh.Tools.ModsManager.exe")}\""
+ var copyFrom = Path.Combine(tempZipDir, "openkh");
+ var copyTo = OpenkhInstallation.Directory;
+ var packagedModManagerExecutable = Path.Combine(
+ copyFrom,
+ "Apps",
+ "ModManager",
+ "OpenKh.Tools.ModsManager.exe"
+ );
+ var modManagerExecutable = File.Exists(packagedModManagerExecutable)
+ ? Path.Combine(copyTo, "Apps", "ModManager", "OpenKh.Tools.ModsManager.exe")
+ : OpenkhInstallation.GetModManagerExecutable(copyTo);
+ var restartExecutable = string.IsNullOrWhiteSpace(executableToRestart)
+ ? modManagerExecutable
+ : executableToRestart;
+ await CreateBatchFileAsync(
+ tempBatFile: tempBatFile,
+ copyFrom: copyFrom,
+ copyTo: copyTo,
+ processToStop: Path.GetFileName(restartExecutable),
+ execAfter: $"start \"\" \"{restartExecutable}\""
);
Process.Start(
@@ -79,14 +97,20 @@ private async Task CopyToAsyncWithProgress(Stream input, Stream output, long? ma
}
}
- private async Task CreateBatchFileAsync(string tempBatFile, string copyFrom, string copyTo, string execAfter)
+ private async Task CreateBatchFileAsync(
+ string tempBatFile,
+ string copyFrom,
+ string copyTo,
+ string processToStop,
+ string execAfter
+ )
{
var bat = new StringWriter();
bat.WriteLine($"chcp 65001");
- bat.WriteLine($"taskkill /im OpenKh.Tools.ModsManager.exe");
+ bat.WriteLine($"taskkill /im {EscapeRobocopyArg(processToStop)}");
bat.WriteLine($"robocopy {EscapeRobocopyArg(copyFrom)} {EscapeRobocopyArg(copyTo)} /e");
bat.WriteLine($"if errorlevel 8 pause");
- bat.WriteLine($"{execAfter}");
+ bat.WriteLine($"{execAfter}");
bat.WriteLine($"rd /s /q \"{copyFrom}\"");
bat.WriteLine($"del %0");
await File.WriteAllTextAsync(tempBatFile, bat.ToString(), Encoding.UTF8);
diff --git a/OpenKh.sln b/OpenKh.sln
index d031d1f30..65f4f3209 100644
--- a/OpenKh.sln
+++ b/OpenKh.sln
@@ -180,6 +180,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Tools.MissionEditor"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Tools.ModsManager", "OpenKh.Tools.ModsManager\OpenKh.Tools.ModsManager.csproj", "{A1BCC739-3BDC-41AF-BD0B-78C5E55F88A2}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Tools.Launcher", "OpenKh.Tools.Launcher\OpenKh.Tools.Launcher.csproj", "{B33409B4-C097-4CE2-98A7-36C4FF41BCFA}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenKh.Kh1", "OpenKh.Kh1\OpenKh.Kh1.csproj", "{EB189B6E-AFA0-463D-8DE8-A51ED32969FD}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "OpenKh.Research.Panacea", "OpenKh.Research.Panacea\OpenKh.Research.Panacea.vcxproj", "{3EF82533-6397-496F-A70F-429BB3611A17}"
@@ -744,6 +746,14 @@ Global
{A1BCC739-3BDC-41AF-BD0B-78C5E55F88A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1BCC739-3BDC-41AF-BD0B-78C5E55F88A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1BCC739-3BDC-41AF-BD0B-78C5E55F88A2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}..NET Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}..NET Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}..NET Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}..NET Release|Any CPU.Build.0 = Release|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA}.Release|Any CPU.Build.0 = Release|Any CPU
{EB189B6E-AFA0-463D-8DE8-A51ED32969FD}..NET Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB189B6E-AFA0-463D-8DE8-A51ED32969FD}..NET Debug|Any CPU.Build.0 = Debug|Any CPU
{EB189B6E-AFA0-463D-8DE8-A51ED32969FD}..NET Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -1015,6 +1025,7 @@ Global
{940E9927-13D3-4CC7-8034-37982E3885C2} = {402B2669-D594-4DFA-965B-02A92626ADC6}
{A88E268F-A8CC-48E4-8AE9-5CC7A774022D} = {402B2669-D594-4DFA-965B-02A92626ADC6}
{A1BCC739-3BDC-41AF-BD0B-78C5E55F88A2} = {402B2669-D594-4DFA-965B-02A92626ADC6}
+ {B33409B4-C097-4CE2-98A7-36C4FF41BCFA} = {402B2669-D594-4DFA-965B-02A92626ADC6}
{EB189B6E-AFA0-463D-8DE8-A51ED32969FD} = {0FB7CD6A-EE31-467D-A590-267DCD028618}
{3EF82533-6397-496F-A70F-429BB3611A17} = {9AEB4887-7C23-4992-A26D-A1E73DC4FE53}
{C2DB9627-2FE5-4BD9-8271-D3A86DACBA30} = {402B2669-D594-4DFA-965B-02A92626ADC6}
diff --git a/README.md b/README.md
index 5daad7418..cf6c266f9 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,10 @@ New builds of OpenKH are automatically generated every time one of the contribut
All the builds from `master` and from pull requestes are generated from [GitHub Actions](https://github.com/OpenKh/OpenKh/actions).
+After extracting a release, open `OpenKh.Launcher.exe`. The launcher presents the Mod Manager as the recommended option for installing and playing mods, while editors, converters, and command-line utilities for mod creators are kept in the separate `AdvancedTools` section. Application dependencies are organized under `Apps`, leaving the release root with a single visible executable entry point. The launcher checks for update availability in the background and displays an indicator when a new version exists. Nothing is downloaded or installed until the user selects the update action and confirms it. Updates are applied without opening the Mod Manager. The launcher can also create a Mod Manager desktop shortcut.
+
+Existing installations are migrated to the organized layout when the updated launcher or an existing Mod Manager shortcut is opened. Mod Manager settings, presets, mod lists, and user content remain available while obsolete application files are removed from the release root. A hidden compatibility entry point remains in the release root so existing shortcuts continue to work after migration and future updates.
+
OpenKH tools require the installation of the [.NET 8.0 Runtime](https://dotnet.microsoft.com/download/dotnet/8.0). All the UI tools are designed to work on Windows, while command line tools will work on any operating system.
diff --git a/distribution/README-FIRST.txt b/distribution/README-FIRST.txt
new file mode 100644
index 000000000..a2c739750
--- /dev/null
+++ b/distribution/README-FIRST.txt
@@ -0,0 +1,31 @@
+OPENKH QUICK START
+==================
+
+Start OpenKH by opening:
+
+ OpenKh.Launcher.exe
+
+Choose "Open Mod Manager" to install and play mods. You do not need any of the
+programs in AdvancedTools.
+
+Use "Create Desktop Shortcut" for direct access to the Mod Manager. Use "Check
+for Updates" in the launcher footer to check and install OpenKH updates without
+opening the Mod Manager. The launcher marks available updates automatically, but
+does not download or install them until you select the update action and confirm.
+
+The Apps folder contains application dependencies and should remain next to the
+launcher. There is no need to open it manually.
+
+When updating an older OpenKH installation, settings, presets, mod lists, and
+user content are preserved while obsolete application files are removed.
+Existing Mod Manager shortcuts continue to work after the migration.
+
+ADVANCED TOOLS
+==============
+
+The launcher keeps editors, converters, command-line programs, and research
+utilities in a separate section for mod creators. The same programs are stored
+in the AdvancedTools folder.
+
+Documentation: https://openkh.dev/
+Support: https://discord.openkh.dev/
diff --git a/images/openKH_Old.ico b/images/openKH_Old.ico
new file mode 100644
index 000000000..a3bc74f31
Binary files /dev/null and b/images/openKH_Old.ico differ
diff --git a/images/openkh-launcher-home.png b/images/openkh-launcher-home.png
new file mode 100644
index 000000000..6bc25a37f
Binary files /dev/null and b/images/openkh-launcher-home.png differ
diff --git a/images/openkh-launcher-tools.png b/images/openkh-launcher-tools.png
new file mode 100644
index 000000000..1ff0140f7
Binary files /dev/null and b/images/openkh-launcher-tools.png differ