using System.Globalization; using RioJoy.Core.Mapping; using RioJoy.Core.Protocol; namespace RioJoy.Core.Feedback; /// /// Parses one line of the inbound feedback protocol (docs/FEEDBACK.md) into a /// . Pure and forgiving: keywords are /// case-insensitive, malformed lines produce an error string (the caller logs /// and drops them — a bad line must never cost a client its connection). /// This is also the validation boundary for lamp addresses: /// SerialLampSink casts to byte unchecked, so out-of-range /// addresses are rejected here. /// public static class FeedbackLineParser { /// /// Parse one line. Returns with a command when the /// line is actionable. Returns with /// for blank/comment lines /// (skip silently) or an error message for malformed ones (log + drop). /// public static bool TryParse(string line, out FeedbackCommand? command, out string? error) { command = null; error = null; if (string.IsNullOrEmpty(line)) return false; string s = line.Trim(); if (s.Length == 0 || s[0] == '#' || s[0] == ';') return false; // blank or comment int pos = 0; string keyword = NextToken(s, ref pos)!; switch (keyword.ToLowerInvariant()) { case "lamp": return TryParseLamp(s, pos, all: false, out command, out error); case "lamp-all": return TryParseLamp(s, pos, all: true, out command, out error); case "plasma": return TryParsePlasma(s, pos, out command, out error); default: error = $"unknown command '{keyword}'"; return false; } } private static bool TryParseLamp( string s, int pos, bool all, out FeedbackCommand? command, out string? error) { command = null; error = null; int address = 0; if (!all) { string? addrToken = NextToken(s, ref pos); if (addrToken is null) { error = "lamp needs an address and a state"; return false; } if (!TryParseNumber(addrToken, out address)) { error = $"bad lamp address '{addrToken}'"; return false; } if (!RioAddress.IsValid(address)) { error = $"lamp address 0x{address:X2} out of range " + "(valid: 0x00-0x47, 0x50-0x5F, 0x60-0x6F)"; return false; } } string? first = NextToken(s, ref pos); if (first is null) { error = "missing lamp state"; return false; } string? second = NextToken(s, ref pos); if (NextToken(s, ref pos) is string extra) { error = $"unexpected token '{extra}'"; return false; } byte state; if (second is null) { // Single token: a raw state byte, or a brightness word (flash = solid). if (TryParseNumber(first, out int raw)) { if (raw is < 0 or > 0x3F) { error = $"raw lamp state must be 0x00-0x3F, got '{first}'"; return false; } state = (byte)raw; } else if (TryBrightness(first, out LampField1 f1, out LampField2 f2)) { state = RioLampState.Compose(LampFlash.Solid, f1, f2); } else { error = $"unrecognized lamp state '{first}'"; return false; } } else { if (!TryFlash(first, out LampFlash flash)) { error = $"unrecognized flash mode '{first}' (solid|slow|med|fast)"; return false; } if (!TryBrightness(second, out LampField1 f1, out LampField2 f2)) { error = $"unrecognized brightness '{second}' (off|dim|bright)"; return false; } state = RioLampState.Compose(flash, f1, f2); } command = new FeedbackCommand { Kind = all ? FeedbackCommandKind.LampAll : FeedbackCommandKind.Lamp, Address = address, LampState = state, }; return true; } private static bool TryParsePlasma( string s, int pos, out FeedbackCommand? command, out string? error) { command = null; error = null; string? sub = NextToken(s, ref pos); if (sub is null) { error = "plasma needs a subcommand (text|clear|row)"; return false; } switch (sub.ToLowerInvariant()) { case "clear": if (NextToken(s, ref pos) is string extra) { error = $"unexpected token '{extra}'"; return false; } command = new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }; return true; case "text": return TryParsePlasmaText(s, pos, out command, out error); case "row": return TryParsePlasmaRow(s, pos, out command, out error); default: error = $"unknown plasma subcommand '{sub}'"; return false; } } // plasma row <32 hex digits>: one full 128-px bitmap row (16 bytes, // MSB = leftmost pixel), matching the native game's whole-row streaming. private static bool TryParsePlasmaRow( string s, int pos, out FeedbackCommand? command, out string? error) { command = null; error = null; string? yToken = NextToken(s, ref pos); if (yToken is null || !TryParseNumber(yToken, out int y)) { error = "plasma row needs a row number and 32 hex digits"; return false; } if (y is < 0 or > 31) { error = $"plasma row {y} out of range (0-31)"; return false; } string? hex = NextToken(s, ref pos); if (hex is null) { error = "plasma row needs 32 hex digits of row data"; return false; } if (NextToken(s, ref pos) is string extra) { error = $"unexpected token '{extra}'"; return false; } if (hex.Length != 32) { error = $"plasma row data must be exactly 32 hex digits (16 bytes), got {hex.Length}"; return false; } var data = new byte[16]; for (int i = 0; i < 16; i++) { int hi = HexNibble(hex[i * 2]); int lo = HexNibble(hex[i * 2 + 1]); if (hi < 0 || lo < 0) { error = $"plasma row data has a non-hex character ('{hex[hi < 0 ? i * 2 : i * 2 + 1]}')"; return false; } data[i] = (byte)((hi << 4) | lo); } command = new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaRow, Y = (byte)y, Data = data, }; return true; } private static int HexNibble(char c) => c switch { >= '0' and <= '9' => c - '0', >= 'a' and <= 'f' => c - 'a' + 10, >= 'A' and <= 'F' => c - 'A' + 10, _ => -1, }; private static bool TryParsePlasmaText( string s, int pos, out FeedbackCommand? command, out string? error) { command = null; error = null; // Optional "x y" position: taken only when the first TWO tokens are both // numeric (so `plasma text 42` displays "42"; use quotes to force text). byte x = 0, y = 0; int textStart = pos; int peek = pos; string? t1 = NextToken(s, ref peek); if (t1 is not null && TryParseNumber(t1, out int xv)) { string? t2 = NextToken(s, ref peek); if (t2 is not null && TryParseNumber(t2, out int yv)) { if (xv is < 0 or > 255 || yv is < 0 or > 255) { error = $"plasma position ({xv},{yv}) out of range (0-255)"; return false; } x = (byte)xv; y = (byte)yv; textStart = peek; } // t1 numeric but t2 not: the whole remainder (from textStart) is text } if (!TryTakeText(s, textStart, out string? text, out error)) return false; if (text is null) { error = "plasma text needs text to display"; return false; } command = new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaText, Text = text, X = x, Y = y, }; return true; } // Rest-of-line text: quoted (quotes stripped, no escapes, nothing may follow // the closing quote) or the trimmed remainder. Null = nothing there. private static bool TryTakeText(string s, int pos, out string? text, out string? error) { text = null; error = null; while (pos < s.Length && char.IsWhiteSpace(s[pos])) pos++; if (pos >= s.Length) return true; if (s[pos] == '"') { int close = s.IndexOf('"', pos + 1); if (close < 0) { error = "unterminated quote in plasma text"; return false; } if (close + 1 < s.Length && s.Substring(close + 1).Trim().Length != 0) { error = "unexpected content after closing quote"; return false; } text = s.Substring(pos + 1, close - pos - 1); return true; } text = s.Substring(pos).TrimEnd(); return true; } private static string? NextToken(string s, ref int pos) { while (pos < s.Length && char.IsWhiteSpace(s[pos])) pos++; if (pos >= s.Length) return null; int start = pos; while (pos < s.Length && !char.IsWhiteSpace(s[pos])) pos++; return s[start..pos]; } // Decimal, or hex with an 0x/0X prefix. private static bool TryParseNumber(string token, out int value) { if (token.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return int.TryParse( token.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); return int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value); } private static bool TryFlash(string token, out LampFlash flash) { switch (token.ToLowerInvariant()) { case "solid": flash = LampFlash.Solid; return true; case "slow": flash = LampFlash.FlashSlow; return true; case "med": flash = LampFlash.FlashMed; return true; case "fast": flash = LampFlash.FlashFast; return true; default: flash = LampFlash.Solid; return false; } } // Brightness words set both fields, matching SolidOff/SolidDim/SolidBright. private static bool TryBrightness(string token, out LampField1 f1, out LampField2 f2) { switch (token.ToLowerInvariant()) { case "off": f1 = LampField1.Off; f2 = LampField2.Off; return true; case "dim": f1 = LampField1.Dim; f2 = LampField2.Dim; return true; case "bright": f1 = LampField1.Bright; f2 = LampField2.Bright; return true; default: f1 = LampField1.Off; f2 = LampField2.Off; return false; } } }