feat(replay): add input script format
This commit is contained in:
1 parent
13852d7b69
commit
080a5f116f
1 file changed
+297
@@ -0,0 +1,297 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Chip8.Replay;
|
||||||
|
|
||||||
|
public sealed class InputScript
|
||||||
|
{
|
||||||
|
readonly List<ScriptCommand> commandList;
|
||||||
|
|
||||||
|
InputScript(List<ScriptCommand> commands)
|
||||||
|
{
|
||||||
|
commandList = commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ScriptCommand> commands => commandList;
|
||||||
|
|
||||||
|
public int count => commandList.Count;
|
||||||
|
|
||||||
|
public static InputScript parse(string text)
|
||||||
|
{
|
||||||
|
var commands = new List<ScriptCommand>();
|
||||||
|
string[] lines = text.Split('\n');
|
||||||
|
|
||||||
|
for (int number = 1; number <= lines.Length; number++)
|
||||||
|
{
|
||||||
|
string stripped = stripComment(lines[number - 1]).Trim();
|
||||||
|
if (stripped.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScriptCommand command = parseLine(stripped, number);
|
||||||
|
command.sourceLine = number;
|
||||||
|
commands.Add(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InputScript(commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string stripComment(string line)
|
||||||
|
{
|
||||||
|
int hash = line.IndexOf('#');
|
||||||
|
int slashes = line.IndexOf("//", StringComparison.Ordinal);
|
||||||
|
int cut = -1;
|
||||||
|
if (hash >= 0)
|
||||||
|
{
|
||||||
|
cut = hash;
|
||||||
|
}
|
||||||
|
if (slashes >= 0 && (cut < 0 || slashes < cut))
|
||||||
|
{
|
||||||
|
cut = slashes;
|
||||||
|
}
|
||||||
|
return cut < 0 ? line : line.Substring(0, cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScriptCommand parseLine(string line, int number)
|
||||||
|
{
|
||||||
|
string[] head = splitWords(line);
|
||||||
|
if (head.Length > 0 && head[0].Equals("frame", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return parseFrame(line, number);
|
||||||
|
}
|
||||||
|
return parseSimple(head, number);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScriptCommand parseFrame(string line, int number)
|
||||||
|
{
|
||||||
|
int colon = line.IndexOf(':');
|
||||||
|
if (colon < 0)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "'frame' needs a ':' before the command, e.g. 'frame 10: press 5'");
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] prefix = splitWords(line.Substring(0, colon));
|
||||||
|
if (prefix.Length != 2)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "expected 'frame <n>:'");
|
||||||
|
}
|
||||||
|
|
||||||
|
int target = parseNumber(prefix[1], number);
|
||||||
|
if (target < 0)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "frame index cannot be negative");
|
||||||
|
}
|
||||||
|
|
||||||
|
string innerText = line.Substring(colon + 1).Trim();
|
||||||
|
if (innerText.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "'frame <n>:' must be followed by a command");
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] innerWords = splitWords(innerText);
|
||||||
|
if (innerWords.Length > 0 && innerWords[0].Equals("frame", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "'frame' commands cannot be nested");
|
||||||
|
}
|
||||||
|
|
||||||
|
ScriptCommand inner = parseSimple(innerWords, number);
|
||||||
|
inner.sourceLine = number;
|
||||||
|
return new FrameCommand(target, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScriptCommand parseSimple(string[] words, int number)
|
||||||
|
{
|
||||||
|
if (words.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "empty command");
|
||||||
|
}
|
||||||
|
|
||||||
|
string keyword = words[0].ToLowerInvariant();
|
||||||
|
switch (keyword)
|
||||||
|
{
|
||||||
|
case "press":
|
||||||
|
requireArity(words, 2, number, "press <key>");
|
||||||
|
return new PressCommand(parseKey(words[1], number));
|
||||||
|
|
||||||
|
case "release":
|
||||||
|
requireArity(words, 2, number, "release <key>");
|
||||||
|
return new ReleaseCommand(parseKey(words[1], number));
|
||||||
|
|
||||||
|
case "hold":
|
||||||
|
if (words.Length != 4 || !words[2].Equals("for", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "expected 'hold <key> for <frames>'");
|
||||||
|
}
|
||||||
|
return new HoldCommand(parseKey(words[1], number), parsePositive(words[3], number));
|
||||||
|
|
||||||
|
case "wait":
|
||||||
|
case "step":
|
||||||
|
requireArity(words, 2, number, keyword + " <frames>");
|
||||||
|
return new WaitCommand(parsePositive(words[1], number));
|
||||||
|
|
||||||
|
case "assert":
|
||||||
|
return parseAssert(words, number);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new ScriptParseException(number, "unknown command '" + words[0] + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScriptCommand parseAssert(string[] words, int number)
|
||||||
|
{
|
||||||
|
if (words.Length < 2)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "'assert' needs a target");
|
||||||
|
}
|
||||||
|
|
||||||
|
string target = words[1].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (target == "exited")
|
||||||
|
{
|
||||||
|
return new AssertExitedCommand(true);
|
||||||
|
}
|
||||||
|
if (target == "running")
|
||||||
|
{
|
||||||
|
return new AssertExitedCommand(false);
|
||||||
|
}
|
||||||
|
if (target == "pixel")
|
||||||
|
{
|
||||||
|
return parseAssertPixel(words, number);
|
||||||
|
}
|
||||||
|
if (target == "i")
|
||||||
|
{
|
||||||
|
var (op, value) = parseComparison(words, 2, number, 0xFFFF);
|
||||||
|
return new AssertIndexCommand(op, value);
|
||||||
|
}
|
||||||
|
if (target == "pc")
|
||||||
|
{
|
||||||
|
var (op, value) = parseComparison(words, 2, number, 0xFFFF);
|
||||||
|
return new AssertPcCommand(op, value);
|
||||||
|
}
|
||||||
|
if (target.Length == 2 && (target[0] == 'v'))
|
||||||
|
{
|
||||||
|
int register = parseNibble(target[1], number);
|
||||||
|
var (op, value) = parseComparison(words, 2, number, 0xFF);
|
||||||
|
return new AssertRegisterCommand(register, op, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ScriptParseException(number, "unknown assert target '" + words[1] + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScriptCommand parseAssertPixel(string[] words, int number)
|
||||||
|
{
|
||||||
|
if (words.Length < 4 || words.Length > 5)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "expected 'assert pixel <x> <y> [on|off]'");
|
||||||
|
}
|
||||||
|
|
||||||
|
int x = parsePositive(words[2], number);
|
||||||
|
int y = parsePositive(words[3], number);
|
||||||
|
bool expectedLit = true;
|
||||||
|
if (words.Length == 5)
|
||||||
|
{
|
||||||
|
string state = words[4].ToLowerInvariant();
|
||||||
|
if (state == "off")
|
||||||
|
{
|
||||||
|
expectedLit = false;
|
||||||
|
}
|
||||||
|
else if (state != "on")
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "pixel state must be 'on' or 'off'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AssertPixelCommand(x, y, expectedLit);
|
||||||
|
}
|
||||||
|
|
||||||
|
static (CompareOp op, int value) parseComparison(string[] words, int start, int number, int max)
|
||||||
|
{
|
||||||
|
if (words.Length != start + 2)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "expected '<target> <op> <value>' with op == or !=");
|
||||||
|
}
|
||||||
|
|
||||||
|
CompareOp op = parseOp(words[start], number);
|
||||||
|
int value = parseNumber(words[start + 1], number);
|
||||||
|
if (value < 0 || value > max)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "value " + value + " out of range 0.." + max);
|
||||||
|
}
|
||||||
|
return (op, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireArity(string[] words, int expected, int number, string usage)
|
||||||
|
{
|
||||||
|
if (words.Length != expected)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "expected '" + usage + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CompareOp parseOp(string token, int number) => token switch
|
||||||
|
{
|
||||||
|
"==" => CompareOp.Equal,
|
||||||
|
"!=" => CompareOp.NotEqual,
|
||||||
|
_ => throw new ScriptParseException(number, "unknown comparison '" + token + "', use == or !="),
|
||||||
|
};
|
||||||
|
|
||||||
|
static int parseKey(string token, int number)
|
||||||
|
{
|
||||||
|
int key = token.Length == 1 && isHexDigit(token[0])
|
||||||
|
? parseNibble(token[0], number)
|
||||||
|
: parseNumber(token, number);
|
||||||
|
if (key < 0 || key > 15)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "key " + key + " out of range 0..15");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parsePositive(string token, int number)
|
||||||
|
{
|
||||||
|
int value = parseNumber(token, number);
|
||||||
|
if (value < 0)
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "value cannot be negative");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parseNumber(string token, int number)
|
||||||
|
{
|
||||||
|
bool negative = token.StartsWith("-", StringComparison.Ordinal);
|
||||||
|
string body = negative ? token.Substring(1) : token;
|
||||||
|
int magnitude;
|
||||||
|
|
||||||
|
if (body.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(body.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out magnitude))
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "invalid hex number '" + token + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!int.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture, out magnitude))
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "invalid number '" + token + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return negative ? -magnitude : magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int parseNibble(char c, int number)
|
||||||
|
{
|
||||||
|
if (!isHexDigit(c))
|
||||||
|
{
|
||||||
|
throw new ScriptParseException(number, "'" + c + "' is not a hex digit");
|
||||||
|
}
|
||||||
|
return Convert.ToInt32(c.ToString(), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isHexDigit(char c) =>
|
||||||
|
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||||
|
|
||||||
|
static string[] splitWords(string line) =>
|
||||||
|
line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user