From 99c25780971150c8fb3b9a7b8af52a85f460b66f Mon Sep 17 00:00:00 2001 From: Richard Griffiths Date: Sat, 11 Apr 2026 12:46:21 +0200 Subject: [PATCH 1/3] Fix race condition with CustomUnits DEPLOY actor in dynamic dialogue (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BindAbstractActorToBindingKey() used positional indexing to map binding keys to actors. When CustomUnits injects a temporary DEPLOY actor at position 0 of the player lance, all indices shift by one, binding the wrong actors to dialogue cast keys. After deployment, the dead DEPLOY actor causes an infinite loop in HandleDeadActorFromDialogueContent(). Changes: - Match actors by pilot identity in BindAbstractActorToBindingKey(), with bounds-checked index fallback - Add iteration guards to both dead-actor rebinding loops in HandleDeadActorFromDialogueContent(), scaling with lance size - Clean up stale BoundAbstractActorsFullIndex entries on Darius fallback to prevent re-binding dead actors Also fixes #709: RebindDeadUnitCastDef passed a bare bindKey to HandleFallback without the castDef_ prefix, so IsBindableRandomCastDefID always returned false on the rebinding path. DynamicCastDefs and BoundAbstractActorsFullIndex were never updated during fallback, causing an infinite loop when all lance pilots are dead — even without CU. --- .../Interpolation/DialogueInterpolator.cs | 28 +++++++++++++++---- .../Interpolation/PilotCastInterpolator.cs | 27 +++++++++++++++--- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Core/Interpolation/DialogueInterpolator.cs b/src/Core/Interpolation/DialogueInterpolator.cs index 07c2da2c..e498a0e7 100644 --- a/src/Core/Interpolation/DialogueInterpolator.cs +++ b/src/Core/Interpolation/DialogueInterpolator.cs @@ -667,7 +667,10 @@ public void HandleDeadActorFromDialogueContent(ref CastDef castDef) { // Handle bound units if (bindingKey != null) { AbstractActor unit = DialogueInterpolator.Instance.GetBoundUnit(bindingKey); - while (unit != null && unit.IsDead) { + int rebindAttempts = 0; + int maxRebindAttempts = MissionControl.Instance.CurrentContract.Lances.GetLanceUnits(TeamUtils.GetTeamGuid("Player1")).Length + 1; + while (unit != null && unit.IsDead && rebindAttempts < maxRebindAttempts) { + rebindAttempts++; string reboundCastDefID = PilotCastInterpolator.Instance.RebindDeadUnit(bindingKey); Main.LogDebug($"[Interpolate.HandleDeadActorFromDialogueContent] Unit '{unit.UnitName} with pilot '{unit.GetPilot().Name}' is dead (or ejected). Rebinding all castdefs and references for unit key '{reboundCastDefID}'"); CastDef reboundCastDef = UnityGameInstance.Instance.Game.DataManager.CastDefs.Get(reboundCastDefID); @@ -675,13 +678,21 @@ public void HandleDeadActorFromDialogueContent(ref CastDef castDef) { castDef = reboundCastDef; unit = GetBoundUnit(bindingKey == DialogueInterpolationConstants.Commander ? DialogueInterpolationConstants.Darius : bindingKey); } + + if (rebindAttempts >= maxRebindAttempts) { + Main.Logger.LogWarning($"[HandleDeadActorFromDialogueContent] Exhausted rebind attempts for '{bindingKey}'. Falling back to Darius."); + castDef = RuntimeCastFactory.GetCastDef(CustomCastDef.castDef_Darius); + } } else { // Attempt to see if a unit exists against the castDef pilot (PureRandom units) AbstractActor unit = GetSpeakerUnit(RuntimeCastFactory.GetPilotDefIDFromCastDefID(castDef.id)); - while (unit != null && unit.IsDead) { - Contract contract = MissionControl.Instance.CurrentContract; - SpawnableUnit[] lanceConfigUnits = contract.Lances.GetLanceUnits(TeamUtils.GetTeamGuid("Player1")); - int randomPosition = UnityEngine.Random.Range(0, lanceConfigUnits.Length); - string pilotDefID = lanceConfigUnits[randomPosition].PilotId; + int pureRandomRebindAttempts = 0; + Contract pureRandomContract = MissionControl.Instance.CurrentContract; + SpawnableUnit[] pureRandomLanceConfigUnits = pureRandomContract.Lances.GetLanceUnits(TeamUtils.GetTeamGuid("Player1")); + int maxPureRandomRebindAttempts = pureRandomLanceConfigUnits.Length + 1; + while (unit != null && unit.IsDead && pureRandomRebindAttempts < maxPureRandomRebindAttempts) { + pureRandomRebindAttempts++; + int randomPosition = UnityEngine.Random.Range(0, pureRandomLanceConfigUnits.Length); + string pilotDefID = pureRandomLanceConfigUnits[randomPosition].PilotId; string pilotCastDefID = RuntimeCastFactory.GetCastDefIDFromPilotDefID(pilotDefID); CastDef updatedCastDef = RuntimeCastFactory.GetCastDef(pilotCastDefID); @@ -706,6 +717,11 @@ public void HandleDeadActorFromDialogueContent(ref CastDef castDef) { unit = null; } } + + if (pureRandomRebindAttempts >= maxPureRandomRebindAttempts) { + Main.Logger.LogWarning($"[HandleDeadActorFromDialogueContent] Exhausted PureRandom rebind attempts. Falling back to Darius."); + castDef = RuntimeCastFactory.GetCastDef(CustomCastDef.castDef_Darius); + } } } } diff --git a/src/Core/Interpolation/PilotCastInterpolator.cs b/src/Core/Interpolation/PilotCastInterpolator.cs index b99518ca..2efba14d 100644 --- a/src/Core/Interpolation/PilotCastInterpolator.cs +++ b/src/Core/Interpolation/PilotCastInterpolator.cs @@ -90,6 +90,7 @@ private string HandleFallback(int pilotPosition, string selectedCastDefID) { if (IsBindableRandomCastDefID(selectedCastDefID)) { string bindingKey = GetBindingKey(selectedCastDefID); PilotCastInterpolator.Instance.DynamicCastDefs[bindingKey] = fallbackCastDefID; + PilotCastInterpolator.Instance.BoundAbstractActorsFullIndex.Remove(bindingKey); } return fallbackCastDefID; @@ -170,9 +171,27 @@ private void BindAbstractActorToBindingKey() { List units = lance.GetLanceUnits(); foreach (KeyValuePair entry in BoundAbstractActorsFullIndex) { - AbstractActor actor = units[entry.Value - 1]; - // Main.LogDebug($"[PilotCastInterpolator.BindAbstractActorToBindingKey] Binding AbstractActor '{actor.UnitName}' with pilot '{actor.GetPilot().Name}' using '{entry.Key}:{entry.Value - 1}'"); - BoundAbstractActors[entry.Key] = actor; + AbstractActor matchedActor = null; + + // Primary: match by pilot identity (robust against other mods injecting temporary actors into the lance) + if (DynamicCastDefs.TryGetValue(entry.Key, out string castDefId)) { + string pilotDefId = RuntimeCastFactory.GetPilotDefIDFromCastDefID(castDefId); + matchedActor = units.FirstOrDefault(u => + u.GetPilot().pilotDef.Description.Id.ToUpperFirst() == pilotDefId.ToUpperFirst()); + } + + // Fallback: index-based with bounds checking + if (matchedActor == null) { + int index = entry.Value - 1; + if (index >= 0 && index < units.Count) { + matchedActor = units[index]; + } + } + + if (matchedActor != null) { + // Main.LogDebug($"[PilotCastInterpolator.BindAbstractActorToBindingKey] Binding AbstractActor '{matchedActor.UnitName}' with pilot '{matchedActor.GetPilot().Name}' using '{entry.Key}'"); + BoundAbstractActors[entry.Key] = matchedActor; + } } // Bind the commander if they are in combat @@ -209,7 +228,7 @@ private string RebindDeadUnitCastDef(string bindKey) { int pilotPosition = FindNonUsedPilotPosition("Player1", lanceConfigUnits); if (!IsPilotPositionValid(pilotPosition)) { - return HandleFallback(pilotPosition, bindKey); + return HandleFallback(pilotPosition, GetDynamicCastDefIDFromBindKey(bindKey)); } return BindCastDefAndActorIndex(pilotPosition, GetDynamicCastDefIDFromBindKey(bindKey), lanceConfigUnits, fullLanceConfigUnits); From 277d9b9b5f76eb63f1e216b4be51df35f18f74a5 Mon Sep 17 00:00:00 2001 From: Richard Griffiths Date: Sat, 11 Apr 2026 13:27:34 +0200 Subject: [PATCH 2/3] Fix PureRandom rebinding to sample without replacement (#708) The PureRandom dead-actor loop sampled positions with replacement, meaning it could repeatedly pick the same dead pilot and miss a live one. With the iteration guard this caused a ~24% false Darius fallback on a 4-pilot lance with 1 survivor. Shuffle positions using Fisher-Yates so each pilot is tried exactly once, guaranteeing a survivor is found if one exists. --- .../Interpolation/DialogueInterpolator.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Core/Interpolation/DialogueInterpolator.cs b/src/Core/Interpolation/DialogueInterpolator.cs index e498a0e7..2dbf1d01 100644 --- a/src/Core/Interpolation/DialogueInterpolator.cs +++ b/src/Core/Interpolation/DialogueInterpolator.cs @@ -685,14 +685,20 @@ public void HandleDeadActorFromDialogueContent(ref CastDef castDef) { } } else { // Attempt to see if a unit exists against the castDef pilot (PureRandom units) AbstractActor unit = GetSpeakerUnit(RuntimeCastFactory.GetPilotDefIDFromCastDefID(castDef.id)); - int pureRandomRebindAttempts = 0; Contract pureRandomContract = MissionControl.Instance.CurrentContract; SpawnableUnit[] pureRandomLanceConfigUnits = pureRandomContract.Lances.GetLanceUnits(TeamUtils.GetTeamGuid("Player1")); - int maxPureRandomRebindAttempts = pureRandomLanceConfigUnits.Length + 1; - while (unit != null && unit.IsDead && pureRandomRebindAttempts < maxPureRandomRebindAttempts) { - pureRandomRebindAttempts++; - int randomPosition = UnityEngine.Random.Range(0, pureRandomLanceConfigUnits.Length); - string pilotDefID = pureRandomLanceConfigUnits[randomPosition].PilotId; + + // Shuffle positions to sample without replacement — guarantees finding a survivor if one exists + List shuffledPositions = Enumerable.Range(0, pureRandomLanceConfigUnits.Length).ToList(); + for (int i = shuffledPositions.Count - 1; i > 0; i--) { + int j = UnityEngine.Random.Range(0, i + 1); + (shuffledPositions[i], shuffledPositions[j]) = (shuffledPositions[j], shuffledPositions[i]); + } + + int positionIndex = 0; + while (unit != null && unit.IsDead && positionIndex < shuffledPositions.Count) { + string pilotDefID = pureRandomLanceConfigUnits[shuffledPositions[positionIndex]].PilotId; + positionIndex++; string pilotCastDefID = RuntimeCastFactory.GetCastDefIDFromPilotDefID(pilotDefID); CastDef updatedCastDef = RuntimeCastFactory.GetCastDef(pilotCastDefID); @@ -718,7 +724,7 @@ public void HandleDeadActorFromDialogueContent(ref CastDef castDef) { } } - if (pureRandomRebindAttempts >= maxPureRandomRebindAttempts) { + if (positionIndex >= shuffledPositions.Count && unit != null && unit.IsDead) { Main.Logger.LogWarning($"[HandleDeadActorFromDialogueContent] Exhausted PureRandom rebind attempts. Falling back to Darius."); castDef = RuntimeCastFactory.GetCastDef(CustomCastDef.castDef_Darius); } From 5d2ca5f5d6a0d1e63b2229f23fb3f5fdd024da46 Mon Sep 17 00:00:00 2001 From: Richard Griffiths Date: Sat, 11 Apr 2026 16:52:18 +0200 Subject: [PATCH 3/3] Fix dead pilot name shown in other speakers' dialogue text (#712) When a pilot bound to castDef_TeamPilot_Random_X is dead or ejected, the rebinding only triggered when that pilot was the speaker. If an earlier message (e.g. the commander) referenced the dead pilot's DisplayName, the stale name was shown. Now InterpolatePlayerLances checks if the referenced pilot is dead during text interpolation and triggers rebinding before resolving the name. Uses a while loop to handle multiple dead pilots. --- src/Core/Interpolation/DialogueInterpolator.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Core/Interpolation/DialogueInterpolator.cs b/src/Core/Interpolation/DialogueInterpolator.cs index 2dbf1d01..aff385b0 100644 --- a/src/Core/Interpolation/DialogueInterpolator.cs +++ b/src/Core/Interpolation/DialogueInterpolator.cs @@ -205,6 +205,15 @@ private string InterpolatePlayerLances(InterpolateType interpolateType, CastDef // Continue with interpolation if (unitKey.StartsWith(DialogueInterpolationConstants.TeamPilot_Random)) { + // Rebind if the referenced pilot is dead or ejected, keep trying until a live one is found + int textRebindAttempts = 0; + int maxTextRebindAttempts = MissionControl.Instance.CurrentContract.Lances.GetLanceUnits(TeamUtils.GetTeamGuid("Player1")).Length + 1; + while (unit != null && unit.IsDead && PilotCastInterpolator.Instance.BoundAbstractActors.ContainsKey(unitKey) && textRebindAttempts < maxTextRebindAttempts) { + textRebindAttempts++; + PilotCastInterpolator.Instance.RebindDeadUnit(unitKey); + unit = GetBoundUnit(unitKey); + } + if (unitDataKey == "DisplayName") { if (PilotCastInterpolator.Instance.DynamicCastDefs.ContainsKey(unitKey)) { string castDefId = PilotCastInterpolator.Instance.DynamicCastDefs[unitKey];