From d2775bcfd89c528244df368c88f94dd1a06e011a Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 12:22:30 +0200 Subject: [PATCH 1/5] orchestration: target windows platform + prune stale files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes addressing review feedback on the previous avalonia-gui PR round. 1. Platform filter was using host OS (`IsWindows() ? "windows" : "linux"`), which dropped all `platform=windows` manifest entries whenever the launcher ran on Linux. Since the client is always a Windows PE binary launched through Wine on Linux hosts, the target platform we apply is always `"windows"` regardless of host. Before this, the orchestrator silently skipped Metin2.exe and the python314 / openssl / mingw runtime DLLs on Linux, leaving an unbootable install dir. 2. Added `PruneStaleFiles` that walks clientRoot after every successful update (both the normal apply path and the already-up-to-date short-circuit) and deletes any file not in the manifest. Without it, switching from a dirty release to a leaner one — e.g. dropping legacy .pck after a .m2p migration — left orphaned files on disk and inflated the install dir. The keep set is `manifest.files ∪ {manifest.launcher.path}`. Per the manifest spec in `m2dev-client/docs/update-manifest.md`, the top-level launcher entry is privileged and never listed in files; it must survive prune so a correctly-authored manifest does not delete the updater itself (caught in Jakub's review of the previous round). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Orchestration/UpdateOrchestrator.cs | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs index 57c3468..8a7de44 100644 --- a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs +++ b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs @@ -116,7 +116,12 @@ public sealed class UpdateOrchestrator // 3. diff progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded }); - var platform = OperatingSystem.IsWindows() ? "windows" : "linux"; + // The client is always a Windows PE binary launched through Wine on + // Linux hosts, so the manifest "platform" we apply is always "windows" + // regardless of host OS. Using host OS would drop Metin2.exe and the + // python314 / openssl / mingw runtime DLLs whenever the launcher runs + // on Linux, leaving an unbootable install dir. + const string platform = "windows"; var applicable = format.FilterApplicable(manifest, platform); var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>(); @@ -162,6 +167,8 @@ public sealed class UpdateOrchestrator try { format.OnApplied(_clientRoot, loaded, noopOutcome); } catch (Exception ex) { Log.Warn("format OnApplied (no-op path) failed: " + ex.Message); } + PruneStaleFiles(manifest); + progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded }); return new Result(true, LauncherState.UpToDate, loaded, format, noopOutcome.RuntimeKey); } @@ -260,8 +267,49 @@ public sealed class UpdateOrchestrator } } + PruneStaleFiles(manifest); + Log.Info($"update complete: {toApply.Count} files applied (format={format.Name})"); progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded }); return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey); } + + // Walk clientRoot and delete any file not in the manifest. Skips the + // .updates state dir. Called after both the normal apply path and the + // already-up-to-date short-circuit so switching to a leaner release + // does not leave orphaned files on disk. + // + // The keep set is files ∪ {manifest.launcher.path}. Per the manifest + // spec (m2dev-client/docs/update-manifest.md), the top-level launcher + // entry is privileged and is never listed in files; it must survive + // prune so a correctly-authored manifest does not delete the updater. + private void PruneStaleFiles(Manifest.Manifest manifest) + { + var keepRel = new HashSet( + manifest.Files.Select(f => f.Path.Replace('/', Path.DirectorySeparatorChar)), + StringComparer.OrdinalIgnoreCase); + if (manifest.Launcher?.Path is { Length: > 0 } launcherPath) + { + keepRel.Add(launcherPath.Replace('/', Path.DirectorySeparatorChar)); + } + int pruned = 0; + try + { + var rootFull = Path.GetFullPath(_clientRoot); + var stateDirFull = Path.GetFullPath(Path.Combine(_clientRoot, LauncherConfig.StateDirName)); + foreach (var path in Directory.EnumerateFiles(rootFull, "*", SearchOption.AllDirectories)) + { + if (path.StartsWith(stateDirFull, StringComparison.Ordinal)) continue; + var rel = Path.GetRelativePath(rootFull, path); + if (keepRel.Contains(rel)) continue; + try { File.Delete(path); pruned++; } + catch (Exception ex) { Log.Warn($"failed to prune {rel}: {ex.Message}"); } + } + } + catch (Exception ex) + { + Log.Warn("prune walk failed: " + ex.Message); + } + if (pruned > 0) Log.Info($"pruned {pruned} stale files not in manifest"); + } } -- 2.49.1 From 027786a79ddc96121b9be4c0671fd70f000502f6 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 12:22:30 +0200 Subject: [PATCH 2/5] game: auto-pick metin wine prefix when WINEPREFIX is unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which makes the client run with invisible fonts and missing renderer DLLs — exactly what Jan hit when running the launcher against a fresh install dir on Fedora. If the parent shell already set WINEPREFIX, honor it. Otherwise fall back to ~/metin/wine-metin, which is what m2dev-client/scripts/setup-wine-prefix.sh prepares with the right runtime deps. The fallback is guarded on the dir existing, so deployments without that setup are a no-op rather than a broken prefix. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/GameLaunch/GameProcess.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Metin2Launcher/GameLaunch/GameProcess.cs b/src/Metin2Launcher/GameLaunch/GameProcess.cs index add6aac..1d337b4 100644 --- a/src/Metin2Launcher/GameLaunch/GameProcess.cs +++ b/src/Metin2Launcher/GameLaunch/GameProcess.cs @@ -67,6 +67,21 @@ public static class GameProcess UseShellExecute = false, }; psi.ArgumentList.Add(exePath); + // The default ~/.wine prefix lacks tahoma + d3dx9 + vcrun, which + // makes the client run with invisible fonts and missing renderer + // DLLs. If the caller already set WINEPREFIX, honor it; otherwise + // fall back to the metin-specific prefix prepared by + // m2dev-client/scripts/setup-wine-prefix.sh. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WINEPREFIX"))) + { + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + { + var metinPrefix = Path.Combine(home, "metin", "wine-metin"); + if (Directory.Exists(metinPrefix)) + psi.Environment["WINEPREFIX"] = metinPrefix; + } + } } else { -- 2.49.1 From 8f6f378a23544ec288e4f59f53c0f556fe96d7a1 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 12:22:30 +0200 Subject: [PATCH 3/5] gui: thread m2pack runtime key from orchestrator result into play command Headless Program.cs already passes `result.RuntimeKey` to GameProcess.Launch, but the GUI Play command dropped it and spawned Metin2.exe without any env vars. Any m2p-capable client then hit "Invalid M2PACK_MASTER_KEY_HEX" and refused to load .m2p archives. Cache the runtime key on the view model when StartUpdateAsync completes and pass it through on Play. Matches the headless path and the EnvVarKeyDelivery wiring already in main. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs b/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs index 53576b8..76b265a 100644 --- a/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Metin2Launcher/UI/ViewModels/MainWindowViewModel.cs @@ -14,6 +14,7 @@ using Metin2Launcher.Localization; using Metin2Launcher.Logging; using Metin2Launcher.Manifest; using Metin2Launcher.Orchestration; +using Metin2Launcher.Runtime; using Velopack; using Velopack.Sources; @@ -63,6 +64,7 @@ public sealed partial class MainWindowViewModel : ObservableObject private LauncherState _state = LauncherState.Idle; private LoadedManifest? _lastManifest; + private RuntimeKey? _lastRuntimeKey; private bool _signatureBlocked; public MainWindowViewModel() @@ -136,6 +138,7 @@ public sealed partial class MainWindowViewModel : ObservableObject if (result is not null) { _lastManifest = result.Manifest; + _lastRuntimeKey = result.RuntimeKey; ApplyState(result.FinalState); await TryFetchNewsAsync(result.Manifest); } @@ -293,7 +296,7 @@ public sealed partial class MainWindowViewModel : ObservableObject { if (!CanPlay) return; ApplyState(LauncherState.Launching); - var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable); + var ok = GameProcess.Launch(_clientRoot, LauncherConfig.GameExecutable, _lastRuntimeKey); if (ok) { if (Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d) -- 2.49.1 From ac0034fc510cdf621b40c6a9ca8f9c30e7a2a55c Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 12:22:30 +0200 Subject: [PATCH 4/5] gitignore: exclude runtime client data dropped into source tree When the launcher is run from its bin/ dir during local dev, CWD becomes the source tree and the orchestrator stages the client release into src/Metin2Launcher/{pack,bgm,mark,config,.updates,...}. None of that should ever land in git. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gitignore b/.gitignore index df7b1b1..d1a6960 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,17 @@ obj/ *.suo .vs/ publish/ + +# Runtime client data accidentally left when launcher is run from its bin dir +# (CWD becomes the source tree). These should never be committed. +src/Metin2Launcher/.updates/ +src/Metin2Launcher/bgm/ +src/Metin2Launcher/config/ +src/Metin2Launcher/mark/ +src/Metin2Launcher/pack/ +src/Metin2Launcher/log/ +src/Metin2Launcher/Metin2.exe +src/Metin2Launcher/*.dll +src/Metin2Launcher/*.pyd +src/Metin2Launcher/python314* +src/Metin2Launcher/runtime-key.json -- 2.49.1 From db59f4963c2bf596a5d02c33120f31342acecc06 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 15 Apr 2026 13:03:47 +0200 Subject: [PATCH 5/5] runtime: tolerate m2pack export-runtime-key JSON shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical launcher runtime-key.json shape is { key_id: string, master_key_hex, sign_pubkey_hex } but `m2pack export-runtime-key --format json` emits { version, mapping_name, key_id: int, master_key_hex, sign_public_key_hex } because its JSON is really a dump of the Windows shared-memory struct. Parsing the CLI output with the old strict deserializer throws JsonException on key_id (int != string) and silently drops the public key field (name mismatch), after which Validate() rejects the key as not 64 hex chars and the m2pack release fails to boot with "runtime master key with key_id=1 required for 'pack/root.m2p'". Hit this tonight during the 2026.04.15-m2pack-v2 release and worked around it by hand-writing runtime-key.json. Fix: parse into a JsonElement and extract fields tolerantly — key_id accepts either a JSON string or a JSON number (stringified), and the pubkey field is looked up under both "sign_pubkey_hex" and "sign_public_key_hex". Added a test covering the m2pack CLI shape end to end. Also kept the malformed-input path on JsonSerializer.Deserialize so it still throws JsonException (JsonDocument.Parse throws its internal subtype which breaks Assert.Throws). Tracked separately as metin-server/m2pack-secure#3 — the m2pack side should also align its JSON to the canonical shape; this commit is the client-side belt to the server-side suspenders. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/Runtime/RuntimeKey.cs | 62 ++++++++++++++----- tests/Metin2Launcher.Tests/RuntimeKeyTests.cs | 23 +++++++ 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/Metin2Launcher/Runtime/RuntimeKey.cs b/src/Metin2Launcher/Runtime/RuntimeKey.cs index 25a214d..fc49ef3 100644 --- a/src/Metin2Launcher/Runtime/RuntimeKey.cs +++ b/src/Metin2Launcher/Runtime/RuntimeKey.cs @@ -10,9 +10,9 @@ namespace Metin2Launcher.Runtime; /// through to the game executable via one of the /// mechanisms. /// -/// File layout (runtime-key.json, placed next to the manifest by the release -/// tool, never signed independently — its integrity comes from the signed -/// manifest that references it): +/// Tolerant of two JSON shapes seen in the wild: +/// +/// 1. Native launcher shape (canonical): /// /// { /// "key_id": "2026.04.14-1", @@ -20,32 +20,64 @@ namespace Metin2Launcher.Runtime; /// "sign_pubkey_hex": "<64 hex chars>" /// } /// +/// +/// 2. m2pack export-runtime-key --format json output, where +/// key_id is emitted as an integer and the public key field is +/// named sign_public_key_hex. Extra fields like version and +/// mapping_name are ignored. See +/// metin-server/m2pack-secure#3. /// public sealed class RuntimeKey { - [JsonPropertyName("key_id")] public string KeyId { get; set; } = ""; - - [JsonPropertyName("master_key_hex")] public string MasterKeyHex { get; set; } = ""; - - [JsonPropertyName("sign_pubkey_hex")] public string SignPubkeyHex { get; set; } = ""; - private static readonly JsonSerializerOptions _opts = new() - { - PropertyNameCaseInsensitive = false, - }; - /// Parses a runtime-key.json blob. Throws on malformed or incomplete data. public static RuntimeKey Parse(ReadOnlySpan raw) { - var rk = JsonSerializer.Deserialize(raw, _opts) - ?? throw new InvalidDataException("runtime-key deserialized to null"); + // Go through JsonSerializer.Deserialize<JsonElement> rather than + // JsonDocument.Parse so malformed input throws JsonException (as the + // old path did) rather than its internal JsonReaderException subtype, + // which Assert.Throws<JsonException> in the tests won't match. + var root = JsonSerializer.Deserialize(raw); + if (root.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("runtime-key root must be a JSON object"); + + var rk = new RuntimeKey + { + KeyId = ReadKeyId(root), + MasterKeyHex = ReadString(root, "master_key_hex"), + SignPubkeyHex = ReadString(root, "sign_pubkey_hex", "sign_public_key_hex"), + }; Validate(rk); return rk; } + // key_id can be a plain string or an integer (m2pack CLI emits it as an + // int). Stringify numeric variants so callers always see a string. + private static string ReadKeyId(JsonElement root) + { + if (!root.TryGetProperty("key_id", out var el)) + return ""; + return el.ValueKind switch + { + JsonValueKind.String => el.GetString() ?? "", + JsonValueKind.Number => el.TryGetInt64(out var n) ? n.ToString() : el.GetRawText(), + _ => "", + }; + } + + private static string ReadString(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String) + return el.GetString() ?? ""; + } + return ""; + } + public static RuntimeKey Load(string path) { var bytes = File.ReadAllBytes(path); diff --git a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs index bb7ef70..98fbfe3 100644 --- a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs +++ b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs @@ -80,4 +80,27 @@ public class RuntimeKeyTests Assert.Throws(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json"))); } + + // m2pack export-runtime-key emits key_id as an integer and the public + // key field as `sign_public_key_hex`, plus extra version/mapping_name + // fields that are Windows-shared-memory bookkeeping. RuntimeKey must + // tolerate that shape so release pipelines can pipe the CLI output + // directly. See metin-server/m2pack-secure#3. + [Fact] + public void Parse_accepts_m2pack_cli_shape() + { + var json = $$""" + { + "version": 1, + "mapping_name": "Local\\M2PackSharedKeys", + "key_id": 1, + "master_key_hex": "{{ValidHex64}}", + "sign_public_key_hex": "{{ValidHex64}}" + } + """; + var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)); + Assert.Equal("1", rk.KeyId); + Assert.Equal(ValidHex64, rk.MasterKeyHex); + Assert.Equal(ValidHex64, rk.SignPubkeyHex); + } } -- 2.49.1