Some ThinkNode M9 cards shipped with a dormant Windows worm (Elecrow
security advisory, September 2026). An infected card seen since carries
autorun.inf in the root, launching xlfqf.pif on open, explore and autoplay
with random-junk comment lines in between: the Sality autorun pattern.
- SD Scan store app (deploy/apps/sdscan/1.0, requires "sd", not seeded):
walks the card a small page per tick, lists what it finds and why, and
removes it after a confirmation screen with Cancel first. It says on
every screen that it only removes files it recognises and that
formatting the card is the safe fix. On older firmware it still finds
threats by name but cannot remove them.
- Firmware: wada.sd.check(path) and wada.sd.remove(path), plus paging for
wada.sd.list(path, start, max) and caps().sd_clean. What counts as a
threat lives in SdThreat.h: autorun.inf, Windows program, script and
shortcut extensions, or a real MZ+PE header under any name. remove()
classifies again in firmware and refuses anything else, so no app can
use it to delete tiles, backups or chat history. It clears read-only,
hidden and system first, because FAT refuses to delete a read-only file.
- A warning when a card with Windows malware in its top folder is mounted,
at boot or on insert, offering SD Scan (or the Store).
- Tests: test/test_sd_threat.cpp, and SD Scan harness scenarios including
the real infected card's root. Removal checked on a T-Deck.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Web interface: unlock a manually locked screen (#506). The device publishes
its lock state over the mirror socket and the page shows an Unlock button
while it is set. The lock screen absorbs taps by design and only a held
trackball or BOOT press unlocks on the device, so a browser had no way back
from a screen it had locked itself.
- Console mode: hold the panel for three seconds to leave it (#507). The
banner and `help` both say so. Console mode also applies the "Older keyboard
protocol" setting now: that is applied in the graphical startup path, which
console mode returns before, so the console ran on protocol detection alone.
On a T-Deck that needs the older protocol every keystroke there is garbage,
which is why `ui` could not be typed and the reporter had to side-load a
second firmware to get the device back.
- Console mode: a touch wakes a dark panel again on the touch-only boards,
where the keyboard and button wake paths sit below the console branch's
return. The waking press is swallowed so it cannot also type.
- Paste into a key field lifts the key out of the surrounding text (#526): a
32 hex digit channel secret or a 64 hex digit public key, spaced keys
included. Before, the field filled with prose and the length cap cut the key
off.
- ThinkNode M9: a waiting firmware update is visible (#443). The red "!" over
the bottom-bar gear is built in the #else of that board's block, so the M9
had no update signal at all; it gets a third slot in its own notice row,
steady amber rather than blinking. The Settings tile in the app drawer
carries an "!" on every board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bluetooth serves either the phone app or a keyboard (Settings > Bluetooth).
Pairing lists keyboards in pairing mode and pairs with or without a code;
the paired keyboard reconnects by itself.
Keys are read the way phones and computers read them: the keyboard's Report
Map says which report carries the keys and how, so media keys and touchpads
are left alone, and the boot protocol is only the fallback. Key positions are
translated with the chosen layout (US, UK, German, French, Belgian), with
AltGr and dead keys.
While a keyboard is connected:
- text goes into the focused field, and touchscreen-only boards keep the
on-screen keyboard down (a second tap on a field brings it up anyway)
- the arrows, Tab, Enter and Page Up/Down drive the focus highlight
- Esc is the back button; a "Back key" setting picks another key for
keyboards whose Esc key types a character
- tab hotkeys and their hints over the tab bar are switched on
- Command tapped alone opens the emoji picker in a chat
- the status bar shows a keyboard instead of the Bluetooth glyph
The Bluetooth page is now a flex column with the choice on top, and the
settings page refit keeps it scrollable when its content grows.
Settings: prefs v61 (mode, layout, paired keyboard) and v62 (Back key).
Tests: test/test_hid_report_map.cpp, test_touch_prefs_schema.cpp.
Not on the P4 boards (Tanmatsu, T-Display P4).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "System Information" key fix (6bb98a1) changed the flat packs in
deploy/apps/lang/, but devices never download those: they fetch the immutable
copy at apps/lang/<ver>/<code>.lang, keyed on the version in langs.json. With
everything still at v21, the store kept serving the old key from lang/21/ and
no device re-downloaded anything, so the fix reached only the Heltec V4, which
bakes the table into its firmware.
Bumped "# ver:" and the catalog to 22 in all 13 languages and snapshotted the
packs into lang/22/, the documented three-step publish. The generator ignores
the version line, so the baked table and the published beta_80 firmware are
unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#458 renamed TR("System info") to TR("System Information") and updated the
translation table to match, so the title stayed translated. But
src/ui-touch/i18n_builtin.h is GENERATED ("DO NOT EDIT") by
scripts/build/gen-lang-builtin.py from deploy/apps/lang/*.lang, and the rename
never reached those packs. Every regeneration since then -- the PlatformIO pre-hook
and the IDF build.sh both run it -- quietly wrote the old key back, so the source
asked for "System Information" while all thirteen tables answered "System info",
and the page title fell back to English in every language.
That is also why #517 appeared to revert the rename: its copy of the header was a
freshly regenerated one, faithfully reproducing the stale packs.
Rekeyed the thirteen source packs, then regenerated. The generated table changed by
exactly one key per language and nothing else, which confirms it was otherwise in
step with the packs, and the fix now survives the build's own regeneration, which is
the thing that used to undo it. The corrected packs reach devices that download a
language once the app store is republished with the next beta.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five new user-facing strings arrived with #429/#439/#440/#441/#442/#444/#445.
All nine S3 envs and both ESP32-P4 targets green on the merged result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things, both found by looking rather than by being told.
A Heltec V4-R8 crash dump from beta_77 decodes to a data race in the Arduino
core's Wi-Fi event list. Correcting for the unwinder mangling Xtensa return
addresses, the event task was inside _eventCallback copying a WiFiEventCbList
entry, iterating the callback vector, while loopTask was still in setup() inside
_M_realloc_insert growing that same vector through WiFi.onEvent(). The
reallocation frees the buffer the event task is walking, so it then calls
through a dangling function pointer, which is the garbage program counter in the
dump.
Neither side locks, and the vector is the core's, not ours. What is ours is the
ordering: both handlers were registered AFTER the stack was started, one of them
after WiFi.begin(), so a STA_DISCONNECTED arriving during boot association lands
in the window. A failed first association is ordinary, which is why this happens
at all. Both now register before WiFi.mode(WIFI_STA), so nothing mutates the
vector once events can flow and the race is impossible rather than unlikely.
This is a crash during setup(), so on an unlucky unit it is a bootloop, which
makes it worth more than its single report.
The moved handler drops its WIFI_DEBUG_PRINTLN tracing, which comes from a
header not in scope that early. The prints compile to nothing in a release build
and the reconnect flag they accompanied is unchanged.
Separately: the location-privacy setting shipped in beta_77 on the wrong page.
It was inside the Sensors section rather than GPS, because the insertion was
anchored to a comment belonging to the Sensors block, and that section is
described in its own code as V4-with-kit only. So on most boards it was not
reachable at all, which is exactly what two people reported: they went looking
and correctly concluded it was not there. It now sits in the GPS section, is
named "Location privacy" rather than "Position in adverts" (nobody searching for
a privacy control scans for the word "adverts"), and offers four buttons instead
of a cycling one, so every option and the current choice are visible without
touching anything. Given the failure here was discoverability, showing the most
without interaction is the point.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dreirund reported the device as frozen at "Download mode... reflash over USB".
It was not frozen: that is the state working as designed, waiting to be flashed
with its own UI stopped. They accepted that once told, and made the fair point
that the button should say what it will do.
So the menu entry now reads "Download mode (wait for USB flash)", and the
message shown before the UI stops says that RESET cancels, which is the piece of
information whose absence turned a working feature into a bug report. It also
stays on screen long enough to be read.
A state a user cannot leave without already knowing the way out is a bug in the
telling, even when the code is right.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twelve new user-facing strings arrived with #413. Added as placeholder rows so
the audit is clean and translators have the keys to fill in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict with #407 resolved in favour of the newer gesture: #407 changed the M9
unlock from a hold to a double-press, and #409 branched before that and still
described the hold. The M9 strings keep #407's wording; #409's Wio Tracker L2
arm is taken as written, since that board's gesture is not what changed.
Requested by dreirund: with no GPS fix, no Wi-Fi and no companion app there was
no way to set the clock at all. That is not cosmetic, because message timestamps
are built from it, so a device used purely offline could not hold a correct one.
Settings, Clock now takes local time as "YYYY-MM-DD HH:MM". mktime() reads it
through the configured zone, the same zone the clock is displayed in, so what you
type is what you see rather than something you convert to UTC yourself, and
tm_isdst is left at -1 so summer time is not an hour out. The field is prefilled
with the current reading, which makes a correction an edit of a digit or two
rather than typing a full stamp on a device keyboard.
Rejected values are refused the same way every other clock source is: below the
send-timestamp floor is not accepted, so a mistyped year cannot walk the ratchet
backwards.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#415: a Lua app was covered by the app drawer whenever a message arrived.
Reported by jadestarwatcher, reproduced by mysterywavi, both on T-Deck. Mine,
from the #393 badge fix. Apps launched from the drawer deliberately leave it
alive underneath (appTileCb says so outright), and my badge refresh rebuilt it on
every unread-count change, with openAppDrawer() ending in a move_foreground that
threw it on top of the running app.
The refresh now runs only when nothing is drawn in front of the drawer, tested by
sibling order rather than by listing the tools, so a tool added later is covered
without anyone remembering to update a list. The status bar is excluded, since it
legitimately floats above the drawer at all times and treating it as covering
would have stopped the badges refreshing at all. Leaving the signature stale is
what makes it self-healing: the check runs again each tick, so the count is right
by the time the drawer is back in front, with no need to hook every close path.
Scroll position is now preserved across the rebuild too, which my change had also
been resetting.
#410: on the M9 the accent variants appeared but could not be selected. Also mine,
from the #387 work. The picker was handled below m9HandleArrowKey, which takes
LEFT and RIGHT for the caret and returns, so the arrows never reached it: the box
was drawn and nothing could touch it. Lifted above that call, as its own function
rather than a second copy of the logic.
#399, requested by @Danie10: an option to advertise a position near you rather
than your address. Settings, GPS, cycling exact / 100 m / 250 m / 1 km.
The displacement is derived from the node identity, so it is the same offset
every time. That is the whole point rather than an implementation detail: a fresh
random offset per advert would scatter points around the true position, and
averaging a night of them would recover the centre exactly. A fixed displacement
instead looks like a node that sits somewhere else, which is what a manually set
location already looks like. It applies only to our own adverts; the map and the
GPS page keep the real fix, because the aim is to tell other people less, not to
lie to yourself.
Schema v57. The host test caught the new field the moment it was added, which is
what it is for.
All nine S3 envs and both ESP32-P4 targets green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drive the L2 I2C backlight during sleep and wake, poll its expander button reliably, and remove irreversible software power-off. Make a two-second wake-button hold lock or unlock, and keep Home and empty-map chrome clear and readable.
Signed-off-by: Michael A. Cojocari <michael.cojocari@gmail.com>
Open the selected message action menu with a normal Enter press. Replace the lock-screen long wait with a d-pad-center double press while preserving directional behavior and the controller long-press fallback.
Signed-off-by: Michael A. Cojocari <michael.cojocari@gmail.com>
oumike. A firmware-wide appearance setting: Night stays the default and renders
exactly as before, Day is a low-glare light palette with its own semantic roles
for text, panels, fields, borders, controls, focus, charts, status colours and
chat surfaces. Selecting a mode restarts, so every LVGL object is rebuilt from
one coherent palette rather than half-repainted. There is a Night/Day selector
in Settings, Display, the Control Center Theme chip is now a direct toggle whose
sun/moon icon shows the active mode, and the standalone console UI has a
matching Day palette.
Main already carried the palette scaffolding for this, pinned to Night behind a
comment saying it was waiting on this branch, so the merge mostly replaces those
stubs with the real thing.
The prefs schema needed care. The branch appended theme_mode as v54, but v54 and
v55 were taken by boot_wifi_time/boot_wifi_open and loud_alerts before this
merged, so the field moves to the tail behind them and the version becomes v56,
with the trailing-field assert and the migration step moved to match. A packed
struct read back at the wrong offsets is the failure this schema's asserts exist
to prevent, so the invariants from both sides are kept rather than one replacing
the other.
That also surfaced a stale test: the v53 case asserted that v53 plus exactly two
bytes was the whole struct, which stopped being true when loud_alerts landed and
was not caught because the host test was not run then. It now counts every byte
appended since and asserts the new fields come back at their defaults. The
schema host tests pass. While there, the size comment said "about 500 bytes";
the struct is 114.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The M9's screen was never really going off (#390, Buko84, who spotted the lock
screen still faintly visible in bright light). Backlight off is not screen off:
the ST7789 keeps refreshing the same static image behind a dark backlight, which
is exactly what retains an image into the glass. The panel-sleep command the
T-Deck and V4 use was a no-op stub on this board and the M9's backlight path
never called it anyway. Both halves are fixed. The M9 takes the display driver's
default constructor branch, on the global SPI instance it shares with the radio
and with no PIN_TFT_SCL/SDA defined, so the command goes over that same bus
object inside a transaction rather than a second one on pins this board does not
declare.
The Discovered list showed impossible hop counts (#394, jesshampshire). 0xFF is
OUT_PATH_UNKNOWN, meaning an advert arrived by flood with no known path, and it
was being printed as a number: "255 hop". It now says the path is unknown, says
"direct" for zero hops, and pluralises, so a two-hop node no longer reads as
"2 hop".
Keyboard backlight controls are hidden on the older T-Decks (#382,
jesshampshire), which have no controllable backlight, so those controls did
nothing while still looking like settings. The "Older keyboard protocol" switch
added in beta_74 already declares which keyboard is fitted, so it gates these
too rather than asking the same question twice. The other half of that request,
swallowing the backlight key combo so it does not type a stray character, needs
to know which character actually appears; asked on the issue.
Also removed a duplicated pair of declarations in the prefs header.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buko84 asked whether the M9's notification sounds can be made louder. There is
no volume setting because there is nothing to turn: the buzzer on these boards
is a bare piezo on a GPIO driven by tone(), a fixed-duty square wave, so
amplitude is simply whatever the part does at that pitch.
Frequency is the lever that does exist. A piezo is far louder near its mechanical
resonance, typically around 4 kHz, than at the 1 to 2.6 kHz the chime uses, so
"Loud alerts" in Settings, Sound plays the same three-note shape shifted into
that band. Same chime, so it still reads as the same alert rather than a new one,
and toggling it plays it so the difference is audible while the switch is still
under your finger.
Off by default and opt-in, because the exact resonant peak varies by part and
this has not been measured on an M9. Schema v55, field appended at the tail.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wi-Fi came back on after a power cycle on the T-Display P4 while every switch
in the UI read off, and the radio really was on: it downloaded a map tile
(#373, wb6zsu, with photographs of both states). The C6 runs its own AT
firmware with auto-connect enabled, so it rejoins the last access point by
itself at power-on, before the firmware ever asks it to. The loop that enforces
the radio pref only acts on a change, and on the first pass it recorded the
state instead of applying it, so "off" never became a transition and the join
the C6 made on its own was never torn down. That is also why toggling Wi-Fi on
and then off fixed it: that made a transition. The state is now applied on the
first pass, once the AT link is actually up. The Tanmatsu carried the same
first-pass logic and got the same fix.
The map's own location marker is a bare white glyph and vanishes on light
basemaps (#366, 100monkeys). The contact markers a few lines below it already
carry a dark border for exactly this reason and self never got one, so it now
gets the same: a dark chip behind the glyph, which reads on any basemap without
spending a colour that already means something else.
Wi-Fi passwords can be revealed while typing (#381, jesshampshire). Entering a
PSK blind on a device keyboard is a real failure mode: the reporter needed about
ten attempts to join their own network. The toggle sits on the password label's
line so it costs no vertical space, and it switches the keyboard mirror too, or
the characters stay masked in the one field being looked at.
The composer shows a character count (#350, jrote1). The 160-character cap was
silent: typing just stops, which reads as a broken keyboard. It appears only in
the last quarter and marks the limit when reached, so an ordinary short message
carries no readout. Counted in codepoints to match how the cap is enforced;
by bytes an emoji would read as four characters.
Built on all nine S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike's #384closes#374: MeshCore seeds _txt_last_ts from an unrestricted
random uint32 and sendMessage() then raises the real RTC timestamp above it, so
outbound direct messages carried future dates on most boots. Patched in the
vendored core, fail-closed and idempotent.
#386closes#383: hardware RTC for the Pager's PCF85063A and the M9's PCF8563
with validation on both sides, time retained through a full power-off, and an
opt-in cold-boot Wi-Fi time sync. Prefs schema to v54, appended at the tail with
the assert updated.
cvhviz's #385 makes the M9's battery saver reachable at all (the machinery was
wired but the only enable switch was compiled for two other boards), fixes
GPS-off leakage, and enlarges the GPS UART ring mid-session.
Two build problems, neither visible to the authors:
- #384 wires its core patch into the IDF builds as a fail-closed VERIFY, and
the vendored copy in this checkout predated it. Applying the same patch step
fixes it; the error already said to run fetch-deps.sh, which is the right
design.
- #385 calls gpsEnsureBigRxRing() from UITask.cpp and MyMesh.cpp, which every
target compiles, while the definition is in src/main.cpp, which the two
ESP32-P4 targets never compile. Both P4 builds failed to link. A no-op
definition for those boards lives in UITask.cpp now, which is the standing
rule for anything shared between the UI and the Arduino main. A no-op is
honest here: neither P4 drives GPS through Serial1.
Built on all eight S3 envs and both ESP32-P4 targets.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one that matters most is #354: tileCacheRemove() was written for the
firmware's own LittleFS cache but resolves through s_tile_fs, which is
re-pointed at the SD card root on every board that caches tiles there. So it
was calling SD.remove() on the user's own offline map pack, permanently, for
every queued tile whose .jpg happened to be absent. Gated to the internal
partition now.
The rest: remount through the full ladder after an in-device format (#359),
live labels no longer overlap the rows beneath (#357), hidden and inert widgets
stay out of the d-pad focus group (#358), keypress work is attributed in the
stall ring (#360), and GPS acquisition is measured from when it started rather
than when you looked (#365).
Built on all eight S3 envs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Vybo reports no change in beta_73. The runtime demotion I added is not firing on
his device, and I still do not have the one serial line that would say why. So
rather than guess a third time, give him a switch.
Settings > Keyboard > "Older keyboard protocol" skips raw detection entirely and
uses the protocol every T-Deck controller understands. The point is that it is
reachable BY TOUCH: when detection guesses wrong the keyboard types the wrong
letters, so any remedy that needs the keyboard is not a remedy. It applies
immediately, without a reboot, because the driver re-reads the flag every poll
and will drop out of raw mode on the spot.
The cost of turning it on is modifier latching, which a controller that needs
this switch cannot do anyway.
Schema goes to v53, appended at the tail with the existing static_assert
updated to point at the new last field.
This does not replace finding the real cause; it stops people being stuck while
I do. Automatic detection is unchanged and still the default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jrote1: a route lists resolved repeater names, and when a name is wrong or two
repeaters share one there is nothing left to identify the hop by. The hash is
what the node is actually keyed by on the wire, so it is the part that stays
true exactly when you are reading a route to work out where a message went.
Each hop now reads "3A Repeater Name" rather than just the name. The unresolved
case is unchanged, since it was already showing the hash.
Also pisti87's two corrections (#347): "Known regions" had never been
translated, and the Bluetooth pairing hint gets his shorter wording. Languages
to v21.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Mark read" was an untranslated placeholder. "Join the public channel" had the
long form, which he wants shortened to fit the button.
His text for the second reads "nyílvános"; the file already uses "nyilvános"
everywhere else, so this follows the file rather than introducing a second
spelling. Easy to change back if the long i was deliberate.
The other line numbers in that issue are from his own build and no longer point
at what he means, so they need his source rather than mine to resolve.
Languages go to v20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contact action sheet could message, ping, telemetry, range-test, favorite,
share location, reset path, block and delete — but not hand the contact itself
to anyone. The plumbing was already there and only the companion protocol could
reach it: shareContactZeroHop() has backed CMD_SHARE_CONTACT all along.
So this is a button and a wrapper. uiShareContact() sits next to
uiResetContactPath() and looks the contact up in the live table the same way;
actionSheetShareContactCb mirrors actionSheetResetPathCb exactly — fetch by
index, close the sheet before acting, toast the result. No refreshContactsList():
unlike Reset path and Delete this does not touch the contact table, it only puts
a packet on the air.
Zero-hop, deliberately. Flooding a third party's advert spends the whole mesh's
airtime on a packet nobody asked for, and it re-advertises someone else's node
well beyond the room you are standing in. Zero-hop is what the companion command
already does and keeps the action to "hand this to whoever is next to me". A
flood variant is a different airtime trade-off and does not ride along with this.
Gated on !from_map like Share my loc and Block, so the map-marker sheet stays
compact, and placed after Reset path so Block and the full-width Delete keep the
bottom of the sheet.
grid_items goes 6 -> 7 for the non-from_map case. That is housekeeping, not the
fix — as the #306 note at the bottom of the function says, scrollability is
decided from the height the buttons actually reached, and that is untouched. The
#266 Share my loc row is still missing from the same tally; fixing it here would
widen the diff into behaviour this issue put out of scope.
Three new strings across the thirteen language files (Contact gone already
existed), inserted in sorted position and translated in each file's own style for
its neighbours — Contact added for the success toast, Send failed / Save failed
for the failure one, Share QR / Share channel for the button. i18n_builtin.h is
regenerated, not hand-edited; it costs ~2 KB of .rodata, which the V4 feels most.
Audit clean on all thirteen (1034 -> 1037 keys). T-Deck and Heltec V4 TFT build.
Not yet checked on hardware: a second node in direct range actually adding the
contact, and the smallest panel at the largest UI scale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 pointed at UITask.cpp:22021 twice, and he was right both times. sigCell()
does not translate its argument, so "Signal", "Signal (stale)" and "nothing heard
yet" were raw at the call site and the card read English in every language.
Easy to miss because the home-graph signal popup has its own TR()'d copies of the
same three strings, so the text is translated in one place and not the other. It
also explains why "nothing heard yet" matched no key when his translation for it
did not appear: there was no key, only a literal.
Hungarian supplied by him.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
beta_69 published Wardrive and Nearby. Both need the extended SDK, and the
Heltec V4 does not have it, so the most common board in the mesh showed a
prominent Get button for two apps that download, install, open, and then refuse
to do anything. matthewjk reported exactly that on Discord and had to be told it
was a hardware limit.
An app can now declare `"requires"` in the catalog. The Store greys the button,
labels it N/A and says so on the row, rather than letting someone spend a slow
download to find out. Wardrive and Nearby declare sdk_ext.
An unknown requirement allows the install: a newer catalog must not disable apps
on firmware that has never heard of the capability name, which would be exactly
the wrong way round.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 read v18 and called it chaos. He was right, and it was mine.
Two defects in the extractor I added yesterday:
- It joined every string literal in a call argument, so a ternary became one
key. `cut ? "Paste (move)" : "Paste (copy)"` shipped as the single key
"Paste (move)Paste (copy)", and with it "Unblock Block",
"Unfav Favorite", "Other networksNetworks", "Batteryactivityon" and
"Stop sharing loc Share my loc". None of those strings exist anywhere in
the firmware. Literals are now grouped only when genuinely adjacent, which
is what the compiler concatenates, so both branches become their own key.
- It scanned raw source, comments included. That is how "Geblokkeerde
gebruikers" -- Dutch, appearing only inside a comment about how a long
translation degrades -- became a KEY in the Hungarian file. Comments are
stripped now, string literals preserved.
202 invented rows removed across the thirteen files. Only rows that were both
unknown to the extractor AND still untranslated were touched, so no
translator's work could be lost either way.
Also his: the curly quotes in the Hungarian credits render as boxes because no
bundled font carries U+201E/U+201D. Five values de-curled. And five strings he
found raw are wrapped: the Wi-Fi rescan and hidden-network rows, the update
check, and the downgrade prompt. The two Wi-Fi rows build their label at
runtime, since TR() returns a pointer and cannot join a glyph literal at
compile time.
Languages go to v19. v18 is deleted rather than left behind: it was only ever
correct for about a day and everything in it is superseded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
33 of the pairs he posted apply directly to keys that only became visible when
the extractor learned about helper-wrapped and table-held literals. The rest
did not, and the reasons are worth recording rather than dropping:
- Four were escaped in his markdown, so the key read `Node\\nRegion` against a
real key of `Node\nRegion`. Matched after unescaping.
- `Export crash report` is `Export crash report (%uK)` in source; the size is
part of the label. Applied with the conversion appended.
- `Distance` and `Heard` are column headers he read off the screen; the keys
are `Distance: km` and `Recently heard`. Not guessed.
- `Sent only when that contact asks...` is a translation of wording the
English has since changed. Applying it would ship a Hungarian sentence that
no longer describes what the setting does.
- `nothing heard yet` and `Other (hidden) network...` match no key at all,
which usually means a raw string somewhere the audit still cannot see.
88 Hungarian rows are still English: the remainder of the newly visible keys,
plus console mode, which is new.
Languages go to v18. The v17 snapshot is deleted rather than kept: it was cut
before the audit fix added 116 keys, so it was already wrong, and it never
reached a device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 reported a long list of text that stays English whatever the language,
and said the strings were in his language file and still did not appear (#257).
Both halves are true, and the reason is the audit.
The extractor only ever recognised TR("literal"). Three very common shapes were
therefore invisible:
mk_row_btn("Reload tiles in view", cb) // helper TR()s its parameter
for (auto& r : rows) TR(r.label) // literal lives in a local table
TR(contactsSortOptName(m)) // helper returns one of several
All three translate correctly at runtime, so the source looks properly wrapped.
But the literal at the call site was never emitted as a key, so it never entered
a .lang file, so no translator could ever supply it -- and adding it by hand
did nothing, because the audit's key list is what the files are checked against.
That is 51 strings across the map options sheet, the sort sheets, the contacts
filters and the home launcher.
The audit now understands all three, plus tr("...") in the Lua apps, and the
newly visible keys are in all thirteen files as placeholders so translators can
see them. 1017 keys, up from 966.
Four strings were genuinely raw and are now wrapped: the reader's idle status,
the Discover empty feed, the crash-report export button and Paste (move/copy).
Lua apps had no way to translate anything at all, so every built-in was hard
English regardless of the device language. wada.sys.tr() gives them the same
table the interface uses; airtime 1.4 is the first to use it, with the
`sys.tr or identity` fallback so it still runs on older firmware.
Two more instances of the drift this issue is really about:
- gen-lua-builtin.py read out/firmware/apps/, which nothing writes -- the
deploy rsyncs deploy/apps/ straight to the VPS. So the mirror was stale and
the two apps added in beta_68 were never baked in: boards that cannot reach
the Store shipped without them. It reads the canonical directory now, and
regenerates from the same pre-build hook as the language table.
- Baking a row whose translation equals its key does nothing, since TR()
returns the key on a miss. Skipping them takes the header from 1.11 MB to
939 KB and gives the V4 back 16 KB of flash, which matters at 89%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The map About/credits sheet was 820 bytes of raw English built with snprintf and
no TR() anywhere in it, so it stayed English in every language (#257). It is now
three keys: the two attribution headers (the OpenTopoMap variant is credited
separately because its style is CC-BY-SA) and the body, kept whole rather than
split per paragraph so translators get prose instead of fragments. The buffer
grows 820 -> 2048 because Hungarian runs about 1.5x English here and the
Cyrillic and Greek files are two bytes a letter.
Hungarian text from pisti87 (#257). Two edits to what he posted, both flagged on
the issue: the hard line breaks he inserted at the English wrap points are gone,
because the label wraps itself and a fixed break lands mid-sentence on any other
panel width; and the header reads "Terkep adatok" rather than "Map adatok",
which looked like a copy-paste artifact given the rest is fully translated. The
OpenTopoMap variant is derived from his own wording and is his to correct.
Also raw, from the same report: the Discovered auto-add hint and the four type
words it interpolates. The hint buffer goes to 240 bytes and the type list to
128, since the translated plurals are longer than "chats, repeaters".
The reason none of that would have shipped: gen-lang-builtin.py exists so the
baked-in table and the .lang files the store serves cannot drift, and its
docstring promises a pre-build step that runs it. Nothing ran it. Editing a
.lang and building produced an image carrying the OLD translations, silently.
It is now a real pre: hook on all seven PlatformIO envs and a line in both IDF
build scripts, regenerating only when a .lang is newer than the header.
deploy-apps.sh grew the matching check for the other half of that path: a
catalog version that disagrees with the file's own "# ver:" publishes
translations to a version no device asks for.
All thirteen languages snapshot to v17 -- the merged region and SD work added
keys to every file, not just Hungarian.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oumike. Closes#271.
Region presets stop being freq/BW/SF typed in by hand: the list carries the
legal frequency and duty-cycle for each region, so picking one sets a
coherent set rather than three fields that have to agree.
oumike. Verified the reported regression is real: e2d07d8 converted two of the
regions guarding 'Save update bin to SD' to CAP_SD && CAP_OTA and left another
on the old three-board list, which excludes the T-Lora Pager.
Closes#289.
The other half of the request. The previous commit made a scoped message
resolvable to a region NAME; this is the list that decides which names are on
offer. Until now the registry only filled itself from our own default region and
per-channel scope overrides, so a region you can see traffic from but do not
participate in could never be named -- which is most of them.
Settings -> Radio, under the region-scope field, opens "Known regions": a field
plus Add, and a row per region with Remove. Deliberately placed next to the
region we SEND under, because the distinction is the confusing part -- that field
is the region we transmit in, this list is the regions we can RECOGNISE, and a
region only has to be named to be recognised, since its key is SHA256 of that
name. You do not need to be in a region to identify its traffic.
Remove is a RETIRE, not a delete. The slot and name stay bound; the region simply
stops being matched from that point on. Freeing the slot for reuse would hand it
to the next region added and silently relabel every message already received
under the old one -- the exact failure #271 warned about, and the reason slots
were never list indices. Re-adding a retired region revives its original slot, so
its old and new messages stay one region rather than splitting in two.
The persisted slot file gains an active mask and a magic bump (RGS1 -> RGS2). An
RGS1 file still loads, with every assigned slot treated as live, which is what it
meant before the mask existed.
Two failures are reported rather than silently doing nothing: the list being full
(all 14 slot bindings used, retired ones included -- they are permanent), and a
name that canonicalises to nothing, like blank or a bare "#".
Not board-gated -- the page uses the standard app-page chrome, so it inherits the
tall title bar, the back chevron and focus-group navigation on the keyboard-only
boards the same way Blocked users does. Laid out and reasoned about on the T-Deck
for now; the short-panel boards want a look on real glass before anyone calls it
done there. The page title is untranslated, matching every other app page here
(Blocked users, Spectrum, Discover).
All 8 S3 envs build. Four new strings in 13 .lang files, English both sides per
4709c81; the audit's 3 remaining gaps are pre-existing on main from the beta_68
SDK work. Not hardware-tested: adding a region is verifiable on-device, but
whether a message then resolves to it needs real scoped traffic from a second
node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Message details could only ever say "my region" or "another region". The request
was a user-maintained region list so the scope line resolves to a real name.
The mechanism was already here. transport_codes[0] is HMAC(region key, payload)
truncated to 16 bits -- per-packet, not a region id, which is why the raw hex
changes on every message (#259) and no code-to-name table can work. The only way
to name a region is to recompute the code with a key you hold and compare, while
the packet is still in hand. MyMesh has done that since #259, for exactly one
key: our own default scope. This widens it from one key to N.
Storage and key derivation are MeshCore's RegionMap + TransportKeyStore, which
already ship in every build (helpers/*.cpp is in the library's src filter) and
were simply never instantiated here. A public "#tag" region's key is plain
SHA256("#tag"), which is byte-for-byte what MyMesh::setRegionScope already
derives for our own region, so a name is the whole record.
Two things are deliberately not reused:
RegionMap::findMatch() returns the FIRST match. A 16-bit tag collides at roughly
N/65534 per packet with N keys live, and a confident wrong region name is worse
than none, so the loop here counts every match and reports ambiguity instead of
picking one.
RegionEntry::id is a uint16 that climbs as regions are added, and per-message
storage has 4 bits. So messages record a SLOT (1..14, 15 = ambiguous, 0 =
unknown) assigned once and never renumbered -- deleting or reordering regions
cannot silently relabel old history, which #271 called out specifically.
Those 4 bits are the free top nibble of meta_flags, which is already persisted at
a fixed offset. No record growth, no history version bump, and no segment
migration: messages written before a region was registered read back slot 0,
which is the honest "unknown" state for a message received before we could name
it. Note this fills meta_flags -- bits 0-3 are the existing semantics, 4-7 are
now the slot -- so the next per-message bit does need a record change.
The registry seeds itself: the default region in MyMesh::begin(), per-channel
scope overrides in UITask::begin() (touch prefs, readable only on that side), and
both save paths register on change so naming starts from the next packet rather
than the next boot. No list-management UI yet; that sits cleanly on top.
Scope reads "#denmark", or "ambiguous (several regions matched)", or falls back
to the old my-region / another-region answer when nothing matched.
Known limits, both honest states rather than bugs: private "$region" keys never
match, because TransportKeyStore::loadKeysFor/saveKeysFor are unimplemented stubs
in MeshCore 1.10 -- when the core fills them in, getTransportKeysFor() starts
returning keys and this works unchanged. And codes[1], the reply-region hint, is
still shown under "Scope" as #157 left it; giving it its own line needs a spare
meta bit, which no longer exists.
T-Deck, T-Lora Pager, ThinkNode M9, Heltec V4 and RAK Tap V2 all build. One new
string in 13 .lang files, English both sides per 4709c81; the audit's 3 other
gaps are pre-existing on main from the beta_68 SDK work. Not hardware-tested --
matching is only exercised by real scoped traffic from a second node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four capabilities that kept third-party apps a sketch of a built-in one:
* wada.map the firmware's own tiles, projection and cache inside an app
page, with its own capped pool so it never evicts the Map tab's
* wada.ui.list the missing "pick one of N" widget; rows are real buttons, so
keyboard and trackball nav walk them for free
* on_packet each frame delivered once instead of polling a 16-deep ring,
which sampled rather than observed
* wada.mesh.discover the active zero-hop probe, behind its own permission
because it spends every neighbour's airtime, not just ours
Plus the surface those need to be useful: packet identity reported only where
the frame actually carries it, exact micro-degree coordinates (Lua is built
LUA_32BITS, so its floats were quietly costing a metre), altitude and satellite
time, wada.geo, wada.ui.input, named and one-shot timers, http_post, windowed
fs.read, and wada.sys.env on a hardware gate rather than the memory one.
Fixes:
* Both ESP32-P4 targets could not link. g_wifi_last_disc_reason was defined in
src/main.cpp, which the IDF builds never compile, so all nine S3 envs stayed
green while Tanmatsu and T-Display P4 were dead.
* Map zoom level was invisible in +/- buttons mode; the readout was hidden with
the slider it was anchored to.
* Hungarian and Dutch held each other's "No SD card" translation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Pager's File Manager only drew an SD row when the card mounted. A card the
board can see but cannot read -- exFAT out of the box, or a damaged FAT --
produced no row at all, so the user could not tell "no card" from "card the
firmware refuses", and had no way to retry short of a reboot.
The Pager is the board that can actually tell those apart: it has a card-detect
line on the expander (XL9555 ch10), which the T-Deck does not. So a detected but
unmounted card now draws greyed as "SD card (unreadable - tap to retry)", and
tapping it clears the mount backoff and retries. An absent card still draws
nothing, which is the existing deliberate behaviour.
The retry is the same single 4 MHz attempt fmSdTryMount() already makes. No retry
ladder, no teardown of the live shared display/radio bus -- both of which that
function's comment forbids on this board, and both of which were learned the hard
way in M7.
In-app formatting stays off for the Pager. That was already true but read as an
oversight, so the reasoning now sits on the format-helper guard: f_mkfs needs an
SD.end() + sdcard_init/uninit lifecycle teardown on that shared bus; with no
touchscreen the T-Deck's hold-to-format gesture maps to holding ENTER, which is
an undiscoverable way to erase a card on a device where ENTER is how you move;
and it has never been run on Pager hardware. Formatting on a computer costs
30 seconds and carries none of that. The Arduino SD library never calls
spi->begin()/end(), so it is probably safe -- "probably" is not enough for a
one-way whole-card erase.
Two new strings, added to all 13 .lang files with English on both sides.
i18n-audit: 954 keys, nothing missing. Pager and T-Deck build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gating "Save update bin to SD" on CAP_SD gave it to the Pager, the ThinkNode M9
and the Heltec V4-R8 as well as the T-Deck. The two strings it shows still name
bmorcelli's Launcher -- "For Launcher installs: ..." on the button, and
"Saved: %s\nFlash it from the Launcher." when it finishes -- which is the T-Deck's
update path and means nothing on the other three. Those users get told to open
something that does not exist on their device.
The obvious fix is to reword the strings, and that is the trap: both are
translated in all 13 .lang files, TR() looks rows up by their English text, and
editing the English orphans every translation of them silently. So the T-Deck
keeps its strings byte-identical and the other boards get their own keys, chosen
at compile time. Two new keys, added to all 13 files with English on both sides,
which is this repo's convention for a row awaiting a translator (see 4709c81).
i18n-audit: 13/13 languages, 952 keys, nothing missing. audit-lang: no UNSAFE
format mismatches, which matters here because SDFW_SAVED_FMT is handed to
snprintf as the format string. T-Deck (the #if arm), Pager, M9 and V4-R8 (the
#else arm) all build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 asked whether the obsolete entries in the language files will be removed
(#262). Measuring it first was the right move, because the tool that would have
driven that cleanup was wrong.
source_keys() only ever matched TR("literal"). Strings reached indirectly, as in
TR(kSettingsCats[c].label), were invisible to it, so every settings-category name
looked unreferenced: About, Backups, Language, MQTT bridge, App permissions and
the rest. Anyone pruning the .lang files on that output would have deleted live
translations for some of the most visible labels in the UI.
The audit now also pulls literals out of any table whose name appears inside a
TR(...) subscript. Deliberately greedy: over-collecting keeps a translation alive,
under-collecting deletes one.
That immediately found a real gap it had been hiding. "App permissions", the
settings category added with the permissions page, was missing from all 13
languages and the old audit reported everything as covered. Added, builtin
regenerated, published as language v15.
Also adds --obsolete, the reverse check pisti87 actually needs: rows in each .lang
file that no TR() key matches. It reports and never deletes, and says plainly that
these are candidates to check rather than a delete list, for the reason above.
Numbers now: 950 keys reachable, hu.lang carries 1044 rows, 94 unreferenced. The
earlier figure was 119, so 25 of those were the false positives just fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pre-release tidy-up, so the same mistake as last week does not repeat: the SDK
page described a lifecycle the firmware never had, and shipping an expanded SDK
against a page describing the old two-permission model would be the same thing
again.
sdk.html now documents wada.mesh.send_dm and wada.mesh.channels, the m.kind field
on on_message, all four permissions with why they are four rather than one, and a
new wada.crypto section including why it exists (pure-Lua HMAC does not fit the
instruction budget) and why it is on every board rather than ext-gated.
i18n: 11 keys were missing across all 13 languages, six from the SDK work and
five from PR #287 which added strings without lang entries. All covered; audit
reports 924 keys with nothing missing, builtin fallback regenerated, published as
language v14 and the store is live.
Also removes an em dash from "Wi-Fi off, new map areas can't download", new in
#287 and not yet translated, so the key could be corrected for free.
All 8 S3 boards build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 pointed at a commit of his (dc94f44) with 64 Hungarian rows, including
translations for the strings added over the last few releases.
He put them in src/ui-touch/i18n_builtin.h, which says "DO NOT EDIT" at the top
because it is GENERATED from deploy/apps/lang/*.lang. That turns out to be my
fault rather than his: yesterday I added 29 keys to the .lang files and never
ran gen-lang-builtin.py, so the compiled-in fallback still had the old table and
a device that has not downloaded a pack still showed English. Editing the
generated file was a reasonable guess at where the strings lived.
So: his rows are merged into deploy/apps/lang/hu.lang, the canonical source, and
the builtin is regenerated from it (13 languages, 12514 rows). Both paths agree
again.
Four of his rows would not have compiled - two used a tab where a comma belongs,
and two left the quotes inside the English unescaped, one of which had pasted
"Nincs válasz." into the middle of the English key. Reconstructed rather than
dropped; the intent was unambiguous in each.
A fifth pointed at something real: he translated "Battery life: geathering
data..." and no such key exists. Ours has no typo, but more importantly it was
never wrapped in TR() at all - a bare literal snprintf'd straight into a
user-visible buffer, which the unwrapped-literal scanner does not look at since
it only inspects LVGL setters. It is wrapped now, along with the one literal the
scanner did flag (the UI size dropdown), so the scan is at zero and both strings
are translatable for the first time.
Audit: 916 keys covered in all 13 languages. Published as language v13 and the
store is live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87 listed the untranslated text he was seeing. Running the audit tool from
#254 found 29 keys missing from all 13 languages, which is a superset of his
list: the position-sharing strings and the note under them, the app-permission
prompts and page, the 1-character spam filter, the map "Max dots" and "%d of %d
on map", the room "Join w/ password" and its escalation prompt, and the new
path-hash hint. Most of them are mine from today.
All 13 are now complete: the audit reports 914 keys covered with nothing
missing. Untranslated rows carry the English on both sides, which is the
existing convention in these files (49 rows already looked like that), so a
translator sees the row and knows what to fill rather than the key simply not
existing.
Hungarian gets the one real translation: pisti87 supplied the telemetry-note
text in the issue, so it ships translated rather than English.
Published as language v12 for every language, and the store is live. Note the
canonical files the audit checks are the FLAT deploy/apps/lang/*.lang; the
numbered directories are published snapshots, so both were updated.
Also removes an em dash from the position-sharing note. It was written before
that rule landed and had no translations yet, so the key could be corrected for
free. The remaining em dashes in older strings are deliberately left alone:
changing those keys would orphan their existing translations in all 13 files,
which is a decision worth making on purpose rather than as a side effect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setting already behaves this way — BaseChatMesh's eviction loop skips any
contact carrying the favourite flag, so a starred contact is never the one
dropped when the table fills. The label just never said so, which left people
roaming between regions unable to tell whether starring a contact protected it.
Renamed to the reporter's own wording, "Overwrite oldest non-favorite", and
registered in all 13 language files. No behaviour change.
Reported by mikecarper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First pass at pisti87's list. These were never wrapped in TR(), which is why
uploading a translation did nothing for them — there was no key to match.
Sort by / Sort discovered, and the filter rows Peers, Favorites,
Has location, Direct (0-hop); the home traffic panel's
"Traffic (since boot) / Sent / Recv"; and the contact list's "Heard <ago>".
Keys registered in all 13 language files as empty rows. audit-lang.py reports
0 missing and 0 unsafe. The two new format strings are covered by the #258
placeholder guard.
Deliberately NOT done in this pass — the rest of the list needs a check I did
not want to rush:
- the map option table (Show coordinates, Show tile z/x/y, Tile debug
overlay, ...) is a static initialiser, so TR() cannot go in the table; it
has to be applied where the rows are consumed.
- "Name (A-Z)" / "Recent message" / "Nearest first" are dropdown options.
Translating an option string breaks any code that maps a selection back by
comparing text rather than index, and we have shipped that exact bug before
(channel send matching by name). Each needs its consumer read first.
Reported by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oumike's #263 replaces the save chip's floppy icon with words, which is the
right call — two people independently read the icon as ambiguous, and one of
them was the maintainer. Two gaps came with it, both fixed here rather than
blocking the merge:
- "Saved %s", "Save FAIL x%u" and "Save migrating" were bare English
literals. The chip previously had no words at all, so it was language
neutral; as written it would have been permanently English in all 13
languages and reopened the drift closed in d4ade2e. Wrapped in TR() and
registered in every .lang file as empty rows. audit-lang.py: 0 missing,
0 unsafe.
- the user guide, published yesterday, describes a floppy-disk icon and lists
the states as bare times. Reworded to match what the firmware now draws —
docs and UI have to move together or the guide is worse than none.
"Save FAIL x%u" and "Saved %s" carry printf placeholders, so they are covered
by the format-safety guard added for #258: a translation that reorders or drops
them falls back to English instead of misreading the stack.
All 8 S3 envs build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The #259 popup change introduced TR("Scope "), TR("my region") and
TR("another region"). Two problems, both caught by re-running the audit rather
than by eye:
- none of the 13 language files had rows for them, which would have reopened
the drift closed in d4ade2e. Added to all 13 as empty rows (the format's
own untranslated marker), so translators see the gap and English falls
through meanwhile.
- the key was "Scope " with a trailing space. TR() strips icon-glyph prefixes,
NOT trailing whitespace, so the lookup would never have matched a "Scope"
row and that label was permanently English in every language. The space now
lives in the format string where it belongs.
audit-lang.py: 0 missing and 0 unsafe across all 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pisti87's T-Deck rebooted every time he logged into a repeater, but only in
Hungarian. The login clock-skew warning does:
snprintf(msg, n, TR("Device clock differs from \"%s\" by %lu min%s"),
name, minutes, suffix);
and the Hungarian row reordered the conversions to "%lu ... %s ... %s". Varargs
are positional, so snprintf read the name POINTER as an unsigned long and then
took the minute count -- the integer 3 -- as a char* and dereferenced address 3.
Instant panic, every login, Hungarian only. English fit the declared order, so
it never showed there.
Fixing the four bad Hungarian rows is not sufficient: TR() returns a format
string and translations come from .lang files that users download or hand-write,
so any file can crash any device. TR() now compares the ordered conversion
signatures of key and translation and falls back to the English key on a
mismatch -- the key IS the call site's format string, so it is always correct.
The scan runs only for keys containing '%', which is a small minority.
Also fixed the four rows (three were Hungarian-only crashes or dropped values),
bumped hu to v11, and taught audit-lang.py to fail the build on a mismatch so a
future translation PR cannot reintroduce this. Unit-checked that the audit
detects the original bad row and accepts the repaired one.
Reported by pisti87.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>