1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-17 05:22:54 +08:00
osu-lazer/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

642 lines
30 KiB
C#
Raw Normal View History

// 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.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
2020-08-10 11:21:10 +08:00
using osu.Game.Skinning;
using osuTK;
2020-08-23 21:08:02 +08:00
using osuTK.Graphics;
namespace osu.Game.Beatmaps.Formats
{
public class LegacyBeatmapEncoder
{
public const int FIRST_LAZER_VERSION = 128;
private readonly IBeatmap beatmap;
2020-08-31 23:24:03 +08:00
private readonly ISkin? skin;
2020-08-23 21:08:02 +08:00
private readonly int onlineRulesetID;
2020-08-23 21:08:02 +08:00
/// <summary>
/// Creates a new <see cref="LegacyBeatmapEncoder"/>.
/// </summary>
/// <param name="beatmap">The beatmap to encode.</param>
2020-08-30 22:07:58 +08:00
/// <param name="skin">The beatmap's skin, used for encoding combo colours.</param>
public LegacyBeatmapEncoder(IBeatmap beatmap, ISkin? skin)
{
this.beatmap = beatmap;
this.skin = skin;
onlineRulesetID = beatmap.BeatmapInfo.Ruleset.OnlineID;
if (onlineRulesetID < 0 || onlineRulesetID > 3)
throw new ArgumentException("Only beatmaps in the osu, taiko, catch, or mania rulesets can be encoded to the legacy beatmap format.", nameof(beatmap));
}
public void Encode(TextWriter writer)
{
writer.WriteLine($"osu file format v{FIRST_LAZER_VERSION}");
writer.WriteLine();
handleGeneral(writer);
writer.WriteLine();
handleEditor(writer);
writer.WriteLine();
handleMetadata(writer);
writer.WriteLine();
handleDifficulty(writer);
writer.WriteLine();
handleEvents(writer);
writer.WriteLine();
handleControlPoints(writer);
2020-08-10 11:21:10 +08:00
writer.WriteLine();
2020-08-31 23:24:03 +08:00
handleColours(writer);
2020-08-10 11:21:10 +08:00
writer.WriteLine();
handleHitObjects(writer);
}
private void handleGeneral(TextWriter writer)
{
writer.WriteLine("[General]");
2021-11-04 12:59:40 +08:00
if (!string.IsNullOrEmpty(beatmap.Metadata.AudioFile)) writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}"));
writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}"));
writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}"));
writer.WriteLine(FormattableString.Invariant($"Countdown: {(int)beatmap.BeatmapInfo.Countdown}"));
2023-05-03 12:33:31 +08:00
writer.WriteLine(FormattableString.Invariant(
2023-06-24 22:07:01 +08:00
$"SampleSet: {toLegacySampleBank(((beatmap.ControlPointInfo as LegacyControlPointInfo)?.SamplePoints.FirstOrDefault() ?? SampleControlPoint.DEFAULT).SampleBank)}"));
writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}"));
writer.WriteLine(FormattableString.Invariant($"Mode: {onlineRulesetID}"));
2019-12-16 16:06:52 +08:00
writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}"));
// if (beatmap.BeatmapInfo.UseSkinSprites)
// writer.WriteLine(@"UseSkinSprites: 1");
// if (b.AlwaysShowPlayfield)
// writer.WriteLine(@"AlwaysShowPlayfield: 1");
// if (b.OverlayPosition != OverlayPosition.NoChange)
// writer.WriteLine(@"OverlayPosition: " + b.OverlayPosition);
// if (!string.IsNullOrEmpty(b.SkinPreference))
// writer.WriteLine(@"SkinPreference:" + b.SkinPreference);
if (beatmap.BeatmapInfo.EpilepsyWarning)
writer.WriteLine(@"EpilepsyWarning: 1");
if (beatmap.BeatmapInfo.CountdownOffset > 0)
writer.WriteLine(FormattableString.Invariant($@"CountdownOffset: {beatmap.BeatmapInfo.CountdownOffset}"));
if (onlineRulesetID == 3)
2019-12-16 16:06:52 +08:00
writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.BeatmapInfo.SpecialStyle ? '1' : '0')}"));
writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.BeatmapInfo.WidescreenStoryboard ? '1' : '0')}"));
if (beatmap.BeatmapInfo.SamplesMatchPlaybackRate)
writer.WriteLine(@"SamplesMatchPlaybackRate: 1");
}
private void handleEditor(TextWriter writer)
{
writer.WriteLine("[Editor]");
if (beatmap.BeatmapInfo.Bookmarks.Length > 0)
writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.BeatmapInfo.Bookmarks)}"));
writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.BeatmapInfo.DistanceSpacing}"));
writer.WriteLine(FormattableString.Invariant($"BeatDivisor: {beatmap.BeatmapInfo.BeatDivisor}"));
writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.BeatmapInfo.GridSize}"));
writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.BeatmapInfo.TimelineZoom}"));
}
private void handleMetadata(TextWriter writer)
{
writer.WriteLine("[Metadata]");
writer.WriteLine(FormattableString.Invariant($"Title: {beatmap.Metadata.Title}"));
2021-11-04 12:59:40 +08:00
if (!string.IsNullOrEmpty(beatmap.Metadata.TitleUnicode)) writer.WriteLine(FormattableString.Invariant($"TitleUnicode: {beatmap.Metadata.TitleUnicode}"));
writer.WriteLine(FormattableString.Invariant($"Artist: {beatmap.Metadata.Artist}"));
2021-11-04 12:59:40 +08:00
if (!string.IsNullOrEmpty(beatmap.Metadata.ArtistUnicode)) writer.WriteLine(FormattableString.Invariant($"ArtistUnicode: {beatmap.Metadata.ArtistUnicode}"));
writer.WriteLine(FormattableString.Invariant($"Creator: {beatmap.Metadata.Author.Username}"));
writer.WriteLine(FormattableString.Invariant($"Version: {beatmap.BeatmapInfo.DifficultyName}"));
2021-11-04 12:59:40 +08:00
if (!string.IsNullOrEmpty(beatmap.Metadata.Source)) writer.WriteLine(FormattableString.Invariant($"Source: {beatmap.Metadata.Source}"));
if (!string.IsNullOrEmpty(beatmap.Metadata.Tags)) writer.WriteLine(FormattableString.Invariant($"Tags: {beatmap.Metadata.Tags}"));
if (beatmap.BeatmapInfo.OnlineID > 0) writer.WriteLine(FormattableString.Invariant($"BeatmapID: {beatmap.BeatmapInfo.OnlineID}"));
if (beatmap.BeatmapInfo.BeatmapSet?.OnlineID > 0) writer.WriteLine(FormattableString.Invariant($"BeatmapSetID: {beatmap.BeatmapInfo.BeatmapSet.OnlineID}"));
}
private void handleDifficulty(TextWriter writer)
{
writer.WriteLine("[Difficulty]");
writer.WriteLine(FormattableString.Invariant($"HPDrainRate: {beatmap.Difficulty.DrainRate}"));
writer.WriteLine(FormattableString.Invariant($"CircleSize: {beatmap.Difficulty.CircleSize}"));
writer.WriteLine(FormattableString.Invariant($"OverallDifficulty: {beatmap.Difficulty.OverallDifficulty}"));
writer.WriteLine(FormattableString.Invariant($"ApproachRate: {beatmap.Difficulty.ApproachRate}"));
2020-04-21 15:45:01 +08:00
writer.WriteLine(FormattableString.Invariant($"SliderMultiplier: {beatmap.Difficulty.SliderMultiplier}"));
writer.WriteLine(FormattableString.Invariant($"SliderTickRate: {beatmap.Difficulty.SliderTickRate}"));
}
private void handleEvents(TextWriter writer)
{
2019-12-12 17:48:22 +08:00
writer.WriteLine("[Events]");
2019-12-12 17:49:47 +08:00
if (!string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile))
2019-12-12 17:51:05 +08:00
writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Background},0,\"{beatmap.BeatmapInfo.Metadata.BackgroundFile}\",0,0"));
2019-12-12 17:49:47 +08:00
foreach (var b in beatmap.Breaks)
writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Break},{b.StartTime},{b.EndTime}"));
foreach (string l in beatmap.UnhandledEventLines)
writer.WriteLine(l);
}
private void handleControlPoints(TextWriter writer)
{
var legacyControlPoints = new LegacyControlPointInfo();
foreach (var point in beatmap.ControlPointInfo.AllControlPoints)
legacyControlPoints.Add(point.Time, point.DeepClone());
writer.WriteLine("[TimingPoints]");
SampleControlPoint? lastRelevantSamplePoint = null;
DifficultyControlPoint? lastRelevantDifficultyPoint = null;
// In osu!taiko and osu!mania, a scroll speed is stored as "slider velocity" in legacy formats.
// In that case, a scrolling speed change is a global effect and per-hit object difficulty control points are ignored.
bool scrollSpeedEncodedAsSliderVelocity = onlineRulesetID == 1 || onlineRulesetID == 3;
// iterate over hitobjects and pull out all required sample and difficulty changes
extractDifficultyControlPoints(beatmap.HitObjects);
extractSampleControlPoints(beatmap.HitObjects);
if (scrollSpeedEncodedAsSliderVelocity)
{
foreach (var point in legacyControlPoints.EffectPoints)
legacyControlPoints.Add(point.Time, new DifficultyControlPoint { SliderVelocity = point.ScrollSpeed });
}
LegacyControlPointProperties lastControlPointProperties = new LegacyControlPointProperties();
foreach (var group in legacyControlPoints.Groups)
{
var groupTimingPoint = group.ControlPoints.OfType<TimingControlPoint>().FirstOrDefault();
var controlPointProperties = getLegacyControlPointProperties(group, groupTimingPoint != null);
// If the group contains a timing control point, it needs to be output separately.
if (groupTimingPoint != null)
{
writer.Write(FormattableString.Invariant($"{groupTimingPoint.Time},"));
writer.Write(FormattableString.Invariant($"{groupTimingPoint.BeatLength},"));
outputControlPointAt(controlPointProperties, true);
lastControlPointProperties = controlPointProperties;
lastControlPointProperties.SliderVelocity = 1;
}
if (controlPointProperties.IsRedundant(lastControlPointProperties))
continue;
// Output any remaining effects as secondary non-timing control point.
writer.Write(FormattableString.Invariant($"{group.Time},"));
writer.Write(FormattableString.Invariant($"{-100 / controlPointProperties.SliderVelocity},"));
outputControlPointAt(controlPointProperties, false);
lastControlPointProperties = controlPointProperties;
}
LegacyControlPointProperties getLegacyControlPointProperties(ControlPointGroup group, bool updateSampleBank)
{
2023-05-03 12:33:31 +08:00
var timingPoint = legacyControlPoints.TimingPointAt(group.Time);
var difficultyPoint = legacyControlPoints.DifficultyPointAt(group.Time);
var samplePoint = legacyControlPoints.SamplePointAt(group.Time);
var effectPoint = legacyControlPoints.EffectPointAt(group.Time);
// Apply the control point to a hit sample to uncover legacy properties (e.g. suffix)
2020-12-01 14:37:51 +08:00
HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo(string.Empty));
int customSampleBank = toLegacyCustomSampleBank(tempHitSample);
// Convert effect flags to the legacy format
LegacyEffectFlags effectFlags = LegacyEffectFlags.None;
if (effectPoint.KiaiMode)
effectFlags |= LegacyEffectFlags.Kiai;
if (timingPoint.OmitFirstBarLine)
effectFlags |= LegacyEffectFlags.OmitFirstBarLine;
return new LegacyControlPointProperties
{
SliderVelocity = difficultyPoint.SliderVelocity,
TimingSignature = timingPoint.TimeSignature.Numerator,
SampleBank = updateSampleBank ? (int)toLegacySampleBank(tempHitSample.Bank) : lastControlPointProperties.SampleBank,
// Inherit the previous custom sample bank if the current custom sample bank is not set
CustomSampleBank = customSampleBank >= 0 ? customSampleBank : lastControlPointProperties.CustomSampleBank,
SampleVolume = tempHitSample.Volume,
EffectFlags = effectFlags
};
}
void outputControlPointAt(LegacyControlPointProperties controlPoint, bool isTimingPoint)
{
writer.Write(FormattableString.Invariant($"{controlPoint.TimingSignature.ToString(CultureInfo.InvariantCulture)},"));
writer.Write(FormattableString.Invariant($"{controlPoint.SampleBank.ToString(CultureInfo.InvariantCulture)},"));
writer.Write(FormattableString.Invariant($"{controlPoint.CustomSampleBank.ToString(CultureInfo.InvariantCulture)},"));
writer.Write(FormattableString.Invariant($"{controlPoint.SampleVolume.ToString(CultureInfo.InvariantCulture)},"));
writer.Write(FormattableString.Invariant($"{(isTimingPoint ? "1" : "0")},"));
writer.Write(FormattableString.Invariant($"{((int)controlPoint.EffectFlags).ToString(CultureInfo.InvariantCulture)}"));
2019-12-16 16:08:46 +08:00
writer.WriteLine();
}
IEnumerable<DifficultyControlPoint> collectDifficultyControlPoints(IEnumerable<HitObject> hitObjects)
{
if (scrollSpeedEncodedAsSliderVelocity)
yield break;
foreach (var hitObject in hitObjects)
{
if (hitObject is IHasSliderVelocity hasSliderVelocity)
yield return new DifficultyControlPoint { Time = hitObject.StartTime, SliderVelocity = hasSliderVelocity.SliderVelocityMultiplier };
}
}
void extractDifficultyControlPoints(IEnumerable<HitObject> hitObjects)
{
foreach (var hDifficultyPoint in collectDifficultyControlPoints(hitObjects).OrderBy(dp => dp.Time))
{
if (!hDifficultyPoint.IsRedundant(lastRelevantDifficultyPoint))
{
legacyControlPoints.Add(hDifficultyPoint.Time, hDifficultyPoint);
lastRelevantDifficultyPoint = hDifficultyPoint;
}
}
}
IEnumerable<SampleControlPoint> collectSampleControlPoints(IEnumerable<HitObject> hitObjects)
{
foreach (var hitObject in hitObjects)
{
Fix slider tail volume not saving Closes https://github.com/ppy/osu/issues/28587. As outlined in the issue thread, the tail volume wasn't saving because it wasn't actually attached to a hitobject properly, and as such the `LegacyBeatmapEncoder` logic, which is based on hitobjects, did not pick them up on save. To fix that, switch to using `NodeSamples` for objects that are `IHasRepeats`. That has one added complication in that having it work properly requires changes to the decode side too. That is because the intent is to allow the user to change the sample settings for each node (which are specified via `NodeSamples`), as well as "the rest of the object", which generally means ticks or auxiliary samples like `sliderslide` (which are specified by `Samples`). However, up until now, `Samples` always queried the control point which was _active at the end time of the slider_. This obviously can't work anymore when converting `NodeSamples` to legacy control points, because the last node's sample is _also_ at the end time of the slider. To bypass that, add extra sample points after each node (just out of reach of the 5ms leniency), which are supposed to control volume of ticks and/or slides. Upon testing, this *sort of* has the intended effect in stable, with the exception of `sliderslide`, which seems to either respect or _not_ respect the relevant volume spec dependent on... not sure what, and not sure I want to be debugging that. It might be frame alignment, or it might be the phase of the moon.
2024-06-26 20:27:14 +08:00
if (hitObject is IHasRepeats hasNodeSamples)
{
Fix slider tail volume not saving Closes https://github.com/ppy/osu/issues/28587. As outlined in the issue thread, the tail volume wasn't saving because it wasn't actually attached to a hitobject properly, and as such the `LegacyBeatmapEncoder` logic, which is based on hitobjects, did not pick them up on save. To fix that, switch to using `NodeSamples` for objects that are `IHasRepeats`. That has one added complication in that having it work properly requires changes to the decode side too. That is because the intent is to allow the user to change the sample settings for each node (which are specified via `NodeSamples`), as well as "the rest of the object", which generally means ticks or auxiliary samples like `sliderslide` (which are specified by `Samples`). However, up until now, `Samples` always queried the control point which was _active at the end time of the slider_. This obviously can't work anymore when converting `NodeSamples` to legacy control points, because the last node's sample is _also_ at the end time of the slider. To bypass that, add extra sample points after each node (just out of reach of the 5ms leniency), which are supposed to control volume of ticks and/or slides. Upon testing, this *sort of* has the intended effect in stable, with the exception of `sliderslide`, which seems to either respect or _not_ respect the relevant volume spec dependent on... not sure what, and not sure I want to be debugging that. It might be frame alignment, or it might be the phase of the moon.
2024-06-26 20:27:14 +08:00
double spanDuration = hasNodeSamples.Duration / hasNodeSamples.SpanCount();
Fix slider tail volume not saving Closes https://github.com/ppy/osu/issues/28587. As outlined in the issue thread, the tail volume wasn't saving because it wasn't actually attached to a hitobject properly, and as such the `LegacyBeatmapEncoder` logic, which is based on hitobjects, did not pick them up on save. To fix that, switch to using `NodeSamples` for objects that are `IHasRepeats`. That has one added complication in that having it work properly requires changes to the decode side too. That is because the intent is to allow the user to change the sample settings for each node (which are specified via `NodeSamples`), as well as "the rest of the object", which generally means ticks or auxiliary samples like `sliderslide` (which are specified by `Samples`). However, up until now, `Samples` always queried the control point which was _active at the end time of the slider_. This obviously can't work anymore when converting `NodeSamples` to legacy control points, because the last node's sample is _also_ at the end time of the slider. To bypass that, add extra sample points after each node (just out of reach of the 5ms leniency), which are supposed to control volume of ticks and/or slides. Upon testing, this *sort of* has the intended effect in stable, with the exception of `sliderslide`, which seems to either respect or _not_ respect the relevant volume spec dependent on... not sure what, and not sure I want to be debugging that. It might be frame alignment, or it might be the phase of the moon.
2024-06-26 20:27:14 +08:00
for (int i = 0; i < hasNodeSamples.NodeSamples.Count; ++i)
{
double nodeTime = hitObject.StartTime + i * spanDuration;
2024-06-26 22:56:43 +08:00
if (hasNodeSamples.NodeSamples[i].Count > 0)
Fix slider tail volume not saving Closes https://github.com/ppy/osu/issues/28587. As outlined in the issue thread, the tail volume wasn't saving because it wasn't actually attached to a hitobject properly, and as such the `LegacyBeatmapEncoder` logic, which is based on hitobjects, did not pick them up on save. To fix that, switch to using `NodeSamples` for objects that are `IHasRepeats`. That has one added complication in that having it work properly requires changes to the decode side too. That is because the intent is to allow the user to change the sample settings for each node (which are specified via `NodeSamples`), as well as "the rest of the object", which generally means ticks or auxiliary samples like `sliderslide` (which are specified by `Samples`). However, up until now, `Samples` always queried the control point which was _active at the end time of the slider_. This obviously can't work anymore when converting `NodeSamples` to legacy control points, because the last node's sample is _also_ at the end time of the slider. To bypass that, add extra sample points after each node (just out of reach of the 5ms leniency), which are supposed to control volume of ticks and/or slides. Upon testing, this *sort of* has the intended effect in stable, with the exception of `sliderslide`, which seems to either respect or _not_ respect the relevant volume spec dependent on... not sure what, and not sure I want to be debugging that. It might be frame alignment, or it might be the phase of the moon.
2024-06-26 20:27:14 +08:00
yield return createSampleControlPointFor(nodeTime, hasNodeSamples.NodeSamples[i]);
if (spanDuration > LegacyBeatmapDecoder.CONTROL_POINT_LENIENCY + 1 && hitObject.Samples.Count > 0 && i < hasNodeSamples.NodeSamples.Count - 1)
Fix slider tail volume not saving Closes https://github.com/ppy/osu/issues/28587. As outlined in the issue thread, the tail volume wasn't saving because it wasn't actually attached to a hitobject properly, and as such the `LegacyBeatmapEncoder` logic, which is based on hitobjects, did not pick them up on save. To fix that, switch to using `NodeSamples` for objects that are `IHasRepeats`. That has one added complication in that having it work properly requires changes to the decode side too. That is because the intent is to allow the user to change the sample settings for each node (which are specified via `NodeSamples`), as well as "the rest of the object", which generally means ticks or auxiliary samples like `sliderslide` (which are specified by `Samples`). However, up until now, `Samples` always queried the control point which was _active at the end time of the slider_. This obviously can't work anymore when converting `NodeSamples` to legacy control points, because the last node's sample is _also_ at the end time of the slider. To bypass that, add extra sample points after each node (just out of reach of the 5ms leniency), which are supposed to control volume of ticks and/or slides. Upon testing, this *sort of* has the intended effect in stable, with the exception of `sliderslide`, which seems to either respect or _not_ respect the relevant volume spec dependent on... not sure what, and not sure I want to be debugging that. It might be frame alignment, or it might be the phase of the moon.
2024-06-26 20:27:14 +08:00
yield return createSampleControlPointFor(nodeTime + LegacyBeatmapDecoder.CONTROL_POINT_LENIENCY + 1, hitObject.Samples);
}
}
else if (hitObject.Samples.Count > 0)
{
yield return createSampleControlPointFor(hitObject.GetEndTime(), hitObject.Samples);
}
foreach (var nested in collectSampleControlPoints(hitObject.NestedHitObjects))
yield return nested;
}
Fix slider tail volume not saving Closes https://github.com/ppy/osu/issues/28587. As outlined in the issue thread, the tail volume wasn't saving because it wasn't actually attached to a hitobject properly, and as such the `LegacyBeatmapEncoder` logic, which is based on hitobjects, did not pick them up on save. To fix that, switch to using `NodeSamples` for objects that are `IHasRepeats`. That has one added complication in that having it work properly requires changes to the decode side too. That is because the intent is to allow the user to change the sample settings for each node (which are specified via `NodeSamples`), as well as "the rest of the object", which generally means ticks or auxiliary samples like `sliderslide` (which are specified by `Samples`). However, up until now, `Samples` always queried the control point which was _active at the end time of the slider_. This obviously can't work anymore when converting `NodeSamples` to legacy control points, because the last node's sample is _also_ at the end time of the slider. To bypass that, add extra sample points after each node (just out of reach of the 5ms leniency), which are supposed to control volume of ticks and/or slides. Upon testing, this *sort of* has the intended effect in stable, with the exception of `sliderslide`, which seems to either respect or _not_ respect the relevant volume spec dependent on... not sure what, and not sure I want to be debugging that. It might be frame alignment, or it might be the phase of the moon.
2024-06-26 20:27:14 +08:00
SampleControlPoint createSampleControlPointFor(double time, IList<HitSampleInfo> samples)
{
int volume = samples.Max(o => o.Volume);
int customIndex = samples.Any(o => o is ConvertHitObjectParser.LegacyHitSampleInfo)
? samples.OfType<ConvertHitObjectParser.LegacyHitSampleInfo>().Max(o => o.CustomSampleBank)
: -1;
return new LegacyBeatmapDecoder.LegacySampleControlPoint { Time = time, SampleVolume = volume, CustomSampleBank = customIndex };
}
}
void extractSampleControlPoints(IEnumerable<HitObject> hitObject)
{
foreach (var hSamplePoint in collectSampleControlPoints(hitObject).OrderBy(sp => sp.Time))
{
if (!hSamplePoint.IsRedundant(lastRelevantSamplePoint))
{
legacyControlPoints.Add(hSamplePoint.Time, hSamplePoint);
lastRelevantSamplePoint = hSamplePoint;
}
}
}
}
2020-08-31 23:24:03 +08:00
private void handleColours(TextWriter writer)
2020-08-10 11:21:10 +08:00
{
2020-08-31 23:24:03 +08:00
var colours = skin?.GetConfig<GlobalSkinColours, IReadOnlyList<Color4>>(GlobalSkinColours.ComboColours)?.Value;
2020-08-10 11:21:10 +08:00
2020-08-12 12:37:33 +08:00
if (colours == null || colours.Count == 0)
2020-08-10 11:21:10 +08:00
return;
writer.WriteLine("[Colours]");
for (int i = 0; i < colours.Count; i++)
2020-08-10 11:21:10 +08:00
{
var comboColour = colours[i];
writer.Write(FormattableString.Invariant($"Combo{1 + i}: "));
2020-08-23 21:08:02 +08:00
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.R * byte.MaxValue)},"));
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.G * byte.MaxValue)},"));
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.B * byte.MaxValue)},"));
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.A * byte.MaxValue)}"));
writer.WriteLine();
2020-08-10 11:21:10 +08:00
}
}
private void handleHitObjects(TextWriter writer)
{
writer.WriteLine("[HitObjects]");
if (beatmap.HitObjects.Count == 0)
return;
foreach (var h in beatmap.HitObjects)
handleHitObject(writer, h);
}
private void handleHitObject(TextWriter writer, HitObject hitObject)
{
Vector2 position = new Vector2(256, 192);
switch (onlineRulesetID)
{
2019-12-16 15:57:40 +08:00
case 0:
case 2:
position = ((IHasPosition)hitObject).Position;
break;
case 3:
int totalColumns = (int)Math.Max(1, beatmap.Difficulty.CircleSize);
position.X = (int)Math.Ceiling(((IHasXPosition)hitObject).X * (512f / totalColumns));
break;
}
writer.Write(FormattableString.Invariant($"{position.X},"));
writer.Write(FormattableString.Invariant($"{position.Y},"));
writer.Write(FormattableString.Invariant($"{hitObject.StartTime},"));
2019-12-18 16:35:51 +08:00
writer.Write(FormattableString.Invariant($"{(int)getObjectType(hitObject)},"));
writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(hitObject.Samples)},"));
2020-05-31 21:30:55 +08:00
if (hitObject is IHasPath path)
{
2020-05-31 21:30:55 +08:00
addPathData(writer, path, position);
2021-04-09 14:28:42 +08:00
writer.Write(getSampleBank(hitObject.Samples));
2019-12-18 16:35:51 +08:00
}
else
{
2020-05-27 11:38:39 +08:00
if (hitObject is IHasDuration)
addEndTimeData(writer, hitObject);
2019-12-18 16:35:51 +08:00
writer.Write(getSampleBank(hitObject.Samples));
}
2019-12-12 18:52:15 +08:00
2019-12-18 16:35:51 +08:00
writer.WriteLine();
}
2020-04-22 15:27:07 +08:00
private LegacyHitObjectType getObjectType(HitObject hitObject)
2019-12-18 16:35:51 +08:00
{
LegacyHitObjectType type = 0;
2019-12-18 16:35:51 +08:00
if (hitObject is IHasCombo combo)
{
type = (LegacyHitObjectType)(combo.ComboOffset << 4);
2019-12-18 16:35:51 +08:00
if (combo.NewCombo)
type |= LegacyHitObjectType.NewCombo;
}
2019-12-18 16:35:51 +08:00
switch (hitObject)
{
2022-06-24 20:25:23 +08:00
case IHasPath:
2019-12-18 16:35:51 +08:00
type |= LegacyHitObjectType.Slider;
break;
2022-06-24 20:25:23 +08:00
case IHasDuration:
if (onlineRulesetID == 3)
2020-04-22 15:27:07 +08:00
type |= LegacyHitObjectType.Hold;
else
type |= LegacyHitObjectType.Spinner;
2019-12-18 16:35:51 +08:00
break;
default:
type |= LegacyHitObjectType.Circle;
break;
}
return type;
}
private void addPathData(TextWriter writer, IHasPath pathData, Vector2 position)
2019-12-18 16:35:51 +08:00
{
PathType? lastType = null;
for (int i = 0; i < pathData.Path.ControlPoints.Count; i++)
2019-12-18 16:35:51 +08:00
{
PathControlPoint point = pathData.Path.ControlPoints[i];
2019-12-18 16:35:51 +08:00
if (point.Type != null)
2019-12-18 16:35:51 +08:00
{
// We've reached a new (explicit) segment!
2021-04-05 18:59:54 +08:00
// Explicit segments have a new format in which the type is injected into the middle of the control point string.
// To preserve compatibility with osu-stable as much as possible, explicit segments with the same type are converted to use implicit segments by duplicating the control point.
// One exception are consecutive perfect curves, which aren't supported in osu!stable and can lead to decoding issues if encoded as implicit segments
2023-11-13 15:24:09 +08:00
bool needsExplicitSegment = point.Type != lastType || point.Type == PathType.PERFECT_CURVE;
2021-04-05 18:59:54 +08:00
// Another exception to this is when the last two control points of the last segment were duplicated. This is not a scenario supported by osu!stable.
// Lazer does not add implicit segments for the last two control points of _any_ explicit segment, so an explicit segment is forced in order to maintain consistency with the decoder.
if (i > 1)
{
// We need to use the absolute control point position to determine equality, otherwise floating point issues may arise.
Vector2 p1 = position + pathData.Path.ControlPoints[i - 1].Position;
Vector2 p2 = position + pathData.Path.ControlPoints[i - 2].Position;
if ((int)p1.X == (int)p2.X && (int)p1.Y == (int)p2.Y)
needsExplicitSegment = true;
}
if (needsExplicitSegment)
{
2023-11-13 15:24:09 +08:00
switch (point.Type?.Type)
2019-12-12 18:52:15 +08:00
{
2023-11-13 15:24:09 +08:00
case SplineType.BSpline:
writer.Write(point.Type.Value.Degree > 0 ? $"B{point.Type.Value.Degree}|" : "B|");
break;
2023-11-13 15:24:09 +08:00
case SplineType.Catmull:
2019-12-18 16:35:51 +08:00
writer.Write("C|");
break;
2023-11-13 15:24:09 +08:00
case SplineType.PerfectCurve:
2019-12-18 16:35:51 +08:00
writer.Write("P|");
break;
2023-11-13 15:24:09 +08:00
case SplineType.Linear:
2019-12-18 16:35:51 +08:00
writer.Write("L|");
break;
2019-12-12 18:52:15 +08:00
}
lastType = point.Type;
2019-12-18 16:35:51 +08:00
}
else
{
2019-12-18 16:35:51 +08:00
// New segment with the same type - duplicate the control point
writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}|"));
}
}
2019-12-18 16:35:51 +08:00
if (i != 0)
{
writer.Write(FormattableString.Invariant($"{position.X + point.Position.X}:{position.Y + point.Position.Y}"));
writer.Write(i != pathData.Path.ControlPoints.Count - 1 ? "|" : ",");
}
}
var curveData = pathData as IHasPathWithRepeats;
writer.Write(FormattableString.Invariant($"{(curveData?.RepeatCount ?? 0) + 1},"));
writer.Write(FormattableString.Invariant($"{pathData.Path.ExpectedDistance.Value ?? pathData.Path.Distance},"));
2019-12-18 16:35:51 +08:00
if (curveData != null)
2019-12-18 16:35:51 +08:00
{
for (int i = 0; i < curveData.SpanCount() + 1; i++)
{
writer.Write(FormattableString.Invariant($"{(i < curveData.NodeSamples.Count ? (int)toLegacyHitSoundType(curveData.NodeSamples[i]) : 0)}"));
writer.Write(i != curveData.SpanCount() ? "|" : ",");
}
for (int i = 0; i < curveData.SpanCount() + 1; i++)
{
writer.Write(i < curveData.NodeSamples.Count ? getSampleBank(curveData.NodeSamples[i], true) : "0:0");
writer.Write(i != curveData.SpanCount() ? "|" : ",");
}
2019-12-18 16:35:51 +08:00
}
}
private void addEndTimeData(TextWriter writer, HitObject hitObject)
{
2020-05-27 11:38:39 +08:00
var endTimeData = (IHasDuration)hitObject;
var type = getObjectType(hitObject);
char suffix = ',';
// Holds write the end time as if it's part of sample data.
if (type == LegacyHitObjectType.Hold)
suffix = ':';
writer.Write(FormattableString.Invariant($"{endTimeData.EndTime}{suffix}"));
}
2021-04-09 14:28:42 +08:00
private string getSampleBank(IList<HitSampleInfo> samples, bool banksOnly = false)
{
2019-12-16 16:03:58 +08:00
LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank);
LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL)?.Bank);
StringBuilder sb = new StringBuilder();
2021-04-09 14:28:42 +08:00
sb.Append(FormattableString.Invariant($"{(int)normalBank}:"));
sb.Append(FormattableString.Invariant($"{(int)addBank}"));
if (!banksOnly)
{
int customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name)));
2019-12-16 16:05:24 +08:00
string sampleFilename = samples.FirstOrDefault(s => string.IsNullOrEmpty(s.Name))?.LookupNames.First() ?? string.Empty;
int volume = samples.FirstOrDefault()?.Volume ?? 100;
// We want to ignore custom sample banks and volume when not encoding to the mania game mode,
// because they cause unexpected results in the editor and are already satisfied by the control points.
if (onlineRulesetID != 3)
{
customSampleBank = 0;
volume = 0;
}
sb.Append(':');
sb.Append(FormattableString.Invariant($"{customSampleBank}:"));
sb.Append(FormattableString.Invariant($"{volume}:"));
sb.Append(FormattableString.Invariant($"{sampleFilename}"));
}
return sb.ToString();
}
private LegacyHitSoundType toLegacyHitSoundType(IList<HitSampleInfo> samples)
{
LegacyHitSoundType type = LegacyHitSoundType.None;
foreach (var sample in samples)
{
switch (sample.Name)
{
case HitSampleInfo.HIT_WHISTLE:
type |= LegacyHitSoundType.Whistle;
break;
case HitSampleInfo.HIT_FINISH:
type |= LegacyHitSoundType.Finish;
break;
case HitSampleInfo.HIT_CLAP:
type |= LegacyHitSoundType.Clap;
break;
}
}
return type;
}
private LegacySampleBank toLegacySampleBank(string? sampleBank)
{
2019-12-16 16:07:30 +08:00
switch (sampleBank?.ToLowerInvariant())
{
2022-10-19 19:34:41 +08:00
case HitSampleInfo.BANK_NORMAL:
return LegacySampleBank.Normal;
2022-10-19 19:34:41 +08:00
case HitSampleInfo.BANK_SOFT:
return LegacySampleBank.Soft;
2022-10-19 19:34:41 +08:00
case HitSampleInfo.BANK_DRUM:
return LegacySampleBank.Drum;
default:
return LegacySampleBank.None;
}
}
private int toLegacyCustomSampleBank(HitSampleInfo? hitSampleInfo)
{
if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy)
return legacy.CustomSampleBank;
return 0;
}
2023-05-03 12:33:31 +08:00
private struct LegacyControlPointProperties
{
internal double SliderVelocity { get; set; }
internal int TimingSignature { get; init; }
internal int SampleBank { get; init; }
internal int CustomSampleBank { get; init; }
internal int SampleVolume { get; init; }
internal LegacyEffectFlags EffectFlags { get; init; }
internal bool IsRedundant(LegacyControlPointProperties other) =>
SliderVelocity == other.SliderVelocity &&
TimingSignature == other.TimingSignature &&
SampleBank == other.SampleBank &&
CustomSampleBank == other.CustomSampleBank &&
SampleVolume == other.SampleVolume &&
EffectFlags == other.EffectFlags;
}
}
}