Summary
Two related defects in the chaser crossfade engine when steps are Collections rather than Scenes. The first I have a patch for; the second I have a root-cause analysis but no fix yet. Filing both together so anyone investigating crossfade behaviour has the full picture.
- Defect A — A Scene included as a child of multiple consecutive Collection steps (e.g. a base FOH state, dimmer-at-max) flickers / blackouts during the transition between those steps instead of holding its constant value.
- Defect B — When transitioning between Collection steps via the VC CueList "Crossfade" slider (
SlidersMode=Crossfade), LTP-grouped channels (Colour, Gobo, Pan, Tilt) jump to their new value instead of fading. The same chaser, advanced by a timed fade (FadeIn>0 on the step), fades those channels smoothly.
Both behaviours reproduce on QLC+ 4.14.4 (engine commit 027e6d4fd). v5 not yet verified, but the engine source is shared so v5 likely shares both defects.
Why it matters
Theatrical / show-control workflow: each cue is typically a Collection bundling FOH + RGB + position + colour + gobo. Sharing a base wash Scene across multiple cues is a natural authoring pattern. Under the two defects above:
- Defect A makes the shared-child pattern unusable for fades — fixtures blackout mid-transition.
- Defect B makes the side-slider unusable for moving-head cues — heads snap to position rather than panning/tilting smoothly, despite the same fixture choreography working fine under timed fade.
Reproduction workspaces
Attached test workspaces. They use only Generic RGB + Generic dimmer + Martin MiniMAC Profile, all stock fixtures.
- testing.qxw — reproduces Defect A. Three Collections, each containing the shared Scene
a (dimmer = 255) plus a unique RGB Scene. Chaser steps through the Collections; VC CueList drives it.
- testing2.qxw — reproduces Defect B in addition. Adds a MiniMAC moving head and parallel chasers: one slider-driven (
Crossfade slider test, FadeIn=0), one auto-fade (auto fade test, FadeIn=3000). Both drive the same Collections.
To reproduce Defect A:
- Load
testing.qxw.
- Open the VC CueList and drag the side crossfade slider across the step boundary.
- Watch the
Dimmers fixture (Universe 1 addr 3) in Simple Desk. Expected: holds 255 throughout. Observed: drops / flickers.
To reproduce Defect B (after Defect A is patched):
- Load
testing2.qxw.
- Use CueList 0 (slider). Drag through a step boundary while watching the MiniMAC pan/tilt/colour/gobo channels in Simple Desk. Observed: channels jump at the moment a new step is reached.
- Use CueList 1 (auto-fade, 3 s). Click Next. Observed: the same channels fade smoothly over 3 s.
Root cause — Defect A
When the same Scene Function is included as a child of consecutive Collection steps, the chaser transition triggers a stop() from the outgoing Collection and a start() from the incoming Collection in the same MasterTimer tick. The Scene's lifecycle goes through postRun → preRun in the same tick (engine/src/mastertimer.cpp:287-301), which calls Scene::handleFadersEnd (engine/src/scene.cpp:794-821) and either:
- dismisses the Scene's faders (fadeOut=0 case →
dismissAllFaders → asynchronous removal), or
- flags them for fade-to-0 (fadeOut>0 case → orphan fader writing toward 0 while the new fader writes toward target).
In addition, Collection::adjustAttribute only applied intensity to children when isRunning(), but during the transient preRun/postRun of a Collection the children's intensity overrides could be set against stale or duplicated parent state — the shared-Scene case ended up with mismatched intensities between the two Collections and the resulting Universe writes oscillated.
Patch (Defect A) — 725da3145
The patch is on branch xfade-investigation-4.14.4 of my fork.
engine: enhance Collection class to manage child intensity and starting states
- Adds
Collection::childIntensity(quint32 childId, bool includeSelf): walks all Collections in the Doc and sums the intensity contribution of every currently-running (or currently-starting) Collection that controls the given child. Shared Scenes therefore get the combined parent intensity rather than only the last-started Collection's value.
- Introduces a
m_startingChildren flag tracked across preRun so adjustAttribute and childIntensity can include a Collection whose children are mid-start (between requestAttributeOverride and the end of preRun). This closes the same-tick re-start race.
- Moves the per-child
start() calls outside the mutex held over m_functions/m_runningChildren. Avoids reentrancy issues when a Scene's start() triggers signals that touch the same Collection state.
postRun: snapshots the relevant state under the mutex, then performs function->stop() calls outside the lock and re-applies childIntensity(false) to children that are still running under other Collections (so a Scene still owned by another active Collection retains the correct combined intensity, instead of being suddenly orphaned with stale override).
VCCueList::syncCrossfadePrimarySideForStartup: syncs m_primaryTop to the slider's current value at chaser start, so the side-fader doesn't temporarily think the "primary" step is the wrong one when the slider isn't at 0/100. Parallel implementation added to qmlui/virtualconsole/vccuelist.cpp for v5.
- Adds
engine/test/collection/collection_test cases for shared-child intensity scenarios and ui/test/vccuelist/vccuelist_test cases for the new crossfade-primary-side logic.
After the patch, testing.qxw no longer flickers the dimmer. Behaviour matches expected: shared channels stay constant; non-shared channels (RGB) crossfade smoothly via HTP intensity scaling.
Root cause — Defect B (not patched)
Surfaced after Defect A was fixed and the MiniMAC moving head was added to the test rig.
GenericFader::write (engine/src/genericfader.cpp:241-254) has two gated branches for canFade channels:
if (fc.canFade())
{
if ((flags & FadeChannel::CrossFade) && fc.fadeTime() == 0)
{
// morph start <-> target via intensity()
...
}
else if (flags & FadeChannel::Intensity)
{
value = fc.current(compIntensity);
}
}
- The
Intensity flag is set in fadechannel.cpp:177 only when m_channelRef->group() == QLCChannel::Intensity. Dimmer + RGB → set. Colour, Gobo, Pan, Tilt → not set.
- The
CrossFade flag is set in scene.cpp:760 only when the Scene's m_blendFunctionIDs contains a predecessor Scene that also owns the channel. ChaserRunner::startNewStep (chaserrunner.cpp:498-519) only wires setBlendFunctionID between two Function::SceneType steps. For Collection-typed outgoing or incoming steps, m_lastFunctionID is reset to invalidId() (chaserrunner.cpp:250,474,785) and the blend wiring never happens.
Consequence in slider mode (Chaser::Blended / BlendedCrossfade → chaserrunner.cpp:521-633 zeroes step fadeIn/fadeOut):
- HTP/Intensity channels (dimmer, RGB): the
else if branch fires, value = fc.current(compIntensity) scales by the slider-driven parentIntensity. They fade.
- LTP channels (Colour, Gobo, Pan, Tilt) on a Collection step: neither branch fires.
value = fc.current() which is m_target (snapped because fadeTime == 0). They jump.
Timed-fade mode works for the same LTP channels because Chaser::FromFunction keeps the step's authored fadeIn (3000 ms in the test workspace), Scene::processValue calls fc->setFadeTime(3000) (scene.cpp:789), and FadeChannel::calculateCurrent (fadechannel.cpp:393-398) linearly ramps m_current over the fade time regardless of channel group. The LTP write path falls through GenericFader::write with a ramped fc.current() and the channel fades.
Expected behaviour (Defect B)
Crossfade slider should drive a smooth ramp on every canFade channel, matching the timed-fade behaviour. Workspace authors who want a snap (e.g. discrete gobo wheel position before opening the shutter) can structure that with an extra step — same control they have under timed fade today.
Fix directions considered (Defect B)
In my followup analysis I sketched five hypotheses. Two are the most plausible:
- F1 — Extend blend-predecessor wiring to flatten Collection children.
ChaserRunner would set m_blendFunctionIDs on the incoming Collection's Scene children to the outgoing Collection's Scene children, so the CrossFade flag gets set and the existing morph branch in GenericFader::write runs. This was attempted in earlier (now-superseded) patches and was defeated by the shared-Scene orphan-fader race — but that race is closed by 725da3145, so the predecessor wiring should now be safe to extend.
- F5 — Treat the slider as a manual elapsed-time scrubber on the step's authored fade time, instead of zeroing it.
ChaserRunner::startNewStep keeps the step's fade time; tick advancement of FadeChannel::m_elapsed is replaced by slider position. Closer to the user-visible semantic ("slider mimics timed fade"), but a larger structural change.
I am not proposing either yet — wanted to land the Defect A patch first and document the next layer.
Files touched / referenced
Patch (725da3145):
engine/src/collection.cpp, engine/src/collection.h — childIntensity, startingChildren, preRun/postRun rework.
engine/test/collection/collection_test.{cpp,h} — new test coverage.
ui/src/virtualconsole/vccuelist.{cpp,h} — syncCrossfadePrimarySideForStartup.
qmlui/virtualconsole/vccuelist.{cpp,h} — v5 parallel implementation.
ui/test/vccuelist/vccuelist_test.{cpp,h} — new test coverage.
Referenced in analysis but not modified by the patch:
engine/src/genericfader.cpp (the LTP fade gate).
engine/src/fadechannel.cpp (Intensity flag autodetection, calculateCurrent ramp).
engine/src/chaserrunner.cpp (blend predecessor wiring restricted to SceneType).
engine/src/scene.cpp (CrossFade flag application in processValue, handleFadersEnd lifecycle).
engine/src/mastertimer.cpp (same-tick stop+start path).
Environment
- QLC+ 4.14.4 (engine commit
027e6d4fd).
- Built on Linux, Qt 6, GCC 14. Compilation required demoting two
-Werror flags in variables.cmake (-Wno-error=sfinae-incomplete, broader -Wno-error for unused-but-set-variable and deprecated libusb enum mixing). Not part of this patch's scope; flagged separately.
- DMX observed via Simple Desk; no capture rig used.
Status / what I'm asking for
- Defect A patch (
725da3145) is ready for review on branch xfade-investigation-4.14.4 of my fork — happy to open it as a PR if maintainers want it in that form.
- Defect B I'm filing for visibility — would welcome opinions from anyone with deeper knowledge of the GenericFader / FadeChannel design. If maintainers prefer one direction over the other, please say so here.
Summary
Two related defects in the chaser crossfade engine when steps are Collections rather than Scenes. The first I have a patch for; the second I have a root-cause analysis but no fix yet. Filing both together so anyone investigating crossfade behaviour has the full picture.
SlidersMode=Crossfade), LTP-grouped channels (Colour, Gobo, Pan, Tilt) jump to their new value instead of fading. The same chaser, advanced by a timed fade (FadeIn>0on the step), fades those channels smoothly.Both behaviours reproduce on QLC+ 4.14.4 (engine commit
027e6d4fd). v5 not yet verified, but the engine source is shared so v5 likely shares both defects.Why it matters
Theatrical / show-control workflow: each cue is typically a Collection bundling FOH + RGB + position + colour + gobo. Sharing a base wash Scene across multiple cues is a natural authoring pattern. Under the two defects above:
Reproduction workspaces
Attached test workspaces. They use only Generic RGB + Generic dimmer + Martin MiniMAC Profile, all stock fixtures.
a(dimmer = 255) plus a unique RGB Scene. Chaser steps through the Collections; VC CueList drives it.Crossfade slider test, FadeIn=0), one auto-fade (auto fade test, FadeIn=3000). Both drive the same Collections.To reproduce Defect A:
testing.qxw.Dimmersfixture (Universe 1 addr 3) in Simple Desk. Expected: holds 255 throughout. Observed: drops / flickers.To reproduce Defect B (after Defect A is patched):
testing2.qxw.Root cause — Defect A
When the same Scene Function is included as a child of consecutive Collection steps, the chaser transition triggers a
stop()from the outgoing Collection and astart()from the incoming Collection in the sameMasterTimertick. The Scene's lifecycle goes throughpostRun→preRunin the same tick (engine/src/mastertimer.cpp:287-301), which callsScene::handleFadersEnd(engine/src/scene.cpp:794-821) and either:dismissAllFaders→ asynchronous removal), orIn addition,
Collection::adjustAttributeonly applied intensity to children whenisRunning(), but during the transientpreRun/postRunof a Collection the children's intensity overrides could be set against stale or duplicated parent state — the shared-Scene case ended up with mismatched intensities between the two Collections and the resulting Universe writes oscillated.Patch (Defect A) —
725da3145The patch is on branch
xfade-investigation-4.14.4of my fork.engine: enhance Collection class to manage child intensity and starting statesCollection::childIntensity(quint32 childId, bool includeSelf): walks all Collections in the Doc and sums the intensity contribution of every currently-running (or currently-starting) Collection that controls the given child. Shared Scenes therefore get the combined parent intensity rather than only the last-started Collection's value.m_startingChildrenflag tracked acrosspreRunsoadjustAttributeandchildIntensitycan include a Collection whose children are mid-start (betweenrequestAttributeOverrideand the end ofpreRun). This closes the same-tick re-start race.start()calls outside the mutex held overm_functions/m_runningChildren. Avoids reentrancy issues when a Scene'sstart()triggers signals that touch the same Collection state.postRun: snapshots the relevant state under the mutex, then performsfunction->stop()calls outside the lock and re-applieschildIntensity(false)to children that are still running under other Collections (so a Scene still owned by another active Collection retains the correct combined intensity, instead of being suddenly orphaned with stale override).VCCueList::syncCrossfadePrimarySideForStartup: syncsm_primaryTopto the slider's current value at chaser start, so the side-fader doesn't temporarily think the "primary" step is the wrong one when the slider isn't at 0/100. Parallel implementation added toqmlui/virtualconsole/vccuelist.cppfor v5.engine/test/collection/collection_testcases for shared-child intensity scenarios andui/test/vccuelist/vccuelist_testcases for the new crossfade-primary-side logic.After the patch,
testing.qxwno longer flickers the dimmer. Behaviour matches expected: shared channels stay constant; non-shared channels (RGB) crossfade smoothly via HTP intensity scaling.Root cause — Defect B (not patched)
Surfaced after Defect A was fixed and the MiniMAC moving head was added to the test rig.
GenericFader::write(engine/src/genericfader.cpp:241-254) has two gated branches forcanFadechannels:Intensityflag is set infadechannel.cpp:177only whenm_channelRef->group() == QLCChannel::Intensity. Dimmer + RGB → set. Colour, Gobo, Pan, Tilt → not set.CrossFadeflag is set inscene.cpp:760only when the Scene'sm_blendFunctionIDscontains a predecessor Scene that also owns the channel.ChaserRunner::startNewStep(chaserrunner.cpp:498-519) only wiressetBlendFunctionIDbetween twoFunction::SceneTypesteps. For Collection-typed outgoing or incoming steps,m_lastFunctionIDis reset toinvalidId()(chaserrunner.cpp:250,474,785) and the blend wiring never happens.Consequence in slider mode (
Chaser::Blended/BlendedCrossfade→chaserrunner.cpp:521-633zeroes step fadeIn/fadeOut):else ifbranch fires,value = fc.current(compIntensity)scales by the slider-drivenparentIntensity. They fade.value = fc.current()which ism_target(snapped becausefadeTime == 0). They jump.Timed-fade mode works for the same LTP channels because
Chaser::FromFunctionkeeps the step's authored fadeIn (3000 ms in the test workspace),Scene::processValuecallsfc->setFadeTime(3000)(scene.cpp:789), andFadeChannel::calculateCurrent(fadechannel.cpp:393-398) linearly rampsm_currentover the fade time regardless of channel group. The LTP write path falls throughGenericFader::writewith a rampedfc.current()and the channel fades.Expected behaviour (Defect B)
Crossfade slider should drive a smooth ramp on every
canFadechannel, matching the timed-fade behaviour. Workspace authors who want a snap (e.g. discrete gobo wheel position before opening the shutter) can structure that with an extra step — same control they have under timed fade today.Fix directions considered (Defect B)
In my followup analysis I sketched five hypotheses. Two are the most plausible:
ChaserRunnerwould setm_blendFunctionIDson the incoming Collection's Scene children to the outgoing Collection's Scene children, so theCrossFadeflag gets set and the existing morph branch inGenericFader::writeruns. This was attempted in earlier (now-superseded) patches and was defeated by the shared-Scene orphan-fader race — but that race is closed by725da3145, so the predecessor wiring should now be safe to extend.ChaserRunner::startNewStepkeeps the step's fade time; tick advancement ofFadeChannel::m_elapsedis replaced by slider position. Closer to the user-visible semantic ("slider mimics timed fade"), but a larger structural change.I am not proposing either yet — wanted to land the Defect A patch first and document the next layer.
Files touched / referenced
Patch (
725da3145):engine/src/collection.cpp,engine/src/collection.h— childIntensity, startingChildren, preRun/postRun rework.engine/test/collection/collection_test.{cpp,h}— new test coverage.ui/src/virtualconsole/vccuelist.{cpp,h}—syncCrossfadePrimarySideForStartup.qmlui/virtualconsole/vccuelist.{cpp,h}— v5 parallel implementation.ui/test/vccuelist/vccuelist_test.{cpp,h}— new test coverage.Referenced in analysis but not modified by the patch:
engine/src/genericfader.cpp(the LTP fade gate).engine/src/fadechannel.cpp(Intensity flag autodetection, calculateCurrent ramp).engine/src/chaserrunner.cpp(blend predecessor wiring restricted to SceneType).engine/src/scene.cpp(CrossFade flag application inprocessValue,handleFadersEndlifecycle).engine/src/mastertimer.cpp(same-tick stop+start path).Environment
027e6d4fd).-Werrorflags invariables.cmake(-Wno-error=sfinae-incomplete, broader-Wno-errorfor unused-but-set-variable and deprecated libusb enum mixing). Not part of this patch's scope; flagged separately.Status / what I'm asking for
725da3145) is ready for review on branchxfade-investigation-4.14.4of my fork — happy to open it as a PR if maintainers want it in that form.