diff --git a/ContentPatcher/Framework/Migrations/Migration_2_0.ForObjectContextTags.cs b/ContentPatcher/Framework/Migrations/Migration_2_0.ForObjectContextTags.cs
new file mode 100644
index 000000000..dd6764ae2
--- /dev/null
+++ b/ContentPatcher/Framework/Migrations/Migration_2_0.ForObjectContextTags.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using ContentPatcher.Framework.Migrations.Internal;
+using ContentPatcher.Framework.Patches;
+using StardewModdingAPI;
+using StardewModdingAPI.Framework.Content;
+using StardewValley.Extensions;
+using StardewValley.GameData.Objects;
+
+namespace ContentPatcher.Framework.Migrations
+{
+ internal partial class Migration_2_0 : BaseRuntimeMigration
+ {
+ //
+ // Known limitation: since we're combining two different assets, it's possible some mods added the context tags
+ // in Data/ObjectContextTags before adding the objects in Data/ObjectInformation. Unfortunately we can't add
+ // context tags to an object which doesn't exist yet, so those context tags will be ignored.
+ //
+
+ /// The migration logic to apply pre-1.6 Data/ObjectContextTags patches to Data/Objects.
+ private class ObjectContextTagsMigrator : IEditAssetMigrator
+ {
+ /*********
+ ** Fields
+ *********/
+ /// The pre-1.6 asset name.
+ private const string OldAssetName = "Data/ObjectContextTags";
+
+ /// The 1.6 asset name.
+ private const string NewAssetName = "Data/Objects";
+
+
+ /*********
+ ** Public methods
+ *********/
+ ///
+ public bool AppliesTo(IAssetName assetName)
+ {
+ return assetName?.IsEquivalentTo(ObjectContextTagsMigrator.OldAssetName, useBaseName: true) is true;
+ }
+
+ ///
+ public IAssetName? RedirectTarget(IAssetName assetName, IPatch patch)
+ {
+ return new AssetName(ObjectContextTagsMigrator.NewAssetName, null, null);
+ }
+
+ ///
+ public bool TryApplyLoadPatch(LoadPatch patch, IAssetName assetName, [NotNullWhen(true)] ref T? asset, out string? error)
+ {
+ // we can't migrate Action: Load patches because the patch won't actually contain any object data
+ // besides the context tags.
+ error = $"can't migrate load patches for '{ObjectContextTagsMigrator.OldAssetName}' to Stardew Valley 1.6";
+ return false;
+ }
+
+ ///
+ public bool TryApplyEditPatch(EditDataPatch patch, IAssetData asset, Action onWarning, out string? error)
+ {
+ var data = asset.GetData>();
+ Dictionary tempData = this.GetOldFormat(data);
+ Dictionary tempDataBackup = new(tempData);
+ patch.Edit>(new FakeAssetData(asset, this.GetOldAssetName(asset.Name), tempData), onWarning);
+ this.MergeIntoNewFormat(data, tempData, tempDataBackup, patch.ContentPack.Manifest.UniqueID);
+
+ error = null;
+ return true;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// Get the old asset to edit.
+ /// The new asset name whose locale to use.
+ private IAssetName GetOldAssetName(IAssetName newName)
+ {
+ return new AssetName(ObjectContextTagsMigrator.OldAssetName, newName.LocaleCode, newName.LanguageCode);
+ }
+
+ /// Get the pre-1.6 equivalent for the new asset data.
+ /// The data to convert.
+ private Dictionary GetOldFormat(IDictionary asset)
+ {
+ var data = new Dictionary();
+
+ foreach ((string objectId, ObjectData entry) in asset)
+ {
+ if (entry.Name is null)
+ continue;
+
+ string key = this.GetOldEntryKey(objectId, entry);
+ data[key] = entry.ContextTags?.Count > 0
+ ? string.Join(", ", entry.ContextTags)
+ : string.Empty;
+ }
+
+ return data;
+ }
+
+ /// Merge pre-1.6 data into the new asset.
+ /// The asset data to update.
+ /// The pre-1.6 data to merge into the asset.
+ /// A copy of before edits were applied.
+ /// The unique ID for the mod, used in auto-generated entry IDs.
+ private void MergeIntoNewFormat(IDictionary asset, IDictionary contextTags, IDictionary? contextTagsBackup, string modId)
+ {
+ // skip if no entries changed
+ // (We can't remove unchanged entries though, since we need to combine context tags by both ID and name)
+ if (contextTagsBackup is not null)
+ {
+ bool anyChanged = false;
+
+ foreach ((string oldKey, string rawTags) in contextTags)
+ {
+ if (!contextTagsBackup.TryGetValue(oldKey, out string? prevRawTags) || prevRawTags != rawTags)
+ {
+ anyChanged = true;
+ break;
+ }
+ }
+
+ if (!anyChanged)
+ return;
+ }
+
+ // get context tags by item ID
+ var contextTagsById = new Dictionary>();
+ {
+ ILookup itemIdsByName = asset.ToLookup(p => p.Value.Name, p => p.Key);
+
+ foreach ((string oldKey, string rawTags) in contextTags)
+ {
+ string[] tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.RemoveEmptyEntries);
+
+ // add by ID
+ if (oldKey.StartsWith("id_"))
+ {
+ if (oldKey.StartsWith("id_o_"))
+ {
+ string objectId = oldKey.Substring("id_o_".Length);
+ this.TrackRawContextTagsById(contextTagsById, objectId, tags);
+ }
+ }
+
+ // else by name
+ else
+ {
+ foreach (string objectId in itemIdsByName[oldKey])
+ this.TrackRawContextTagsById(contextTagsById, objectId, tags);
+ }
+ }
+ }
+
+ // merge into Data/Objects
+ foreach ((string oldKey, HashSet tags) in contextTagsById)
+ {
+ // get or add matching object record
+ if (!asset.TryGetValue(oldKey, out ObjectData? entry))
+ continue;
+
+ // update context tags
+ if (tags.Count == 0)
+ entry.ContextTags?.Clear();
+ else
+ {
+ entry.ContextTags ??= new List();
+ entry.ContextTags.Clear();
+ entry.ContextTags.AddRange(tags);
+ }
+ }
+ }
+
+ /// Add context tags to a lookup by object ID.
+ /// The lookup to update.
+ /// The object ID whose context tags to track.
+ /// The context tags to track, in addition to any already tracked for the same object ID.
+ private void TrackRawContextTagsById(Dictionary> contextTagsById, string objectId, string[] tags)
+ {
+ // merge into previous
+ if (contextTagsById.TryGetValue(objectId, out HashSet? prevTags))
+ prevTags.AddRange(tags);
+
+ // else add new
+ else
+ contextTagsById[objectId] = new HashSet(tags);
+ }
+
+ /// Get the entry key in Data/ObjectContextTags for an entry.
+ /// The unique object ID.
+ /// The object data.
+ private string GetOldEntryKey(string objectId, ObjectData entry)
+ {
+ switch (objectId)
+ {
+ case "113": // Chicken Statue
+ case "126": // Strange Doll #1
+ case "127": // Strange Doll #2
+ case "340": // Honey
+ case "342": // Pickles
+ case "344": // Jelly
+ case "348": // Wine
+ case "350": // Juice
+ case "447": // Aged Roe
+ case "812": // Roe
+ return "id_0_" + objectId; // match pre-1.6 key
+
+ default:
+ return entry.Name ?? "id_0_" + objectId;
+ }
+ }
+ }
+ }
+}
diff --git a/ContentPatcher/Framework/Migrations/Migration_2_0.ForWeapons.cs b/ContentPatcher/Framework/Migrations/Migration_2_0.ForWeapons.cs
new file mode 100644
index 000000000..d7c01acfe
--- /dev/null
+++ b/ContentPatcher/Framework/Migrations/Migration_2_0.ForWeapons.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using ContentPatcher.Framework.Migrations.Internal;
+using ContentPatcher.Framework.Patches;
+using StardewModdingAPI;
+using StardewValley;
+using StardewValley.GameData.Weapons;
+using StardewTokenParser = StardewValley.TokenizableStrings.TokenParser;
+
+namespace ContentPatcher.Framework.Migrations
+{
+ internal partial class Migration_2_0 : BaseRuntimeMigration
+ {
+ /// The migration logic to apply pre-1.6 Data/Weapons patches to the new format.
+ private class WeaponsMigrator : IEditAssetMigrator
+ {
+ /*********
+ ** Fields
+ *********/
+ /// The asset name.
+ private const string AssetName = "Data/Weapons";
+
+
+ /*********
+ ** Public methods
+ *********/
+ ///
+ public bool AppliesTo(IAssetName assetName)
+ {
+ return assetName?.IsEquivalentTo(WeaponsMigrator.AssetName, useBaseName: true) is true;
+ }
+
+ ///
+ public IAssetName? RedirectTarget(IAssetName assetName, IPatch patch)
+ {
+ return null; // same asset name
+ }
+
+ ///
+ public bool TryApplyLoadPatch(LoadPatch patch, IAssetName assetName, [NotNullWhen(true)] ref T? asset, out string? error)
+ {
+ Dictionary tempData = patch.Load>(assetName);
+ Dictionary newData = new();
+ this.MergeIntoNewFormat(newData, tempData, null);
+ asset = (T)(object)newData;
+
+ error = null;
+ return true;
+ }
+
+ ///
+ public bool TryApplyEditPatch(EditDataPatch patch, IAssetData asset, Action onWarning, out string? error)
+ {
+ var data = asset.GetData>();
+ Dictionary tempData = this.GetOldFormat(data);
+ Dictionary tempDataBackup = new(tempData);
+ patch.Edit>(new FakeAssetData(asset, asset.Name, tempData), onWarning);
+ this.MergeIntoNewFormat(data, tempData, tempDataBackup);
+
+ error = null;
+ return true;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// Get the pre-1.6 equivalent for the new asset data.
+ /// The data to convert.
+ private Dictionary GetOldFormat(IDictionary from)
+ {
+ var data = new Dictionary();
+
+ string[] fields = new string[15];
+ foreach ((string objectId, WeaponData entry) in from)
+ {
+ fields[0] = entry.Name;
+ fields[1] = StardewTokenParser.ParseText(entry.Description);
+ fields[2] = entry.MinDamage.ToString();
+ fields[3] = entry.MaxDamage.ToString();
+ fields[4] = entry.Knockback.ToString();
+ fields[5] = entry.Speed.ToString();
+ fields[6] = entry.Precision.ToString();
+ fields[7] = entry.Defense.ToString();
+ fields[8] = entry.Type.ToString();
+ fields[9] = entry.MineBaseLevel.ToString();
+ fields[10] = entry.MineMinLevel.ToString();
+ fields[11] = entry.AreaOfEffect.ToString();
+ fields[12] = entry.CritChance.ToString();
+ fields[13] = entry.CritMultiplier.ToString();
+ fields[14] = StardewTokenParser.ParseText(entry.DisplayName);
+
+ data[objectId] = string.Join('/', fields);
+ }
+
+ return data;
+ }
+
+ /// Merge pre-1.6 data into the new asset.
+ /// The asset data to update.
+ /// The pre-1.6 data to merge into the asset.
+ /// A copy of before edits were applied.
+ private void MergeIntoNewFormat(IDictionary asset, IDictionary from, IDictionary? fromBackup)
+ {
+ // remove deleted entries
+ foreach (string key in asset.Keys)
+ {
+ if (!from.ContainsKey(key))
+ asset.Remove(key);
+ }
+
+ // apply entries
+ foreach ((string objectId, string fromEntry) in from)
+ {
+ // skip if unchanged
+ string[]? backupFields = null;
+ if (fromBackup is not null)
+ {
+ if (fromBackup.TryGetValue(objectId, out string? prevRow) && prevRow == fromEntry)
+ continue; // no changes
+ backupFields = prevRow?.Split('/');
+ }
+
+ // get/add target record
+ bool isNew = false;
+ if (!asset.TryGetValue(objectId, out WeaponData? entry))
+ {
+ isNew = true;
+ entry = new WeaponData();
+ }
+
+ // merge fields into new asset
+ {
+ string[] fields = fromEntry.Split('/');
+
+ entry.Name = ArgUtility.Get(fields, 0, entry.Name, allowBlank: false);
+ entry.Description = RuntimeMigrationHelper.GetValueForTokenizableFieldIfChanged(ArgUtility.Get(fields, 1), ArgUtility.Get(backupFields, 1), entry.Description);
+ entry.MinDamage = ArgUtility.GetInt(fields, 2, entry.MinDamage);
+ entry.MaxDamage = ArgUtility.GetInt(fields, 3, entry.MaxDamage);
+ entry.Knockback = ArgUtility.GetFloat(fields, 4, entry.Knockback);
+ entry.Speed = ArgUtility.GetInt(fields, 5, entry.Speed);
+ entry.Precision = ArgUtility.GetInt(fields, 6, entry.Precision);
+ entry.Defense = ArgUtility.GetInt(fields, 7, entry.Defense);
+ entry.Type = ArgUtility.GetInt(fields, 8, entry.Type);
+ entry.MineBaseLevel = ArgUtility.GetInt(fields, 9, entry.MineBaseLevel);
+ entry.MineMinLevel = ArgUtility.GetInt(fields, 10, entry.MineMinLevel);
+ entry.AreaOfEffect = ArgUtility.GetInt(fields, 11, entry.AreaOfEffect);
+ entry.CritChance = ArgUtility.GetFloat(fields, 12, entry.CritChance);
+ entry.CritMultiplier = ArgUtility.GetFloat(fields, 13, entry.CritMultiplier);
+ entry.DisplayName = RuntimeMigrationHelper.GetValueForTokenizableFieldIfChanged(ArgUtility.Get(fields, 14), ArgUtility.Get(backupFields, 14), entry.DisplayName);
+ }
+
+ // set value
+ if (isNew)
+ asset[objectId] = entry;
+ }
+ }
+ }
+ }
+}
diff --git a/ContentPatcher/Framework/Migrations/Migration_2_0.cs b/ContentPatcher/Framework/Migrations/Migration_2_0.cs
index 4545a5bc4..7d990db05 100644
--- a/ContentPatcher/Framework/Migrations/Migration_2_0.cs
+++ b/ContentPatcher/Framework/Migrations/Migration_2_0.cs
@@ -28,7 +28,9 @@ public Migration_2_0()
new CropsMigrator(),
new LocationsMigrator(),
new NpcDispositionsMigrator(),
- new ObjectInformationMigrator()
+ new ObjectContextTagsMigrator(),
+ new ObjectInformationMigrator(),
+ new WeaponsMigrator()
];
}