#if UNITY_EDITOR using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using AutomaticDoorSystem.Utilities; using Unity.Collections; using Unity.Entities; using Unity.Rendering; using Unity.Scenes; using Unity.Transforms; using UnityEditor; using UnityEngine; namespace AutomaticDoorSystem.Editor { /// /// Play-mode diagnostics for the whole door runtime, one tab per concern: /// /// - Overview: the managers, the baked-world census (duplicate ids, doors invisible to the /// bridge, disabled doors) and the selected door's live state + authoring locator. The /// generic checks every other tab depends on. /// - Colliders: the pooled BoxCollider chain, fleet-wide. Surfaces the runtime's two SILENT /// failure paths: an empty bridge cache (it retries forever without logging) and a door /// whose panel buffer cannot be resolved (the pool skips it without a warning). /// - Audio: the pooled AudioSource chain for one door, link by link, with test events and /// output-stage bypasses (formerly the Door Audio Doctor). /// /// The static helpers back the audio section on the DoorAuthoring inspector, so the same /// checks are available right on the door. /// public class DoorDoctorWindow : EditorWindow { private enum Tab { Overview, Colliders, Audio } private static readonly string[] TabLabels = { "Overview", "Colliders", "Audio" }; private Tab _tab; private int _doorId; private Vector2 _scroll; private bool _collidersOnlyProblems; // Locator scan results (button-triggered - reading every closed subscene file each GUI // frame would be far too slow). private int _scannedDoorId = int.MinValue; private List<(string sceneName, SubScene subScene)> _subScenesWithDoor; [MenuItem("Tools/Jeanf/AutomaticDoorSystem/Door Doctor")] public static void Open() { var window = GetWindow("Door Doctor"); window.minSize = new Vector2(460f, 340f); window.Show(); } /// Inspector entry point ("Doctor..." on DoorAuthoring) - lands on the Audio tab. public static void Open(int doorId) { Open(); var window = GetWindow(); window._doorId = doorId; window._tab = Tab.Audio; } private void OnEnable() { TryAdoptSelection(); } private void OnSelectionChange() { TryAdoptSelection(); Repaint(); } private void OnInspectorUpdate() { // Live values (state, distance, slot assignments) change while playing - keep the view fresh. if (Application.isPlaying) Repaint(); } private void TryAdoptSelection() { var go = Selection.activeGameObject; if (go == null) return; var authoring = go.GetComponentInParent(); if (authoring != null) _doorId = authoring.doorId; } private void OnGUI() { EditorGUILayout.Space(); using (new EditorGUILayout.HorizontalScope()) { _doorId = EditorGUILayout.IntField("Door Id", _doorId); if (GUILayout.Button("From selection", GUILayout.Width(110f))) { TryAdoptSelection(); } } _tab = (Tab)GUILayout.Toolbar((int)_tab, TabLabels); EditorGUILayout.Space(); _scroll = EditorGUILayout.BeginScrollView(_scroll); switch (_tab) { case Tab.Overview: DrawOverviewTab(); break; case Tab.Colliders: DrawCollidersTab(); break; case Tab.Audio: DrawAudioTab(); break; } EditorGUILayout.EndScrollView(); } // --------------------------------------------------------------------------------------- // Overview tab - managers, world census, selected door state, authoring locator. private void DrawOverviewTab() { if (!Application.isPlaying) { EditorGUILayout.HelpBox( "Live checks need play mode - the pools, the bridges and the door entities only exist there. " + "Static asset checks live in Tools > Jeanf > AutomaticDoorSystem > Setup Validator.", MessageType.Info); DrawAuthoringLocator(); return; } var world = World.DefaultGameObjectInjectionWorld; if (world == null || !world.IsCreated) { EditorGUILayout.HelpBox("No DefaultGameObjectInjectionWorld - ECS is not running at all.", MessageType.Error); return; } EditorGUILayout.LabelField("Managers", EditorStyles.boldLabel); var dataBridge = DoorDataBridge.Instance; Row(dataBridge != null, "DoorDataBridge present in the scene", "No DoorDataBridge instance - nothing reads the door entities. Run the Setup Validator."); Row(BoxColliderPoolManager.Instance != null, "BoxColliderPoolManager present in the scene", "No BoxColliderPoolManager - doors never get physical colliders."); Row(AudioSourcePoolManager.Instance != null, "AudioSourcePoolManager present in the scene", "No AudioSourcePoolManager - no pooled AudioSources exist at all."); Row(DoorAudioBridge.Instance != null, "DoorAudioBridge present in the scene", "No DoorAudioBridge - audio events are never routed to an AudioSource."); Row(Camera.main != null, "Camera.main found (both pools cull by distance to it)", "No enabled MainCamera - neither pool ever activates anything."); var audioPool = AudioSourcePoolManager.Instance; if (audioPool != null) { // The door pool's share of Steam Audio's game-wide reflection-source cap // ("Simulating reflections for N sources, which is more than the max"). audioPool.GetSteamAudioLoad(out int simulated, out int reflecting); EditorGUILayout.HelpBox( $"Steam Audio load from this pool: {simulated}/{audioPool.maxPoolSize} pooled source(s) in the " + $"simulator, {reflecting} in the reflections pass (reflections culled beyond " + $"{audioPool.reflectionsCullingDistance:0}m; unassigned slots are fully disabled).", MessageType.None); } EditorGUILayout.Space(); EditorGUILayout.LabelField("Baked world census", EditorStyles.boldLabel); DrawWorldCensus(world.EntityManager, dataBridge); EditorGUILayout.Space(); if (dataBridge != null && dataBridge.TryGetDoorInfo(_doorId, out var info)) { EditorGUILayout.LabelField($"Door {_doorId}", EditorStyles.boldLabel); DrawDoorState(info); } else { EditorGUILayout.HelpBox($"Door id {_doorId} is not in the bridge cache - see the census above " + "for duplicates / invisible doors, or locate its authoring below.", MessageType.Warning); } DrawAuthoringLocator(); } /// /// Fleet-wide integrity of the baked doors: duplicate ids (all but one lose colliders AND /// audio), doors missing one of the components the bridge's query requires (they never /// reach the cache - silently), and disabled doors. /// private void DrawWorldCensus(EntityManager em, DoorDataBridge dataBridge) { var idsSeen = new Dictionary(); var invisibleRows = new List(); int enabledCount; using (var query = em.CreateEntityQuery(ComponentType.ReadOnly())) { var entities = query.ToEntityArray(Allocator.Temp); enabledCount = entities.Length; for (var i = 0; i < entities.Length; i++) { var e = entities[i]; var id = em.GetComponentData(e).DoorId; idsSeen[id] = idsSeen.TryGetValue(id, out var n) ? n + 1 : 1; // The bridge's query needs ALL of these; a door missing one never reaches the // cache - and therefore never gets a collider or audio - without any log. var missing = new List(); if (!em.HasComponent(e)) missing.Add("DoorStateComponent"); if (!em.HasComponent(e)) missing.Add("DoorTransformData"); if (!em.HasComponent(e)) missing.Add("LocalToWorld"); if (!em.HasComponent(e)) missing.Add("DoorTriggerVolume"); if (missing.Count > 0) invisibleRows.Add($"Door {id} (e{e.Index}) missing: {string.Join(", ", missing)}"); } entities.Dispose(); } int disabledCount; var allDesc = new EntityQueryDesc { All = new[] { ComponentType.ReadOnly() }, Options = EntityQueryOptions.IncludeDisabledEntities }; using (var query = em.CreateEntityQuery(allDesc)) { disabledCount = query.CalculateEntityCount() - enabledCount; } var cacheCount = dataBridge != null ? dataBridge.GetAllDoorInfo()?.Count ?? 0 : 0; EditorGUILayout.LabelField( $"Door entities: {enabledCount} enabled, {disabledCount} disabled | distinct ids: {idsSeen.Count} | bridge cache: {cacheCount}"); if (enabledCount == 0) { EditorGUILayout.HelpBox( "ZERO enabled door entities in the world. Door subscenes are not streamed in, or their entity " + "bakes are stale/empty.", MessageType.Error); } foreach (var d in idsSeen.Where(kv => kv.Value > 1)) { EditorGUILayout.HelpBox( $"Door Id {d.Key} exists on {d.Value} baked doors - all but one lose their collider AND their " + "audio. Stale subscene bake or missed renumbering; renumber with the Setup Validator.", MessageType.Error); } foreach (var row in invisibleRows) { EditorGUILayout.HelpBox(row + " -> invisible to DoorDataBridge (silently gets no collider and no audio).", MessageType.Error); } if (dataBridge != null) { EditorGUILayout.HelpBox(LoadedIdsSummary(dataBridge), MessageType.Info); } } // --------------------------------------------------------------------------------------- // Colliders tab - the pooled BoxCollider chain, fleet-wide. private void DrawCollidersTab() { if (!Application.isPlaying) { EditorGUILayout.HelpBox("Enter play mode and walk near the doors you are testing.", MessageType.Info); return; } var dataBridge = DoorDataBridge.Instance; var pool = BoxColliderPoolManager.Instance; var cam = Camera.main; var world = World.DefaultGameObjectInjectionWorld; if (!Row(dataBridge != null, "DoorDataBridge present", "No DoorDataBridge - the pool has nothing to read.")) return; if (!Row(pool != null, "BoxColliderPoolManager present", "No BoxColliderPoolManager - no pooled colliders exist.")) return; if (!Row(cam != null, "Camera.main found (pool culls by distance to it)", "No enabled MainCamera - the pool never activates any collider.")) return; if (world == null || !world.IsCreated) return; var em = world.EntityManager; var camPos = cam.transform.position; var slots = ReadColliderSlots(pool); var slotsByDoor = slots.Where(s => s.DoorId >= 0) .GroupBy(s => s.DoorId) .ToDictionary(g => g.Key, g => g.ToList()); var cache = dataBridge.GetAllDoorInfo(); if (cache == null || cache.Count == 0) { EditorGUILayout.HelpBox( "The bridge cache is EMPTY - the pool's update returns early without any console message. " + "See the Overview tab's census: either no door entities are loaded, or they are missing " + "components the bridge's query requires.", MessageType.Error); return; } EditorGUILayout.Space(); using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField( $"{cache.Count} door(s) cached | range {pool.cullingDistance:0}m | " + $"{pool.GetActiveColliderCount()}/{pool.maxPoolSize} colliders active (a double door uses 2)", EditorStyles.boldLabel); _collidersOnlyProblems = GUILayout.Toggle(_collidersOnlyProblems, "problems only", GUILayout.Width(110f)); } foreach (var door in cache.OrderBy(d => Vector3.Distance(d.position, camPos))) { var dist = Vector3.Distance(door.position, camPos); var inRange = dist <= pool.cullingDistance; slotsByDoor.TryGetValue(door.doorId, out var doorSlots); var activeSlots = doorSlots?.Count(s => s.ColliderEnabled) ?? 0; var panelsOk = dataBridge.TryGetDoorPanels(door.doorId, out var panels, out var panelCount); var panelText = "no panel buffer"; if (panelsOk) { var withCollider = 0; for (var p = 0; p < panelCount; p++) if (panels[p].hasColliderData) withCollider++; panelText = $"{panelCount} panel(s), {withCollider} with baked collider size"; } // A door root within Minimum Spacing of a nearer door is silently dropped by // RemoveSpatialDuplicates - name the conflicting door instead of guessing. var spatialConflicts = cache .Where(d => d.doorId != door.doorId && Vector3.Distance(d.position, door.position) < pool.minimumSpacing) .Select(d => d.doorId).ToList(); string verdict; var type = MessageType.None; if (!inRange) { verdict = "out of range - no collider expected"; } else if (!panelsOk) { verdict = "IN RANGE but the panel buffer cannot be resolved (no DoubleDoorBuffer on the baked " + "entity, or a dead cached entity) - the pool skips this door with NO console message. " + "Re-bake this door's subscene."; type = MessageType.Error; } else if (activeSlots == 0 && spatialConflicts.Count > 0) { verdict = $"in range, panels fine, but NO slot - door root within {pool.minimumSpacing:0.00}m of " + $"door(s) {string.Join(", ", spatialConflicts)}: the pool keeps only the nearest of " + "spatially overlapping doors. Separate the door roots or lower Minimum Spacing."; type = MessageType.Warning; } else if (activeSlots == 0) { verdict = "in range, panels fine, but NO slot - nearer doors may exhaust the pool " + $"({pool.GetActiveColliderCount()}/{pool.maxPoolSize} in use; raise Max Pool Size to test)."; type = MessageType.Warning; } else { verdict = $"OK - {activeSlots} pooled collider(s) tracking it" + (door.isLocked ? " (door locked: colliders hold position by design)" : ""); } // Alignment: the assigned colliders must sit ON the rendered panels. Compares each // enabled slot's world bounds against the panel subtree's WorldRenderBounds - an // independent source of truth, so it catches stale bakes (child-local centers baked // raw by package < 2.14.0 land mirrored across the hinge) and any future pose bug. var misalignments = new List(); if (panelsOk && doorSlots != null) { foreach (var s in doorSlots) { if (!s.ColliderEnabled || s.Collider == null) continue; if (s.PanelIndex < 0 || s.PanelIndex >= panelCount) continue; if (!TryGetPanelRenderBounds(em, panels[s.PanelIndex].panelEntity, out var meshBounds)) continue; var delta = s.Collider.bounds.center - meshBounds.center; if (delta.magnitude > ColliderAlignmentTolerance) misalignments.Add($"panel {s.PanelIndex} (slot {s.PoolIndex}): collider is {delta.magnitude:0.00}m " + $"from the rendered mesh (delta {delta})"); } } if (misalignments.Count > 0) { verdict = "collider(s) MISALIGNED with the rendered panels - " + string.Join("; ", misalignments) + ". Most likely a stale bake: the panel's BoxCollider lives on a child node and this " + "subscene was baked before the panel-frame fix (package 2.14.0). Re-import (re-bake) " + "the door's subscene; if it persists, run the Setup Validator on the door prefab."; type = MessageType.Error; } if (_collidersOnlyProblems && type == MessageType.None) continue; var marker = door.doorId == _doorId ? "> " : " "; EditorGUILayout.LabelField($"{marker}Door {door.doorId} {dist:0.0}m {panelText}"); if (type == MessageType.None) EditorGUILayout.LabelField(" " + verdict, EditorStyles.miniLabel); else EditorGUILayout.HelpBox(verdict, type); } EditorGUILayout.Space(); EditorGUILayout.LabelField("Pool slots", EditorStyles.boldLabel); foreach (var s in slots) { var label = s.DoorId < 0 ? $"slot {s.PoolIndex:D2}: free" : $"slot {s.PoolIndex:D2}: door {s.DoorId} panel {s.PanelIndex} {(s.ColliderEnabled ? "ENABLED" : "disabled")} at {s.Position}"; EditorGUILayout.LabelField(label, EditorStyles.miniLabel); } } private struct ColliderSlotInfo { public int PoolIndex; public int DoorId; public int PanelIndex; public bool ColliderEnabled; public Vector3 Position; public BoxCollider Collider; } private static List ReadColliderSlots(BoxColliderPoolManager pool) { var result = new List(pool.maxPoolSize); for (var i = 0; i < pool.maxPoolSize; i++) { if (!pool.TryGetSlotInfo(i, out var doorId, out var panelIndex, out var collider)) break; result.Add(new ColliderSlotInfo { PoolIndex = i, DoorId = doorId, PanelIndex = panelIndex, ColliderEnabled = collider != null && collider.enabled, Position = collider != null ? collider.transform.position : Vector3.zero, Collider = collider, }); } return result; } /// /// A pooled collider farther than this from the panel's rendered bounds is reported as /// misaligned. Generous enough to swallow the one-physics-tick MovePosition lag of an /// animating door; the historical failure this catches (child-local center baked raw, /// mirroring the box across the hinge) is a full door width, ~1 m. /// private const float ColliderAlignmentTolerance = 0.25f; /// /// World AABB of everything the panel entity renders (its own WorldRenderBounds plus all /// descendants'). This is ground truth for "where the door visually is", independent of /// the collider math being checked. /// private static bool TryGetPanelRenderBounds(EntityManager em, Entity panel, out Bounds bounds) { var min = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); var max = new Vector3(float.MinValue, float.MinValue, float.MinValue); var found = false; AccumulateRenderBounds(em, panel, ref min, ref max, ref found, 0); bounds = found ? new Bounds((min + max) * 0.5f, max - min) : default; return found; } private static void AccumulateRenderBounds(EntityManager em, Entity entity, ref Vector3 min, ref Vector3 max, ref bool found, int depth) { if (depth > 8 || entity == Entity.Null || !em.Exists(entity)) return; if (em.HasComponent(entity)) { var aabb = em.GetComponentData(entity).Value; min = Vector3.Min(min, (Vector3)aabb.Min); max = Vector3.Max(max, (Vector3)aabb.Max); found = true; } if (!em.HasBuffer(entity)) return; // Copy before recursing: nested GetBuffer calls invalidate the handle. var buffer = em.GetBuffer(entity, true); var children = new List(buffer.Length); for (var i = 0; i < buffer.Length; i++) children.Add(buffer[i].Value); foreach (var child in children) AccumulateRenderBounds(em, child, ref min, ref max, ref found, depth + 1); } // --------------------------------------------------------------------------------------- // Audio tab - the pooled AudioSource chain for one door (formerly the Door Audio Doctor). private void DrawAudioTab() { if (!Application.isPlaying) { EditorGUILayout.HelpBox( "Live checks need play mode - the pool, the bridges and the door entities only exist there. " + "Static asset checks live in Tools > Jeanf > AutomaticDoorSystem > Setup Validator.", MessageType.Info); DrawAuthoringLocator(); return; } var dataBridge = DoorDataBridge.Instance; var pool = AudioSourcePoolManager.Instance; var audioBridge = DoorAudioBridge.Instance; if (!Row(dataBridge != null, "DoorDataBridge present in the scene", "No DoorDataBridge instance - nothing reads the door entities. Run the Setup Validator.")) return; if (!Row(pool != null, "AudioSourcePoolManager present in the scene", "No AudioSourcePoolManager - no pooled AudioSources exist at all.")) return; if (!Row(audioBridge != null, "DoorAudioBridge present in the scene", "No DoorAudioBridge - audio events are never routed to an AudioSource.")) return; // --- door entity --- bool found = dataBridge.TryGetDoorInfo(_doorId, out var info); if (!Row(found, $"Door entity with id {_doorId} found", $"No baked door entity has id {_doorId}. Either the subscene is not entity-loaded, the baked " + "id differs from the authoring (stale bake - re-bake the subscene), or baking skipped the " + "door (missing DoorConfig).")) { CountLiveEntities(_doorId, out int liveEnabled, out int liveDisabled); if (liveEnabled >= 2) { EditorGUILayout.HelpBox( $"{liveEnabled} live door entities share id {_doorId}! The bridge's id-keyed lookup can " + "only serve one of them - the others get no colliders and no audio. Find and renumber " + "the duplicates (Setup Validator > Subscene bake lists which subscenes author this id).", MessageType.Error); } else if (liveEnabled == 1) { EditorGUILayout.HelpBox( $"A live entity with id {_doorId} DOES exist - the bridge's cached handle was stale " + "(its subscene streamed out and back in). The bridge self-heals on lookup as of 2.12.0; " + "if this message persists, report it.", MessageType.Warning); } else if (liveDisabled > 0) { EditorGUILayout.HelpBox( $"Door {_doorId} IS baked - but DISABLED: its authoring GameObject is inactive, so the " + "entity carries the Disabled tag and is invisible to detection, animation and audio. " + "If this door should work, activate its GameObject in the subscene and save.", MessageType.Warning); } EditorGUILayout.HelpBox(LoadedIdsSummary(dataBridge), MessageType.Info); DrawAuthoringLocator(); return; } DrawDoorState(info); // --- audio config --- if (!Row(info.audioConfig != null, $"Audio config baked: {(info.audioConfig != null ? info.audioConfig.name : "none")}", "The entity carries no DoorAudioConfiguration - the DoorAuthoring had none assigned when the " + "subscene was baked. Assign one and re-bake; the pool skips config-less doors entirely.")) return; var configIssues = DoorAudioConfigurationValidator.Validate(info.audioConfig); int configErrors = configIssues.Count(i => i.IsError); Row(configErrors == 0, $"Audio config valid ({configIssues.Count} issue(s), {configErrors} error(s))", "The config has authoring errors - open it and check the console, or run the Setup Validator."); // --- distance / culling --- var cam = Camera.main; if (Row(cam != null, "Camera.main found (pool culls by distance to it)", "No enabled MainCamera - the pool never activates any AudioSource.") && cam != null) { float distance = Vector3.Distance(cam.transform.position, info.audioPosition); Row(distance <= pool.cullingDistance, $"Within pool culling range ({distance:0.0}m of {pool.cullingDistance:0.0}m)", "The door's audio position is beyond the pool's culling distance, so it holds no AudioSource " + "until the player gets closer."); } // --- id / spacing conflicts --- var allDoors = dataBridge.GetAllDoorInfo(); int sameId = allDoors.Count(d => d.doorId == _doorId); Row(sameId <= 1, $"Door id unique among loaded doors ({sameId} door(s) use id {_doorId})", "Several loaded doors share this id - pool slots and audio registrations are keyed by id, so all " + "but one of them stay silent. Renumber with the Setup Validator."); var tooClose = allDoors .Where(d => d.doorId != _doorId && d.audioConfig != null && Vector3.Distance(d.audioPosition, info.audioPosition) < pool.minimumSourceSpacing) .ToList(); Row(tooClose.Count == 0, tooClose.Count == 0 ? $"No other door's audio position within {pool.minimumSourceSpacing:0.00}m" : $"Audio position conflicts with door(s) {string.Join(", ", tooClose.Select(d => d.doorId))} " + $"(closer than {pool.minimumSourceSpacing:0.00}m)", "The pool drops all but the nearest of spatially overlapping doors, so this door may never get an " + "AudioSource while the conflicting door is in range. Give one of them an Audio Anchor to separate " + "them, or lower Minimum Source Spacing on the pool."); // --- pool slot --- bool hasSlot = pool.TryGetPoolSlot(_doorId, out int slot, out var slotSource, out bool playingFlag); string parkedInfo = ""; if (hasSlot && slotSource != null) { // The source is parked ON the door when assigned - a large distance here means it // is actually serving a different position than expected. float parkedDistance = Vector3.Distance(slotSource.transform.position, info.audioPosition); parkedInfo = $", parked {parkedDistance:0.00}m from this door's audio position"; } Row(hasSlot, hasSlot ? $"Pool slot assigned: slot {slot} -> '{slotSource?.name}'{parkedInfo}{(playingFlag ? " (mid-playback)" : "")}" : "No pool slot assigned to this door", "The pool has not parked an AudioSource on this door. Causes: out of culling range, an id/spacing " + "conflict above, or all slots taken by closer doors (pool size " + $"{pool.maxPoolSize}, {pool.GetActiveAudioSourceCount()} in use)."); if (hasSlot && slotSource != null) { bool audible = !slotSource.mute && slotSource.volume > 0.01f && slotSource.isActiveAndEnabled; Row(audible, $"AudioSource audible (volume {slotSource.volume:0.00}, mute {slotSource.mute}, " + $"enabled {slotSource.isActiveAndEnabled}, playing {slotSource.isPlaying})", "The pooled source is muted, at volume 0 or disabled - it may still be fading in, or was " + "deactivated by the pool."); // Reflections are distance-culled by the pool (Steam Audio caps reflection sources // game-wide), so "off" beyond the radius is the healthy state, not a fault. var steamSource = slotSource.GetComponent(); if (steamSource != null && cam != null) { float reflDistance = Vector3.Distance(cam.transform.position, info.audioPosition); bool inReflectionsRange = reflDistance <= pool.reflectionsCullingDistance; Row(steamSource.reflections == inReflectionsRange || !steamSource.reflections, steamSource.reflections ? $"Steam Audio reflections ON ({reflDistance:0.0}m of {pool.reflectionsCullingDistance:0.0}m reflections range)" : $"Steam Audio reflections culled ({reflDistance:0.0}m > {pool.reflectionsCullingDistance:0.0}m - direct sound only" + ", or disabled in the audio config)", "Reflections are ON for a door beyond the reflections culling distance - the pool's " + $"distance check (every {pool.distanceCheckInterval:0.0}s) should cull it; if this persists, report it."); } } // --- output stage: listener, routing, spatial processing --- if (hasSlot && slotSource != null) { DrawOutputStage(slotSource); } // --- bridge registration --- bool registered = audioBridge.TryGetRegistration(_doorId, out var registeredSource, out var registeredConfig); Row(registered, registered ? $"DoorAudioBridge registration: '{registeredSource.name}' with config '{registeredConfig?.name}'" : "DoorAudioBridge has no AudioSource registered for this door", "Audio events for this id are dropped at the bridge. The pool registers a source when it assigns " + "a slot - fix the pool rows above first."); if (registered && hasSlot && registeredSource != slotSource) { EditorGUILayout.HelpBox( "The bridge's registered AudioSource differs from the pool's slot source - a stale " + "registration. Report this; it should not happen.", MessageType.Error); } DrawActions(); } // Original routing of the source while a debug bypass is active, so it can be restored. // Two independent toggles: mixer-only and spatial-only, so the silent layer can be // pinpointed instead of lumping both into one switch. private AudioSource _bypassedSource; private UnityEngine.Audio.AudioMixerGroup _bypassedGroup; private float _bypassedSpatialBlend; private bool _bypassedSpatialize; private bool _mixerBypassOn; private bool _spatialBypassOn; /// /// The stage the chain checks cannot see: everything between a playing AudioSource and the /// ear. Listener state (paused / volume 0 silences the whole game), where the source is /// routed, and whether spatial processing is involved - plus a bypass toggle that pulls the /// source out of the mixer and into 2D. Bypass audible + normal path silent = routing or /// 3D processing; both silent = listener or global audio state. /// private void DrawOutputStage(AudioSource source) { var listeners = FindObjectsByType() .Where(l => l.isActiveAndEnabled).ToList(); if (Row(listeners.Count == 1, listeners.Count == 1 ? $"One active AudioListener, {Vector3.Distance(listeners[0].transform.position, source.transform.position):0.0}m from the source" : $"{listeners.Count} active AudioListener(s) in the scene", listeners.Count == 0 ? "No active AudioListener - nothing in the game is audible." : "Multiple listeners - Unity uses an arbitrary one, so 3D audio positions are unreliable.") && listeners.Count == 1) { Row(!AudioListener.pause && AudioListener.volume > 0.01f, $"Listener state ok (pause {AudioListener.pause}, global volume {AudioListener.volume:0.00})", "AudioListener.pause / AudioListener.volume is silencing the ENTIRE game - some system " + "(pause menu, scenario) set it and did not restore it."); } // A spatialized voice without a SteamAudioSource has no component pushing per-source // parameters into the Steam Audio Spatializer - the classic result is a source that // "plays" at full volume yet renders silence. if (source.spatialize) { bool hasSteamSource = source.GetComponent() != null; Row(hasSteamSource, "Spatialize is ON and a SteamAudioSource feeds the spatializer", "Spatialize is ON but the source has NO SteamAudioSource component - the Steam Audio " + "Spatializer gets no per-source parameters and typically renders SILENCE. Add a " + "SteamAudioSource to the pooled AudioSource prefab, or uncheck Spatialize on it to fall " + "back to plain Unity 3D panning."); } string groupLabel = source.outputAudioMixerGroup != null ? $"mixer group '{source.outputAudioMixerGroup.name}' ({source.outputAudioMixerGroup.audioMixer.name})" : "no mixer group (direct out)"; string spatializer = string.IsNullOrEmpty(AudioSettings.GetSpatializerPluginName()) ? "none" : AudioSettings.GetSpatializerPluginName(); EditorGUILayout.HelpBox( $"Output: {groupLabel} | spatialBlend {source.spatialBlend:0.00} | " + $"spatialize {source.spatialize} (plugin: {spatializer})\n" + "Snapshot transitions or an attenuated parent group can silence this route even though the " + "source itself is playing at full volume.", MessageType.None); // The mixer-wide mute: MixerManager parks the whole mixer on the Muted snapshot // (Master at -80 dB) during loads and region changes. If the unmute signal is missed, // EVERY mixer-routed sound is silent while every per-source check stays green - the // exact "all green but silent" case. var mixerManager = FindAnyObjectByType(); if (mixerManager != null) { Row(!mixerManager.IsCurrentlyMuted, "MixerManager: mixer is unmuted", "MixerManager reports the mixer is MUTED (snapshot with Master at -80 dB) - every sound " + "routed through the mixer is silent, doors included. The unmute normally fires after " + "region loading completes; if this persists, the load-complete signal was missed " + "(audiosystems >= 0.5.1 unmutes on a fallback timeout)."); } var steamManager = FindAnyObjectByType(); Row(steamManager != null || !source.spatialize, steamManager != null ? "SteamAudioManager present (Steam Audio simulation running)" : "No SteamAudioManager needed (source not spatialized)", "The source is spatialized through Steam Audio but no SteamAudioManager exists - the " + "simulation (occlusion, HRTF) never runs and spatialized voices can render silence."); // A source that changed hands invalidates any stored bypass state. if (_bypassedSource != source && _bypassedSource != null) { _mixerBypassOn = false; _spatialBypassOn = false; _bypassedSource = null; } using (new EditorGUILayout.HorizontalScope()) { bool newMixer = GUILayout.Toggle(_mixerBypassOn, "Bypass mixer group", "Button"); bool newSpatial = GUILayout.Toggle(_spatialBypassOn, "Bypass 3D + spatializer", "Button"); if (newMixer != _mixerBypassOn) { if (newMixer) { CaptureOriginals(source); source.outputAudioMixerGroup = null; } else { source.outputAudioMixerGroup = _bypassedGroup; } _mixerBypassOn = newMixer; ReleaseIfRestored(source); } if (newSpatial != _spatialBypassOn) { if (newSpatial) { CaptureOriginals(source); source.spatialBlend = 0f; source.spatialize = false; } else { source.spatialBlend = _bypassedSpatialBlend; source.spatialize = _bypassedSpatialize; } _spatialBypassOn = newSpatial; ReleaseIfRestored(source); } } if (_mixerBypassOn || _spatialBypassOn) { EditorGUILayout.HelpBox( (_mixerBypassOn && _spatialBypassOn ? "Both bypasses active - the source plays 2D straight to the output." : _mixerBypassOn ? "Mixer bypass active - 3D/spatializer processing still applies." : "Spatial bypass active (2D) - the mixer route still applies.") + " Fire a test event and toggle one at a time: the bypass that brings the sound back names " + "the layer that eats it. Spatializer-side silence usually means Steam Audio occlusion - the " + "source parks at the trigger-volume centre, INSIDE the doorway geometry, when the door has " + "no Audio Anchor; assign an anchor just outside the door plane or disable occlusion in the " + "DoorAudioConfiguration.", MessageType.Warning); } } private void CaptureOriginals(AudioSource source) { if (_bypassedSource == source) return; _bypassedSource = source; _bypassedGroup = source.outputAudioMixerGroup; _bypassedSpatialBlend = source.spatialBlend; _bypassedSpatialize = source.spatialize; } private void ReleaseIfRestored(AudioSource source) { if (!_mixerBypassOn && !_spatialBypassOn && _bypassedSource == source) { _bypassedSource = null; } } private void DrawDoorState(DoorDataBridge.DoorInfo info) { var world = World.DefaultGameObjectInjectionWorld; if (world == null || !world.EntityManager.HasComponent(info.rootEntity)) return; var state = world.EntityManager.GetComponentData(info.rootEntity); EditorGUILayout.HelpBox( $"Live state: {state.CurrentState} (was {state.PreviousState}) | timer {state.StateTimer:0.00}s | " + $"entities in trigger: {state.EntitiesInTrigger} | locked: {(state.IsLocked == 1 ? "yes" : "no")}\n" + "No sound on approach but a working test event means the problem is detection/state, not audio.", MessageType.None); } private void DrawActions() { EditorGUILayout.Space(); EditorGUILayout.LabelField("Actions (play mode)", EditorStyles.boldLabel); var source = FindAssignedSource(_doorId); using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(source == null)) { if (GUILayout.Button("Select AudioSource")) { Selection.activeGameObject = source.gameObject; EditorGUIUtility.PingObject(source.gameObject); } } if (GUILayout.Button("Fire Open event")) { if (!TryFireTestEvent(_doorId, AudioEventType.Open, out var error)) Debug.LogError(error); } if (GUILayout.Button("Fire Close event")) { if (!TryFireTestEvent(_doorId, AudioEventType.Close, out var error)) Debug.LogError(error); } } EditorGUILayout.LabelField( "Test events go through the real DoorAudioBridge path (event entity -> registered source -> config). " + "Assigned pooled sources are named 'PooledAudioSource_NN [door id]' in the hierarchy.", EditorStyles.wordWrappedMiniLabel); } // --------------------------------------------------------------------------------------- // Authoring locator: where does door N live, and how do I see its config? /// /// Finds the DoorAuthoring for the current id in the open scenes (select it directly), or /// - via a button-triggered text scan - the closed subscene that contains it, with a /// one-click open. This is how "the door exists in some subscene I can't see" turns into /// an inspectable object. /// private void DrawAuthoringLocator() { EditorGUILayout.Space(); EditorGUILayout.LabelField("Find the door's authoring", EditorStyles.boldLabel); var authorings = FindObjectsByType(FindObjectsInactive.Include); var match = authorings.FirstOrDefault(a => a.doorId == _doorId); if (match != null) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField($"'{match.gameObject.name}' in '{match.gameObject.scene.name}'"); if (GUILayout.Button("Select", GUILayout.Width(80f))) { Selection.activeGameObject = match.gameObject; EditorGUIUtility.PingObject(match.gameObject); } } return; } EditorGUILayout.LabelField( $"No DoorAuthoring with id {_doorId} in the open scenes. It may live in a closed subscene:", EditorStyles.wordWrappedMiniLabel); if (GUILayout.Button($"Scan closed subscenes for door {_doorId}")) { ScanClosedSubScenes(); } if (_scannedDoorId != _doorId || _subScenesWithDoor == null) return; if (_subScenesWithDoor.Count == 0) { EditorGUILayout.HelpBox( $"No closed subscene's file mentions door id {_doorId}. The id may only exist as a stale " + "baked value, or the door is in a scene that is not currently in the hierarchy.", MessageType.Warning); return; } foreach (var (sceneName, subScene) in _subScenesWithDoor) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField($"Subscene '{sceneName}' mentions door {_doorId}"); if (subScene != null && GUILayout.Button("Open subscene", GUILayout.Width(110f))) { Unity.Scenes.Editor.SubSceneUtility.EditScene(subScene); } } } } /// /// Text-scans the scene files behind closed SubScenes for this door id - both the /// prefab-override form ("propertyPath: doorId" + "value: N") and the plain serialized /// form ("doorId: N"). A text scan is approximate but instant compared to opening scenes. /// private void ScanClosedSubScenes() { _scannedDoorId = _doorId; _subScenesWithDoor = new List<(string, SubScene)>(); var overridePattern = new Regex($@"propertyPath: doorId\s*\r?\n\s*value: {_doorId}\r?$", RegexOptions.Multiline); var plainPattern = new Regex($@"doorId: {_doorId}\r?$", RegexOptions.Multiline); foreach (var subScene in FindObjectsByType(FindObjectsInactive.Include)) { if (subScene.SceneAsset == null || subScene.IsLoaded) continue; var assetPath = AssetDatabase.GetAssetPath(subScene.SceneAsset); if (string.IsNullOrEmpty(assetPath)) continue; string fullPath = Path.GetFullPath(assetPath); if (!File.Exists(fullPath)) continue; var text = File.ReadAllText(fullPath); if (overridePattern.IsMatch(text) || plainPattern.IsMatch(text)) { _subScenesWithDoor.Add((subScene.SceneName, subScene)); } } } // --------------------------------------------------------------------------------------- // Static helpers - also used by the DoorAuthoring inspector's audio section. /// The pooled/registered AudioSource currently serving a door, or null. public static AudioSource FindAssignedSource(int doorId) { if (AudioSourcePoolManager.Instance != null && AudioSourcePoolManager.Instance.TryGetPoolSlot(doorId, out _, out var slotSource, out _) && slotSource != null) { return slotSource; } if (DoorAudioBridge.Instance != null && DoorAudioBridge.Instance.TryGetRegistration(doorId, out var registered, out _)) { return registered; } return null; } /// /// Injects a real DoorAudioEventComponent entity, exactly like DoorAudioSystem does when a /// door changes state - so the whole bridge/pool/config chain runs without touching the door. /// public static bool TryFireTestEvent(int doorId, AudioEventType eventType, out string error) { error = null; if (!Application.isPlaying) { error = "[DoorDoctor] Test events only work in play mode."; return false; } var world = World.DefaultGameObjectInjectionWorld; if (world == null) { error = "[DoorDoctor] No default ECS world - cannot fire a test event."; return false; } Vector3 position = Vector3.zero; if (DoorDataBridge.Instance != null && DoorDataBridge.Instance.TryGetDoorInfo(doorId, out var info)) { position = info.audioPosition; } var entityManager = world.EntityManager; var eventEntity = entityManager.CreateEntity(); entityManager.AddComponentData(eventEntity, new DoorAudioEventComponent { DoorId = doorId, EventType = eventType, SoundId = 0, ClipName = default, Position = position }); Debug.Log($"[DoorDoctor] Fired test {eventType} event for door {doorId}."); return true; } /// One-paragraph live status of a door's audio chain, for inspector embedding. public static string LiveStatusSummary(int doorId) { var dataBridge = DoorDataBridge.Instance; var pool = AudioSourcePoolManager.Instance; var audioBridge = DoorAudioBridge.Instance; if (dataBridge == null || pool == null || audioBridge == null) { return "Door audio systems not running (DoorDataBridge / AudioSourcePoolManager / DoorAudioBridge " + "missing) - run the Setup Validator."; } if (!dataBridge.TryGetDoorInfo(doorId, out var info)) { SubSceneDoorScanner.BakedDoorIds(out _, out var disabledIds); if (disabledIds.Contains(doorId)) { return $"Door {doorId} is baked but DISABLED (inactive GameObject) - activate it in the " + "subscene if it should work."; } return $"No baked door entity with id {doorId} - stale bake or subscene not entity-loaded. " + LoadedIdsSummary(dataBridge); } string config = info.audioConfig != null ? $"config '{info.audioConfig.name}'" : "NO audio config baked"; string slot = pool.TryGetPoolSlot(doorId, out int index, out var source, out _) ? $"pool slot {index} ('{source?.name}')" : "no pool slot assigned"; string registration = audioBridge.TryGetRegistration(doorId, out _, out _) ? "bridge registered" : "not registered on the bridge"; return $"Entity found, {config}, {slot}, {registration}."; } /// What door ids ARE loaded - the fastest way to spot a stale-bake id mismatch. public static string LoadedIdsSummary(DoorDataBridge dataBridge, int max = 60) { var ids = dataBridge.GetAllDoorInfo().Select(d => d.doorId).Distinct().OrderBy(i => i).ToList(); if (ids.Count == 0) { return "No door entities are loaded at all - the door subscenes have not been entity-loaded yet."; } string list = string.Join(", ", ids.Take(max)); if (ids.Count > max) list += $", ... (+{ids.Count - max} more)"; return $"{ids.Count} door id(s) currently loaded: {list}"; } /// Live entity counts for one door id, straight from the world - no caches. private static void CountLiveEntities(int doorId, out int enabled, out int disabled) { enabled = 0; disabled = 0; var world = World.DefaultGameObjectInjectionWorld; if (world == null || !world.IsCreated) return; using (var query = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly())) using (var doors = query.ToComponentDataArray(Allocator.Temp)) { foreach (var door in doors) { if (door.DoorId == doorId) enabled++; } } var allDesc = new EntityQueryDesc { All = new[] { ComponentType.ReadOnly() }, Options = EntityQueryOptions.IncludeDisabledEntities }; using (var query = world.EntityManager.CreateEntityQuery(allDesc)) using (var doors = query.ToComponentDataArray(Allocator.Temp)) { int total = 0; foreach (var door in doors) { if (door.DoorId == doorId) total++; } disabled = total - enabled; } } /// Draws one check row; returns the check's result so callers can early-out. private static bool Row(bool ok, string okMessage, string failHint) { if (ok) { EditorGUILayout.HelpBox(okMessage, MessageType.None); } else { EditorGUILayout.HelpBox($"{okMessage}\n\n{failHint}", MessageType.Error); } return ok; } } } #endif