From a8dcb1daf94af051bcb34db8b7584f49b527130b Mon Sep 17 00:00:00 2001 From: BoroBongo Date: Mon, 15 Jun 2026 02:44:29 +0100 Subject: [PATCH 1/4] fix: prevent dialog queue freeze when NPC audio file is missing Output.Start() was throwing NullReferenceException on audioClip.length when a .wav wasn't found, leaving _audioPlaySeconds=0 and _randomDialogAnimationName=null. IsFinished() then threw on every frame trying to StopAnimation(null), so the action never completed and the player stayed locked with LockPlayerInPlace() forever. Fix: null-guard audioClip with a 3s fallback display duration; guard _randomDialogAnimationName in IsFinished() before calling StopAnimation. DialogService.StartDialog() was also calling ShowDialog([]) when the selectableDialogs list came up empty after a choice callback (e.g. after the PSI temple guard says "Lester is trustworthy, you may pass"). Now calls StopDialog() instead, properly releasing the player. --- .../Npc/Actions/AnimationActions/Output.cs | 19 ++++++++++++++----- .../Scripts/Services/Player/DialogService.cs | 7 +++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs index 8b1f54eae..fb210b208 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs @@ -12,6 +12,8 @@ using Reflex.Attributes; using UnityEngine; using Random = UnityEngine.Random; +using Logger = Gothic.Core.Logging.Logger; +using LogCat = Gothic.Core.Logging.LogCat; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions { @@ -50,12 +52,15 @@ public override void Start() } var audioClip = _audioService.CreateAudioClip(OutputName); - _audioPlaySeconds = audioClip.length; + if (audioClip == null) + Logger.LogWarning($"[Output] Audio not found: {OutputName} — using fallback duration", LogCat.Dialog); + _audioPlaySeconds = audioClip != null ? audioClip.length : 3f; // Hero if (_isHeroSpeaking) { - _npcService.GetHeroGameObject().GetComponent().PlayOneShot(audioClip); + if (audioClip != null) + _npcService.GetHeroGameObject().GetComponent().PlayOneShot(audioClip); PrintDialog(); } @@ -69,7 +74,8 @@ public override void Start() PrefabProps.AnimationSystem.PlayAnimation(_randomDialogAnimationName); PrefabProps.AnimationSystem.PlayHeadAnimation(HeadMorph.HeadMorphType.Viseme); - PrefabProps.NpcSound.PlayOneShot(audioClip); + if (audioClip != null) + PrefabProps.NpcSound.PlayOneShot(audioClip); PrintDialog(); } @@ -138,8 +144,11 @@ public override bool IsFinished() // NPC else { - PrefabProps.AnimationSystem.StopAnimation(_randomDialogAnimationName); - PrefabProps.AnimationSystem.StopHeadAnimation(HeadMorph.HeadMorphType.Viseme); + if (_randomDialogAnimationName != null) + { + PrefabProps.AnimationSystem.StopAnimation(_randomDialogAnimationName); + PrefabProps.AnimationSystem.StopHeadAnimation(HeadMorph.HeadMorphType.Viseme); + } PrefabProps.NpcSubtitles.HideSubtitles(); } diff --git a/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs b/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs index 6635f89e6..e6ecd4bc0 100644 --- a/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs @@ -117,6 +117,13 @@ public void StartDialog(NpcContainer npcContainer, bool initialDialogStarting) } selectableDialogs = selectableDialogs.OrderBy(d => d.Nr).ToList(); + + if (!selectableDialogs.Any()) + { + StopDialog(npcContainer); + return; + } + _contextDialogService.FillDialog(npcContainer.Instance, selectableDialogs); _contextDialogService.ShowDialog(npcContainer.Go); } From f24c410db7e6728d933bb96d419bc5030601ee01 Mon Sep 17 00:00:00 2001 From: BoroBongo Date: Mon, 15 Jun 2026 18:20:39 +0100 Subject: [PATCH 2/4] fix: resolve ambient NPC dialogs showing total=0 for guild NPCs ZenKit fails to execute `B_AssignAmbientInfos` Daedalus member assignments like `Info_Grd_6_EXIT.npc = X` inside function bodies ("C_INFO.NPC without an instance set"). Added `TryAssignAmbientDialogs` in `NpcService.SetDialogs` which matches C_INFO symbol names by guild abbreviation and voice number (e.g. INFO_GRD_6_*, INFO_STT_10_*) and sets InfoInstance.Npc directly via the ZenKitCS C# setter, bypassing the Daedalus bug. --- .../Scripts/Services/Npc/NpcService.cs | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs index ec605f8f7..9bb9c3b97 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs @@ -487,8 +487,84 @@ public void SetDialogs(NpcContainer npcContainer) .Where(dialog => dialog.Npc == npcIndex) .OrderByDescending(dialog => dialog.Important) .ToList(); + + if (!npcContainer.Props.Dialogs.Any()) + TryAssignAmbientDialogs(npcContainer); + } + + // ZenKit bug: Daedalus member assignment like `Info_Grd_6_EXIT.npc = X` inside + // function bodies fails with "C_INFO.NPC without an instance set". B_AssignAmbientInfos + // relies on this to link ambient C_INFO instances to NPCs at runtime. We replicate + // the assignment here by matching symbol name patterns (INFO___*) + // and setting InfoInstance.Npc directly via the ZenKitCS setter. + private void TryAssignAmbientDialogs(NpcContainer npcContainer) + { + var guildAbbr = GetAmbientGuildAbbreviation(npcContainer.Instance.Guild); + if (guildAbbr == null) + return; + + _ambientInfoByPrefix ??= BuildAmbientInfoPrefixLookup(); + + var voice = npcContainer.Instance.Voice; + if (!_ambientInfoByPrefix.TryGetValue($"INFO_{guildAbbr}_{voice}", out var ambientDialogs) && + !_ambientInfoByPrefix.TryGetValue($"INFO_MINE_{guildAbbr}_{voice}", out ambientDialogs)) + return; + + var npcIndex = npcContainer.Instance.Index; + foreach (var dialog in ambientDialogs) + dialog.Npc = npcIndex; + + npcContainer.Props.Dialogs = ambientDialogs.OrderByDescending(d => d.Important).ToList(); } + // Cached lookup: "INFO_GRD_6" → list of ambient C_INFO instances with that guild+voice prefix. + // Built lazily from symbol names to avoid repeated GetSymbolByIndex calls per frame. + private Dictionary> _ambientInfoByPrefix; + + private Dictionary> BuildAmbientInfoPrefixLookup() + { + var lookup = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var info in _gameStateService.Dialogs.Instances) + { + var name = _vm.GetSymbolByIndex(info.Index)?.Name; + if (name == null) continue; + + var parts = name.Split('_'); + if (parts.Length < 4 || !parts[0].Equals("INFO", StringComparison.OrdinalIgnoreCase)) + continue; + + string key; + if (parts[1].Equals("MINE", StringComparison.OrdinalIgnoreCase) && parts.Length >= 5 && int.TryParse(parts[3], out var mineVoice) && mineVoice <= 15) + key = $"INFO_MINE_{parts[2]}_{parts[3]}"; + else if (int.TryParse(parts[2], out var campVoice) && campVoice <= 15) + key = $"INFO_{parts[1]}_{parts[2]}"; + else + continue; + + if (!lookup.TryGetValue(key, out var list)) + { + list = new List(); + lookup[key] = list; + } + list.Add(info); + } + return lookup; + } + + private static string GetAmbientGuildAbbreviation(int guild) => guild switch + { + (int)VmGothicEnums.Guild.GIL_GRD => "GRD", + (int)VmGothicEnums.Guild.GIL_STT => "STT", + (int)VmGothicEnums.Guild.GIL_VLK => "VLK", + (int)VmGothicEnums.Guild.GIL_SLD => "SLD", + (int)VmGothicEnums.Guild.GIL_ORG => "ORG", + (int)VmGothicEnums.Guild.GIL_BAU => "BAU", + (int)VmGothicEnums.Guild.GIL_SFB => "SFB", + (int)VmGothicEnums.Guild.GIL_NOV => "NOV", + (int)VmGothicEnums.Guild.GIL_TPL => "TPL", + _ => null + }; + private void EventNpcMeshCullingChanged(NpcContainer npcContainer, NpcLoader npcLoader, bool isInVisibleRange, bool wasOutOfDistance) { // Alter position tracking of NPC From 5680d2874af6c23fefeaba7e4257fe41d7cef4ab Mon Sep 17 00:00:00 2001 From: BoroBongo Date: Mon, 15 Jun 2026 18:22:13 +0100 Subject: [PATCH 3/4] fix: play hero greeting SVM in parallel with NPC turn The hero's "hey you!" SVM (AI_OutputSvm in ZS_Talk) was blocking the NPC's animation queue, causing the NPC to stand still until the hero finished speaking before turning around to face the player. OutputSvm.Start() now calls StartHeroFireAndForget() for hero SVM lines, which schedules subtitle auto-hide via INpcSubtitles.ScheduleHide (Invoke) and immediately marks the action finished so the NPC queue continues. Regular AI_Output dialog lines (Output.cs) remain fully blocking so dialog conversations are unaffected. Also adds WasPlayerInitiated flag to suppress hero reactive SVM lines when the NPC is the one who initiated the conversation (important dialog). --- .../Scripts/Adapters/Npc/INpcSubtitles.cs | 1 + .../Npc/Actions/AnimationActions/Output.cs | 17 +++++++------- .../Npc/Actions/AnimationActions/OutputSvm.cs | 13 ++++++++++- .../Scripts/Models/Npc/DialogModel.cs | 8 +++++++ .../Scripts/Services/Player/DialogService.cs | 23 ++++++++++++------- .../Scripts/Adapters/UI/VRSubtitles.cs | 3 +++ Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs | 1 + 7 files changed, 49 insertions(+), 17 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs b/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs index 5056843e2..117b3c7b3 100644 --- a/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs +++ b/Assets/Gothic-Core/Scripts/Adapters/Npc/INpcSubtitles.cs @@ -4,5 +4,6 @@ public interface INpcSubtitles { void ShowSubtitles(string text); void HideSubtitles(); + void ScheduleHide(float delay); } } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs index fb210b208..c063c04db 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs @@ -4,11 +4,8 @@ using Gothic.Core.Models.Container; using Gothic.Core.Services; using Gothic.Core.Services.Caches; -using Gothic.Core.Services.Config; using Gothic.Core.Services.Npc; -using Gothic.Core.Adapters.Npc; using Gothic.Core.Extensions; -using Gothic.Core.Const; using Reflex.Attributes; using UnityEngine; using Random = UnityEngine.Random; @@ -19,7 +16,6 @@ namespace Gothic.Core.Domain.Npc.Actions.AnimationActions { public class Output : AbstractAnimationAction { - [Inject] private readonly ConfigService _configService; [Inject] private readonly DialogService _dialogService; [Inject] private readonly AudioService _audioService; [Inject] private readonly NpcService _npcService; @@ -87,13 +83,9 @@ private void PrintDialog() var currentMessage = _gameStateService.Dialogs.CutsceneLibrary.Blocks.Find(x => x.Name == OutputName).Message; if (_isHeroSpeaking) - { _npcService.GetHeroContainer().PrefabProps.NpcSubtitles.ShowSubtitles(currentMessage.Text); - } else - { PrefabProps.NpcSubtitles.ShowSubtitles(currentMessage.Text); - } } /// @@ -114,6 +106,13 @@ private int GetDialogGestureCount() return _gameStateService.Dialogs.GestureCount; } + // Used by OutputSvm for hero greeting: audio plays fire-and-forget, subtitles auto-hide after clip ends. + protected void StartHeroFireAndForget() + { + _npcService.GetHeroContainer().PrefabProps.NpcSubtitles.ScheduleHide(_audioPlaySeconds); + IsFinishedFlag = true; + } + public override void StopImmediately() { _audioPlaySeconds = 0f; @@ -132,6 +131,8 @@ public override void StopImmediately() public override bool IsFinished() { + if (IsFinishedFlag) return true; + _audioPlaySeconds -= Time.deltaTime; if (_audioPlaySeconds <= 0f) diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs index 088dd5b55..803a194cb 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/OutputSvm.cs @@ -18,9 +18,20 @@ public OutputSvm(AnimationAction action, NpcContainer npcContainer) : base(actio public override void Start() { var svm = VmCacheService.TryGetSvmData(NpcInstance.Voice); - _preparedSvmFileName = svm.GetAudioName(Action.String0); + _preparedSvmFileName = svm?.GetAudioName(Action.String0); + + if (_preparedSvmFileName == null) + { + IsFinishedFlag = true; + return; + } base.Start(); + + // Hero SVM (e.g. "Hej ty!") is fire-and-forget — NPC queue continues immediately + // so the NPC can turn and greet in parallel. Regular Output dialog lines stay blocking. + if (Action.Int0 == 0) + StartHeroFireAndForget(); } } } diff --git a/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs b/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs index b49973e30..6b883d68f 100644 --- a/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs +++ b/Assets/Gothic-Core/Scripts/Models/Npc/DialogModel.cs @@ -10,6 +10,13 @@ public class DialogModel public List Instances = new(); public bool IsInDialog; + /// + /// Set from C# when the hero physically initiates dialog (collision/grab). + /// False when the NPC initiates (e.g. important dialog walks to player). + /// Used to suppress hero's reactive "hey" SVM lines for NPC-initiated conversations. + /// + public bool WasPlayerInitiated; + public CutsceneLibrary CutsceneLibrary; public int GestureCount; @@ -20,6 +27,7 @@ public class DialogModel public void Dispose() { IsInDialog = false; + WasPlayerInitiated = false; CurrentInstance = null; CurrentOptions.Clear(); GestureCount = 0; diff --git a/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs b/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs index e6ecd4bc0..a1234a8cf 100644 --- a/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Player/DialogService.cs @@ -96,7 +96,6 @@ public void StartDialog(NpcContainer npcContainer, bool initialDialogStarting) continue; } - // TODO - Should be outsourced to some VmManager.Call function which sets and resets values. var oldSelf = _gameStateService.GothicVm.GlobalSelf; var oldOther = _gameStateService.GothicVm.GlobalOther; @@ -111,7 +110,7 @@ public void StartDialog(NpcContainer npcContainer, bool initialDialogStarting) { continue; } - + // We can now add the dialog selectableDialogs.Add(dialog); } @@ -190,18 +189,25 @@ public void ExtAiOutput(NpcInstance self, NpcInstance target, string outputName) npcTalkingTo.GetUserData())); } - /// - /// SVM (Standard Voice Module) dialogs are only for NPCs between each other. Not related to Hero dialogs. - /// public void ExtAiOutputSvm(NpcInstance npc, NpcInstance target, string svmName) { - var npcContainer = GetNpcContainer(npc); + var isHero = npc.Id == 0; - if (target != null) + // Hero SVM: enqueue on target's queue (hero's queue is never processed); hero container provides voice ID. + if (isHero && target != null) { - Logger.LogWarning("Ai_OutputSvm() - Handling with target not yet implemented!", LogCat.Dialog); + // NPC walked to player (e.g. important dialog) — hero's "hey" reactive lines make no sense. + if (!_gameStateService.Dialogs.WasPlayerInitiated) + return; + + var heroContainer = GetNpcContainer(npc); + target.GetUserData().Props.AnimationQueue.Enqueue(new OutputSvm( + new AnimationAction(int0: 0, string0: svmName), + heroContainer)); + return; } + var npcContainer = GetNpcContainer(npc); npcContainer.Props.AnimationQueue.Enqueue(new OutputSvm( new AnimationAction(int0: npcContainer.Instance.Id, string0: svmName), npcContainer)); @@ -286,6 +292,7 @@ public void StopDialog(NpcContainer npc) _gameStateService.Dialogs.CurrentInstance = null; _gameStateService.Dialogs.CurrentOptions.Clear(); _gameStateService.Dialogs.IsInDialog = false; + _gameStateService.Dialogs.WasPlayerInitiated = false; // WIP: unlocking movement _contextInteractionService.UnlockPlayer(); diff --git a/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs b/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs index 9538df458..68ccc2c35 100644 --- a/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs +++ b/Assets/Gothic-VR/Scripts/Adapters/UI/VRSubtitles.cs @@ -51,6 +51,7 @@ public void ShowSubtitles(string text) if (!_configService.Gothic.IniSubtitles) return; + CancelInvoke(nameof(HideSubtitles)); gameObject.SetActive(true); _dialogText.text = text; } @@ -59,6 +60,8 @@ public void HideSubtitles() { gameObject.SetActive(false); } + + public void ScheduleHide(float delay) => Invoke(nameof(HideSubtitles), delay); } } #endif diff --git a/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs b/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs index 8664f2289..0eba7af39 100644 --- a/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs +++ b/Assets/Gothic-VR/Scripts/Adapters/VRNpc.cs @@ -53,6 +53,7 @@ public void OnGrabbed(HVRGrabberBase grabber, HVRGrabbable grabbable) } else { + _gameStateService.Dialogs.WasPlayerInitiated = true; _npcAiService.ExecutePerception(VmGothicEnums.PerceptionType.AssessTalk, _npcData.Props, _npcData.Instance, null, (NpcInstance)_gameStateService.GothicVm.GlobalHero); } } From 6f468edfafa714473d75e3006c186026a0961cfc Mon Sep 17 00:00:00 2001 From: BoroBongo Date: Mon, 15 Jun 2026 22:00:14 +0100 Subject: [PATCH 4/4] fix: add missing $sc_heywaitasecond SVM mapping --- Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs b/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs index a98faa185..cf6ad4984 100644 --- a/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs +++ b/Assets/Gothic-Core/Scripts/Extensions/ZenKitExtension.cs @@ -318,6 +318,7 @@ public static string GetAudioName(this SvmInstance svm, string svmEntry) "$smalltalk23" => svm.Smalltalk23, "$smalltalk24" => svm.Smalltalk24, "$om" => svm.Om, + "$sc_heywaitasecond" => svm.ScHeyWaitASecond, "$sc_heyturnaround" => svm.ScHeyTurnAround, "$sc_heyturnaround02" => svm.ScHeyTurnAround02, "$sc_heyturnaround03" => svm.ScHeyTurnAround03,