Skip to content

Ground rendering / pushback / livery / weather / channels — 36-commit fix set#312

Merged
TwinFan merged 40 commits into
TwinFan:PR312from
nebukadnezar:master
May 17, 2026
Merged

Ground rendering / pushback / livery / weather / channels — 36-commit fix set#312
TwinFan merged 40 commits into
TwinFan:PR312from
nebukadnezar:master

Conversation

@nebukadnezar
Copy link
Copy Markdown
Contributor

LiveTraffic — proposed fixes from nebukadnezar/LiveTraffic fork

Branch: master, 36 commits ahead of TwinFan/LiveTraffic@39c8816 (v4.4.0).
Fork: https://github.com/nebukadnezar/LiveTraffic
Diffstat: 15 files changed, 3668 insertions(+), 60 deletions(-)
Date: 2026-05-13 (initial), updated 2026-05-17 with sections 5–9

This document describes a series of fixes developed against the v4.4.0 release on macOS (Apple Silicon, XCode 26, X-Plane 12) while diagnosing visible aircraft-rendering issues with the RealTraffic feed at busy airports (LSZH, EGLL, KJFK, KDFW, YSSY). Each section gives the symptom, the root cause as observed in the log/code, the fix, and the commit hash.

The change set falls into nine themes:

  1. Ground rendering — eliminating the "dance" of parked and slow-taxiing aircraft, smoothing rotation/de-rotation, correcting heading during high-speed ground motion. (Sections 1, 5)
  2. Livery matching — preventing garbage 3-letter "airline codes" from registrations, surfacing existing CSL liveries for codeshare/merger affiliates via an extended relOp.txt, and using the v6 operator field as a fallback. (Sections 2, 8)
  3. Weather robustness — rejecting placeholder QNH responses from RealTraffic that were destroying altitude correction. (Section 3)
  4. Channel recovery — making the channel-enable checkbox revive an invalidated channel; TCP-keepalive + connection-reuse fixes that stop curl error 55 storms. (Sections 4, 6)
  5. Centripetal Catmull-Rom ground spline — replacing the per-leg Bezier with an arc-length-reparameterised C¹-continuous spline through four control points, eliminating sliding-into-turns and back-jitter at slow ground speeds. (Section 6)
  6. Take-off / climb / liftoff smoothing — Gaussian-weighted altitude regression archive to remove altitude pops at rotation; pitch cap on rotate. (Section 7)
  7. Parked traffic — making the RealTraffic parked snapshot actually render, persist, and hold the correct heading; durable gate-handoff that survives mid-cycle re-fetches. (Section 8)
  8. Pushback state machine — full pipeline rework: apt.dat startup-loc gate detection, distance-gated motion acceptance, locked nose-source choice with safety-valve emergency exit. (Section 9)
  9. Misc data-qualityFF**** placeholder-hex duplicate prune; revert of an incorrect posTime -= PosAge adjustment. (Section 9)

All code-side changes are heavily commented inline (rationale + thresholds + how the fix interacts with the surrounding flight model). The relOp.txt change lives in Lib/XPMP2/Resources/relOp.txt and is the only edit outside Src//Include/.


1. Ground rendering — the "dance" problem

Symptom (user-facing):
Aircraft sitting at gates visibly oscillate / rotate / pivot in place ("dance"). Slow-taxiing aircraft turn jerkily. Landing rollouts slide off the runway centerline toward the next taxiway turn-off. Take-off rolls show the aircraft pointing 45° off motion direction "until well after airborne". Touch-down snaps the nose flat in one frame.

Why this was happening:

  • LTFlightData::CalcHeading derives heading from atan2 of consecutive lat/lon deltas (Src/LTFlightData.cpp:1473). Public ADS-B / MLAT feeds carry ~1 Hz positions with several metres of jitter. For a parked aircraft, that noise is the apparent motion vector and the derived heading swings wildly.
  • The renderer (LTAircraft::CalcAcPos) was using to.heading() (the next slot's reported heading) as the end-tangent of a Bezier curve between slots, even at runway-roll speeds where the aircraft physically tracks straight along the centerline. The Bezier then arced across the runway corner.
  • pitch.moveTo(0) on touch-down was being clobbered the next frame by a ground-pitch override, snapping the nose flat instead of walking it down smoothly during roll-out.
  • During pushback the track-from-pos-delta is the opposite direction to the nose, but the renderer trusted track over the feed-supplied heading even at pushback speeds.

Fixes (in order applied):

# Commit Summary
2 98f7f71 New Constants.h tunables for ground stability (hysteresis, rate limit, stationary threshold, holding timeout, ground attitude, pushback band, Bezier ground-min, track-heading threshold). Every constant has an inline rationale comment.
3 511bc67 Hard-set pitch / roll to GND_PITCH_DEG / GND_ROLL_DEG (initially 2° / 0°, later 0° / 0°) on the ground each frame in CalcAcPos, with phase exceptions for FPH_ROTATE/FPH_FLARE/FPH_TOUCH_DOWN so the dynamic-pitch transitions aren't clobbered.
4 cbd27fa Stationary-freeze and ±4° hysteresis branches at the top of CalcHeading. When both adjacent positions are on-ground with derived gs ≤ GND_STATIONARY_GS_KT, reuse the predecessor's filtered heading instead of recomputing from jitter.
5 51d319b Per-frame heading rate limit (GND_HEADING_MAX_RATE_DPS) in CalcAcPos. Even if the target jumps, the rendered nose walks smoothly. Bypassed in the air, where the MovingParam's defDuration already smooths.
6 c517eb5 Holding state machine in LTFlightData::AddNewPos. After GND_HOLDING_TIMEOUT_S of continuous stationary streak (GND_STATIONARY_GS_KT and below) the channel-side flag bGroundHolding is set; subsequent feed positions within GND_HOLDING_TRIVIAL_DIST_M of the latest stored position are silently dropped — the deque sees a perfectly stable parked aircraft. New GND_HOLDING_EXIT_CONSEC requires multiple consecutive non-stationary slots to exit holding (a single isolated 2 kt sample from feed jitter won't break the lock).
7 df0f1e3 Lower the Bezier-activation threshold on the ground (GND_BEZIER_MIN_HEAD_DIFF = 1° vs the 2.5° airborne default) so slow taxi turns get curve-tangent heading rather than the linear MovingParam fallback.
8 22e4d4b State-free pushback heuristic in CalcHeading. When a ground slot has derived gs between stationary and PUSHBACK_DETECT_GS_MAX_KT AND the track is ≥ PUSHBACK_DETECT_HEAD_DIFF_DEG from the previous heading, the slot's heading is locked to the predecessor's value. Visually: aircraft moves backward with its nose still pointing at the gate. Per-slot classification — no state machine, no separate code path.
9 a2decf9 After observation: set GND_PITCH_DEG to 0° (commit 3 had 2°; visually wrong on narrow-bodies — turned into a tail-dragger look). Also added unconditional GND_DIAG_* diagnostic logging in AddNewPos / CalcHeading (tagged so it's grep-able and easy to remove). These logs were essential for the threshold-tuning that follows.
10 a1e0423 Threshold retune from real RealTraffic data: GND_STATIONARY_GS_KT 0.5→1.5, GND_HEADING_HYSTERESIS_DEG 0.5→4.0, GND_HEADING_MAX_RATE_DPS 60→12 (matches TAXI_TURN_TIME), GND_HOLDING_TRIVIAL_DIST_M 7→15, plus new GND_HOLDING_EXIT_CONSEC = 2. All values now correspond to observed RealTraffic jitter envelopes for parked traffic at gates (gs ≤ 1.1 kt from a few meters of positional noise, ±5–7° heading wobble at slow taxi).
11 51a1ced New rule: on the ground at gs < GND_USE_FEED_HEADING_MAX_KT (10 kt), trust the feed-supplied heading over a track-derived one. RealTraffic delivers a stable heading for parked and pushback aircraft via the transponder data; the previous code was discarding it in favour of jitter-driven atan2. Fixes the observed pushback misrendering of AA1146 / AA2980 at peak hub time.
13 658dc6b De-rotation on landing. Add FPH_ROLL_OUT to the ground-pitch override's phase exception list, so the MovingParam walk from PITCH_FLARE down to GND_PITCH_DEG (issued at touchdown) plays out smoothly over ~1 s of roll-out instead of being snapped to 0 the frame after touchdown. Roll is still forced flat in all phases including the dynamic-pitch ones.
14 6aad04c At high ground speed (rollout, takeoff, fast taxi at gs ≥ GND_TRACK_HEADING_MIN_KT = 10 kt) skip BezierCurve::Define and force linear interpolation. The Bezier's end-tangent was to.heading(), which during runway-end → turn-off transitions arcs the rendered path across the corner. Linear interpolation walks heading toward vec.angle (the actual motion vector) instead. Also skip the half-way retarget to to.heading() under the same condition.
15 9477671 Refinement of (14): use vec.speed_kn() (leg-average speed = dist / dt) instead of GetSpeed_kt() (current rendered speed) for the high-ground-speed check. The original check used the speed coming into the leg, which is wrong for the runway-entry leg where gs enters at ~5 kt and leaves at ~12 kt: the leg-average is 11.8 kt, but the entry speed is 5, so the old check failed and the Bezier was set up across the 89° corner. The next leg's track-heading rule then started walking nose toward the runway, but the MovingParam's natural turn rate is too slow to converge before rotation. Result: aircraft "ADO15 take-off facing 45° away from travel direction until well after airborne". Switching to leg-average fixes this from the moment the aircraft enters the runway.
16 4528131 Skip SnapToTaxiways while bGroundHolding is true. LTAptSnap synthesises intermediate waypoints between feed samples with heading=NaN, expecting CalcHeading to fill them in. For a parked aircraft an isolated large feed jump (observed: ACA34 at YSSY, 105 m glitch while the RT app showed the aircraft stationary) gets accepted (exceeds the 15 m trivial-drop threshold), and SnapToTaxiways then synthesises a path of intermediate waypoints between the parked position and the jumped position. The phantom slots' headings come out following the taxi-graph geometry (302° → 256° → 244° in the observed case), not the aircraft's nose direction — and the rendered aircraft walks through them, "dancing" along a non-existent taxi route. Suppressing snap during holding keeps the parked aircraft visually stable; once holding exits (real motion), snap resumes.

Files touched: Include/Constants.h, Include/LTFlightData.h, Src/LTAircraft.cpp, Src/LTFlightData.cpp. Verbose doxygen comments above every modified function explain the why.

Diagnostic logging left in: the GND_DIAG_* tags in LTFlightData::CalcHeading and LTFlightData::AddNewPos fire unconditionally for every on-ground feed update and heading computation (no GetDebugAcPos flag required). They were critical for the threshold tuning and could be wrapped in a debug flag before merging if you prefer.


2. Livery matching — relOp.txt extension + tail-number detection

Symptom (user-facing):
American Airlines flights showing in random liveries (Air Berlin, Cyprus Airways, etc.). Republic Airways E170s wearing Estonian Air livery. PVL (Pivot Airlines) Dash-8 showing as Austrian. NetJets (EJA) business jets matching Pacific Airways. Many private aircraft (N-prefix registrations) picking arbitrary commercial airline liveries.

Why this was happening:

FDStaticData::airlineCode() returns opIcao if set, else call.substr(0, 3). The RealTraffic DRCT channel reads RT_DRCT_CallSign (field 13 of the JSON array — "ATC Callsign" per the API v6 docs, ICAO format like QFA1926) into stat.call, but never sets stat.opIcao. The substring is therefore the only airline source.

Three independent root causes were diagnosed from a session's Log.txt plus the captured LTRawFD.log:

(A) Garbage 3-char codes from tail-number callsigns. Private/GA aircraft commonly transmit their registration as the callsign (N552FX, 9K1876, 1I637). call.substr(0, 3) returns "N55", "9K1", "1I6" — none of which are valid airline ICAO codes. XPMP2 fails to match the airline, falls back to "any livery of this aircraft class", and the tie-breaker is random.

(B) relOp.txt is essentially empty. The shipped file has 13 lines: only Aer Lingus and Ryanair are grouped. None of the major North American codeshare/merger groups (American Eagle, United Express, Delta Connection, AirTran/Southwest, Virgin America/Alaska), no Air Canada family, no KLM/HOP-Air-France/Lufthansa-CityLine grouping. So when a regional carrier (RPA, SKW, EDV, ENY) is requested and no specific livery exists, the matcher has no "related operator" hint and picks randomly.

(C) Missing CSL packages. Some (type, airline) combinations are entirely absent from the installed Bluebell library: no A319_AAL, no A320_AAL, no Republic (*_RPA) at all, no Bombardier Challenger (CL30/CL35/CL60), no Gulfstream G-series widebodies, no A220 (BCS3), no A350 (A35x). These can only be fixed by installing additional repaint packages; we can't conjure CSL models from code.

Fix in commit 12 (1102320): Update airlineCode() to detect tail-number-style callsigns. If any of the first three characters of call is not an alphabetic letter (ICAO airline codes are always 3 letters), return an empty string rather than the substring. XPMP2 then does type-only matching, which is more faithful to reality than "private jet wearing Austrian livery". Inline doxygen comment explains the rationale.

inline std::string airlineCode() const
{
    if (!opIcao.empty())
        return opIcao;
    if (call.length() < 3)
        return "";
    if (!std::isalpha(static_cast<unsigned char>(call[0])) ||
        !std::isalpha(static_cast<unsigned char>(call[1])) ||
        !std::isalpha(static_cast<unsigned char>(call[2])))
        return "";
    return call.substr(0, 3);
}

Fix on the data side (not committed to this repo — see note below):
Extended Lib/XPMP2/Resources/relOp.txt with ~12 new groups covering North American codeshare and merger relationships, plus the European mainline-group consolidations. Each entry is documented inline (which carriers, which parent, why the grouping was chosen). Notably:

AAL USA TWA ENY JIA PDT MQA RPA       ; American family + Eagle codeshares
UAL COA SKW ASH GJS AWI UCA           ; United family + Express codeshares
DAL NWA EDV                            ; Delta family + Connection codeshares
SWA TRS
ASA QXE VRD
ACA JZA ROU PVL                        ; Air Canada + Jazz + Rouge + Pivot Airlines
EJA LXJ VJA EJM FTD TWY                ; Fractional/charter business-jet group
KLM KLC
AFR HOP
DLH CLH EWG
BAW CFE
CSN CXA                                ; China Southern + Xiamen

Lib/XPMP2/Resources/related.txt was also updated to merge the Dassault Falcon trijets (F900/FA50/FA7X/FA8X) into the L2J biz-jet group because no CSL library currently ships trijet biz-jet liveries. Without the merge, an FA8X falls outside the related group entirely and lands on a 757 (size-matched fallback to the widest commercial twin). With the merge, FA8X falls back to a Gulfstream / Challenger / Hawker — vastly closer visually.

Note on the relOp.txt and related.txt edits: These files live in the XPMP2 submodule (Lib/XPMP2/Resources/) rather than the LiveTraffic tree. The edits are not part of the 18 commits on this branch — they show as modified: Lib/XPMP2 (modified content) in git status. To integrate them cleanly the recommendation would be a separate XPMP2 PR (since XPMP2 is the upstream owner of those files). I have the exact diff ready if you'd like me to send it as a XPMP2 patch in parallel.


3. Weather — placeholder QNH responses (commit 17, 82042be)

Symptom (user-facing):
At YSSY (real QNH 1034 hPa) landing aircraft were touching down on the terrain about a mile short of the runway threshold.

Why this was happening:

LTRealTraffic::PreProcessWeather accepts every successful weather response from the RT endpoint. The endpoint sometimes returns a stripped-down placeholder response for the same query coordinates that previously returned a valid one. Observed sequence at YSSY:

0:00:32  QNH 1013 hPa at (35.54/139.81)        — Tokyo area (pre-Sydney)
0:03:45  QNH 1034 hPa at YSSY (-33.93/151.17)  — correct, with METAR
0:04:49  QNH 1013 hPa at  (-33.93/151.17)      — placeholder! no ICAO, no METAR
0:05:50+ QNH 1013.0                            — stuck at standard for rest of session

The 1013 placeholder overwrites the good 1034. From then on BaroAltToGeoAlt_ft(d, rtWx.QNH) applies essentially zero correction. At YSSY's real QNH 1034 the missing correction is +560 ft per 1500 ft of baro altitude. Aircraft on final at 1500 ft AGL are rendered at -60 ft relative to the runway elevation; X-Plane's terrain probe sees them already below the surrounding terrain, "lands" them on that terrain, and the user sees aircraft sliding to the ground a mile before the runway.

Fix: in PreProcessWeather, treat a response as a placeholder when all of:

  • No ICAO field
  • No METAR text
  • wxQNH within 0.5 hPa of HPA_STANDARD (1013.25)
  • We already hold a non-standard QNH

…and skip the SetWeather call. The last condition lets a genuine 1013-hPa first weather update through; we only reject when a placeholder would overwrite a previously confirmed non-standard value. A debug log line Ignoring placeholder RealTraffic weather (QNH=…, no ICAO, no METAR); keeping previous QNH=… makes the rejection visible.

Verified in a follow-up session: QNH 1034 received once, no subsequent placeholder overwrites observed (RT just happened to consistently return good data this run); guard is in place for the next time the symptom recurs.


4. Channels — re-enable doesn't revive an invalidated channel (commit 18, 21c0a36)

Symptom (user-facing):
After RealTraffic experienced a brief network outage (7 connect failures to rtwa.flyrealtraffic.com:443 over 1.2 s), the channel was marked invalid. The user toggled the channel-enable checkbox off and back on. Nothing happened — the channel stayed dead. The user reported "the log shows no errors preventing restart but the channel won't come back".

Why this was happening:

LTChannel::shallRun() (the gate the maintenance loop uses to decide whether to call Start()) requires both IsValid() and IsChannelEnabled(). The channel-enable checkbox is wired to DataRefs::SetChannelEnabled (via the DataRef setter in DataRefs.cpp:1285), which only flips bChannel[ch] and never touches bValid. So after an outage:

  1. Channel went invalid → bValid=false
  2. User toggled OFF → bChannel[ch]=false, bValid unchanged
  3. User toggled ON → bChannel[ch]=true, bValid still false
  4. LTFlightDataAcMaintenance polls every 2 s → shallRun() returns false because IsValid()==false → no Start() call

The intended recovery path was the "Restart Stopped Channels" button in Settings → Basic (which calls LTFlightDataRestartInvalidChs). Most users will reach for the channel's own checkbox first; that does nothing visible. The single existing caller of LTChannel::SetEnable (which does call SetValid(true) on enable) is the OpenSky auth-recovery path; it's not used by the UI.

Fix: in DataRefs::SetChannelEnabled, when bEnable=true, look up the LTChannel via LTFlightDataGetCh and call SetValid(true) on any currently-invalid channel. The off-then-on toggle now behaves as the intuitive UX suggests: a re-enable revives the channel for the next maintenance tick. SetValid(true) also resets errCnt, so the channel gets a fresh CH_MAC_ERR_CNT budget.

Trade-off note: if the underlying failure is permanent (bad credentials, server still down), the channel will re-invalidate on the next attempt — and the user gets immediate visible feedback rather than silent inaction.


What is NOT in this branch (intentionally)

  • Pushback Bezier with reversed-tangent path. A more sophisticated pushback rendering (build the position path from the actual feed positions but render heading as the reversed Bezier tangent) was scoped but deferred. The current state-free PUSHBACK_DETECT_* heuristic in CalcHeading handles the visible "tail-first" symptom adequately; the more sophisticated version would help when the feed's reported heading is stale during the maneuver.
  • CL35 / CL60 / FA8X liveries. Symptoms reported by the user. The diagnosis is purely CSL package coverage: no Bombardier Challenger, no Gulfstream widebody, no Falcon trijet models are installed in the Bluebell library used during testing. Recommendation: install additional repaint packages; no LT-side code/data fix exists.
  • CEB (Cebu Pacific) widebodies. Same root cause as above — only A319_CEB exists in the user's CSL. No widebody. The matcher prefers same-type-wrong-airline over wrong-type-same-airline (bit 3 outweighs bit 1 in the quality bitmask), so the A330 falls back to a random A330. CEB has no logical sister carrier to borrow from in relOp.txt. Documented as a CSL-only fix.

5. Ground rendering — feed-vs-track cross-check + snap-order fix

Symptom (user-facing):
Some aircraft taxiing visibly sideways — body pointed one direction, motion in another — particularly noticeable on slow turns and short connecting taxiways. Other aircraft snapping onto the wrong taxi segment because SnapToTaxiways was being given a stale heading.

Why this was happening:

Two coupled issues:

  • EHS staleness. The feed-supplied heading from Mode-S Enhanced Surveillance is sampled less often than the position. On a slow turn, the position-derived track has already rotated 20–30° while the feed still reports the pre-turn heading. The trust-feed-at-low-gs rule from commit 11 (section 1) was correct for parked aircraft but over-trusted feed during low-speed turns.
  • Snap order. LTAptSnap ran before CalcHeading's feed-vs-track decision, so the snap saw the unfiltered raw heading. Wrong snap candidate selected → aircraft on the wrong taxiway segment.

Fixes:

# Commit Summary
19 d861869 Cross-check feed heading vs derived track at low ground speed. Three regimes gated by GND_FEED_TRACK_AGREE_DEG = 30°: agree (<30°) → trust feed; opposite (>150°) → trust feed and infer "track reversed (pushback)"; disagree (30°–150°) → fall through to position-derived heading. Catches EHS staleness during slow turns without breaking the trust-feed behaviour at the parked / pushback endpoints.
20 a28ae93 Apply the CalcHeading cross-check BEFORE SnapToTaxiways. The snap candidate selection now uses the corrected heading, so an aircraft mid-turn won't get snapped onto the segment it's coming FROM.

Files touched: Src/LTFlightData.cpp (cross-check block at line ~1880), Src/LTApt.cpp (snap call ordering).


6. Channel reliability — TCP keepalive + no connection reuse + faster wait floor

Symptom (user-facing):
RealTraffic channel hitting curl error 55: Send failure: Broken pipe repeatedly, then going invalid mid-session even with a healthy network. Long gaps between traffic updates causing visible aircraft "freezes" at busy airports.

Why this was happening:

  • No TCP keepalive. curl handles opened with default options never sent keepalive probes. Idle middleboxes (residential routers, ISP NAT) silently dropped the TCP state after ~5 min. The next request hit a half-open socket → broken pipe → IncErrCnt → eventual channel invalidation.
  • Connection reuse. Even after a successful response, curl reused the same socket for the next request. If the middlebox had timed out the connection in the meantime, the reuse failed instead of opening fresh.
  • Wait floor too high. RT_DRCT_TRAFFIC_WAIT_FLOOR_S was set to 5 s, meaning even when traffic was responsive we waited 5 s between requests. At busy airports with rapid feed turnover this lost real-time fidelity.

Fixes:

# Commit Summary
21 cee1c1c Enable CURLOPT_TCP_KEEPALIVE, CURLOPT_TCP_KEEPIDLE=30, CURLOPT_TCP_KEEPINTVL=15 on all curl handles. Sockets get probed before middleboxes idle them out.
22 600904f Forbid curl HTTP connection reuse via CURLOPT_FORBID_REUSE=1. Each request gets a fresh connection — minor overhead, eliminates the half-open-socket failure mode.
23 6fb0b1f Lower RT_DRCT_TRAFFIC_WAIT_FLOOR_S from 5 s to 2 s. Combined with the reliability fixes above, the channel is now responsive enough that the lower floor doesn't induce server pressure.

Files touched: Src/LTChannel.cpp, Src/LTRealTraffic.cpp, Include/LTRealTraffic.h.


7. Centripetal Catmull-Rom ground spline (commits 24-25, 486b916 + 78b58db)

Symptom (user-facing):

  • Aircraft sliding sideways into and out of turns on taxiways.
  • 6 s of visible backward jitter at segment switches when the renderer crossed from one feed slot to the next.
  • Speed pulsation: aircraft would visibly slow down then speed up within a single segment.

Why this was happening:

The legacy code used per-leg quadratic Bezier curves with from.heading() and to.heading() as tangents. On 1 Hz ground feeds with several metres of position jitter, the Bezier:

  • Over-curved through noise (tangent direction was a noisy heading).
  • Recomputed every leg with no memory of the previous leg's end-state, causing tangent discontinuities (the visible "back-jitter" at segment switches).
  • Parameterised in the native Bezier t, not arc length, so the rendered position moved at non-constant speed.

Fix in commit 24 (486b916):

Replace the per-leg Bezier with a centripetal Catmull-Rom spline through 4 control points: P0 = posPrev (cached at previous segment switch), P1 = from (current leg start, NOT smoothed — see below), P2 = to (current leg end, smoothed against P3 via a 3-tap binomial kernel), P3 = posNext (cached at this segment's switch, doesn't read live posDeque). The spline:

  • Passes exactly through P1 at u=0 so segment switches stay seamless when the loop assigns posList.front() = ppos.
  • Smooths P2 only — turns the interpolating Catmull-Rom into an approximating spline that threads through smoothed endpoints rather than reaching every noisy raw sample.
  • Uses centripetal knot spacing (α=0.5) to prevent the overshoot loops that show up on uniform Catmull-Rom with closely-spaced points.

The spline's tangent is the rendered heading (eliminating sliding through turns). New constants in Constants.h: GND_SPLINE_SMOOTH_WEIGHT, GND_SPLINE_MIN_CHORD_M, GND_SPLINE_MAX_KT, GND_SPLINE_ARC_LUT_N.

Fix in commit 25 (78b58db):

Polish pass after observation:

  • Arc-length LUT. Build a per-segment table mapping arc-length fraction → centripetal-knot u, so the renderer's f (linear in time across the leg) yields constant-speed motion along the curve.
  • Sub-spline-min-chord fallback. At parked/stationary jitter (chord < GND_SPLINE_MIN_CHORD_M = 5 m), skip the spline entirely: linear interp + preserve from.heading() (avoid noise-driven tangent on near-coincident control points).
  • High-speed fallback. At gs > GND_SPLINE_MAX_KT = 40 kt (rollout, takeoff), skip the spline: linear interp + chord-bearing heading (the chord IS the centerline for straight-line runway motion).
  • bHeadFixed honour. When from.f.bHeadFixed (or to.f.bHeadFixed per the later pushback fix), interpolate the slot headings instead of using the spline tangent — preserves the held nose during pushback.

Files touched: Src/CoordCalc.cpp (Catmull-Rom evaluator + arc-length LUT, +262 LOC), Include/CoordCalc.h (+119 LOC), Src/LTAircraft.cpp (CalcAcPos ground branch rewrite), Include/Constants.h (4 new tunables).


7b. Liftoff / climb altitude smoothing

Symptom (user-facing):

  • Aircraft "popping up" by 100–300 ft at the moment of rotation, instead of smoothly transitioning from ground roll to climb.
  • Visible kinks in the climb-out altitude profile due to ADS-B 25-ft altitude quantisation interacting with irregular slot timing.
  • Aircraft pitching up to >20° on rotate (clearly wrong; tailstrike posture).

Fixes:

# Commit Summary
26 f9941c3 Smooth altitude blend on liftoff. Cache liftoffStartAlt_m at the moment FPH_LIFT_OFF fires; for LIFTOFF_BLEND_TIME_S = 10 s afterwards, render altitude as liftoffStartAlt_m + smootherstep * max(0, posAlt - liftoffStartAlt_m). C²-continuous (smootherstep, not smoothstep) so there's no acceleration discontinuity at the blend boundary. Locks bOnGrnd=false during the blend so the floor clamp doesn't reactivate mid-rise.
27 d36ba8c Per-aircraft pastAltSamples_ archive (mirrors deque slots but doesn't pop) + Gaussian-weighted local linear regression on altitude in (t - targetTs) relative time (avoids floating-point cancellation at the absolute-epoch scale). Replaces the earlier smoothstep / Hermite / PCHIP attempts that each had pathological cases (PCHIP froze alt at terrain after liftoff, etc.). Floor-clamp + bOnGrnd lock during liftoff blend window to prevent the regression dip flipping bOnGrnd true mid-climb.
28 25c9e33 Cap rotate pitch at ROTATE_PITCH_MAX_DEG = 10° (was unbounded, leading to tailstrikes). Add FPH_LIFT_OFF to the ground-attitude override exception list so the rotate→liftoff phase transition doesn't have its pitch clobbered mid-rotation.

Files touched: Src/LTAircraft.cpp, Include/LTAircraft.h, Include/Constants.h.


8. Parked traffic — show / persist / right-direction (commits 29, 31, 33)

Symptom (user-facing):
The RealTraffic parked-traffic snapshot was meant to populate the gates at major airports with stationary aircraft so the sim isn't visually empty. Observed: only a small fraction of parked traffic actually rendered; many parked aircraft appeared briefly then vanished; many that did render were facing the default 0° (north) regardless of stand orientation. Also: when a real aircraft pulled into a stand and the next 5-min parked-feed re-fetch fired, the just-departed parked-aircraft ghost would re-seed at the same coordinates, creating a brief duplicate at the stand.

Fixes:

# Commit Summary
29 5336ce3 Three coupled fixes in LTRealTraffic::ProcessParkedAcBuffer: (a) skip dedup when parkPos is empty — without this, every empty-name aircraft (GA, cargo, remote stands) collapsed into one map slot and only one was kept; (b) copy the SPOS_STARTUP flag onto the position so the Synthetic channel's re-feed path can persist the aircraft; (c) feed the apt.dat startup-loc heading (not the default 0°) onto the position so parked aircraft face the right way.
30 908ca73 Gate the ProcessParkedAcBuffer against LTAptAvailable() AT PROCESSING TIME, not just at request time. The ~1–2 s network round-trip between issuing the parked request and the response arriving is long enough for the camera to move and kick off an async apt.dat reload (bAptAvailable flips false). Processing the response then would run startup-loc lookups against a half-rebuilt apt map and mis-place every parked aircraft. Drop the response if layout is not currently available; the next cycle retries.
31 1c9715c Durable gate-handoff eviction. When a real aircraft is detected pulling into a stand that an RT-parked-ghost was occupying, the ghost is evicted. But the next 5-min parked re-fetch would re-introduce the ghost at the same coords. Persistent evictedHexIds set (lives for the plugin session) in SyntheticConnection plus a defence-in-depth distance check in ProcessParkedAcBuffer — a hex marked evicted is never re-seeded as long as the new aircraft remains close to that stand.

Files touched: Src/LTRealTraffic.cpp, Src/LTSynthetic.cpp, Include/LTSynthetic.h.


8b. Livery — v6 operator field

Symptom (user-facing):
American Eagle regionals showing in random liveries because opIcao was never set and the callsign substring trick (section 2) only handles a fraction of cases.

Fix in commit 32 (411ac33):

Use the RealTraffic v6 API's dedicated operator_icao field (RT_DRCT_OpIcao in LTRealTraffic.h) and write it into stat.opIcao. airlineCode() then returns the proper operator code directly instead of falling back to the callsign-substring heuristic. Fixes the wrong-livery problem for the entire AA family and any operator whose callsign-substring differs from their ICAO operator code (Eagle codeshares, JetBlue, etc.).

Files touched: Src/LTRealTraffic.cpp (~10 lines), Include/LTRealTraffic.h.


9. Pushback state machine + safety valve + duplicate-hex prune

Symptom (user-facing):
The state-free pushback heuristic from section 1 commit 8 handled the simple case (tail-first push from a stand) but broke on rotating pushbacks (where the aircraft is being turned 90° as the tug pushes), on aircraft never in the parked snapshot (live-feed-only, e.g. UAL466), and during the entry/exit transitions (aircraft pivoted to face motion direction at gate; aircraft stuck in "pushing back" state while taxiing out at 20+ kt facing the wrong way). Some aircraft never appeared at all (only 1 deque slot — renderer needs ≥2 for interpolation). Some appeared twice (placeholder FF**** hex + real ICAO hex).

Why this was happening:

Several distinct failure modes, identified one by one by capturing logs across many sessions at KDFW:

  • The heuristic had no entry/exit transitions, so once "pushback-like" classification kicked in for a slot, the next slot might or might not match and the body flickered between two heading rules.
  • No way to identify "at a gate" beyond the existing bGroundHolding (which fires anywhere extended-stationary — runway hold-shorts, taxi pauses, etc.), so the pushback rule false-positived on every non-gate stop.
  • bMotion for the state machine used the global GND_STATIONARY_GS_KT = 1.5 kt, but real pushbacks roll at 0.4–1.4 kt, so the machine never engaged at slow-push speeds.
  • Held nose was always derived as track + 180°, which is correct for straight pushes but wrong for rotating pushes (the body actually rotates and the feed reports that rotation).
  • After successful pushback, no exit when the aircraft started taxiing — held nose was locked at the wrong direction and the body rendered backward through the entire taxi-out.
  • Some upstream ingest paths emit aircraft with placeholder FF**** hex IDs; a real-ICAO source later picks up the same callsign with the real hex → two LTFlightData entries, two rendered aircraft.

Fixes (in order applied):

# Commit Summary
33 95a4189 (WIP) First pass: PB_NONE / PB_ACTIVE / PB_PAUSED state machine in CalcHeading with feed-vs-track nose-source selection. Committed as WIP because of multiple still-broken edge cases — left as a checkpoint before the rewrite.
34 cf8c6c4 Revert: drop the posTime -= PosAge adjustment. The earlier change subtracted RT_DRCT_PosAge from the feed timestamp on the theory that TimeStamp was a fix-issue time and PosAge was the report-arrival delay. User clarified field 10 (TimeStamp) is already the position-update epoch; PosAge is info-only. Subtracting double-counted the delay and shifted all positions backward in time. Reverted to using raw TimeStamp.
35 7a072fa Full pipeline rework. New constants: GATE_DETECT_MAX_DIST_M = 30, GATE_HOLD_MIN_ACCEPT_M = 30, PB_MOTION_GS_KT = 0.3, PB_FEED_NOSE_AGREE_DEG = 30°. New LTFlightData members: pbState, pbHeldNose, pbUseFeedNose, bGateParked. Gate detection (three paths): SPOS_STARTUP slot, OR bGroundHolding flip + apt.dat LTAptFindStartupLoc within 30 m. Motion suppression (distance-based): while bGateParked && pbState==PB_NONE, drop any slot < 30 m from latest accepted; on acceptance clear bGroundHolding. State machine: entry on bGateParked && bMotion && motion rearward of held heading; nose source locked at entry (feed if first-motion feedHdg within 30° of parked heading, else track + 180°); refresh on every motion slot; exit when resumed motion is forward of held nose. Renderer: ground-spline branch in LTAircraft::CalcAcPos now honours from.bHeadFixed OR to.bHeadFixed (was from only); covers entry-leg case where from is a stale live-feed parked slot. Diagnostic: GND_DIAG_GATE, GND_DIAG_GATE_HOLD, GND_DIAG_GATE_RELEASE, PUSHBACK_DIAG ENTRY/ACTIVE/PAUSED/FORCE_EXIT. Also includes per-frame youngestTS update on dropped slots so gate-parked aircraft don't outdate.
36 8c9f16b Safety-valve emergency exit. PB_MAX_GS_KT = 10 constant; in CalcHeading, if pbState ∈ {ACTIVE, PAUSED} and slot gs > 10 kt, force exit to PB_NONE regardless of direction. Catches the failure mode where the held-nose source was wrong at entry (TRACK+180 picked because feedHdg disagreed with parkedHdg, but feedHdg was actually right because the aircraft had rotated during GATE_HOLD suppression). With a wrong reference the directional exit never fires; aircraft taxi-out at 20+ kt while renderer still shows backward nose. Real pushbacks never exceed ~5 kt. Also: FF**** placeholder-hex duplicate prune. New static LTFlightData::PrunePlaceholderHexDuplicates() — two-pass scan of mapFd: pass 1 collects callsigns held by non-FF entries; pass 2 invalidates any FF entry whose callsign matches. Hooked from LTRegularUpdates with a 10 s throttle.

Files touched: Include/Constants.h (+8 constants), Include/LTFlightData.h (PB state members + PrunePlaceholderHexDuplicates decl), Src/LTFlightData.cpp (CalcHeading PB block ~300 LOC + AddNewPos gate-detection paths + prune impl), Src/LTAircraft.cpp (spline-branch heading rule), Src/LTMain.cpp (prune hook in LTRegularUpdates), Src/LTApt.cpp.

Diagnostic logging left in: the PUSHBACK_DIAG, GND_DIAG_GATE* tags fire unconditionally and have been essential for diagnosing the iterative fixes. Wrap behind a debug flag before merge if preferred.


Files changed (excluding XPMP2 submodule)

File LOC delta What changed
.gitignore +7 Ignore patterns for local helper/editor artifacts
Include/Constants.h +489 Ground-stability constants, spline tunables, liftoff blend, pushback state-machine constants, gate-detection thresholds — each documented inline
Include/CoordCalc.h +119 Centripetal Catmull-Rom evaluator + arc-length LUT API
Include/LTAircraft.h +101 liftoffStartAlt_m, touchdownTs, pastAltSamples_ archive; LookupAltAtTs(); per-aircraft tug-attach (future use)
Include/LTFlightData.h +129, -? airlineCode() tail-number detection; holding-state members; PB state-machine members; PrunePlaceholderHexDuplicates()
Include/LTRealTraffic.h +57 v6 operator-icao field constant; parked-refresh interval; misc tunables
Include/LTSynthetic.h +34 evictedHexIds API; persistent-eviction marker for gate-handoff durability
Src/CoordCalc.cpp +262 Centripetal Catmull-Rom evaluator, arc-length LUT builder, helper math
Src/DataRefs.cpp +31 SetChannelEnabled revives invalid channel on enable
Src/LTAircraft.cpp +860 Ground-attitude override, heading rate limit, Bezier-skip + track-heading at speed, de-rotation phase exception, Catmull-Rom ground rendering, arc-length spline reparam, liftoff blend, Gaussian alt regression, rotate pitch cap
Src/LTApt.cpp +23 Snap-order fix (cross-check before SnapToTaxiways); LTAptAvailable() gating
Src/LTChannel.cpp +36 TCP keepalive on curl handles; forbid connection reuse
Src/LTFlightData.cpp +1196 Stationary freeze, hysteresis, holding state machine, pushback state machine + safety valve, snap-while-holding guard, apt.dat gate detection, distance-gated motion acceptance, placeholder-hex prune, GND_DIAG_* / PUSHBACK_DIAG diagnostic logging
Src/LTMain.cpp +27 Periodic placeholder-hex prune hook
Src/LTRealTraffic.cpp +284 Trust feed-supplied heading at slow ground speed; placeholder QNH rejection; parked-snapshot fixes (dedup, SPOS_STARTUP copy, startup-loc heading); v6 operator field; gate-handoff defence-in-depth; lower traffic-request wait floor
Src/LTSynthetic.cpp +80 Persistent evictedHexIds; gate-handoff durability

Total: 15 files, +3668 / -60.


Repo

https://github.com/nebukadnezar/LiveTraffic branch master, 36 commits ahead of TwinFan/LiveTraffic@39c8816.

TwinFan and others added 30 commits March 27, 2026 21:40
fix/RT: fixed epoch handling, processes buffered a/c
Adds patterns for local-only scratch files and helper scripts that
should not be tracked.
Introduces a new "Ground Behavior Stability" block of compile-time
constants that will drive a layered fix for the visual "dance" seen
when stationary or slow-taxiing aircraft are rendered from jittery
1 Hz feed positions: heading hysteresis, per-frame heading rate
limiting, stationary detection, holding suppression, hard-set ground
attitude, and pushback detection thresholds.

This change only declares the constants — no behaviour is wired up
yet. Each value carries an inline rationale comment so the chosen
threshold is justified at the point of use.
Forces rendered pitch/roll to GND_PITCH_DEG / GND_ROLL_DEG each frame
whenever bOnGrnd is true, with phase exceptions for FPH_ROTATE,
FPH_FLARE and FPH_TOUCH_DOWN where the nose is dynamically moving
relative to the ground. Also aligns the pitch MovingParam: aircraft
spawned on the ground start at GND_PITCH_DEG, and the touch-down
phase walks pitch to GND_PITCH_DEG (was 0).

Eliminates a class of subtle pitch/roll drift that produced visual
"nose-bobbing" and micro-bank in slow-taxi and parked aircraft.
Adds two ground-only filters to LTFlightData::CalcHeading:

1. Stationary freeze: when the slot is on the ground and the derived
   groundspeed to its neighbour(s) is at or below GND_STATIONARY_GS_KT,
   reuse the predecessor's already-filtered heading instead of computing
   one from jittery position deltas. Requires both adjacent segments
   (or the only available one at deque ends) to be stationary, so a
   single tight cluster during taxi does not lock the heading.

2. Hysteresis: after the heading has been computed, snap it to the
   predecessor's value when the difference is below
   GND_HEADING_HYSTERESIS_DEG. Absorbs the sub-degree noise that
   ADS-B/MLAT feeds routinely contain even on a straight ground roll.

Both filters apply only to on-ground slots; airborne logic is
unchanged. Together with the SIMILAR_POS_DIST short-circuit that
already existed, these stop the heading sequence at gates from
swinging wildly under feed jitter.
Adds a clamp in LTAircraft::CalcAcPos that limits the per-frame change
in ppos.heading() to GND_HEADING_MAX_RATE_DPS * dt when the aircraft
is on the ground. The check uses HeadingDiff for signed shortest-path
semantics across the 360°/0° wrap and re-syncs the heading MovingParam
so subsequent frames continue from the clamped value rather than
catching up in a snap.

Airborne heading propagation is unchanged: in the air, the heading
MovingParam is already smoothed via FLIGHT_TURN_TIME, and an extra
clamp would make en-route course changes visibly lag.

Together with the deque-side stationary freeze and hysteresis, this
ensures that even a deliberate large heading jump at slow taxi (e.g.,
new "to" position with a new heading) walks smoothly to the target
instead of snapping.
Adds a per-aircraft holding state machine in LTFlightData:

- groundHoldingSinceTs tracks the start of the current stationary-
  on-ground streak (feed timestamp, not sim time).
- bGroundHolding flips to true once the streak exceeds
  GND_HOLDING_TIMEOUT_S.

In AddNewPos, once bGroundHolding is set, incoming positions that
fall within GND_HOLDING_TRIVIAL_DIST_M of the latest known position
and whose derived groundspeed is below GND_STATIONARY_GS_KT are
dropped silently — never reaching posToAdd. Movements that exceed
the trivial-distance envelope (potential pushback or taxi start)
always go through and reset the holding state.

This is the data-layer half of the ground "dance" fix: long-parked
aircraft become rock-steady because the noisy feed updates never
reach the renderer. Verbose debug logs are emitted on state
transitions when GetDebugAcPos is enabled for the aircraft.
Adds GND_BEZIER_MIN_HEAD_DIFF (1°) and uses it in place of the airborne
2.5° BEZIER_MIN_HEAD_DIFF when the aircraft is on the ground. Effect:
slow taxi turns of 1–2° per leg are now rendered as a Bezier curve
with tangent-derived heading instead of as a polyline whose heading
walks via the MovingParam fallback. Combined with the rate-limit
clamp and the deque-side stationary freeze, this gives visually
fluid ground turns at all sizes without making airborne course
corrections enter/exit Bezier mode constantly.
Adds a state-free pushback heuristic to LTFlightData::CalcHeading.
When a ground slot derived groundspeed falls between
GND_STATIONARY_GS_KT and PUSHBACK_DETECT_GS_MAX_KT and the track
direction is at least PUSHBACK_DETECT_HEAD_DIFF_DEG away from the
previous heading, the slot heading is locked to the predecessor
heading rather than rotated to match the (backward) track. The
rendered aircraft then visually moves backwards while keeping its
nose pointed at the gate, which is the correct pushback appearance.

Detection is purely sample-based: every slot is classified on its
own geometry, so no extra per-aircraft state is needed and the
behaviour naturally turns off when the tug stops (next slot below
stationary threshold) or when the aircraft starts forward taxi
(track realigns with heading).
- GND_PITCH_DEG: 2.0 -> 0.0. The 2 deg value looked wrong on common
  narrow-bodies (tail-dragger appearance). 0 deg matches LiveTraffic
  pre-existing convention (touch-down already walked pitch to 0).

- Adds temporary unconditional diagnostic logging tagged GND_DIAG_ADD,
  GND_DIAG_CHD, GND_DIAG_HYST, GND_DIAG_FREEZE, GND_DIAG_HOLDIN,
  GND_DIAG_HOLDOUT, GND_DIAG_DROP in LTFlightData::AddNewPos and
  CalcHeading. These fire for every on-ground feed update and heading
  computation regardless of GetDebugAcPos, so per-aircraft pos debug
  does NOT need to be enabled. Search the log for "GND_DIAG_" to
  isolate. To be removed once thresholds are tuned.
Diagnostic logs from AA3107 (parked, RealTraffic feed providing a
stable heading) and AA2675 (slow-taxiing, RealTraffic feed providing
no heading) showed three concrete misfits in the previous thresholds:

- Parked aircraft routinely produced derived gs of 0.75-1.05 kt at the
  gate purely from feed jitter, just above the 0.5 kt stationary
  threshold. Holding therefore exited and small position movements
  propagated to the renderer.

- Slow-taxi heading wobbled 5-7 deg per slot due to track-from-pos-
  delta on RealTraffic positions without a heading field. The 0.5 deg
  hysteresis never engaged.

- A single isolated above-threshold slot ended the holding streak
  immediately, requiring another full 30 s of stationary samples
  before suppression resumed.

Changes:
- GND_STATIONARY_GS_KT       0.5 -> 1.5   (above gate-jitter band)
- GND_HEADING_HYSTERESIS_DEG 0.5 -> 4.0   (catches slow-taxi wobble)
- GND_HEADING_MAX_RATE_DPS  60.0 -> 12.0  (matches TAXI_TURN_TIME)
- GND_HOLDING_TRIVIAL_DIST_M 7.0 -> 15.0  (covers observed envelope)
- new GND_HOLDING_EXIT_CONSEC = 2         (consecutive non-stationary
                                           slots needed to exit)

Holding state machine in AddNewPos updated:
- Stationary slot resets groundNonStationaryCnt to 0.
- Non-stationary slot increments the counter; only exits holding once
  the counter reaches GND_HOLDING_EXIT_CONSEC.
- On exit, groundHoldingSinceTs is reset to the current ts (not 0) so
  that if motion ceases again immediately, the next holding promotion
  is timed from the resumption of stationarity.
When the data feed (e.g. RealTraffic) supplies an explicit heading
value AND the aircraft is on the ground moving below
GND_USE_FEED_HEADING_MAX_KT (10 kn), use the feed value directly
and skip the position-from-track derivation entirely.

The previous behaviour computed heading from atan2 over consecutive
lat/lon pairs, which is wrong in two important slow-ground cases:

1. Pushback. The aircraft is moving tail-first, so the track-over-
   ground points OPPOSITE to the nose. atan2 produced a heading
   ~180 deg off, which is exactly what was observed for AA1146 and
   AA2980 during real pushbacks at gates: their feed heading was
   correctly ~30 deg / ~20 deg but the rendered nose snapped to 270 deg
   matching the backwards motion.

2. Parked aircraft with feed-fabricated jitter. The "motion" vector
   between samples is dominated by noise, so the derived heading
   wanders. Meanwhile the feed-reported heading is usually stable
   and correct.

The new fast-path check runs before all other heading logic in
CalcHeading: if on ground and feed heading is non-NaN and derived
gs < threshold, accept the feed value and return. Above the
threshold the track-derived heading is the better source (it
reflects the curve the aircraft is actually flying) so we fall
through to the normal logic.

A new GND_DIAG_FEEDHDG diagnostic logs each trust event so we can
verify the rule fires for pushback/slow-taxi traffic.
Previously, FDStaticData::airlineCode() returned call.substr(0,3) when
opIcao was empty. For aircraft whose callsign is actually a tail
registration (N552FX, 9K1876, 1I637, etc.) this produced garbage
3-character strings like "N55", "9K1", "1I6" which XPMP2 then tried
to match as an airline. No airline ICAO code begins with a digit, so
the match always failed and XPMP2 fell back to "any livery of this
aircraft class" — producing visibly random liveries.

The fix is a simple alpha test: the first three characters of an ICAO
airline ATC callsign are always letters (AAL, RPA, QFA). If any of
the first three characters is not alphabetic, return an empty string
so XPMP2 does type-only matching instead. From a captured session,
this affects 7 of 36 wrong-livery outcomes (~20%): N-prefix
registrations of small jets, GA piston aircraft, and entries where
the feed delivers a registration in the callsign field.

Companion change (not in this commit, ships separately): the
installed Resources/relOp.txt has been extended with North American
codeshare/merger groups (Republic with American Eagle, SkyWest with
United Express, Endeavor with Delta Connection, US Airways with
American, Continental with United, Northwest with Delta, etc.) so
regional carriers without dedicated CSL liveries fall back to their
parent mainline's livery rather than a random unrelated airline.
That change lives at /Users/balt/X-Plane 12/Resources/plugins/
LiveTraffic/Resources/relOp.txt (the file XPMP2 actually loads at
runtime) and has a mirror in the XPMP2 submodule source at
Lib/XPMP2/Resources/relOp.txt which has not been committed because
it requires a separate commit inside the submodule.
The ground-pitch override in LTAircraft::CalcAcPos was snapping pitch
to GND_PITCH_DEG one frame after touchdown. FPH_TOUCH_DOWN is
documented as a single-cycle event; the next frame is FPH_ROLL_OUT,
which the override did not previously exempt. So even though
CalcFlightModel kicked off a smooth pitch.moveTo(GND_PITCH_DEG) on
touchdown, the override clobbered the MovingParam's walk on the very
next frame — producing a one-frame nose-slam visible to the user.

Fix: add FPH_ROLL_OUT to the phase exception list. During roll-out
the MovingParam now walks pitch smoothly from PITCH_FLARE down to
GND_PITCH_DEG over ~1 second. Once roll-out ends and the aircraft
enters FPH_TAXI, the override applies again and pitch is locked
at GND_PITCH_DEG (which by then matches the MovingParam value).

Roll is still forced flat in all phases including the dynamic-pitch
ones — there is no scenario where a wheeled aircraft banks during
rotation, flare, touchdown, or roll-out.
Landing rollout and takeoff aircraft were rendered with their nose
pointing toward the NEXT slot's reported heading instead of along
their actual direction of motion. When the next slot was on a turn-
off taxiway (heading 326 deg) and the current slot was at end-of-
runway (heading 020 deg), BezierCurve::Define used to.heading() as
the end-tangent, arcing the rendered path across the runway corner.
Result: aircraft visually "slides off the runway" with its nose
~50 deg off the motion vector.

Fix: introduce GND_TRACK_HEADING_MIN_KT (10 kn) as the boundary
between "trust the feed heading" (slow taxi / pushback / parked)
and "trust the motion vector" (rollout / takeoff / fast taxi).

In LTAircraft::CalcAcPos two coordinated changes:

1. At leg setup, when on ground and gs >= GND_TRACK_HEADING_MIN_KT,
   skip BezierCurve::Define and fall through to the linear-path
   branch. The linear path already targets `vec.angle` (the bearing
   from `from` to `to` — the actual motion direction) as the
   heading goal, which is what an aircraft physically does on the
   ground at speed: nose along the track.

2. At the half-way-through-leg checkpoint, skip the retarget to
   `to.heading()` under the same condition. The leg-start setup
   has already pointed heading at the motion vector; retargeting
   would restart the same Bezier-clobber problem. Once gs drops
   below the threshold, the retarget is allowed again so the
   aircraft can converge on the slot's reported orientation for
   turn-offs, gate manoeuvres, etc.

10 kn is deliberately the same threshold as
GND_USE_FEED_HEADING_MAX_KT so the two rules form one consistent
boundary: below 10 kn, the feed heading wins; at or above 10 kn,
the motion vector wins. No middle ground.
A previous commit added a bGndFast guard so that at high ground speed
the renderer skips the Bezier path between slots and walks heading
along the motion vector. The guard used GetSpeed_kt() — the
currently-rendered speed at leg-setup time — which is the speed the
aircraft is COMING INTO the leg, not the speed during the leg.

This produced a regression on takeoff (ADO15 observed): the leg that
spans taxi-into-runway has gs entering at ~5 kt and gs leaving at
~12 kt (47 m in 7.84 s). At leg-setup the rendered speed is ~5 kt,
so bGndFast was false, Bezier was set up across the 89 deg corner
from taxi heading (59 deg) to runway heading (330 deg), and the
rendered nose arced through ~14 deg mid-leg while the aircraft
positioned onto the runway. The subsequent runway-roll legs at
gs > 50 kt did engage track-heading and started walking nose toward
the motion vector — but the MovingParam's walk rate (TAXI_TURN_TIME)
is too slow to converge before liftoff, so the aircraft was still
~45 deg off motion direction through rotation and into climb-out.

Fix: use the leg's average speed vec.speed_kn() = dist/dt instead.
The taxi-to-runway leg averages 11.8 kt — above the 10 kt threshold
— so track-heading engages from the moment the aircraft enters that
transition leg, and the nose walks straight toward the motion vector
(which is along the runway) rather than arcing through the corner.

By the time the aircraft is at runway-roll speeds, the rendered
heading is already aligned with the runway centerline, liftoff
happens nose-aligned, and the climb-out continues nose-aligned.

Same fix applied to the half-way retarget skip so the two conditions
remain consistent.
LTFlightData::SnapToTaxiways inserts taxiway-node waypoints into
posDeque with heading=NaN, expecting CalcHeading to fill it in
later. For a parked aircraft in bGroundHolding an isolated large
position jump from the feed (observed: ACA34 at YSSY, 105 m jump
while the RT app showed the aircraft stationary) gets accepted —
it exceeds the 15 m trivial-drop threshold — and SnapToTaxiways
then synthesises a sequence of intermediate waypoints along the
airport taxi graph between the original parking position and the
jumped position.

CalcHeading on those phantom slots sees hdg_in=NaN, fails the
feed-heading-trust check, fails the stationary check (the synthetic
inter-waypoint derived gs is ~7 kt), and falls through to vector-
from-pos-delta. The resulting headings reflect the taxiway geometry
(302° → 256° → 244° in the observed case), not the aircraft's nose
direction. The renderer walks the aircraft through the phantom path
and the parked aircraft visually dances along a non-existent taxi
route.

Fix: skip the snap loop entirely when bGroundHolding is true. The
glitched position still enters posDeque, but it is now interpolated
as a single linear segment (a one-time visual wobble at worst) and
no waypoints with their rotating headings are inserted. When the
aircraft genuinely begins to taxi, AddNewPos's GND_HOLDING_EXIT_CONSEC
counter clears bGroundHolding and snap-to-taxiway resumes for
subsequent slots.
When RealTraffic's weather endpoint sometimes returns a stripped-down
response — no ICAO, no METAR, QNH=1013 — for a query location whose
real weather it previously delivered correctly. Observed at YSSY: a
valid {"ICAO":"YSSY","QNH":1034,"METAR":...} response followed ~60 s
later by a {"QNH":1013, no ICAO, no METAR} response for the same
query coordinates. The 1013 is RealTraffic's standard-pressure
placeholder, not a real reading.

The previous code in PreProcessWeather accepted these placeholders
unconditionally. rtWx.QNH got overwritten from 1034 back to 1013.25,
and BaroAltToGeoAlt_ft stopped applying the local-pressure correction.
At YSSY (real QNH 1034) the correction is +560 ft per 1500 ft of baro
altitude; without it, landing aircraft render ~500 ft below true MSL
and touch down on the surrounding terrain a mile short of the runway
threshold.

Fix: in PreProcessWeather, treat a response as a placeholder and skip
the SetWeather call when ALL of:
  * no ICAO/airport identifier
  * no METAR text
  * QNH ~ 1013.25 (within 0.5 hPa)
  * we already hold a confirmed non-standard QNH
The last condition lets a genuine 1013-hPa reading still arrive as a
first weather update; we only reject placeholders when they would
overwrite a previously confirmed non-standard value.
A channel that hit too many consecutive network errors gets marked
bValid=false. While invalid, LTChannel::shallRun() returns false and
LTFlightDataAcMaintenance() never restarts its thread — even if the
user toggles the channel's enable checkbox off and back on, because
the toggle only flips bChannel[ch] and leaves bValid alone.

The intended recovery path was the hidden "Restart Stopped Channels"
button in Settings → Basic (which calls LTFlightDataRestartInvalidChs).
Most users won't notice that button and will instead reach for the
channel's own checkbox, which silently does nothing — the thread
never resumes even though the UI now claims the channel is enabled.

Fix: in DataRefs::SetChannelEnabled, when bEnable=true, look up the
LTChannel and call SetValid(true) on any channel that is currently
invalid. This makes the off-then-on toggle behave the way the user
expects: a re-enable revives an invalidated channel and the next
maintenance tick (~2 s later) starts its network thread.

SetValid(true) also resets errCnt, so the channel gets a fresh
CH_MAC_ERR_CNT budget before it could go invalid again. If the
underlying failure is persistent (bad credentials, server still
down, …) the channel will simply re-invalidate on the next attempt
and the user gets immediate visible feedback that something is
wrong with the configuration — preferable to silent inaction.
A tester reported aircraft "driving sideways" during parts of taxi
routes — after leaving the gate, before starting the taxi to take off,
and on parts of the taxi-to-gate after landing. Investigation showed
the symptom is concentrated on TURNS, not straight segments.

Root cause: the heading field that LiveTraffic trusts at slow ground
speed (`GND_USE_FEED_HEADING_MAX_KT = 10 kn`) is sourced from Mode S
Enhanced Surveillance (EHS), which typically only updates every ~10 s
and is unavailable entirely in regions without enhanced interrogation
coverage. Between EHS updates the value is held constant by the
receiver. During a taxi turn at 5–10 kn the aircraft can change
direction by 60° or more inside one 10 s freshness window — and the
held value lags. The renderer was trusting the stale value, so the
nose stayed at the pre-turn direction while the body progressed along
the new direction.

Fix: cross-check the feed heading against the position-derived track
(bearing from the predecessor slot to this one — always "now") inside
the on-ground feed-heading branch of LTFlightData::CalcHeading. Three
bands gated by the new GND_FEED_TRACK_AGREE_DEG = 30° constant:

  |Δ| < 30°            agree   trust feed   (straight taxi or fresh EHS)
  30° ≤ |Δ| ≤ 150°     disagree fall through (feed has lagged in turn)
  |Δ| > 150°           opposite trust feed   (pushback)

Unchanged at fast ground speeds: above 10 kn the track-heading regime
in LTAircraft::CalcAcPos already owns the rendering. Unchanged when
stationary: the track is meaningless under positional jitter so feed
still wins. Unchanged in pushback: the >150° band detects the reversed
track explicitly.

Diagnostic GND_DIAG_FEEDHDG log line is extended with the track angle
and a short reason string so future regressions are easy to attribute
from a Log.txt capture.
Reported symptom: aircraft taking off "jump into the air" at rotation
— the rendered altitude teleports from runway level to a few hundred
feet in a single frame rather than walking up gradually as the
aircraft rotates and lifts off.

Cause: while the aircraft is on the ground, CalcPPos's `if (bOnGrnd)`
branch clamps ppos.alt_m to terrainAlt_m every frame. The very next
frame after CalcFlightModel decides the aircraft is airborne
(`bOnGrnd` flips from true to false, phase becomes FPH_LIFT_OFF),
that clamp stops applying and ppos.alt_m takes on its raw linearly-
interpolated value between the last on-ground slot and the next
airborne slot. Feed samples during takeoff are often 5-15 s apart,
so by the time the renderer reaches the first airborne slot the
interpolation has ppos.alt_m already at 100-500 ft above the runway.
The aircraft visibly teleports up to that altitude in one frame.

Fix: introduce LIFTOFF_BLEND_TIME_S = 1.5 s (Constants.h) and a new
liftoffBlendStartTs member on LTAircraft. CalcFlightModel records the
sim time on the frame `bOnGrnd` transitions true→false. For the next
1.5 s, CalcPPos's airborne branch lerps ppos.alt_m from terrainAlt_m
toward the raw interpolated value using a cubic smoothstep easing
(f(t) = t² (3-2t), C¹-continuous at both ends — no kink at start or
finish of the blend). After 1.5 s the blend is complete and the
aircraft renders at its full interpolated altitude as normal.

1.5 s coincides roughly with the pitch.max() walk driven by
FPH_ROTATE, so the visual rotation and the altitude lift play out
together. The cubic smoothstep avoids the linear-ramp "track up the
slope" look and gives a natural ease-in/ease-out climb-out.
RealTraffic supports 2 s polling in regular operation; the previous
8 s `RT_DRCT_DEFAULT_WAIT` floor was capping the per-aircraft sample
gap at ~10-20 s even when the server's RRL response would otherwise
allow faster polling.

The per-response `rrl` value supplied by RealTraffic is the
authoritative rate limit (see ProcessFetchedData) and is honoured
directly when it is at or above this floor. The floor exists only
to defend against the server returning rrl=0 (or no rrl at all),
in which case we fall back to this conservative interval. Lowering
it to 2 s matches RT's documented minimum.

Downstream effect: per-aircraft position granularity drops from
~10-20 s gaps to ~2-4 s gaps. The Mode-S EHS heading update interval
(~10 s) becomes the dominant lag source rather than the polling
floor, so the EHS-staleness cross-check added in commit d861869
engages less often (feed and track will agree more often) and the
SnapToTaxiways backward-routing symptom investigated in task TwinFan#24
becomes much less severe (less inter-sample distance for the
synthesised path to mis-route across).

No new config knob is exposed: the server-supplied RRL still drives
the actual cadence, this constant is purely the safety floor.
Tester reported aircraft "taxiing backwards" for parts of the route
(observed: DAL973, RPA5716 at YSSY). Root cause: LTAptSnap uses
pos.heading() to decide which direction along a taxi edge to route
the aircraft (TaxiEdge::startByHeading / endByHeading in LTApt.cpp,
called from several sites: 734, 763, 839, 1298, 1348, 1369, 1401).

The feed-supplied heading is sourced from Mode S Enhanced Surveillance
(EHS), which updates only every ~10 s and lags noticeably during
turns. When SnapToTaxiways processes a slot whose feed heading is
stale, the synthesised taxi path between this slot and the next gets
routed in the wrong direction along the edge — the rendered aircraft
visibly moves backward along its taxiway between feed samples.

CalcHeading (since commit d861869) already cross-checks the feed
heading against the position-derived track and falls through to the
track-derived value when the two disagree by 30–150°. But CalcNextPos
ran SnapToTaxiways BEFORE the CalcHeading loop, so snap was reading
the raw feed heading, bypassing the cross-check.

Fix: in SnapToTaxiways, call CalcHeading on each on-ground slot
immediately before LTAptSnap. This applies the full on-ground
heading filter chain — stationary freeze, feed/track cross-check,
pushback detect, hysteresis — so snap sees a heading that reflects
the actual direction of travel rather than the stale EHS value.

The existing post-snap CalcHeading loop in CalcNextPos stays in
place. Its role is to fill in the heading of the intermediate
waypoints that SnapToTaxiways itself synthesises (inserted with
heading=NaN, then derived from the bearing between consecutive
waypoints once the path geometry is known).

The recent RT 2 s polling change (6fb0b1f) reduces but does not
eliminate this symptom — even at 2 s feed samples, if the EHS lag
exceeds 2 s (which can still happen) snap could route wrong. This
fix addresses the underlying cause regardless of polling rate.
Replaces the linear chord interpolation between two consecutive ground
position slots with a centripetal Catmull-Rom spline fit through four
control points: P0 = the most-recently-retired `from`, P1 = current
from, P2 = current to, P3 = the slot after `to` if available. The
rendered position comes from the spline at the current leg parameter,
and the rendered heading comes from the spline's tangent at the same
parameter (atan2 of dC/dt).

Why this is the right shape of fix

The previous chord-based interpolation produced a visible "sideways"
look during taxi turns: the rendered position cut straight across each
turn corner while the rendered heading walked toward the chord
direction via a rate-limited MovingParam. Because the chord is the
average of the entry and exit headings, the nose always lagged the
actual taxiway tangent by half the per-leg heading change. With
larger feed gaps (or at higher taxi speeds) the lag became visually
objectionable — tester reports for TAP227, TAP215 and others.

A smooth curve through four positions captures the actual arc the
aircraft flew, and its tangent at any parameter is by construction
the direction of motion at that point. Position and heading then come
from the same curve and are aligned by construction — no separate
rate-limit, no chord-vs-arc mismatch.

Centripetal (Lee 2009) parameterisation is specifically chosen over
the uniform or chordal variants because it is the only Catmull-Rom
form that avoids cusps and self-intersecting loops at sharp corners,
which we routinely encounter on runway turn-offs (45-90° in 30-60 m).

Math implementation

New helper `CatmullRomEvalCentripetal` in CoordCalc.{h,cpp}. Operates
on a local meters frame centred at P1 (using existing Lat2Dist /
Lon2Dist helpers) so the math is Euclidean and avoids cos(lat)
accumulation across control points. Knot intervals use sqrt(chord
distance) per the centripetal variant; barycentric (Lee) evaluation
form for both the position and a finite-difference tangent.
Heavily commented inline.

LTAircraft changes

  * New `posPrev` member (positionTy, default NaN) stores the slot
    being popped during the position switch in CalcPPos so it remains
    available as P0 for the next leg's spline.
  * In CalcPPos, the non-Bezier branch now picks between spline and
    linear:
      - Both endpoints on ground → spline (lat/lon + heading from
        spline; alt + pitch stay linear because they're not part of
        the horizontal-plane curve and get clamped/forced later by
        the bOnGrnd block anyway).
      - Any other case → linear, as before. This includes cruise
        legs, the touchdown / liftoff boundary, and air segments.
  * Existing Bezier path (turn.GetPos) still wins when active — that
    machinery is unchanged and continues to handle cut-corner setups
    elsewhere in the flight model. On the ground, Bezier rarely
    fires now because the per-frame heading rate limit and the new
    spline both produce smooth results without it.

What this replaces / interacts with

  * Per-frame heading rate limit (a1e0423): still in place, still
    runs after the spline assigns ppos.heading(). It's a no-op
    against spline output because the spline tangent already varies
    continuously with the parameter; the clamp only ever fires for
    the linear-fallback case now.
  * Half-way retarget to to.heading(): unchanged — still skipped at
    high ground speed; allowed at low ground speed where the slot
    heading from the feed/track cross-check (d861869) is a useful
    secondary target. The spline-set heading dominates in practice.
  * SnapToTaxiways waypoint insertion: complementary. Snap inserts
    extra control points along the taxi graph; the spline then
    naturally curves through them, which is exactly the smooth
    taxiway-centreline shape we want.
After the RT polling floor dropped to 2 s (commit 6fb0b1f) the log
started showing curl error 55 (CURLE_SEND_ERROR) with the message
"Connection died, tried 5 times before giving up", at roughly 27 s
intervals. Each occurrence cost five send attempts plus a channel
error counter tick.

Symptom is the classic stale-HTTP-keep-alive case: the previous
request's TCP connection is silently dropped by an intermediate
NAT/firewall (or by the server) while we are idle between polls,
and libcurl finds out only when its next `send()` on the kept-alive
socket fails. At an 8 s poll cadence the idle interval was short
enough that the connection rarely went stale; at 2 s it should be
even less of a problem in principle, but the symptom suggests that
some hop in the path declares the socket idle on a sub-30 s timer
that the previous cadence happened to dodge by aligning with the
server's own keepalive cadence.

Fix: enable TCP keepalive at the OS level via curl options in
LTOnlineChannel::InitCurl:
  * CURLOPT_TCP_KEEPALIVE = 1  enable SO_KEEPALIVE
  * CURLOPT_TCP_KEEPIDLE  = 20 first probe after 20 s of idle
  * CURLOPT_TCP_KEEPINTVL = 10 subsequent probes every 10 s

With the OS sending a TCP heartbeat on each idle socket, NATs and
servers see the connection as active and don't drop it. The kept-
alive HTTP connection then stays usable for the next 2 s poll,
eliminating the CURLE_SEND_ERROR cluster.

Applies to all online channels (OpenSky / ADSBHub / RealTraffic /
etc.) since the option is set in the base-class InitCurl. The
non-RT channels poll less frequently so they were unlikely to hit
the symptom, but keepalive is a strict improvement for any
persistent HTTPS connection that may sit idle for tens of seconds.
TCP keepalive (cee1c1c) did not stop the CURLE_SEND_ERROR / "Connection
died, tried 5 times" pattern recurring at ~30s intervals. Whatever is
recycling the kept-alive socket does so actively (upstream load
balancer, NAT idle-drop, or curl's own connection-cache age limit), and
keepalive probes cannot help against an active RST or FIN.

Set CURLOPT_FORBID_REUSE so every request opens a fresh TCP+TLS
connection and closes it after the response: no kept-alive socket ever
survives long enough to go stale. Cost is one TLS handshake per request
(~100-300ms with TLS 1.3 resumption), negligible at the 2s RT polling
cadence. The keepalive options are kept as belt-and-braces.
Builds on the centripetal Catmull-Rom ground spline (486b916) with a
set of fixes for jitter, speed pulsation and segment-boundary jumps
surfaced during testing:

- Arc-length reparameterisation: a per-segment 16-sample LUT
  (CatmullRomArcLut) maps the time-linear parameter onto constant
  arc-length-per-time, so the rendered position no longer speeds up
  and slows down within a leg.

- Cache P3 (posNext) at segment switch instead of reading posList[2]
  live each frame. A mid-segment feed update no longer shifts the
  spline geometry underfoot and snaps the rendered position.

- Control-point smoothing: pre-smooth the look-ahead endpoint P2 with
  a [1,2,1]/4 binomial kernel so the spline approximates rather than
  interpolates noisy feed samples. P1 is left raw so the curve still
  returns 'from' exactly at u=0, keeping segment joins seamless with
  the posList.front()=ppos continuity mechanism.

- High-speed fallback: above 40kt (takeoff roll, landing rollout) skip
  the spline for plain linear interpolation. The aircraft tracks a
  straight line there; the spline only amplifies glitchy feed data and
  interacts awkwardly with the acceleration-profile parameter.

- Near-stationary fallback: below a 5m chord, skip the spline so a
  noise-dominated tangent cannot wobble the heading of a parked
  aircraft.

- Tolerate sub-1s backward sim-time jumps in NextCycle instead of
  tearing down and rebuilding the whole aircraft fleet on every frame
  stutter or brief pause/unpause.
The RealTraffic parked-aircraft feed was almost entirely missing in the
sim. Several compounding bugs, fixed together:

- Dedup collapsed empty parking-position names. ProcessParkedAcBuffer
  dedups by RT_PARK_ParkPosName (Jeppesen stand), keeping the
  newest-timestamp entry per stand. But that field is frequently empty
  (GA, cargo, remote stands), so every empty-name aircraft collided into
  one map slot and all but one were dropped. Empty names now key on the
  unique hex id instead, so each is kept. The log line also reports the
  post-dedup count.

- SPOS_STARTUP was set only on a successful gate match. Without that
  flag an aircraft never enters FPH_PARKED, so the Synthetic channel
  never adopts it to keep it alive and it ages out minutes after
  creation; and the ground-holding trivial-drop is not exempted for it.
  The flag (and bHeadFixed) is now set unconditionally — RT's feed is
  authoritative that the aircraft is parked, gate match or not.

- Ground-holding trivial-drop ate the bootstrap seeds. AddNewPos drops
  stationary near-duplicate positions as jitter once holding is active.
  The parked feed's four identical seed positions (and the Synthetic
  keep-alive re-feeds) are exactly that shape, so they were dropped and
  the aircraft was left with too few positions to render. SPOS_STARTUP
  positions are now exempt from the drop: they are intentional
  placements, not feed jitter, and raw live-feed jitter is not
  SPOS_STARTUP at AddNewPos time (snapping runs later).

- Every parked aircraft faced north. The gate match was tested via
  startupPos.isNormal(), but LTAptFindStartupLoc returns the matched
  apt.dat location with a NaN timestamp and isNormal() rejects a NaN ts
  — so the match was discarded every time and the heading override
  never ran. The match is now tested on the returned distance (NaN only
  when nothing matched). The startup-location search radius is also
  widened to the 3x default since RT's coordinates are not metre-precise,
  and dyn.heading is synced after the gate-match correction.

- Gate handoff was indiscriminate. SyntheticConnection removed any
  stored parked aircraft within 10 m of any on-ground aircraft, so
  traffic merely taxiing past a gate wrongly evicted parked aircraft.
  The trigger is now ac.GetFlightPhase() == FPH_PARKED: only an aircraft
  that has itself come to rest on a stand evicts a stale ghost there.

- Parked traffic is now re-fetched periodically. The request was a
  one-shot fired only on airport-data load, freezing the parked picture
  for the whole visit. RT_PARKED_REFRESH_INTVL_S (300 s) re-arms it so
  new arrivals appear and departures are reconciled.
The parked-traffic request was gated on LTAptAvailable() at issue time
(SetRequType), but everything downstream was not — ProcessParkedAcBuffer
could run ~1-2s later against a half-built gmapApt after the camera
moved and a new async apt.dat load started. LTAptSnap and
LTAptFindStartupLoc were not gated at any point, so live arrivals could
also snap against a reloading layout and pick up wrong startup
locations / wrong headings that then stuck (KLM604 "facing ass to the
terminal" was an example).

Three gates added:

- ProcessFetchedData re-checks LTAptAvailable() when the parked
  response arrives, before calling ProcessParkedAcBuffer. If the layout
  went not-ready in the request->response window, the response is
  dropped, bDoParkedTraffic stays armed, and tLastParkedRefresh is left
  unchanged so the next cycle retries promptly.

- LTAptSnap bails immediately if !LTAptAvailable(). AsyncReadApt first
  PurgeApt's, then re-adds airports one by one, releasing the lock
  between each, so a snap against gmapApt mid-load lands in the wrong
  taxiway / startup location and the bad heading sticks.

- LTAptFindStartupLoc bails similarly. ProcessParkedAcBuffer is now
  also gated upstream, but this also protects the Synthetic channel's
  call site and any future lookup that races a mid-flight reload.

Direct answer to the dev's "hope it doesn't backfire" concern after the
parked-traffic acceleration work.
…ete)

Replaces the state-free per-slot pushback heuristic with a real state
machine, restructured to run BEFORE the feed-heading block so it is
authoritative once it engages. Adds the renderer bridge needed for the
held heading to actually reach the screen, and chooses the heading
source per slot based on what the feed is actually reporting.

THIS IS NOT YET A WORKING FIX. Pushback detection still misses cases
where the feed heading is course-over-ground and the parked->push
geometry is borderline (DAL73), false-triggers / fights for cases where
the snapping pipeline interleaves SPOS_STARTUP positions into the
deque (AMX026), and does not fully reproduce the "facing ass to the
terminal" path (KLM604 — separate snapping issue). Work continues; this
commit captures the state to keep the Windows-build tester in sync.

What changed:

- LTFlightData::bPushback state (Include/LTFlightData.h). Plus
  headingStable / headingStartup members consumed by the PUSHBACK_DIAG
  diagnostic and likely by the next round of detection redesign.

- PUSHBACK_DETECT_GS_MAX_KT raised 3->12 (entry-only ceiling now; the
  135deg direction gate is the real discriminator). PUSHBACK_EXIT_FWD_DIFF_DEG
  added (45deg, exit-when-moving-forward-relative-to-maintained-nose).
  PUSHBACK_FEED_IS_NOSE_DEG added (90deg, switches the held-heading
  source between feedHdg (true-nose feeds) and track+180 (course-over-
  ground feeds)). PUSHBACK_MIDPOINT_DIST_M removed (was dead code).

- CalcHeading: pushback state machine moved BEFORE the feed-heading
  cross-check so it is authoritative when engaged. ENTRY on the
  geometric signature, HOLD chooses feedHdg-vs-track+180 per slot
  based on |feedHdg-track|, EXIT on direction reversal (no timer).
  Sub-threshold-motion slots carry the predecessor heading.
  Airborne forces bPushback=false defensively.

- LTAircraft spline branch: when from.f.bHeadFixed is set, the
  rendered heading is interpolated from the slot headings instead of
  overridden by the spline tangent. Without this, the held pushback
  heading was discarded at render time because the tangent points
  along the (backward) motion.

- TEMPORARY: PUSHBACK_DIAG logging in CalcHeading dumps feedHdg,
  track, the captured stable / startup reference headings and the
  angular gaps to each. To be removed once the detection is sorted.
RealTraffic v6 added a hex-keyed ICAO operator/airline flag code to
both the UDP RTTFC broadcast (field 43, RT App v11.1.452+) and the
HTTP Direct API JSON array (field 48). It is sourced from RT's
OperatorFlagCode column of the OpenSky-derived BaseStation aircraft
database, keyed on the transponder hex ID rather than the operating
callsign — so it remains correct under wet-lease and codeshare
operations where the callsign-derived airline would be wrong.

LiveTraffic now populates FDStaticData::opIcao from this field in both
processing paths, bounds-checked so older RT App builds / sparser
responses still parse. When present (≈55% of records in test, doc says
~70% — the rest are GA/private airframes RT does not have in its DB),
it wins over the callsign-substring guess in airlineCode() and is used
authoritatively for XPMP2 livery matching. When absent it falls through
to the existing fallback path with no behaviour change.

UDP enum (RT_RTTFC_FIELDS_TY): index 41 renamed from
AUGMENTATION_STATUS to BARO_ALT_UNCORRECTED (v6 changed the slot's
semantics; the old name was not referenced anywhere). Added
AUTHENTICATION (=42, shares slot with the preserved MIN_TFC_FIELDS
strict-parse gate) and OPERATOR (43). The min-fields gate stays at 42
so older RT App builds that do not send fields 42-43 still parse.

HTTP Direct enum (RT_DRCT_FIELDS_TY): inserted RT_DRCT_Operator at
index 48 ahead of the NUM_FIELDS sentinel. The existing strict gate at
ProcessFetchedData picks up the new sentinel value cleanly because v6
Direct API responses always carry 49 fields per the doc.

Verified in-session in both RT App (UDP) and Direct API (HTTP) modes:
840 RealTraffic adds, 461 with a populated operator code, 379 empty
and falling through to the callsign-substring fallback as designed.
Two related guards to stop a previously evicted parked ghost from
coming back, plus a debug channel for diagnosing position-feed issues:

- SyntheticConnection: persist hex ids that gate-handoff has evicted
  in a session-scoped set (evictedHexIds). The Synthetic re-adoption
  path now skips marked ids, blocking the race where a FetchAllData
  pass running during the async SetInvalid teardown recreated the
  ghost ~40s after eviction.
- ProcessParkedAcBuffer: refuses to re-seed a marked id, and as
  defence-in-depth refuses to re-seed any hex that is already
  live-tracked and has either left the ground or moved farther than
  GATE_REFEED_MAX_DIST_M (50 m) from the gate position RT is
  reporting. Blocks the TFL3NA-class symptom where the 5-minute
  parked re-fetch silently appended an SPOS_STARTUP gate-position
  seed to an airborne aircraft deque, causing the render clock to
  later walk into it and teleport the aircraft back to the gate.

Adds GATE_REFEED_MAX_DIST_M constant in Constants.h with verbose
rationale on why 50 m is the right footprint for still-at-the-stand.

Also lands FEED_DIAG instrumentation across both RT paths
(ProcessTrafficBuffer / HTTP-Direct and ProcessRTTFC / UDP RTTFC):
per-aircraft monotonicity log marking NEW / OK / REPEAT / BACKWARDS
for every accepted feed timestamp, plus the source msg_type and
seen age. Drives the recent debug analyses (TFL3NA, KLM911, KLM99F,
KLM48/KLM874 gate handoff) and remains intentionally tagged
TEMPORARY in the source so it is easy to find and remove once the
feed-jitter work settles.
Several intertwined changes that together fix the visible takeoff
glitches users reported (discrete 100ft+ altitude jumps mid-climb,
aircraft slamming back to runway after partial liftoff, premature
rotation animation without altitude change).

Smoothing the rendered climb-out altitude:
- LookupAltAtTs in LTAircraft now fits altitude via a Gaussian-
  weighted local linear regression (sigma 5s) instead of returning
  the raw per-leg linear interpolation. ADS-B reports altitude in
  25ft quantization steps and slots arrive at irregular 1-5s cadence,
  so the per-leg slope swings wildly even during a steady climb; an
  interpolating spline (PCHIP etc.) faithfully reproduces that as
  visible kinks every few seconds. The regression renders along the
  fitted trend instead.
- Numerically stable implementation: works in (t - targetTs) so the
  variance and covariance sums stay in the +-30s range. The textbook
  Sum(w*t*t) - sumW*tMean*tMean form would subtract two values of
  order 3e19 (timestamps are around 1.78e9) to get a result of
  order 1e2, losing the signal to floating-point cancellation.
- pastAltSamples_ archive: fd.posDeque only keeps one past slot at
  any moment (CalcNextPos pops aggressively), and a regression with
  near-unit weight on that one sample shifts discretely when it
  pops. The archive mirrors every slot we ever observe and does NOT
  pop, so the Gaussian weight on each sample fades out continuously
  over many frames as targetTs moves past it. No more step changes
  at the deque-pop boundary.

Liftoff-blend ground lock + floor clamp in CalcFlightModel /
CalcPPos:
- During the 10-second liftoff blend, bOnGrnd is locked to false
  even if LookupAltAtTs momentarily dips below MDL_CLOSE_TO_GND
  above terrain. Without this guard, transient regression dips
  (smoothing-window rebalancing as past-ground samples weight-out
  against future-airborne samples) would re-flip bOnGrnd to true,
  clamp ppos.alt to terrain, regress the phase from FPH_LIFT_OFF
  to FPH_TO_ROLL, and the next frame's recovery would reset
  liftoffBlendStartTs and restart the blend from scratch. Visibly:
  aircraft climbs to ~Nft, slams to the runway, continues T/O
  roll, rotates again, climbs again.
- The blend formula clamps (LookupAltAtTs - liftoffStartAlt) to be
  non-negative so the rendered altitude can never go below terrain
  during the blend even if the smoothed input dips below the frozen
  start.
- ALT_DIAG log line on every bOnGrnd transition with the relevant
  state (PHeight, blend status, sinceLO, phase) so future debugging
  can verify there is one clean transition per real liftoff.

RealTraffic ingest:
- posTime = TimeStamp - PosAge on both HTTP-Direct and UDP RTTFC
  paths. The RT TimeStamp is when the record was generated server-
  side and PosAge is the ingest age of the underlying measurement.
  Different sources (ADS-B vs MLAT vs satellite multilateration)
  have very different ingest delays, so two consecutive records can
  have the same TimeStamp but represent measurements taken seconds
  apart. Normalising to actual measurement time aligns the deque
  with what-the-aircraft-did-when rather than when-RT-ingested-it.
- FEED_DIAG now also includes alt, gnd flag, and reported vsi so
  debug analyses can see what the feed is actually saying.

Takeoff/landing animation polish (carried over from earlier in the
session, not yet shipped):
- LIFTOFF_BLEND_TIME_S bumped from 1.5s to 10s for a gradual
  climb-away from the runway. With the spline / regression
  smoothing on top, the blend curve is C2-continuous at both
  endpoints and renders as a single smooth arc.
- TOUCHDOWN_HOLD_PITCH_S = 5s: after FPH_TOUCH_DOWN the pitch.moveTo
  to GND_PITCH_DEG is deferred for 5s, modelling the aerobrake
  during which real airliners keep the nose up after the mains
  touch. Previously the nose was on the ground within ~3s of
  touchdown.
…e exception

Two changes addressing reported visual pitch glitches on take-off.

ROTATE_PITCH_MAX_DEG = 10 deg
- FPH_ROTATE entry used to call pitch.max(), which walks the pitch
  MovingParam toward pMdl->PITCH_MAX (15 deg by default). 15 deg is
  past the tail-strike geometry of most narrow-bodies (B738 ~ 11 deg,
  A320 ~ 13.5 deg), and users were seeing rendered aircraft drag
  their tails during rotation.
- New constant ROTATE_PITCH_MAX_DEG = 10 caps the rotation target.
  pitch.moveTo(ROTATE_PITCH_MAX_DEG) replaces pitch.max() in the
  ENTERED(FPH_ROTATE) block.
- After FPH_LIFT_OFF, the in-air pitch logic in
  LTFlightData::CalcNextPos (around line 1700) takes over and walks
  pitch toward a VSI-derived target, still clamped to
  pMdl->PITCH_MAX. So steep climbs still reach the full 15 deg, just
  not while the gear is on the runway.

FPH_LIFT_OFF added to the ground-attitude pitch override exception
- CalcAcPos used to force pitch back to GND_PITCH_DEG whenever
  bOnGrnd was true and phase was not in
  {FPH_ROTATE, FPH_FLARE, FPH_TOUCH_DOWN, FPH_ROLL_OUT}. Phase can
  advance ROTATE -> LIFT_OFF via the V_Climbing branch in
  CalcFlightModel as soon as VSI crosses VSI_STABLE (100 fpm) -
  which happens BEFORE bOnGrnd actually flips false on takeoffs
  with smoothed altitude (the regression has not yet crossed
  MDL_CLOSE_TO_GND above terrain).
- With FPH_LIFT_OFF missing from the exception list, the override
  forcibly reset ppos.pitch() to 2 deg for the frames between
  phase-becomes-LIFT_OFF and bOnGrnd-actually-flips-false.
  Visible as: nose pitches up, briefly flips level on the runway,
  then pitches up again once airborne. Reported on AAL2449.
- Adding FPH_LIFT_OFF to the exception list keeps the MovingParam
  in control of pitch during the entire rotate-to-airborne window.
Reverts the `posTime -= jag_n(pJAc, RT_DRCT_PosAge)` adjustment added
in commit d36ba8c on both ingest paths (HTTP-Direct ProcessTrafficBuffer
and UDP RTTFC ProcessRTTFC).

The original adjustment was based on an incorrect interpretation of
the two time-related fields in the RT data: it assumed
RT_DRCT_TimeStamp (idx 10) was a server-side record-creation
timestamp and RT_DRCT_PosAge (idx 39) was the ingest-pipeline
latency back to the actual measurement time, so subtracting one
from the other would normalise positions to "what the aircraft
did when". That is not what these fields mean in RT v6: idx 10 is
already the measurement time and is the only field that should
be used for chronological comparisons. The subtraction was
actively moving every position backwards by an amount that does
not represent any real lag, breaking deque ordering — slots from
sources with higher PosAge values were being placed too far in
the past, manifesting as cascades of BACKWARDS rejections and
deque-position artefacts in the FEED_DIAG log.

posTime on both paths is now back to the raw
RT_DRCT_TimeStamp / RT_RTTFC_TIMESTAMP value, exactly as it was
before d36ba8c. FEED_DIAG still prints PosAge as `seen=` for
informational purposes but the `ts=` field and the OK / BACKWARDS
/ REPEAT flags now reflect raw-timestamp deltas, which is the
correct chronology to use.
Builds the full pipeline for correctly identifying parked aircraft at gates
and rendering their pushback maneuver, replacing the previous heading-flip-
based heuristic with a state machine driven by gate detection, distance-
gated motion acceptance, and locked nose-source choice.

Constants.h
- GATE_DETECT_MAX_DIST_M (30 m): max distance from apt.dat startup-loc for
  a held position to count as "at a gate". Tight on purpose — keeps runway
  hold-shorts and taxi pauses from being misclassified.
- GATE_HOLD_MIN_ACCEPT_M (30 m): minimum displacement before the first
  motion slot is accepted on a gate-parked aircraft. Distance-based, NOT
  counter-based, because real pushbacks roll at 0.4-1.4 kt — below the
  stationary threshold — and a non-stationary-slot counter never advances
  for a real slow push.
- PB_MOTION_GS_KT (0.3): pushback state-machine bMotion threshold, distinct
  from the global GND_STATIONARY_GS_KT (1.5). The global threshold is too
  high for real slow pushbacks; the state machine needs its own.
- PB_FEED_NOSE_AGREE_DEG (30): max delta between parked heading and first-
  motion feed heading for the feed to be treated as true-nose source.

LTFlightData (CalcHeading PB block)
- bMotion loosened: drop the per-slot dist>=SIMILAR_POS_DIST requirement,
  use PB_MOTION_GS_KT. Continuous slow motion stays in PB_ACTIVE instead
  of bouncing into PB_PAUSED on every sub-7m slot.
- Nose source locked at entry: FEED if first-motion feedHdg is within
  PB_FEED_NOSE_AGREE_DEG of the prior parked heading (true-nose case),
  otherwise TRACK+180. Held for the entire push — no per-slot flipping.
- Statistical 4-slot track filter (equal-weight centroid, reject 2 largest
  outliers, vector between 2 inliers). Replaces the noisy 2-point chord
  during slow ground motion.
- Predecessor fallback: when posDeque is drained during a long stationary
  period and the GATE_RELEASE slot arrives alone, fall back to
  pAc->GetToPos() as virtual predecessor so the state machine can engage
  on the first accepted slot.
- bHeadFixed pinning: stamp the predecessor slot (guarded against
  posDeque.cbegin) so the renderer interpolates across the entry leg.

LTFlightData (AddNewPos)
- Third gate-detection path: on bGroundHolding flip, one-shot
  LTAptFindStartupLoc lookup; if within GATE_DETECT_MAX_DIST_M, set
  bGateParked. Distance signal complements the SPOS_STARTUP path and
  catches live-tracked aircraft that were never in RT's parked snapshot.
  The lookup uses outDist (not isNormal()) as the success signal —
  startup-loc positionTy has ts=NaN/alt=NaN and isNormal() rejects it.
- Distance-based gate-hold motion suppression: drop any slot with
  displacement < GATE_HOLD_MIN_ACCEPT_M from the latest accepted position
  while bGateParked && pbState==PB_NONE. On acceptance, also clear
  bGroundHolding so subsequent in-push slots flow through.
- youngestTS advancement on drops: keep the freshness timestamp moving
  even when slots are filtered out. Without this, an aircraft sitting at
  a gate receiving valid feed updates (all trivial-dropped) outdates
  after GetAcOutdatedIntvl() and is removed despite the feed being alive.

LTAircraft (ground spline branch)
- Honour to.f.bHeadFixed as well as from.f.bHeadFixed when deciding
  whether to interpolate heading or use the spline tangent. The previous
  from-only check failed at the PB_NONE→PB_ACTIVE transition: from
  (posList[0]) was a live-feed parked slot with bHeadFixed=false, so the
  renderer used the motion-tangent (≈ direction of travel) and visually
  pivoted the aircraft to face the push direction at the gate. Honouring
  to.f.bHeadFixed covers the transition from the destination side, since
  pinning bHeadFixed retroactively on the predecessor isn't reachable when
  posDeque has been drained.

LTMain
- Cosmetic (whitespace) from earlier revert of rewind-detection feature.

Diagnostic logging
- GND_DIAG_GATE (HIT / NEAR / NOAPT / APT_UNAVAIL) at HOLDIN moment.
- GND_DIAG_GATE_HOLD on suppressed motion at gate (with dist/threshold).
- GND_DIAG_GATE_RELEASE on acceptance.
- PUSHBACK_DIAG ENTRY with chosen nose source and entry values.
- Per-slot PUSHBACK_DIAG (state, gs, track, heldNose, assigned).
PB safety-valve emergency exit
- New constant PB_MAX_GS_KT = 10.0 in Constants.h.
- In CalcHeading, when pbState ∈ {ACTIVE, PAUSED} and the slot gs
  exceeds PB_MAX_GS_KT, force exit to PB_NONE: clear pbHeldNose,
  pbUseFeedNose, bGateParked and log PUSHBACK_DIAG ... FORCE_EXIT.
- Catches the failure mode where the held-nose source was chosen
  incorrectly at entry (TRACK+180 picked because feedHdg disagreed
  with parkedHdg, but the feed was actually right because the
  aircraft had already rotated during GATE_HOLD suppression). With a
  wrong reference, the directional PB_PAUSED→PB_NONE exit test never
  fires; the aircraft taxis out at 20+ kt while the renderer still
  shows the held nose, producing the visible tail-first / ass-
  forward symptom. 10 kt is a hard upper bound on physical pushback
  speed; anything above is definitively taxi.

`FF****` placeholder-hex duplicate prune
- New static LTFlightData::PrunePlaceholderHexDuplicates() (declared
  in LTFlightData.h, implemented in LTFlightData.cpp).
- Two-pass scan of mapFd: pass 1 collects callsigns from non-FF
  entries; pass 2 invalidates any FF entry whose callsign matches.
  Two passes are necessary because the real-hex entry may appear
  after its FF counterpart in iteration order. SetInvalid(true)
  drops the rendered aircraft so the visual duplicate disappears
  immediately and the standard cleanup pipeline erases the entry.
  Uses GetUnsafeStat() for the callsign reads — racy but acceptable
  given the periodic nature of the scan (stale reads re-evaluated
  next pass).
- Hooked from LTRegularUpdates with a 10 s throttle; the duplicate
  condition develops over many seconds and the per-flight-loop
  cadence would waste mapFd-mutex time.
- Logs PRUNE_FF for visibility on each invalidation.
GitHub Actions exposes repository secrets via env-var assignments
unconditionally (`.github/workflows/build.yml:14-17`). When a fork PR
doesn't have the FSC secrets configured, GA passes the env vars as the
empty string — NOT as undefined. The previous `if(DEFINED ENV{...})`
checks treat empty-string-defined as "set", so the cmake fall-through
to the default `"3"` / `"INOP"` stub values never fires. The cmake
substitution at line ~320 then emits bare `-DFSC_PROD_CLIENT_ID=` (no
value); the preprocessor expands `FSC_PROD_CLIENT_ID` to nothing in
`Src/LTFSCharter.cpp`; the Linux CI build fails with "expected
primary-expression before ','" at line 48.

Every fork PR hits this — trivially reproducible with no FSC secrets
in the fork settings, succeeds in the upstream's own CI because the
secrets ARE configured there, succeeds locally because the env vars
aren't set at all (DEFINED is false → default fallback fires).

Fix: add `AND NOT "$ENV{...}" STREQUAL ""` to each of the four FSC
DEFINED-checks. Empty env vars now fall through to the defaults in
the same code path as the not-defined case. Verified locally that
both `unset` and `=""` simulate-as-CI cases produce the expected
"FSCharter Production = Client ID: 3, Client Secret: INOP" output.
@TwinFan TwinFan self-assigned this May 17, 2026
@TwinFan TwinFan changed the base branch from master to PR312 May 17, 2026 14:20
@TwinFan TwinFan merged commit e844b33 into TwinFan:PR312 May 17, 2026
3 checks passed
TwinFan added a commit to TwinFan/XPMP2 that referenced this pull request May 18, 2026
TwinFan added a commit that referenced this pull request May 21, 2026
PR312 - Plane Movement Enhancements
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.

2 participants