diff --git a/src/Metin2Launcher/Config/InstallDir.cs b/src/Metin2Launcher/Config/InstallDir.cs
new file mode 100644
index 0000000..d16006e
--- /dev/null
+++ b/src/Metin2Launcher/Config/InstallDir.cs
@@ -0,0 +1,136 @@
+using System.Diagnostics;
+
+namespace Metin2Launcher.Config;
+
+///
+/// Resolves the install directory the launcher operates on, with strict
+/// safety rules so the updater can never clobber a user's home directory.
+///
+/// The contract, in priority order:
+///
+/// 1. The install directory is the directory containing the running
+/// launcher executable (Process.MainModule.FileName). It is NOT
+/// Directory.GetCurrentDirectory() — a user who runs
+/// wine /path/to/Metin2Launcher.exe from their home directory
+/// must not cause the launcher to treat their home as the install dir.
+///
+/// 2. The resolved directory is rejected if it matches a known-dangerous
+/// target: the user's home directory itself, common system directories,
+/// or the filesystem root. In that case the launcher refuses to run
+/// with throwing .
+///
+/// 3. If METIN2_INSTALL_DIR is set in the environment, that value
+/// wins over the exe location. Used for local dev and CI, where the
+/// launcher is run from bin/Release/net8.0/ and we want the
+/// installed client to land in a separate sandbox.
+///
+/// There is no CLI override. The previous --install-dir flag was
+/// silently ignored anyway; re-adding it without thinking through the
+/// safety semantics would reintroduce the same footgun.
+///
+public static class InstallDir
+{
+ public sealed class UnsafeInstallDirException : Exception
+ {
+ public UnsafeInstallDirException(string message) : base(message) { }
+ }
+
+ public const string EnvOverride = "METIN2_INSTALL_DIR";
+
+ public static string Resolve()
+ {
+ var candidate = ResolveCandidate();
+ var full = Path.GetFullPath(candidate);
+ AssertSafe(full);
+ return full;
+ }
+
+ private static string ResolveCandidate()
+ {
+ var env = Environment.GetEnvironmentVariable(EnvOverride);
+ if (!string.IsNullOrWhiteSpace(env))
+ return env;
+
+ var exePath = Process.GetCurrentProcess().MainModule?.FileName;
+ if (!string.IsNullOrEmpty(exePath))
+ {
+ var dir = Path.GetDirectoryName(exePath);
+ if (!string.IsNullOrEmpty(dir))
+ return dir;
+ }
+
+ // Last-resort fallback: current working directory. This is the
+ // legacy behaviour and is inherently unsafe, so we still run the
+ // safety gate below on whatever it resolves to.
+ return Directory.GetCurrentDirectory();
+ }
+
+ private static void AssertSafe(string full)
+ {
+ // Normalize trailing separator for comparison.
+ var normalized = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (normalized.Length == 0)
+ normalized = Path.DirectorySeparatorChar.ToString();
+
+ foreach (var danger in DangerousDirs())
+ {
+ if (string.Equals(normalized, danger, StringComparison.OrdinalIgnoreCase))
+ throw new UnsafeInstallDirException(
+ $"refusing to use {full} as install directory — this is a user/system " +
+ $"location. Create a dedicated folder (e.g. {SuggestedExample()}) " +
+ $"and run the launcher from inside it, or set {EnvOverride}=.");
+ }
+ }
+
+ private static string SuggestedExample()
+ {
+ if (OperatingSystem.IsWindows())
+ return @"C:\Games\Metin2";
+ var home = Environment.GetEnvironmentVariable("HOME") ?? "/home/you";
+ return $"{home}/Games/Metin2";
+ }
+
+ private static IEnumerable DangerousDirs()
+ {
+ var list = new List();
+
+ // User home dir and common subdirs a user would *not* want
+ // overwritten by a game client installer.
+ var home = Environment.GetEnvironmentVariable("HOME")
+ ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrEmpty(home))
+ {
+ list.Add(home.TrimEnd(Path.DirectorySeparatorChar));
+ foreach (var sub in new[] { "Desktop", "Documents", "Downloads", "Music",
+ "Pictures", "Videos", "plocha", "Dokumenty",
+ "Stažené" })
+ {
+ list.Add(Path.Combine(home, sub).TrimEnd(Path.DirectorySeparatorChar));
+ }
+ }
+
+ // Filesystem roots and system dirs.
+ if (OperatingSystem.IsWindows())
+ {
+ foreach (var drive in new[] { "C:", "D:", "E:", "F:" })
+ {
+ list.Add(drive);
+ list.Add(drive + @"\");
+ list.Add(drive + @"\Windows");
+ list.Add(drive + @"\Program Files");
+ list.Add(drive + @"\Program Files (x86)");
+ list.Add(drive + @"\Users");
+ }
+ }
+ else
+ {
+ foreach (var d in new[] { "/", "/bin", "/boot", "/dev", "/etc", "/home",
+ "/lib", "/lib64", "/media", "/mnt", "/opt",
+ "/proc", "/root", "/run", "/sbin", "/srv",
+ "/sys", "/tmp", "/usr", "/var" })
+ list.Add(d);
+ }
+
+ return list;
+ }
+}
diff --git a/src/Metin2Launcher/Program.cs b/src/Metin2Launcher/Program.cs
index 97a6974..166a773 100644
--- a/src/Metin2Launcher/Program.cs
+++ b/src/Metin2Launcher/Program.cs
@@ -23,7 +23,24 @@ public static class Program
// install / firstrun / uninstall hooks without the rest of our code running.
VelopackApp.Build().SetArgs(args).Run();
- var clientRoot = Directory.GetCurrentDirectory();
+ // Resolve the install directory via InstallDir (not CWD!) so users
+ // who run `wine /path/to/Metin2Launcher.exe` from any shell CWD
+ // still install into the dir the exe lives in. Dangerous locations
+ // (user home, system dirs) are refused outright.
+ string clientRoot;
+ try
+ {
+ clientRoot = InstallDir.Resolve();
+ }
+ catch (InstallDir.UnsafeInstallDirException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ return 4;
+ }
+ // Set CWD so downstream code using Directory.GetCurrentDirectory()
+ // lands in the same place as clientRoot. Keeps invariants simple.
+ Directory.SetCurrentDirectory(clientRoot);
+
var stateDir = Path.Combine(clientRoot, LauncherConfig.StateDirName);
Directory.CreateDirectory(stateDir);
Log.SetLogFilePath(Path.Combine(stateDir, "launcher.log"));