Extends commit 8c4afaa: the TypingIpc mirror covered only two of the six
ChatTwo IPC slots. Third-party plugins like Artisan and AllaganTools
subscribe to a different ChatTwo IPC surface — the context-menu
integration (ChatTwo.Register / Unregister / Available / Invoke) that
lets them push item-links into the chat. Smoke test against the
deployed v1.4.9 build showed Artisan logging "Chat2 is not available"
because those four gates were not yet mirrored.
This commit adds the missing four ChatTwo-prefixed provider gates in
IpcManager.cs:
- ChatTwo.Register (Func<string>) — bound to the existing Register()
backing method, so plugins that subscribe via either namespace land
in the same Registered list.
- ChatTwo.Unregister (Action<string>) — bound to the existing
Unregister() backing method, same shared-state rationale.
- ChatTwo.Available (Action<>) — SendMessage() fires from the ctor right
after AvailableGate.SendMessage(), so any subscriber waiting on the
"Chat 2 became available" signal sees both events.
- ChatTwo.Invoke (Action<string, PlayerPayload?, ulong, Payload?,
SeString?, SeString?>) — Invoke() fans the context-menu event out to
both InvokeGate and ChatTwoInvokeGate in lockstep. Subscribers compare
on the registration ID they got back from Register, so the
shared-backing approach keeps that contract intact regardless of which
namespace they subscribed under.
Dispose() unregisters all four ChatTwo gates plus the four existing
HellionChat gates. The conflict-detection that prevents ChatTwo from
loading alongside HellionChat guarantees no slot collision at runtime.
With this commit the full ChatTwo IPC surface (6 of 6 slots) is mirrored:
- ChatTwo.GetChatInputState (TypingIpc, commit 8c4afaa)
- ChatTwo.ChatInputStateChanged (TypingIpc, commit 8c4afaa)
- ChatTwo.Register (IpcManager, this commit)
- ChatTwo.Unregister (IpcManager, this commit)
- ChatTwo.Available (IpcManager, this commit)
- ChatTwo.Invoke (IpcManager, this commit)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HellionChat replaces ChatTwo (conflict detection prevents parallel loading)
but third-party plugins with a no-fork policy keep subscribing only to the
ChatTwo.*-prefixed IPC gates. Mirroring the two TypingIpc provider slots
under the ChatTwo namespace lets those plugins keep working without code
changes on their side.
Mirrored slots:
- ChatTwo.GetChatInputState ←→ HellionChat.GetChatInputState
- ChatTwo.ChatInputStateChanged ←→ HellionChat.ChatInputStateChanged
Implementation:
- Two additional ICallGateProvider fields (ChatTwoStateQueryGate +
ChatTwoStateChangedGate) with the identical ChatInputState tuple
signature. The tuple's underlying types match ChatTwo's surface byte-
for-byte (bool/bool/bool/bool/int/ushort — ChatType is `ushort` in both
repos), so Dalamud's IPC marshalling matches across plugin boundaries
even when the subscribing plugin defines its own copy of the ChatType
enum.
- ctor registers the new provider gates and binds RegisterFunc(GetState)
to ChatTwoStateQueryGate so query calls route to the same backing path.
- Update() pushes the state to both ChatTwoStateChangedGate and the
existing StateChangedGate in lockstep.
- Dispose() unregisters both query gates.
Ipc/ExtraChat.cs is intentionally unchanged — it is a subscriber on
ExtraChat's own IPC, not a provider, so no compatibility mirror applies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synchronises the v1.4.9 changelog across the manifest sources that the
Dalamud plugin installer, the gitea repo.json feed and the Forge auto-
announce workflow read at release-tag time.
Files touched:
- HellionChat/HellionChat.yaml: v1.4.9 block inserted at the top of the
changelog: literal. v1.4.5 dropped to keep the slim-rule at 4 subblocks
(preflight Block C enforces YAML_VERSIONS <= 4). Current set is
v1.4.9/v1.4.8/v1.4.7/v1.4.6.
- repo.json: Changelog field kept synchronous with the yaml — v1.4.9
block prepended, v1.4.5 substring removed, JSON-escaped newlines.
- .github/forge-posts/v1.4.9.md: new file with frontmatter (subtitle
"Plugin-Load Render Polish", versionsnatur "Performance-Patch") and
a German-only body. The English half of the eventual Discord embed
is pulled automatically from the yaml changelog at tag-push time by
.gitea/workflows/forge-announce.yml — same workflow as v1.4.4
onwards, the post file does not carry an English block.
Char-cap pre-check passes (title 46 + description ~2700 + footer 33 =
~2800 chars, well under the 5500-char Discord embed total cap).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manifest version bump for the v1.4.9 release cut. Schema-required v16
stays unchanged (R1/R2/R3 are all config-neutral refactors).
Files touched:
- HellionChat/HellionChat.csproj: <Version> 1.4.8 -> 1.4.9
- HellionChat/Plugin.cs: schema-migration error string self-reference
(v1.4.8 -> v1.4.9, required schema v16 stays)
- repo.json: AssemblyVersion, TestingAssemblyVersion, 3x DownloadLink*
URLs all bumped to 1.4.9 / v1.4.9. Changelog field is still on v1.4.8;
the v1.4.9 block plus v1.4.5 slim-drop land in the next commit.
- README.md: shield badge, version header in lead paragraph, project-
status block rewritten for v1.4.9 (Plugin-Load Render Polish).
- docs/CHANGELOG.md: v1.4.9 block inserted above v1.4.8.
- docs/ROADMAP.md: v1.4.9 moved into the released-versions list,
"Next Cycle" header now targets v1.4.10 (Render Clipper + Symbol
Picker reserves carried over from the v1.4.9 plan).
yaml changelog block and repo.json Changelog field follow in the
docs commit so the slim-drop of v1.4.5 stays atomic with the v1.4.9
block insert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cut first-frame HITCH from ~127ms median down to ~76ms median (4-reload
sample, threshold lowered to 1ms for measurement) — comfortably under
Dalamud's 100ms warning threshold. ChatTwo upstream sits at ~63ms median
for comparison; the remaining ~13ms gap is the cost of HellionChat-only
features (Sidebar tab view, custom StatusBar, Honorific integration).
Mechanism: a single `_firstFrameDone` flag (flipped in Draw's finally
block) gates six sections that don't need to render on frame 0:
- StatusBar.Draw (~12ms): the bottom status bar
- DrawChannelName chunks (~17ms): SeString-Renderer layout, replaced
with a plain-text fallback (activeTab.Name) for frame 0
- PositionReset/BoundsCheck (~10ms): EnsureWindowOnScreen viewport
iteration, only matters once the user notices a mispositioned window
- DrawV061HintBannerIfNeeded (~3-5ms): v0.6.1 migration notice
- DrawAutoComplete (~6ms): renders nothing until the user types a command
- InputPreview.CalculatePreview (~3-5ms): triggers InputPreview first-
frame lazy init, user-typing-driven anyway
Frame 1 then renders all of them in ~40ms (still well under the warning
threshold), and frames 2+ stay at 0ms as before. User sees the deferred
sections ~17ms (60fps) later than before — invisible inside the ~2.5s
Atlas-Build window after every plugin reload.
Hypothesis triage from the R2-profiling pass:
- (a) Atlas-Sync-Fallback: falsified. xllog shows the Atlas-Complete
line always lands ~2.5s before the HITCH frame.
- (b) Theme-Apply ABGR-Cache-Init: not dominant. PushGlobal is 5ms.
- (c) Multiple-Window-Render: falsified in v1.4.9 Stage-2-Lazy-Init
diagnose (deferred 4 windows, no measurable delta).
- (d) DrawList-Setup-Cost per Window: actual root cause. Layout cost
distributes evenly across ~10 ImGui sections inside ChatLogWindow
(5-20ms each). No single hot-spot to optimise — the six selective
skips above are the pragmatic fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull the four user-triggered slash-commands (/hellion, /hellionView,
/hellionDebugger, /hellionSeString) plus the two Plugin-Manager
UiBuilder hooks (OpenConfigUi, OpenMainUi) out of their window
constructors and into a central Plugin.SetupCommands method so they
work before their target window has been opened the first time. A
matching TearDownCommands runs as the first CaptureFailure inside the
framework-thread teardown lambda. /hellion and /hellionSeString stay
under the same #if DEBUG guard SeStringDebugger had before. The four
window classes keep their public Dispose method signatures so the
existing Plugin.DisposeAsync method-group binding still resolves —
the bodies are now empty pointers to TearDownCommands. The pre-v1.4.9
`OpenMainUi` body that flipped SettingsWindow.IsOpen and the three
private Toggle(string, string) method-group wrappers are gone since
the central handlers call SettingsWindow.Toggle() / DbViewer.Toggle()
etc. directly.
The properties stay eager in stage 1 — the lazy-init switch lands in
stage 2 with the matching `_lazyWindowLock` guard around AddWindow
and RemoveAllWindows. Doing it in two commits keeps the slash-command
correctness verifiable on its own.
Smoke (release build): /hellion, /hellionView, /hellionDebugger,
/clearhellion plus Plugin-Manager Settings and Open buttons all
toggle their target window. /hellionSeString remains DEBUG-only as
before.
Bump AutoTranslate-warmup and FilterAllTabs log-level from Debug to
Information so the xllog tail surfaces them without a Debug filter.
Wrap MessageStore.Connect and MessageStore.Migrate in Stopwatches so
the SQLite open and migration-chain costs are visible too.
Sub-Task 3.4 Befund on v1.4.8-baseline (4 reloads, medians):
- MessageStore.Connect: 50.5 ms
- MessageStore.Migrate: 2 ms
- MessageManager.FilterAllTabs: 68.5 ms
- AutoTranslate warmup: 108 ms
- UiBuilder HITCH: 108.9 ms
Outcome D — none of the three dominates the 200 ms threshold. The
ChatTwo "300 ms" comment for AutoTranslate is falsified at ~108 ms;
SQLite is not the bottleneck (52.5 ms total); FilterAllTabs runs on
the worker thread and only competes for CPU slots. The HITCH is left
unexplained by these probes, which keeps Hypothesis c (multi-window
WindowSystem.Draw initial pass) as the main R2 suspect to be
validated by the R1 lazy-window refactor.
Logs stay in as belt-and-suspenders for future plugin-load
regressions.
FullTextSearch + LoadByGuids could stall the draw thread for 100-300 ms
on large databases with a popular search term. The two hot trigger sites
(FTS toggle, search input) now route via TriggerFilterRefresh, which
dispatches the FTS path to Task.Run; the in-memory page-filter path
stays inline because it is sub-ms on the loaded page array.
_ftsFilterSeq is bumped per trigger so a late worker recognises itself
as stale and drops its result instead of overwriting a newer one. The
date/channel and history workers already lived on Task.Run and are
untouched.
Surfaced during the v1.4.8 pre-tag review.
- HellionChat.yaml: v1.4.8 changelog block above v1.4.7, v1.4.4
dropped per slim-rule (verify-changelog-sync enforces max 4).
- repo.json: Changelog field synchronised with yaml, same slim-drop.
- .github/forge-posts/v1.4.8.md: bilingual announcement post (DE
body, EN block resolved from yaml at workflow time). Frontmatter
subtitle 32/60 chars, versionsnatur 12/40 chars, embed total
~2787/5500 chars.
- csproj <Version>, Plugin.cs schema-gate self-reference, repo.json
(AssemblyVersion, TestingAssemblyVersion, 3x DownloadLink URLs).
- README.md shield badge, version header, Project Status body.
- docs/CHANGELOG.md gains a v1.4.8 section above v1.4.7.
- docs/ROADMAP.md flips Next Cycle to v1.4.9 (Plugin-Load Render
Polish), v1.4.8 moves into the released history above v1.4.7.
- Config schema stays at v17, Migration v17 stays additive.
repo.json Changelog field and HellionChat.yaml changelog block plus
the new forge-posts/v1.4.8.md follow in a separate commit (slim-drop
of v1.4.4 happens there).
messages.Id is declared BLOB but stored as TEXT because Microsoft.Data.Sqlite
binds Guid parameters as UUID strings (UpsertMessage uses AddWithValue with
a Guid). RebuildFtsIndex cast reader.GetValue(0) to byte[] and threw
InvalidCastException at the first row. LoadByGuids bound byte[] params
against the TEXT-stored Id and would have returned no rows once the index
had built.
- RebuildFtsIndex reads via GetGuid and stores ToString() in
messages_fts.message_guid.
- LoadByGuids parses incoming UUID strings and binds them as Guid so
Microsoft.Data.Sqlite re-serialises to TEXT, matching the messages.Id
storage form.
- DbViewer caller variable renamed hexIds -> guidHits for clarity.
Retention sweep no longer blocks for ~194ms on Framework.Run().Wait().
The clear+refilter pair is now scheduled on the next framework tick, so
it still runs on the framework thread (keeping the Tabs-list mutation
serialisation invariant -- Plugin.Config.Tabs is plain List<Tab> and
AutoTellTabsService can mutate it from background paths) but does not
block the sweep thread while the framework finishes the current frame.
A new _isDisposing volatile bool is set as the first statement in
DisposeAsync so a deferred tick that fires after teardown bails before
it touches MessageManager / Log / static fields the dispose path has
already cleared. The retention worker is IsBackground=true so plugin
unload can race against a still-pending tick.
The existing RetentionSweepLock / RetentionSweepRunning serialisation
covers the not-two-sweeps-at-once invariant; we don't add a CTS here
because RunOnTick is fire-and-forget and the framework service owns
the tick lifecycle.
v1.4.8 B3. Coverage via in-game smoke (frame-time trace during a
retention sweep run) in Task 9 -- no Build-Suite test because the
suite has no FakeFramework fixture and the change is a schedule-form
swap rather than new behaviour.
When the user edits their active custom theme JSON in an external editor
and saves, the change now propagates to HellionChat within ~1 second
without re-selecting the theme in the picker.
RefreshActiveIfStale runs from Plugin.Draw on every frame but the actual
File.GetLastWriteTimeUtc stat is 1Hz-throttled -- 60fps would otherwise
mean 3600 stats/min, more on Wine. Built-in themes short-circuit on the
IsBuiltIn check; custom themes without a captured source path (Switch
fell to default) short-circuit on the null check.
Switch() now captures the source path of custom themes via an out-param
on LoadCustomBySlug, which now reverse-looks-up against the existing
_customCache (no re-parse, no extra disk IO). Plugin.LoadAsync warms the
cache via AllCustom() once before the first Switch so a Config.Theme
pointing at a custom slug does not fall through to the built-in default
on a cold registry.
Switch's lookup order is now built-in-first to match Get(slug), so a
user-authored JSON that declares a built-in slug is consistently
ignored in both code paths.
Pure-helper ThemeStampDiff isolates the stamp-diff rules for the
Build-Suite (covers DateTime.MinValue hold-the-line semantics).
v1.4.8 B2.
Replace the fixed 22px const Height with a computed property that bakes
in the ImGui font line height plus a GlobalScale-rounded 2px spacer.
The constant clipped the bottom bar on Windows display-scaling >100%
because ImGui rendered the actual font taller than 22px; the bar then
got pushed off the window edge.
ChatLogWindow.cs:423 reservation drops the explicit +2 because the
spacer now lives inside Height. Same idiom as the v1.4.6 F7.2 underline
pill in ChatLogWindow.cs:1639-1653.
v1.4.8 B1. Coverage via in-game smoke on Windows (Jin) and Linux/Wayland
in Task 9 -- DrawList-coupled, no Build-Suite test.
New UseFullTextSearch transient UI bool flips DbViewer.Filter() between
the existing local page filter (default) and the FTS5 MATCH path across
the whole database. ImRaii.Disabled blocks the toggle while the bulk-insert
worker is still building the index; the HelpMarker swaps between two
hints, one for the indexing state and one for the phrase-match advisory
once the index is ready.
Three new HellionStrings entries cover EN + DE + the Designer accessor:
- DbViewer_FullTextToggle (label)
- DbViewer_FullTextToggle_Hint_Indexing (tooltip while indexing)
- DbViewer_FullTextToggle_Hint_PhraseMode (tooltip once ready, warns
multi-word terms match as phrases and how to opt into raw MATCH syntax)
Filter() short-circuits to the local fallback if the toggle is on but
ftsReady has flipped back to false -- defensive against a mid-session
Dispose-and-reopen during indexing.
v1.4.8 H2 Sub-Task 4.4.
Two new public query methods plus an internal EscapeFtsTerm helper:
- FullTextSearch(term, limit) runs MATCH against messages_fts and returns
hex-encoded GUIDs sorted by FTS5 rank. Empty/whitespace short-circuits
to an empty list so callers can fall back to the local page filter.
- LoadByGuids(hexIds) resolves the hex GUIDs back to Message rows via
WHERE Id IN (...). Chunked at 500 to stay below SQLite's 999-parameter
cap, and the BLOB-PK autoindex means the join is O(log n) per id.
- EscapeFtsTerm wraps user input in double-quotes so multi-word queries
match as a phrase, not as per-word AND. Users opt into raw MATCH
syntax by writing their own quotes.
Plus _readLock serialises every Connection-touching internal method
(UpsertMessage, MessageCount, all readers, retention writers, etc.).
The DbViewer filter worker now runs FullTextSearch on a Task.Run thread
while the PendingMessageThread keeps calling UpsertMessage; SqliteConnection
is not safe for concurrent use, so this single lock is the minimal
architecture change that closes the race. The Lazy-Enumerator methods
(StreamForExport, GetDateRange, GetPagedDateRange) hold the lock only
through command-setup + ExecuteReader; v1.4.8 doc-notes the caveat for
the v1.5.x DI cycle to address with a snapshot-to-list or connection pool.
RebuildFtsIndex stays outside the lock -- it owns its own SqliteConnection
via OpenSecondaryConnection.
Adds the worker that fills the messages_fts virtual table after Migrate4.
The bulk-insert runs off the framework thread on its own SqliteConnection
opened via OpenSecondaryConnection -- WAL lets the live UpsertMessage
path on the primary Connection keep flowing, and the worker's writer
lock yields every 500 rows with a 5ms breather so PendingMessageThread
does not hit "database is locked" after DefaultTimeout=5s.
InitFtsReadyCache runs in the ctor and short-circuits to ready=true when
the index is already populated or when the messages table is empty. The
DbViewer (Task 4.4) reads IsFtsIndexBuilt per frame as a single volatile
field read.
Plugin.cs LoadAsync kicks the worker after FilterAllTabsAsync, gated on
IsFtsIndexBuilt and a CancellationTokenSource that DisposeAsync cancels
before MessageManager tears down. Progress reports back via IActiveNotification,
marshalled onto the framework thread via Framework.RunOnTick. Success path
finishes the notification as Success with a 5s linger; cancellation
dismisses it; an error swaps the type to Error with a fallback hint.
Pre-step for the v1.4.8 FTS5 bulk-insert worker. The worker opens its
own secondary SqliteConnection on the same db path so the WAL journal
lets parallel reads/writes through, and it has to apply the exact same
connection-string options and PRAGMAs as Connect() -- otherwise the
worker connection drifts the moment Connect grows a new pragma.
Splitting BuildConnectionString + ApplyPragmas out lets both Connect()
and the upcoming OpenSecondaryConnection() share the same source of
truth instead of duplicating the body. No behaviour change.
Lays down a messages_fts virtual table with message_guid (UNINDEXED, hex
TEXT of the BLOB primary key), sender_text and content_text columns
using the unicode61 tokenizer with diacritic folding. Standalone FTS5
without content='messages' linking, because messages.Id is BLOB and
FTS5's content_rowid contract requires an INTEGER rowid alias.
LoadByGuids (Task 4.3) will resolve the hex GUIDs back to messages rows
via WHERE Id IN (...) joins. Schema step only -- the bulk-insert worker
that fills the index lives in Task 4.2.
Internal Connection property exposure plus a HasMessagesFtsTable helper
let the Build-Suite verify Migrate4 without raw PRAGMA glue in each test.
v1.4.8 H2 Sub-Task 4.1.
Pure deserialisation helper that pulls one row from the current reader
position into a Message. The MessageEnumerator load path delegates to
it, and the upcoming FTS-join LoadByGuids (Task 4.3) will share the
same code so both stay in lockstep when the column layout shifts.
Pre-step for v1.4.8 H2 FTS5 full-text search.
Smoke-test round 4 surfaced a clean reproducer: a Party or Linkshell
tab with channel /p, then Settings → Save, popped the input back to
/tell <pinned-partner> on the next interaction. Two bugs combined:
1. Configuration.UpdateFrom captured only Messages+LastSendUnread from
the live state during the persistent-tab merge. CurrentChannel was
not preserved, so a Settings save overwrote the runtime channel
state with the settings-time snapshot. If the user switched channel
in-game between Settings-open and Settings-save, that switch was
lost. Live CurrentChannel now joins Messages and LastSendUnread in
the per-Identifier preservation tuple.
2. TabSwitched seeded a new tab's CurrentChannel from previousTab via
reference copy (`newTab.CurrentChannel = previousTab.CurrentChannel`).
That left both tabs sharing the same UsedChannel instance, so a
later mutation on one bled into the other — exactly the path that
carried a pinned tell-target onto Party. Switched to a deep clone
(UsedChannel.Clone(), same Cherry-Pick-Patch-B pattern from v1.4.6)
plus a Debug log so the next smoke can confirm at a glance which
previous tab donated its channel state.
Pre-existing ChatTwo upstream pattern; v1.4.7 just made it visible
because pinned tabs are now the kind of long-lived tell-target that
sticks around for the seed path to grab.
Smoke-test round 3 feedback from Jin:
- Sidebar now groups tabs into three sections rendered in this order:
persistent → pinned TempTabs → unpinned TempTabs. Each TempTab
section carries its own divider header ("Angepinnt (n)" / "Aktive
Tells (n)"). Plugin.Config.Tabs order is untouched — only the
display order changes, so tabI still mirrors the real index and
LastTab/WantedTab stay consistent.
- The thumbtack glyph overlay on a pinned tab dropped from accent
colour at full alpha to TextMuted at ~47% alpha. The section header
is now the primary discoverability cue; the glyph is just a per-tab
confirmation hint.
- Sidebar width is now a Config field (default 44, range 44-160).
Slider lives in Theme & Layout under the existing Sidebar-Tab-View
toggle. The icon button inside each row stretches with the width so
a widened sidebar doesn't leave the icon floating in dead space.
Smoke-test round 2 feedback from Jin:
- Promote-to-permanent label "Dauerhaft behalten" was indistinguishable
from Pin in German, leading to misclicks that dropped the tell-target.
Removed the menu entry from TempTabs entirely — Promote stays as a
service method for future use, but the user-facing path is gone. Anyone
who wants a regular tab can still create one via the existing
"neuen Tab anlegen" flow.
- No visual confirmation that pin took effect. Added a FontAwesome
thumbtack overlay top-left of the sidebar icon, accent-coloured, and
appended a "Pinned — survives relog" line to the hover tooltip.
- Pinned tabs came back empty after a full disable/enable cycle because
Tab.Messages is NonSerialized. RehydratePinnedTabs now also runs the
same MessageStore-backed PreloadHistory the spawn path uses, so the
recent conversation window reappears alongside the rehydrated
TellTarget.
Diagnose-logging on TryPin/Unpin/Promote/Rehydrate stays in so the next
smoke can confirm at a glance which path fired from the Dalamud console.
Smoke-test (Jin's scenario) surfaced two coupled bugs in v1.4.7 pin
persistence:
1. The chat input couldn't send to the pinned partner after a reload.
Tab.CurrentChannel is NonSerialized, so it came back as a fresh
UsedChannel with TellTarget=null even though tab.TellTarget (the
persisted twin) was intact. The game-side channel hook only repaints
CurrentChannel on a /tell or channel switch, so the pinned tab sat
there mute until the user manually re-bounced the channel.
2. An incoming tell from the pinned partner spawned a *second* TempTab
instead of routing into the existing pinned one. The Name+World
lookup in FindTempTab was vulnerable to any round-trip nuance on
tab.TellTarget — the fallback path now matches by tab name, which
FormatTabName pins at spawn time.
Fix:
- AutoTellTabsService.Initialize now calls RehydratePinnedTabs() after
the Phase-2 wiring lands, seeding tab.CurrentChannel.TellTarget +
Channel from the persisted tab.TellTarget. Channel is also defaulted
to InputChannel.Tell on the tab record so the chat-input bar paints
Tell mode immediately on first selection.
- FindTempTab gained a Name-based fallback for the case where the
primary TellTarget lookup misses (e.g. a pinned tab whose TellTarget
didn't round-trip cleanly through an old save).
- HandleTell self-heals: when the fallback matches a pinned tab with a
missing TellTarget, the tab is repaired from the live partner data
and persisted, so subsequent messages take the fast path.
Build-suite coverage was attempted (PinnedTabJsonRoundtripTests) but
Tab + TellTarget are both Dalamud-coupled — Newtonsoft's reflection
walk loads Dalamud.dll which isn't available in the xUnit AppDomain
(documented in feedback_dalamud_test_isolation). Verification stays on
the ingame smoke path.
Honorific's TitleData carries Glow / Color3 / GradientColourSet /
GradientAnimationStyle beyond the Title + Color we parsed in Cycle 1.
The DTO now mirrors all four so the JSON roundtrip doesn't silently
drop fields.
Rendering for v1.4.7 covers Glow only: when Config.ShowHonorificGlow
is on and the title has a Glow colour, the chat header title gets an
8-direction ±1px draw-list outline pre-pass in the glow colour at 0.4
alpha, then the primary text on top.
Gradient (Color3 / GradientColourSet / GradientAnimationStyle) is parsed
and stashed for a later cycle — porting the full animation needs
Honorific's hardcoded Pride-palette list and GradientSystem.cs (or an
upstream IPC PR exposing the resolved frame colour). Tracked as
"Honorific Full Gradient Port" in the vault backlog.
ShowHonorificGlow defaults OFF — keeps v1.4.6 visuals untouched and
dodges per-frame DrawList overhead on low-end hardware. Tooltip flags
the gradient deferral so users aren't surprised by static rendering.
Tester-Request from Jin (2026-05-03): TempTabs should be pinnable so a
key conversation partner survives a relog. Right-click a TempTab and
choose Pin Tab / Unpin Tab / Promote to permanent.
Pool semantics:
- AutoTellTabsLimit (15) still gates the auto-managed unpinned pool.
- Pinned TempTabs live in their own pool, hard-capped at 5.
- The 6th pin attempt fails with a notification; users can unpin first
or promote to permanent.
- Unpinning into a full unpinned pool drops the oldest unpinned (no
user friction).
Mechanics:
- Tab.IsPinned (default false); Tab.Clone() carries it.
- Migration v16 -> v17 (additive; existing tabs default to unpinned).
- Three strip-sites synchronised through TabLifecycleHelpers:
Plugin.cs load-time, Plugin.SaveConfig, Configuration.UpdateFrom.
- AutoTellTabsService:
* MaxPinnedTempTabs constant.
* F2.1 _activeTempTabCount counter retired — ActiveTempTabCount is
now Tabs.Count(predicate). Pin/Unpin/Promote transitions are
cold-path and don't need lock-free reads.
* DropOldestTempTab filters on IsInUnpinnedPool so pinned tabs are
never drop candidates.
* OnLogout strips only the unpinned pool; pinned popouts and the
active-tab switch behave correspondingly.
* TryPin / Unpin / PromoteToPermanent service methods.
- ChatLogWindow tab context menu: Pin / Unpin / Promote with disabled-
state at-cap tooltip + Promote tooltip explaining the channel-filter
side effect.
- HellionStrings (EN+DE) for menu labels, tooltips, the limit warning.
- AutoTellTabsLimit slider description now flags the separate pinned
pool so users aren't surprised by 18 tabs when the limit reads 15.
MessageStore's Migrate0 (and the Migrate1/2/3 siblings) called
Plugin.Log.Information directly, which prevented an isolated xUnit
construction test from running — Dalamud.dll cannot load in the test
AppDomain. With IPluginLogProxy threaded through the ctor and the inner
MessageEnumerator, the whole MessageStore.cs file is now Dalamud-static
free and the Build-Suite covers it (Floor 688 -> 690).
This is the second half of F12.2; the remaining ~82 Plugin.Log call
sites in the rest of the plugin will be routed through the static
Plugin.LogProxy wrapper in a follow-up commit.
F12.2 closes the gap that F12.1 left open: MessageStore's ctor calls
Plugin.Log.Information inside Migrate0, which prevents an isolated xUnit
construction test (Dalamud.dll cannot load in the test AppDomain).
The proxy mirrors IPluginLog's full surface (Verbose/Debug/Information/
Info/Warning/Error/Fatal — both Info and Information as Dalamud exposes
them) with both single-string and Exception+string overloads, so the
~91 existing Plugin.Log.* call-sites become a drop-in rewrite to
Plugin.LogProxy.*.
A later DI-container adoption cycle (v1.5.x) may swap this for
Microsoft.Extensions.Logging's ILogger<T>; this commit is the
intermediate decorator step.
`id + 1.ToString()` resolves as `id.ToString() + "1"`, producing "01"
instead of "1" for the ArrowRight button. The single live caller
(DbViewer page navigation) still produced unique IDs by accident, but
the semantics were wrong. Explicit parentheses fix it.
Crystal Nocturne (royal sapphire + electric magenta on obsidian, by
CRYSTALLITE) replaces Moonlit Bloom in the built-in roster. The same
chat-channel tinting convention applies: sapphire-blue identity on
party/team channels, accent-magenta on tells, and an alternating
mint/yellow/peach palette across the eight linkshell slots so each
LS stays individually distinguishable on the dark obsidian background.
Users who had Moonlit Bloom selected fall back to the default Hellion
Arctic on the first plugin load. A custom JSON copy of Moonlit Bloom
dropped into pluginConfigs/HellionChat/themes/ keeps working as a
user theme.
Plus a cosmetic re-sort of the registry: insertion order now drives a
deliberate Theme-Picker grid layout (3 columns) — blue family in row 1,
purple to magenta in row 2, green/warm/classic in row 3, Synthwave
Sunset alone in row 4 as a retro bonus.
Plugin.cs:171-172 hardcoded the version into the schema-gate
InvalidOperationException string. The follow-up rename in v1.4.7 will
move this to Plugin.Interface.Manifest.AssemblyVersion so this commit
stops happening every cycle, but for v1.4.6 the bare version bump is
the smallest change.
Also picks up a one-line csharpier reflow on UrlValidation.cs
collapsed by the format pass.
DE body for the Hellion Forge Discord embed; subtitle and
versionsnatur frontmatter fields within the 60/40 char caps;
embed-total ~2267/5500 per the changelog-sync verifier.
CHANGELOG.md gets the full per-bullet block, ROADMAP.md gets the
released-cycle summary plus a v1.4.7 next-cycle placeholder, README
status section and version badge updated.
csproj <Version>, yaml changelog block (v1.4.6 added on top, v1.4.2
rotated out per the slim-4-versions rule), repo.json AssemblyVersion
+ TestingAssemblyVersion + the three DownloadLink URLs + Changelog
string, all in sync.
Inspired by ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12).
Upstream dropped the width parameter entirely because nothing called
it. We keep the parameter — two ChatLogWindow header buttons (Cog,
EyeSlash) size themselves to match the preceding ChannelIcon button.
The actual bug is local: the previous size = width - 2 * CellPadding.X
mixed a raw int (HUD-scale unaware) with CellPadding.X (HUD-scaled),
so the button shrank under elevated HUD scale. ImGui.Button handles
its own frame padding internally, so the measured width passes
through unchanged.
Cherry-pick from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12).
Chat.SetChannel allocates a native Utf8String for the target name and
then runs a validity check. The previous early return on an invalid
linkshell skipped Dtor and leaked the native allocation; every invalid
linkshell switch added one Utf8String to the unmanaged heap.
- Renamed ValidAnyLinkshell to IsChannelOrExistingLinkshell so the
call-site reads naturally.
- Wrapped ChangeChatChannel in the validity check instead of
early-returning. Dtor now runs on every path.
- ChatLogWindow follows the rename at its single call-site.
Cherry-pick from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12).
Tab.Clone() used to assign CurrentChannel = CurrentChannel and run
TellTarget.From(TellTarget). The first was a plain reference copy of
the UsedChannel — the clone and the source shared the same channel
state, so a channel switch or TellTarget update on a PopOut/Temp tab
also mutated its origin tab. The second was a static factory call
that read like a constructor where every other place uses Clone().
- TellTarget: static From(t) replaced by instance Clone(); only
call-site swapped to TellTarget.Clone().
- UsedChannel: new Clone() that copies the scalar fields and runs
Clone() on the two TellTarget references (null-safe).
- Tab.Clone(): CurrentChannel goes through UsedChannel.Clone().
Ten Util.OpenLink call-sites across five files now go through the
IPlatformUtil indirection: WrapperUtil.TryOpenUri, the Settings Ko-Fi
buttons (x2), the Information tab (issues link plus media/upstream
links, x3), the Integrations tab (Honorific repo/author plus forge
discord, x3), and the ThemeAndLayout 'open themes folder' button.
A future addition to this pattern only needs to plug into IPlatformUtil
instead of touching Dalamud.Utility.Util directly.
MessageStore.Connect used to call Util.IsWine() directly via a
DalamudUtil alias, which made the ctor unreachable from the xUnit
test AppDomain: any test that allocated a MessageStore tripped a
FileNotFoundException on Dalamud.dll before reaching the assertion.
The ctor now takes an IPlatformUtil and reads the cached IsWine
property. MessageManager passes Plugin.PlatformUtil in. Production
behaviour is identical; the test path can now substitute a fake
and exercise the SQLite migration logic in isolation.
Introduces a thin interface around Util.IsWine and Util.OpenLink so
services can be constructed in an isolated xUnit AppDomain without
forcing Dalamud.dll onto the assembly search path. Production wiring
(DalamudPlatformUtil) caches IsWine at ctor time — it's a runtime
probe that never changes for the lifetime of a plugin instance,
mirroring the Lightless DalamudUtilService pattern.
Plugin.PlatformUtil is wired in the Phase-1 ctor so any service that
LoadAsync allocates can resolve the platform indirection without
plumbing the instance through additional constructor params.
Follow-up commits route MessageStore and the OpenLink call-sites
through this interface.
The pre-serialization snapshot used to clone the entire Config.Tabs
list, then Clear/AddRange the snapshot back. With a typical config of
~30 user-defined tabs plus up to 15 session-only temp tabs, that's a
45-item clone on every save. The persistent tabs never leave the list
during this routine, so cloning only the temp subset is functionally
identical and keeps the allocation proportional to AutoTellTabsLimit.
The 2px underline pill was hardcoded — at 125/150% DPI the surrounding
tab layout scaled with ImGuiHelpers.GlobalScale but the pill stayed
2px, so the line landed on sub-pixel boundaries and rendered as a
fuzzy band. Now: height scales with GlobalScale (clamped to >=1px),
and the DrawList coordinates round to physical pixels via MathF.Round
so the rect aligns with the framebuffer grid.