A language review over all 24 files found that the translation batch had
been written as if the file were empty. It is not: eleven years of
FFXIV vocabulary and several hundred existing values already decided how
this plugin says things, and the new strings disagreed with them.
Three said something wrong rather than something unusual:
- Norwegian called opacity "tetthet", which is density -- and the same
file uses that exact word for compact density, so one word stood for
two different settings.
- Danish called the brand section "Brand", which in Danish is fire.
- Turkish called a manifest a notification, in a window that already has
a notifications section.
Then the glossary itself. "tab" is zakładka in Polish, tab in Romanian
and Danish; "plugin" stays plugin in five languages that had translated
it; "pop-out" is a loanword in six. The Shift key keeps its name in
Swedish, Finnish and Italian, because that is what is printed on it.
Fixing pop-out also shortened the three longest row labels in the batch
past the point where SettingRow would have clipped them.
Czech was the clearest outright error: the existing file addresses the
reader informally throughout, and five new strings switched to the
formal form.
And a gap in my own previous fix: I had moved four of the five tell
strings to the established loanword and missed the fifth pair, so the
failed-tell warning still said "Flüstern" while its neighbours said
"Tell".
435 keys, 25 files, no gaps, no orphans, no placeholder drift, and every
one of the five tell keys now carries the same word in every language.
Whether German and French should say Flüstern and message privé rather
than tell is a real question -- both game clients do -- but that is a
glossary decision for the whole file, not something to introduce through
four new keys.
A review pass found the translation commit had introduced a split rather
than closed one.
Every existing string in this plugin leaves "tell" untranslated -- it is
what the game calls the thing, in twenty values across the German file
alone. My new keys translated it, so the channels tab showed "Auto-Tell-
Tabs" and, two rows below, "Flüstern automatisch öffnen". Thirteen of
fourteen sampled languages had the same break. The four affected keys
now use the established word, which also fixes a German line that had
tells "aufgehen" like a door.
Turkish said the opposite of what the control does: saydamlık is
transparency, the slider is opacity, and 1.0 means fully opaque. Every
other language uses the opacity word. Corrected, along with the inactive
one beside it.
Compact density had grown a second key for a field that already had one.
Appearance_UseCompactDensity_Name and _Description were sitting there
with no caller -- which is precisely what this cycle exists to fix -- so
the chat tab uses those and my duplicate is gone again. The German
wizard label for the same field said "Kompakter Density-Modus"; it says
the same thing as the other two now.
Also: Hungarian called a pop-out window "kiemelt" (highlighted), the
project's word is "kiugró"; German called it "ausgeklappt", which is
what a menu does; Italian and Polish had a clumsy inactive-opacity
label.
435 keys, all 25 files, no gaps, no orphans in either direction.
One claim in the previous commit was wrong and is worth stating plainly:
it said only log lines, thread names, developer tools and brand strings
remained English. The appearance tab is still English throughout -- the
theme picker, the colour editor, the font section, the live preview --
and so are parts of the database viewer, which is a documented user
command, not a developer tool. That is a separate block, not a footnote.
The settings window was rebuilt in v1.10.0 and v1.11.0 with its labels
written straight into the C#. Every section heading and most row labels
across five tabs read English in all 25 languages, which the memory
notes had accepted as a known backlog.
Forty-nine keys close it: seventeen section headings, the rows under
them, and the two layout choices. All 25 languages, 437 keys each, no
gaps and no placeholder drift in either direction.
The layout labels are a property rather than a static array now. A
static one would hold whichever language the plugin started in, and this
plugin switches language at runtime.
What stays English, deliberately: log lines and thread names, which no
user reads; the two developer tools behind Shift plus Ctrl+Shift, per
the decision that developer surfaces stay English; and the About tab's
brand lines, attributions and licence identifiers, which are names.
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 two menu items went out with the v1.5.6 chat window in cf4705e.
Their strings stayed, in the base file only, so the feature was gone and
its labels were untranslated in all 24 other languages.
They live in the chat context menu now, next to hide-chat, which is
where the rest of that window's items ended up. The game expands <flag>
and <item> at send time, so appending the literal token is the whole
implementation; each entry is disabled while its precondition is missing
so a token cannot be sent only to expand into nothing at the other end.
ChatLog_ScrollToBottom_Tooltip is the one that stings: it has had a
caller since v1.9.0 and existed in the base file alone, so every player
outside English read that tooltip in English.
All three are translated now, and with them the resource files are at
full parity for the first time -- 25 files, every key in every one.
Every field here has a reader in the running plugin and had no control
in the window. The config file was the only way to any of them.
HideChat is the one that mattered. It defaults to on, it suppresses the
game's own chat window, and the only other path to it is a right-click
item that sets it to true and can never set it back. Anyone who used
that item once had to edit JSON to get their chat back. The new toggle
reads and writes the same field, so the two agree instead of fighting.
The rest, by tab:
- Window: the three remaining hide conditions, the preview minimum that
belongs to a toggle already on screen, native item tooltips and their
offset.
- Chat: an emotes section holding the BetterTTV switch, the cache state,
and an editor for the blocked-code list that has been in the config
since v1.0 with nowhere to edit it. Blocking one code is a finer
instrument than switching emotes off wholesale. Plus auto-translate
sorting.
- General: the failed-tell warning. The game reports a failed tell in
the log only, where somebody who is typing does not see it.
- Data & Privacy: battle messages. It decides whether battle lines are
persisted at all, which makes it a storage decision rather than a chat
one, and it sits in front of the whitelist rather than inside it.
MaxParallelPopouts stays out. It is read once in the pool's constructor,
the windows are registered once, and registering at runtime is
explicitly forbidden -- a slider would do nothing until a reload, which
is exactly the kind of decoration this cycle exists to remove.
Four new strings in all 25 languages; the other fifteen labels were
already translated and waiting.
Reported after a translation pass: the channel list in Data & Privacy
reads English in every language. It was not, as assumed, the client's
own naming -- the list called ToString() on the enum member, so it
showed "FreeCompanyLoginLogout" while Language.ChatType_* sat next to it
holding "Freie Gesellschaft (An-/Abmeldung)" in all 25 languages.
Fixing the label exposed how little else the list had. Six translated
strings written for exactly this control had no caller: the explanation
of what it does, the note that it governs the database and not the chat
window, and three presets -- data minimisation, select all, deselect
all. Eighty-nine checkboxes without a "recommended" button is not a
choice anyone makes.
So the list now carries the same eight groups the export uses, with the
individual channels one fold away. It is the one control that decides
what reaches the database; the two screens describing channels the same
way is worth more here than anywhere else.
The unknown-channel switch gets its description back, and the telemetry
section stops being the last English literal in the tab. It stays a
statement rather than a switch: a toggle would imply there is something
to turn off.
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.
The privacy filter only decides what gets written from now on. Whatever
was stored before the user narrowed their channels stays there until
something removes it, and that something has had no button since May.
Two rules shape the section, both because this deletes history and
cannot be undone:
- Without a preview the apply button does not exist. Not greyed out,
absent. A disabled button is something a user waits for; a missing one
is something they have to go and earn.
- A preview that no longer matches the settings counts as no preview.
The old version only recoloured the number and left the button live,
so a changed whitelist could be applied against counts computed for
the previous one.
The mapping from the live rule to CleanupRetainOnly is the part worth
reading twice. CleanupRetainOnly takes one set and deletes everything
else, so it can only stand in for the live rule where that rule narrows
something: filter off means nothing is filtered, and an empty list means
a full wipe, which has its own button and its own confirmation. Both
cases now say so instead of offering a destructive action that does not
mean what it looks like.
Inside that, the allowlist is the whitelist itself plus any stored
channel this build does not recognise while the unknown-channel failsafe
is on. Deriving it from the counts instead would delete messages that
arrive on a whitelisted but currently empty channel between the preview
and the apply, and dropping the unrecognised ones would defeat the
failsafe, which exists to hold on to a new patch's channel until the
user has decided about it.
The preview runs on a worker over its own connection. It is a GROUP BY
across every stored row, the old version ran it inline on the draw
thread, and holding the read lock for it would stall UpsertMessage on
the framework thread for the length of the scan.
Cleanup_Help_SavedNote stays unused: it tells the reader to press Save
first, and the window it was written for had a Save button.
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.
Last commit hid the header row when the title bar was on, to stop the tab name
appearing twice. The close button was in that row, and the title bar carries
none of its own -- ShowCloseButton is off because closing has to go through the
pool to release the slot. So a pop-out with its title bar on could not be closed
at all.
Pop-in moves into the input row, alongside the settings and hide buttons that
are already there. It is red, contrast-checked against the button plate it sits
on, and carries a tooltip, since a bare X next to an emoji picker does not say
where the tab is going.
Wired by setter after construction rather than through the constructor: the
window is what the button has to call, and it does not exist yet while its own
input row is being built. Routing it through the graph would close a
factory-callsite cycle MS.DI cannot detect.
The header row is now just the tab name, drawn only when there is no title bar
to carry it.
InputBar_PopIn_Tooltip ships in all 25 languages, including the Designer entry.
New strings get translated with the change from here on, not batched.
The three quick-button tooltips were hard-coded English while the rest of the
UI goes through HellionStrings. All 25 resource files now carry them, machine
translated on Flo's go-ahead.
Verified: every file still parses as XML, and the base file stays English so a
missing language falls back to what was there before.
Five new keys across the EN source plus 24 locale variants (DE plus
23 AI-assisted, each carrying the pending-review marker): the header
quick-picker tooltip and two section headers, plus name and
description for a new ReduceMotion checkbox.
ReduceMotion was a config field with no UI -- the checkbox lands in
the Theme & Layout tab's window-style section. Designer.cs hand-edited
as a v1.5.4 block matching the v1.4.8 convention.
Plugin.cs:937 only pushed RegularFont when Config.FontsEnabled was true.
FontsAndColours.cs:50 forces FontsEnabled=false whenever UseHellionFont is
enabled (to hide the chooser UI), so the bundled-font path was silently
dead and the FFXIV Axis game-font took over. Exo 2 looked "almost right"
because it overlaps Axis on basic Latin, so the regression went unnoticed
for the entire v1.5.x series.
The fix routes RegularFont through draw whenever either FontsEnabled or
UseHellionFont is on. First-frame HITCH dropped from ~74 ms to ~20 ms
median (5-reload Linux/Wine sample 17.9-23.6 ms) as a side effect — the
v1.5.1 "too optimistic" defer-pattern hypothesis was actually a symptom
of this bug, not bad math.
Font-stack overhaul on top:
- Inter Light (Static 18pt-Light, 343 KB, SIL OFL 1.1) replaces Exo 2 as
the bundled font. Inter ships full Latin Extended-A/B, Greek polytonic
and Cyrillic Supplement coverage.
- NotoSansCjkRegular added as a third merge layer for Hangul,
Simplified-Chinese-specific Han glyphs, and CJK fallbacks the FFXIV
Japanese font does not ship.
- Two new ExtraGlyphRanges flags (LatinExtended, Greek) implemented via
AddChar pair lists in SetUpRanges.
- Settings.Apply auto-activates the matching ExtraGlyphRanges flag on
language change. Plugin.LoadAsync runs a one-shot migration that ORs
in the required flag for an already-selected language.
- ExtraGlyphRanges CollapsingHeader reachable regardless of
UseHellionFont (was hidden in the early-return branch).
- New WarningText below the language combo: FFXIV's chat engine only
fully supports EN/DE/FR/JA. Other scripts render in the HellionChat
UI but may garble in in-game chat input/send.
Localisation wave (originally a FR-only cycle):
- 24 selectable UI languages. LanguageOverride enum gains 10 new locales
plus 3 previously commented-out (Italian, Korean, Norwegian with ISO
code `nb` instead of `no`). All new values append to keep existing
user-config integer serialisation stable.
- Resource bundle split: HellionStrings.resx (24 locales, 328 keys) for
fork-added strings, Language.resx (24 locales, 456 keys) for the
ChatTwo-Crowdin-heritage. 4 post-sync Crowdin keys backfilled into
13 legacy locales with per-key AI-assisted comment marker.
- Em-dash sweep on EN source plus 18 translations. Russian and Ukrainian
keep their typographic norm.
Old HellionFont.ttf + HellionFont-OFL.txt removed; Inter-Light.ttf +
Inter-OFL.txt take their place. Configuration field UseHellionFont keeps
its name for backwards-compat. Migration v17 stays.
Split fork-added keys into a dedicated HellionStrings resource bundle separate
from the Language.*.resx Chat-2 Crowdin heritage.
- Add HellionStrings.resx (EN source, 328 keys) and HellionStrings.Designer.cs
- Add 22 HellionStrings.<code>.resx variants: ca, cs, da, de, es, fi, fr, hu, it,
ja, ko, nb, nl, pl, pt-BR, pt-PT, ro, ru, sv, tr, uk, zh-Hans, zh-Hant
- Add matching Language.<code>.resx siblings for the new locales with the
Hellion Forge maintainer header
- FR pass: align labels with the rest of the UI
(Confidentialité, Visualiseur, Violet indigo)