The self-test was the worst of them, because it is the only tool that makes block
A judgeable at all and it destroyed itself on use. It returned Fail while the
atlas was not ready, and its own weight buttons trigger a rebuild -- which is
asynchronous, not synchronous as the comment claimed. Click a weight, watch the
step go red. It waits now, like the two existing steps that had already worked
this out.
The translated stand-in was drawn in the meta face, whose glyph range is ASCII
plus a middle dot. Fifteen of the twenty-five translations reach outside that, so
"not logged in" would have rendered as a row of question marks in Japanese,
Russian, Korean, Greek and eleven others -- and the measured width would have
been the width of the question marks, so the right edge would have drifted too.
The plan said to keep it on the body face and the comment in FontManager says so
as well; the code simply did not. The detail is two parts now rather than one
string, and each part is measured under the face that draws it.
The header was the only place in the UI pushing RegularFont directly, without the
FontsEnabled-or-UseHellionFont check every other push site makes. With both
toggles off the window draws in AXIS and the header would have drawn in
Inter-Light, at a different size, in a band measured against a third one.
And the icon sat on the text baseline. FontAwesome is a fixed-width handle built
at Dalamud's own size and does not follow the plugin's font setting, so at any
other body size it hangs. The sidebar already knew this and centres against the
row; the header does the same now.
Two smaller ones came along: the minimum-height threshold was compared unscaled,
which would have dissolved it at higher display scales, and the height passed
into that check included the input row -- so "is there still room to read" was
measuring the wrong thing. Both callers now say what sits below them.
Drawn before the scroll child in each, so it stays put while the log moves. The
child's height is deliberately left alone: it is given as a negative value, and
ImGui resolves those against the space still available from the current cursor --
which the header has already reduced. Subtracting it a second time would have
opened a gap of exactly the header's height above the input row, in both windows.
Where the name shows follows one rule: never twice on the same screen. The
sidebar layout is the only place nothing else carries it, so that is the only
place the header says it. The top-tab strip carries it, and a pop-out with its
title bar on carries it in the title.
The pop-out's plain title row is gone, replaced by the header. The comment it
left behind is worth keeping in mind -- an earlier header row was removed exactly
because it repeated the tab name one line below the title bar. That warning is
now the rule rather than a reason to have no header at all.
The trailing detail is the home world and the clock, and the clock follows the
same Use24HourClock setting the message timestamps do. Two clock formats in one
window, with the header sitting directly above a column of timestamps, would be a
defect rather than a preference. Culture is pinned for the same reason it is
pinned in the message list.
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.
Reported from the field: a new tell from someone whose tab is popped out
throws the main window onto the General tab, every time.
The router revealed the tab by activating it in the main window. That
window does not display a popped-out tab -- PickMainActiveTab re-anchors
on the next frame, and it anchors to the FIRST non-popped tab in list
order, not to the one the user was reading. So the reveal did nothing it
intended and threw away the active tab on the way.
Nothing needed revealing in the first place: the tab was already on
screen in its own window. That decision now lives in PlanTellReveal,
next to the pop-out helpers it belongs with, pure and pinned by nine
facts -- the mode, the switch and the popped-out state have eight
combinations between them and only two of them should touch anything.
ActivateTab refuses a popped-out tab outright as well. The router is the
caller that got it wrong, but the invariant belongs to the window: its
active tab is never one that something else is drawing.
"Horizontal scanlines" names it exactly. An alpha ramp from 0x34 to 0 has 52
distinct values, so across a 600px pane each one owns a stripe about 12 pixels
tall, and the eye reads those stripes as scanlines. Raising the contrast only
buys more of them, thinner.
So it dithers. Four ramps at a quarter alpha each, every one ending slightly
lower than the last, so they share a starting colour but run at different
slopes. Their step boundaries land on different rows, and where one layer has
stepped up its neighbours have not, which puts the blend between two quantised
values. Same total tint, four times the effective resolution, three extra draw
calls.
Layers are staggered by slope rather than offset on purpose: shifting them would
leave a gap at the top where fewer layers overlap, which reads as a bright band.
That trades one artefact for a worse one.
The tint is roughly twice as strong now, which the settings pane wanted anyway,
and strength is a parameter so the chat log can stay at 45% of it.
Both remaining complaints had one cause. The window paints its own background,
and the backdrop was filling the same area a second time. Two stacked layers
turn a translucent window solid, which is the density over the chat log.
And an opaque fill has to carry the entire gradient by itself. That is where the
banding came from: a shallow ramp across an opaque surface crosses so few 8-bit
values that each one covers a visible stripe. Nothing sits underneath to break
them up.
So it tints now. Near-transparent white at the top, near-transparent black at
the bottom, straight over whatever the window already drew. The window colour
stays visible, the ramp only shades it, and the game showing through disperses
what little stepping is left. The sidebar does the same with a flat black wash
rather than a repainted darker surface.
Motes are roughly half as bright everywhere, and the chat log takes 10% of that.
DrawVerticalGradient stays for the segmented control, which paints a surface
that genuinely is its own rather than one already drawn underneath.
Three problems from one screenshot, and the same root cause behind two of them.
The pane painted at full opacity regardless of the window it sat in. BgAlpha
only ever reaches WindowBg, so a draw-list fill ignores it entirely, and a
deliberately translucent settings window came out as a solid block beside a
translucent chat window. The backdrop now reads the alpha out of the resolved
WindowBg colour and carries it through the gradient, the accent wash and the
motes.
The banding is 8-bit quantisation, not a rendering fault: a gentle ramp across a
tall surface crosses so few distinct values that each one covers a visible band.
Halving the range leaves fewer and fainter steps, and the translucency now
underneath them breaks up what remains.
Motes over the chat log drop to 18%. A settings page is read in glances and can
carry motion behind it; a chat log is read line by line, where anything drifting
behind the text competes with it. Intensity is a parameter rather than a second
particle system, so the two surfaces share one implementation.
The motes were too subtle to register, so: seventy instead of twenty-four, wider
radius range, and each one is a soft halo with a brighter core rather than a
flat disc. At this size a single filled circle reads as a speck of dirt on the
screen; the ring around it is what makes it look lit.
The backdrop moves into its own component and the chat log gets it too. Two
windows in one plugin looking like two separate products was half the reason the
settings window read as untouched -- the chat had been reworked in v1.10.0 and
the settings had not, and no amount of work inside one window closes that gap.
No accent wash over the message list. It works on a settings pane, where the top
of the surface is a heading; over a chat log the oldest visible messages would
sit in a tinted band and read as highlighted.
Registered transient rather than singleton, so two surfaces on screen own
separate mote sets instead of sharing one drift pattern -- which would be
visible the moment a popout sits next to the main window.
MainWindow's constructor changed, so this needs the DI smoke pass.
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.
Sidebar, top tabs and status bar each read Config.Tabs on their own, unlocked,
while the worker added or evicted tabs. That gave three independent views of a
moving list: an index built in one place could resolve to a different tab a few
lines later, which showed up either as an out-of-range crash on the draw thread
or -- worse, because it is silent -- as a click landing in someone else's tell.
MainWindow now takes one snapshot under the lock and passes it through the whole
frame. Deliberately a shallow copy: tab identity is compared by reference all
over the draw path, so cloning would break every ReferenceEquals and Contains.
ChangeTabDelta and ResetActiveTabIfRemoved run on the framework thread and keep
their own locked reads instead; ThemeQuickPicker locks its own copy, since
reaching it would mean threading a parameter through InputBar, which popouts
share and which has no tab list.
Widget IDs move from list position to tab.Identifier. ImGui carries popup and
widget state across frames under that ID, so a position-based one re-binds an
open context menu to a different tab as soon as the list shifts -- a snapshot
cannot fix that, it spans frames. This also resolves top tabs visually merging
into each other when the list changed.
The sidebar section headers counted over the live list while the rows came from
BuildRenderOrder, which skips popped-out tabs. Both sides take the same
predicate now, so the count matches what is drawn.
- gate keybind pill-sync on IsChannelOrExistingLinkshell so an empty
linkshell slot no longer desyncs the pill from the real send channel
- close manually-popped pop-out windows on logout via an IsOpen filter
instead of the PopOut flag (which manual pops never set)
- read the router's tell-tab lookup through a lock-wrapped accessor so the
framework thread cannot enumerate Config.Tabs mid worker-thread mutation
- add a "switch on every tell" toggle (default on) and make the auto-open
mode pick the matching layout, so Sidebar vs Top-tab are distinct
- comment corrections (stale/contradictory text, TEST-MIRROR path depth)
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.
Re-wires four 1.5.6 settings that survived the v1.6.0 rewrite as dormant config
fields but lost their UI + consumers:
- ShowTitleBar / ShowPopOutTitleBar: gate ImGuiWindowFlags.NoTitleBar on the main
window (ResolveFlags) and pop-out windows (new PreDraw). Inverted logic matches
1.5.6 (flag set only when the toggle is off).
- ShowHideButton: gate the input-bar hide button on the toggle.
- Use24HourClock: add the toggle (MessageList already consumes the field).
New 'Window style' section in WindowTab; Use24HourClock in ChatTab display modes.
MainWindowFlagsStep extended with the NoTitleBar fresh-base contract.
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).
Seit dem v1.7.0-Components-Layer-Refactor lebte der PayloadHandler-
Popup-Render in MainWindow.Draw als _messages.DrawHandlerPopups()-
Aufruf nach dem ##hellion-body-Child-Close. ImGui.OpenPopup (in
RightClickPayload, innerhalb ##hellion-main-area-Child) und
ImGui.BeginPopup (in PayloadHandler.DrawPopups, im MainWindow-Root
nach Child-Close) hashed die Popup-ID per g.CurrentWindow->GetID(...)
window-relativ — also unterschiedlich. OpenPopupStack-Eintrag wurde
nie gefunden, popup.Success blieb false, _popup wurde auf null
zurückgesetzt. Alle vier Popup-Switch-Cases waren tot: URL-Rechtsklick,
Player, Item (inkl. EventItem-Subpfad), Status.
Fix nach v1.5.6/ChatTwo-Pattern: _handler?.Draw() ans Ende von
MessageList.Draw() verschieben. MessageList läuft im
##hellion-main-area-Scope und öffnet selbst kein Child, also teilen
OpenPopup und BeginPopup denselben Window-Stack. ID-Hash matched,
Popup rendert.
DrawHandlerPopups-Wrapper aus MessageList und der Aufruf in
MainWindow.Draw entfallen — kein toter Code mehr (grep
DrawHandlerPopups: 0 Treffer).
Hypothese verifiziert gegen imgui.h:845 + imgui.cpp:12282+12528
(beide BeginPopup-Hash und OpenPopup-Hash sind window-relativ),
v1.5.6 ChatLogWindow.cs:1667 (handler.Draw im
##chat2-messages-Child), ChatTwo ChatLog.Window.cs:620 (identisches
Pattern). Reader-Lock auf tab.Messages bleibt während DrawPopups
gehalten — identisch zu v1.5.6-Semantik.
Verifiziert in-game (Flo): Linksklick auf URL öffnet Browser direkt
(v1.5.6-konform), Rechtsklick öffnet wieder das Kontext-Popup. dotnet
build clean, dotnet csharpier check clean.
Plan-Runde 1 dieses Cycles (4-LOC-Reroute LeftClick → RightClickPayload)
wurde verworfen weil empirischer Test zeigte dass auch Rechtsklick
broken war — der Reroute hätte das Symptom nur sichtbarer gemacht
ohne die Root-Cause zu adressieren.
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.
A2 completes the deferred Lender-cycle from A1 and addresses the
handler.Draw() gap identified in I code-quality-review:
- MainWindow ctor takes Lender<PayloadHandler> as new param (DI-reg
extended in PluginHostFactory); _handlerLender.ResetCounter() called
at top of Draw() as primary pool-reset path (InputPreview has the
secondary defensive fallback for MainWindow-closed edge case)
- MessageList.DrawHandlerPopups() new passthrough method
(=> _handler?.Draw()) provides the per-frame popup-tick that
PayloadHandler needs to render the right-click context popup;
MainWindow.Draw() calls it after the message-list body renders
Without this fix, right-clicking a player/item/status in the chat log
would silently fail to open a popup (handler.Draw() never fired for the
MessageList's _handler). Phase 3 smoke steps 3/4/5 unblocked.
Polish-Sweep + Smoke-Gate are the last cycle-tasks.
Replaces the v1.5.6 `LogWindow.LastViewport/LastWindowPos/LastWindowSize`
window-instance state with public/internal MainWindow surfaces refreshed
at the top of Draw() each frame. PayloadHandler.MoveTooltip in Phase 2
will read these to filter cross-viewport AddonLifecycle events and to
reposition the native item tooltip away from the chat window.
LastViewport is `internal unsafe` (not public) — the only consumer is
PayloadHandler.MoveTooltip in the same assembly; keeping the raw pointer
out of the public surface is the safer default.
Split from Sub-Task A — Lender injection lives in A2 (after F's DI-reg).
Three smoke bugs from the second in-game test:
1. The channel pill was draw-list only, so it didn't react to clicks
and there was no way to switch channels inside a tab. The pill now
has a hit area on top and opens a popup that lists every ChatType
in tab.SelectedChannels with a ToInputChannel mapping; selecting
one writes through CurrentChannel.SetChannel.
2. Switching to Allgemein / Gruppe / Linkshell still showed "—"
because tab.CurrentChannel.Channel stayed at Invalid until somebody
set it. The sidebar now seeds CurrentChannel on tab activation by
walking SelectedChannels for the first key with a valid mapping,
so every tab opens with its own real channel instead of inheriting
the FC default.
3. MainWindow still surfaced an outer scrollbar next to the message
list's own scroll. Adding NoScrollbar + NoScrollWithMouse to the
window flags strips the second bar — the body child owns scroll on
its own.
Plus the system-icon path: System / BattleSystem / GatheringSystem /
Error / Notice / LootNotice all map to fa-cog now, so the System tab
renders the gear instead of falling back to the generic comment.
Six new ISelfTestStep entries register with the Dalamud SelfTestRegistry:
SidebarModeAutoSwitch probes the width threshold both above and below the
exact boundary so the >= contract stays pinned; ColorEditorBuffer is a
placeholder until the picker arrives; ConfigMigrationV20 asserts the
schema stamp and the five v20 field defaults; HoverSheenAlloc drives 100
hovered frames against three constant keys and a final un-hover sweep to
exercise the cleanup branch; HonorificHeaderRender runs one Draw call on
the live component to catch IPC fallback crashes; PerformanceBaseline
prints a JSON line of the IO counters so the cycle notes can pick up a
snapshot. MainWindow gets internal accessors so the probes can reach the
sidebar and honorific header without widening the public surface.
Top-level chat window composes HonorificHeader, Sidebar, MessageList,
InputBar and StatusBar in the layout from the master spec: header row,
horizontal body (sidebar + main area with messages + input), status
strip pinned to the bottom. Component types are fully qualified through
the Ui.Components prefix so the v1.5.6 Ui.StatusBar type cannot shadow
the new layer through parent-namespace resolution before it is removed.
Toggle is a new-shadow on Window.Toggle so the open path also writes
Config.MainWindowOpen; OnClose covers the close path through the base
behaviour. InputBar.Height is now public so the layout math can reach
it from outside the components folder.