diff --git a/src/Metin2Launcher/Formats/IReleaseFormat.cs b/src/Metin2Launcher/Formats/IReleaseFormat.cs
new file mode 100644
index 0000000..c1bc339
--- /dev/null
+++ b/src/Metin2Launcher/Formats/IReleaseFormat.cs
@@ -0,0 +1,52 @@
+using Metin2Launcher.Manifest;
+
+namespace Metin2Launcher.Formats;
+
+///
+/// Strategy that knows how to interpret the files array of a
+/// signature-verified manifest. Added for the m2pack migration:
+///
+/// - legacy-json-blob ships individual files that replace their
+/// counterparts inside the client root one-for-one.
+/// - m2pack ships .m2p pack archives and a runtime-key.json
+/// sidecar. The archives are placed next to the client root and the
+/// runtime key is forwarded to the game process via env vars — the
+/// launcher NEVER opens, decrypts or decompresses an .m2p archive.
+///
+/// Both implementations share the same download/apply plumbing in
+/// . This interface only
+/// captures the pieces that actually differ between the two formats.
+///
+public interface IReleaseFormat
+{
+ /// Identifier matching .
+ string Name { get; }
+
+ ///
+ /// Filters the manifest file list down to entries that should be
+ /// downloaded on the given platform. Both formats currently delegate
+ /// to ; the indirection
+ /// exists so a future format can special-case (e.g. exclude the
+ /// runtime key from platform filtering, or reject unknown kinds).
+ ///
+ IReadOnlyList FilterApplicable(Manifest.Manifest manifest, string platform);
+
+ ///
+ /// Runs after the apply phase succeeds. The legacy format is a no-op;
+ /// the m2pack format loads the runtime key from the client root and
+ /// stores it on so the caller can forward it
+ /// to the game process.
+ ///
+ void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome);
+}
+
+///
+/// Mutable bag of results emitted by .
+/// Kept separate from the update orchestrator's own Result record so
+/// format-specific data doesn't leak into the generic update flow.
+///
+public sealed class ReleaseOutcome
+{
+ /// Populated by the m2pack format; null for legacy releases.
+ public Runtime.RuntimeKey? RuntimeKey { get; set; }
+}
diff --git a/src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs b/src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs
new file mode 100644
index 0000000..c57000e
--- /dev/null
+++ b/src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs
@@ -0,0 +1,27 @@
+using Metin2Launcher.Manifest;
+
+namespace Metin2Launcher.Formats;
+
+///
+/// The original release format: the manifest's files array lists
+/// individual files (exe, dlls, packs, locale/<pre-m2pack>) that the
+/// launcher downloads by sha256 and atomically replaces inside the client
+/// root. Preserved unchanged so an install that still points at an old
+/// manifest endpoint works identically.
+///
+public sealed class LegacyJsonBlobFormat : IReleaseFormat
+{
+ public const string FormatName = "legacy-json-blob";
+
+ public string Name => FormatName;
+
+ public IReadOnlyList FilterApplicable(Manifest.Manifest manifest, string platform)
+ {
+ return manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
+ }
+
+ public void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome)
+ {
+ // Nothing format-specific happens after apply for the legacy flow.
+ }
+}
diff --git a/src/Metin2Launcher/Formats/M2PackFormat.cs b/src/Metin2Launcher/Formats/M2PackFormat.cs
new file mode 100644
index 0000000..fbb0069
--- /dev/null
+++ b/src/Metin2Launcher/Formats/M2PackFormat.cs
@@ -0,0 +1,59 @@
+using Metin2Launcher.Logging;
+using Metin2Launcher.Manifest;
+using Metin2Launcher.Runtime;
+
+namespace Metin2Launcher.Formats;
+
+///
+/// m2pack release format.
+///
+/// The manifest still has a flat files list, but the entries are now
+/// content-addressed .m2p archives plus a runtime-key.json
+/// sidecar. Signing continues to happen on the top-level manifest.json
+/// — the .m2p files are each a single sha256 entry, and the launcher
+/// NEVER opens them. Decryption, decompression and integrity of the contents
+/// is the game client's problem, gated on the runtime key this class loads.
+///
+/// The runtime-key.json sidecar is listed in the manifest like any other file
+/// and goes through the same sha256 round trip, so tamper resistance of the
+/// key itself comes from the Ed25519 signature over the enclosing manifest.
+///
+public sealed class M2PackFormat : IReleaseFormat
+{
+ public const string FormatName = "m2pack";
+
+ /// Conventional filename of the runtime key sidecar inside the client root.
+ public const string RuntimeKeyFileName = "runtime-key.json";
+
+ public string Name => FormatName;
+
+ public IReadOnlyList FilterApplicable(Manifest.Manifest manifest, string platform)
+ {
+ // Same platform filter as legacy; runtime-key.json is platform=all by convention.
+ return manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
+ }
+
+ public void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome)
+ {
+ var keyPath = Path.Combine(clientRoot, RuntimeKeyFileName);
+ if (!File.Exists(keyPath))
+ {
+ // An m2pack manifest without a runtime-key.json is a release-tool bug,
+ // but we don't hard-fail here: the game will refuse to start without the
+ // env vars and surface the real error to the user with better context.
+ Log.Warn($"m2pack: runtime key file not found at {keyPath}");
+ return;
+ }
+
+ try
+ {
+ outcome.RuntimeKey = RuntimeKey.Load(keyPath);
+ Log.Info($"m2pack: loaded runtime key {outcome.RuntimeKey.KeyId}");
+ }
+ catch (Exception ex)
+ {
+ Log.Error($"m2pack: failed to parse runtime key {keyPath}", ex);
+ throw;
+ }
+ }
+}
diff --git a/src/Metin2Launcher/Formats/ReleaseFormatFactory.cs b/src/Metin2Launcher/Formats/ReleaseFormatFactory.cs
new file mode 100644
index 0000000..23b7302
--- /dev/null
+++ b/src/Metin2Launcher/Formats/ReleaseFormatFactory.cs
@@ -0,0 +1,26 @@
+namespace Metin2Launcher.Formats;
+
+///
+/// Resolves an for a given manifest. Lives
+/// behind a factory so the
+/// doesn't grow a switch statement and so tests can inject fakes.
+///
+public static class ReleaseFormatFactory
+{
+ ///
+ /// Returns the strategy for the given manifest format identifier.
+ /// Unknown identifiers throw — we deliberately do NOT silently fall back
+ /// to the legacy format, because that would turn a signed, malicious
+ /// format: "evil" value into a downgrade attack vector.
+ ///
+ public static IReleaseFormat Resolve(string effectiveFormat)
+ {
+ return effectiveFormat switch
+ {
+ LegacyJsonBlobFormat.FormatName => new LegacyJsonBlobFormat(),
+ M2PackFormat.FormatName => new M2PackFormat(),
+ _ => throw new NotSupportedException(
+ $"manifest release format '{effectiveFormat}' is not supported by this launcher"),
+ };
+ }
+}