using System; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using Dalamud.Plugin.Services; using Newtonsoft.Json; namespace HellionChat.Integrations; // We pull Newtonsoft.Json into this single file for IPC compatibility: // Honorific serialises its TitleData with Newtonsoft (see // Honorific-master/IpcProvider.cs:9 and CustomTitle.cs:12). Using the // same library guarantees identical handling of System.Numerics.Vector3? // and the enum fields we ignore. Newtonsoft is a transitive dependency // via Dalamud, so no new NuGet entry is needed. The rest of HellionChat // keeps using System.Text.Json. internal sealed class HonorificService : IDisposable { private const string IpcNamespace = "Honorific"; // Major version of the Honorific IPC contract HellionChat is built against. // Used both by the runtime compatibility check and by the settings tab when // it tells the user which major version we expected, so the literal lives // in exactly one place. internal const uint ExpectedApiMajor = 3; // IPC gates we subscribe to. Keep them as fields so Dispose can // unsubscribe the same instances we subscribed in the constructor. private readonly ICallGateSubscriber<(uint, uint)> _apiVersion; private readonly ICallGateSubscriber _getLocalCharacterTitle; private readonly ICallGateSubscriber _localCharacterTitleChanged; private readonly ICallGateSubscriber _ready; private readonly ICallGateSubscriber _disposing; private readonly IPluginLog _log; private readonly IFramework _framework; private bool _versionWarningLogged; 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, IPluginLog log, IFramework framework ) { _framework = framework; _log = log; // Dalamud caches gate objects per-name for the lifetime of the // plugin interface, so we can register subscribers even when // Honorific isn't loaded yet — the gate just won't fire. Calling // InvokeFunc before Honorific is up will throw, which is why the // initial pull below is wrapped in try-catch. // // Thread-context: plugin constructors run on Dalamud's plugin-loader // thread, NOT the framework thread. Honorific's IPC handlers read // ObjectTable.LocalPlayer (Honorific IpcProvider.cs:61), which throws // "Not on main thread!" outside the framework thread. If Honorific is // already loaded when HellionChat starts, a synchronous InvokeFunc // here would surface that exception, the broad catch below would // mark IsAvailable=false, and OnTitleChanged's `if (!IsAvailable)` // gate would block every subsequent title update. We therefore // schedule the initial pull onto the framework thread via // IFramework.RunOnFrameworkThread so the IPC call sees the right // thread context. _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() { // Honorific may already be gone by the time we dispose. Wrap each // unsubscribe so a missing gate doesn't prevent the others from // unsubscribing — leaking even one subscription leaves a callback // alive that captures `this`, which keeps the whole service alive // and breaks plugin reload. TryUnsubscribe(() => _localCharacterTitleChanged.Unsubscribe(OnTitleChanged)); TryUnsubscribe(() => _ready.Unsubscribe(OnReady)); TryUnsubscribe(() => _disposing.Unsubscribe(OnDisposing)); } private void TryInitialPull() { try { var version = _apiVersion.InvokeFunc(); DetectedApiVersion = version; if (!IsApiVersionCompatible(version)) { if (!_versionWarningLogged) { _log.Warning( "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; // Pull the current title once at startup; from here on we rely // on LocalCharacterTitleChanged events. var json = _getLocalCharacterTitle.InvokeFunc(); CurrentTitle = ParseTitleJson(json); } catch (Exception ex) { // Honorific isn't installed or hasn't initialised yet. The Ready // event will give us a second chance later. Log at Debug so // users without Honorific don't see noise on every reload. _log.Debug(ex, "Honorific not available at HellionChat startup; awaiting Ready."); IsAvailable = false; CurrentTitle = null; } } // Honorific fires LocalCharacterTitleChanged through its nameplate hook // (Honorific-master/Plugin.cs:665), which means we get title updates on // character switches automatically as soon as the new character is // rendered. While the user is in the character-select menu, HellionChat's // window is hidden by default via HideWhenNotLoggedIn (Configuration.cs:152), // so the stale-title window between logout and login isn't user-visible. private void OnTitleChanged(string json) { // Don't update cached state when we've already decided we can't trust // Honorific (e.g. version mismatch). Subscription stays live in case a // compatible Honorific reloads, in which case Ready triggers TryInitialPull // and sets IsAvailable back to true. if (!IsAvailable) return; CurrentTitle = ParseTitleJson(json); } private void OnReady() { // Honorific loaded after HellionChat; redo the version check and // initial pull. Idempotent on purpose — Honorific can fire Ready // more than once across reloads. // // Honorific's NotifyReady may dispatch from any thread, and // TryInitialPull eventually calls IPC handlers that read // ObjectTable.LocalPlayer — same "Not on main thread!" hazard as // the constructor path. Schedule onto the framework thread. _framework.RunOnFrameworkThread(TryInitialPull); } private void OnDisposing() { // Honorific is unloading. Drop our cached state so the header // hides on the next frame; subscriptions stay registered because // the gates may come back later (Honorific reload). // // Race-note: Honorific's NotifyDisposing calls ChangedLocalCharacterTitle(null) // BEFORE SendMessage on the Disposing gate (IpcProvider.cs:109-111), // so OnTitleChanged is expected to fire first and already null out // CurrentTitle. We re-clear here as belt-and-braces; should the // ordering ever flip, ShouldRenderSlot would still gate on IsAvailable. CurrentTitle = null; IsAvailable = false; DetectedApiVersion = null; } private void TryUnsubscribe(Action unsubscribe) { try { unsubscribe(); } catch (Exception ex) { _log.Debug(ex, "Honorific unsubscribe failed (likely already gone)."); } } // Threading note: Dalamud fires IPC events on the framework thread and // ImGui renders on the framework thread, so OnTitleChanged and the // render path that reads CurrentTitle never race — OnTitleChanged is // safe to keep direct (no RunOnFrameworkThread wrap needed) because // LocalCharacterTitleChanged delivery is framework-thread by Dalamud // contract. If a future change moves either side onto a worker thread, // switch to volatile/Interlocked for the CurrentTitle field. // // The constructor's initial pull and OnReady, on the other hand, are // explicitly scheduled via IFramework.RunOnFrameworkThread because // they run outside that contract: the constructor executes on the // plugin-loader thread, and Honorific's NotifyReady can dispatch from // any thread. Both call paths eventually invoke IPC handlers that read // ObjectTable.LocalPlayer, which throws "Not on main thread!" off the // framework thread — see the constructor comment block for context. // // Divergence from ChatTwo/Ipc/ExtraChat.cs: that file uses `volatile` // on its state fields out of caution. We don't, because the framework- // thread delivery is the documented Dalamud contract. If the two files // ever need to share a threading audit, this is the place to revisit. // --- Pure-logic helpers below; tested via HellionChat.Tests/Integrations. --- 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; } }