mirror of
https://github.com/SK-la/Ez2Lazer.git
synced 2026-03-13 11:20:28 +00:00
1127 lines
44 KiB
C#
1127 lines
44 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using osu.Game.Beatmaps;
|
|
using osu.Game.Rulesets.Mania.Beatmaps;
|
|
using osu.Game.Rulesets.Mania.LAsEZMania.Analysis;
|
|
using osu.Game.Rulesets.Objects;
|
|
using osu.Game.Rulesets.Objects.Types;
|
|
|
|
namespace osu.Game.Rulesets.Mania.Tests.Analysis
|
|
{
|
|
public static class SRCalculatorTunable
|
|
{
|
|
// Tunable parameters (defaults mirror original SRCalculator)
|
|
public static class Tunables
|
|
{
|
|
// keyUsage / anchor
|
|
public static double KeyUsageBaseContribution = 3.75; // original base_contribution
|
|
public static double KeyUsageDurationCap = 1500; // original clampedDuration cap
|
|
public static double KeyUsageExtensionDivisor = 150.0; // original extension divisor
|
|
|
|
// LN representation
|
|
public static double LNRepHeadBoost = 1.3;
|
|
public static double LNRepMidNeg = -0.3;
|
|
public static double LNRepTailNeg = -1.0;
|
|
public static double LNRepFallbackBase = 2.5;
|
|
public static double LNRepFallbackOffsetMult = 0.5;
|
|
|
|
// pBar / lnIntegral
|
|
public static double PBarLnMultiplier = 6 * 0.001; // 0.006 original
|
|
|
|
// rBar / jack penalty
|
|
public static double RBarStrengthBase = 0.08; // original
|
|
public static double JackPenaltyMultiplier = 35.0; // original
|
|
|
|
// final note adjustments
|
|
public static double FinalLNLenCap = 1000; // original
|
|
public static double FinalLNToNotesFactor = 0.5; // original multiplication
|
|
public static double FinalLNLenDivisor = 200.0; // original
|
|
public static double TotalNotesOffset = 60.0; // original denominator offset
|
|
public static double FinalScale = 0.975; // original
|
|
}
|
|
|
|
// Public API similar to SRCalculator
|
|
public static double CalculateSR(IBeatmap beatmap)
|
|
{
|
|
return ComputeInternalXxySR(beatmap, 1.0).sr;
|
|
}
|
|
|
|
public static double CalculateSR(IBeatmap beatmap, double clockRate)
|
|
{
|
|
return ComputeInternalXxySR(beatmap, clockRate).sr;
|
|
}
|
|
|
|
private static (double sr, Dictionary<string, long> times) ComputeInternalXxySR(IBeatmap beatmap, double clockRate = 1.0)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
ManiaBeatmap maniaBeatmap = (ManiaBeatmap)beatmap;
|
|
int keyCount = Math.Max(1, maniaBeatmap.TotalColumns > 0 ? maniaBeatmap.TotalColumns : (int)Math.Round(maniaBeatmap.BeatmapInfo.Difficulty.CircleSize));
|
|
|
|
double sr = XxySRCalculateCoreTunable(maniaBeatmap, keyCount, clockRate);
|
|
stopwatch.Stop();
|
|
|
|
var timings = new Dictionary<string, long> { ["Total"] = stopwatch.ElapsedMilliseconds };
|
|
return (sr, timings);
|
|
}
|
|
|
|
#region NoteStruct / LNRep
|
|
|
|
private readonly record struct NoteStruct
|
|
{
|
|
public NoteStruct(int column, int headTime, int tailTime)
|
|
{
|
|
Column = column;
|
|
HeadTime = headTime;
|
|
TailTime = tailTime;
|
|
}
|
|
|
|
public int Column { get; }
|
|
public int HeadTime { get; }
|
|
public int TailTime { get; }
|
|
public bool IsLongNote => TailTime >= 0 && TailTime > HeadTime;
|
|
}
|
|
|
|
private readonly struct LNRepStruct
|
|
{
|
|
public LNRepStruct(int[] points, double[] cumulative, double[] values)
|
|
{
|
|
Points = points;
|
|
Cumulative = cumulative;
|
|
Values = values;
|
|
}
|
|
|
|
public int[] Points { get; }
|
|
public double[] Cumulative { get; }
|
|
public double[] Values { get; }
|
|
}
|
|
|
|
#endregion
|
|
|
|
private static readonly Comparison<NoteStruct> note_comparer = compareNotes;
|
|
|
|
// Core: copy of XxySRCalculateCore but using Tunables
|
|
public static double XxySRCalculateCoreTunable(ManiaBeatmap maniaBeatmap, int keyCount, double clockRate = 1.0)
|
|
{
|
|
double[]? cross = CrossMatrixProvider.GetMatrix(keyCount);
|
|
if (cross == null || cross[0] == -1) throw new NotSupportedException($"Key mode {keyCount}k is not supported by the SR algorithm.");
|
|
|
|
int estimatedNotes = maniaBeatmap.HitObjects.Count;
|
|
if (estimatedNotes == 0) return 0.0;
|
|
|
|
var notes = new List<NoteStruct>(estimatedNotes);
|
|
var notesByColumn = new List<NoteStruct>[keyCount];
|
|
for (int i = 0; i < keyCount; i++) notesByColumn[i] = new List<NoteStruct>(estimatedNotes / keyCount + 1);
|
|
|
|
foreach (var hitObject in maniaBeatmap.HitObjects)
|
|
{
|
|
int column = Math.Clamp(hitObject.Column, 0, keyCount - 1);
|
|
int head = (int)Math.Round(hitObject.StartTime / clockRate);
|
|
int tail = (int)Math.Round(hitObject.GetEndTime() / clockRate);
|
|
if ((hitObject as IHasDuration)?.EndTime == null) tail = -1;
|
|
if (tail <= head) tail = -1;
|
|
var note = new NoteStruct(column, head, tail);
|
|
notes.Add(note);
|
|
notesByColumn[column].Add(note);
|
|
}
|
|
|
|
notes.Sort(note_comparer);
|
|
foreach (var colNotes in notesByColumn) colNotes.Sort(note_comparer);
|
|
|
|
var longNotes = notes.Where(n => n.IsLongNote).ToList();
|
|
var longNotesByTails = longNotes.OrderBy(n => n.TailTime).ToList();
|
|
|
|
double od = maniaBeatmap.BeatmapInfo.Difficulty.OverallDifficulty;
|
|
double x = computeHitLeniency(od);
|
|
|
|
int maxHead = notes.Max(n => n.HeadTime);
|
|
int maxTail = longNotes.Count > 0 ? longNotes.Max(n => n.TailTime) : maxHead;
|
|
int totalTime = Math.Max(maxHead, maxTail) + 1;
|
|
|
|
(double[] allCorners, double[] baseCorners, double[] aCorners) = buildCorners(totalTime, notes);
|
|
bool[][] keyUsage = buildKeyUsage(keyCount, totalTime, notes, baseCorners);
|
|
int[][] activeColumns = deriveActiveColumns(keyUsage);
|
|
|
|
double[][] keyUsage400 = buildKeyUsage400_tunable(keyCount, totalTime, notes, baseCorners);
|
|
double[] anchorBase = computeAnchor(keyCount, keyUsage400, baseCorners);
|
|
|
|
LNRepStruct? lnRep = longNotes.Count > 0 ? buildLNRepresentation_tunable(longNotes, totalTime) : null;
|
|
|
|
(double[][] deltaKs, double[] jBarBase) = computeJBar(keyCount, totalTime, x, notesByColumn, baseCorners);
|
|
double[] jBar = interpValues(allCorners, baseCorners, jBarBase);
|
|
|
|
double[] xBarBase = computeXBar(keyCount, totalTime, x, notesByColumn, activeColumns, baseCorners, cross);
|
|
double[] xBar = interpValues(allCorners, baseCorners, xBarBase);
|
|
|
|
double[] pBarBase = computePBar_tunable(keyCount, totalTime, x, notes, lnRep, anchorBase, baseCorners);
|
|
double[] pBar = interpValues(allCorners, baseCorners, pBarBase);
|
|
|
|
double[] aBarBase = computeABar(keyCount, totalTime, deltaKs, activeColumns, aCorners, baseCorners);
|
|
double[] aBar = interpValues(allCorners, aCorners, aBarBase);
|
|
|
|
double[] rBarBase = computeRBar_tunable(keyCount, totalTime, x, notesByColumn, longNotesByTails, baseCorners);
|
|
double[] rBar = interpValues(allCorners, baseCorners, rBarBase);
|
|
|
|
(double[] cStep, double[] ksStep) = computeCAndKs(keyCount, notes, keyUsage, baseCorners);
|
|
double[] cArr = stepInterp(allCorners, baseCorners, cStep);
|
|
double[] ksArr = stepInterp(allCorners, baseCorners, ksStep);
|
|
|
|
double[] gaps = computeGaps(allCorners);
|
|
double[] effectiveWeights = new double[allCorners.Length];
|
|
for (int i = 0; i < allCorners.Length; i++) effectiveWeights[i] = cArr[i] * gaps[i];
|
|
|
|
double[] dAll = new double[allCorners.Length];
|
|
|
|
Parallel.For(0, allCorners.Length, i =>
|
|
{
|
|
double abarExponent = 3.0 / Math.Max(ksArr[i], 1e-6);
|
|
double abarPow = aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], abarExponent);
|
|
double minCandidateContribution = 0.85 * jBar[i];
|
|
double minCandidate = 8 + minCandidateContribution;
|
|
double minJ = Math.Min(jBar[i], minCandidate);
|
|
double jackComponent = abarPow * minJ;
|
|
double term1 = 0.4 * (jackComponent <= 0 ? 0 : Math.Pow(jackComponent, 1.5));
|
|
|
|
double scaledP = 0.8 * pBar[i];
|
|
double jackPenalty = rBar[i] * Tunables.JackPenaltyMultiplier;
|
|
double ratio = jackPenalty / (cArr[i] + 8);
|
|
double pComponent = scaledP + ratio;
|
|
double powerBase = (aBar[i] <= 0 ? 0 : Math.Pow(aBar[i], 2.0 / 3.0)) * pComponent;
|
|
double term2 = 0.6 * (powerBase <= 0 ? 0 : Math.Pow(powerBase, 1.5));
|
|
|
|
double sumTerms = term1 + term2;
|
|
double s = sumTerms <= 0 ? 0 : Math.Pow(sumTerms, 2.0 / 3.0);
|
|
double numerator = abarPow * xBar[i];
|
|
double denominator = xBar[i] + s + 1;
|
|
double tValue = denominator <= 0 ? 0 : numerator / denominator;
|
|
double sqrtComponent = Math.Sqrt(Math.Max(s, 0));
|
|
double primaryImpact = 2.7 * sqrtComponent * (tValue <= 0 ? 0 : Math.Pow(tValue, 1.5));
|
|
double secondaryImpact = s * 0.27;
|
|
|
|
dAll[i] = primaryImpact + secondaryImpact;
|
|
});
|
|
|
|
double sr = finaliseDifficulty_tunable(dAll.ToList(), effectiveWeights.ToList(), notes, longNotes);
|
|
return sr;
|
|
}
|
|
|
|
#region Tunable helpers (variants of original methods)
|
|
|
|
private static double[][] buildKeyUsage400_tunable(int keyCount, int totalTime, List<NoteStruct> notes, double[] baseCorners)
|
|
{
|
|
double[][] usage = new double[keyCount][];
|
|
for (int k = 0; k < keyCount; k++) usage[k] = new double[baseCorners.Length];
|
|
|
|
double base_contribution = Tunables.KeyUsageBaseContribution;
|
|
double falloff = Tunables.KeyUsageBaseContribution / (400.0 * 400.0);
|
|
|
|
foreach (var note in notes)
|
|
{
|
|
int startTime = Math.Max(note.HeadTime, 0);
|
|
int endTime = note.IsLongNote ? Math.Min(note.TailTime, totalTime - 1) : note.HeadTime;
|
|
|
|
int left400 = lowerBound(baseCorners, startTime - 400);
|
|
int left = lowerBound(baseCorners, startTime);
|
|
int right = lowerBound(baseCorners, endTime);
|
|
int right400 = lowerBound(baseCorners, endTime + 400);
|
|
|
|
int duration = endTime - startTime;
|
|
double clampedDuration = Math.Min(duration, Tunables.KeyUsageDurationCap);
|
|
double extension = clampedDuration / Tunables.KeyUsageExtensionDivisor;
|
|
double contribution = base_contribution + extension;
|
|
|
|
for (int idx = left; idx < right; idx++) usage[note.Column][idx] += contribution;
|
|
|
|
for (int idx = left400; idx < left; idx++)
|
|
{
|
|
double offset = baseCorners[idx] - startTime;
|
|
double falloffContribution = falloff * Math.Pow(offset, 2);
|
|
double value = base_contribution - falloffContribution;
|
|
double clamped = Math.Max(value, 0);
|
|
usage[note.Column][idx] += clamped;
|
|
}
|
|
|
|
for (int idx = right; idx < right400; idx++)
|
|
{
|
|
double offset = baseCorners[idx] - endTime;
|
|
double falloffContribution = falloff * Math.Pow(offset, 2);
|
|
double value = base_contribution - falloffContribution;
|
|
double clamped = Math.Max(value, 0);
|
|
usage[note.Column][idx] += clamped;
|
|
}
|
|
}
|
|
|
|
return usage;
|
|
}
|
|
|
|
private static double[] computeAnchor(int keyCount, double[][] keyUsage400, double[] baseCorners)
|
|
{
|
|
double[] anchor = new double[baseCorners.Length];
|
|
|
|
for (int i = 0; i < baseCorners.Length; i++)
|
|
{
|
|
double[] counts = new double[keyCount];
|
|
for (int k = 0; k < keyCount; k++)
|
|
counts[k] = keyUsage400[k][i];
|
|
|
|
Array.Sort(counts);
|
|
Array.Reverse(counts);
|
|
|
|
double[] nonZero = counts.Where(c => c > 0).ToArray();
|
|
|
|
if (nonZero.Length <= 1)
|
|
{
|
|
anchor[i] = 0;
|
|
continue;
|
|
}
|
|
|
|
double walk = 0;
|
|
double maxWalk = 0;
|
|
|
|
for (int idx = 0; idx < nonZero.Length - 1; idx++)
|
|
{
|
|
double current = nonZero[idx];
|
|
double next = nonZero[idx + 1];
|
|
double ratio = next / current;
|
|
double offset = 0.5 - ratio;
|
|
double offsetPenalty = 4 * Math.Pow(offset, 2);
|
|
double damping = 1 - offsetPenalty;
|
|
walk += current * damping;
|
|
maxWalk += current;
|
|
}
|
|
|
|
double value = maxWalk <= 0 ? 0 : walk / maxWalk;
|
|
anchor[i] = 1 + Math.Min(value - 0.18, 5 * Math.Pow(value - 0.22, 3));
|
|
}
|
|
|
|
return anchor;
|
|
}
|
|
|
|
private static LNRepStruct buildLNRepresentation_tunable(List<NoteStruct> longNotes, int totalTime)
|
|
{
|
|
var diff = new Dictionary<int, double>();
|
|
|
|
foreach (var note in longNotes)
|
|
{
|
|
int t0 = Math.Min(note.HeadTime + 60, note.TailTime);
|
|
int t1 = Math.Min(note.HeadTime + 120, note.TailTime);
|
|
|
|
addToMap(diff, t0, Tunables.LNRepHeadBoost);
|
|
addToMap(diff, t1, Tunables.LNRepMidNeg);
|
|
addToMap(diff, note.TailTime, Tunables.LNRepTailNeg);
|
|
}
|
|
|
|
var pointsSet = new SortedSet<int> { 0, totalTime };
|
|
foreach (int key in diff.Keys) pointsSet.Add(key);
|
|
|
|
int[] points = pointsSet.ToArray();
|
|
double[] cumulative = new double[points.Length];
|
|
double[] values = new double[points.Length - 1];
|
|
|
|
double current = 0;
|
|
|
|
for (int i = 0; i < points.Length - 1; i++)
|
|
{
|
|
if (diff.TryGetValue(points[i], out double delta)) current += delta;
|
|
|
|
double fallbackOffset = Tunables.LNRepFallbackOffsetMult * current;
|
|
double fallback = Tunables.LNRepFallbackBase + fallbackOffset;
|
|
double transformed = Math.Min(current, fallback);
|
|
values[i] = transformed;
|
|
|
|
int length = points[i + 1] - points[i];
|
|
double segment = length * transformed;
|
|
cumulative[i + 1] = cumulative[i] + segment;
|
|
}
|
|
|
|
return new LNRepStruct(points, cumulative, values);
|
|
}
|
|
|
|
private static double[] computePBar_tunable(int keyCount, int totalTime, double x, List<NoteStruct> notes, LNRepStruct? lnRep, double[] anchor, double[] baseCorners)
|
|
{
|
|
double[] pStep = new double[baseCorners.Length];
|
|
|
|
for (int i = 0; i < notes.Count - 1; i++)
|
|
{
|
|
var leftNote = notes[i];
|
|
var rightNote = notes[i + 1];
|
|
int deltaTime = rightNote.HeadTime - leftNote.HeadTime;
|
|
|
|
if (deltaTime <= 0)
|
|
{
|
|
double invX = 1.0 / Math.Max(x, 1e-6);
|
|
double spikeInnerBase = 4 * invX;
|
|
double spikeInner = spikeInnerBase - 24;
|
|
double spikeBase = 0.02 * spikeInner;
|
|
if (spikeBase <= 0) continue;
|
|
|
|
double spikeMagnitude = Math.Pow(spikeBase, 0.25);
|
|
double spike = 1000 * spikeMagnitude;
|
|
int leftIdx = lowerBound(baseCorners, leftNote.HeadTime);
|
|
int rightIdx = upperBound(baseCorners, leftNote.HeadTime);
|
|
for (int idx = leftIdx; idx < rightIdx; idx++) pStep[idx] += spike;
|
|
continue;
|
|
}
|
|
|
|
int left = lowerBound(baseCorners, leftNote.HeadTime);
|
|
int right = lowerBound(baseCorners, rightNote.HeadTime);
|
|
if (right <= left) continue;
|
|
|
|
double delta = 0.001 * deltaTime;
|
|
double v = 1;
|
|
if (lnRep.HasValue) v += Tunables.PBarLnMultiplier * lnIntegral(lnRep.Value, leftNote.HeadTime, rightNote.HeadTime);
|
|
|
|
double booster = streamBooster(delta);
|
|
double effective = Math.Max(booster, v);
|
|
|
|
double inc;
|
|
|
|
if (delta < 2 * x / 3)
|
|
{
|
|
double invX = 1.0 / Math.Max(x, 1e-6);
|
|
double halfX = x / 2.0;
|
|
double deltaCentre = delta - halfX;
|
|
double deltaTerm = 24 * invX * Math.Pow(deltaCentre, 2);
|
|
double inner = 0.08 * invX * (1 - deltaTerm);
|
|
double innerClamp = Math.Max(inner, 0);
|
|
double magnitude = Math.Pow(innerClamp, 0.25);
|
|
inc = magnitude / Math.Max(delta, 1e-6) * effective;
|
|
}
|
|
else
|
|
{
|
|
double invX = 1.0 / Math.Max(x, 1e-6);
|
|
double centreTerm = Math.Pow(x / 6.0, 2);
|
|
double deltaTerm = 24 * invX * centreTerm;
|
|
double inner = 0.08 * invX * (1 - deltaTerm);
|
|
double innerClamp = Math.Max(inner, 0);
|
|
double magnitude = Math.Pow(innerClamp, 0.25);
|
|
inc = magnitude / Math.Max(delta, 1e-6) * effective;
|
|
}
|
|
|
|
for (int idx = left; idx < right; idx++)
|
|
{
|
|
double doubled = inc * 2;
|
|
double limit = Math.Max(inc, doubled - 10);
|
|
double anchored = inc * anchor[idx];
|
|
double contribution = Math.Min(anchored, limit);
|
|
pStep[idx] += contribution;
|
|
}
|
|
}
|
|
|
|
return SmoothOnCorners(baseCorners, pStep, 500, 0.001, SmoothMode.Sum);
|
|
}
|
|
|
|
private static double[] computeRBar_tunable(int keyCount, int totalTime, double x, List<NoteStruct>[] notesByColumn, List<NoteStruct> tailNotes, double[] baseCorners)
|
|
{
|
|
if (tailNotes.Count < 2) return new double[baseCorners.Length];
|
|
|
|
double[] iList = new double[tailNotes.Count];
|
|
|
|
for (int idx = 0; idx < tailNotes.Count; idx++)
|
|
{
|
|
var note = tailNotes[idx];
|
|
var next = findNextColumnNote(note, notesByColumn);
|
|
double nextHead = next?.HeadTime ?? 1_000_000_000;
|
|
|
|
double ih = 0.001 * Math.Abs(note.TailTime - note.HeadTime - 80) / Math.Max(x, 1e-6);
|
|
double it = 0.001 * Math.Abs(nextHead - note.TailTime - 80) / Math.Max(x, 1e-6);
|
|
|
|
iList[idx] = 2 / (2 + Math.Exp(-5 * (ih - 0.75)) + Math.Exp(-5 * (it - 0.75)));
|
|
}
|
|
|
|
double[] rStep = new double[baseCorners.Length];
|
|
|
|
for (int idx = 0; idx < tailNotes.Count - 1; idx++)
|
|
{
|
|
var current = tailNotes[idx];
|
|
var next = tailNotes[idx + 1];
|
|
int left = lowerBound(baseCorners, current.TailTime);
|
|
int right = lowerBound(baseCorners, next.TailTime);
|
|
if (right <= left) continue;
|
|
|
|
double delta = 0.001 * Math.Max(next.TailTime - current.TailTime, 1e-6);
|
|
double invSqrtDelta = Math.Pow(delta, -0.5);
|
|
double invX = 1.0 / Math.Max(x, 1e-6);
|
|
double blend = iList[idx] + iList[idx + 1];
|
|
double blendContribution = 0.8 * blend;
|
|
double modulation = 1 + blendContribution;
|
|
double strength = Tunables.RBarStrengthBase * invSqrtDelta * invX * modulation;
|
|
|
|
for (int baseIdx = left; baseIdx < right; baseIdx++) rStep[baseIdx] = Math.Max(rStep[baseIdx], strength);
|
|
}
|
|
|
|
return SmoothOnCorners(baseCorners, rStep, 500, 0.001, SmoothMode.Sum);
|
|
}
|
|
|
|
private static double finaliseDifficulty_tunable(List<double> difficulties, List<double> weights, List<NoteStruct> notes, List<NoteStruct> longNotes)
|
|
{
|
|
// reuse original finaliseDifficulty logic but replace LN-related constants
|
|
var combined = difficulties.Zip(weights, (d, w) => (d, w)).OrderBy(pair => pair.d).ToList();
|
|
if (combined.Count == 0) return 0;
|
|
|
|
double[] sortedD = combined.Select(p => p.d).ToArray();
|
|
double[] sortedWeights = combined.Select(p => Math.Max(p.w, 0)).ToArray();
|
|
|
|
double[] cumulative = new double[sortedWeights.Length];
|
|
cumulative[0] = sortedWeights[0];
|
|
for (int i = 1; i < sortedWeights.Length; i++) cumulative[i] = cumulative[i - 1] + sortedWeights[i];
|
|
|
|
double totalWeight = Math.Max(cumulative[^1], 1e-9);
|
|
double[] norm = cumulative.Select(v => v / totalWeight).ToArray();
|
|
|
|
double[] targets = { 0.945, 0.935, 0.925, 0.915, 0.845, 0.835, 0.825, 0.815 };
|
|
double percentile93 = 0;
|
|
double percentile83 = 0;
|
|
|
|
for (int i = 0; i < 4; i++)
|
|
{
|
|
int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1);
|
|
percentile93 += sortedD[index];
|
|
}
|
|
|
|
percentile93 /= 4.0;
|
|
|
|
for (int i = 4; i < 8; i++)
|
|
{
|
|
int index = Math.Min(bisectLeft(norm, targets[i]), sortedD.Length - 1);
|
|
percentile83 += sortedD[index];
|
|
}
|
|
|
|
percentile83 /= 4.0;
|
|
|
|
double weightedMeanNumerator = 0;
|
|
for (int i = 0; i < sortedD.Length; i++) weightedMeanNumerator += Math.Pow(sortedD[i], 5) * sortedWeights[i];
|
|
double weightedMean = Math.Pow(Math.Max(weightedMeanNumerator / totalWeight, 0), 0.2);
|
|
|
|
double topComponent = 0.25 * 0.88 * percentile93;
|
|
double middleComponent = 0.2 * 0.94 * percentile83;
|
|
double meanComponent = 0.55 * weightedMean;
|
|
double sr = topComponent + middleComponent + meanComponent;
|
|
sr = Math.Pow(sr, 1.0) / Math.Pow(8, 1.0) * 8;
|
|
|
|
double totalNotes = notes.Count;
|
|
|
|
foreach (var ln in longNotes)
|
|
{
|
|
double len = Math.Min(ln.TailTime - ln.HeadTime, Tunables.FinalLNLenCap);
|
|
totalNotes += Tunables.FinalLNToNotesFactor * (len / Tunables.FinalLNLenDivisor);
|
|
}
|
|
|
|
sr *= totalNotes / (totalNotes + Tunables.TotalNotesOffset);
|
|
sr = rescaleHigh(sr);
|
|
sr *= Tunables.FinalScale;
|
|
|
|
return sr;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Reused helper methods (copied from original)
|
|
|
|
private static double computeHitLeniency(double overallDifficulty)
|
|
{
|
|
double leniency = 0.3 * Math.Sqrt((64.5 - Math.Ceiling(overallDifficulty * 3.0)) / 500.0);
|
|
double offset = leniency - 0.09;
|
|
double scaledOffset = 0.6 * offset;
|
|
double adjustedWindow = scaledOffset + 0.09;
|
|
return Math.Min(leniency, adjustedWindow);
|
|
}
|
|
|
|
private static (double[] allCorners, double[] baseCorners, double[] aCorners) buildCorners(int totalTime, List<NoteStruct> notes)
|
|
{
|
|
var baseSet = new HashSet<int>();
|
|
|
|
foreach (var note in notes)
|
|
{
|
|
baseSet.Add(note.HeadTime);
|
|
if (note.IsLongNote) baseSet.Add(note.TailTime);
|
|
}
|
|
|
|
foreach (int value in baseSet.ToArray())
|
|
{
|
|
baseSet.Add(value + 501);
|
|
baseSet.Add(value - 499);
|
|
baseSet.Add(value + 1);
|
|
}
|
|
|
|
baseSet.Add(0);
|
|
baseSet.Add(totalTime);
|
|
double[] baseCorners = baseSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray();
|
|
|
|
var aSet = new HashSet<int>();
|
|
|
|
foreach (var note in notes)
|
|
{
|
|
aSet.Add(note.HeadTime);
|
|
if (note.IsLongNote) aSet.Add(note.TailTime);
|
|
}
|
|
|
|
foreach (int value in aSet.ToArray())
|
|
{
|
|
aSet.Add(value + 1000);
|
|
aSet.Add(value - 1000);
|
|
}
|
|
|
|
aSet.Add(0);
|
|
aSet.Add(totalTime);
|
|
double[] aCorners = aSet.Where(v => v >= 0 && v <= totalTime).Select(v => (double)v).Distinct().OrderBy(v => v).ToArray();
|
|
|
|
double[] allCorners = baseCorners.Concat(aCorners).Distinct().OrderBy(v => v).ToArray();
|
|
return (allCorners, baseCorners, aCorners);
|
|
}
|
|
|
|
private static bool[][] buildKeyUsage(int keyCount, int totalTime, List<NoteStruct> notes, double[] baseCorners)
|
|
{
|
|
bool[][] keyUsage = new bool[keyCount][];
|
|
for (int i = 0; i < keyCount; i++) keyUsage[i] = new bool[baseCorners.Length];
|
|
|
|
foreach (var note in notes)
|
|
{
|
|
int start = Math.Max(note.HeadTime - 150, 0);
|
|
int end = note.IsLongNote ? Math.Min(note.TailTime + 150, totalTime - 1) : Math.Min(note.HeadTime + 150, totalTime - 1);
|
|
int left = lowerBound(baseCorners, start);
|
|
int right = lowerBound(baseCorners, end);
|
|
for (int idx = left; idx < right; idx++) keyUsage[note.Column][idx] = true;
|
|
}
|
|
|
|
return keyUsage;
|
|
}
|
|
|
|
private static int[][] deriveActiveColumns(bool[][] keyUsage)
|
|
{
|
|
int length = keyUsage[0].Length;
|
|
int[][] active = new int[length][];
|
|
|
|
for (int i = 0; i < length; i++)
|
|
{
|
|
var list = new List<int>();
|
|
|
|
for (int col = 0; col < keyUsage.Length; col++)
|
|
{
|
|
if (keyUsage[col][i])
|
|
list.Add(col);
|
|
}
|
|
|
|
active[i] = list.ToArray();
|
|
}
|
|
|
|
return active;
|
|
}
|
|
|
|
private static (double[][] deltaKs, double[] jBar) computeJBar(int keyCount, int totalTime, double x, List<NoteStruct>[] notesByColumn, double[] baseCorners)
|
|
{
|
|
const double default_delta = 1e9;
|
|
double[][] deltaKs = new double[keyCount][];
|
|
double[][] jKs = new double[keyCount][];
|
|
Parallel.For(0, keyCount, k =>
|
|
{
|
|
deltaKs[k] = Enumerable.Repeat(default_delta, baseCorners.Length).ToArray();
|
|
jKs[k] = new double[baseCorners.Length];
|
|
var columnNotes = notesByColumn[k];
|
|
|
|
for (int i = 0; i < columnNotes.Count - 1; i++)
|
|
{
|
|
var current = columnNotes[i];
|
|
var next = columnNotes[i + 1];
|
|
int left = lowerBound(baseCorners, current.HeadTime);
|
|
int right = lowerBound(baseCorners, next.HeadTime);
|
|
if (right <= left) continue;
|
|
|
|
double headGap = Math.Max(next.HeadTime - current.HeadTime, 1e-6);
|
|
double delta = 0.001 * headGap;
|
|
double deltaShift = Math.Abs(delta - 0.08);
|
|
double penalty = 0.15 + deltaShift;
|
|
double attenuation = Math.Pow(penalty, -4);
|
|
double nerfFactor = 7e-5 * attenuation;
|
|
double jackNerfer = 1 - nerfFactor;
|
|
double xRoot = Math.Pow(x, 0.25);
|
|
double rootScale = 0.11 * xRoot;
|
|
double jackBase = delta + rootScale;
|
|
double inverseJack = Math.Pow(jackBase, -1);
|
|
double inverseDelta = 1.0 / delta;
|
|
double value = inverseDelta * inverseJack * jackNerfer;
|
|
|
|
for (int idx = left; idx < right; idx++)
|
|
{
|
|
deltaKs[k][idx] = Math.Min(deltaKs[k][idx], delta);
|
|
jKs[k][idx] = value;
|
|
}
|
|
}
|
|
|
|
jKs[k] = SmoothOnCorners(baseCorners, jKs[k], 500, 0.001, SmoothMode.Sum);
|
|
});
|
|
|
|
double[] jBar = new double[baseCorners.Length];
|
|
|
|
for (int idx = 0; idx < baseCorners.Length; idx++)
|
|
{
|
|
double numerator = 0;
|
|
double denominator = 0;
|
|
|
|
for (int k = 0; k < keyCount; k++)
|
|
{
|
|
double v = Math.Max(jKs[k][idx], 0);
|
|
double weight = 1.0 / Math.Max(deltaKs[k][idx], 1e-9);
|
|
numerator += Math.Pow(v, 5) * weight;
|
|
denominator += weight;
|
|
}
|
|
|
|
double combined = denominator <= 0 ? 0 : numerator / denominator;
|
|
jBar[idx] = Math.Pow(Math.Max(combined, 0), 0.2);
|
|
}
|
|
|
|
return (deltaKs, jBar);
|
|
}
|
|
|
|
private static double[] computeXBar(int keyCount, int totalTime, double x, List<NoteStruct>[] notesByColumn, int[][] activeColumns, double[] baseCorners, double[] cross)
|
|
{
|
|
double[][] xKs = new double[keyCount + 1][];
|
|
double[][] fastCross = new double[keyCount + 1][];
|
|
|
|
for (int i = 0; i < xKs.Length; i++)
|
|
{
|
|
xKs[i] = new double[baseCorners.Length];
|
|
fastCross[i] = new double[baseCorners.Length];
|
|
}
|
|
|
|
Parallel.For(0, keyCount + 1, k =>
|
|
{
|
|
var pair = new List<NoteStruct>();
|
|
|
|
if (k == 0) pair.AddRange(notesByColumn[0]);
|
|
else if (k == keyCount) pair.AddRange(notesByColumn[keyCount - 1]);
|
|
else
|
|
{
|
|
pair.AddRange(notesByColumn[k - 1]);
|
|
pair.AddRange(notesByColumn[k]);
|
|
}
|
|
|
|
pair.Sort(note_comparer);
|
|
if (pair.Count < 2) return;
|
|
|
|
for (int i = 1; i < pair.Count; i++)
|
|
{
|
|
var prev = pair[i - 1];
|
|
var current = pair[i];
|
|
int left = lowerBound(baseCorners, prev.HeadTime);
|
|
int right = lowerBound(baseCorners, current.HeadTime);
|
|
if (right <= left) continue;
|
|
|
|
double delta = 0.001 * Math.Max(current.HeadTime - prev.HeadTime, 1e-6);
|
|
double val = 0.16 * Math.Pow(Math.Max(x, delta), -2);
|
|
int idxStart = Math.Min(left, baseCorners.Length - 1);
|
|
int idxEnd = Math.Min(Math.Max(right, 0), baseCorners.Length - 1);
|
|
bool condition1 = !contains(activeColumns[idxStart], k - 1) && !contains(activeColumns[idxEnd], k - 1);
|
|
bool condition2 = !contains(activeColumns[idxStart], k) && !contains(activeColumns[idxEnd], k);
|
|
if (condition1 || condition2) val *= 1 - cross[Math.Min(k, cross.Length - 1)];
|
|
|
|
for (int idx = left; idx < right; idx++)
|
|
{
|
|
xKs[k][idx] = val;
|
|
fastCross[k][idx] = Math.Max(0, 0.4 * Math.Pow(Math.Max(Math.Max(delta, 0.06), 0.75 * x), -2) - 80);
|
|
}
|
|
}
|
|
});
|
|
|
|
double[] xBase = new double[baseCorners.Length];
|
|
|
|
for (int idx = 0; idx < baseCorners.Length; idx++)
|
|
{
|
|
double sum = 0;
|
|
for (int k = 0; k <= keyCount; k++) sum += cross[Math.Min(k, cross.Length - 1)] * xKs[k][idx];
|
|
|
|
for (int k = 0; k < keyCount; k++)
|
|
{
|
|
double leftVal = fastCross[k][idx] * cross[Math.Min(k, cross.Length - 1)];
|
|
double rightVal = fastCross[k + 1][idx] * cross[Math.Min(k + 1, cross.Length - 1)];
|
|
sum += Math.Sqrt(Math.Max(leftVal * rightVal, 0));
|
|
}
|
|
|
|
xBase[idx] = sum;
|
|
}
|
|
|
|
return SmoothOnCorners(baseCorners, xBase, 500, 0.001, SmoothMode.Sum);
|
|
}
|
|
|
|
// many helper methods reused without modification
|
|
private static double lnIntegral(LNRepStruct repStruct, int a, int b)
|
|
{
|
|
int[] points = repStruct.Points;
|
|
double[] cumulative = repStruct.Cumulative;
|
|
double[] values = repStruct.Values;
|
|
int startIndex = upperBound(points, a) - 1;
|
|
int endIndex = upperBound(points, b) - 1;
|
|
if (startIndex < 0) startIndex = 0;
|
|
if (endIndex < startIndex) endIndex = startIndex;
|
|
double total = 0;
|
|
|
|
if (startIndex == endIndex) total = (b - a) * values[startIndex];
|
|
else
|
|
{
|
|
total += (points[startIndex + 1] - a) * values[startIndex];
|
|
total += cumulative[endIndex] - cumulative[startIndex + 1];
|
|
total += (b - points[endIndex]) * values[endIndex];
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
private static double[] computeABar(int keyCount, int totalTime, double[][] deltaKs, int[][] activeColumns, double[] aCorners, double[] baseCorners)
|
|
{
|
|
double[] aStep = Enumerable.Repeat(1.0, aCorners.Length).ToArray();
|
|
|
|
for (int i = 0; i < aCorners.Length; i++)
|
|
{
|
|
int idx = lowerBound(baseCorners, aCorners[i]);
|
|
idx = Math.Min(idx, baseCorners.Length - 1);
|
|
int[] cols = activeColumns[idx];
|
|
if (cols.Length < 2) continue;
|
|
|
|
for (int j = 0; j < cols.Length - 1; j++)
|
|
{
|
|
int c0 = cols[j];
|
|
int c1 = cols[j + 1];
|
|
double deltaGap = Math.Abs(deltaKs[c0][idx] - deltaKs[c1][idx]);
|
|
double maxDelta = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]);
|
|
double offset = Math.Max(maxDelta - 0.11, 0);
|
|
double offsetContribution = 0.4 * offset;
|
|
double diff = deltaGap + offsetContribution;
|
|
|
|
if (diff < 0.02)
|
|
{
|
|
double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]);
|
|
double factorContribution = 0.5 * factorBase;
|
|
double factor = 0.75 + factorContribution;
|
|
aStep[i] *= Math.Min(factor, 1);
|
|
}
|
|
else if (diff < 0.07)
|
|
{
|
|
double factorBase = Math.Max(deltaKs[c0][idx], deltaKs[c1][idx]);
|
|
double growth = 5 * diff;
|
|
double factorContribution = 0.5 * factorBase;
|
|
double factor = 0.65 + growth + factorContribution;
|
|
aStep[i] *= Math.Min(factor, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return SmoothOnCorners(aCorners, aStep, 250, 0, SmoothMode.Average);
|
|
}
|
|
|
|
private static (double[] cStep, double[] ksStep) computeCAndKs(int keyCount, List<NoteStruct> notes, bool[][] keyUsage, double[] baseCorners)
|
|
{
|
|
double[] cStep = new double[baseCorners.Length];
|
|
double[] ksStep = new double[baseCorners.Length];
|
|
var noteTimesList = new List<double>(notes.Count);
|
|
foreach (var note in notes) noteTimesList.Add(note.HeadTime);
|
|
noteTimesList.Sort();
|
|
double[] noteTimes = noteTimesList.ToArray();
|
|
|
|
for (int idx = 0; idx < baseCorners.Length; idx++)
|
|
{
|
|
double left = baseCorners[idx] - 500;
|
|
double right = baseCorners[idx] + 500;
|
|
int leftIndex = lowerBound(noteTimes, left);
|
|
int rightIndex = lowerBound(noteTimes, right);
|
|
cStep[idx] = Math.Max(rightIndex - leftIndex, 0);
|
|
int activeCount = 0;
|
|
|
|
for (int col = 0; col < keyCount; col++)
|
|
{
|
|
if (keyUsage[col][idx])
|
|
activeCount++;
|
|
}
|
|
|
|
ksStep[idx] = Math.Max(activeCount, 1);
|
|
}
|
|
|
|
return (cStep, ksStep);
|
|
}
|
|
|
|
private static double[] computeGaps(double[] corners)
|
|
{
|
|
if (corners.Length == 0) return Array.Empty<double>();
|
|
|
|
double[] gaps = new double[corners.Length];
|
|
|
|
if (corners.Length == 1)
|
|
{
|
|
gaps[0] = 0;
|
|
return gaps;
|
|
}
|
|
|
|
gaps[0] = (corners[1] - corners[0]) / 2.0;
|
|
gaps[^1] = (corners[^1] - corners[^2]) / 2.0;
|
|
for (int i = 1; i < corners.Length - 1; i++) gaps[i] = (corners[i + 1] - corners[i - 1]) / 2.0;
|
|
return gaps;
|
|
}
|
|
|
|
private static NoteStruct? findNextColumnNote(NoteStruct note, List<NoteStruct>[] notesByColumn)
|
|
{
|
|
var columnNotes = notesByColumn[note.Column];
|
|
int index = columnNotes.IndexOf(note);
|
|
if (index >= 0 && index + 1 < columnNotes.Count) return columnNotes[index + 1];
|
|
|
|
return null;
|
|
}
|
|
|
|
private static double[] interpValues(double[] newX, double[] oldX, double[] oldVals)
|
|
{
|
|
double[] result = new double[newX.Length];
|
|
|
|
for (int i = 0; i < newX.Length; i++)
|
|
{
|
|
double x = newX[i];
|
|
|
|
if (x <= oldX[0])
|
|
{
|
|
result[i] = oldVals[0];
|
|
continue;
|
|
}
|
|
|
|
if (x >= oldX[^1])
|
|
{
|
|
result[i] = oldVals[^1];
|
|
continue;
|
|
}
|
|
|
|
int idx = lowerBound(oldX, x);
|
|
|
|
if (idx < oldX.Length && nearlyEquals(oldX[idx], x))
|
|
{
|
|
result[i] = oldVals[idx];
|
|
continue;
|
|
}
|
|
|
|
int prev = Math.Max(idx - 1, 0);
|
|
double x0 = oldX[prev];
|
|
double x1 = oldX[idx];
|
|
double y0 = oldVals[prev];
|
|
double y1 = oldVals[idx];
|
|
double deltaY = y1 - y0;
|
|
double deltaX = x - x0;
|
|
double numerator = deltaY * deltaX;
|
|
double fraction = numerator / (x1 - x0);
|
|
result[i] = y0 + fraction;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static double[] stepInterp(double[] newX, double[] oldX, double[] oldVals)
|
|
{
|
|
double[] result = new double[newX.Length];
|
|
|
|
for (int i = 0; i < newX.Length; i++)
|
|
{
|
|
int idx = upperBound(oldX, newX[i]) - 1;
|
|
if (idx < 0) idx = 0;
|
|
result[i] = oldVals[Math.Min(idx, oldVals.Length - 1)];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static double[] SmoothOnCorners(double[] positions, double[] values, double window, double scale, SmoothMode mode)
|
|
{
|
|
if (positions.Length == 0) return Array.Empty<double>();
|
|
|
|
double[] cumulative = buildCumulative(positions, values);
|
|
double[] output = new double[positions.Length];
|
|
|
|
for (int i = 0; i < positions.Length; i++)
|
|
{
|
|
double s = positions[i];
|
|
double a = Math.Max(s - window, positions[0]);
|
|
double b = Math.Min(s + window, positions[^1]);
|
|
|
|
if (b <= a)
|
|
{
|
|
output[i] = 0;
|
|
continue;
|
|
}
|
|
|
|
double integral = queryIntegral(positions, cumulative, values, b) - queryIntegral(positions, cumulative, values, a);
|
|
if (mode == SmoothMode.Average) output[i] = integral / Math.Max(b - a, 1e-9);
|
|
else output[i] = integral * scale;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
private static double[] buildCumulative(double[] positions, double[] values)
|
|
{
|
|
double[] cumulative = new double[positions.Length];
|
|
|
|
for (int i = 1; i < positions.Length; i++)
|
|
{
|
|
double width = positions[i] - positions[i - 1];
|
|
double increment = values[i - 1] * width;
|
|
cumulative[i] = cumulative[i - 1] + increment;
|
|
}
|
|
|
|
return cumulative;
|
|
}
|
|
|
|
private static double queryIntegral(double[] positions, double[] cumulative, double[] values, double point)
|
|
{
|
|
if (point <= positions[0]) return 0;
|
|
|
|
if (point >= positions[^1]) return cumulative[^1];
|
|
|
|
int idx = lowerBound(positions, point);
|
|
if (idx < positions.Length && nearlyEquals(positions[idx], point)) return cumulative[idx];
|
|
|
|
int prev = Math.Max(idx - 1, 0);
|
|
double delta = point - positions[prev];
|
|
double contribution = values[prev] * delta;
|
|
return cumulative[prev] + contribution;
|
|
}
|
|
|
|
private static double streamBooster(double delta)
|
|
{
|
|
double inv = 7.5 / Math.Max(delta, 1e-6);
|
|
if (inv <= 160 || inv >= 360) return 1;
|
|
|
|
double shifted = inv - 160;
|
|
double distance = inv - 360;
|
|
double adjustment = 1.7e-7 * shifted * Math.Pow(distance, 2);
|
|
return 1 + adjustment;
|
|
}
|
|
|
|
private static bool contains(int[] array, int target)
|
|
{
|
|
if (target < 0) return false;
|
|
|
|
for (int i = 0; i < array.Length; i++)
|
|
{
|
|
if (array[i] == target)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static int lowerBound(double[] array, double value)
|
|
{
|
|
int left = 0;
|
|
int right = array.Length;
|
|
|
|
while (left < right)
|
|
{
|
|
int span = right - left;
|
|
int mid = left + (span >> 1);
|
|
if (array[mid] < value) left = mid + 1;
|
|
else right = mid;
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private static int lowerBound(double[] array, int value) => lowerBound(array, (double)value);
|
|
|
|
private static int lowerBound(int[] array, double value)
|
|
{
|
|
int left = 0;
|
|
int right = array.Length;
|
|
|
|
while (left < right)
|
|
{
|
|
int span = right - left;
|
|
int mid = left + (span >> 1);
|
|
if (array[mid] < value) left = mid + 1;
|
|
else right = mid;
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private static int upperBound(int[] array, int value)
|
|
{
|
|
int left = 0;
|
|
int right = array.Length;
|
|
|
|
while (left < right)
|
|
{
|
|
int span = right - left;
|
|
int mid = left + (span >> 1);
|
|
if (array[mid] <= value) left = mid + 1;
|
|
else right = mid;
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private static int upperBound(double[] array, double value)
|
|
{
|
|
int left = 0;
|
|
int right = array.Length;
|
|
|
|
while (left < right)
|
|
{
|
|
int span = right - left;
|
|
int mid = left + (span >> 1);
|
|
if (array[mid] <= value) left = mid + 1;
|
|
else right = mid;
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private static int bisectLeft(double[] array, double value)
|
|
{
|
|
int left = 0;
|
|
int right = array.Length;
|
|
|
|
while (left < right)
|
|
{
|
|
int span = right - left;
|
|
int mid = left + (span >> 1);
|
|
if (array[mid] < value) left = mid + 1;
|
|
else right = mid;
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private static double safePow(double value, double exponent)
|
|
{
|
|
if (value <= 0) return 0;
|
|
|
|
return Math.Pow(value, exponent);
|
|
}
|
|
|
|
private static double rescaleHigh(double sr)
|
|
{
|
|
double excess = sr - 9;
|
|
double normalized = excess / 1.2;
|
|
double softened = 9 + normalized;
|
|
return sr <= 9 ? sr : softened;
|
|
}
|
|
|
|
private static int clamp(int value, int min, int max) { return Math.Min(Math.Max(value, min), max); }
|
|
|
|
private static bool nearlyEquals(double a, double b, double epsilon = 1e-9) { return Math.Abs(a - b) <= epsilon; }
|
|
|
|
private static int compareNotes(NoteStruct a, NoteStruct b)
|
|
{
|
|
int headCompare = a.HeadTime.CompareTo(b.HeadTime);
|
|
return headCompare != 0 ? headCompare : a.Column.CompareTo(b.Column);
|
|
}
|
|
|
|
private static void addToMap(Dictionary<int, double> map, int key, double value)
|
|
{
|
|
if (!map.TryAdd(key, value)) map[key] += value;
|
|
}
|
|
|
|
private enum SmoothMode { Sum, Average }
|
|
|
|
#endregion
|
|
}
|
|
}
|