using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; using System.Threading; using BepInEx; using BepInEx.Logging; using HarmonyLib; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: AssemblyTitle("NeoZancleBridge_v.4.0")] [assembly: AssemblyDescription("")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("NeoZancleBridge_v.4.0")] [assembly: AssemblyCopyright("Copyright © 2026")] [assembly: AssemblyTrademark("")] [assembly: ComVisible(false)] [assembly: Guid("97140b1c-99af-443e-9648-3f7743b91bcc")] [assembly: AssemblyFileVersion("0.9.0.0")] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: AssemblyVersion("0.9.0.0")] [BepInPlugin("com.neozancle.bridge", "NeoZancleBridge", "0.9.0")] public class NeoZancleBridgePlugin : BaseUnityPlugin { internal static ManualLogSource Log; internal static string LatestJson = "{\"type\":\"none\",\"message\":\"NeoZancleBridge online, no roll yet\"}"; internal static string CityStateJson = "{\"type\":\"falcenet_city_state\",\"status\":\"empty\"}"; internal static string NetRequestJson = "{\"type\":\"falcenet_net_request\",\"status\":\"empty\"}"; internal static string EventBusJson = "{\"type\":\"falcenet_event\",\"status\":\"empty\"}"; internal static string TestJson = "{\"type\":\"falcenet_test\",\"status\":\"empty\"}"; internal static string CampaignJson = "{\"type\":\"falcenet_campaign\",\"status\":\"empty\"}"; internal static Dictionary SessionClients = new Dictionary(); internal static object BridgeLock = new object(); internal static bool IsLocalGmMode = false; private Harmony harmony; private HttpListener listener; private Thread listenerThread; private volatile bool serverRunning; private string falceNetRoot; private void Awake() { Log = ((BaseUnityPlugin)this).Logger; ((BaseUnityPlugin)this).Logger.LogWarning((object)"### NeoZancleBridge v0.9.0-protocol-core ###"); ResolveFalceNetRoot(); StartLocalServer(); PatchTaleSpireDiceResults(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"NZB: pronto. FalceNet principale = Symbiote, bridge = risultati/log."); } private void OnDestroy() { try { if (harmony != null) { harmony.UnpatchSelf(); } } catch { } StopLocalServer(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"NeoZancleBridge OFFLINE"); } private void PatchTaleSpireDiceResults() { //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_0010: Expected O, but got Unknown //IL_007d: Unknown result type (might be due to invalid IL or missing references) //IL_008a: Expected O, but got Unknown harmony = new Harmony("com.neozancle.bridge"); Type type = AccessTools.TypeByName("Dice.DiceRollManager"); if (type == null) { ((BaseUnityPlugin)this).Logger.LogError((object)"NZB: Dice.DiceRollManager non trovato"); return; } MethodInfo methodInfo = AccessTools.Method(type, "OnResults", (Type[])null, (Type[])null); if (methodInfo == null) { ((BaseUnityPlugin)this).Logger.LogError((object)"NZB: Dice.DiceRollManager.OnResults non trovato"); return; } MethodInfo methodInfo2 = AccessTools.Method(typeof(DiceResultsPatch), "Postfix", (Type[])null, (Type[])null); harmony.Patch((MethodBase)methodInfo, (HarmonyMethod)null, new HarmonyMethod(methodInfo2), (HarmonyMethod)null, (HarmonyMethod)null, (HarmonyMethod)null); ((BaseUnityPlugin)this).Logger.LogInfo((object)"NZB: Dice results postfix attiva v0.9.0-protocol-core"); } private void OnUnityLogMessage(string condition, string stackTrace, LogType type) { try { if (!string.IsNullOrEmpty(condition) && condition.Contains("SetPanelOffsetBasedOnGMMode")) { if (condition.Contains("-115 to -190")) { IsLocalGmMode = true; ((BaseUnityPlugin)this).Logger.LogInfo((object)"NZB_TALESPIRE_UI_MODE observed = GM (non-authoritative)"); } else if (condition.Contains("-190 to -115")) { IsLocalGmMode = false; ((BaseUnityPlugin)this).Logger.LogInfo((object)"NZB_TALESPIRE_UI_MODE observed = PLAYER (non-authoritative)"); } } } catch { } } private void StartLocalServer() { try { listener = new HttpListener(); listener.Prefixes.Add("http://localhost:8765/"); listener.Start(); serverRunning = true; listenerThread = new Thread(ServerLoop); listenerThread.IsBackground = true; listenerThread.Start(); ((BaseUnityPlugin)this).Logger.LogInfo((object)"NZB: HTTP server started on localhost:8765"); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("NZB: HTTP server failed: " + ex.Message)); } } private void StopLocalServer() { serverRunning = false; try { if (listener != null) { listener.Stop(); listener.Close(); } } catch { } } private void ServerLoop() { while (serverRunning) { try { HttpListenerContext context = listener.GetContext(); HandleRequest(context); } catch { if (!serverRunning) { break; } } } } private void HandleRequest(HttpListenerContext ctx) { string absolutePath = ctx.Request.Url.AbsolutePath; if (ctx.Request.HttpMethod.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { WriteOptions(ctx); } else if (absolutePath.Equals("/health", StringComparison.OrdinalIgnoreCase) || absolutePath.Equals("/status", StringComparison.OrdinalIgnoreCase)) { WriteJson(ctx, "{\"status\":\"online\",\"bridge\":\"NeoZancleBridge\",\"version\":\"0.9.0\",\"localMode\":\"UNKNOWN\",\"roleAuthoritative\":false,\"rollHook\":\"native_talespire_onresults\"}"); } else if (absolutePath.Equals("/latest", StringComparison.OrdinalIgnoreCase)) { WriteJson(ctx, LatestJson); } else if (absolutePath.Equals("/falcenet-state", StringComparison.OrdinalIgnoreCase)) { HandleSimpleMailbox(ctx, ref CityStateJson, "falcenet_city_state"); } else if (absolutePath.Equals("/falcenet-net-request", StringComparison.OrdinalIgnoreCase)) { HandleSimpleMailbox(ctx, ref NetRequestJson, "falcenet_net_request"); } else if (absolutePath.Equals("/falcenet-event", StringComparison.OrdinalIgnoreCase)) { HandleSimpleMailbox(ctx, ref EventBusJson, "falcenet_event"); } else if (absolutePath.Equals("/falcenet-test", StringComparison.OrdinalIgnoreCase)) { HandleSimpleMailbox(ctx, ref TestJson, "falcenet_test"); } else if (absolutePath.Equals("/falcenet-campaign", StringComparison.OrdinalIgnoreCase)) { HandleSimpleMailbox(ctx, ref CampaignJson, "falcenet_campaign"); } else if (absolutePath.Equals("/falcenet-session", StringComparison.OrdinalIgnoreCase)) { HandleSessionRegistry(ctx); } else if (absolutePath.Equals("/dice", StringComparison.OrdinalIgnoreCase) || absolutePath.Equals("/falcenet-roll", StringComparison.OrdinalIgnoreCase)) { HandleDiceDebugRequest(ctx); } else if (absolutePath.StartsWith("/falcenet/", StringComparison.OrdinalIgnoreCase)) { ServeFalceNet(ctx, absolutePath.Substring("/falcenet/".Length)); } else { WriteJson(ctx, "{\"status\":\"unknown\",\"use\":\"/health, /status, /latest, /falcenet-test, /falcenet-session, /falcenet-state, /falcenet-net-request, /falcenet-event, /falcenet-campaign, /falcenet/\"}"); } } private void HandleSimpleMailbox(HttpListenerContext ctx, ref string box, string typeName) { try { if (ctx.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase)) { string text = ReadRequestBody(ctx); if (!string.IsNullOrEmpty(text)) { lock (BridgeLock) { box = text; } ((BaseUnityPlugin)this).Logger.LogInfo((object)("NZB_MAILBOX_RX " + typeName + " " + text.Length + " bytes")); } WriteJson(ctx, "{\"status\":\"ok\",\"type\":\"" + typeName + "\"}"); return; } lock (BridgeLock) { WriteJson(ctx, box); } } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("NZB mailbox error " + typeName + ": " + ex.Message)); WriteJson(ctx, "{\"status\":\"error\",\"type\":\"" + typeName + "\"}"); } } private void HandleSessionRegistry(HttpListenerContext ctx) { try { if (ctx.Request.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase)) { string text = ReadRequestBody(ctx); string text2 = ExtractJsonObjectAfterKey(text, "client"); if (string.IsNullOrEmpty(text2)) { text2 = text; } string text3 = ExtractJsonString(text2, "characterId"); if (string.IsNullOrEmpty(text3)) { text3 = ExtractJsonString(text2, "clientId"); } if (string.IsNullOrEmpty(text3)) { text3 = "client_" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); } lock (BridgeLock) { SessionClients[text3] = text2; } ((BaseUnityPlugin)this).Logger.LogInfo((object)("NZB_SESSION_RX key=" + text3)); WriteJson(ctx, "{\"status\":\"ok\",\"type\":\"falcenet_session_presence\",\"key\":\"" + EscapeLocal(text3) + "\"}"); } else { List list; lock (BridgeLock) { list = SessionClients.Values.ToList(); } string json = "{\"type\":\"falcenet_session_registry\",\"clients\":[" + string.Join(",", list.ToArray()) + "]}"; WriteJson(ctx, json); } } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("NZB session error: " + ex.Message)); WriteJson(ctx, "{\"status\":\"error\",\"type\":\"falcenet_session_registry\",\"clients\":[]}"); } } private string ReadRequestBody(HttpListenerContext ctx) { using StreamReader streamReader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding); return streamReader.ReadToEnd(); } private string ExtractJsonObjectAfterKey(string json, string key) { if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key)) { return ""; } string text = "\"" + key + "\""; int num = json.IndexOf(text, StringComparison.OrdinalIgnoreCase); if (num < 0) { return ""; } int num2 = json.IndexOf(':', num + text.Length); if (num2 < 0) { return ""; } int num3 = json.IndexOf('{', num2); if (num3 < 0) { return ""; } int num4 = 0; bool flag = false; bool flag2 = false; for (int i = num3; i < json.Length; i++) { char c = json[i]; if (flag2) { flag2 = false; continue; } switch (c) { case '\\': flag2 = true; continue; case '"': flag = !flag; continue; } if (flag) { continue; } switch (c) { case '{': num4++; break; case '}': num4--; if (num4 == 0) { return json.Substring(num3, i - num3 + 1); } break; } } return ""; } private string ExtractJsonString(string json, string key) { if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key)) { return ""; } string text = "\"" + key + "\""; int num = json.IndexOf(text, StringComparison.OrdinalIgnoreCase); if (num < 0) { return ""; } int num2 = json.IndexOf(':', num + text.Length); if (num2 < 0) { return ""; } int num3 = json.IndexOf('"', num2 + 1); if (num3 < 0) { return ""; } int i = num3 + 1; bool flag = false; for (; i < json.Length; i++) { char c = json[i]; if (flag) { flag = false; continue; } switch (c) { case '\\': flag = true; continue; default: continue; case '"': break; } break; } if (i >= json.Length) { return ""; } return json.Substring(num3 + 1, i - num3 - 1); } private string EscapeLocal(string s) { if (s == null) { return ""; } return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", " ") .Replace("\n", " "); } private void HandleDiceDebugRequest(HttpListenerContext ctx) { string text = ""; try { using (StreamReader streamReader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding)) { text = streamReader.ReadToEnd(); } ((BaseUnityPlugin)this).Logger.LogWarning((object)"NZB DICE DEBUG ENDPOINT HIT"); ((BaseUnityPlugin)this).Logger.LogWarning((object)("NZB DICE BODY: " + text)); ((BaseUnityPlugin)this).Logger.LogWarning((object)"NZB: /dice è solo debug. Il tray reale deve passare dalla Symbiote JS."); WriteJson(ctx, "{\"status\":\"dice_debug_only\",\"message\":\"Use FalceNet as TaleSpire Symbiote for native tray\"}"); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("NZB /dice debug error: " + ex)); WriteJson(ctx, "{\"status\":\"dice_error\"}"); } } private void ResolveFalceNetRoot() { string directoryName = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); falceNetRoot = Path.Combine(directoryName, "FalceNet"); if (!Directory.Exists(falceNetRoot)) { falceNetRoot = Path.Combine(Paths.PluginPath, "NeoZancleBridge", "FalceNet"); } } private void ServeFalceNet(HttpListenerContext ctx, string relativePath) { try { if (string.IsNullOrEmpty(relativePath)) { relativePath = "index.html"; } relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); string fullPath = Path.GetFullPath(Path.Combine(falceNetRoot, relativePath)); string fullPath2 = Path.GetFullPath(falceNetRoot); if (!fullPath.StartsWith(fullPath2)) { ctx.Response.StatusCode = 403; ctx.Response.Close(); return; } if (!File.Exists(fullPath)) { ctx.Response.StatusCode = 404; ctx.Response.Close(); return; } byte[] array = File.ReadAllBytes(fullPath); ctx.Response.ContentType = GuessContentType(fullPath); ctx.Response.ContentLength64 = array.Length; AddCorsHeaders(ctx); ctx.Response.OutputStream.Write(array, 0, array.Length); ctx.Response.OutputStream.Close(); } catch (Exception ex) { Log.LogError((object)("NZB SERVE ERROR: " + ex)); } } private void AddCorsHeaders(HttpListenerContext ctx) { try { ctx.Response.AddHeader("Access-Control-Allow-Origin", "*"); } catch { } try { ctx.Response.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); } catch { } try { ctx.Response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); } catch { } try { ctx.Response.AddHeader("Access-Control-Max-Age", "86400"); } catch { } } private void WriteOptions(HttpListenerContext ctx) { AddCorsHeaders(ctx); ctx.Response.StatusCode = 204; ctx.Response.Close(); } private void WriteJson(HttpListenerContext ctx, string json) { byte[] bytes = Encoding.UTF8.GetBytes(json); ctx.Response.ContentType = "application/json"; ctx.Response.ContentEncoding = Encoding.UTF8; ctx.Response.ContentLength64 = bytes.Length; AddCorsHeaders(ctx); ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } private string GuessContentType(string path) { switch (Path.GetExtension(path).ToLowerInvariant()) { case ".html": case ".htm": return "text/html; charset=utf-8"; case ".js": return "application/javascript; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".json": return "application/json; charset=utf-8"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".gif": return "image/gif"; case ".svg": return "image/svg+xml"; case ".webp": return "image/webp"; case ".mp3": return "audio/mpeg"; case ".ogg": return "audio/ogg"; case ".wav": return "audio/wav"; default: return "application/octet-stream"; } } } public static class DiceResultsPatch { public static void Postfix(object clientId, object rollResults, bool isGmRoll, bool showResult, object resultsOrigin, object optionalSymbioteInteropId) { try { if (rollResults == null) { NeoZancleBridgePlugin.Log.LogInfo((object)"NZB_ROLL: rollResults null"); return; } RollExtract rollExtract = RollParser.ExtractRoll(rollResults); bool flag = isGmRoll; bool flag2 = rollExtract.Values.Contains(10); bool flag3 = rollExtract.Values.Contains(1); NeoZancleBridgePlugin.LatestJson = "{\"type\":\"dice_roll\",\"source\":\"NeoZancleBridge\",\"version\":\"0.9.0\",\"rollId\":\"" + Escape(rollExtract.RollId) + "\",\"client\":\"" + Escape((clientId == null) ? "" : clientId.ToString()) + "\",\"isGmRoll\":" + (flag ? "true" : "false") + ",\"localGmMode\":" + (NeoZancleBridgePlugin.IsLocalGmMode ? "true" : "false") + ",\"showResult\":" + (showResult ? "true" : "false") + ",\"origin\":\"" + Escape((resultsOrigin == null) ? "" : resultsOrigin.ToString()) + "\",\"total\":" + rollExtract.Total + ",\"values\":[" + string.Join(",", rollExtract.Values.ToArray()) + "],\"crit\":" + (flag2 ? "true" : "false") + ",\"fumble\":" + (flag3 ? "true" : "false") + ",\"raw\":\"" + Escape(rollResults.ToString()) + "\"}"; NeoZancleBridgePlugin.Log.LogInfo((object)("NZB_ROLL_JSON rollId=" + rollExtract.RollId + " total=" + rollExtract.Total + " values=[" + string.Join(",", rollExtract.Values.ToArray()) + "] gm=" + flag + " crit=" + flag2 + " fumble=" + flag3)); } catch (Exception ex) { NeoZancleBridgePlugin.Log.LogError((object)("NZB DICE ERROR: " + ex)); } } private static string Escape(string s) { if (s == null) { return ""; } return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", " ") .Replace("\n", " "); } } public class RollExtract { public string RollId = ""; public int Total; public List Values = new List(); } public static class RollParser { public static RollExtract ExtractRoll(object rollResults) { RollExtract rollExtract = new RollExtract(); object fieldOrProp = GetFieldOrProp(rollResults, "k__BackingField", "RollId"); if (fieldOrProp != null) { rollExtract.RollId = fieldOrProp.ToString(); } object fieldOrProp2 = GetFieldOrProp(rollResults, "k__BackingField", "ResultsGroups"); if (fieldOrProp2 != null) { foreach (object item in AsEnumerable(fieldOrProp2)) { object fieldOrProp3 = GetFieldOrProp(item, "Result", "Result"); if (fieldOrProp3 != null) { int num = EvaluateOperand(fieldOrProp3, rollExtract, 0); rollExtract.Total += num; } } } return rollExtract; } private static int EvaluateOperand(object operand, RollExtract extract, int depth) { if (operand == null || depth > 16) { return 0; } string text = SafeText(GetFieldOrProp(operand, "_which", "Which")); if (text.Contains("Value")) { return EvaluateValue(GetFieldOrProp(operand, "_value", "Value"), extract); } if (text.Contains("Result")) { return EvaluateRollResult(GetFieldOrProp(operand, "_result", "Result"), extract, depth + 1); } if (text.Contains("Operation")) { return EvaluateOperation(GetFieldOrProp(operand, "_operation", "Operation"), extract, depth + 1); } int num = EvaluateValue(GetFieldOrProp(operand, "_value", "Value"), extract); if (num != 0) { return num; } int num2 = EvaluateRollResult(GetFieldOrProp(operand, "_result", "Result"), extract, depth + 1); if (num2 != 0) { return num2; } return EvaluateOperation(GetFieldOrProp(operand, "_operation", "Operation"), extract, depth + 1); } private static int EvaluateOperation(object operation, RollExtract extract, int depth) { if (operation == null || depth > 16) { return 0; } string text = SafeText(GetFieldOrProp(operation, "Operator", "Operator")); object fieldOrProp = GetFieldOrProp(operation, "Operands", "Operands"); List list = new List(); if (fieldOrProp != null) { foreach (object item in AsEnumerable(fieldOrProp)) { list.Add(EvaluateUnknown(item, extract, depth + 1)); } } if (list.Count == 0) { return 0; } if (text.Contains("Subtract") || text.Contains("Sub")) { int num = list[0]; for (int i = 1; i < list.Count; i++) { num -= list[i]; } return num; } int num2 = 0; foreach (int item2 in list) { num2 += item2; } return num2; } private static int EvaluateRollResult(object rollResult, RollExtract extract, int depth) { if (rollResult == null || depth > 16) { return 0; } object fieldOrProp = GetFieldOrProp(rollResult, "Results", "Results"); if (fieldOrProp == null) { return 0; } int num = 0; foreach (object item in AsEnumerable(fieldOrProp)) { num += EvaluateUnknown(item, extract, depth + 1); } return num; } private static int EvaluateUnknown(object obj, RollExtract extract, int depth) { if (obj == null || depth > 16) { return 0; } Type type = obj.GetType(); if (obj is short || obj is int) { int num = Convert.ToInt32(obj); extract.Values.Add(num); return num; } string text = type.FullName ?? ""; if (text.Contains("RollOperand")) { return EvaluateOperand(obj, extract, depth + 1); } if (text.Contains("RollResult")) { return EvaluateRollResult(obj, extract, depth + 1); } if (text.Contains("RollOperation")) { return EvaluateOperation(obj, extract, depth + 1); } if (text.Contains("RollValue")) { return EvaluateValue(obj, extract); } if (obj is IEnumerable && !(obj is string)) { int num2 = 0; { foreach (object item in AsEnumerable(obj)) { num2 += EvaluateUnknown(item, extract, depth + 1); } return num2; } } return 0; } private static int EvaluateValue(object rollValue, RollExtract extract) { if (rollValue == null) { return 0; } object fieldOrProp = GetFieldOrProp(rollValue, "Value", "Value"); if (fieldOrProp == null) { return 0; } try { int num = Convert.ToInt32(fieldOrProp); if (num != 0) { extract.Values.Add(num); } return num; } catch { return 0; } } private static object GetFieldOrProp(object obj, string fieldName, string propName) { if (obj == null) { return null; } Type type = obj.GetType(); FieldInfo field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null) { try { return field.GetValue(obj); } catch { } } PropertyInfo property = type.GetProperty(propName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (property != null && property.CanRead) { try { return property.GetValue(obj, null); } catch { } } return null; } private static IEnumerable AsEnumerable(object obj) { if (!(obj is IEnumerable enumerable)) { yield break; } foreach (object item in enumerable) { yield return item; } } private static string SafeText(object obj) { if (obj == null) { return ""; } try { return obj.ToString(); } catch { return ""; } } }