I dug into this and I think I have the root cause,
plus a fix that I verified against the shipped build.
I believe that the root cause is the encounter-candidate pre-filter added in revision 4993
Revision 4993
- Replaced the flat SOI-size cutoff for automatic encounter detection with an orbital-geometry check; siblings are now excluded only when their radius band can't overlap ours or when an approximate MOID (evaluated at the two mutual orbital nodes) puts them well beyond their own sphere of influence,
so small moons like Phobos and Deimos are no longer silently skipped just for having a small SOI.
- Fixed a radius-band gap where bodies orbiting entirely inside our own periapsis weren't excluded from candidacy either.
- Removed the now-unused FlightPlan.FAST_SOI_PATCH_SIZE constant.
JPLRepoRocketwerkz • 23.07.2026 06:43
That change replaced the flat SOI-size cutoff with an approximate MOID "evaluated at the two mutual orbital nodes" (
PatchedConic.OrbitsCanApproach). The problem is that it takes both radii
at the same direction from the focus, so the value it computes is the distance between two concrete points, i.e. an
upper bound on the true minimum distance between the two orbits.
Rejecting a candidate because an upper bound exceeds a threshold should not work a big gap at the nodes says nothing about how close the orbits get anywhere else.
Sampling the nodes is only near-tight while the two planes are separated by more than the margin away from the nodes, which needs
sin(relative inclination) * bandInnerRadius > 4 * SOI.
For a vessel and Luna that works out to roughly
40 to 44 degrees of relative inclination. Every ordinary Earth-to-Luna transfer is far below that, so the node samples carry no information and Luna gets dropped from candidacy entirely.
The intended escape hatch never fires, because the only degeneracy guard is an
IsExactlyZero() test on the normalized cross product of the two orbit normals, i.e. bit-exact coplanarity.
In the attached save:
Vessel: a = 187,538.6 km, e = 0.964698, Pe 6,620.5 km, Ap 368,456.7 km. Luna: Pe 355,215.4 km, Ap 406,511.2 km, SOI 61,853.6 km. Relative inclination 0.0056 deg.
Code:
+node: vessel nu = 120.85 deg r = 25,738.0 km | Luna r = 369,362.8 km | |dr| = 343,624.8 km
-node: vessel nu = 300.85 deg r = 8,702.7 km | Luna r = 389,440.9 km | |dr| = 380,738.1 km
min 343,624.8 km > 4 * SOI = 247,414.3 km -> Luna rejected
The two orbits actually pass within
60 km of each other, and the vessel physically flies to 12,826 km of Luna. The test is wrong by about 343,000 km.
Sweeping the node-line orientation over the full circle for this orbit pair, only 26.6% of orientations pass, so roughly three quarters of near-coplanar Earth-to-Luna transfers on an ellipse like this get silently dropped. Hence why i earlier wrote "more often on near-coplanar transfers".
Since
OrbitsCanApproach is a pure function of two fixed conics, the verdict is constant in time, so the vessel was excluded for the whole coast. And because
PatchedConic.ScanForEncounters uses the same predicate with no filter, the re-scan cannot catch it either.
PhysicsStates.CheckSoiTransitions does implement a correct geometric proximity test, but it is only reached from the constrained step and from the Maneuvering/Rolling/Sailing/Dragging branch, never "off the rails", so a coasting vessel has no backstop.
Everything downstream of the filter is fine. If I bypass the candidate filter and hand the same orbit to the encounter solver from before SOI entry, it finds the encounter and builds the right patch chain: Earth -> Encounter at t=328,008 s -> Luna -> Escape at t=457,635 s -> Earth.
Proposed fix
C#:
private static bool OrbitsCanApproach(Orbit orbit, Orbit siblingOrbit, double siblingSoi)
{
double margin = siblingSoi * ENCOUNTER_MOID_SOI_MARGIN;
// GetRadiusAt is p / (1 + e cos nu) with no reachability check, so past a hyperbola's
// asymptote it returns a negative radius, which can only inflate the sampled gap.
if (orbit.IsUnbound() || siblingOrbit.IsUnbound())
return true;
double3 nodeAxis = double3.Cross(siblingOrbit.GetOrbitNormalCci(), orbit.GetOrbitNormalCci());
double sinRelativeInclination = nodeAxis.Length();
// An approach can only happen at radii both orbits reach, and the out-of-plane separation
// r * sin(relativeInclination) is smallest at the inner edge of that shared band.
// If even there the planes are within the margin, a close approach can occur at any true anomaly and
// sampling the two nodes proves nothing.
double bandInnerRadius = Math.Max(orbit.Periapsis, siblingOrbit.Periapsis);
if (!(sinRelativeInclination * bandInnerRadius > margin))
return true;
double3 node = nodeAxis / sinRelativeInclination;
double atNode = Math.Abs(orbit.GetRadiusAt(orbit.GetTrueAnomaly(node))
- siblingOrbit.GetRadiusAt(siblingOrbit.GetTrueAnomaly(node)));
double3 antiNode = -node;
double atAntiNode = Math.Abs(orbit.GetRadiusAt(orbit.GetTrueAnomaly(antiNode))
- siblingOrbit.GetRadiusAt(siblingOrbit.GetTrueAnomaly(antiNode)));
return Math.Min(atNode, atAntiNode) <= margin;
}
For this save the guard evaluates
9.848e-5 * 355,215 km = 35.0 km against a 247,414 km margin, so Luna is kept. Both degenerate directions are covered, prograde coplanar and retrograde coplanar near 180 deg, and both are correct to keep since those orbits genuinely intersect.
This keeps the intent of 4993 where it matters, because the guard scales with SOI: Phobos (SOI override 30 km) re-enables the node test above about 0.74 deg of relative inclination and Deimos above about 0.08 deg, so small moons keep being filtered.
For Luna-class bodies, where the SOI is a large fraction of the orbit radius, the prune ends up effectively disabled below ~44 deg and candidacy falls back to the radial-band test, which is the right fallback since coplanar orbits with overlapping bands genuinely do intersect.
Being honest: This is A fix, not sure if it is "the best" fix even with the guard, the node radii are compared at a single point rather than across the node window, so residual false negatives are still possible for eccentric orbits at moderate inclination.
A structurally correct version could maybe compare radius
ranges over
asin(margin / (periapsis * sin i_rel)) around each node.
I patched exactly that one method over the shipped 2026.7.9.5018 build and re-ran the ordinary planner path on the attached save's orbit.
IsEncounterCandidate(Luna) flips to true and the plan comes back as the correct three patches, Earth -> Luna -> Earth, while a control ellipse with apoapsis 100,000 km is still rejected.
Three related things I noticed (but did not test via an patch such as the main bug)
PatchedConic.IsEncounterCandidate has two bare radius comparisons with no SOI margin: sibling.Orbit.Periapsis > maxReachableRadius and sibling.Orbit.Apoapsis < orbit.Periapsis. The first drops any vessel with apoapsis in 287,176 to 355,215 km for Luna, a 68,039 km band where Luna's SOI genuinely reaches down, and it sits outside the sibling != encounterFilter block so targeting Luna does not bypass it. That one is not new, it goes back to at least 2026.7.3.4826, but it is my prime suspect for the follow-up case where an underperforming transfer burn left a lower apoapsis. Both probably want the same margin ApPeIntersects already uses.
- Once a vessel is inside the SOI the encounter can never be re-acquired, even with the candidate filter fully out of the way. I measured at 1 h after SOI entry (58,751 km from Luna, still approaching) and at the save epoch (12,826 km, receding) and the solver returns nothing in both cases.
FindSoiBoundary only searches for a |r| = SOI crossing between the patch start and the closest approach and clamps its iterate into that interval, so from inside the sphere there is no root; past closest approach the candidate is discarded even earlier for being in the past. Since the SOI transition is driven purely by the flight plan, the ship just coasts through. That is why setting up a correction burn from inside the SOI still ignores Luna. Practical consequence: fixing the filter will not repair an already-affected save, it needs a fresh transfer.
- When zero candidates survive,
FlightPlan.CalculatePatch leaves expiryGameTime at positive infinity, FoldEventExpiry skips it because it is not finite, EventsVerifiedUntil stays infinite, and VehicleUpdateTask.TickPlanVerification therefore never calls ScanForEncounters. So the revision 4848 rolling re-scan is switched off by exactly the failure it exists to catch. Clamping a bound final patch's expiry to startTime + Orbit.Period would restore it.