Skip to content

feat(lang): load only the resident prefix; split the pack ceiling in two - #83

Merged
angeloINTJ merged 2 commits into
mainfrom
feat/lang-resident-ceiling
Aug 19, 2026
Merged

feat(lang): load only the resident prefix; split the pack ceiling in two#83
angeloINTJ merged 2 commits into
mainfrom
feat/lang-resident-ceiling

Conversation

@angeloINTJ

Copy link
Copy Markdown
Owner

Resolves the es-ES ceiling squeeze that was blocking any new UI string (#60's hidden cost): the pack sat at 32,045 B of a 32,768 B ceiling — 723 B left — while ~60% of those bytes (@webdict, 18.8 KB) never touch the RAM the ceiling exists to protect.

What changed

loadLangFile() reads only the resident prefix. The @webdict marker is located by streaming the file through a 256 B stack chunk; the malloc covers just the sections the firmware actually keeps (@DICT/@LOGCODES/@TRL/@HELP/@license). The blob's byte range is recorded for GET /api/lang exactly as before — the new length formula is byte-for-byte what the old excision served. The excise-and-rebase pass (two live buffers, ~45 KB transient peak) is deleted outright; peak load heap is now 13.3 KB instead of ~45 KB.

Two ceilings replace the one, each guarding what it actually bounds:

old new
Resident (heap for the uptime) 32,768 shared 16,384 — es-ES at 13,251 B (80%)
Whole file (flash-side) 32,768 shared 49,152 — es-ES at 32,045 B (65%)

Contract: @webdict must be the file's suffix (both shipped packs already are). The device rejects a pack with sections after it, and check_lang_packs.py enforces the same rule at build time — plus both ceilings, read from the parser source so the gate can't drift. tools/test_lang_gate.py provokes every new failure mode on synthetic packs (8/8) with an A-vs-A control on the shipped packs.

Deploy order note: a pack over 32,768 B needs this firmware first — the old loader rejects it whole and the UI silently reverts to English. The shipped packs are unchanged and work on both firmwares.

Validation on the rig (over OTA, image b66379ab)

  1. Baseline captured on v2.2.14-beta Latest: /api/lang = 18,785 B, langCode es-ES.
  2. OTA to this branch (83.7 s cycle); packs restored after the FS reformat (fsguard, sha256-verified).
  3. A/B, same pack, new firmware: /api/lang byte-identical to baseline.
  4. Acceptance: a 36,545 B inflated es-ES (whitespace-padded JSON — no new keys, resident prefix untouched) loads: es-ES stays active and its 23,285 B body is served byte-for-byte. The old ceiling rejects this file whole.
  5. Original pack restored, A/B re-verified identical; rig returned to port 80.

Gates: pio run release + test both SUCCESS (flash delta −8 B), check_lang_packs.py OK on both packs, test_lang_gate.py 8/8. The one release warning (-Wcomment in WebManager_Files.cpp) predates this branch and its fix ships in #82.

Unblocks: error-states and aria-label i18n from #60's pick-list, and any future translation growth up to ~33 KB of @webdict per pack.

The 32,768 B LANG_FILE_MAX charged web translations against RAM they never
used: @webdict is ~60% of a pack by bytes and is served from flash by
GET /api/lang, yet the loader malloc'd the whole file and then excised the
blob into a second buffer — peaking at file + resident (~45 KB) to keep
~13 KB. With es-ES at 32,045 B (723 B left), every new web string was being
priced against that single ceiling.

loadLangFile() now locates the @webdict marker by streaming the file
through a 256 B stack chunk, mallocs only the resident prefix, and records
the blob's byte range for the web handler exactly as before — the length
formula (fsize - bodyStart) is byte-for-byte what the excision used to
serve. The blob must be the file's suffix; a pack with sections after it is
rejected, and tools/check_lang_packs.py enforces the same order at build
time. The excise-and-rebase pass is deleted: the hole never loads at all.

Two ceilings replace the one:

  LANG_RESIDENT_MAX = 16384   the malloc that lives for the whole uptime
  LANG_FILE_MAX     = 49152   flash-side envelope for the whole file

es-ES moves from 97% of one ceiling to resident 13,251 B (80%) + file
32,045 B (65%): ~17 KB now available for web translations, ~3 KB for
resident ones. Load peak drops from a ~45 KB transient to 13.3 KB.
Flash cost: -8 B.

Validated on the rig over OTA (image b66379ab): /api/lang byte-identical
to the pre-flash baseline with the unchanged pack (18,785 B); a 36,545 B
inflated es-ES — which the old ceiling rejects whole, reverting the UI to
English — loads with es-ES active and serves its 23,285 B body
byte-for-byte; original pack restored and re-verified identical.
check_lang_packs.py reads LANG_FILE_MAX and LANG_RESIDENT_MAX from the
parser source so the gate cannot drift from the device rule, measures the
resident prefix exactly as loadLangFile() mallocs it, fails a pack whose
sections continue past @webdict, and keeps the 95% warning band on both
numbers. tools/test_lang_gate.py provokes each new failure mode on
synthetic packs mutated from the real es-ES — a gate that never fired is
indistinguishable from one that cannot fire — plus an A-vs-A control on
the shipped packs: 8/8.
@github-actions github-actions Bot added display Display, TFT, touchscreen, and themes i18n Internationalization and translations tools Build tools, scripts, and developer tooling code Firmware source changes (src/, WebUI.h) labels Aug 19, 2026
@angeloINTJ
angeloINTJ merged commit 861a04e into main Aug 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code Firmware source changes (src/, WebUI.h) display Display, TFT, touchscreen, and themes i18n Internationalization and translations tools Build tools, scripts, and developer tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant