Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions WitcherScriptMerger.Core/Inventory/FileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ public class MergeReportData
// only the entries for paths that actually made it into summary.Merged.
Dictionary<string, List<string>> _functionLevelDecisionsByPath = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);

// The global mod ranking for this run, pulled out of the order file's reserved
// "*" entry (see LoadOrder/ModPriority). Null when none was supplied, which is
// the normal case - every tiebreak then behaves exactly as it did before.
string[] _modPriority;

// Anchored at BOTH ends ("^...$") - see IsVanillaDlcBundleFolder's own comment
// below for why this matters: it's matched against just the extracted folder-name
// segment, not the full path, so a full-string match is required, not merely a
Expand Down Expand Up @@ -485,6 +490,12 @@ public HeadlessMergeSummary MergeConflictsHeadless(
bool dryRun = false,
bool overwrite = false)
{
// The ranking rides in the same order-file payload as the per-file overrides, so
// it reaches every host and the MCP tool without new plumbing. Split out here,
// once, so ResolveMergeOrder only ever sees real relative-path entries.
_modPriority = ModPriority.ExtractRanking(orderOverrides);
orderOverrides = ModPriority.WithoutRankingEntry(orderOverrides);

var summary = new HeadlessMergeSummary();

foreach (var conflict in conflicts.Where(c =>
Expand Down Expand Up @@ -632,7 +643,8 @@ bool MergeFlatConflictHeadless(ModFile conflict, Merge merge, string mergedModNa
var hash = conflict.Mods.First(h => h.Name.EqualsIgnoreCase(orderedNames[i]));
var source2 = MergeSource.FromFlatFile(new FileInfo(conflict.GetModFile(orderedNames[i])), hash);

var mergedFile = MergeTextHeadless(merge, source1, source2, dryRun, DescribeAccumulated(orderedNames.Take(i)), orderedNames[i]);
var mergedFile = MergeTextHeadless(merge, source1, source2, dryRun, DescribeAccumulated(orderedNames.Take(i)), orderedNames[i],
ModPriority.Resolve(_modPriority, orderedNames.Take(i), orderedNames[i]));
if (mergedFile == null)
return false;
source1 = MergeSource.FromFlatFile(mergedFile, null);
Expand Down Expand Up @@ -689,7 +701,8 @@ bool MergeBundleConflictHeadless(ModFile conflict, Merge merge, string[] ordered
if (!GetUnpackedFiles(conflict.RelativePath, ref source1, ref source2))
return false;

var mergedFile = MergeTextHeadless(merge, source1, source2, dryRun, DescribeAccumulated(orderedNames.Take(i)), orderedNames[i]);
var mergedFile = MergeTextHeadless(merge, source1, source2, dryRun, DescribeAccumulated(orderedNames.Take(i)), orderedNames[i],
ModPriority.Resolve(_modPriority, orderedNames.Take(i), orderedNames[i]));
if (mergedFile == null)
return false;
source1 = MergeSource.FromFlatFile(mergedFile, null);
Expand Down Expand Up @@ -773,7 +786,8 @@ string[] ResolveMergeOrder(ModFile conflict, string mergedModName, IReadOnlyDict
.ToArray();
}

FileInfo MergeTextHeadless(Merge merge, MergeSource source1, MergeSource source2, bool dryRun, string oldDescription = null, string newDescription = null)
FileInfo MergeTextHeadless(Merge merge, MergeSource source1, MergeSource source2, bool dryRun, string oldDescription = null, string newDescription = null,
PreferredSide preferredSide = PreferredSide.None)
{
ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name}";

Expand All @@ -786,7 +800,7 @@ FileInfo MergeTextHeadless(Merge merge, MergeSource source1, MergeSource source2
// review before it shipped (see docs/decisions/kdiff3-retirement.md).
var result = _mergeEngine.MergeHeadless(
source1, source2, _vanillaFile, _outputPath, openConflictMarkers: !dryRun,
oldDescription: oldDescription, newDescription: newDescription);
oldDescription: oldDescription, newDescription: newDescription, preferredSide: preferredSide);

if (_mergeEngine.LastFunctionLevelDecisions.Count > 0)
{
Expand Down
139 changes: 139 additions & 0 deletions WitcherScriptMerger.Core/LoadOrder/ModPriority.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace WitcherScriptMerger.LoadOrder
{
// Which side of a pairwise merge step a mod ranking prefers. `None` means the ranking
// has no opinion here and the engine's existing behavior applies unchanged - the normal
// case, since a ranking is opt-in and may be partial.
public enum PreferredSide
{
None,
Old,
New,
}

// A user-supplied ranking of mods, used to decide whose version of a function survives
// when two mods edit the same function differently.
//
// This is a different lever from the existing per-file order override
// (FileMerger.ResolveMergeOrder): that one sets the *chain order* mods are merged in,
// and must name every mod for the file it covers. This one doesn't reorder anything -
// it answers "whose code wins" at the point FunctionLevelMergeEngine would otherwise
// fall back to its most-distinct-from-vanilla tiebreak, which is order-independent and
// has no notion of the user preferring one mod over another.
//
// Deliberately partial: rank only the mods you care about. A pair where neither side is
// ranked returns None and behaves exactly as before, so adding a ranking can never
// change the outcome of a conflict it doesn't mention.
public static class ModPriority
{
// Reserved key an order file uses for the global ranking, alongside its ordinary
// "<relative path>": [mods...] entries. Safe as a sentinel because `*` is a
// reserved character in Windows file and directory names, so it can never collide
// with a real conflict's relative path.
public const string OrderFileKey = "*";

// Lower index = higher priority, so rank 0 beats rank 1. An unranked mod sits below
// every ranked one.
const int Unranked = int.MaxValue;

static int RankOf(IReadOnlyList<string> ranking, string modName)
{
if (ranking == null || string.IsNullOrWhiteSpace(modName))
return Unranked;

for (var i = 0; i < ranking.Count; ++i)
{
if (ranking[i] != null && ranking[i].EqualsIgnoreCase(modName))
return i;
}
return Unranked;
}

/// <summary>
/// Decides which side of one pairwise chain step the ranking prefers.
/// </summary>
/// <param name="ranking">Mod names, highest priority first. Null/empty = no opinion.</param>
/// <param name="oldModNames">
/// Every mod already accumulated into the old side. A chain step's old side is the
/// merge of everything before it, so it carries several mods at once; its rank is the
/// BEST rank among them. Without that, a highly-ranked mod would stop winning as soon
/// as one more mod merged on top of it, which is the opposite of what ranking it
/// means.
/// </param>
/// <param name="newModName">The single mod being merged in at this step.</param>
public static PreferredSide Resolve(IReadOnlyList<string> ranking, IEnumerable<string> oldModNames, string newModName)
{
if (ranking == null || ranking.Count == 0)
return PreferredSide.None;

var oldRank = Unranked;
if (oldModNames != null)
{
foreach (var name in oldModNames)
oldRank = Math.Min(oldRank, RankOf(ranking, name));
}

var newRank = RankOf(ranking, newModName);

// Neither side is mentioned - stay out of it entirely.
if (oldRank == Unranked && newRank == Unranked)
return PreferredSide.None;

if (oldRank < newRank)
return PreferredSide.Old;
if (newRank < oldRank)
return PreferredSide.New;

// Equal ranks means the same mod appears on both sides, which the chain
// shouldn't produce. No opinion rather than an arbitrary pick.
return PreferredSide.None;
}

/// <summary>
/// Pulls the global ranking out of an order file's entries, or null if it has none.
/// The reserved key is removed from the caller's view of the dictionary by
/// <see cref="WithoutRankingEntry"/> so it can never be mistaken for a path override.
/// </summary>
public static string[] ExtractRanking(IReadOnlyDictionary<string, string[]> orderOverrides)
{
if (orderOverrides == null || !orderOverrides.TryGetValue(OrderFileKey, out var ranking) || ranking == null)
return null;

// Blank entries are dropped rather than rejected: a ranking is advisory, and a
// stray empty string in a hand-edited file shouldn't fail a whole merge run.
var cleaned = ranking
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim())
.ToArray();

return cleaned.Length == 0 ? null : cleaned;
}

/// <summary>
/// The order-file entries with the reserved ranking key removed, so
/// <c>ResolveMergeOrder</c> only ever sees real relative-path overrides.
/// </summary>
public static IReadOnlyDictionary<string, string[]> WithoutRankingEntry(IReadOnlyDictionary<string, string[]> orderOverrides)
{
if (orderOverrides == null || !orderOverrides.ContainsKey(OrderFileKey))
return orderOverrides;

// Preserve the source dictionary's key comparer. A plain ToDictionary would
// rebuild with the default ordinal one and silently undo a deliberately-installed
// case-insensitive comparer - WsmMcpTools.MergeConflicts builds exactly that
// (StringComparer.OrdinalIgnoreCase, alongside separator normalization) so a
// differently-cased but otherwise-correct path key isn't ignored. Dropping it here
// would turn those keys back into silent no-ops, but only for callers that supply
// a ranking - a bug that hides until the feature is used.
var comparer = (orderOverrides as Dictionary<string, string[]>)?.Comparer
?? EqualityComparer<string>.Default;

return orderOverrides
.Where(kvp => !string.Equals(kvp.Key, OrderFileKey, StringComparison.Ordinal))
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, comparer);
}
}
}
14 changes: 8 additions & 6 deletions WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using DiffPlex.Model;
using WitcherScriptMerger.FileIndex;
using WitcherScriptMerger.Inventory;
using WitcherScriptMerger.LoadOrder;

namespace WitcherScriptMerger.Tools
{
Expand Down Expand Up @@ -159,7 +160,8 @@ public MergeEngineResult MergeHeadless(
string outputPath,
bool openConflictMarkers = true,
string oldDescription = null,
string newDescription = null)
string newDescription = null,
PreferredSide preferredSide = PreferredSide.None)
{
LastFunctionLevelDecisions = Array.Empty<string>();

Expand Down Expand Up @@ -233,7 +235,7 @@ public MergeEngineResult MergeHeadless(
// merge even when the whole-file 3-way diff hits this bug. Only
// attempted for .ws files - the extractor is WitcherScript-specific and
// has no notion of XML structure.
if (TryFunctionLevelRescue(baseText, oldText, newText, source1, source2, outputPath, oldDescription, newDescription, openConflictMarkers))
if (TryFunctionLevelRescue(baseText, oldText, newText, source1, source2, outputPath, oldDescription, newDescription, openConflictMarkers, preferredSide))
return MergeEngineResult.AutoSolved;

// DiffPlex's own diff algorithm produced output it isn't safe to trust
Expand Down Expand Up @@ -287,7 +289,7 @@ public MergeEngineResult MergeHeadless(
"DiffPlex ThreeWayDiffer bug (see CLAUDE.md). Falling back to the function-level merge.",
"Merge Output Rejected", NotifyButtons.OK, DialogIcon.Warning);

if (TryFunctionLevelRescue(baseText, oldText, newText, source1, source2, outputPath, oldDescription, newDescription, openConflictMarkers))
if (TryFunctionLevelRescue(baseText, oldText, newText, source1, source2, outputPath, oldDescription, newDescription, openConflictMarkers, preferredSide))
return MergeEngineResult.AutoSolved;

// Same policy as the DiffAlgorithmException branch above, for the same
Expand All @@ -308,7 +310,7 @@ public MergeEngineResult MergeHeadless(
// FunctionLevelMergeEngine's own comment for why this is a fallback that
// only ever activates where the whole-file merge has already failed, never
// a parallel code path for merges that would have succeeded anyway.
if (TryFunctionLevelRescue(baseText, oldText, newText, source1, source2, outputPath, oldDescription, newDescription, openConflictMarkers))
if (TryFunctionLevelRescue(baseText, oldText, newText, source1, source2, outputPath, oldDescription, newDescription, openConflictMarkers, preferredSide))
return MergeEngineResult.AutoSolved;

// Never write conflict markers to outputPath itself: FileMerger's headless
Expand Down Expand Up @@ -455,7 +457,7 @@ static void DeleteIfExists(string path)
bool TryFunctionLevelRescue(
string baseText, string oldText, string newText,
FileMerger.MergeSource source1, FileMerger.MergeSource source2, string outputPath,
string oldDescription, string newDescription, bool openConflictMarkers)
string oldDescription, string newDescription, bool openConflictMarkers, PreferredSide preferredSide)
{
// ModFile.IsScript, not a locally reinvented extension check - the same
// vocabulary every other file-category dispatch in Core uses for this exact
Expand All @@ -470,7 +472,7 @@ bool TryFunctionLevelRescue(
result = FunctionLevelMergeEngine.TryMerge(
baseText, oldText, newText,
source1.Name, source2.Name,
oldDescription ?? source1.Name, newDescription ?? source2.Name);
oldDescription ?? source1.Name, newDescription ?? source2.Name, preferredSide);
}
catch (ScriptUnitExtractor.ExtractionException)
{
Expand Down
34 changes: 31 additions & 3 deletions WitcherScriptMerger.Core/Tools/FunctionLevelMergeEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text;
using System.Text.RegularExpressions;
using DiffPlex;
using WitcherScriptMerger.LoadOrder;

namespace WitcherScriptMerger.Tools
{
Expand Down Expand Up @@ -53,10 +54,15 @@ public FunctionLevelMergeResult(bool applied, string mergedText, IReadOnlyList<s
// independently before reassembling.
public static class FunctionLevelMergeEngine
{
// preferredSide is the caller's mod-ranking verdict for this pairwise step (see
// LoadOrder/ModPriority). Defaulted so every existing caller and test is unaffected,
// and only consulted at the whole-function tiebreak - a ranking never overrides a
// clean, genuinely-merged result.
public static FunctionLevelMergeResult TryMerge(
string baseText, string oldText, string newText,
string oldMarkerLabel, string newMarkerLabel,
string oldDescription, string newDescription)
string oldDescription, string newDescription,
PreferredSide preferredSide = PreferredSide.None)
{
ScriptDocument baseDoc, oldDoc, newDoc;
try
Expand Down Expand Up @@ -137,7 +143,7 @@ public static FunctionLevelMergeResult TryMerge(
baseDoc.Units[i],
oldAlignment.MatchedSideIndex[i].HasValue ? oldDoc.Units[oldAlignment.MatchedSideIndex[i].Value].FullText : null,
newAlignment.MatchedSideIndex[i].HasValue ? newDoc.Units[newAlignment.MatchedSideIndex[i].Value].FullText : null,
oldMarkerLabel, newMarkerLabel, oldDescription, newDescription, decisions);
oldMarkerLabel, newMarkerLabel, oldDescription, newDescription, decisions, preferredSide);
}

var merged = new StringBuilder();
Expand Down Expand Up @@ -288,7 +294,7 @@ static FunctionLevelMergeResult DeclineWithWarning(string message)
static string ResolveUnit(
ScriptUnit baseUnit, string oldText, string newText,
string oldMarkerLabel, string newMarkerLabel, string oldDescription, string newDescription,
List<string> decisions)
List<string> decisions, PreferredSide preferredSide)
{
var baseText = baseUnit.FullText;

Expand Down Expand Up @@ -357,6 +363,28 @@ static string ResolveUnit(
"tiebreak below instead of the spliced result.");
}

// A user-supplied mod ranking outranks the distinctness heuristic. Distinctness
// is a guess at which side "did more" and has no way to know the user simply
// wants a particular mod's version of a function to survive; a ranking is that
// user telling us directly. Only consulted here, at the point the engine was
// already going to pick a whole side - never anywhere that would discard a
// clean, genuinely-merged result.
if (preferredSide == PreferredSide.New)
{
decisions.Add(
$"{baseUnit.DescribeKind()} {baseUnit.Name}: kept {newDescription}'s version because the configured " +
$"mod ranking prefers it over {oldDescription}, discarding {oldDescription}'s conflicting change here.");
return newText;
}

if (preferredSide == PreferredSide.Old)
{
decisions.Add(
$"{baseUnit.DescribeKind()} {baseUnit.Name}: kept {oldDescription}'s version because the configured " +
$"mod ranking prefers it over {newDescription}, discarding {newDescription}'s conflicting change here.");
return oldText;
}

var oldDistinctness = ComputeDistinctness(baseText, oldText);
var newDistinctness = ComputeDistinctness(baseText, newText);

Expand Down
Loading
Loading