Skip to content

refactor(radio): flatten LS state and only save frozen state per FM#7219

Draft
raphaelcoeffic wants to merge 5 commits intomodel-arenafrom
flatten-ls-state
Draft

refactor(radio): flatten LS state and only save frozen state per FM#7219
raphaelcoeffic wants to merge 5 commits intomodel-arenafrom
flatten-ls-state

Conversation

@raphaelcoeffic
Copy link
Copy Markdown
Member

@raphaelcoeffic raphaelcoeffic commented Mar 26, 2026

Summary

Flatten per-FM logical switch state (lswFm[MAX_FLIGHT_MODES]) to a single global context (lswCtx[MAX_LOGICAL_SWITCHES]), saving ~2 KB RAM and making logicalSwitchesTimerTick 9× faster. Clarify the relationship between evalLogicalSwitches and the mixer fade loop. Encapsulate the Lua sticky switch buffer behind a proper API.

Background

Per-FM logical switch state was introduced in opentx/opentx#1119 (May 2014) to fix a reentrancy bug (opentx/opentx#1039) and give each flight mode independent LS evaluation during fade transitions. One month later, commit f3bc8cb9a4 (fixing opentx/opentx#1345) gated evalLogicalSwitches() behind if (tick10ms), which is zero for non-current FMs during fades. This made per-FM LS evaluation dead code — only the active FM's context is ever evaluated, while non-active FM contexts carry stale state from the moment of transition.

The per-FM state has been inert for 12 years.

Changes

Commit 1: test(radio): prove per-FM logical switch state is redundant

  • Test suite proving that timer/sticky/edge lastValue is always identical across FMs
  • Test proving that non-current FM's LS state is stale during fade transitions (never independently evaluated)

Commit 2: refactor(radio): flatten per-FM logical switch state to single global context

  • Flatten lswFm[MAX_FLIGHT_MODES]lswCtx[MAX_LOGICAL_SWITCHES] (single global array)
  • Hide LogicalSwitchContext struct as implementation detail in switches.cpp
  • Collapse logicalSwitchesTimerTick from FM×LS nested loop to single LS loop
  • Replace logicalSwitchesCopyState (full context copy on FM transition) with lswFreezeState (bitmap snapshot of LS state bits into uint32_t array)
  • Add mixerActiveFlightMode to distinguish active FM from temporarily assigned mixerCurrentFlightMode during fade evaluation
  • getSwitch() reads from frozen bitmap for fading-out FMs, live context for active FM — preserving existing fade cross-fade behavior

Commit 3: refactor(radio): move evalLogicalSwitches out of evalFlightModeMixes

  • Move evalLogicalSwitches() from inside evalFlightModeMixes to evalMixes, called once before the fade loop
  • Remove the isCurrentFlightmode parameter (was always true since the tick10ms guard prevented the false path for 12 years)
  • Expand the fade loop ternaries into an explicit if/else for clarity
  • This makes the intent explicit: LS evaluation happens once per mixer cycle for the active FM, not per-FM during fades

Commit 4: refactor(radio): encapsulate sticky switch buffer behind lswSetStickySwitch

  • Add lswSetStickySwitch(idx, value) API in switches.cpp; Lua calls this instead of encoding messages directly
  • Replace CircularBuffer<uint8_t> (bit-packed, limited to 64 LS) with a lock-free SPSC ring buffer using std::atomic for correct cross-task ordering (GUI task → mixer task)
  • Remove CircularBuffer class from edgetx_helpers.h (no remaining users)
  • Remove MAX_LOGICAL_SWITCHES == 64 compile-time assertions

Preserving fade transition behavior

During FM fade transitions, the mixer evaluates mixes for multiple FMs per cycle. Fading-out FMs previously saw their (stale) LS state from lswFm[]. To preserve this behavior, a uint32_t bitmap per FM captures the frozen LS state at transition time. This replaces ~2 KB of per-FM context with 72 bytes of bitmaps + 1 byte for mixerActiveFlightMode.

Test plan

  • Existing test suite proves per-FM state redundancy
  • New test (perFmLsStateNotEvaluatedDuringFade) verifies fade behavior
  • Full gtests-radio suite passes on TX16S, NB4P, GX12
  • Flight test with FM fade transitions and LS-conditioned mixes

🤖 Generated with Claude Code

@raphaelcoeffic
Copy link
Copy Markdown
Member Author

This works as before with @philmoz 's example model from #7199:
tx15.ls.fm.test.etx.zip

@raphaelcoeffic raphaelcoeffic force-pushed the flatten-ls-state branch 2 times, most recently from ade1852 to 8ac7525 Compare March 26, 2026 07:38
@raphaelcoeffic raphaelcoeffic force-pushed the flatten-ls-state branch 4 times, most recently from 59bab6f to b7d339f Compare March 28, 2026 06:55
@raphaelcoeffic
Copy link
Copy Markdown
Member Author

raphaelcoeffic commented Mar 28, 2026

Some test results with E-Soar Plus template:

  • RM Boxer without FM transition:
main:                Tmix max    1.20ms
flatten-ls-state:    Tmix max    0.93ms (77.5% of baseline)
  • RM Boxer with FM transitions:
main:                Tmix max    1.90ms
flatten-ls-state:    Tmix max    1.57ms (82.6% of baseline)
  • RM TX15 without FM transition:
main:                Tmix max    0.52ms
flatten-ls-state:    Tmix max    0.55ms (105.7% of baseline)
  • RM TX15 with FM transitions:
main:                Tmix max    0.74ms
flatten-ls-state:    Tmix max    0.70ms (94.6% of baseline)

@RC-SOAR
Copy link
Copy Markdown

RC-SOAR commented Mar 28, 2026

Just resumed testing with RM GX12

  • Flashed pre-3.0.0-PR7219 eb2bd7b7
  • Started Companion 3.0.0--main Mar 18 2026
  • Opened .etx file
  • Transferred setups to tx.

The setups transferred successfully to the tx, however there were a couple of glitches:
(1) initially, the switches didn't work (failed to respond in Sys>Hardware>Debug Keys menu). Switches recovered after reboot.
(2) Most if not all SF's were disabled (repeat of old issue?)

I fixed the above, then tested a complex (F3F) setup using the mixer monitor - seems to work but really need to visualise as it's impractical to assemble the models each time. I'm going to try a quick and dirty port of my visualiser script to the TX16S.

@raphaelcoeffic
Copy link
Copy Markdown
Member Author

raphaelcoeffic commented Mar 29, 2026

Just resumed testing with RM GX12

  • Flashed pre-3.0.0-PR7219 eb2bd7b7
  • Started Companion 3.0.0--main Mar 18 2026
  • Opened .etx file

Could you please upload the models prior to conversion using Companion? I'd like to take potential issues with current Companion out of the equation.

Also, it seems the models are using LUA scripts which are not included (ex: /SCRIPTS/MIXES/snp500.lua).

  • Transferred setups to tx.

Ideally, and to really exclude potential pre-existing issues with current main branch, you would:

  • first flash latest nightly release
  • transfer models
  • verify everything is ok
  • then flash the firmware from this PR
  • verify things still work

Alternatively, you could use 2.12.0 instead of nightly.

The setups transferred successfully to the tx, however there were a couple of glitches:
(1) initially, the switches didn't work (failed to respond in Sys>Hardware>Debug Keys menu). Switches recovered after reboot.

That would be the radio settings. I'll have a look specifically there. So far, I could not reproduce it here.
Ideally, I'd need a copy of /RADIO/radio.yml before and after loading it into the radio. That might show something regarding the corruption. So far, @philmoz reported something like that as well (was calibration data in his case), but I couldn't reproduce it.

(2) Most if not all SF's were disabled (repeat of old issue?)

It seems to happen as well if the same models are loaded with current nightly release. So possibly not something introduced by this PR or #7199.

Loading your .etx file in latest Companion built from main branch shows most of the SFs as disabled (all but SF2 & SF3 on "FS5_620"). This might have been introduced by the conversion done with Companion. Companion 2.12.0 might be a better choice to do the initial conversion.

I fixed the above, then tested a complex (F3F) setup using the mixer monitor - seems to work but really need to visualise as it's impractical to assemble the models each time. I'm going to try a quick and dirty port of my visualiser script to the TX16S.

Is there anything that would help you testing? Would it be easier to test using the Companion simulator?

raphaelcoeffic and others added 5 commits March 29, 2026 08:31
Add LswPerFmTest suite (7 tests) that evidence the per-FM logical
switch context (lswFm[MAX_FLIGHT_MODES]) behavior:

- TimerStateIdenticalAcrossFMs: timer lastValue identical across 9 FMs
- StickyStateIdenticalAcrossFMs: sticky state identical through ON/OFF
- TimerStateSurvivesFMSwitch: FM switches don't affect timer counters
- EvalStateOnlyWrittenToCurrentFM: only the `state` bool differs
- DelayDurationDivergesAcrossFMs: timerState/timer DO diverge (delay/
  duration state machine only runs for current FM in getLogicalSwitch)
- TimerRealisticTiming: realistic 10ms/100ms timing, timers stay synced
- StickyRealisticTiming: sticky stays synced through FM switches

Findings: timer/sticky/edge lastValue (the counters) are always
identical across all FMs since logicalSwitchesTimerTick updates all FMs
with identical inputs. The `state` field and delay/duration `timerState`
diverge because evalLogicalSwitches/getLogicalSwitch only process the
current FM.

Move LogicalSwitchContext/LogicalSwitchesFlightModeContext structs from
switches.cpp to switches.h, add lswContext() accessor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… context

Replace lswFm[MAX_FLIGHT_MODES] (one LogicalSwitchContext per FM per LS)
with a flat lswCtx[MAX_LOGICAL_SWITCHES] array.  The previous commit's
test suite proved that per-FM state is fully redundant: timer/sticky/edge
lastValue is always identical across FMs, the `state` bit is dead in
non-active FMs, and delay/duration divergence is a bug (background timer
expiry) not a feature.

During FM fade transitions, the mixer evaluates mixes for multiple FMs
in a single cycle. To preserve the existing behavior where fading-out FMs
see the LS state from the moment of transition (smooth cross-fade for
LS-conditioned mixes), a uint32_t bitmap array per FM captures the frozen
LS state at transition time. getSwitch() reads from the frozen bitmap for
non-active FMs and from live lswCtx[] for the active FM. This replaces
~2 KB of per-FM context with 9 × 8 = 72 bytes of bitmaps + 1 byte for
mixerActiveFlightMode.

Changes:
- Flatten lswFm[MAX_FLIGHT_MODES] → lswCtx[MAX_LOGICAL_SWITCHES]
- Remove LogicalSwitchesFlightModeContext wrapper struct
- Hide LogicalSwitchContext struct and lswCtx[] as implementation details
  in switches.cpp; expose lswGetState/lswGetLastValue/lswSetState accessors
- Collapse logicalSwitchesTimerTick from FM×LS nested loop to single LS loop
- Replace logicalSwitchesCopyState with lswFreezeState bitmap snapshot
  called from mixer on FM transition
- Add mixerActiveFlightMode to distinguish active FM from temporarily
  assigned mixerCurrentFlightMode during fade evaluation
- Replace 16-test LswPerFmTest suite with 8-test LswTest suite verifying
  single-context behavior (delay/duration survive FM switches correctly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
evalLogicalSwitches was called inside evalFlightModeMixes, gated by
`if (tick10ms)` — a 12-year-old workaround (opentx commit f3bc8cb)
to prevent LS evaluation for non-current FMs during fade transitions.
This overloaded tick10ms (a timing parameter for mix delay/slow) as a
flag to control LS evaluation, obscuring the actual intent.

Since LS evaluation only needs to run once per mixer cycle for the
active FM, move the call to evalMixes directly, before the fade loop.
This makes the intent explicit and simplifies the code:

- Remove evalLogicalSwitches from evalFlightModeMixes
- Call evalLogicalSwitches() once in evalMixes, gated by tick10ms
- Remove the unused `isCurrentFlightmode` parameter (was always true
  since the tick10ms guard prevented the false path)
- Expand the fade loop ternaries into an explicit if/else for clarity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Switch

The Lua setStickySwitch API was directly encoding messages into a shared
CircularBuffer<uint8_t> using a bit-packed format that limited LS indices
to 6 bits (max 64 logical switches).

- Add lswSetStickySwitch(idx, value) function in switches.cpp
- Replace CircularBuffer class with a minimal SPSC ring buffer using a
  volatile struct array (GUI task writes, mixer task reads)
- Remove CircularBuffer class from edgetx_helpers.h (no remaining users)
- Make the buffer static in switches.cpp (no longer extern in edgetx.h)
- Lua API calls lswSetStickySwitch instead of encoding directly
- Remove MAX_LOGICAL_SWITCHES == 64 compile-time assertions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update the runtime cost table in model_arena.h to reflect the frozen
LS bitmap (frozenLsState[]) added alongside the lswCtx[] flattening.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@3djc
Copy link
Copy Markdown
Collaborator

3djc commented Mar 29, 2026

@RC-SOAR Mike, the GX12 issue is different from this PR, as SA and SD have moved to switches to custom switches, you must be experiencing side effects about that. Could we have you initial model ?

@RC-SOAR
Copy link
Copy Markdown

RC-SOAR commented Mar 29, 2026

Loading your .etx file in latest Companion built from main branch shows most of the SFs as disabled (all but SF2 & SF3 on "FS5_620"). This might have been introduced by the conversion done with Companion. Companion 2.12.0 might be a better choice to do the initial conversion.

Aha - it's an old issue! I've just checked - the SF's were already disabled in the 2.10 .etx file before import into Companion 3. The reason being that the 2.10 .etx had itself been converted from an OpenTX file, and SF's were disabled during that conversion - I seem to remember this was a legacy issue which was fixed.

Is there anything that would help you testing? Would it be easier to test using the Companion simulator?

I can test using the actual hardware, with my visualiser script (now supports colour radios) - it would be easier than using Companion.

2026-03-29_220621_cr

Ideally, and to really exclude potential pre-existing issues with current main branch, you would:

  • first flash latest nightly release 2.12.0
  • transfer models
  • verify everything is ok
  • then flash the firmware from this PR
  • verify things still work

All noted. I will make a start tomorrow. Is it sufficient to test using a single target? (I can test on a TX16S Mk1, TX16S Mk3 and/or TX15.)

The setups transferred successfully to the tx, however there were a couple of glitches:
(1) initially, the switches didn't work (failed to respond in Sys>Hardware>Debug Keys menu). Switches recovered after reboot.

That would be the radio settings. I'll have a look specifically there. So far, I could not reproduce it here. Ideally, I'd need a copy of /RADIO/radio.yml before and after loading it into the radio. That might show something regarding the corruption. So far, @philmoz reported something like that as well (was calibration data in his case), but I couldn't reproduce it.

Just to clarify, the switch failure occurred after exit from the bootloader "flash firmware" screen. The problem was at a low level, switches were not responding in the Hardware Debug menu. After reboot all was normal. The switch assignments were correct for the GX12 before import (SA/SD not used). I'll see if I can reproduce the issue using a more stable test setup.

@raphaelcoeffic
Copy link
Copy Markdown
Member Author

All noted. I will make a start tomorrow. Is it sufficient to test using a single target? (I can test on a TX16S Mk1, TX16S Mk3 and/or TX15.)

Yes. The goal is really to check if FM transitions are the same as before. The change in this PR is really only about the mixer and per-FM LS state. It works the same on all targets.

Just to clarify, the switch failure occurred after exit from the bootloader "flash firmware" screen. The problem was at a low level, switches were not responding in the Hardware Debug menu. After reboot all was normal. The switch assignments were correct for the GX12 before import (SA/SD not used). I'll see if I can reproduce the issue using a more stable test setup.

This might have been caused by the previous version which had a use-after-free memory issue, which can cause uncontrolled memory accesses and thus break basically anything. That should be fixed now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants