diff --git a/MoreChartFormats/MaiSxt/SrtReader.cs b/MoreChartFormats/MaiSxt/SrtReader.cs new file mode 100644 index 0000000..91492d1 --- /dev/null +++ b/MoreChartFormats/MaiSxt/SrtReader.cs @@ -0,0 +1,79 @@ +using System; +using System.Runtime.CompilerServices; +using Manager; +using MoreChartFormats.MaiSxt.Structures; + +namespace MoreChartFormats.MaiSxt; + +public class SrtReader(NotesReferences refs) : SxtReaderBase(refs) +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void LoadRow(int rowIdx, in SxtRow row) + { + var rowTime = row.NotesTime(Refs.Reader); + var note = new NoteData + { + type = row.NoteType switch + { + 0 when row.SlideId != 0 => NotesTypeID.Def.Star, + 0 => NotesTypeID.Def.Tap, + 2 => NotesTypeID.Def.Hold, + 4 when row.SlideId != 0 => NotesTypeID.Def.BreakStar, + 4 => NotesTypeID.Def.Break, + 128 => NotesTypeID.Def.Slide, + _ => throw new Exception($"Unknown note type ID {row.NoteType} at row {rowIdx}, expected 0/2/4/128") + }, + startButtonPos = row.Position, + time = rowTime, + end = rowTime, + beatType = ParserUtilities.GetBeatType(rowTime.grid), + index = rowIdx, + indexNote = NoteIndex + }; + + if (note.type.isHold()) + { + note.end += ParserUtilities.NotesTimeFromBars(Refs, row.HoldDuration); + note.end.calcMsec(Refs.Reader); + } + + if (note.type.isSlide()) + { + if (!SlideHeads.TryGetValue(row.SlideId, out var starNoteRow)) + { + throw new Exception($"Slide body (ID {row.SlideId}) declared without or before its head"); + } + + note.startButtonPos = starNoteRow.Position; + note.slideData = new SlideData + { + type = row.SlidePattern switch + { + 0 => SlideType.Slide_Straight, + 1 => SlideType.Slide_Circle_R, + 2 => SlideType.Slide_Circle_L, + _ => throw new Exception($"Unknown slide type {row.SlidePattern} at row {rowIdx}, expected 0/1/2"), + }, + index = row.SlideId, + targetNote = row.Position, + shoot = new TimingBase { index = rowIdx }, + arrive = new TimingBase { index = rowIdx }, + }; + + note.time = starNoteRow.NotesTime(Refs.Reader); + note.beatType = ParserUtilities.GetBeatType(note.time.grid); + note.slideData.shoot.time = note.time + starNoteRow.SlideDelayNotesTime(Refs); + note.slideData.shoot.time.calcMsec(Refs.Reader); + note.slideData.arrive.time.copy(rowTime); + note.end.copy(rowTime); + } + + if (note.type.isStar()) + { + SlideHeads[row.SlideId] = row; + } + + Refs.Notes._noteData.Add(note); + NoteIndex++; + } +} \ No newline at end of file diff --git a/MoreChartFormats/MaiSxt/Structures/SxtRow.cs b/MoreChartFormats/MaiSxt/Structures/SxtRow.cs new file mode 100644 index 0000000..3bf1b9a --- /dev/null +++ b/MoreChartFormats/MaiSxt/Structures/SxtRow.cs @@ -0,0 +1,26 @@ +using System.Runtime.CompilerServices; +using Manager; +using MoreChartFormats.Simai.Structures; + +namespace MoreChartFormats.MaiSxt.Structures; + +public struct SxtRow +{ + public float Bar; + public float Grid; + public float HoldDuration; + public int Position; + public int NoteType; + public int SlideId; + public int SlidePattern; + // public int SlideCount; + public float? SlideDelay; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly NotesTime NotesTime(NotesReader nr) => new NotesTime((int)Bar, (int)(Grid * nr.getResolution()), nr); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly NotesTime SlideDelayNotesTime(NotesReferences refs) => SlideDelay.HasValue + ? ParserUtilities.NotesTimeFromBars(refs, SlideDelay.Value) + : new NotesTime(0, refs.Header._resolutionTime / 4, refs.Reader); +} diff --git a/MoreChartFormats/MaiSxt/SxtReader.cs b/MoreChartFormats/MaiSxt/SxtReader.cs new file mode 100644 index 0000000..4b5711a --- /dev/null +++ b/MoreChartFormats/MaiSxt/SxtReader.cs @@ -0,0 +1,81 @@ +using System; +using System.Runtime.CompilerServices; +using Manager; +using MoreChartFormats.MaiSxt.Structures; + +namespace MoreChartFormats.MaiSxt; + +public class SxtReader(NotesReferences refs) : SxtReaderBase(refs) +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected override void LoadRow(int rowIdx, in SxtRow row) + { + if (row.NoteType == 0) + { + if (row.SlideId == 0) + { + throw new Exception($"Slide head at row {rowIdx} does not declare a valid slide ID"); + } + + System.Console.WriteLine("[MoreChartFormats] Saving slide head with ID {0}", row.SlideId); + SlideHeads[row.SlideId] = row; + return; + } + + var rowTime = row.NotesTime(Refs.Reader); + var note = new NoteData + { + type = row.NoteType switch + { + 1 => NotesTypeID.Def.Tap, + 2 => NotesTypeID.Def.Hold, + 3 => NotesTypeID.Def.Break, + 4 => NotesTypeID.Def.Star, + 5 => NotesTypeID.Def.BreakStar, + 128 => NotesTypeID.Def.Slide, + _ => throw new Exception($"Unknown note type ID {row.NoteType} at row {rowIdx}, expected 1/2/3/4/5/128") + }, + startButtonPos = row.Position, + time = rowTime, + end = rowTime, + beatType = ParserUtilities.GetBeatType(rowTime.grid), + index = rowIdx, + indexNote = NoteIndex + }; + + if (note.type.isHold()) + { + note.end += ParserUtilities.NotesTimeFromBars(Refs, row.HoldDuration); + note.end.calcMsec(Refs.Reader); + } + + if (note.type.isSlide()) + { + if (!SlideHeads.TryGetValue(row.SlideId, out var slideHeadRow)) + { + throw new Exception($"Slide body (ID {row.SlideId}) declared without or before its head"); + } + + note.startButtonPos = slideHeadRow.Position; + note.slideData = new SlideData + { + type = (SlideType)row.SlidePattern, + index = row.SlideId, + targetNote = row.Position, + shoot = new TimingBase { index = rowIdx }, + arrive = new TimingBase { index = rowIdx }, + }; + + note.time = slideHeadRow.NotesTime(Refs.Reader); + note.beatType = ParserUtilities.GetBeatType(note.time.grid); + note.slideData.shoot.time = note.time + slideHeadRow.SlideDelayNotesTime(Refs); + note.slideData.shoot.time.calcMsec(Refs.Reader); + note.slideData.arrive.time.copy(rowTime); + note.end.copy(rowTime); + } + + System.Console.WriteLine("[MoreChartFormats] [SXT] Adding note {0} at {1}", note.type.getEnumName(), note.time.getBar()); + Refs.Notes._noteData.Add(note); + NoteIndex++; + } +} diff --git a/MoreChartFormats/MaiSxt/SxtReaderBase.cs b/MoreChartFormats/MaiSxt/SxtReaderBase.cs new file mode 100644 index 0000000..787fc1e --- /dev/null +++ b/MoreChartFormats/MaiSxt/SxtReaderBase.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using Manager; +using MoreChartFormats.MaiSxt.Structures; +using MoreChartFormats.Simai.Structures; + +namespace MoreChartFormats.MaiSxt; + +public abstract class SxtReaderBase(NotesReferences refs) +{ + protected readonly NotesReferences Refs = refs; + protected int NoteIndex; + protected readonly Dictionary SlideHeads = new(); + + public void Deserialize(string content) + { + var table = GetSxtTable(content); + + for (var rowIdx = 0; rowIdx < table.Length; rowIdx++) + { + var row = ParseRow(rowIdx, table[rowIdx]); + LoadRow(rowIdx, in row); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + protected abstract void LoadRow(int rowIdx, in SxtRow row); + + private static SxtRow ParseRow(int rowIdx, string[] row) + { + var srtRow = new SxtRow(); + + if (!float.TryParse(row[0], NumberStyles.Float, CultureInfo.InvariantCulture, out srtRow.Bar)) + { + throw new Exception($"Invalid whole measure at row {rowIdx}: {row[0]}"); + } + + if (!float.TryParse(row[1], NumberStyles.Float, CultureInfo.InvariantCulture, out srtRow.Grid)) + { + throw new Exception($"Invalid fractional measure at row {rowIdx}: {row[1]}"); + } + + if (!float.TryParse(row[2], NumberStyles.Float, CultureInfo.InvariantCulture, out srtRow.HoldDuration)) + { + throw new Exception($"Invalid hold duration at row {rowIdx}: {row[2]}"); + } + + if (!int.TryParse(row[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out srtRow.Position)) + { + throw new Exception($"Invalid position at row {rowIdx}: {row[3]}"); + } + + if (!int.TryParse(row[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out srtRow.NoteType)) + { + throw new Exception($"Invalid note type ID at row {rowIdx}: {row[4]}"); + } + + if (!int.TryParse(row[5], NumberStyles.Integer, CultureInfo.InvariantCulture, out srtRow.SlideId)) + { + throw new Exception($"Invalid slide ID at row {rowIdx}: {row[5]}"); + } + + if (!int.TryParse(row[6], NumberStyles.Integer, CultureInfo.InvariantCulture, out srtRow.SlidePattern)) + { + throw new Exception($"Invalid slide type at row {rowIdx}: {row[6]}"); + } + + // if (row.Length > 7 && !int.TryParse(row[7], NumberStyles.Integer, CultureInfo.InvariantCulture, + // out srtRow.SlideCount)) + // { + // throw new Exception($"Invalid slide count at row {rowIdx}: {row[7]}"); + // } + + if (row.Length > 8) + { + if (!float.TryParse(row[8], NumberStyles.Float, CultureInfo.InvariantCulture, + out var slideDelay)) + throw new Exception($"Invalid slide delay at row {rowIdx}: {row[8]}"); + + srtRow.SlideDelay = slideDelay; + } + + return srtRow; + } + + private static string[][] GetSxtTable(string content) + { + return content.Split(["\n", "\r\n"], StringSplitOptions.RemoveEmptyEntries) + .Select(r => r.Split([","], StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).Where(s => s.Length > 0).ToArray()) + .ToArray(); + } +} \ No newline at end of file diff --git a/MoreChartFormats/MoreChartFormats.csproj b/MoreChartFormats/MoreChartFormats.csproj new file mode 100644 index 0000000..cb90251 --- /dev/null +++ b/MoreChartFormats/MoreChartFormats.csproj @@ -0,0 +1,91 @@ + + + + + Debug + AnyCPU + {A375F626-7238-4227-95C9-2BB1E5E099F6} + Library + Properties + MoreChartFormats + Assembly-CSharp.MoreChartFormats.mm + v4.6.2 + 512 + latest + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + ..\External\MonoMod.exe + False + + + ..\External\Assembly-CSharp.dll + False + + + ..\External\UnityEngine.dll + False + + + ..\External\UnityEngine.CoreModule.dll + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MoreChartFormats/NotesReferences.cs b/MoreChartFormats/NotesReferences.cs new file mode 100644 index 0000000..7439a8a --- /dev/null +++ b/MoreChartFormats/NotesReferences.cs @@ -0,0 +1,11 @@ +using Manager; + +namespace MoreChartFormats; + +public class NotesReferences +{ + public NotesReader Reader; + public NotesHeader Header; + public NotesComposition Composition; + public NotesData Notes; +} \ No newline at end of file diff --git a/MoreChartFormats/ParserUtilities.cs b/MoreChartFormats/ParserUtilities.cs new file mode 100644 index 0000000..805635b --- /dev/null +++ b/MoreChartFormats/ParserUtilities.cs @@ -0,0 +1,48 @@ +using Manager; +using MoreChartFormats.Simai.Structures; + +namespace MoreChartFormats; + +public class ParserUtilities +{ + internal static NoteData.BeatType GetBeatType(int grid) + { + if (grid % 96 == 0) + { + return NoteData.BeatType.BeatType04; + } + + if (grid % 48 == 0) + { + return NoteData.BeatType.BeatType08; + } + + if (grid % 24 == 0) + { + return NoteData.BeatType.BeatType16; + } + + if (grid % 16 == 0) + { + return NoteData.BeatType.BeatType24; + } + + return NoteData.BeatType.BeatTypeOther; + } + + internal static NotesTime NotesTimeFromBars(NotesReferences refs, float bars) + { + var bar = (int)bars; + var grid = (int)((bars - bar) * refs.Header._resolutionTime); + + return new NotesTime(bar, grid, refs.Reader); + } + + internal static NotesTime NotesTimeFromGrids(NotesReferences refs, int grids) + { + var nt = new NotesTime(grids); + nt.calcMsec(refs.Reader); + + return nt; + } +} \ No newline at end of file diff --git a/MoreChartFormats/Properties/AssemblyInfo.cs b/MoreChartFormats/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..05993f0 --- /dev/null +++ b/MoreChartFormats/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("MoreChartFormats")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("MoreChartFormats")] +[assembly: AssemblyCopyright("Copyright © 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("A375F626-7238-4227-95C9-2BB1E5E099F6")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/MoreChartFormats/Simai/BpmChangeDataExtensions.cs b/MoreChartFormats/Simai/BpmChangeDataExtensions.cs new file mode 100644 index 0000000..03a5667 --- /dev/null +++ b/MoreChartFormats/Simai/BpmChangeDataExtensions.cs @@ -0,0 +1,14 @@ +using Manager; + +namespace MoreChartFormats.Simai; + +public static class BpmChangeDataExtensions +{ + public static float SecondsPerBar(this BPMChangeData timing) + { + // Work under the assumption that 1 bar = 4 beats + // which is kinda true because the meter is always 4/4. + // 240 is 60 * 4. + return timing.bpm == 0 ? 0 : 240f / timing.bpm; + } +} diff --git a/MoreChartFormats/Simai/Errors/InvalidSyntaxException.cs b/MoreChartFormats/Simai/Errors/InvalidSyntaxException.cs new file mode 100644 index 0000000..95b2fdc --- /dev/null +++ b/MoreChartFormats/Simai/Errors/InvalidSyntaxException.cs @@ -0,0 +1,9 @@ +namespace MoreChartFormats.Simai.Errors +{ + public class InvalidSyntaxException : SimaiException + { + public InvalidSyntaxException(int line, int character) : base(line, character) + { + } + } +} diff --git a/MoreChartFormats/Simai/Errors/ScopeMismatchException.cs b/MoreChartFormats/Simai/Errors/ScopeMismatchException.cs new file mode 100644 index 0000000..05cf167 --- /dev/null +++ b/MoreChartFormats/Simai/Errors/ScopeMismatchException.cs @@ -0,0 +1,22 @@ +using System; + +namespace MoreChartFormats.Simai.Errors +{ + public class ScopeMismatchException : SimaiException + { + public readonly ScopeType correctScope; + + public ScopeMismatchException(int line, int character, ScopeType correctScope) : base(line, character) + { + this.correctScope = correctScope; + } + + [Flags] + public enum ScopeType + { + Note = 1, + Slide = 1 << 1, + Global = 1 << 2 + } + } +} diff --git a/MoreChartFormats/Simai/Errors/SimaiException.cs b/MoreChartFormats/Simai/Errors/SimaiException.cs new file mode 100644 index 0000000..6a866db --- /dev/null +++ b/MoreChartFormats/Simai/Errors/SimaiException.cs @@ -0,0 +1,19 @@ +using System; + +namespace MoreChartFormats.Simai.Errors +{ + [Serializable] + public class SimaiException : Exception + { + public readonly int line; + public readonly int character; + + /// The line on which the error occurred + /// The first character involved in the error + public SimaiException(int line, int character) + { + this.character = character; + this.line = line; + } + } +} diff --git a/MoreChartFormats/Simai/Errors/UnexpectedCharacterException.cs b/MoreChartFormats/Simai/Errors/UnexpectedCharacterException.cs new file mode 100644 index 0000000..cb2433f --- /dev/null +++ b/MoreChartFormats/Simai/Errors/UnexpectedCharacterException.cs @@ -0,0 +1,24 @@ +namespace MoreChartFormats.Simai.Errors +{ + internal class UnexpectedCharacterException : SimaiException + { + public readonly string expected; + + /// + /// + /// This is thrown when reading a character that is not fit for the expected syntax + /// + /// + /// This issue is commonly caused by a typo or a syntax error. + /// + /// + /// + /// The line on which the error occurred + /// The first character involved in the error + /// The expected syntax + public UnexpectedCharacterException(int line, int character, string expected) : base(line, character) + { + this.expected = expected; + } + } +} diff --git a/MoreChartFormats/Simai/Errors/UnsupportedSyntaxException.cs b/MoreChartFormats/Simai/Errors/UnsupportedSyntaxException.cs new file mode 100644 index 0000000..234dc6d --- /dev/null +++ b/MoreChartFormats/Simai/Errors/UnsupportedSyntaxException.cs @@ -0,0 +1,16 @@ +namespace MoreChartFormats.Simai.Errors +{ + public class UnsupportedSyntaxException : SimaiException + { + /// + /// + /// This is thrown when an unsupported syntax is encountered when attempting to tokenize or deserialize the simai file. + /// + /// + /// The line on which the error occurred + /// The first character involved in the error + public UnsupportedSyntaxException(int line, int character) : base(line, character) + { + } + } +} diff --git a/MoreChartFormats/Simai/Errors/UnterminatedSectionException.cs b/MoreChartFormats/Simai/Errors/UnterminatedSectionException.cs new file mode 100644 index 0000000..e129f33 --- /dev/null +++ b/MoreChartFormats/Simai/Errors/UnterminatedSectionException.cs @@ -0,0 +1,9 @@ +namespace MoreChartFormats.Simai.Errors +{ + public class UnterminatedSectionException : SimaiException + { + public UnterminatedSectionException(int line, int character) : base(line, character) + { + } + } +} diff --git a/MoreChartFormats/Simai/LexicalAnalysis/Token.cs b/MoreChartFormats/Simai/LexicalAnalysis/Token.cs new file mode 100644 index 0000000..dbf6494 --- /dev/null +++ b/MoreChartFormats/Simai/LexicalAnalysis/Token.cs @@ -0,0 +1,23 @@ +using System; + +namespace MoreChartFormats.Simai.LexicalAnalysis; + +public readonly struct Token +{ + public readonly TokenType type; + public readonly string lexeme; + + public readonly int line; + public readonly int character; + + public Token(TokenType type, + string lexeme, + int line, + int character) + { + this.type = type; + this.lexeme = lexeme; + this.line = line; + this.character = character; + } +} diff --git a/MoreChartFormats/Simai/LexicalAnalysis/TokenType.cs b/MoreChartFormats/Simai/LexicalAnalysis/TokenType.cs new file mode 100644 index 0000000..01d26a4 --- /dev/null +++ b/MoreChartFormats/Simai/LexicalAnalysis/TokenType.cs @@ -0,0 +1,46 @@ +namespace MoreChartFormats.Simai.LexicalAnalysis +{ + public enum TokenType + { + None, + Tempo, + Subdivision, + + /// + /// Touch locations (A~E + 1~8) and tap locations (1~8) + /// + /// Takes either only a number (1 ~ 8) or a character (A ~ E) followed by a number (1 ~ 8 for A, B, D, E and 1 or + /// 2 for C) + /// + /// + Location, + + /// + /// Applies note styles and note types + /// + Decorator, + + /// + /// Takes a and target vertices + /// + Slide, + + /// + /// Usually denotes the length of a hold or a + /// + Duration, + + /// + /// Allows multiple slides to share the same parent note + /// + SlideJoiner, + + /// + /// Progresses the time by 1 beat + /// + TimeStep, + + EachDivider, + EndOfFile + } +} \ No newline at end of file diff --git a/MoreChartFormats/Simai/LexicalAnalysis/Tokenizer.cs b/MoreChartFormats/Simai/LexicalAnalysis/Tokenizer.cs new file mode 100644 index 0000000..1ff12eb --- /dev/null +++ b/MoreChartFormats/Simai/LexicalAnalysis/Tokenizer.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using MoreChartFormats.Simai.Errors; + +namespace MoreChartFormats.Simai.LexicalAnalysis +{ + internal sealed class Tokenizer + { + private const char Space = (char)0x0020; + private const char EnSpace = (char)0x2002; + private const char PunctuationSpace = (char)0x2008; + private const char IdeographicSpace = (char)0x3000; + + private const char LineSeparator = (char)0x2028; + private const char ParagraphSeparator = (char)0x2029; + + private const char EndOfFileChar = 'E'; + + private static readonly HashSet EachDividerChars = new() + { + '/', '`' + }; + + private static readonly HashSet DecoratorChars = new() + { + 'f', 'b', 'x', 'h', 'm', + '!', '?', + '@', '$' + }; + + private static readonly HashSet SlideChars = new() + { + '-', + '>', '<', '^', + 'p', 'q', + 'v', 'V', + 's', 'z', + 'w' + }; + + private static readonly HashSet SeparatorChars = new() + { + '\r', '\t', + LineSeparator, + ParagraphSeparator, + Space, + EnSpace, + PunctuationSpace, + IdeographicSpace + }; + + private readonly char[] _sequence; + private int _current; + private int _charIndex; + private int _line = 1; + + private int _start; + + public Tokenizer(string sequence) + { + _sequence = sequence.ToCharArray(); + } + + private bool IsAtEnd => _current >= _sequence.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IEnumerable GetTokens() + { + while (!IsAtEnd) + { + _start = _current; + + var nextToken = ScanToken(); + + if (nextToken.HasValue) + yield return nextToken.Value; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Token? ScanToken() + { + _charIndex++; + var c = Advance(); + switch (c) + { + case ',': + return CompileToken(TokenType.TimeStep); + + case '(': + return CompileSectionToken(TokenType.Tempo, '(', ')'); + case '{': + return CompileSectionToken(TokenType.Subdivision, '{', '}'); + case '[': + return CompileSectionToken(TokenType.Duration, '[', ']'); + + case var _ when TryScanLocationToken(out var length): + _current += length - 1; + return CompileToken(TokenType.Location); + + case var _ when DecoratorChars.Contains(c): + return CompileToken(TokenType.Decorator); + + case var _ when IsReadingSlideDeclaration(out var length): + _current += length - 1; + return CompileToken(TokenType.Slide); + + case '*': + return CompileToken(TokenType.SlideJoiner); + + case var _ when EachDividerChars.Contains(c): + return CompileToken(TokenType.EachDivider); + + case var _ when SeparatorChars.Contains(c): + // Ignore whitespace. + return null; + + case '\n': + _line++; + _charIndex = 0; + return null; + + case 'E': + return CompileToken(TokenType.EndOfFile); + + case '|': + { + if (Peek() != '|') + throw new UnexpectedCharacterException(_line, _charIndex, "|"); + + while (Peek() != '\n' && !IsAtEnd) + Advance(); + + return null; + } + + default: + throw new UnsupportedSyntaxException(_line, _charIndex); + } + } + + private bool TryScanLocationToken(out int length) + { + var firstLocationChar = PeekPrevious(); + + if (IsButtonLocation(firstLocationChar)) + { + length = 1; + return true; + } + + length = 0; + + if (!IsSensorLocation(firstLocationChar)) + return false; + + var secondLocationChar = Peek(); + + if (IsButtonLocation(secondLocationChar)) + { + length = 2; + return true; + } + + if (firstLocationChar == 'C') + { + length = 1; + return true; + } + + var secondCharIsEmpty = SeparatorChars.Contains(secondLocationChar) || + secondLocationChar is '\n' or '\0'; + + // This is the notation for EOF. + if (firstLocationChar == EndOfFileChar && secondCharIsEmpty) + return false; + + throw new UnexpectedCharacterException(_line, _charIndex, "1, 2, 3, 4, 5, 6, 7, 8"); + } + + private bool IsReadingSlideDeclaration(out int length) + { + if (!SlideChars.Contains(PeekPrevious())) + { + length = 0; + return false; + } + + var nextChar = Peek(); + + length = nextChar is 'p' or 'q' ? 2 : 1; + return true; + } + + private Token? CompileSectionToken(TokenType tokenType, char initiator, char terminator) + { + _start++; + while (Peek() != terminator) + { + if (IsAtEnd || Peek() == initiator) + throw new UnterminatedSectionException(_line, _charIndex); + + Advance(); + } + + var token = CompileToken(tokenType); + + // The terminator. + Advance(); + + return token; + } + + private Token CompileToken(TokenType type) + { + var text = new string(_sequence.Skip(_start).Take(_current - _start).ToArray()); + + return new Token(type, text, _line, _charIndex); + } + + private static bool IsSensorLocation(char value) + { + return value is >= 'A' and <= 'E'; + } + + private static bool IsButtonLocation(char value) + { + return value is >= '0' and <= '8'; + } + + /// + /// Returns the glyph, and increments by one. + /// + private char Advance() + { + return _sequence[_current++]; + } + + /// + /// Returns the glyph without incrementing. + /// + private char Peek() + { + return IsAtEnd ? default : _sequence[_current]; + } + + /// + /// Returns the last glyph without decrementing. + /// + private char PeekPrevious() + { + return _current == 0 ? default : _sequence[_current - 1]; + } + } +} diff --git a/MoreChartFormats/Simai/SimaiReader.cs b/MoreChartFormats/Simai/SimaiReader.cs new file mode 100644 index 0000000..1ef0b28 --- /dev/null +++ b/MoreChartFormats/Simai/SimaiReader.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Manager; +using MoreChartFormats.Simai.Errors; +using MoreChartFormats.Simai.LexicalAnalysis; +using MoreChartFormats.Simai.Structures; +using MoreChartFormats.Simai.SyntacticAnalysis; + +namespace MoreChartFormats.Simai; + +public class SimaiReader +{ + public static List Tokenize(string value) + { + return new Tokenizer(value).GetTokens().ToList(); + } + + public static void Deserialize(IEnumerable tokens, NotesReferences refs) + { + new Deserializer(tokens).GetChart(refs); + } + + public static void ReadBpmChanges(IEnumerable tokens, NotesReferences refs) + { + var currentTime = new NotesTime(); + var subdivision = 4f; + var deltaGrids = 0f; + + var nr = refs.Reader; + var header = refs.Header; + var composition = refs.Composition; + + foreach (var token in tokens) + { + switch (token.type) + { + case TokenType.Tempo: + { + var deltaBars = deltaGrids / header._resolutionTime; + var bar = (int)deltaBars; + var grid = (int)((deltaBars - bar) * header._resolutionTime); + + currentTime += new NotesTime(bar, grid, nr); + deltaGrids -= bar * header._resolutionTime + grid; + + var bpmChangeData = new BPMChangeData(); + bpmChangeData.time.copy(currentTime); + + if (!float.TryParse(token.lexeme, NumberStyles.Any, CultureInfo.InvariantCulture, + out bpmChangeData.bpm)) + { + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + } + + composition._bpmList.Add(bpmChangeData); + break; + } + case TokenType.Subdivision: + { + if (token.lexeme[0] == '#') + { + throw new UnsupportedSyntaxException(token.line, token.character); + } + + if (!float.TryParse(token.lexeme, NumberStyles.Any, CultureInfo.InvariantCulture, out subdivision)) + { + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + } + break; + } + case TokenType.TimeStep: + { + deltaGrids += header._resolutionTime / subdivision; + break; + } + default: + continue; + } + } + + if (composition._bpmList.Count == 0) + { + var dummyBpmChange = new BPMChangeData { bpm = ReaderConst.DEFAULT_BPM }; + dummyBpmChange.time.init(0, 0, nr); + + composition._bpmList.Add(dummyBpmChange); + } + } +} diff --git a/MoreChartFormats/Simai/Structures/SlideSegment.cs b/MoreChartFormats/Simai/Structures/SlideSegment.cs new file mode 100644 index 0000000..fcae396 --- /dev/null +++ b/MoreChartFormats/Simai/Structures/SlideSegment.cs @@ -0,0 +1,9 @@ +using Manager; + +namespace MoreChartFormats.Simai.Structures; + +public class SlideSegment +{ + public NoteData NoteData; + public SlideTiming Timing; +} diff --git a/MoreChartFormats/Simai/Structures/SlideTiming.cs b/MoreChartFormats/Simai/Structures/SlideTiming.cs new file mode 100644 index 0000000..41645b5 --- /dev/null +++ b/MoreChartFormats/Simai/Structures/SlideTiming.cs @@ -0,0 +1,9 @@ +using Manager; + +namespace MoreChartFormats.Simai.Structures; + +public class SlideTiming +{ + public NotesTime? Delay; + public NotesTime Duration; +} diff --git a/MoreChartFormats/Simai/SyntacticAnalysis/Deserializer.cs b/MoreChartFormats/Simai/SyntacticAnalysis/Deserializer.cs new file mode 100644 index 0000000..5b66bce --- /dev/null +++ b/MoreChartFormats/Simai/SyntacticAnalysis/Deserializer.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Globalization; +using System.Linq; +using Manager; +using MoreChartFormats.Simai.Errors; +using MoreChartFormats.Simai.LexicalAnalysis; +using MoreChartFormats.Simai.Structures; +using MoreChartFormats.Simai.SyntacticAnalysis.States; + +namespace MoreChartFormats.Simai.SyntacticAnalysis; + +internal class Deserializer(IEnumerable sequence) : IDisposable +{ + internal readonly IEnumerator TokenEnumerator = sequence.GetEnumerator(); + + internal BPMChangeData CurrentBpmChange; + internal NotesTime CurrentTime = new(0); + + // References all notes between two TimeSteps + internal LinkedList> CurrentNoteDataCollections; + + // References the current note between EACH dividers + internal List CurrentNoteData; + + internal float Subdivision = 4f; + internal bool IsEndOfFile; + + public void Dispose() + { + TokenEnumerator.Dispose(); + } + + public void GetChart(NotesReferences refs) + { + var manuallyMoved = false; + var deltaGrids = 0f; + var index = 1; + var noteIndex = 0; + var slideIndex = 0; + var currentBpmChangeIndex = 0; + var firstBpmChangeEventIgnored = false; + var fakeEach = false; + + CurrentBpmChange = refs.Composition._bpmList[currentBpmChangeIndex]; + + while (!IsEndOfFile && (manuallyMoved || MoveNext())) + { + var token = TokenEnumerator.Current; + manuallyMoved = false; + + switch (token.type) + { + case TokenType.Subdivision: + { + if (token.lexeme[0] == '#') + { + if (!float.TryParse(token.lexeme.Substring(1), NumberStyles.Any, CultureInfo.InvariantCulture, out var absoluteSubdivision)) + { + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + } + + Subdivision = CurrentBpmChange.SecondsPerBar() / absoluteSubdivision; + } + else if (!float.TryParse(token.lexeme, NumberStyles.Any, CultureInfo.InvariantCulture, out Subdivision)) + { + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + } + + break; + } + case TokenType.Location: + { + // We have to stack up all deltas before adding them because adding it every time + // we reach a TimeStep would cause precision loss due to casting floating points + // back to integers. + var delta = ParserUtilities.NotesTimeFromGrids(refs, (int)deltaGrids); + + CurrentTime += delta; + deltaGrids -= delta.grid; + + CurrentNoteDataCollections ??= []; + CurrentNoteData = []; + CurrentNoteDataCollections.AddLast(CurrentNoteData); + + // ForceEach not supported + if (token.lexeme[0] == '0') + { + throw new UnsupportedSyntaxException(token.line, token.character); + } + + NoteReader.Process(this, in token, refs, ref index, ref noteIndex, ref slideIndex); + manuallyMoved = true; + break; + } + case TokenType.TimeStep: + { + if (CurrentNoteDataCollections != null) + { + if (fakeEach) + { + ProcessFakeEach(refs); + fakeEach = false; + } + refs.Notes._noteData.AddRange(CurrentNoteDataCollections.SelectMany(c => c)); + CurrentNoteDataCollections = null; + CurrentNoteData = null; + } + + deltaGrids += refs.Header._resolutionTime / Subdivision; + break; + } + case TokenType.EachDivider: + fakeEach = fakeEach || token.lexeme[0] == '`'; + break; + case TokenType.Decorator: + throw new ScopeMismatchException(token.line, token.character, + ScopeMismatchException.ScopeType.Note); + case TokenType.Slide: + throw new ScopeMismatchException(token.line, token.character, + ScopeMismatchException.ScopeType.Note); + case TokenType.Duration: + throw new ScopeMismatchException(token.line, token.character, + ScopeMismatchException.ScopeType.Note | + ScopeMismatchException.ScopeType.Slide); + case TokenType.SlideJoiner: + throw new ScopeMismatchException(token.line, token.character, + ScopeMismatchException.ScopeType.Slide); + case TokenType.EndOfFile: + // There isn't a way to signal to the game engine that the chart + // is ending prematurely, as it expects that every note in the + // chart is actually in the chart. + IsEndOfFile = true; + break; + case TokenType.Tempo: + if (!firstBpmChangeEventIgnored) + { + firstBpmChangeEventIgnored = true; + } + else + { + CurrentBpmChange = refs.Composition._bpmList[++currentBpmChangeIndex]; + } + break; + case TokenType.None: + break; + default: + throw new UnsupportedSyntaxException(token.line, token.character); + } + + index++; + } + + if (CurrentNoteDataCollections == null) + { + return; + } + + if (fakeEach) + { + ProcessFakeEach(refs); + } + refs.Notes._noteData.AddRange(CurrentNoteDataCollections.SelectMany(c => c)); + CurrentNoteDataCollections = null; + CurrentNoteData = null; + } + + private void ProcessFakeEach(NotesReferences refs) + { + var node = CurrentNoteDataCollections.First.Next; + var singleTick = new NotesTime(refs.Header._resolutionTime / 384); + var delta = singleTick; + + while (node != null) + { + foreach (var note in node.Value) + { + note.time += delta; + note.end += delta; + note.beatType = ParserUtilities.GetBeatType(note.time.grid); + + if (note.type.isSlide()) + { + note.slideData.shoot.time += delta; + note.slideData.arrive.time += delta; + } + + delta += singleTick; + } + + node = node.Next; + } + } + + internal static bool TryReadLocation(in Token token, out int position, out TouchSensorType touchGroup) + { + var isSensor = token.lexeme[0] is >= 'A' and <= 'E'; + var index = isSensor ? token.lexeme.Substring(1) : token.lexeme; + + touchGroup = TouchSensorType.Invalid; + + if (isSensor) + { + touchGroup = token.lexeme[0] switch + { + 'A' => TouchSensorType.A, + 'B' => TouchSensorType.B, + 'C' => TouchSensorType.C, + 'D' => TouchSensorType.D, + 'E' => TouchSensorType.E, + _ => TouchSensorType.Invalid, + }; + + switch (touchGroup) + { + case TouchSensorType.Invalid: + position = -1; + return false; + case TouchSensorType.C: + position = 0; + return true; + } + } + + if (!int.TryParse(index, out position)) + { + position = -1; + return false; + } + + // Convert to 0-indexed position + position -= 1; + return true; + } + + internal bool MoveNext() + { + IsEndOfFile = !TokenEnumerator.MoveNext(); + return !IsEndOfFile; + } +} \ No newline at end of file diff --git a/MoreChartFormats/Simai/SyntacticAnalysis/States/NoteReader.cs b/MoreChartFormats/Simai/SyntacticAnalysis/States/NoteReader.cs new file mode 100644 index 0000000..048db05 --- /dev/null +++ b/MoreChartFormats/Simai/SyntacticAnalysis/States/NoteReader.cs @@ -0,0 +1,255 @@ +using System.Collections.Generic; +using System.Globalization; +using Manager; +using MoreChartFormats.Simai.Errors; +using MoreChartFormats.Simai.LexicalAnalysis; + +namespace MoreChartFormats.Simai.SyntacticAnalysis.States; + +internal static class NoteReader +{ + public static void Process(Deserializer parent, in Token identityToken, NotesReferences refs, ref int index, ref int noteIndex, ref int slideIndex) + { + if (!Deserializer.TryReadLocation(in identityToken, out var position, out var touchGroup)) + { + throw new InvalidSyntaxException(identityToken.line, identityToken.character); + } + + var forceNormal = false; + var forceTapless = false; + var noteData = new NoteData + { + type = NotesTypeID.Def.Tap, + index = index, + startButtonPos = position, + beatType = ParserUtilities.GetBeatType(parent.CurrentTime.grid), + indexNote = noteIndex++, + }; + noteData.time.copy(parent.CurrentTime); + noteData.end = noteData.time; + + if (touchGroup != TouchSensorType.Invalid) + { + // simai does not have syntax for specifying touch size. + noteData.touchSize = NoteSize.M1; + noteData.touchArea = touchGroup; + noteData.type = NotesTypeID.Def.TouchTap; + } + + // Some readers (e.g. NoteReader) moves the enumerator automatically. + // We can skip moving the pointer if that's satisfied. + var manuallyMoved = false; + var noteDataAdded = false; + + while (!parent.IsEndOfFile && (manuallyMoved || parent.MoveNext())) + { + var token = parent.TokenEnumerator.Current; + manuallyMoved = false; + + switch (token.type) + { + case TokenType.Tempo: + case TokenType.Subdivision: + throw new ScopeMismatchException(token.line, token.character, ScopeMismatchException.ScopeType.Global); + + case TokenType.Decorator: + DecorateNote(in token, ref noteData, ref forceNormal, ref forceTapless); + break; + + case TokenType.Slide: + { + if (!forceNormal && !forceTapless) + { + if (!StarMapping.TryGetValue(noteData.type, out var def)) + { + throw new InvalidSyntaxException(token.line, token.character); + } + + noteData.type = def; + } + + if (!forceTapless) + { + parent.CurrentNoteData.Add(noteData); + } + + SlideReader.Process(parent, in token, refs, noteData, ref index, ref noteIndex, ref slideIndex); + noteDataAdded = true; + manuallyMoved = true; + + break; + } + + case TokenType.Duration: + ReadDuration(refs, parent.CurrentBpmChange, in token, ref noteData); + break; + + case TokenType.SlideJoiner: + throw new ScopeMismatchException(token.line, token.character, ScopeMismatchException.ScopeType.Slide); + + case TokenType.TimeStep: + case TokenType.EachDivider: + case TokenType.EndOfFile: + case TokenType.Location: + // note terminates here + if (!noteDataAdded) + { + parent.CurrentNoteData.Add(noteData); + } + return; + + case TokenType.None: + break; + + default: + throw new UnsupportedSyntaxException(token.line, token.character); + } + } + + parent.CurrentNoteData.Add(noteData); + } + + private static readonly Dictionary BreakMapping = new() + { + { NotesTypeID.Def.Tap, NotesTypeID.Def.Break }, + { NotesTypeID.Def.ExTap, NotesTypeID.Def.ExBreakTap }, + { NotesTypeID.Def.Star, NotesTypeID.Def.BreakStar }, + { NotesTypeID.Def.ExStar, NotesTypeID.Def.ExBreakStar }, + { NotesTypeID.Def.Hold, NotesTypeID.Def.BreakHold }, + { NotesTypeID.Def.ExHold, NotesTypeID.Def.ExBreakHold }, + { NotesTypeID.Def.Slide, NotesTypeID.Def.BreakSlide }, + }; + + private static readonly Dictionary ExMapping = new() + { + { NotesTypeID.Def.Tap, NotesTypeID.Def.ExTap }, + { NotesTypeID.Def.Break, NotesTypeID.Def.ExBreakTap }, + { NotesTypeID.Def.Star, NotesTypeID.Def.ExStar }, + { NotesTypeID.Def.BreakStar, NotesTypeID.Def.ExBreakStar }, + { NotesTypeID.Def.Hold, NotesTypeID.Def.ExHold }, + { NotesTypeID.Def.BreakHold, NotesTypeID.Def.ExBreakHold }, + }; + + private static readonly Dictionary HoldMapping = new() + { + { NotesTypeID.Def.Tap, NotesTypeID.Def.Hold }, + { NotesTypeID.Def.Break, NotesTypeID.Def.BreakHold }, + { NotesTypeID.Def.ExTap, NotesTypeID.Def.ExHold }, + { NotesTypeID.Def.ExBreakTap, NotesTypeID.Def.ExBreakHold }, + { NotesTypeID.Def.TouchTap, NotesTypeID.Def.TouchHold }, + }; + + private static readonly Dictionary StarMapping = new() + { + { NotesTypeID.Def.Tap, NotesTypeID.Def.Star }, + { NotesTypeID.Def.Break, NotesTypeID.Def.BreakStar }, + { NotesTypeID.Def.ExTap, NotesTypeID.Def.ExStar }, + { NotesTypeID.Def.ExBreakTap, NotesTypeID.Def.ExBreakStar }, + }; + + private static void DecorateNote(in Token token, ref NoteData noteData, ref bool forceNormal, ref bool forceTapless) + { + switch (token.lexeme[0]) + { + case 'f' when noteData.type.isTouch(): + noteData.effect = TouchEffectType.Eff1; + return; + case 'b': + { + if (!BreakMapping.TryGetValue(noteData.type, out var def)) + { + return; + } + + noteData.type = def; + return; + } + case 'x': + { + if (!ExMapping.TryGetValue(noteData.type, out var def)) + { + return; + } + + noteData.type = def; + return; + } + case 'h': + { + if (!HoldMapping.TryGetValue(noteData.type, out var def)) + { + return; + } + + noteData.type = def; + return; + } + case '@': + forceNormal = true; + return; + case '?': + forceTapless = true; + return; + case '!': + forceTapless = true; + return; + case '$': + { + if (!StarMapping.TryGetValue(noteData.type, out var def)) + { + return; + } + + noteData.type = def; + return; + } + default: + throw new UnsupportedSyntaxException(token.line, token.character); + } + } + + private static void ReadDuration(NotesReferences refs, BPMChangeData timing, in Token token, ref NoteData note) + { + if (HoldMapping.TryGetValue(note.type, out var def)) + { + note.type = def; + } + + var hashIndex = token.lexeme.IndexOf('#'); + + if (hashIndex == 0) + { + if (!float.TryParse(token.lexeme.Substring(1), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var explicitValue)) + throw new UnexpectedCharacterException(token.line, token.character + 1, "0-9 or \".\""); + + note.end = note.time + ParserUtilities.NotesTimeFromBars(refs, explicitValue / timing.SecondsPerBar()); + return; + } + + if (hashIndex != -1) + { + // The [BPM#a:b] syntax doesn't really make sense for holds? Unless we're adding + // a BPM change event just for this hold. I am not bothering. + throw new UnsupportedSyntaxException(token.line, token.character); + } + + var separatorIndex = token.lexeme.IndexOf(':'); + + if (!float.TryParse(token.lexeme.Substring(0, separatorIndex), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var denominator)) + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + + if (!float.TryParse(token.lexeme.Substring(separatorIndex + 1), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var nominator)) + throw new UnexpectedCharacterException(token.line, token.character + separatorIndex + 1, "0-9 or \".\""); + + note.end = note.time + ParserUtilities.NotesTimeFromBars(refs, nominator / denominator); + } +} \ No newline at end of file diff --git a/MoreChartFormats/Simai/SyntacticAnalysis/States/SlideReader.cs b/MoreChartFormats/Simai/SyntacticAnalysis/States/SlideReader.cs new file mode 100644 index 0000000..bf8ae97 --- /dev/null +++ b/MoreChartFormats/Simai/SyntacticAnalysis/States/SlideReader.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MAI2.Util; +using Manager; +using MoreChartFormats.Simai.Errors; +using MoreChartFormats.Simai.LexicalAnalysis; +using MoreChartFormats.Simai.Structures; + +namespace MoreChartFormats.Simai.SyntacticAnalysis.States; + +internal static class SlideReader +{ + public static void Process(Deserializer parent, in Token identityToken, NotesReferences refs, NoteData starNote, ref int index, ref int noteIndex, + ref int slideIndex) + { + var segments = new LinkedList(); + + + var firstSlide = ReadSegment(parent, in identityToken, starNote.startButtonPos, index, ref noteIndex, ref slideIndex); + firstSlide.time = new NotesTime(parent.CurrentTime); + + var firstSegment = new SlideSegment { NoteData = firstSlide }; + segments.AddLast(firstSegment); + + var currentSegment = firstSegment; + var slideStartPos = firstSlide.slideData.targetNote; + + // Some readers (e.g. NoteReader) moves the enumerator automatically. + // We can skip moving the pointer if that's satisfied. + var manuallyMoved = true; + + while (!parent.IsEndOfFile && (manuallyMoved || parent.MoveNext())) + { + var token = parent.TokenEnumerator.Current; + manuallyMoved = false; + + switch (token.type) + { + case TokenType.Tempo: + case TokenType.Subdivision: + throw new ScopeMismatchException(token.line, token.character, ScopeMismatchException.ScopeType.Global); + case TokenType.Decorator: + DecorateSlide(in token, ref firstSlide); + break; + case TokenType.Slide: + currentSegment = new SlideSegment + { + NoteData = ReadSegment(parent, in token, slideStartPos, ++index, + ref noteIndex, ref slideIndex), + }; + segments.AddLast(currentSegment); + slideStartPos = currentSegment.NoteData.slideData.targetNote; + manuallyMoved = true; + break; + case TokenType.Duration: + currentSegment.Timing = ReadDuration(refs, parent.CurrentBpmChange, in token); + break; + case TokenType.SlideJoiner: + ProcessSlideSegments(parent, refs, in identityToken, segments); + slideStartPos = starNote.startButtonPos; + segments.Clear(); + break; + case TokenType.TimeStep: + case TokenType.EachDivider: + case TokenType.EndOfFile: + case TokenType.Location: + ProcessSlideSegments(parent, refs, in identityToken, segments); + return; + case TokenType.None: + break; + default: + throw new UnsupportedSyntaxException(token.line, token.character); + } + } + } + + private static void ProcessSlideSegments(Deserializer parent, NotesReferences refs, in Token identityToken, LinkedList segments) + { + // Fast path for non-festival slides + if (segments.Count == 1) + { + ProcessSingleSlideSegment(parent, refs, segments.First.Value); + return; + } + + var segmentsWithTiming = segments.Count(s => s.Timing != null); + + if (segmentsWithTiming != 1 && segmentsWithTiming != segments.Count) + { + throw new InvalidSyntaxException(identityToken.line, identityToken.character); + } + + if (segmentsWithTiming == segments.Count) + { + ProcessSlideSegmentsAllDurations(parent, refs, segments); + } + else + { + ProcessSlideSegmentsSingleDuration(parent, refs, in identityToken, segments); + } + } + + private static void ProcessSingleSlideSegment(Deserializer parent, NotesReferences refs, SlideSegment segment) + { + segment.NoteData.time = new NotesTime(parent.CurrentTime); + segment.NoteData.beatType = ParserUtilities.GetBeatType(segment.NoteData.time.grid); + segment.NoteData.slideData.shoot.time = new NotesTime(segment.NoteData.time); + + if (segment.Timing.Delay.HasValue) + { + segment.NoteData.slideData.shoot.time += segment.Timing.Delay.Value; + } + else + { + segment.NoteData.slideData.shoot.time += new NotesTime(0, refs.Header._resolutionTime / 4, refs.Reader); + } + + segment.NoteData.slideData.arrive.time = segment.NoteData.slideData.shoot.time + segment.Timing.Duration; + segment.NoteData.end = new NotesTime(segment.NoteData.slideData.arrive.time); + + parent.CurrentNoteData.Add(segment.NoteData); + } + + private static void ProcessSlideSegmentsAllDurations(Deserializer parent, NotesReferences refs, LinkedList segments) + { + var time = parent.CurrentTime; + var isFirstSegment = true; + + foreach (var segment in segments) + { + if (!isFirstSegment) + { + segment.NoteData.type = NotesTypeID.Def.ConnectSlide; + } + + segment.NoteData.time = new NotesTime(time); + segment.NoteData.beatType = ParserUtilities.GetBeatType(segment.NoteData.time.grid); + segment.NoteData.slideData.shoot.time = new NotesTime(segment.NoteData.time); + + if (segment.Timing.Delay.HasValue) + { + segment.NoteData.slideData.shoot.time += segment.Timing.Delay.Value; + } + else if (isFirstSegment) + { + segment.NoteData.slideData.shoot.time += new NotesTime(0, refs.Header._resolutionTime / 4, refs.Reader); + } + + segment.NoteData.slideData.arrive.time = + segment.NoteData.slideData.shoot.time + segment.Timing.Duration; + segment.NoteData.end = new NotesTime(segment.NoteData.slideData.arrive.time); + time = segment.NoteData.end; + + parent.CurrentNoteData.Add(segment.NoteData); + + isFirstSegment = false; + } + } + + private static void ProcessSlideSegmentsSingleDuration(Deserializer parent, NotesReferences refs, + in Token identityToken, LinkedList segments) + { + var time = parent.CurrentTime; + var slideTiming = segments.Last.Value.Timing; + + if (slideTiming == null) + { + throw new InvalidSyntaxException(identityToken.line, identityToken.character); + } + + var slideManager = Singleton.Instance; + var slideLengths = segments.Select( + s => slideManager.GetSlideLength( + s.NoteData.slideData.type, s.NoteData.startButtonPos, s.NoteData.slideData.targetNote)).ToList(); + var totalSlideLength = slideLengths.Sum(); + + var segmentNode = segments.First; + var i = 0; + + while (segmentNode != null) + { + var slideLength = slideLengths[i]; + var segment = segmentNode.Value; + var isFirstSegment = i == 0; + + segment.NoteData.time = new NotesTime(time); + segment.NoteData.beatType = ParserUtilities.GetBeatType(segment.NoteData.time.grid); + segment.NoteData.slideData.shoot.time = new NotesTime(segment.NoteData.time); + + if (isFirstSegment) + { + if (slideTiming.Delay.HasValue) + { + segment.NoteData.slideData.shoot.time += slideTiming.Delay.Value; + } + else + { + segment.NoteData.slideData.shoot.time += new NotesTime(0, refs.Header._resolutionTime / 4, refs.Reader); + } + } + else + { + segment.NoteData.type = NotesTypeID.Def.ConnectSlide; + } + + var slideDurationSlice = slideLength / totalSlideLength; + var slideDuration = new NotesTime((int)Math.Round(slideTiming.Duration.grid * slideDurationSlice)); + + segment.NoteData.slideData.arrive.time = + segment.NoteData.slideData.shoot.time + slideDuration; + segment.NoteData.end = new NotesTime(segment.NoteData.slideData.arrive.time); + time = segment.NoteData.end; + + parent.CurrentNoteData.Add(segment.NoteData); + + segmentNode = segmentNode.Next; + i++; + } + } + + private static NoteData ReadSegment(Deserializer parent, in Token identityToken, int startingPosition, int index, ref int noteIndex, ref int slideIndex) + { + var length = identityToken.lexeme.Length; + var vertices = RetrieveVertices(parent, in identityToken); + + return new NoteData + { + type = NotesTypeID.Def.Slide, + index = index, + indexNote = noteIndex++, + indexSlide = slideIndex++, + startButtonPos = startingPosition, + slideData = new SlideData + { + targetNote = vertices.Last(), + type = DetermineSlideType(in identityToken, startingPosition, length, vertices), + shoot = new TimingBase { index = noteIndex }, + arrive = new TimingBase { index = noteIndex }, + }, + }; + } + + private static SlideTiming ReadDuration(NotesReferences refs, BPMChangeData timing, in Token token) + { + // Accepted slide duration formats: + // - {BPM}#{denominator}:{nominator} + // - #{slide duration in seconds} + // - {BPM}#{slide duration in seconds} + // - {seconds}##{slide duration in seconds} + var result = new SlideTiming { Duration = new NotesTime() }; + + var hashIndex = token.lexeme.IndexOf('#'); + var statesIntroDelayDuration = hashIndex > 0; + var durationDeclarationStart = hashIndex + 1; + + if (statesIntroDelayDuration) + { + result.Delay = new NotesTime(); + + var secondHashIndex = token.lexeme.LastIndexOf('#'); + var isAbsoluteDelay = secondHashIndex > -1; + + if (!float.TryParse(token.lexeme.Substring(0, hashIndex), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var value)) + throw new UnexpectedCharacterException(token.line, token.character + 1, "0-9 or \".\""); + + if (isAbsoluteDelay) + { + durationDeclarationStart = secondHashIndex + 1; + result.Delay.Value.copy(ParserUtilities.NotesTimeFromBars(refs, value / timing.SecondsPerBar())); + } + else + { + var grids = (int)Math.Round((float)refs.Header._resolutionTime / 4 * (timing.bpm / value)); + result.Delay.Value.copy(ParserUtilities.NotesTimeFromGrids(refs, grids)); + } + } + + var durationDeclaration = token.lexeme.Substring(durationDeclarationStart); + var separatorIndex = durationDeclaration.IndexOf(':'); + + if (separatorIndex > -1) + { + if (!float.TryParse(durationDeclaration.Substring(0, separatorIndex), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var denominator)) + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + + if (!float.TryParse(durationDeclaration.Substring(separatorIndex + 1), + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var nominator)) + throw new UnexpectedCharacterException(token.line, token.character + separatorIndex + 1, "0-9 or \".\""); + + result.Duration.copy(ParserUtilities.NotesTimeFromBars(refs, nominator / denominator)); + } + else + { + if (!float.TryParse(durationDeclaration, + NumberStyles.Any, + CultureInfo.InvariantCulture, + out var seconds)) + throw new UnexpectedCharacterException(token.line, token.character, "0-9 or \".\""); + + result.Duration.copy(ParserUtilities.NotesTimeFromBars(refs, seconds / timing.SecondsPerBar())); + } + + return result; + } + + private static SlideType DetermineSlideType(in Token identityToken, int startingPosition, int length, List vertices) + { + return identityToken.lexeme[0] switch + { + '-' => SlideType.Slide_Straight, + '^' => DetermineRingType(startingPosition, vertices[0]), + '>' => DetermineRingType(startingPosition, vertices[0], 1), + '<' => DetermineRingType(startingPosition, vertices[0], -1), + 'V' => DetermineRingType(startingPosition, vertices[0]) switch + { + SlideType.Slide_Circle_L => SlideType.Slide_Skip_L, + SlideType.Slide_Circle_R => SlideType.Slide_Skip_R, + _ => throw new ArgumentOutOfRangeException(), + }, + 'v' => SlideType.Slide_Corner, + 's' => SlideType.Slide_Thunder_R, + 'z' => SlideType.Slide_Thunder_L, + 'p' when length == 2 && identityToken.lexeme[1] == 'p' => SlideType.Slide_Bend_L, + 'q' when length == 2 && identityToken.lexeme[1] == 'q' => SlideType.Slide_Bend_R, + 'p' => SlideType.Slide_Curve_L, + 'q' => SlideType.Slide_Curve_R, + 'w' => SlideType.Slide_Fan, + _ => throw new UnexpectedCharacterException(identityToken.line, identityToken.character, "-, ^, >, <, v, V, s, z, pp, qq, p, q, w"), + }; + } + + private static void DecorateSlide(in Token token, ref NoteData note) + { + switch (token.lexeme[0]) + { + case 'b': + note.type = NotesTypeID.Def.BreakSlide; + return; + default: + throw new UnsupportedSyntaxException(token.line, token.character); + } + } + + private static SlideType DetermineRingType(int startPosition, int endPosition, int direction = 0) + { + switch (direction) + { + case 1: + return (startPosition + 2) % 8 >= 4 ? SlideType.Slide_Circle_L : SlideType.Slide_Circle_R; + case -1: + return (startPosition + 2) % 8 >= 4 ? SlideType.Slide_Circle_R : SlideType.Slide_Circle_L; + default: + { + var difference = endPosition - startPosition; + + var rotation = difference >= 0 + ? difference > 4 ? -1 : 1 + : difference < -4 ? 1 : -1; + + return rotation > 0 ? SlideType.Slide_Circle_R : SlideType.Slide_Circle_L; + } + } + } + + private static List RetrieveVertices(Deserializer parent, in Token identityToken) + { + var vertices = new List(); + + do + { + if (!parent.TokenEnumerator.MoveNext()) + throw new UnexpectedCharacterException(identityToken.line, identityToken.character, + "1, 2, 3, 4, 5, 6, 7, 8"); + + var current = parent.TokenEnumerator.Current; + + if (Deserializer.TryReadLocation(in current, out var location, out _)) + vertices.Add(location); + } while (parent.TokenEnumerator.Current.type == TokenType.Location); + + return vertices; + } +} diff --git a/MoreChartFormats/patch_NotesReader.cs b/MoreChartFormats/patch_NotesReader.cs new file mode 100644 index 0000000..036fb96 --- /dev/null +++ b/MoreChartFormats/patch_NotesReader.cs @@ -0,0 +1,209 @@ +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming + +using System; +using System.IO; +using System.Xml.Serialization; +using MAI2.Util; +using Manager.MaiStudio.Serialize; +using MonoMod; +using MoreChartFormats; +using MoreChartFormats.MaiSxt; +using MoreChartFormats.Simai; +using MoreChartFormats.Simai.Errors; +using MoreChartFormats.Simai.Structures; + +namespace Manager; + +class patch_NotesReader : NotesReader +{ + private new FormatType checkFormat(string fileName) => Path.GetExtension(fileName) switch + { + ".simai" => FormatType.FORMAT_M2S, + ".srt" => FormatType.FORMAT_SRT, + ".szt" => FormatType.FORMAT_SZT, + ".sct" => FormatType.FORMAT_SCT, + ".sdt" => FormatType.FORMAT_SDT, + _ => FormatType.FORMAT_MA2, + }; + + [MonoModIgnore] + private extern bool loadMa2(string fileName, LoadType loadType = LoadType.LOAD_FULL); + + [MonoModReplace] + public new bool load(string fileName, LoadType loadType = LoadType.LOAD_FULL) + { + if (!File.Exists(fileName)) + { + return false; + } + + var format = checkFormat(fileName); + + return format switch + { + FormatType.FORMAT_MA2 => loadMa2(fileName, loadType), + FormatType.FORMAT_SRT or FormatType.FORMAT_SZT or FormatType.FORMAT_SCT or FormatType.FORMAT_SDT => + loadSxt(format, fileName), + FormatType.FORMAT_M2S => loadSimai(fileName), + _ => false, + }; + } + + private bool loadSxt(FormatType format, string fileName) + { + init(_playerID); + fillDummyHeader(fileName); + _loadType = LoadType.LOAD_FULL; + + try + { + // HACK: we are assuming that the chart file is stored in the same folder + // as the Music.xml, which it must be due to how this game loads assets. + // refer to `Manager.MaiStudio.Serialize.FilePath.AddPath(string parentPath)`. + // + // There must be a better way to get the song's BPM... + var musicFolder = Path.GetDirectoryName(fileName); + + if (musicFolder == null) + { + throw new Exception("Music.xml is contained in the root directory?!"); + } + + var serializer = new XmlSerializer(typeof(MusicData)); + using (var musicXml = File.OpenRead(Path.Combine(musicFolder, "Music.xml"))) + { + var music = (MusicData)serializer.Deserialize(musicXml); + var bpmChangeData = new BPMChangeData + { + bpm = music.bpm, + time = new NotesTime(0, 0, this), + }; + _composition._bpmList.Add(bpmChangeData); + calcBPMList(); + } + + var content = File.ReadAllText(fileName); + var refs = new NotesReferences + { + Reader = this, + Header = _header, + Composition = _composition, + Notes = _note, + }; + SxtReaderBase reader = format == FormatType.FORMAT_SRT ? new SrtReader(refs) : new SxtReader(refs); + + reader.Deserialize(content); + calcAll(); + } + catch (Exception e) + { + System.Console.WriteLine("[MoreChartFormats] [SXT] Could not load SXT chart: {0}", e); + return false; + } + + return true; + } + + private bool loadSimai(string fileName) + { + init(_playerID); + fillDummyHeader(fileName); + _loadType = LoadType.LOAD_FULL; + + try + { + System.Console.WriteLine("[MoreChartFormats] [Simai] Tokenizing chart"); + var tokens = SimaiReader.Tokenize(File.ReadAllText(fileName)); + var refs = new NotesReferences + { + Reader = this, + Header = _header, + Composition = _composition, + Notes = _note, + }; + + System.Console.WriteLine("[MoreChartFormats] [Simai] Processing BPM changes"); + SimaiReader.ReadBpmChanges(tokens, refs); + calcBPMList(); + + System.Console.WriteLine("[MoreChartFormats] [Simai] Reading chart"); + SimaiReader.Deserialize(tokens, refs); + + System.Console.WriteLine("[MoreChartFormats] [Simai] Mirroring chart and calculating timings"); + foreach (var note in _note._noteData) + { + note.time.calcMsec(this); + note.end.calcMsec(this); + note.startButtonPos = ConvertMirrorPosition(note.startButtonPos); + + if (note.type.isSlide() || note.type == NotesTypeID.Def.ConnectSlide) + { + note.slideData.shoot.time.calcMsec(this); + note.slideData.arrive.time.calcMsec(this); + note.slideData.targetNote = ConvertMirrorPosition(note.slideData.targetNote); + note.slideData.type = ConvertMirrorSlide(note.slideData.type); + } + + if (note.type.isTouch() && note.touchArea is TouchSensorType.D or TouchSensorType.E) + { + note.startButtonPos = ConvertMirrorTouchEPosition(note.startButtonPos); + } + } + + System.Console.WriteLine("[MoreChartFormats] [Simai] Calculating chart data"); + calcAll(); + + System.Console.WriteLine("[MoreChartFormats] [Simai] Loaded {0} notes", _total.GetAllNoteNum()); + System.Console.WriteLine("[MoreChartFormats] [Simai] > {0} taps", _total.GetTapNum()); + System.Console.WriteLine("[MoreChartFormats] [Simai] > {0} holds", _total.GetHoldNum()); + System.Console.WriteLine("[MoreChartFormats] [Simai] > {0} slides", _total.GetSlideNum()); + System.Console.WriteLine("[MoreChartFormats] [Simai] > {0} touches", _total.GetTouchNum()); + System.Console.WriteLine("[MoreChartFormats] [Simai] > {0} break", _total.GetBreakNum()); + } + catch (SimaiException e) + { + System.Console.WriteLine($"[MoreChartFormats] There was an error loading the chart at line {e.line}, col {e.character}: {e} "); + return false; + } + catch (Exception e) + { + System.Console.WriteLine($"[MoreChartFormats] There was an error loading the chart: {e}"); + return false; + } + + return true; + } + + private void fillDummyHeader(string fileName) + { + _header._notesName = fileName; + _header._resolutionTime = ReaderConst.DEFAULT_RESOLUTION_TIME; + _header._version[0].major = 0; + _header._version[0].minor = 0; + _header._version[0].release = 0; + _header._version[1].major = 1; + _header._version[1].minor = 4; + _header._version[0].release = 0; + _header._metInfo.denomi = 4; + _header._metInfo.num = 4; + _header._clickFirst = ReaderConst.DEFAULT_RESOLUTION_TIME; + + // The game doesn't care if a non-fes-mode chart uses utage mechanics. + // It's just a flag. + _header._isFes = false; + } + + private void calcAll() + { + calcNoteTiming(); + calcEach(); + calcSlide(); + calcEndTiming(); + calcBPMInfo(); + calcBarList(); + calcSoflanList(); + calcClickList(); + calcTotal(); + } +} \ No newline at end of file diff --git a/README.md b/README.md index 7e1d805..a809751 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,39 @@ Enables loading tables from loose `.json` files in `Sinmai_Data/StreamingAssets/ Useful for string edits (a.k.a. english patch). Tables are automatically generated if `Sinmai_Data/StreamingAssets/DB` doesn't exist. + +### MoreChartFormats + +Loads charts written in various known formats: +- [simai](https://w.atwiki.jp/simai) (powered by a custom fork of [SimaiSharp](https://github.com/reflektone-games/SimaiSharp)) +- srt/szt/sct/sdt (maimai classic chart format) + +To use, edit Music.xml to point the chart file path to your chart file: +```xml + + + {filename}.sdt + + + +``` + +The chart loader used depends on the file extension: +- simai chart files must end with `.simai` +- srt chart files must end with `.srt` +- szt/sct/sdt files can use `.szt`/`.sct`/`.sdt` interchangeably, since they only +differ by two ending columns. [details](https://listed.to/@donmai/18173/the-four-chart-formats-of-maimai-classic) + +#### Simai caveats +- **`maidata.txt` is not supported. If you have one, paste the content of each +`inote_x` into their own `.simai` file.** +- Both `?` and `!` will create a slide without a star note, but both of them will +make the slide fade in (`!` makes the slide suddenly appear in standard simai). +- `$` cannot be used to create a tapless slide (maiPad PLUS syntax). +- `$$` is ignored, as star notes only spin when there's an associated slide. +- `[BPM#a:b]` is not supported for specifying hold time. +- `` ` `` (fake EACH) makes taps 1/384 measures apart. + +#### SXT caveats +- Encrypted chart files (`.srb`/`.szb`/`.scb`/`.sdb`) are not supported. Decrypt +them before loading into the game. \ No newline at end of file diff --git a/sinmai-mods.sln b/sinmai-mods.sln index 055194f..47980a1 100644 --- a/sinmai-mods.sln +++ b/sinmai-mods.sln @@ -4,6 +4,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CachedDataManager", "Cached EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FixLocaleIssues", "FixLocaleIssues\FixLocaleIssues.csproj", "{48B5F480-D749-48E9-9D26-E0E5260D95DE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LooseDBTables", "LooseDBTables\LooseDBTables.csproj", "{F15988CC-BDF0-4F86-811B-BAE18EEA6519}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LooseDBTables.GeneratePatches", "LooseDBTables.GeneratePatches\LooseDBTables.GeneratePatches.csproj", "{7DF53594-C7B2-44D1-ADF7-CCE4BC9E7625}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoreChartFormats", "MoreChartFormats\MoreChartFormats.csproj", "{A375F626-7238-4227-95C9-2BB1E5E099F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +24,17 @@ Global {48B5F480-D749-48E9-9D26-E0E5260D95DE}.Debug|Any CPU.Build.0 = Debug|Any CPU {48B5F480-D749-48E9-9D26-E0E5260D95DE}.Release|Any CPU.ActiveCfg = Release|Any CPU {48B5F480-D749-48E9-9D26-E0E5260D95DE}.Release|Any CPU.Build.0 = Release|Any CPU + {F15988CC-BDF0-4F86-811B-BAE18EEA6519}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F15988CC-BDF0-4F86-811B-BAE18EEA6519}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F15988CC-BDF0-4F86-811B-BAE18EEA6519}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F15988CC-BDF0-4F86-811B-BAE18EEA6519}.Release|Any CPU.Build.0 = Release|Any CPU + {7DF53594-C7B2-44D1-ADF7-CCE4BC9E7625}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7DF53594-C7B2-44D1-ADF7-CCE4BC9E7625}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7DF53594-C7B2-44D1-ADF7-CCE4BC9E7625}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7DF53594-C7B2-44D1-ADF7-CCE4BC9E7625}.Release|Any CPU.Build.0 = Release|Any CPU + {A375F626-7238-4227-95C9-2BB1E5E099F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A375F626-7238-4227-95C9-2BB1E5E099F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A375F626-7238-4227-95C9-2BB1E5E099F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A375F626-7238-4227-95C9-2BB1E5E099F6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal