diff --git a/Runtime/CampaignPacks.meta b/Runtime/CampaignPacks.meta
new file mode 100644
index 0000000..9c381dd
--- /dev/null
+++ b/Runtime/CampaignPacks.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 9c1e0b465a5d4d858308fca1b8676f5e
+timeCreated: 1780215600
diff --git a/Runtime/CampaignPacks/CampaignPackDefinitions.cs b/Runtime/CampaignPacks/CampaignPackDefinitions.cs
new file mode 100644
index 0000000..e1cb44b
--- /dev/null
+++ b/Runtime/CampaignPacks/CampaignPackDefinitions.cs
@@ -0,0 +1,240 @@
+using System.Collections.Generic;
+
+namespace PatchManager.CampaignPacks
+{
+ ///
+ /// Runtime JSON-facing representation of a campaign pack.
+ ///
+ public sealed class CampaignPackDefinition
+ {
+ ///
+ /// Stable campaign pack identifier used by other campaign pack definitions and extensions.
+ ///
+ public string Id = string.Empty;
+
+ ///
+ /// Localization key used for the player-facing campaign pack name.
+ ///
+ public string NameLocKey = string.Empty;
+
+ ///
+ /// Localization key used for the player-facing campaign pack description.
+ ///
+ public string DescriptionLocKey = string.Empty;
+
+ ///
+ /// Addressable or data key of the galaxy definition used by this campaign pack.
+ ///
+ public string GalaxyDefinitionKey = string.Empty;
+
+ ///
+ /// Optional identifier of the tech tree set referenced by this campaign pack.
+ ///
+ public string TechTreeSetId = string.Empty;
+
+ ///
+ /// Optional identifier of the mission set referenced by this campaign pack.
+ ///
+ public string MissionSetId = string.Empty;
+
+ ///
+ /// Optional identifier of the science set referenced by this campaign pack.
+ ///
+ public string ScienceSetId = string.Empty;
+ }
+
+ ///
+ /// Runtime JSON-facing representation of a tech tree set.
+ ///
+ public sealed class TechTreeSetDefinition
+ {
+ ///
+ /// Stable tech tree set identifier.
+ ///
+ public string Id = string.Empty;
+
+ ///
+ /// Tech tree node identifiers included by this set before extensions are applied.
+ ///
+ public List TechNodeIds = new();
+ }
+
+ ///
+ /// Runtime JSON-facing representation of a mission set.
+ ///
+ public sealed class MissionSetDefinition
+ {
+ ///
+ /// Stable mission set identifier.
+ ///
+ public string Id = string.Empty;
+
+ ///
+ /// Mission identifiers included by this set before extensions are applied.
+ ///
+ public List MissionIds = new();
+ }
+
+ ///
+ /// Runtime JSON-facing representation of a science set.
+ ///
+ public sealed class ScienceSetDefinition
+ {
+ ///
+ /// Stable science set identifier.
+ ///
+ public string Id = string.Empty;
+
+ ///
+ /// Science experiment identifiers included by this set before extensions are applied.
+ ///
+ public List ExperimentIds = new();
+
+ ///
+ /// Science region identifiers included by this set before extensions are applied.
+ ///
+ public List ScienceRegionIds = new();
+
+ ///
+ /// Discoverable identifiers included by this set before extensions are applied.
+ ///
+ public List DiscoverableIds = new();
+ }
+
+ ///
+ /// Runtime JSON-facing representation of campaign pack extension add/remove operations.
+ ///
+ public sealed class CampaignPackExtensionDefinition
+ {
+ ///
+ /// Stable campaign pack extension identifier.
+ ///
+ public string Id = string.Empty;
+
+ ///
+ /// Optional campaign pack identifier this extension applies to.
+ ///
+ public string TargetCampaignPackId = string.Empty;
+
+ ///
+ /// Optional tech tree set identifier this extension applies to.
+ ///
+ public string TargetTechTreeSetId = string.Empty;
+
+ ///
+ /// Optional mission set identifier this extension applies to.
+ ///
+ public string TargetMissionSetId = string.Empty;
+
+ ///
+ /// Optional science set identifier this extension applies to.
+ ///
+ public string TargetScienceSetId = string.Empty;
+
+ ///
+ /// Tech tree node identifiers added to matching campaign packs.
+ ///
+ public List AddTechNodeIds = new();
+
+ ///
+ /// Tech tree node identifiers removed from matching campaign packs after additions are applied.
+ ///
+ public List RemoveTechNodeIds = new();
+
+ ///
+ /// Mission identifiers added to matching campaign packs.
+ ///
+ public List AddMissionIds = new();
+
+ ///
+ /// Mission identifiers removed from matching campaign packs after additions are applied.
+ ///
+ public List RemoveMissionIds = new();
+
+ ///
+ /// Science experiment identifiers added to matching campaign packs.
+ ///
+ public List AddExperimentIds = new();
+
+ ///
+ /// Science experiment identifiers removed from matching campaign packs after additions are applied.
+ ///
+ public List RemoveExperimentIds = new();
+
+ ///
+ /// Science region identifiers added to matching campaign packs.
+ ///
+ public List AddScienceRegionIds = new();
+
+ ///
+ /// Science region identifiers removed from matching campaign packs after additions are applied.
+ ///
+ public List RemoveScienceRegionIds = new();
+
+ ///
+ /// Discoverable identifiers added to matching campaign packs.
+ ///
+ public List AddDiscoverableIds = new();
+
+ ///
+ /// Discoverable identifiers removed from matching campaign packs after additions are applied.
+ ///
+ public List RemoveDiscoverableIds = new();
+ }
+
+ ///
+ /// Fully resolved campaign pack contents after base sets and matching extensions are applied.
+ ///
+ public sealed class EffectiveCampaignPack
+ {
+ ///
+ /// Identifier of the campaign pack that was resolved.
+ ///
+ public string CampaignPackId = string.Empty;
+
+ ///
+ /// Localization key used for the resolved campaign pack name.
+ ///
+ public string NameLocKey = string.Empty;
+
+ ///
+ /// Localization key used for the resolved campaign pack description.
+ ///
+ public string DescriptionLocKey = string.Empty;
+
+ ///
+ /// Galaxy definition key selected by the resolved campaign pack.
+ ///
+ public string GalaxyDefinitionKey = string.Empty;
+
+ ///
+ /// Effective tech tree node identifiers after base sets and extensions are applied.
+ ///
+ public List TechNodeIds = new();
+
+ ///
+ /// Effective mission identifiers after base sets and extensions are applied.
+ ///
+ public List MissionIds = new();
+
+ ///
+ /// Effective science experiment identifiers after base sets and extensions are applied.
+ ///
+ public List ExperimentIds = new();
+
+ ///
+ /// Effective science region identifiers after base sets and extensions are applied.
+ ///
+ public List ScienceRegionIds = new();
+
+ ///
+ /// Effective discoverable identifiers after base sets and extensions are applied.
+ ///
+ public List DiscoverableIds = new();
+
+ ///
+ /// Identifiers of extensions that matched and were applied while resolving this campaign pack.
+ ///
+ public List AppliedExtensionIds = new();
+ }
+}
diff --git a/Runtime/CampaignPacks/CampaignPackDefinitions.cs.meta b/Runtime/CampaignPacks/CampaignPackDefinitions.cs.meta
new file mode 100644
index 0000000..23d252c
--- /dev/null
+++ b/Runtime/CampaignPacks/CampaignPackDefinitions.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 34a397478f6248aeb44f2bcaa77d0f61
+timeCreated: 1780215600
diff --git a/Runtime/CampaignPacks/CampaignPackRuntimeCatalog.cs b/Runtime/CampaignPacks/CampaignPackRuntimeCatalog.cs
new file mode 100644
index 0000000..ffd21a1
--- /dev/null
+++ b/Runtime/CampaignPacks/CampaignPackRuntimeCatalog.cs
@@ -0,0 +1,445 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace PatchManager.CampaignPacks
+{
+ ///
+ /// In-memory runtime catalog for loaded campaign pack definitions and resolved previews.
+ ///
+ public sealed class CampaignPackRuntimeCatalog
+ {
+ private readonly Dictionary _packs = new(StringComparer.Ordinal);
+ private readonly Dictionary _techTreeSets = new(StringComparer.Ordinal);
+ private readonly Dictionary _missionSets = new(StringComparer.Ordinal);
+ private readonly Dictionary _scienceSets = new(StringComparer.Ordinal);
+ private readonly Dictionary _extensions = new(StringComparer.Ordinal);
+ private readonly List _issues = new();
+
+ ///
+ /// Loaded campaign pack definitions keyed by campaign pack id.
+ /// Prefer , , and
+ /// for gameplay-facing queries.
+ ///
+ public IReadOnlyDictionary Packs => _packs;
+
+ ///
+ /// Loaded tech tree set definitions keyed by set id.
+ ///
+ public IReadOnlyDictionary TechTreeSets => _techTreeSets;
+
+ ///
+ /// Loaded mission set definitions keyed by set id.
+ ///
+ public IReadOnlyDictionary MissionSets => _missionSets;
+
+ ///
+ /// Loaded science set definitions keyed by set id.
+ ///
+ public IReadOnlyDictionary ScienceSets => _scienceSets;
+
+ ///
+ /// Loaded campaign pack extension definitions keyed by extension id.
+ ///
+ public IReadOnlyDictionary Extensions => _extensions;
+
+ ///
+ /// Load and validation issues discovered while building the catalog.
+ ///
+ public IReadOnlyList Issues => _issues;
+
+ ///
+ /// Gets the loaded campaign pack identifiers in stable id order.
+ ///
+ /// Loaded campaign pack identifiers.
+ public IReadOnlyList GetCampaignPackIds()
+ {
+ return _packs.Keys
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ ///
+ /// Checks whether a campaign pack definition is loaded.
+ ///
+ /// Campaign pack identifier to check.
+ /// when the catalog contains the requested campaign pack.
+ public bool ContainsCampaignPack(string packId)
+ {
+ return !string.IsNullOrWhiteSpace(packId) && _packs.ContainsKey(packId);
+ }
+
+ ///
+ /// Attempts to fetch the raw campaign pack definition for editor/debug style inspection.
+ ///
+ /// Campaign pack identifier to fetch.
+ /// Loaded campaign pack definition when found; otherwise .
+ /// when the definition was found.
+ public bool TryGetCampaignPackDefinition(string packId, out CampaignPackDefinition definition)
+ {
+ if (!string.IsNullOrWhiteSpace(packId) && _packs.TryGetValue(packId, out definition))
+ {
+ return true;
+ }
+
+ definition = null;
+ return false;
+ }
+
+ ///
+ /// Adds a load-time issue to the catalog diagnostics.
+ ///
+ /// Human-readable issue text.
+ public void AddLoadIssue(string issue)
+ {
+ _issues.Add($"[Load] {issue}");
+ }
+
+ ///
+ /// Removes all loaded definitions and validation issues.
+ ///
+ public void Clear()
+ {
+ _packs.Clear();
+ _techTreeSets.Clear();
+ _missionSets.Clear();
+ _scienceSets.Clear();
+ _extensions.Clear();
+ _issues.Clear();
+ }
+
+ ///
+ /// Adds a campaign pack definition to the catalog.
+ ///
+ /// Campaign pack definition to add.
+ /// Optional source asset name used for diagnostics.
+ public void AddPack(CampaignPackDefinition definition, string sourceName = "")
+ {
+ AddDefinition(_packs, definition?.Id, definition, "Campaign pack", sourceName);
+ }
+
+ ///
+ /// Adds a tech tree set definition to the catalog.
+ ///
+ /// Tech tree set definition to add.
+ /// Optional source asset name used for diagnostics.
+ public void AddTechTreeSet(TechTreeSetDefinition definition, string sourceName = "")
+ {
+ AddDefinition(_techTreeSets, definition?.Id, definition, "Tech tree set", sourceName);
+ }
+
+ ///
+ /// Adds a mission set definition to the catalog.
+ ///
+ /// Mission set definition to add.
+ /// Optional source asset name used for diagnostics.
+ public void AddMissionSet(MissionSetDefinition definition, string sourceName = "")
+ {
+ AddDefinition(_missionSets, definition?.Id, definition, "Mission set", sourceName);
+ }
+
+ ///
+ /// Adds a science set definition to the catalog.
+ ///
+ /// Science set definition to add.
+ /// Optional source asset name used for diagnostics.
+ public void AddScienceSet(ScienceSetDefinition definition, string sourceName = "")
+ {
+ AddDefinition(_scienceSets, definition?.Id, definition, "Science set", sourceName);
+ }
+
+ ///
+ /// Adds a campaign pack extension definition to the catalog.
+ ///
+ /// Campaign pack extension definition to add.
+ /// Optional source asset name used for diagnostics.
+ public void AddExtension(CampaignPackExtensionDefinition definition, string sourceName = "")
+ {
+ AddDefinition(_extensions, definition?.Id, definition, "Campaign pack extension", sourceName);
+ }
+
+ ///
+ /// Resolves the effective contents for a loaded campaign pack.
+ ///
+ /// Campaign pack identifier.
+ /// Resolved contents, or null when the pack is unknown.
+ public EffectiveCampaignPack Resolve(string packId)
+ {
+ if (string.IsNullOrWhiteSpace(packId) || !_packs.TryGetValue(packId, out var pack))
+ {
+ return null;
+ }
+
+ var result = new EffectiveCampaignPack
+ {
+ CampaignPackId = pack.Id,
+ NameLocKey = pack.NameLocKey,
+ DescriptionLocKey = pack.DescriptionLocKey,
+ GalaxyDefinitionKey = pack.GalaxyDefinitionKey
+ };
+
+ if (!string.IsNullOrWhiteSpace(pack.TechTreeSetId) && _techTreeSets.TryGetValue(pack.TechTreeSetId, out var techTreeSet))
+ {
+ result.TechNodeIds.AddRange(CopyDistinct(techTreeSet.TechNodeIds));
+ }
+
+ if (!string.IsNullOrWhiteSpace(pack.MissionSetId) && _missionSets.TryGetValue(pack.MissionSetId, out var missionSet))
+ {
+ result.MissionIds.AddRange(CopyDistinct(missionSet.MissionIds));
+ }
+
+ if (!string.IsNullOrWhiteSpace(pack.ScienceSetId) && _scienceSets.TryGetValue(pack.ScienceSetId, out var scienceSet))
+ {
+ result.ExperimentIds.AddRange(CopyDistinct(scienceSet.ExperimentIds));
+ result.ScienceRegionIds.AddRange(CopyDistinct(scienceSet.ScienceRegionIds));
+ result.DiscoverableIds.AddRange(CopyDistinct(scienceSet.DiscoverableIds));
+ }
+
+ var matching = GetMatchingExtensions(pack).OrderBy(e => e.Id, StringComparer.Ordinal).ToList();
+ foreach (var extension in matching)
+ {
+ AddRangeUnique(result.TechNodeIds, extension.AddTechNodeIds);
+ AddRangeUnique(result.MissionIds, extension.AddMissionIds);
+ AddRangeUnique(result.ExperimentIds, extension.AddExperimentIds);
+ AddRangeUnique(result.ScienceRegionIds, extension.AddScienceRegionIds);
+ AddRangeUnique(result.DiscoverableIds, extension.AddDiscoverableIds);
+ if (!string.IsNullOrWhiteSpace(extension.Id))
+ {
+ result.AppliedExtensionIds.Add(extension.Id);
+ }
+ }
+
+ foreach (var extension in matching)
+ {
+ RemoveAll(result.TechNodeIds, extension.RemoveTechNodeIds);
+ RemoveAll(result.MissionIds, extension.RemoveMissionIds);
+ RemoveAll(result.ExperimentIds, extension.RemoveExperimentIds);
+ RemoveAll(result.ScienceRegionIds, extension.RemoveScienceRegionIds);
+ RemoveAll(result.DiscoverableIds, extension.RemoveDiscoverableIds);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Attempts to resolve the effective contents for a loaded campaign pack.
+ ///
+ /// Campaign pack identifier.
+ /// Resolved contents when found; otherwise .
+ /// when the campaign pack exists and was resolved.
+ public bool TryResolve(string packId, out EffectiveCampaignPack effectivePack)
+ {
+ effectivePack = Resolve(packId);
+ return effectivePack != null;
+ }
+
+ ///
+ /// Rebuilds runtime validation issues for all loaded definitions.
+ ///
+ public void Validate()
+ {
+ _issues.RemoveAll(issue => issue.StartsWith("[Validation]", StringComparison.Ordinal));
+
+ foreach (var pack in _packs.Values)
+ {
+ if (string.IsNullOrWhiteSpace(pack.GalaxyDefinitionKey))
+ {
+ AddValidationIssue($"Campaign pack '{pack.Id}' has no galaxy definition key.");
+ }
+
+ AddMissingReference(pack.Id, "tech tree set", pack.TechTreeSetId, _techTreeSets);
+ AddMissingReference(pack.Id, "mission set", pack.MissionSetId, _missionSets);
+ AddMissingReference(pack.Id, "science set", pack.ScienceSetId, _scienceSets);
+ }
+
+ foreach (var extension in _extensions.Values)
+ {
+ var hasTarget = !string.IsNullOrWhiteSpace(extension.TargetCampaignPackId) ||
+ !string.IsNullOrWhiteSpace(extension.TargetTechTreeSetId) ||
+ !string.IsNullOrWhiteSpace(extension.TargetMissionSetId) ||
+ !string.IsNullOrWhiteSpace(extension.TargetScienceSetId);
+
+ if (!hasTarget)
+ {
+ AddValidationIssue($"Campaign pack extension '{extension.Id}' does not target a pack or set.");
+ }
+ else if (!_packs.Values.Any(pack => TargetsPack(extension, pack)))
+ {
+ AddValidationIssue($"Campaign pack extension '{extension.Id}' does not match any loaded campaign pack.");
+ }
+
+ AddConflictIssue(extension.Id, "tech node", extension.AddTechNodeIds, extension.RemoveTechNodeIds);
+ AddConflictIssue(extension.Id, "mission", extension.AddMissionIds, extension.RemoveMissionIds);
+ AddConflictIssue(extension.Id, "science experiment", extension.AddExperimentIds, extension.RemoveExperimentIds);
+ AddConflictIssue(extension.Id, "science region", extension.AddScienceRegionIds, extension.RemoveScienceRegionIds);
+ AddConflictIssue(extension.Id, "discoverable", extension.AddDiscoverableIds, extension.RemoveDiscoverableIds);
+ }
+ }
+
+ ///
+ /// Resolves all loaded campaign packs in stable id order.
+ ///
+ /// Resolved campaign pack previews.
+ public IReadOnlyList ResolveAll()
+ {
+ return GetCampaignPackIds()
+ .Select(Resolve)
+ .OfType()
+ .ToList();
+ }
+
+ ///
+ /// Builds a human-readable summary of loaded definitions, resolved contents, and diagnostics.
+ ///
+ /// Summary text suitable for logs and Patch Manager detail UI.
+ public string BuildSummaryText()
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Campaign Packs:");
+ sb.AppendLine($"Packs: {_packs.Count}");
+ sb.AppendLine($"Tech tree sets: {_techTreeSets.Count}");
+ sb.AppendLine($"Mission sets: {_missionSets.Count}");
+ sb.AppendLine($"Science sets: {_scienceSets.Count}");
+ sb.AppendLine($"Extensions: {_extensions.Count}");
+
+ var effectivePacks = ResolveAll();
+ foreach (var pack in effectivePacks)
+ {
+ sb.AppendLine("");
+ sb.AppendLine($"- {pack.CampaignPackId}");
+ sb.AppendLine($" Galaxy: {ValueOrNone(pack.GalaxyDefinitionKey)}");
+ AppendIdList(sb, "Tech nodes", pack.TechNodeIds);
+ AppendIdList(sb, "Missions", pack.MissionIds);
+ AppendIdList(sb, "Experiments", pack.ExperimentIds);
+ AppendIdList(sb, "Science regions", pack.ScienceRegionIds);
+ AppendIdList(sb, "Discoverables", pack.DiscoverableIds);
+ AppendIdList(sb, "Extensions", pack.AppliedExtensionIds);
+ }
+
+ if (_issues.Count > 0)
+ {
+ sb.AppendLine("");
+ sb.AppendLine("Issues:");
+ foreach (var issue in _issues)
+ {
+ sb.AppendLine($"- {issue}");
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private void AddDefinition(Dictionary target, string id, T definition, string kind, string sourceName)
+ where T : class
+ {
+ if (definition == null)
+ {
+ AddLoadIssue($"{kind} from '{ValueOrNone(sourceName)}' could not be read.");
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ AddLoadIssue($"{kind} from '{ValueOrNone(sourceName)}' has no id.");
+ return;
+ }
+
+ if (target.ContainsKey(id))
+ {
+ AddLoadIssue($"{kind} id '{id}' is duplicated; keeping the first loaded definition.");
+ return;
+ }
+
+ target[id] = definition;
+ }
+
+ private IEnumerable GetMatchingExtensions(CampaignPackDefinition pack)
+ {
+ return _extensions.Values.Where(extension => TargetsPack(extension, pack));
+ }
+
+ private static bool TargetsPack(CampaignPackExtensionDefinition extension, CampaignPackDefinition pack)
+ {
+ return Matches(extension.TargetCampaignPackId, pack.Id) ||
+ Matches(extension.TargetTechTreeSetId, pack.TechTreeSetId) ||
+ Matches(extension.TargetMissionSetId, pack.MissionSetId) ||
+ Matches(extension.TargetScienceSetId, pack.ScienceSetId);
+ }
+
+ private static bool Matches(string target, string actual)
+ {
+ return !string.IsNullOrWhiteSpace(target) &&
+ !string.IsNullOrWhiteSpace(actual) &&
+ string.Equals(target, actual, StringComparison.Ordinal);
+ }
+
+ private void AddMissingReference(string packId, string kind, string id, Dictionary known)
+ {
+ if (!string.IsNullOrWhiteSpace(id) && !known.ContainsKey(id))
+ {
+ AddValidationIssue($"Campaign pack '{packId}' references missing {kind} '{id}'.");
+ }
+ }
+
+ private void AddConflictIssue(string extensionId, string kind, IEnumerable additions, IEnumerable removals)
+ {
+ var addSet = new HashSet((additions ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)));
+ foreach (var id in (removals ?? Enumerable.Empty()).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct())
+ {
+ if (addSet.Contains(id))
+ {
+ AddValidationIssue($"Campaign pack extension '{extensionId}' both adds and removes {kind} '{id}'. Removal will win.");
+ }
+ }
+ }
+
+ private void AddValidationIssue(string issue)
+ {
+ _issues.Add($"[Validation] {issue}");
+ }
+
+ private static IEnumerable CopyDistinct(IEnumerable values)
+ {
+ return (values ?? Enumerable.Empty()).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct();
+ }
+
+ private static void AddRangeUnique(List target, IEnumerable values)
+ {
+ if (values == null) return;
+ foreach (var value in values)
+ {
+ if (string.IsNullOrWhiteSpace(value) || target.Contains(value)) continue;
+ target.Add(value);
+ }
+ }
+
+ private static void RemoveAll(List target, IEnumerable values)
+ {
+ if (values == null) return;
+ var removals = new HashSet(values.Where(value => !string.IsNullOrWhiteSpace(value)));
+ target.RemoveAll(removals.Contains);
+ }
+
+ private static string ValueOrNone(string value)
+ {
+ return string.IsNullOrWhiteSpace(value) ? "" : value;
+ }
+
+ private static void AppendIdList(StringBuilder sb, string label, IReadOnlyCollection ids)
+ {
+ sb.AppendLine($" {label} ({ids.Count}):");
+
+ if (ids.Count == 0)
+ {
+ sb.AppendLine(" ");
+ return;
+ }
+
+ foreach (var id in ids)
+ {
+ sb.AppendLine($" - {id}");
+ }
+ }
+ }
+}
diff --git a/Runtime/CampaignPacks/CampaignPackRuntimeCatalog.cs.meta b/Runtime/CampaignPacks/CampaignPackRuntimeCatalog.cs.meta
new file mode 100644
index 0000000..782f9ec
--- /dev/null
+++ b/Runtime/CampaignPacks/CampaignPackRuntimeCatalog.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 70e3392c4d0d42cd987bc47db44667c5
\ No newline at end of file
diff --git a/Runtime/CampaignPacks/CampaignPacksModule.cs b/Runtime/CampaignPacks/CampaignPacksModule.cs
new file mode 100644
index 0000000..c12feba
--- /dev/null
+++ b/Runtime/CampaignPacks/CampaignPacksModule.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Collections.Generic;
+using KSP.Game;
+using Newtonsoft.Json;
+using PatchManager.Shared;
+using PatchManager.Shared.Modules;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.ResourceManagement.AsyncOperations;
+using UnityEngine.UIElements;
+
+namespace PatchManager.CampaignPacks
+{
+ ///
+ /// Loads baked campaign pack JSON and exposes a read-only runtime inspection summary.
+ ///
+ public sealed class CampaignPacksModule : BaseModule
+ {
+ ///
+ /// Addressables label used for baked campaign pack JSON assets.
+ ///
+ public const string CampaignPacksLabel = "campaign_packs";
+
+ ///
+ /// Addressables label used for baked campaign pack tech tree set JSON assets.
+ ///
+ public const string TechTreeSetsLabel = "campaign_pack_tech_tree_sets";
+
+ ///
+ /// Addressables label used for baked campaign pack mission set JSON assets.
+ ///
+ public const string MissionSetsLabel = "campaign_pack_mission_sets";
+
+ ///
+ /// Addressables label used for baked campaign pack science set JSON assets.
+ ///
+ public const string ScienceSetsLabel = "campaign_pack_science_sets";
+
+ ///
+ /// Addressables label used for baked campaign pack extension JSON assets.
+ ///
+ public const string ExtensionsLabel = "campaign_pack_extensions";
+
+ ///
+ /// Runtime catalog populated from baked campaign pack JSON assets.
+ ///
+ public static CampaignPackRuntimeCatalog Catalog { get; } = new();
+
+ ///
+ /// Gets the loaded campaign pack identifiers in stable id order.
+ ///
+ /// Loaded campaign pack identifiers.
+ public static IReadOnlyList GetCampaignPackIds()
+ {
+ return Catalog.GetCampaignPackIds();
+ }
+
+ ///
+ /// Checks whether a campaign pack definition is loaded.
+ ///
+ /// Campaign pack identifier to check.
+ /// when the catalog contains the requested campaign pack.
+ public static bool ContainsCampaignPack(string packId)
+ {
+ return Catalog.ContainsCampaignPack(packId);
+ }
+
+ ///
+ /// Resolves the effective contents for a loaded campaign pack.
+ ///
+ /// Campaign pack identifier.
+ /// Resolved contents, or when the pack is unknown.
+ public static EffectiveCampaignPack Resolve(string packId)
+ {
+ return Catalog.Resolve(packId);
+ }
+
+ ///
+ /// Attempts to resolve the effective contents for a loaded campaign pack.
+ ///
+ /// Campaign pack identifier.
+ /// Resolved contents when found; otherwise .
+ /// when the campaign pack exists and was resolved.
+ public static bool TryResolve(string packId, out EffectiveCampaignPack effectivePack)
+ {
+ return Catalog.TryResolve(packId, out effectivePack);
+ }
+
+ ///
+ /// Resolves all loaded campaign packs in stable id order.
+ ///
+ /// Resolved campaign pack previews.
+ public static IReadOnlyList ResolveAll()
+ {
+ return Catalog.ResolveAll();
+ }
+
+ private int _pendingLoads;
+ private bool _loadStarted;
+ private TextElement _detailsText;
+
+ ///
+ public override void Load()
+ {
+ Catalog.Clear();
+ _pendingLoads = 5;
+ _loadStarted = true;
+
+ LoadDefinitions(
+ CampaignPacksLabel,
+ (definition, sourceName) => Catalog.AddPack(definition, sourceName));
+ LoadDefinitions(
+ TechTreeSetsLabel,
+ (definition, sourceName) => Catalog.AddTechTreeSet(definition, sourceName));
+ LoadDefinitions(
+ MissionSetsLabel,
+ (definition, sourceName) => Catalog.AddMissionSet(definition, sourceName));
+ LoadDefinitions(
+ ScienceSetsLabel,
+ (definition, sourceName) => Catalog.AddScienceSet(definition, sourceName));
+ LoadDefinitions(
+ ExtensionsLabel,
+ (definition, sourceName) => Catalog.AddExtension(definition, sourceName));
+ }
+
+ ///
+ public override VisualElement GetDetails()
+ {
+ var foldout = new Foldout
+ {
+ text = "PatchManager.CampaignPacks",
+ visible = true,
+ style =
+ {
+ display = DisplayStyle.Flex
+ }
+ };
+
+ _detailsText = new TextElement
+ {
+ visible = true,
+ style =
+ {
+ display = DisplayStyle.Flex,
+ whiteSpace = WhiteSpace.Normal
+ }
+ };
+ RefreshDetailsText();
+ foldout.Add(_detailsText);
+ return foldout;
+ }
+
+ private void LoadDefinitions(string label, Action addDefinition)
+ where T : class
+ {
+ var handle = GameManager.Instance.Assets.LoadAssetsAsync(
+ label,
+ asset => ImportDefinition(asset, addDefinition));
+
+ handle.Completed += result =>
+ {
+ if (result.Status != AsyncOperationStatus.Succeeded)
+ {
+ Logging.LogWarning($"[Campaign Packs] Failed to load addressable label '{label}'.");
+ Catalog.AddLoadIssue($"Failed to load addressable label '{label}'.");
+ }
+
+ Addressables.Release(handle);
+ OnLabelLoadFinished();
+ };
+ }
+
+ private static void ImportDefinition(TextAsset asset, Action addDefinition)
+ where T : class
+ {
+ if (!asset)
+ {
+ return;
+ }
+
+ try
+ {
+ var definition = JsonConvert.DeserializeObject(asset.text);
+ addDefinition(definition, asset.name);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogWarning($"[Campaign Packs] Failed to parse '{asset.name}': {ex.Message}");
+ Catalog.AddLoadIssue($"Failed to parse '{asset.name}': {ex.Message}");
+ }
+ }
+
+ private void OnLabelLoadFinished()
+ {
+ _pendingLoads--;
+ if (_pendingLoads > 0)
+ {
+ RefreshDetailsText();
+ return;
+ }
+
+ Catalog.Validate();
+ LogSummary();
+ RefreshDetailsText();
+ }
+
+ private static void LogSummary()
+ {
+ Logging.LogInfo($"[Campaign Packs]\n{Catalog.BuildSummaryText()}");
+ foreach (var issue in Catalog.Issues)
+ {
+ Logging.LogWarning($"[Campaign Packs] {issue}");
+ }
+ }
+
+ private void RefreshDetailsText()
+ {
+ if (_detailsText == null)
+ {
+ return;
+ }
+
+ var loading = _loadStarted && _pendingLoads > 0
+ ? $"Loading campaign pack labels... {_pendingLoads} remaining.\n\n"
+ : string.Empty;
+ _detailsText.text = loading + Catalog.BuildSummaryText();
+ }
+ }
+}
diff --git a/Runtime/CampaignPacks/CampaignPacksModule.cs.meta b/Runtime/CampaignPacks/CampaignPacksModule.cs.meta
new file mode 100644
index 0000000..5adc43b
--- /dev/null
+++ b/Runtime/CampaignPacks/CampaignPacksModule.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 60a24681846d4179989ac79d4afe1de0
+timeCreated: 1780215600
diff --git a/Runtime/PatchManager.cs b/Runtime/PatchManager.cs
index 4ec54dc..8dbc20a 100644
--- a/Runtime/PatchManager.cs
+++ b/Runtime/PatchManager.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using PatchManager.CampaignPacks;
using PatchManager.Core;
using PatchManager.Generic;
using PatchManager.LuaPatching;
@@ -39,6 +40,7 @@ public void Awake()
ModuleManager.Register(typeof(ResourcesModule));
ModuleManager.Register(typeof(ScienceModule));
ModuleManager.Register(typeof(PlanetsModule));
+ ModuleManager.Register(typeof(CampaignPacksModule));
Logging.Initialize(SWLogger);
foreach (var module in ModuleManager.Modules)
{