diff --git a/BetterGenshinImpact.CombatScript.Test/BetterGenshinImpact.CombatScript.Test.csproj b/BetterGenshinImpact.CombatScript.Test/BetterGenshinImpact.CombatScript.Test.csproj
new file mode 100644
index 00000000..91168046
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript.Test/BetterGenshinImpact.CombatScript.Test.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ latest
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BetterGenshinImpact.CombatScript.Test/ScriptEmitTest.cs b/BetterGenshinImpact.CombatScript.Test/ScriptEmitTest.cs
new file mode 100644
index 00000000..21821434
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript.Test/ScriptEmitTest.cs
@@ -0,0 +1,45 @@
+namespace BetterGenshinImpact.CombatScript.Test;
+
+[TestClass]
+public sealed class ScriptEmitTest
+{
+ [TestMethod]
+ public void TestEmit()
+ {
+ ScriptUnit scriptUnit = new(
+ [
+ new CommentSymbol("测试注释"),
+ new LineBreakTriviaSymbol(),
+ new AvatarInstructionListSymbol(new("钟离"), [new SpaceTriviaSymbol()], new(
+ [
+ new WalkSymbol(WalkDirection.Backward, [new DoubleSymbol(0.1)], [new CommaTriviaSymbol()]),
+ new SkillSymbol(true, [new HoldSymbol()], [new CommaTriviaSymbol()]),
+ new WaitSymbol([new DoubleSymbol(0.3)], [new CommaTriviaSymbol()]),
+ new WalkSymbol(WalkDirection.Forward, [new DoubleSymbol(0.1)], []),
+ ])),
+ new LineBreakTriviaSymbol(),
+ new AvatarInstructionListSymbol(new("芙宁娜"), [new SpaceTriviaSymbol()], new(
+ [
+ new SkillSymbol(true, [new CommaTriviaSymbol()]),
+ new BurstSymbol(true, [])
+ ])),
+ new LineBreakTriviaSymbol(),
+ new AvatarInstructionListSymbol(new("行秋"), [new SpaceTriviaSymbol()], new(
+ [
+ new SkillSymbol(true, [new CommaTriviaSymbol()]),
+ new BurstSymbol(true, [new CommaTriviaSymbol()]),
+ new SkillSymbol(true, []),
+ ])),
+ ]);
+
+ Console.WriteLine(scriptUnit.Emit(new DefaultSymbolEmitter()));
+ }
+
+ [TestMethod]
+ public void Test()
+ {
+ ReadOnlySpan raw = "ABCDEF;GHIJKL\r\nMNOPQR\nSTUVWX\rYZ\r\n";
+ SymbolParser parser = new();
+ ScriptUnit scriptUnit = parser.Parse(raw);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/AttackSymbol.cs b/BetterGenshinImpact.CombatScript/AttackSymbol.cs
new file mode 100644
index 00000000..115022e9
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/AttackSymbol.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class AttackSymbol : InstructionSymbol, IInstructionSymbolHasDuration
+{
+ public AttackSymbol(ImmutableArray parameterList, ImmutableArray trivia)
+ : base("attack", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [0, 1]);
+
+ if (parameterList.Length is 1)
+ {
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out DoubleSymbol doubleSymbol);
+
+ HasDuration = true;
+ Duration = TimeSpan.FromSeconds(doubleSymbol.Value);
+ }
+ }
+
+ public AttackSymbol(ImmutableArray trivia)
+ : base("attack", trivia)
+ {
+ }
+
+ public bool HasDuration { get; }
+
+ public TimeSpan Duration { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/AvatarInstructionListSymbol.cs b/BetterGenshinImpact.CombatScript/AvatarInstructionListSymbol.cs
new file mode 100644
index 00000000..a4882178
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/AvatarInstructionListSymbol.cs
@@ -0,0 +1,24 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class AvatarInstructionListSymbol : BaseSymbol
+{
+ public AvatarInstructionListSymbol(AvatarSymbol avatar, ImmutableArray triviaList, InstructionListSymbol instructionList)
+ {
+ Avatar = avatar;
+ TriviaList = triviaList;
+ InstructionList = instructionList;
+ }
+
+ public AvatarSymbol Avatar { get; }
+
+ public ImmutableArray TriviaList { get; }
+
+ public InstructionListSymbol InstructionList { get; }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(Avatar).Append(TriviaList).Append(InstructionList);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/AvatarSymbol.cs b/BetterGenshinImpact.CombatScript/AvatarSymbol.cs
new file mode 100644
index 00000000..031c3952
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/AvatarSymbol.cs
@@ -0,0 +1,16 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class AvatarSymbol : BaseSymbol
+{
+ public AvatarSymbol(string name)
+ {
+ Name = name;
+ }
+
+ public string Name { get; }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(Name);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/BaseSymbol.cs b/BetterGenshinImpact.CombatScript/BaseSymbol.cs
new file mode 100644
index 00000000..456b4cc7
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/BaseSymbol.cs
@@ -0,0 +1,6 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public abstract class BaseSymbol : ISymbol
+{
+ public abstract void Emit(ISymbolEmitter emitter);
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/BetterGenshinImpact.CombatScript.csproj b/BetterGenshinImpact.CombatScript/BetterGenshinImpact.CombatScript.csproj
new file mode 100644
index 00000000..93bf97d1
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/BetterGenshinImpact.CombatScript.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ disable
+ enable
+
+
+
diff --git a/BetterGenshinImpact.CombatScript/BurstSymbol.cs b/BetterGenshinImpact.CombatScript/BurstSymbol.cs
new file mode 100644
index 00000000..9843d244
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/BurstSymbol.cs
@@ -0,0 +1,25 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class BurstSymbol : InstructionSymbol, IInstructionSymbolHasAlias
+{
+ public BurstSymbol(bool isAlias, ImmutableArray parameterList, ImmutableArray trivia)
+ : base("burst", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [0]);
+
+ IsAlias = isAlias;
+ }
+
+ public BurstSymbol(bool isAlias, ImmutableArray trivia)
+ : base("burst", trivia)
+ {
+ IsAlias = isAlias;
+ }
+
+ public string AliasName { get; } = "q";
+
+ public bool IsAlias { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/ChargeSymbol.cs b/BetterGenshinImpact.CombatScript/ChargeSymbol.cs
new file mode 100644
index 00000000..b30611c2
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/ChargeSymbol.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class ChargeSymbol : InstructionSymbol, IInstructionSymbolHasDuration
+{
+ public ChargeSymbol(ImmutableArray parameterList, ImmutableArray trivia)
+ : base("charge", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [0, 1]);
+
+ if (parameterList.Length is 1)
+ {
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out DoubleSymbol doubleSymbol);
+
+ HasDuration = true;
+ Duration = TimeSpan.FromSeconds(doubleSymbol.Value);
+ }
+ }
+
+ public ChargeSymbol(ImmutableArray trivia)
+ : base("charge", trivia)
+ {
+ }
+
+ public bool HasDuration { get; }
+
+ public TimeSpan Duration { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/CommaTriviaSymbol.cs b/BetterGenshinImpact.CombatScript/CommaTriviaSymbol.cs
new file mode 100644
index 00000000..6ad905b4
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/CommaTriviaSymbol.cs
@@ -0,0 +1,17 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class CommaTriviaSymbol : TriviaSymbol
+{
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(',');
+ }
+}
+
+public sealed class SemicolonTriviaSymbol : TriviaSymbol
+{
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(';');
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/CommentSymbol.cs b/BetterGenshinImpact.CombatScript/CommentSymbol.cs
new file mode 100644
index 00000000..a0eada04
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/CommentSymbol.cs
@@ -0,0 +1,16 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class CommentSymbol : TriviaSymbol
+{
+ public CommentSymbol(string comment)
+ {
+ Comment = comment;
+ }
+
+ public string Comment { get; }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append("//").Append(Comment);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/DashSymbol.cs b/BetterGenshinImpact.CombatScript/DashSymbol.cs
new file mode 100644
index 00000000..c8f8549f
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/DashSymbol.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class DashSymbol : InstructionSymbol, IInstructionSymbolHasDuration
+{
+ public DashSymbol(ImmutableArray parameterList, ImmutableArray trivia)
+ : base("dash", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [0, 1]);
+
+ if (parameterList.Length is 1)
+ {
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out DoubleSymbol doubleSymbol);
+
+ HasDuration = true;
+ Duration = TimeSpan.FromSeconds(doubleSymbol.Value);
+ }
+ }
+
+ public DashSymbol(ImmutableArray trivia)
+ : base("dash", trivia)
+ {
+ }
+
+ public bool HasDuration { get; }
+
+ public TimeSpan Duration { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/DefaultSymbolEmitter.cs b/BetterGenshinImpact.CombatScript/DefaultSymbolEmitter.cs
new file mode 100644
index 00000000..03d833f1
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/DefaultSymbolEmitter.cs
@@ -0,0 +1,31 @@
+using System.Text;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class DefaultSymbolEmitter : ISymbolEmitter
+{
+ private readonly StringBuilder builder = new();
+
+ public string Emit()
+ {
+ return builder.ToString();
+ }
+
+ public ISymbolEmitter Append(char value)
+ {
+ builder.Append(value);
+ return this;
+ }
+
+ public ISymbolEmitter Append(double value)
+ {
+ builder.Append(value);
+ return this;
+ }
+
+ public ISymbolEmitter Append(string value)
+ {
+ builder.Append(value);
+ return this;
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/DoubleSymbol.cs b/BetterGenshinImpact.CombatScript/DoubleSymbol.cs
new file mode 100644
index 00000000..bf8b4376
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/DoubleSymbol.cs
@@ -0,0 +1,16 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class DoubleSymbol : BaseSymbol, IParameterSymbol
+{
+ public DoubleSymbol(double value)
+ {
+ Value = value;
+ }
+
+ public double Value { get; }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(Value);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/HoldSymbol.cs b/BetterGenshinImpact.CombatScript/HoldSymbol.cs
new file mode 100644
index 00000000..dcb4ebd6
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/HoldSymbol.cs
@@ -0,0 +1,9 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class HoldSymbol : BaseSymbol, IParameterSymbol
+{
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append("hold");
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/IInstructionSymbolHasAlias.cs b/BetterGenshinImpact.CombatScript/IInstructionSymbolHasAlias.cs
new file mode 100644
index 00000000..0e9ead45
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/IInstructionSymbolHasAlias.cs
@@ -0,0 +1,8 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public interface IInstructionSymbolHasAlias
+{
+ public string AliasName { get; }
+
+ public bool IsAlias { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/IInstructionSymbolHasDuration.cs b/BetterGenshinImpact.CombatScript/IInstructionSymbolHasDuration.cs
new file mode 100644
index 00000000..9d56114e
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/IInstructionSymbolHasDuration.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public interface IInstructionSymbolHasDuration
+{
+ public bool HasDuration { get; }
+
+ public TimeSpan Duration { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/IParameterSymbol.cs b/BetterGenshinImpact.CombatScript/IParameterSymbol.cs
new file mode 100644
index 00000000..d18f41af
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/IParameterSymbol.cs
@@ -0,0 +1,5 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public interface IParameterSymbol : ISymbol
+{
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/ISymbol.cs b/BetterGenshinImpact.CombatScript/ISymbol.cs
new file mode 100644
index 00000000..845cc45d
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/ISymbol.cs
@@ -0,0 +1,6 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public interface ISymbol
+{
+ void Emit(ISymbolEmitter emitter);
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/ISymbolEmitter.cs b/BetterGenshinImpact.CombatScript/ISymbolEmitter.cs
new file mode 100644
index 00000000..5706faa3
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/ISymbolEmitter.cs
@@ -0,0 +1,12 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public interface ISymbolEmitter
+{
+ string Emit();
+
+ ISymbolEmitter Append(char value);
+
+ ISymbolEmitter Append(double value);
+
+ ISymbolEmitter Append(string value);
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/InstructionListSymbol.cs b/BetterGenshinImpact.CombatScript/InstructionListSymbol.cs
new file mode 100644
index 00000000..cb491e05
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/InstructionListSymbol.cs
@@ -0,0 +1,18 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class InstructionListSymbol : BaseSymbol
+{
+ public InstructionListSymbol(ImmutableArray instructions)
+ {
+ Instructions = instructions;
+ }
+
+ public ImmutableArray Instructions { get; }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(Instructions);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/InstructionSymbol.cs b/BetterGenshinImpact.CombatScript/InstructionSymbol.cs
new file mode 100644
index 00000000..a6662ace
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/InstructionSymbol.cs
@@ -0,0 +1,50 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public abstract class InstructionSymbol : BaseSymbol
+{
+ protected InstructionSymbol(string name, ImmutableArray trivia)
+ {
+ Name = name;
+ HasParameterList = false;
+ TriviaList = trivia;
+ }
+
+ protected InstructionSymbol(string name, ImmutableArray parameterList, ImmutableArray trivia)
+ {
+ Name = name;
+ HasParameterList = true;
+ ParameterList = parameterList;
+ TriviaList = trivia;
+ }
+
+ public string Name { get; }
+
+ public bool HasParameterList { get; }
+
+ public ImmutableArray ParameterList { get; }
+
+ public ImmutableArray TriviaList { get; }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ if (this is IInstructionSymbolHasAlias {IsAlias: true } hasAlias)
+ {
+ emitter.Append(hasAlias.AliasName);
+ }
+ else
+ {
+ emitter.Append(Name);
+ }
+
+ if (HasParameterList)
+ {
+ emitter.Append('(');
+ emitter.Append(ParameterList);
+ emitter.Append(')');
+ }
+
+ emitter.Append(TriviaList);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/InstructionThrowHelper.cs b/BetterGenshinImpact.CombatScript/InstructionThrowHelper.cs
new file mode 100644
index 00000000..def48c1e
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/InstructionThrowHelper.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public static class InstructionThrowHelper
+{
+ public static void ThrowIfParameterListIsDefault(ImmutableArray parameterList)
+ {
+ if (parameterList.IsDefault)
+ {
+ throw new ArgumentException($"Parameter list can not be default.");
+ }
+ }
+
+ public static void ThrowIfParameterListCountNotCorrect(ImmutableArray parameterList, FrozenSet allowedCounts)
+ {
+ if (!allowedCounts.Contains(parameterList.Length))
+ {
+ throw new ArgumentException($"Instruction parameter count not correct.");
+ }
+ }
+
+ public static void ThrowIfParameterAtIndexIsNot(ImmutableArray parameterList, int index, out T symbol)
+ where T : IParameterSymbol
+ {
+ if (parameterList.Length <= index || parameterList[index] is not T result)
+ {
+ throw new ArgumentException($"Instruction's parameter at index {index} must be a {typeof(T).Name}.");
+ }
+
+ symbol = result;
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/JumpSymbol.cs b/BetterGenshinImpact.CombatScript/JumpSymbol.cs
new file mode 100644
index 00000000..cbbc3149
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/JumpSymbol.cs
@@ -0,0 +1,25 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class JumpSymbol : InstructionSymbol, IInstructionSymbolHasAlias
+{
+ public JumpSymbol(bool isAlias, ImmutableArray parameterList, ImmutableArray trivia)
+ : base("jump", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [0]);
+
+ IsAlias = isAlias;
+ }
+
+ public JumpSymbol(bool isAlias, ImmutableArray trivia)
+ : base("jump", trivia)
+ {
+ IsAlias = isAlias;
+ }
+
+ public string AliasName { get; } = "j";
+
+ public bool IsAlias { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/LineBreakTriviaSymbol.cs b/BetterGenshinImpact.CombatScript/LineBreakTriviaSymbol.cs
new file mode 100644
index 00000000..f9b200c1
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/LineBreakTriviaSymbol.cs
@@ -0,0 +1,28 @@
+using System;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class LineBreakTriviaSymbol : TriviaSymbol
+{
+ private readonly string lineBreak;
+
+ public LineBreakTriviaSymbol()
+ {
+ lineBreak = Environment.NewLine;
+ }
+
+ public LineBreakTriviaSymbol(string lineBreak)
+ {
+ if (lineBreak is not ("\r\n" or "\n" or "\r" or ";"))
+ {
+ throw new ArgumentException("Invalid line break.");
+ }
+
+ this.lineBreak = lineBreak;
+ }
+
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(lineBreak);
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/ScriptUnit.cs b/BetterGenshinImpact.CombatScript/ScriptUnit.cs
new file mode 100644
index 00000000..a1260429
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/ScriptUnit.cs
@@ -0,0 +1,23 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class ScriptUnit
+{
+ public ScriptUnit(ImmutableArray symbols)
+ {
+ Symbols = symbols;
+ }
+
+ public ImmutableArray Symbols { get; }
+
+ public string Emit(ISymbolEmitter emitter)
+ {
+ foreach (ref readonly ISymbol symbol in Symbols.AsSpan())
+ {
+ symbol.Emit(emitter);
+ }
+
+ return emitter.Emit();
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/SkillSymbol.cs b/BetterGenshinImpact.CombatScript/SkillSymbol.cs
new file mode 100644
index 00000000..d554df7a
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/SkillSymbol.cs
@@ -0,0 +1,30 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class SkillSymbol : InstructionSymbol, IInstructionSymbolHasAlias
+{
+ public SkillSymbol(bool isAlias, ImmutableArray parameterList, ImmutableArray trivia)
+ : base("skill", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [0, 1]);
+
+ if (parameterList.Length is 1)
+ {
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out HoldSymbol _);
+ }
+
+ IsAlias = isAlias;
+ }
+
+ public SkillSymbol(bool isAlias, ImmutableArray trivia)
+ : base("skill", trivia)
+ {
+ IsAlias = isAlias;
+ }
+
+ public string AliasName { get; } = "e";
+
+ public bool IsAlias { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/SpaceTriviaSymbol.cs b/BetterGenshinImpact.CombatScript/SpaceTriviaSymbol.cs
new file mode 100644
index 00000000..13e0d33b
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/SpaceTriviaSymbol.cs
@@ -0,0 +1,9 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public class SpaceTriviaSymbol : TriviaSymbol
+{
+ public override void Emit(ISymbolEmitter emitter)
+ {
+ emitter.Append(' ');
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/SymbolEmitterExtensions.cs b/BetterGenshinImpact.CombatScript/SymbolEmitterExtensions.cs
new file mode 100644
index 00000000..5c5fd043
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/SymbolEmitterExtensions.cs
@@ -0,0 +1,23 @@
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public static class SymbolEmitterExtensions
+{
+ public static ISymbolEmitter Append(this ISymbolEmitter emitter, ISymbol symbol)
+ {
+ symbol.Emit(emitter);
+ return emitter;
+ }
+
+ public static ISymbolEmitter Append(this ISymbolEmitter emitter, ImmutableArray symbolList)
+ where TSymbol : ISymbol
+ {
+ foreach(ref readonly TSymbol symbol in symbolList.AsSpan())
+ {
+ symbol.Emit(emitter);
+ }
+
+ return emitter;
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/SymbolParser.cs b/BetterGenshinImpact.CombatScript/SymbolParser.cs
new file mode 100644
index 00000000..eee39f30
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/SymbolParser.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public sealed class SymbolParser
+{
+ public ScriptUnit Parse(ReadOnlySpan raw)
+ {
+ ImmutableArray.Builder symbols = ImmutableArray.CreateBuilder();
+ ParseLines(raw, symbols);
+ return new(symbols.ToImmutable());
+ }
+
+ private void ParseLines(ReadOnlySpan raw, ImmutableArray.Builder symbols)
+ {
+ bool skipNextRange = false;
+ ref readonly char end = ref raw[^1];
+
+ foreach(Range range in raw.SplitAny(['\r', '\n', ';']))
+ {
+ if (skipNextRange)
+ {
+ skipNextRange = false;
+ continue;
+ }
+
+ int offset = range.End.GetOffset(raw.Length);
+ if (offset >= raw.Length)
+ {
+ break;
+ }
+
+ ref readonly char peek = ref raw[offset];
+
+ LineBreakTriviaSymbol lineBreakTrivia;
+ if (peek is '\r')
+ {
+ if (Unsafe.IsAddressLessThan(in peek, in end) && Unsafe.Add(ref Unsafe.AsRef(in peek), 1) is '\n')
+ {
+ // It's a CRLF
+ lineBreakTrivia = new("\r\n");
+ skipNextRange = true;
+ }
+ else
+ {
+ // It's a CR
+ lineBreakTrivia = new("\r");
+ }
+ }
+ else if (peek is '\n')
+ {
+ // It's a LF
+ lineBreakTrivia = new("\n");
+ }
+ else if (peek is ';')
+ {
+ lineBreakTrivia = new(";");
+ }
+ else
+ {
+ throw new InvalidOperationException($"Failed to parse line break trivia at {range}.");
+ }
+
+ ReadOnlySpan currentSpan = raw[range];
+
+ symbols.Add(lineBreakTrivia);
+ }
+ }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/TriviaSymbol.cs b/BetterGenshinImpact.CombatScript/TriviaSymbol.cs
new file mode 100644
index 00000000..90522adf
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/TriviaSymbol.cs
@@ -0,0 +1,5 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public abstract class TriviaSymbol : BaseSymbol
+{
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/WaitSymbol.cs b/BetterGenshinImpact.CombatScript/WaitSymbol.cs
new file mode 100644
index 00000000..40161a6a
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/WaitSymbol.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class WaitSymbol : InstructionSymbol, IInstructionSymbolHasDuration
+{
+ public WaitSymbol(ImmutableArray parameterList, ImmutableArray trivia)
+ : base("wait", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [1]);
+
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out DoubleSymbol doubleSymbol);
+
+ HasDuration = true;
+ Duration = TimeSpan.FromSeconds(doubleSymbol.Value);
+ }
+
+ public bool HasDuration { get; }
+
+ public TimeSpan Duration { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/WalkDirection.cs b/BetterGenshinImpact.CombatScript/WalkDirection.cs
new file mode 100644
index 00000000..9f2cf182
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/WalkDirection.cs
@@ -0,0 +1,9 @@
+namespace BetterGenshinImpact.CombatScript;
+
+public enum WalkDirection
+{
+ Forward,
+ Backward,
+ Left,
+ Right
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.CombatScript/WalkSymbol.cs b/BetterGenshinImpact.CombatScript/WalkSymbol.cs
new file mode 100644
index 00000000..0cde969d
--- /dev/null
+++ b/BetterGenshinImpact.CombatScript/WalkSymbol.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Immutable;
+
+namespace BetterGenshinImpact.CombatScript;
+
+public class WalkSymbol : InstructionSymbol, IInstructionSymbolHasAlias, IInstructionSymbolHasDuration, IParameterSymbol
+{
+ public WalkSymbol(WalkDirection direction, ImmutableArray parameterList, ImmutableArray trivia)
+ : base("walk", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [1]);
+
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out DoubleSymbol doubleSymbol);
+
+ HasDuration = true;
+ Duration = TimeSpan.FromSeconds(doubleSymbol.Value);
+
+ Direction = direction;
+
+ IsAlias = true;
+ }
+
+ // Used for parameter
+ public WalkSymbol(WalkDirection direction, ImmutableArray trivia)
+ : base("walk", trivia)
+ {
+ Direction = direction;
+ IsAlias = true;
+ }
+
+ public WalkSymbol(ImmutableArray parameterList, ImmutableArray trivia)
+ : base("walk", parameterList, trivia)
+ {
+ InstructionThrowHelper.ThrowIfParameterListIsDefault(parameterList);
+ InstructionThrowHelper.ThrowIfParameterListCountNotCorrect(parameterList, [2]);
+
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 0, out WalkSymbol directionAlias);
+ InstructionThrowHelper.ThrowIfParameterAtIndexIsNot(parameterList, 1, out DoubleSymbol doubleSymbol);
+
+ Direction = directionAlias.Direction;
+ HasDuration = true;
+ Duration = TimeSpan.FromSeconds(doubleSymbol.Value);
+
+ IsAlias = false;
+ }
+
+ public string AliasName
+ {
+ get
+ {
+ return Direction switch
+ {
+ WalkDirection.Forward => "w",
+ WalkDirection.Backward => "s",
+ WalkDirection.Left => "a",
+ WalkDirection.Right => "d",
+ _ => throw new ArgumentOutOfRangeException(),
+ };
+ }
+ }
+
+ public bool IsAlias { get; }
+
+ public WalkDirection Direction { get; }
+
+ public bool HasDuration { get; }
+
+ public TimeSpan Duration { get; }
+}
\ No newline at end of file
diff --git a/BetterGenshinImpact.sln b/BetterGenshinImpact.sln
index 4d1709c8..9def17ad 100644
--- a/BetterGenshinImpact.sln
+++ b/BetterGenshinImpact.sln
@@ -19,6 +19,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BetterGenshinImpact.Test",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Fischless.WindowsInput", "Fischless.WindowsInput\Fischless.WindowsInput.csproj", "{9D00BC7A-9280-4AC9-8951-4502EDB71B76}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BetterGenshinImpact.CombatScript", "BetterGenshinImpact.CombatScript\BetterGenshinImpact.CombatScript.csproj", "{57C278F9-F62A-480A-B929-B0E5D8174194}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BetterGenshinImpact.CombatScript.Test", "BetterGenshinImpact.CombatScript.Test\BetterGenshinImpact.CombatScript.Test.csproj", "{09427DA8-665E-441A-A576-C11FBC6F9E5C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +87,22 @@ Global
{9D00BC7A-9280-4AC9-8951-4502EDB71B76}.Release|Any CPU.Build.0 = Release|x64
{9D00BC7A-9280-4AC9-8951-4502EDB71B76}.Release|x64.ActiveCfg = Release|x64
{9D00BC7A-9280-4AC9-8951-4502EDB71B76}.Release|x64.Build.0 = Release|x64
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Debug|x64.Build.0 = Debug|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Release|Any CPU.Build.0 = Release|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Release|x64.ActiveCfg = Release|Any CPU
+ {57C278F9-F62A-480A-B929-B0E5D8174194}.Release|x64.Build.0 = Release|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Debug|x64.Build.0 = Debug|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Release|x64.ActiveCfg = Release|Any CPU
+ {09427DA8-665E-441A-A576-C11FBC6F9E5C}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE