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);
+ }
}