修复PS系列mod转换中潜在漏洞

This commit is contained in:
LA
2026-03-04 22:33:52 +08:00
parent 58831971a5
commit 0c35fb49cf
5 changed files with 193 additions and 71 deletions

View File

@@ -36,6 +36,9 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
public static class ManiaKeyPatternHelp
{
private const double add_note_alignment_tolerance = 6.0;
private const double add_note_min_column_gap = 8.0;
internal readonly struct WindowContext
{
public readonly double BeatLength;
@@ -475,17 +478,22 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
internal static bool TryAddNoteFromOrderedCandidates(ManiaBeatmap beatmap, IReadOnlyList<int> candidates, double time)
{
double alignedTime = AlignTimeToNearbyRow(beatmap, time, add_note_alignment_tolerance);
for (int i = 0; i < candidates.Count; i++)
{
int column = candidates[i];
if (IsHoldOccupyingColumn(beatmap, column, time))
if (IsHoldOccupyingColumn(beatmap, column, alignedTime))
continue;
if (HasNoteAtTime(beatmap, column, time))
if (HasNoteAtTime(beatmap, column, alignedTime))
return false;
beatmap.HitObjects.Add(new Note { Column = column, StartTime = time });
if (hasAnyObjectInColumnNearTime(beatmap, column, alignedTime, add_note_min_column_gap))
return false;
beatmap.HitObjects.Add(new Note { Column = column, StartTime = alignedTime });
return true;
}
@@ -498,7 +506,9 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
Random rng,
double time)
{
int pickedIndex = pickIndexAvoidingHolds(beatmap, candidates, weights, rng, time);
double alignedTime = AlignTimeToNearbyRow(beatmap, time, add_note_alignment_tolerance);
int pickedIndex = pickIndexAvoidingHolds(beatmap, candidates, weights, rng, alignedTime);
if (pickedIndex < 0)
return false;
@@ -506,13 +516,55 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
candidates.RemoveAt(pickedIndex);
weights?.RemoveAt(pickedIndex);
if (HasNoteAtTime(beatmap, column, time))
if (HasNoteAtTime(beatmap, column, alignedTime))
return false;
beatmap.HitObjects.Add(new Note { Column = column, StartTime = time });
if (hasAnyObjectInColumnNearTime(beatmap, column, alignedTime, add_note_min_column_gap))
return false;
beatmap.HitObjects.Add(new Note { Column = column, StartTime = alignedTime });
return true;
}
internal static double AlignTimeToNearbyRow(ManiaBeatmap beatmap, double time, double tolerance = 3.0)
{
double bestTime = time;
double bestDelta = tolerance;
foreach (var obj in beatmap.HitObjects)
{
double delta = Math.Abs(obj.StartTime - time);
if (delta > bestDelta)
continue;
bestDelta = delta;
bestTime = obj.StartTime;
}
return bestTime;
}
private static bool hasAnyObjectInColumnNearTime(ManiaBeatmap beatmap, int column, double time, double minGap, ManiaHitObject? ignore = null)
{
if (minGap <= 0)
return false;
foreach (var obj in beatmap.HitObjects)
{
if (ReferenceEquals(obj, ignore))
continue;
if (obj.Column != column)
continue;
if (Math.Abs(obj.StartTime - time) < minGap)
return true;
}
return false;
}
// Placement helpers (shared rule: hold occupied -> reselect, note exists -> skip).
internal static bool HasNoteAtTime(ManiaBeatmap beatmap, int column, double time, ManiaHitObject? ignore = null, double tolerance = 0.5)
@@ -632,6 +684,52 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
return false;
}
internal static bool IsWithinBeatFraction(double deltaTime, ManiaBeatmap beatmap, double referenceTime, double beatFraction, double tolerance = 0.5)
{
double beatLength = beatmap.ControlPointInfo.TimingPointAt(referenceTime).BeatLength;
if (beatLength <= 0)
return false;
double threshold = beatLength * Math.Max(0, beatFraction);
return deltaTime <= threshold + tolerance;
}
internal static void RemoveSameColumnRunsWithinBeatFraction(ManiaBeatmap beatmap, double windowStart, double windowEnd, double beatFraction, double tolerance = 0.5)
{
var toRemove = new HashSet<ManiaHitObject>();
foreach (var group in beatmap.HitObjects.OfType<Note>().GroupBy(o => o.Column))
{
Note? previous = null;
foreach (var note in group.OrderBy(o => o.StartTime))
{
if (note.StartTime < windowStart - tolerance || note.StartTime > windowEnd + tolerance)
continue;
if (previous == null)
{
previous = note;
continue;
}
double delta = note.StartTime - previous.StartTime;
if (IsWithinBeatFraction(delta, beatmap, previous.StartTime, beatFraction, tolerance))
{
toRemove.Add(note);
continue;
}
previous = note;
}
}
foreach (var obj in toRemove)
beatmap.HitObjects.Remove(obj);
}
internal static void Shuffle<T>(IList<T> list, Random rng)
{
for (int i = list.Count - 1; i > 0; i--)

View File

@@ -24,6 +24,8 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
{
public class ManiaModPatternShift : Mod, IApplicableAfterBeatmapConversion, IApplicableToBeatmapConverter, IHasSeed, IHasApplyOrder
{
private const double min_column_spacing_ms = 8;
public override string Name => "Pattern Shift";
public override string Acronym => "PS";
@@ -250,14 +252,14 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
foreach (var note in chord.Notes)
{
int column = chooseColumn(keyCount, lastColumnTime, lastNote, rng, note.StartTime);
int column = chooseColumn(keyCount, lastColumnTime, lastNote, rng, note.StartTime, min_column_spacing_ms);
if (column < 0)
continue;
if (usedColumns.Contains(column))
continue;
if (hasAssignedNoteAtTime(placedNotes, column, note.StartTime))
if (hasAssignedNoteAtTime(placedNotes, column, note.StartTime, min_column_spacing_ms))
continue;
note.AssignedColumn = column;
@@ -272,16 +274,28 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
}
}
private static int chooseColumn(int keys, double[] lastUsedTime, int lastNote, Random rng, double currentTime)
private static int chooseColumn(int keys, double[] lastUsedTime, int lastNote, Random rng, double currentTime, double minSpacingMs)
{
var candidates = new List<int>();
double safeTime = currentTime - Math.Max(0, minSpacingMs);
for (int i = 0; i < keys; i++)
{
if (lastUsedTime[i] <= currentTime)
if (lastUsedTime[i] <= safeTime)
candidates.Add(i);
}
// 若严格最小间隔下无可用列,退化到旧逻辑,避免过多丢 note。
if (candidates.Count == 0)
{
for (int i = 0; i < keys; i++)
{
if (lastUsedTime[i] <= currentTime)
candidates.Add(i);
}
}
if (candidates.Count == 0)
return -1;

View File

@@ -101,6 +101,8 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
int direction = lastCol == firstCol ? (rng.Next(0, 2) == 0 ? -1 : 1) : (lastCol > firstCol ? 1 : -1);
int prevTarget = firstCol;
int lastAssignedColumn = firstCol;
double lastAssignedTime = groups[0].time;
for (int g = 1; g < groups.Count; g++)
{
@@ -108,25 +110,48 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
if (time < windowStart - TIME_TOLERANCE || time > windowEnd + TIME_TOLERANCE)
continue;
double delta = time - lastAssignedTime;
bool forbidSameColumnWithPrevious = ManiaKeyPatternHelp.IsWithinBeatFraction(delta, beatmap, lastAssignedTime, 1.0 / 2.0, TIME_TOLERANCE);
int? forbiddenColumn = forbidSameColumnWithPrevious ? lastAssignedColumn : null;
int minCol = direction > 0 ? prevTarget + 1 : 0;
int maxCol = direction > 0 ? totalColumns - 1 : prevTarget - 1;
if (!tryPickNearestColumn(beatmap, cur, time, minCol, maxCol, out int chosen))
if (!tryPickNearestColumn(beatmap, cur, time, minCol, maxCol, out int chosen, forbiddenColumn))
{
// 允许保持不下降的单调(非严格)
minCol = direction > 0 ? prevTarget : 0;
maxCol = direction > 0 ? totalColumns - 1 : prevTarget;
if (!tryPickNearestColumn(beatmap, cur, time, minCol, maxCol, out chosen))
continue;
if (!tryPickNearestColumn(beatmap, cur, time, minCol, maxCol, out chosen, forbiddenColumn))
{
// 最后兜底:允许在全列找一个可用列,但仍禁止与前一个在 1/2 beat 内同列。
if (!tryPickNearestColumn(beatmap, cur, time, 0, totalColumns - 1, out chosen, forbiddenColumn))
{
if (forbidSameColumnWithPrevious && cur.Column == lastAssignedColumn)
{
beatmap.HitObjects.Remove(cur);
continue;
}
lastAssignedColumn = cur.Column;
lastAssignedTime = time;
prevTarget = cur.Column;
continue;
}
}
}
cur.Column = chosen;
prevTarget = chosen;
lastAssignedColumn = chosen;
lastAssignedTime = time;
}
// 大间隙单调滑梯:首尾间隔至少 1/2 拍才添加
tryAddLargeGapSlide(beatmap, windowObjects, groups, beatLength, rng, settings);
ManiaKeyPatternHelp.RemoveSameColumnRunsWithinBeatFraction(beatmap, windowStart, windowEnd, 1.0 / 2.0, TIME_TOLERANCE);
}
private static void tryAddLargeGapSlide(ManiaBeatmap beatmap,
@@ -195,7 +220,8 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
double time,
int minCol,
int maxCol,
out int chosen)
out int chosen,
int? forbiddenColumn = null)
{
int totalColumns = Math.Max(1, beatmap.TotalColumns);
int clampedMin = Math.Clamp(minCol, 0, totalColumns - 1);
@@ -209,7 +235,7 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
for (int col = clampedMin; col <= clampedMax; col++)
{
if (!isColumnAvailable(beatmap, obj, col, time))
if ((forbiddenColumn.HasValue && col == forbiddenColumn.Value) || !isColumnAvailable(beatmap, obj, col, time))
continue;
int dist = Math.Abs(col - obj.Column);

View File

@@ -268,6 +268,8 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
Random rng,
double timeTolerance)
{
double alignedTime = ManiaKeyPatternHelp.AlignTimeToNearbyRow(beatmap, time, timeTolerance);
// 不允许在整拍1/1上添加 note。
if (addNote)
{
@@ -275,14 +277,14 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
if (beatLength > 0)
{
double mod = time % beatLength;
double mod = alignedTime % beatLength;
if (mod <= timeTolerance || Math.Abs(beatLength - mod) <= timeTolerance)
return false;
}
}
int totalColumns = Math.Max(1, beatmap.TotalColumns);
var sourceNotes = ManiaKeyPatternHelp.GetNotesAtTime(windowObjects, time, timeTolerance);
var sourceNotes = ManiaKeyPatternHelp.GetNotesAtTime(windowObjects, alignedTime, timeTolerance);
if (sourceNotes.Count == 0)
return false;
@@ -292,8 +294,8 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
for (int column = 0; column < totalColumns; column++)
{
bool hasPrev = time - quarter >= 0 && ManiaKeyPatternHelp.HasNoteAtTime(beatmap, column, time - quarter, null, timeTolerance);
bool hasNext = ManiaKeyPatternHelp.HasNoteAtTime(beatmap, column, time + quarter, null, timeTolerance);
bool hasPrev = alignedTime - quarter >= 0 && ManiaKeyPatternHelp.HasNoteAtTime(beatmap, column, alignedTime - quarter, null, timeTolerance);
bool hasNext = ManiaKeyPatternHelp.HasNoteAtTime(beatmap, column, alignedTime + quarter, null, timeTolerance);
if (hasPrev && hasNext)
bothSides.Add(column);
@@ -339,18 +341,18 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
if (addNote)
{
if (ManiaKeyPatternHelp.HasNoteAtTime(beatmap, targetColumn, time, null, timeTolerance))
if (ManiaKeyPatternHelp.HasNoteAtTime(beatmap, targetColumn, alignedTime, null, timeTolerance))
continue;
if (ManiaKeyPatternHelp.IsHoldOccupyingColumn(beatmap, targetColumn, time, null, timeTolerance))
if (ManiaKeyPatternHelp.IsHoldOccupyingColumn(beatmap, targetColumn, alignedTime, null, timeTolerance))
continue;
}
else
{
if (ManiaKeyPatternHelp.HasNoteAtTime(beatmap, targetColumn, time, source, timeTolerance))
if (ManiaKeyPatternHelp.HasNoteAtTime(beatmap, targetColumn, alignedTime, source, timeTolerance))
continue;
if (ManiaKeyPatternHelp.IsHoldOccupyingColumn(beatmap, targetColumn, time, source, timeTolerance))
if (ManiaKeyPatternHelp.IsHoldOccupyingColumn(beatmap, targetColumn, alignedTime, source, timeTolerance))
continue;
}
@@ -365,7 +367,7 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
available.RemoveAt(targetIndex);
if (addNote)
beatmap.HitObjects.Add(new Note { Column = target, StartTime = time });
beatmap.HitObjects.Add(new Note { Column = target, StartTime = alignedTime });
else
source.Column = target;

View File

@@ -32,12 +32,12 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
// beatmap.HitObjects.AddRange(resolved);
// }
// 2) 去除重叠并降低密度
// 去除重叠并降低密度
CleanOverlapNotes(beatmap);
// 3) 在同一列中强制最小间隙
EnforceMinimumGaps(beatmap);
// 4) Enforce hold-release gap and convert too-short holds to notes.
// Enforce hold-release gap and convert too-short holds to notes.
EnforceHoldReleaseGap(beatmap);
// 调整/转换可能再次引入过小间距,做一次最终兜底。
EnforceMinimumGaps(beatmap);
}
/// <summary>
@@ -84,9 +84,21 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
return 0;
double startTime = beatmap.HitObjects.Min(h => h.StartTime);
double beatLength = beatmap.ControlPointInfo.TimingPointAt(startTime).BeatLength;
double gap = beatLength / beat;
return Math.Clamp(gap, 30, beatLength / beat * 2);
return getMinimumGapAtTime(beatmap, startTime, beat);
}
private static double getMinimumGapAtTime(ManiaBeatmap beatmap, double time, int beat = 8)
{
int safeBeatDivisor = Math.Max(1, beat);
double beatLength = beatmap.ControlPointInfo.TimingPointAt(time).BeatLength;
if (beatLength <= 0)
return 30;
double beatGap = beatLength / safeBeatDivisor;
// 保留“按节拍”判定,同时使用 30ms 作为保守下限,避免高速段阈值过小。
return Math.Max(30, beatGap);
}
/// <summary>
@@ -98,7 +110,6 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
if (beatmap.HitObjects.Count == 0)
return;
double minGapMs = GetMinimumGapMs(beatmap);
int targetKeys = beatmap.TotalColumns;
var byColumn = beatmap.HitObjects.ToList().GroupBy(o => Math.Clamp(o.Column, 0, Math.Max(0, targetKeys - 1)));
@@ -107,53 +118,24 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
foreach (var colGroup in byColumn)
{
var list = colGroup.OrderBy(o => o.StartTime).ToList();
if (list.Count == 0) continue;
if (list.Count == 0)
continue;
// 第一步:预先移除极度密集的音符(> 1/8 拍)作为快速预处理
var denseFiltered = new List<ManiaHitObject> { list[0] };
var kept = new List<ManiaHitObject> { list[0] };
for (int i = 1; i < list.Count; i++)
{
var prev = denseFiltered.Last();
var curr = list[i];
if (curr.StartTime - prev.StartTime < minGapMs)
continue; // 丢弃当前音符(过于密集)
denseFiltered.Add(curr);
}
// 第二步:应用最小间隙规则,包含向前查看的三连处理
var kept = new List<ManiaHitObject> { denseFiltered[0] };
int idx = 0;
while (++idx < denseFiltered.Count)
{
var prev = kept.Last();
var cur = denseFiltered[idx];
var current = list[i];
double prevEnd = prev is HoldNote ph ? ph.EndTime : prev.StartTime;
double gap = cur.StartTime - prevEnd;
double prevEnd = prev is HoldNote prevHold ? prevHold.EndTime : prev.StartTime;
double requiredGap = getMinimumGapAtTime(beatmap, prevEnd);
double actualGap = current.StartTime - prevEnd;
if (gap >= minGapMs)
{
kept.Add(cur);
if (actualGap < requiredGap)
continue;
}
// 间隙过小:尝试向前查看并删除中间音符
if (idx + 1 < denseFiltered.Count)
{
var next = denseFiltered[idx + 1];
double nextGap = next.StartTime - prevEnd;
if (nextGap >= minGapMs)
{
// 删除当前(中间)音符,前进到下一个
idx++; // will move to next in outer loop
kept.Add(next);
}
}
kept.Add(current);
}
survivors.AddRange(kept);
@@ -172,7 +154,6 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
/// <param name="beat"></param>
internal static void EnforceHoldReleaseGap(ManiaBeatmap beatmap, int beat = 8)
{
double minGapMs = GetMinimumGapMs(beatmap, beat);
if (beatmap.HitObjects.Count == 0)
return;
@@ -188,6 +169,7 @@ namespace osu.Game.Rulesets.Mania.LAsEzMania.Mods.LAsMods
continue;
var next = list[i + 1];
double minGapMs = getMinimumGapAtTime(beatmap, next.StartTime, beat);
double gap = next.StartTime - hold.EndTime;
if (gap >= minGapMs)