1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-22 20:12:56 +08:00
osu-lazer/osu.Game/Database/LegacyBeatmapExporter.cs
Bartłomiej Dach b4f63da048
Move control point double-up logic to LegacyBeatmapExporter
Done for two reasons:

- During review it was requested for the logic to be moved out of
  `BezierConverter` as `BezierConverter` was intended to produce
  "lazer style" sliders with per-control-point curve types,
  as a future usability / code layering concern.

- It is also relevant for encode-decode stability. With how the logic
  was structured between the Bezier converter and the legacy beatmap
  encoder, the encoder would leave behind per-control-point Bezier curve
  specs that stable ignored, but subsequent encodes and decodes in lazer
  would end up multiplying the doubled-up control points ad nauseam.
  Instead, it is sufficient to only specify the curve type for the
  head control point as Bezier, not specify any further curve types
  later on, and instead just keep the double-up-control-point for new
  implicit segment logic which is enough to make stable cooperate
  (and also as close to outputting the slider exactly as stable would
  have produced it as we've ever been)
2025-01-30 15:28:47 +01:00

162 lines
8.1 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.IO;
using System.Linq;
using System.Text;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Timing;
using osu.Game.IO;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Database
{
/// <summary>
/// Exporter for osu!stable legacy beatmap archives.
/// Converts all beatmaps in the set to legacy format and exports it as a legacy package.
/// </summary>
public class LegacyBeatmapExporter : LegacyArchiveExporter<BeatmapSetInfo>
{
public LegacyBeatmapExporter(Storage storage)
: base(storage)
{
}
protected override Stream? GetFileContents(BeatmapSetInfo model, INamedFileUsage file)
{
var beatmapInfo = model.Beatmaps.SingleOrDefault(o => o.Hash == file.File.Hash);
if (beatmapInfo == null)
return base.GetFileContents(model, file);
// Read the beatmap contents and skin
using var contentStream = base.GetFileContents(model, file);
if (contentStream == null)
return null;
using var contentStreamReader = new LineBufferedReader(contentStream);
// FIRST_LAZER_VERSION is specified here to avoid flooring object coordinates on decode via `(int)` casts.
// we will be making integers out of them lower down, but in a slightly different manner (rounding rather than truncating)
var beatmapContent = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION).Decode(contentStreamReader);
var workingBeatmap = new FlatWorkingBeatmap(beatmapContent);
var playableBeatmap = workingBeatmap.GetPlayableBeatmap(beatmapInfo.Ruleset);
using var skinStream = base.GetFileContents(model, file);
if (skinStream == null)
return null;
using var skinStreamReader = new LineBufferedReader(skinStream);
var beatmapSkin = new LegacySkin(new SkinInfo(), null!)
{
Configuration = new LegacySkinDecoder().Decode(skinStreamReader)
};
// Convert beatmap elements to be compatible with legacy format
// So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves
// We must first truncate all timing points and move all objects in the timing section with it to ensure everything stays snapped
for (int i = 0; i < playableBeatmap.ControlPointInfo.TimingPoints.Count; i++)
{
var timingPoint = playableBeatmap.ControlPointInfo.TimingPoints[i];
double offset = Math.Floor(timingPoint.Time) - timingPoint.Time;
double nextTimingPointTime = i + 1 < playableBeatmap.ControlPointInfo.TimingPoints.Count
? playableBeatmap.ControlPointInfo.TimingPoints[i + 1].Time
: double.PositiveInfinity;
// Offset all control points in the timing section (including the current one)
foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints.Where(o => o.Time >= timingPoint.Time && o.Time < nextTimingPointTime))
controlPoint.Time += offset;
// Offset all hit objects in the timing section
foreach (var hitObject in playableBeatmap.HitObjects.Where(o => o.StartTime >= timingPoint.Time && o.StartTime < nextTimingPointTime))
hitObject.StartTime += offset;
}
foreach (var controlPoint in playableBeatmap.ControlPointInfo.AllControlPoints)
controlPoint.Time = Math.Floor(controlPoint.Time);
for (int i = 0; i < playableBeatmap.Breaks.Count; i++)
playableBeatmap.Breaks[i] = new BreakPeriod(Math.Floor(playableBeatmap.Breaks[i].StartTime), Math.Floor(playableBeatmap.Breaks[i].EndTime));
foreach (var hitObject in playableBeatmap.HitObjects)
{
// Truncate end time before truncating start time because end time is dependent on start time
if (hitObject is IHasDuration hasDuration && hitObject is not IHasPath)
hasDuration.Duration = Math.Floor(hasDuration.EndTime) - Math.Floor(hitObject.StartTime);
hitObject.StartTime = Math.Floor(hitObject.StartTime);
if (hitObject is IHasXPosition hasXPosition)
hasXPosition.X = MathF.Round(hasXPosition.X);
if (hitObject is IHasYPosition hasYPosition)
hasYPosition.Y = MathF.Round(hasYPosition.Y);
if (hitObject is not IHasPath hasPath) continue;
// stable's hit object parsing expects the entire slider to use only one type of curve,
// and happens to use the last non-empty curve type read for the entire slider.
// this clear of the last control point type handles an edge case
// wherein the last control point of an otherwise-single-segment slider path has a different type than previous,
// which would lead to sliders being mangled when exported back to stable.
// normally, that would be handled by the `BezierConverter.ConvertToModernBezier()` call below,
// which outputs a slider path containing only BEZIER control points,
// but a non-inherited last control point is (rightly) not considered to be starting a new segment,
// therefore it would fail to clear the `CountSegments() <= 1` check.
// by clearing explicitly we both fix the issue and avoid unnecessary conversions to BEZIER.
if (hasPath.Path.ControlPoints.Count > 1)
hasPath.Path.ControlPoints[^1].Type = null;
if (BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1
&& hasPath.Path.ControlPoints[0].Type!.Value.Degree == null) continue;
var convertedToBezier = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints);
hasPath.Path.ControlPoints.Clear();
for (int i = 0; i < convertedToBezier.Count; i++)
{
var convertedPoint = convertedToBezier[i];
// Truncate control points to integer positions
var position = new Vector2(
(float)Math.Floor(convertedPoint.Position.X),
(float)Math.Floor(convertedPoint.Position.Y));
// stable only supports a single curve type specification per slider.
// we exploit the fact that the converted-to-Bézier path only has Bézier segments,
// and thus we specify the Bézier curve type once ever at the start of the slider.
hasPath.Path.ControlPoints.Add(new PathControlPoint(position, i == 0 ? PathType.BEZIER : null));
// however, the Bézier path as output by the converter has multiple segments.
// `LegacyBeatmapEncoder` will attempt to encode this by emitting per-control-point curve type specs which don't do anything for stable.
// instead, stable expects control points that start a segment to be present in the path twice in succession.
if (convertedPoint.Type == PathType.BEZIER)
hasPath.Path.ControlPoints.Add(new PathControlPoint(position));
}
}
// Encode to legacy format
var stream = new MemoryStream();
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw);
stream.Seek(0, SeekOrigin.Begin);
return stream;
}
protected override string FileExtension => @".osz";
}
}