Add MoreChartFormats

This commit is contained in:
beerpsi 2024-05-27 10:59:40 +07:00
parent 688836b131
commit 7d8c3d10bf
27 changed files with 2166 additions and 0 deletions

View File

@ -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++;
}
}

View File

@ -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);
}

View File

@ -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++;
}
}

View File

@ -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<int, SxtRow> 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();
}
}

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props"
Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')"/>
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{A375F626-7238-4227-95C9-2BB1E5E099F6}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MoreChartFormats</RootNamespace>
<AssemblyName>Assembly-CSharp.MoreChartFormats.mm</AssemblyName>
<TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System"/>
<Reference Include="System.Core"/>
<Reference Include="System.Data"/>
<Reference Include="System.Xml"/>
<Reference Include="MonoMod">
<HintPath>..\External\MonoMod.exe</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>..\External\Assembly-CSharp.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>..\External\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>..\External\UnityEngine.CoreModule.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="MaiSxt\SrtReader.cs" />
<Compile Include="MaiSxt\Structures\SxtRow.cs" />
<Compile Include="MaiSxt\SxtReader.cs" />
<Compile Include="MaiSxt\SxtReaderBase.cs" />
<Compile Include="NotesReferences.cs" />
<Compile Include="ParserUtilities.cs" />
<Compile Include="patch_NotesReader.cs" />
<Compile Include="Properties\AssemblyInfo.cs"/>
<Compile Include="Simai\BpmChangeDataExtensions.cs" />
<Compile Include="Simai\SimaiReader.cs" />
<Compile Include="Simai\LexicalAnalysis\Token.cs" />
<Compile Include="Simai\LexicalAnalysis\Tokenizer.cs" />
<Compile Include="Simai\LexicalAnalysis\TokenType.cs" />
<Compile Include="Simai\Errors\*.cs" />
<Compile Include="Simai\Structures\SlideSegment.cs" />
<Compile Include="Simai\Structures\SlideTiming.cs" />
<Compile Include="Simai\SyntacticAnalysis\Deserializer.cs" />
<Compile Include="Simai\SyntacticAnalysis\States\NoteReader.cs" />
<Compile Include="Simai\SyntacticAnalysis\States\SlideReader.cs" />
</ItemGroup>
<ItemGroup>
<Folder Include="Simai\LexicalAnalysis\" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets"/>
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
<Target Name="BeforeBuild">
</Target>
<Target Name="AfterBuild">
</Target>
-->
</Project>

View File

@ -0,0 +1,11 @@
using Manager;
namespace MoreChartFormats;
public class NotesReferences
{
public NotesReader Reader;
public NotesHeader Header;
public NotesComposition Composition;
public NotesData Notes;
}

View File

@ -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;
}
}

View File

@ -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")]

View File

@ -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;
}
}

View File

@ -0,0 +1,9 @@
namespace MoreChartFormats.Simai.Errors
{
public class InvalidSyntaxException : SimaiException
{
public InvalidSyntaxException(int line, int character) : base(line, character)
{
}
}
}

View File

@ -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
}
}
}

View File

@ -0,0 +1,19 @@
using System;
namespace MoreChartFormats.Simai.Errors
{
[Serializable]
public class SimaiException : Exception
{
public readonly int line;
public readonly int character;
/// <param name="line">The line on which the error occurred</param>
/// <param name="character">The first character involved in the error</param>
public SimaiException(int line, int character)
{
this.character = character;
this.line = line;
}
}
}

View File

@ -0,0 +1,24 @@
namespace MoreChartFormats.Simai.Errors
{
internal class UnexpectedCharacterException : SimaiException
{
public readonly string expected;
/// <summary>
/// <para>
/// This is thrown when reading a character that is not fit for the expected syntax
/// </para>
/// <para>
/// This issue is commonly caused by a typo or a syntax error.
/// </para>
///
/// </summary>
/// <param name="line">The line on which the error occurred</param>
/// <param name="character">The first character involved in the error</param>
/// <param name="expected">The expected syntax</param>
public UnexpectedCharacterException(int line, int character, string expected) : base(line, character)
{
this.expected = expected;
}
}
}

View File

@ -0,0 +1,16 @@
namespace MoreChartFormats.Simai.Errors
{
public class UnsupportedSyntaxException : SimaiException
{
/// <summary>
/// <para>
/// This is thrown when an unsupported syntax is encountered when attempting to tokenize or deserialize the simai file.
/// </para>
/// </summary>
/// <param name="line">The line on which the error occurred</param>
/// <param name="character">The first character involved in the error</param>
public UnsupportedSyntaxException(int line, int character) : base(line, character)
{
}
}
}

View File

@ -0,0 +1,9 @@
namespace MoreChartFormats.Simai.Errors
{
public class UnterminatedSectionException : SimaiException
{
public UnterminatedSectionException(int line, int character) : base(line, character)
{
}
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,46 @@
namespace MoreChartFormats.Simai.LexicalAnalysis
{
public enum TokenType
{
None,
Tempo,
Subdivision,
/// <summary>
/// <para>Touch locations (A~E + 1~8) and tap locations (1~8)</para>
/// <para>
/// 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)
/// </para>
/// </summary>
Location,
/// <summary>
/// Applies note styles and note types
/// </summary>
Decorator,
/// <summary>
/// Takes a <see cref="SlideType" /> and target vertices
/// </summary>
Slide,
/// <summary>
/// Usually denotes the length of a hold or a <see cref="SlidePath" />
/// </summary>
Duration,
/// <summary>
/// Allows multiple slides to share the same parent note
/// </summary>
SlideJoiner,
/// <summary>
/// Progresses the time by 1 beat
/// </summary>
TimeStep,
EachDivider,
EndOfFile
}
}

View File

@ -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<char> EachDividerChars = new()
{
'/', '`'
};
private static readonly HashSet<char> DecoratorChars = new()
{
'f', 'b', 'x', 'h', 'm',
'!', '?',
'@', '$'
};
private static readonly HashSet<char> SlideChars = new()
{
'-',
'>', '<', '^',
'p', 'q',
'v', 'V',
's', 'z',
'w'
};
private static readonly HashSet<char> 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<Token> 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';
}
/// <summary>
/// Returns the <see cref="_current" /> glyph, and increments by one.
/// </summary>
private char Advance()
{
return _sequence[_current++];
}
/// <summary>
/// Returns the <see cref="_current" /> glyph without incrementing.
/// </summary>
private char Peek()
{
return IsAtEnd ? default : _sequence[_current];
}
/// <summary>
/// Returns the last glyph without decrementing.
/// </summary>
private char PeekPrevious()
{
return _current == 0 ? default : _sequence[_current - 1];
}
}
}

View File

@ -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<Token> Tokenize(string value)
{
return new Tokenizer(value).GetTokens().ToList();
}
public static void Deserialize(IEnumerable<Token> tokens, NotesReferences refs)
{
new Deserializer(tokens).GetChart(refs);
}
public static void ReadBpmChanges(IEnumerable<Token> 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);
}
}
}

View File

@ -0,0 +1,9 @@
using Manager;
namespace MoreChartFormats.Simai.Structures;
public class SlideSegment
{
public NoteData NoteData;
public SlideTiming Timing;
}

View File

@ -0,0 +1,9 @@
using Manager;
namespace MoreChartFormats.Simai.Structures;
public class SlideTiming
{
public NotesTime? Delay;
public NotesTime Duration;
}

View File

@ -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<Token> sequence) : IDisposable
{
internal readonly IEnumerator<Token> TokenEnumerator = sequence.GetEnumerator();
internal BPMChangeData CurrentBpmChange;
internal NotesTime CurrentTime = new(0);
// References all notes between two TimeSteps
internal LinkedList<List<NoteData>> CurrentNoteDataCollections;
// References the current note between EACH dividers
internal List<NoteData> 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;
}
}

View File

@ -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<NotesTypeID, NotesTypeID.Def> 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<NotesTypeID, NotesTypeID.Def> 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<NotesTypeID, NotesTypeID.Def> 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<NotesTypeID, NotesTypeID.Def> 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);
}
}

View File

@ -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<SlideSegment>();
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<SlideSegment> 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<SlideSegment> 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<SlideSegment> segments)
{
var time = parent.CurrentTime;
var slideTiming = segments.Last.Value.Timing;
if (slideTiming == null)
{
throw new InvalidSyntaxException(identityToken.line, identityToken.character);
}
var slideManager = Singleton<SlideManager>.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<int> 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<int> RetrieveVertices(Deserializer parent, in Token identityToken)
{
var vertices = new List<int>();
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;
}
}

View File

@ -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();
}
}

View File

@ -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
<Notes>
<file>
<path>{filename}.sdt</path>
</file>
<!-- snip -->
</Notes>
```
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.

View File

@ -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