Skip to content

PR312 - Plane Movement Enhancements#313

Merged
TwinFan merged 47 commits into
Nextfrom
PR312
May 21, 2026
Merged

PR312 - Plane Movement Enhancements#313
TwinFan merged 47 commits into
Nextfrom
PR312

Conversation

@TwinFan
Copy link
Copy Markdown
Owner

@TwinFan TwinFan commented May 21, 2026

See original #312

TwinFan and others added 30 commits March 27, 2026 21:40
v4.3.5 XP 12.4.1 Update
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 #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.
nebukadnezar and others added 17 commits May 16, 2026 11:50
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.
Ground rendering / pushback / livery / weather / channels — 36-commit fix set
and reduced PITCH_FLARE for many models. Makes rotate pitch depending on aircraft size.
to make the duration of pitch hold after touch-down depending on aircraft size.
and tied all DIAG logging to it
@TwinFan TwinFan merged commit 2588e23 into Next May 21, 2026
3 checks passed
@TwinFan TwinFan deleted the PR312 branch May 21, 2026 19:58
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