v1.6.0 through v1.15.0 were never published. The whole window layer was being
rewritten from ImGui defaults to custom drawing, and repo.json deliberately kept
its links on v1.5.6 so nobody could update into a half-finished state. That work
lands here in one release.
The config is not migrated, it is replaced. A config carried across nine cycles of
window rebuilds holds values chosen against surfaces that no longer exist, and
starting over is the only way to be sure every install is on the same defaults.
The message database is a separate file and is not touched. The old config is
copied to HellionChat.json.pre-2.0.0.bak first, so rolling back to 1.5.6 stays a
file copy rather than an evening of clicking settings back in.
Config schema 27. The reset runs from the constructor, before LoadAsync seeds the
default tabs, and the self-test now fails if the tab list is empty at /xlperf --
that ordering breaking would leave every user with no tabs and nothing else in the
plugin would notice.
Default layout gains an Emote tab: custom emotes, standard emotes and echo. A
tester asked for it, emotes get lost between system notices otherwise.
Manifest, README, roadmap and the four changelog consumers are on 2.0.0. The
release notes lead with what was fixed rather than what was redrawn -- retroactive
cleanup that could never be applied, a compaction that reported deleting nothing
while deleting everything, pinned tell tabs that came up empty for a session. And
with the one behaviour change users should know about: the channel grid is
authoritative over storage now, so anyone who had unticked channels while the
unknown-channel failsafe was on stores less than they did before.
The first sweep matched a character class that swallowed the digit, so a bare
B1 slipped through while B1-2 was caught. Searching the whole A-Z space instead
of guessing prefixes turned up 130-odd more: B0 through B6, C2, C3, D1, H2, M6,
P7, P8, T2, W2, plus GP-04, KB-01, OD-1, PM-1, PM-3, SEC-01, TR-4, TR-7, UI-11,
UI-12, XC-8 and API-3.
Kept deliberately: 41 B4 01 is a byte signature, "N0" a format string,
#L119-L128 a source anchor, LS4/LS6 are linkshells, and A=FF B=0C G=41 R=C2
explains a colour-channel order. Those look like codes and are not.
Also translated the eight German comments left in the theme files and
ImGuiUtil. Seven of them described what a palette does to which channel, which
is worth reading -- just not in a second language in an otherwise English
codebase.
The cards were the last surface still drawn from ImGui defaults: four emoji on
stock child frames, sized to a fixed 2x2 grid that clipped the longer privacy
descriptions in half the supported languages.
They are tiles in the row language now -- a resting surface, an accent bar down
the left edge when chosen, a held hover over both, tracked caps for the heading.
The emoji are FontAwesome glyphs from the icon font the rest of the plugin
already uses. Each card measures its own height from its text and the pair in a
row takes the taller of the two, inside a scrolling frame, because cutting a
privacy choice off mid-sentence is not a thing this plugin gets to do.
Step three moves onto ToggleSwitch rows and a drawn theme field with a popup
built from PopupRow, using the transient widget overloads throughout: the
Func/Action pair saves on every click, and this step is staged until Finish.
The welcome page is opaque, unlike every other window here. Those are read at a
glance over the game; this one is read once and carries a privacy decision. The
fox sat on a hardcoded off-white rectangle that read as paper taped to the
window -- it is a disc now, tinted from the theme accent and only lightened as
far as the black linework needs, measured rather than set.
Three defects surfaced while doing it, all older than this work:
- The GDPR notice for full history had been translated into 25 languages and
drawn nowhere since the four-step rewrite dropped it on 2026-05-18. It is on
the card where the choice is made.
- Two cards claimed to be recommended: the badge sat on casual while the data
minimisation heading still said "(recommended)" in every language. The suffix
is gone, and the word it carried became the badge label.
- The wizard had no way back into it at all. /hellion wizard reopens it, and
OnOpen resets the staged state so a second run cannot commit picks from a
first one the user never saw.
The welcome text drops the fork framing: Chat 2 and this plugin have diverged
far enough that the codebases no longer line up, so it reads as history in a
muted line rather than as a justification up front. In its place is the notice
that plugins are a grey area in this game and do not belong in public channels.
Channel names in it come from Language.<lang>.resx per language, so the German
build says Sagen/Rufen/Schreien and the Polish one says Say/Yell/Shout, which is
what a Polish player actually sees on an English client.
Four elements, three variants each, drawn side by side in real ImGui against the
live theme. Reachable with /hellion lab.
The alternative was drawing mockups, and mockups are what sent this cycle wrong
once already -- they predate the settings window learning that structure is
typography and only controls get a fill. A picture also cannot show the part that
matters most here: switching themes switches which variant works.
Everything it draws comes from helpers that were already in DrawListExtensions
and had almost no callers. DrawSlipPolygon and DrawVerticalGradient had one
between them, in SegmentedControl. DrawGlowBorder had none at all.
Temporary, and deliberately not behind DEBUG: the decision happens in the build
Flo runs. It comes out once the variants are picked.
This morning's fix hung on TellTarget, and TellTarget is routing state that the
codebase clears deliberately. StripTellBindingOnPromote sets IsTempTab false,
empties TellTarget, and keeps the name -- so a promoted tell tab is called
"Player@World" permanently while carrying neither marker, and falls through both
possible checks. That state survives restarts. A pinned tab whose binding did not
survive a save is the same hole with a different cause; the auto-tell service
logs that case as expected and repairs around it.
The flag is set where the name is built from a partner and is not cleared by
promotion. Renaming clears it, because at that point the user typed it.
Config v26 carries it backwards for tabs that already exist: anything still
holding a tell binding or the temp flag got its name from a partner. Tabs
promoted before this version cannot be recovered -- nothing in the stored data
says where their name came from -- and renaming one has the same effect anyway.
Two more things the header was giving away. Its icon for an auto-tell tab is
derived from the partner and stable across sessions, which is three bits of
linkable information on a picture meant to be shareable; the message path
re-salts its name hashes on every load precisely to avoid that, so screenshot
mode now falls back to a plain envelope. And a world name that is not ASCII --
the CN and KR clients have those, and we ship translations for both -- was being
drawn in the meta face, which carries ASCII and a middle dot. It would have come
out as question marks, the same defect the split was built to prevent.
Plus two that are not privacy: the header had no FontsReady gate, alone among
the drawing components, so its band height and baseline offset were wrong in
exactly the frames this cycle made more common. And a long tab name ran past the
band and got cut mid-glyph at the window edge; it fits now, the way the honorific
header already did it.
Block A ends with no call site in the message list -- that arrives in block C --
so without this step there would be nothing to look at and two helpers with no
caller at all.
It draws rather than asserts, because asserting proves the wrong thing here.
SimplePushedFont pushes nothing at all when a handle is not ready, silently, and
the text then renders in whatever face was already active. A step that compares
two numbers and reports Pass would sail straight past that. So this one puts a
timestamp, a sender and a body line next to each other and lets them be looked
at, with expected-against-actual printed underneath.
The three weight buttons exist because the alternative was three builds and a
plugin restart between each, and nobody compares a typeface across a restart.
RebuildDelegateFonts is synchronous on this thread, so the sample row picks up
the new rasterisation on the next frame.
The cleanup preview marked itself stale before it could be drawn. The
gate bumps a revision on release so a preview cannot survive a wipe; I
then made the preview take the gate, so its own release invalidated it
every single time and the apply button never appeared. The feature has
been shipping non-functional since it was written, with a self-test that
asserted the exact bump that killed it.
Read-only operations no longer move the revision, and preview and
maintenance have their own marks instead of borrowing Cleanup -- which
also stops the five-second metadata refresh from expiring previews, and
stops the UI announcing "another operation is running: cleanup" during a
VACUUM.
The JSON export produced invalid JSON. The chat relation kinds were
interpolated straight into the output, and interpolating an enum writes
its member name, so every message with a recognised relation came out as
"source_kind":LocalPlayer. That is the file a GDPR access request goes
out on. The self-test wrote a JSON file and never parsed it; it does now.
Retention with the limit at zero still deleted. The slider is labelled
"0 = never" and the sweep seeded 31 spec defaults unconditionally before
reading the user's overrides, so zero still lost free company, linkshell
and party history after ninety days -- and the short-circuit written for
exactly this case could never be reached, because the map was never
empty.
A wipe that worked reported that it had failed. VACUUM needs the
database to itself, the refilter walks a lazy reader on the primary
connection outside the lock, and the two collide -- after the DELETE has
committed. The delete paths no longer let that escape: the rows are
gone, an uncompacted file is a housekeeping problem, and telling
somebody their history is still there when it is not is a different kind
of problem.
Also:
- CSV cells starting with =, +, - or @ get a leading apostrophe. The
content is text other people typed into a chat channel and the file
exists to be opened in a spreadsheet.
- An export that matched nothing no longer replaces the previous one. It
used to write its header, move it into place, and then report that
nothing matched. Dalamud's save dialog offers no overwrite
confirmation to fall back on, so this is the part that had to move.
- The retention sweep says so when it loses the race for the gate, and
routes its notifications through the teardown check like everything
else.
The real defect first. RefreshDatabaseMetadata was the one worker of six
that never took the shared lock, and its flag was the one of six missing
from the tab's busy state. It calls MessageCount, which holds the read
lock, so a wipe could start while it was in there -- and the tab would
not have known to grey the button, because it could not see the worker.
Both halves fixed. The pattern is why: seven near-copies of one worker
skeleton, and each copy decided something slightly different.
The clear button failed silently when its thread could not start. The
most destructive control in the plugin, pressed, and nothing happens,
with no way to tell that from a wipe that worked -- while the three
harmless workers beside it do report. Maintenance was the mirror: its
comment promises refusals are said out loud, and then swallowed the
actual failure. Three start-failure paths also bypassed the notify
helper that carries the teardown check, three weeks after it was added
for exactly that.
The database numbers now wait for a real read, like the clear hint
already did. Zero bytes and zero messages read as an empty database, not
as a number nobody has fetched.
SelectionAfterDelete is gone, with its three tests. The accordion has no
selection, so its return value went into a discard -- a function
answering a question the interface does not ask, with green tests
guarding nothing. The project's own self-test README calls that the
anti-pattern of record.
Six new keys replaced by the translated orphans that already said the
same thing. A commit earlier in this cycle is literally called "stop
duplicating a key" and these went past it. The duplicate button also had
the label "Add", which is the one string out of ninety-four that was
never written.
Tests: CleanupDeleteTypes had none, and with the failsafe on -- how a
fresh config ships -- it is the path every cleanup takes. Four now,
including the one that matters: an empty list deletes nothing rather
than everything.
And a self-test for the gate wiring, which is what would have caught the
metadata worker. The unit tests prove the gate works; nothing proved the
workers use it.
Block D of v1.12.0. The line between the two is the whole job, and I got
it wrong once on the way: six cache fields on Tab looked dead because
nothing writes them, and nothing writes them because AutoTellTabTint and
TabTintCache went out with the chat window in cf4705e. Deleting the
fields would have cemented a loss instead of recording a decision.
So they are back, and the sidebar uses them again: an auto-tell tab is
tinted and glyphed from its partner, twelve colours against seven icons.
Four open tells are no longer four identical envelopes in one colour.
Their own header promised the same partner keeps its colour "across
sessions" while hashing with string.GetHashCode, which .NET salts per
process -- every game start reshuffled every tab. FNV-1a now, with a
lowbias32 finalizer that is not decoration: without it a probe over 144
similar keys reached six of the twelve colours, because the caller takes
the low bits with a modulo and FNV leaves those correlated. Three pinned
values guard it, which is also the only assertion that can catch a
regression to a salted hash.
The same question, asked of the three hide conditions this block had
quietly orphaned: HideDuringCutscenes, HideInBattle, HideWhenNotLoggedIn
all had readers in v1.5.6 and lost them in the same commit. Two of them
are states rather than conditions -- a cutscene the user dismissed stays
dismissed until it ends, and combat must not seize a chat that is
already hidden for another reason -- so they come back as a small state
machine with eight pinned transitions, and three toggles whose labels
were already translated in all 25 languages.
Actually deleted, with a reader search each time:
- Six per-tab hide fields. Their reader was the pop-out window and it
stopped consulting them in cf4705e. Per-tab was the wrong unit anyway:
"hide during cutscenes" is a statement about the screen.
- Tab.ChatCodes, whose migration the v16 schema gate had already made
unreachable.
- InactivityHideTimeout and InactivityHideActiveDuringBattle, MaxLinesToRender
which had stopped bounding anything, and the 155 lines of
Configuration.UpdateFrom with no caller at all.
Config version 25, at all three places that carry it. No migration step:
the gate only refuses anything under 16 and Json.NET drops keys it does
not know, so the deleted fields simply stop being written.
One thing a review pass caught that matters more than any of the above:
the clone parity guard had gone hollow. It compares collections by
count, ChatCodes was the only collection the probe seeded, and removing
it left the guard comparing zero against zero. Verified by making
Tab.Clone discard both remaining collections and watching every
assertion stay green. The probe seeds them now, and the same sabotage
fails as it should.
The worst of them made the block's own privacy promise backwards.
PrivacyPersistChannels was given a non-empty field initializer so a
fresh config would record conversations only. Dalamud deserialises with
Json.NET's defaults, which means ObjectCreationHandling.Auto: a
collection field that already holds items gets *populated*, not
replaced. Verified against Newtonsoft 13.0.3 -- saved [] loads as the
initializer, saved [Say] loads as initializer plus Say. So the change
would have unioned the privacy-first list into every existing config on
load and switched channels back on that the user had unticked, while
also making the v24 migration unreachable and its self-test vacuous. The
field is empty again and the seeding moved to CreateFresh, which only
runs when there is no config file at all.
Cleanup could delete a channel it had promised to keep. The allowlist
could only name channels that were already in the database when the
preview ran, so an unrecognised channel whose first message arrived
afterwards fell outside it. Where the failsafe is on, the deletion now
names what goes -- known channels that are not on the list -- instead of
what stays. The window closes completely, and a listed channel that
happens to be empty right now is safe for the same reason.
The cleanup preview was the one long operation that never took the
shared lock, while holding an open reader across a full-table scan.
That is precisely the case the lock was written for.
Clearing the history reported success when it failed. ClearMessages
purges the full-text index between the delete and the VACUUM; if that
step throws, the plaintext stays on disk and the user was told it was
gone. It has its own error string now, in all 25 languages.
Also:
- One busy state for the whole tab. Cleanup, clear, maintenance and
export reach the same store, and per-section flags left two
destructive buttons live at once. The lock turned that into a refusal
rather than damage, but a refusal you have to trigger to discover is
not an answer.
- The gate carries a revision, bumped by every mutating operation that
finishes. A preview taken before a retention sweep no longer passes as
current afterwards: comparing it against the settings alone cannot see
that the rows it counted are gone.
- Database metadata moved to a worker. Checking "is anything busy" first
is not enough, because an operation can take the lock in the gap
before COUNT(*) runs, and then the game stands still for a whole file
rewrite.
- The clear hint stays hidden until the count has actually been read.
"0 messages are stored" in front of the clear button is a lie told at
the worst possible moment.
- Refusal notices read the operation once. Guard and name were two reads
of the same field, so a run finishing in between printed a sentence
that stopped at the colon.
- The retention sweep cannot start twice. The gate only goes busy once
the worker reaches TryBegin, and the due-check runs every tick.
- Teardown waits up to five seconds for the store to come free rather
than disposing the connection under a running VACUUM.
- Maintenance has its own flag and says so when it is refused; reload
gets the same guard as its neighbour; the breakdown tree keeps its
open state across a language switch.
Two sections that had backends and no buttons.
Database: path, size, WAL size, message count, and a clear button. The
numbers refresh at most every five seconds and not at all while a long
operation owns the store -- MessageCount takes the read lock, and asking
for it during a VACUUM means waiting for the whole file to be rewritten,
on the draw thread. The old version called ClearMessages straight from
the draw thread, VACUUM included; it runs on a worker now.
One line beyond the old layout sits above the clear button: how many
messages are stored, and that exporting keeps a copy. Whoever is about
to throw the history away should be told there is a way not to.
The legacy Chat 2 files only get a block when they are actually on disk,
and the advanced tools only appear when the section is expanded with
Shift held. The message injector is not back: it was deleted with the
tab and writing 10,000 fake messages into a user's real database is not
something to rebuild on the way past.
Retention: an "apply now" button, the running hint, and the last-run
line, which v1.11.0 shipped as an English literal while both strings sat
translated in all 25 languages. Plus reset-to-spec next to the existing
clear-overrides, since the two answer different questions and both were
already translated.
Retention_Apply_Tooltip stays unused and gets a replacement. It ends
with "Save your changes first", and the window it was written for had a
Save button.
Also here, found while wiring the manual trigger:
DbOperationGate.End now takes the operation it releases. It used to
reset blindly, on the reasoning that a worker must be able to release
from a finally without knowing whether it acquired. That is backwards: a
worker whose TryBegin was refused also runs its finally, and a blind
reset there hands away the lock of whichever operation actually holds
it. Worse than no gate, because the refused worker walks off believing
it did nothing while a VACUUM starts under somebody's open reader.
IsAllowedForStorage applied the unknown-channel failsafe to known
channels too. Untick Say in the grid, leave "Save unknown channel types"
on, and Say kept being written -- and that failsafe is on by default.
So a config that never met the wizard ran with the filter enabled, an
empty list and the failsafe on, which stored everything while the
filter's own description promised "only messages from allowed channels
are written to the database". The grid was inert for exactly the users
who had not been walked through the wizard.
The rule now reads: on the list, or unknown and the failsafe allows it.
A known channel the user did not pick stays out.
That correction alone would turn "stores everything" into "stores
nothing" for those same configs, so two things move with it:
- Config v24 switches the filter off where it was on with nothing
picked. Same behaviour as before, stated where the user can see it,
and one line in the log saying so. A config that does have picks keeps
them and starts honouring them, which is the point of the change.
- A fresh config seeds the list from PrivacyFirstWhitelist instead of
starting empty. Privacy by Default was already the documented intent;
it just relied on the hole to stay usable.
The rule lives in its own type now. Configuration implements a Dalamud
interface, and the build suite cannot load Dalamud.dll -- the runtime
resolves the declaring type before reaching the method body, so even a
static call on it fails. Fifteen cases pin the truth table and the
migration condition; the self-test checks the running config is not in
the state the migration exists to undo.
The exporter has worked since v1.4.8. The form that drives it went out
with the old settings window in May, which left PRIVACY.md promising an
access request the plugin had no way to answer.
New section in the data and privacy tab: time range, sender substring,
channel groups, format, and a save dialog. Form state lives in the tab,
not the config -- a filter describes one action, and a stale "last 7
days, sender Mira" reappearing weeks later is a worse start than an
empty form.
StreamForExport now takes a caller-owned connection. The reader stays
open for as long as the file is written, seconds to minutes on a large
history, and chat keeps arriving throughout -- so the primary connection
would be read here and written by UpsertMessage at once, and
SqliteConnection is not thread-safe. Holding the read lock instead would
trade that for freezing the game.
ChannelGroups lifts the eight groups out of the deleted tab and finishes
them: 37 of 89 channels belonged to no group and were therefore
unreachable in the UI. Game Master channels follow ChatTypeExt.Parent(),
so GmTell sits with the other tells rather than under system traffic --
an access request that quietly drops part of what it promises is the
dangerous kind of gap.
Also here:
- OpenSecondaryConnection disposes on a failing pragma. Open can succeed
and journal_mode=WAL still time out, and with Pooling=false the
connection then survives until a finalizer reaches it. Affects the
full-text rebuild worker too.
- StreamForExport builds its logger before the reader, so a throwing
CreateLogger cannot leave a reader nobody owns.
- The export thread takes the gate itself instead of the caller taking
it first. Acquiring before Start would strand the gate for the session
if thread creation failed, and the gate also holds back the sweep.
- Notifications are skipped once teardown has started. The thread has no
cancellation path and finishing the file is right, but reporting it to
a plugin that is gone is not.
- Transient widget rows that return their value instead of saving it.
Writing the config file on every keystroke of a sender filter would be
both pointless and slow.
- Five translated keys for "another database operation is running", in
all 25 languages. Two of the four operation names have no trigger yet;
they arrive with the cleanup and maintenance sections.
Two changes to MessageExporter before it gets a caller.
It read SenderSource and ContentSource, the raw SeStrings. TextValue on one
holding an auto-translate phrase reaches SeStringEvaluator, which asserts it is
on the main thread and throws unconditionally when a macro resolves a global
number. An export belongs on a worker, so that would abort it partway and leave
half a file.
The plan called for resolving text in batches on the framework thread. Not
needed: Message.Sender and Message.Content are already-resolved chunk lists --
ChunkUtil turns auto-translate into text at ingest, and the full-text index reads
them exactly this way. Same strings, no evaluator, no thread affinity, and no
batching machinery.
Second, the file handling. The format was validated after the StreamWriter was
opened, so an unknown format left a zero-byte file where a previous export had
been. It is checked first now, and the write goes to a .part file that is moved
into place at the end. A crash halfway used to leave a file that opens cleanly
and is quietly incomplete -- which on the path a GDPR access request goes out on
is worse than an obvious failure.
Almost none of this is reachable from the build suite: ExportToFile takes
IEnumerable<Message>, Message needs SeString, and xUnit cannot load Dalamud.dll
-- even an empty list fails, because the runtime resolves the parameter type
before the body runs. So the format mapping is pinned there and the rest by a new
self-test, which builds probe messages with deliberately empty SeStrings: if the
exporter ever reads them again, the text comes out blank and it fails.
Generalises the retention-sweep lock, which already solved this for a single
case: it stopped a manual sweep from racing the automatic one, and nothing else.
Export, cleanup and clear need the same protection against each other, and for a
sharper reason. An export leaves a reader open on the primary connection
deliberately outside _readLock, because the enumerator is consumed lazily by its
caller. A VACUUM starting while that reader lives meets an active reader on a
connection Microsoft documents as not thread-safe, and PerformMaintenance sets no
command timeout, so it inherits five seconds before throwing -- after the DELETE
has already committed.
TryBegin refuses rather than queues. Every one of these is user-initiated, and a
wipe that fires minutes after the click is worse than one that declines. End is
idempotent and does not check which operation ends, so a worker that throws
before acquiring can still release from its finally block.
Current is volatile because the draw thread reads it every frame to decide which
buttons are disabled. Blocking on the lock to find that out would freeze the game
for the length of a VACUUM, which is the exact failure this is meant to prevent.
Pure state machine, so the transitions are pinned without a database or an ImGui
frame -- including that exactly one of 64 competing callers wins.
TopTabBar had no observability at all -- no counter, no self-test reaching it.
It now exposes LastRenderedUnderlineCount and MainWindow hands the component
out the same way it already does for the sidebar.
Three cases: one of two tabs active draws exactly one underline, a null active
tab draws zero, and an active tab that is not in the list also draws zero. The
last one matters because the strip skips popped-out tabs, so the active tab
legitimately need not be among the drawn ones.
Drives the real Sidebar.Draw and reads the render counter, so a regression in
the draw path fails instead of a parallel calculation passing.
Three cases: one of two tabs active draws exactly one surface, a null active
tab draws zero, and icon-only mode still marks the active row.
Zero is a legitimate state, not a failure -- PickMainActiveTab returns null
when every tab is popped out, so the invariant is "at most one", not "exactly
one".
Every widget in its states, reachable via /hellion widgets. The point is to
check them one at a time before they land in real components: the v2.x style
engine grew three primitives that were never wired to a call site
(DrawGlowBorder, DrawSlipPolygon, DrawHonorificHeader), and this is the cheap
way to notice that before a cycle closes.
DEBUG-only, like SeStringDebugger. It is a verification aid, not a feature, so
it never reaches a release build -- verified against a Release compile.
The header line also shows the live GlobalScale and the hover registry size,
which makes both the scaling work and the eviction contract observable while
dragging the Dalamud scale slider.
The sheen kept its own Dictionary<string, DateTime> of start timestamps and
only cleared an entry in the un-hover branch. A row that disappeared while the
pointer was on it left its entry behind until the plugin reloaded, which is
exactly what happens to temp tabs under the LRU limit.
It now takes the held intensity from HoverState and draws on the rising edge
only. The alpha falls off as the value climbs, so the sweep has faded out by
the time the surface underneath is fully in. On the way out it simply does not
run, which is what stops it from travelling backwards -- the old
SheenStarts.Remove prevented that by resetting, and dropping the map without
this guard would have reintroduced it.
The sidebar call site built "sidebar.tab.{guid}" per row per frame, two
allocations each. Master spec 5.3 asks for constant keys, and 7.5 for a stable
allocation count. It now uses ImGui.GetID("row"u8), which is allocation-free
and seeded from the window's ID stack, so the same literal stays distinct per
window and per PushID'd tab.
HoverSheenAllocStep pinned the old dictionary contract and is replaced by
HoverStateFootprintStep, which pins the same property against the registry:
repeated queries add no entries, and an element that stops being queried leaves
the map instead of leaking.
DrawHoverSheen measured its own elapsed time against DateTime.UtcNow and gave
up after 0.65s, so a row stopped reacting while the pointer was still on it.
There was no held value to interpolate colours against.
HoverState keeps one 0..1 intensity per element, rising at 14/s and falling at
8/s. Slower out than in is what makes the fade read as deliberate.
Query and advance are separate on purpose. Several SelfTest steps call
Sidebar.Draw against the live tab list, so the same element gets submitted up
to three times in one frame, twice from a window the mouse is not over. If the
query advanced the value, the last caller would win and the fade would run
backwards. Query only ORs the hover flag; BeginFrame does all the moving and
the eviction.
BeginFrame sits above the HideInLoadingScreens and New Game+ early returns, so
a hidden main window still lets pop-out hovers fade out instead of freezing
mid-blend.
FrameLerp gains Ramp: Smooth approaches asymptotically and never arrives, so a
value driven by it would never reach zero and never become evictable.
ReduceMotion short-circuits before the map is touched, returning a hard 0 or 1.
An infinite rate would produce NaN and poison the entry for the session.
TabsListLock never covered ChatColours, PrivacyPersistChannels or
RetentionPerChannelDays, yet the settings UI mutates them from the draw thread
while the retention thread can be serializing the same config. Adding a new key
to a dictionary or a new element to a set invalidates a running enumeration, so
this could throw from inside JsonConvert.SerializeObject.
Not a corner case: the colour picker lists 66 channels but only 25 ship with a
default, so the first edit of any of the remaining ones inserts a new key -- and
the reset button removes a key, which makes the next edit a fresh insert again.
The readers matter as much as the writers. IsAllowedForStorage runs per message
on the worker thread and asks PrivacyPersistChannels whether a channel may be
stored; a Contains racing an Add that resizes buckets can answer wrong, and that
answer decides whether a message is written to disk. The retention sweep
enumerates RetentionPerChannelDays on the framework thread while the wizard can
clear it -- Clear does not throw there, it just cuts the enumeration short, so
the sweep would run on half a policy.
New ConfigMapsLock covers all of it. It sits inside TabsListLock (that edge is
real, AutoTellTabsService calls SaveConfig while holding the tabs lock), never
the other way round -- so every call site closes the lock before saving.
Tab.Clone silently dropped Icon and ChatCodes, both serialized. A reflection
test now walks the serialized fields so a future one cannot slip past.
Also: CurrentTab read Count and [0] as two separate accesses.
ImGui sliders report a change in every frame the value moves, so dragging one
rewrote the full 31 KB config to disk per frame -- serialize, fsync and rename,
synchronously on the draw thread. Measured on Linux/Wine that showed up as a
114 ms frame while the plugin itself only drew for 2.9 ms; the rest was waiting
on the write.
The five shared slider helpers now defer SaveConfig to IsItemDeactivatedAfterEdit,
matching what ChatColourPicker already did for the colour wheel.
Renaming a tab needed its own path: the input lives inside a popup, and ImGui
never re-submits it when the popup is dismissed by clicking outside, so
IsItemDeactivatedAfterEdit would not fire and the new name would be lost. A
pending-rename marker scoped to the owning tab flushes it when the popup is
gone -- scoped, because every other tab's Draw reaches that branch too.
DeferredSaveFrames is removed: the debounce was fully wired but never armed,
and this approach makes it redundant.
The eye/hide button now hides the HellionChat window (runtime-only, via a new
DrawConditions gate) instead of toggling native-chat suppression, matching 1.5.6.
The chat-activation keybind (Enter / "/"), whose dispatch was a dead stub in the
KeybindManager since the v1.6.0 rewrite, is re-wired to MainWindow.ActivateChat:
it un-hides, opens if closed, brings the window to front and focuses the input --
so the chat reacts to Enter again from any state. /hellion is a reliable one-press
recovery (Toggle now clears the hide), and the window always shows on login
(start state no longer read from the persisted flag). Adds HideRestoreSelfTestStep.
Brings back the 1.5.4 quick-picker lost in the v1.6.0 rewrite: a palette button
in the input-bar button row (left of the cog) opens a popup that switches the
theme (built-in + custom, active row checked) and jumps between chat tabs without
opening settings. Theme switch mirrors the settings ThemePicker; the tab jump
routes through a new MainWindow.ActivateTab that replays the click path
(previous -> set -> OnTabActivated) so tell/unread handling is unchanged. Main
window only -- pop-out InputBars get a null picker. Adds QuickPickerSelfTestStep.
The v1.8.x sidebar/top-bar rebuild never re-rendered the unread dot, so inactive tabs showed no badge even though the counter was tracked. Draw it again top-right of the tab icon in both Sidebar and TopTabBar, gated on !active && UnreadMode != None && Unread > 0, and zero the active tab's counter every frame (1.5.6 convention) so the dot only ever shows on tabs you are not looking at.
The unread decision moves to MessageManager.ShouldCountUnread and snapshots the active tab + whether it shows the message once before the loop: Unseen suppresses unread on an inactive tab only when the active (real, post-F2) tab also shows that message. Adds SidebarUnreadDotStep (render) and UnreadDecisionStep (decision) self-tests (step count 32 -> 34).
Plugin.CurrentTab now delegates to MainWindow.ActiveTab (fallback Tabs[0]) instead of the never-assigned LastTab index, so the game hooks, unread tracking, notification sounds, InputDisabled and Foray/Eureka paths all operate on the tab the user actually has selected. The dead LastTab/WantedTab fields and both WantedTab writes are removed.
A reference-based MainWindow.ResetActiveTabIfRemoved repairs the active-tab reference on eviction/logout (immune to the SaveConfig temp-tab strip window). The worker-thread eviction path marshals it onto the framework thread so the strip mutation serializes with Draw; logout is already framework-thread. The Draw-seed gains a lazy re-seed for a wholesale config swap. Adds CurrentTabCouplingStep (headless) and the interactive CurrentTabGuidedStep self-test (step count 30 -> 32).
OnTabActivated clears the runtime tell state the game-side detour leaves on a tab (CurrentChannel tell target + partner label) when a DIFFERENT tab becomes the input surface, so a normal typed line can no longer route as a silent /tell to the old partner. Re-clicking the active tab and tabs carrying their own Tab.TellTarget binding (leg1) are preserved.
All four activation paths route through it: Sidebar, TopTabBar, ChannelPopoutPool.TryOpen, and the MainWindow draw-seed. EnsureCurrentChannel becomes a pure derive-helper reached only via OnTabActivated. Adds the TellResetOnActivateStep self-test (step count 29 -> 30).
Input-bar tells went out as a bare "/t" without the target, so the game
rejected them with "you must add the World name". Rebuild the full
"/tell name@world" from the 1.5.6 target chain in a pure BuildOutgoing:
- leg2/leg3 gated on current == Tell so a stale tell target on a Say tab
can't send a say line silently as /tell (CORR-1)
- world-resolve gate: an unresolvable world falls back to the channel
prefix, never "/tell name@ text" (COMP-1)
- ResetTempChannel after the send, tell-only
Also clear the runtime tell state on PromoteToPermanent so a promoted tab
can't route a typed line to the old partner, and surface the tell partner
("-> name@world") in the channel pill so a misfire stays visible. Adds
tell-routing and pill-transparency self tests.
InputPreview was only rendered for PreviewPosition.Top/Bottom (the
DrawConditions IsWindowMode gate). Inside-mode (the default) and
Tooltip-mode had no caller at all because v1.5.6's inline-render path
lived on the deleted ChatLogWindow and was not migrated to the v1.7.0
Components-Layer.
Wire Inside-mode by calling CalculatePreviewHeight + DrawPreview
inline from MainWindow.DrawMainArea between the message-list child
and the input bar, with the message-list height reserved for the
preview block. Wire Tooltip-mode by sampling IsItemHovered() on the
input text widget inside InputBar.DrawInputField (analog to the
existing _isFocused = ImGui.IsItemFocused() idiom on the same line)
and exposing it as WasInputTextHovered; MainWindow opens the tooltip
after _input.Draw when both the hover-flag and PreviewPosition.Tooltip
are active.
Plan-drift acknowledged: the plan stated Plugin.InputPreview is
statically reachable, but the property was declared as an instance
member on Plugin.cs:101. Hoisted to internal static to match the
plan's intention (analog to Plugin.Config); updated the single
external instance-access site in PluginLifecycle.RegisterWindows
to the type-qualified form.
Verified in-game: Inside-mode preview block appears between message
list and input bar on first keystroke; tooltip-mode shows preview on
text-field hover only; Top/Bottom-mode unchanged; empty buffer hides
the preview in all modes. dotnet build clean, dotnet csharpier check
clean.