using System; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Services; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace HellionChat.Integrations; // Newtonsoft.Json is used here for IPC compatibility with Honorific, which // serialises TitleData with it. It's a transitive Dalamud dependency — no // new NuGet entry needed. The rest of HellionChat uses System.Text.Json. internal sealed class HonorificService : IDisposable { private const string IpcNamespace = "Honorific"; // Major version of the Honorific IPC contract we're built against. internal const uint ExpectedApiMajor = 3; // IPC gates — kept as fields so Dispose can unsubscribe the same instances. private readonly ICallGateSubscriber<(uint, uint)> _apiVersion; private readonly ICallGateSubscriber _getLocalCharacterTitle; private readonly ICallGateSubscriber _localCharacterTitleChanged; private readonly ICallGateSubscriber _ready; private readonly ICallGateSubscriber _disposing; private readonly ILogger _logger; private readonly IFramework _framework; private bool _versionWarningLogged; // Thread: framework only — IPC delivery + ImGui render both run there. public HonorificTitleData? CurrentTitle { get; private set; } public bool IsAvailable { get; private set; } public (uint Major, uint Minor)? DetectedApiVersion { get; private set; } public HonorificService( IDalamudPluginInterface pluginInterface, ILogger logger, IFramework framework ) { _framework = framework; _logger = logger; // Gate objects are cached per-name by Dalamud and safe to register // before Honorific loads — they just won't fire until it does. // Initial pull is scheduled on the framework thread because plugin // constructors run on the loader thread, and Honorific's IPC handlers // read ObjectTable.LocalPlayer which throws off the framework thread. _apiVersion = pluginInterface.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion"); _getLocalCharacterTitle = pluginInterface.GetIpcSubscriber( $"{IpcNamespace}.GetLocalCharacterTitle" ); _localCharacterTitleChanged = pluginInterface.GetIpcSubscriber( $"{IpcNamespace}.LocalCharacterTitleChanged" ); _ready = pluginInterface.GetIpcSubscriber($"{IpcNamespace}.Ready"); _disposing = pluginInterface.GetIpcSubscriber($"{IpcNamespace}.Disposing"); _localCharacterTitleChanged.Subscribe(OnTitleChanged); _ready.Subscribe(OnReady); _disposing.Subscribe(OnDisposing); _framework.RunOnFrameworkThread(TryInitialPull); } public void Dispose() { // Wrap each unsubscribe — a missing gate must not block the others. // Leaking a subscription keeps this service alive across plugin reloads. TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged)); TryUnsubscribe(() => _ready.Unsubscribe(OnReady)); TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing)); } // Thread: framework (scheduled from ctor and OnReady). private void TryInitialPull() { try { var version = _apiVersion.InvokeFunc(); DetectedApiVersion = version; if (!IsApiVersionCompatible(version)) { if (!_versionWarningLogged) { _logger.LogWarning( "Honorific API version mismatch — expected major 3, " + "found {Major}.{Minor}. Disabling Honorific integration.", version.Item1, version.Item2 ); _versionWarningLogged = true; } IsAvailable = false; return; } IsAvailable = true; _versionWarningLogged = false; var json = _getLocalCharacterTitle.InvokeFunc(); CurrentTitle = ParseTitleJson(json); } catch (Exception ex) { // Honorific not installed or not yet initialised — Ready will retry. _logger.LogDebug(ex, "Honorific not available at HellionChat startup; awaiting Ready."); IsAvailable = false; CurrentTitle = null; } } // Thread: framework (Dalamud IPC delivery contract). private void OnTitleChanged(string json) { // Skip updates on version mismatch; subscription stays live for reload. if (!IsAvailable) return; CurrentTitle = ParseTitleJson(json); } // Thread: any (Honorific dispatches NotifyReady from its own thread). private void OnReady() { _framework.RunOnFrameworkThread(TryInitialPull); } // Thread: framework (IPC delivery contract); idempotent — Disposing fires once. private void OnDisposing() { // Honorific unloading — clear cached state so the header hides next frame. // Subscriptions stay registered in case Honorific reloads. // CurrentTitle is already nulled by OnTitleChanged before this fires, // re-clearing here is belt-and-braces. CurrentTitle = null; IsAvailable = false; DetectedApiVersion = null; } // Thread: framework (called from Dispose, which runs on the framework // cleanup block in Plugin.DisposeAsync). private void TryUnsubscribe(Action unsubscribe) { try { unsubscribe(); } catch (Exception ex) { // Warning not Debug — a silent unsubscribe failure leaks a live // subscription across plugin reloads. _logger.LogWarning( ex, "Honorific unsubscribe failed (likely API break or gate already gone)." ); } } internal static HonorificTitleData? ParseTitleJson(string json) { if (string.IsNullOrEmpty(json)) return null; try { return JsonConvert.DeserializeObject(json); } catch (JsonException) { return null; } } internal static bool IsApiVersionCompatible((uint Major, uint Minor) apiVersion) { return apiVersion.Major == ExpectedApiMajor; } internal static bool ShouldRenderSlot( bool toggleEnabled, bool isAvailable, HonorificTitleData? title ) { if (!toggleEnabled) return false; if (!isAvailable) return false; if (title is null) return false; if (title.IsOriginal) return false; if (string.IsNullOrEmpty(title.Title)) return false; return true; } }