using System; using System.Collections.Concurrent; using System.Globalization; using System.IO; using System.Net.Sockets; using System.Reflection; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Windows.Forms; using System.Xml; using LiveSplit.Model; using LiveSplit.UI; using LiveSplit.UI.Components; [assembly: ComponentFactory(typeof(LiveSplit.PBMetrObs.Factory))] namespace LiveSplit.PBMetrObs { internal static class Log { private static readonly object _gate = new object(); private static string _path; private static bool _banner; static Log() { try { string docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); if (!string.IsNullOrEmpty(docs)) _path = System.IO.Path.Combine(docs, "PB-metr", "pbmetr.log"); } catch { _path = null; } } public static void W(string cat, string msg) { if (string.IsNullOrEmpty(_path)) return; try { lock (_gate) { string dir = System.IO.Path.GetDirectoryName(_path); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); using (var sw = new StreamWriter(_path, true, Encoding.UTF8)) { if (!_banner) { sw.WriteLine(); sw.WriteLine("=== PB-metr OBS session " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " ==="); _banner = true; } sw.WriteLine("[" + DateTime.Now.ToString("HH:mm:ss.fff") + "] [" + (cat ?? "INFO") + "] " + (msg ?? "")); } } } catch { } } } public class Factory : IComponentFactory { public string ComponentName { get { return "PB-metr OBS"; } } public string Description { get { return "Отправляет цвет сплитов в OBS для спидометра PB-metr."; } } public ComponentCategory Category { get { return ComponentCategory.Other; } } public IComponent Create(LiveSplitState state) { return new PBMetrComponent(state); } public string UpdateName { get { return ComponentName; } } public string XMLURL { get { return ""; } } public string UpdateURL { get { return ""; } } public Version Version { get { return Version.Parse("1.0.0"); } } } public class IniConfig { public string Host = "127.0.0.1"; public int Port = 4455; public string Password = ""; public string Comparison = "auto"; public string Theme = "neon"; public double Idle = 16, Green = 6, Gold = 12, Red = 8, Combo = 1.5, Kick = 1.0; public static IniConfig Load() { IniConfig cfg = new IniConfig(); try { string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); string path = Path.Combine(dir, "pbmetr-obs.ini"); if (!File.Exists(path)) return cfg; CultureInfo ci = CultureInfo.InvariantCulture; string[] lines = File.ReadAllLines(path); for (int li = 0; li < lines.Length; li++) { string line = lines[li].Trim(); if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; int i = line.IndexOf('='); if (i < 0) continue; string key = line.Substring(0, i).Trim().ToLowerInvariant(); string val = line.Substring(i + 1).Trim(); int hash = val.IndexOf('#'); if (hash >= 0) val = val.Substring(0, hash).Trim(); if (key == "host") cfg.Host = val; else if (key == "port") int.TryParse(val, out cfg.Port); else if (key == "password") cfg.Password = val; else if (key == "comparison") cfg.Comparison = val.Length == 0 ? "auto" : val; else if (key == "theme") { if (val.Length > 0) cfg.Theme = val.ToLowerInvariant(); } else if (key == "idle") double.TryParse(val, NumberStyles.Any, ci, out cfg.Idle); else if (key == "green") double.TryParse(val, NumberStyles.Any, ci, out cfg.Green); else if (key == "gold") double.TryParse(val, NumberStyles.Any, ci, out cfg.Gold); else if (key == "red") double.TryParse(val, NumberStyles.Any, ci, out cfg.Red); else if (key == "combo") double.TryParse(val, NumberStyles.Any, ci, out cfg.Combo); else if (key == "kick") double.TryParse(val, NumberStyles.Any, ci, out cfg.Kick); } } catch { } return cfg; } } public class ObsClient : IDisposable { private readonly string _host; private readonly int _port; private readonly string _password; private readonly ConcurrentQueue _outbox = new ConcurrentQueue(); private readonly Random _rng = new Random(); private volatile bool _stop; private volatile bool _identified; private Thread _worker; public Action OnIdentified; public ObsClient(string host, int port, string password) { _host = host; _port = port; _password = password == null ? "" : password; _worker = new Thread(new ThreadStart(Run)); _worker.IsBackground = true; _worker.Start(); } public void Broadcast(string payloadJson) { string msg = "{\"op\":6,\"d\":{\"requestType\":\"BroadcastCustomEvent\",\"requestId\":\"pb\",\"requestData\":{\"eventData\":{\"realm\":\"pbmetr\",\"payload\":" + payloadJson + "}}}}"; _outbox.Enqueue(msg); string junk; while (_outbox.Count > 50) _outbox.TryDequeue(out junk); } private static string Sha256B64(string s) { using (SHA256 sha = SHA256.Create()) return Convert.ToBase64String(sha.ComputeHash(Encoding.UTF8.GetBytes(s))); } private void Run() { while (!_stop) { TcpClient tcp = null; try { tcp = new TcpClient(); Log.W("OBS", "connecting " + _host + ":" + _port); tcp.Connect(_host, _port); NetworkStream stream = tcp.GetStream(); string key = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); string req = "GET / HTTP/1.1\r\nHost: " + _host + ":" + _port + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: " + key + "\r\nSec-WebSocket-Version: 13\r\n\r\n"; byte[] rb = Encoding.ASCII.GetBytes(req); stream.Write(rb, 0, rb.Length); stream.Flush(); StringBuilder hsb = new StringBuilder(); int ch; while ((ch = stream.ReadByte()) >= 0) { hsb.Append((char)ch); if (hsb.Length >= 4 && hsb.ToString(hsb.Length - 4, 4) == "\r\n\r\n") break; } if (hsb.ToString().IndexOf(" 101") < 0) throw new Exception("no 101"); Log.W("OBS", "ws handshake 101 ok"); string hello = RecvMsg(stream); if (hello != null) Log.W("OBS", "hello: " + (hello.Length > 220 ? hello.Substring(0, 220) : hello)); Match ms = Regex.Match(hello == null ? "" : hello, "\"salt\"\\s*:\\s*\"([^\"]*)\""); Match mc = Regex.Match(hello == null ? "" : hello, "\"challenge\"\\s*:\\s*\"([^\"]*)\""); string identify; if (ms.Success && mc.Success) { string secret = Sha256B64(_password + ms.Groups[1].Value); string auth = Sha256B64(secret + mc.Groups[1].Value); identify = "{\"op\":1,\"d\":{\"rpcVersion\":1,\"eventSubscriptions\":0,\"authentication\":\"" + auth + "\"}}"; Log.W("OBS", "auth REQUIRED; identify WITH auth; password=" + (string.IsNullOrEmpty(_password) ? "EMPTY" : ("set(len=" + _password.Length + ")"))); } else { identify = "{\"op\":1,\"d\":{\"rpcVersion\":1,\"eventSubscriptions\":0}}"; Log.W("OBS", "no auth required; identify WITHOUT auth"); } SendFrame(stream, identify); while (!_stop) { string mm = RecvMsg(stream); if (mm == null) throw new Exception("closed after identify (Identified not received) - likely auth rejected"); Match om = Regex.Match(mm, "\"op\"\\s*:\\s*(\\d+)"); Log.W("OBS", "recv op=" + (om.Success ? om.Groups[1].Value : "?") + " : " + (mm.Length > 180 ? mm.Substring(0, 180) : mm)); if (om.Success && om.Groups[1].Value == "2") break; } _identified = true; Log.W("OBS", "identified (ready to send)"); Action h = OnIdentified; if (h != null) { try { h(); } catch { } } while (!_stop) { string msg; while (_outbox.TryDequeue(out msg)) SendFrame(stream, msg); if (stream.DataAvailable) { string im = RecvMsg(stream); if (im == null) throw new Exception("closed"); } Thread.Sleep(40); } } catch (Exception ex) { Log.W("OBS", "connection error: " + ex.Message); } _identified = false; try { if (tcp != null) tcp.Close(); } catch { } if (!_stop) Thread.Sleep(2000); } } private byte[] ReadBytes(Stream stream, int n) { byte[] b = new byte[n]; int off = 0; while (off < n) { int r = stream.Read(b, off, n - off); if (r <= 0) return null; off += r; } return b; } private void SendFrame(Stream stream, string text) { byte[] p = Encoding.UTF8.GetBytes(text); int len = p.Length; MemoryStream ms = new MemoryStream(); ms.WriteByte(0x81); if (len < 126) ms.WriteByte((byte)(0x80 | len)); else if (len < 65536) { ms.WriteByte((byte)(0x80 | 126)); ms.WriteByte((byte)((len >> 8) & 0xff)); ms.WriteByte((byte)(len & 0xff)); } else { ms.WriteByte((byte)(0x80 | 127)); for (int i = 7; i >= 0; i--) ms.WriteByte((byte)((len >> (8 * i)) & 0xff)); } byte[] mask = new byte[4]; _rng.NextBytes(mask); ms.Write(mask, 0, 4); byte[] masked = new byte[len]; for (int i = 0; i < len; i++) masked[i] = (byte)(p[i] ^ mask[i & 3]); ms.Write(masked, 0, len); byte[] arr = ms.ToArray(); stream.Write(arr, 0, arr.Length); stream.Flush(); } private string RecvMsg(Stream stream) { while (true) { int b0 = stream.ReadByte(); if (b0 < 0) return null; int b1 = stream.ReadByte(); if (b1 < 0) return null; int opcode = b0 & 0x0f; long len = b1 & 0x7f; if (len == 126) { byte[] e = ReadBytes(stream, 2); if (e == null) return null; len = (e[0] << 8) | e[1]; } else if (len == 127) { byte[] e = ReadBytes(stream, 8); if (e == null) return null; len = 0; for (int i = 0; i < 8; i++) len = (len << 8) | e[i]; } bool masked = (b1 & 0x80) != 0; byte[] mask = null; if (masked) { mask = ReadBytes(stream, 4); if (mask == null) return null; } byte[] payload = len > 0 ? ReadBytes(stream, (int)len) : new byte[0]; if (payload == null) return null; if (masked) for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(payload[i] ^ mask[i & 3]); if (opcode == 0x8) return null; if (opcode == 0x9) { MemoryStream ms = new MemoryStream(); ms.WriteByte(0x8A); int l = payload.Length; if (l < 126) ms.WriteByte((byte)(0x80 | l)); else { ms.WriteByte((byte)(0x80 | 126)); ms.WriteByte((byte)((l >> 8) & 0xff)); ms.WriteByte((byte)(l & 0xff)); } byte[] mk = new byte[4]; _rng.NextBytes(mk); ms.Write(mk, 0, 4); byte[] mp = new byte[l]; for (int i = 0; i < l; i++) mp[i] = (byte)(payload[i] ^ mk[i & 3]); ms.Write(mp, 0, l); byte[] arr = ms.ToArray(); stream.Write(arr, 0, arr.Length); stream.Flush(); continue; } if (opcode == 0x1) return Encoding.UTF8.GetString(payload); } } public void Dispose() { _stop = true; } } public class PBMetrComponent : LogicComponent { private readonly ObsClient _obs; private readonly LiveSplitState _state; private volatile IniConfig _cfg; private readonly System.Threading.Timer _cfgTimer; public override string ComponentName { get { return "PB-metr OBS"; } } public PBMetrComponent(LiveSplitState state) { _state = state; _cfg = IniConfig.Load(); Log.W("INIT", "component constructed; obs=" + _cfg.Host + ":" + _cfg.Port + " comparison=" + _cfg.Comparison + " theme=" + _cfg.Theme); _obs = new ObsClient(_cfg.Host, _cfg.Port, _cfg.Password); _obs.OnIdentified = new Action(BroadcastConfig); _state.OnStart += OnStart; _state.OnSplit += OnSplit; _state.OnSkipSplit += OnSkipSplit; _state.OnReset += OnReset; _cfgTimer = new System.Threading.Timer(new TimerCallback(CfgTick), null, 1000, 2000); } private void CfgTick(object o) { try { _cfg = IniConfig.Load(); BroadcastConfig(); } catch { } } private static string Fmt(double d) { return d.ToString("0.###", CultureInfo.InvariantCulture); } private static string Esc(string s) { if (s == null) return ""; return s.Replace("\\", "\\\\").Replace("\"", "\\\""); } private void BroadcastConfig() { IniConfig c = _cfg; string p = "{\"type\":\"config\",\"theme\":\"" + Esc(c.Theme) + "\",\"idle\":" + Fmt(c.Idle) + ",\"green\":" + Fmt(c.Green) + ",\"gold\":" + Fmt(c.Gold) + ",\"red\":" + Fmt(c.Red) + ",\"combo\":" + Fmt(c.Combo) + ",\"kick\":" + Fmt(c.Kick) + "}"; _obs.Broadcast(p); } public override void Update(IInvalidator invalidator, LiveSplitState state, float width, float height, LayoutMode mode) { } private void OnStart(object sender, EventArgs e) { Log.W("EVENT", "OnStart -> reset"); try { _obs.Broadcast("{\"type\":\"reset\"}"); } catch { } } private void OnReset(object sender, TimerPhase e) { Log.W("EVENT", "OnReset -> reset"); try { _obs.Broadcast("{\"type\":\"reset\"}"); } catch { } } private void OnSplit(object sender, EventArgs e) { try { int idx = _state.CurrentSplitIndex; int completed = idx - 1; Log.W("EVENT", "OnSplit idx=" + idx + " completed=" + completed + " runCount=" + _state.Run.Count); if (completed >= 0 && completed < _state.Run.Count) SendSplit(_state, completed); if (idx >= _state.Run.Count) SendFinish(_state); } catch (Exception ex) { Log.W("ERR", "OnSplit: " + ex.Message); } } private void OnSkipSplit(object sender, EventArgs e) { try { if (_state.CurrentSplitIndex >= _state.Run.Count) SendFinish(_state); } catch { } } private void SendSplit(LiveSplitState state, int c) { try { TimingMethod method = state.CurrentTimingMethod; string cmp = _cfg.Comparison; string comparison = (cmp == "auto" || string.IsNullOrEmpty(cmp)) ? state.CurrentComparison : cmp; ISegment seg = state.Run[c]; TimeSpan? split = seg.SplitTime[method]; TimeSpan? prev = c > 0 ? state.Run[c - 1].SplitTime[method] : (TimeSpan?)TimeSpan.Zero; TimeSpan? best = seg.BestSegmentTime[method]; TimeSpan? pbCum = seg.Comparisons[comparison][method]; double runDelta = (split.HasValue && pbCum.HasValue) ? (split.Value - pbCum.Value).TotalSeconds : 0.0; TimeSpan? segTime = split.HasValue ? (TimeSpan?)(split.Value - (prev.HasValue ? prev.Value : TimeSpan.Zero)) : (TimeSpan?)null; bool gold = segTime.HasValue && best.HasValue && segTime.Value < best.Value; double goldMag = gold ? Math.Abs((best.Value - segTime.Value).TotalSeconds) : 0.0; string color = gold ? "gold" : (runDelta <= 0 ? "green" : "red"); double mag = gold ? goldMag : Math.Abs(runDelta); string name = Esc(seg.Name); Log.W("SPLIT", color + " runDelta=" + Fmt(runDelta) + " goldMag=" + Fmt(goldMag) + " name=" + name); string p = "{\"type\":\"split\",\"color\":\"" + color + "\",\"segmentDeltaSec\":" + Fmt(mag) + ",\"runDeltaSec\":" + Fmt(runDelta) + ",\"splitIndex\":" + c + ",\"totalSplits\":" + state.Run.Count + ",\"splitName\":\"" + name + "\"}"; _obs.Broadcast(p); } catch { } } private void SendFinish(LiveSplitState state) { try { TimingMethod method = state.CurrentTimingMethod; int last = state.Run.Count - 1; TimeSpan? pb = state.Run[last].PersonalBestSplitTime[method]; TimeSpan? final = state.Run[last].SplitTime[method]; if (!final.HasValue) final = state.CurrentTime[method]; bool isPB = !pb.HasValue || (final.HasValue && final.Value < pb.Value); Log.W("FINISH", (isPB ? "PB!" : "finish") + " final=" + (final.HasValue?Fmt(final.Value.TotalSeconds):"?") + " pb=" + (pb.HasValue?Fmt(pb.Value.TotalSeconds):"?")); _obs.Broadcast(isPB ? "{\"type\":\"pb\",\"splitName\":\"PB!\"}" : "{\"type\":\"finish\"}"); } catch { } } public override void Dispose() { try { _state.OnStart -= OnStart; _state.OnSplit -= OnSplit; _state.OnSkipSplit -= OnSkipSplit; _state.OnReset -= OnReset; } catch { } try { if (_cfgTimer != null) _cfgTimer.Dispose(); } catch { } if (_obs != null) _obs.Dispose(); } public override XmlNode GetSettings(XmlDocument document) { return document.CreateElement("Settings"); } public override Control GetSettingsControl(LayoutMode mode) { return null; } public override void SetSettings(XmlNode settings) { } } }