feat(integrations): wire HonorificService to Honorific IPC gates

This commit is contained in:
2026-05-06 19:13:10 +02:00
parent 74e2c655f0
commit 00deef01a4
+154 -23
View File
@@ -1,19 +1,163 @@
using System;
using Dalamud.Plugin;
using Dalamud.Plugin.Ipc;
using Dalamud.Plugin.Services;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace HellionChat.Integrations; namespace HellionChat.Integrations;
internal sealed class HonorificService // 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
{ {
// We pull Newtonsoft.Json into this single file for IPC compatibility: private const string IpcNamespace = "Honorific";
// Honorific serialises with Newtonsoft (see Honorific-master/IpcProvider.cs:9
// and CustomTitle.cs:12). Using the same library guarantees identical // IPC gates we subscribe to. Keep them as fields so Dispose can
// handling of System.Numerics.Vector3? and the enum fields we ignore. // unsubscribe the same instances we subscribed in the constructor.
// Newtonsoft is a transitive dependency via Dalamud, so no new NuGet private readonly ICallGateSubscriber<(uint, uint)> _apiVersion;
// entry is needed. The rest of HellionChat keeps using System.Text.Json. private readonly ICallGateSubscriber<string> _getLocalCharacterTitle;
private readonly ICallGateSubscriber<string, object> _localCharacterTitleChanged;
private readonly ICallGateSubscriber<object> _ready;
private readonly ICallGateSubscriber<object> _disposing;
private readonly IPluginLog _log;
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)
{
_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.
_apiVersion = pluginInterface
.GetIpcSubscriber<(uint, uint)>($"{IpcNamespace}.ApiVersion");
_getLocalCharacterTitle = pluginInterface
.GetIpcSubscriber<string>($"{IpcNamespace}.GetLocalCharacterTitle");
_localCharacterTitleChanged = pluginInterface
.GetIpcSubscriber<string, object>($"{IpcNamespace}.LocalCharacterTitleChanged");
_ready = pluginInterface
.GetIpcSubscriber<object>($"{IpcNamespace}.Ready");
_disposing = pluginInterface
.GetIpcSubscriber<object>($"{IpcNamespace}.Disposing");
_localCharacterTitleChanged.Subscribe(OnTitleChanged);
_ready.Subscribe(OnReady);
_disposing.Subscribe(OnDisposing);
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;
// 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)
{
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.
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).
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. If a future change
// moves either side onto a worker thread, switch to volatile/Interlocked
// for the CurrentTitle field.
// --- Pure-logic helpers below; tested via HellionChat.Tests/Integrations. ---
// Returns null when the JSON is empty (Honorific signals "no custom title"
// with string.Empty — see IpcProvider.cs:100), or when deserialisation
// throws (defensive: a malformed payload shouldn't crash the chat header).
internal static HonorificTitleData? ParseTitleJson(string json) internal static HonorificTitleData? ParseTitleJson(string json)
{ {
if (string.IsNullOrEmpty(json)) if (string.IsNullOrEmpty(json))
@@ -29,24 +173,11 @@ internal sealed class HonorificService
} }
} }
// Honorific has been on major version 3 since the IPC was introduced.
// We treat anything else as incompatible because a major bump from
// upstream signals a breaking IPC contract change, and rendering a
// title against the wrong shape is worse than rendering nothing.
// If Honorific later ships a non-breaking 4.x major, we relax this
// by extending the accepted-major set rather than removing the check.
internal static bool IsApiVersionCompatible((uint Major, uint Minor) apiVersion) internal static bool IsApiVersionCompatible((uint Major, uint Minor) apiVersion)
{ {
return apiVersion.Major == 3; return apiVersion.Major == 3;
} }
// Single source of truth for whether the chat header should draw the
// Honorific slot in the current frame. Returning a single bool keeps
// the render call branch-free; all skip conditions are evaluated here.
// The IsOriginal short-circuit means: when the user has Honorific
// installed but is using the original FFXIV title, we render nothing —
// matches the design decision in the spec ("Empty-State A: silent
// auto-hide").
internal static bool ShouldRenderSlot( internal static bool ShouldRenderSlot(
bool toggleEnabled, bool toggleEnabled,
bool isAvailable, bool isAvailable,