Skip to content

Instantly share code, notes, and snippets.

@adammyhre
Created March 21, 2026 07:30
Show Gist options
  • Select an option

  • Save adammyhre/506971715750b03252cdf720999a5f2d to your computer and use it in GitHub Desktop.

Select an option

Save adammyhre/506971715750b03252cdf720999a5f2d to your computer and use it in GitHub Desktop.
Unity Editor Tool: Finding What Marks Scenes Dirty
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Debug = UnityEngine.Debug;
/// <summary>
/// Helps track why the active scene becomes dirty (e.g. after exiting Play Mode).
/// Enable logging in <b>Tools / Scene Dirty Diagnostics</b>.
/// </summary>
public static class SceneDirtyDiagnostics {
const string PrefKeyEnabled = "SceneDirtyDiagnostics.Enabled";
const string PrefKeyLogStack = "SceneDirtyDiagnostics.LogStack";
const string PrefKeyScanOnExitPlay = "SceneDirtyDiagnostics.ScanOnExitPlay";
static bool Subscribed;
[MenuItem("Tools/Scene Dirty Diagnostics/Window", priority = 100)]
static void OpenWindow() {
EditorWindow.GetWindow<SceneDirtyDiagnosticsWindow>(false, "Scene dirty", true);
}
[InitializeOnLoadMethod]
static void Bootstrap() {
RefreshSubscriptions();
}
internal static void RefreshSubscriptions() {
var want = EditorPrefs.GetBool(PrefKeyEnabled, false);
if (want == Subscribed) return;
if (want) {
EditorSceneManager.sceneDirtied += OnSceneDirtied;
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
Subscribed = true;
} else {
EditorSceneManager.sceneDirtied -= OnSceneDirtied;
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
Subscribed = false;
}
}
static void OnPlayModeStateChanged(PlayModeStateChange state) {
if (!EditorPrefs.GetBool(PrefKeyEnabled, false)) return;
if (state != PlayModeStateChange.EnteredEditMode) return;
if (!EditorPrefs.GetBool(PrefKeyScanOnExitPlay, true)) return;
// Let Unity finish restoring the scene before scanning.
EditorApplication.delayCall += () => {
EditorApplication.delayCall += () => {
EditorApplication.delayCall += RunScanAfterPlayMode;
};
};
}
static void RunScanAfterPlayMode() {
var scene = EditorSceneManager.GetActiveScene();
if (!scene.IsValid() || !scene.isLoaded) return;
if (!scene.isDirty) {
Debug.Log("[SceneDirtyDiag] Entered Edit Mode: active scene is <b>not</b> dirty.");
return;
}
Debug.LogWarning($"[SceneDirtyDiag] Entered Edit Mode: active scene <b>{scene.name}</b> is <b>DIRTY</b>. Running component scan…");
LogSerializedObjectScan(scene, maxLog: 80);
}
static void OnSceneDirtied(Scene scene) {
if (!EditorPrefs.GetBool(PrefKeyEnabled, false)) return;
var sb = new StringBuilder();
sb.Append("[SceneDirtyDiag] <b>sceneDirtied</b>: ");
sb.Append(scene.name);
sb.Append(" path: ");
sb.Append(string.IsNullOrEmpty(scene.path) ? "(unsaved)" : scene.path);
Debug.Log(sb.ToString());
if (EditorPrefs.GetBool(PrefKeyLogStack, true)) {
// Managed code that calls Undo / EditorUtility.SetDirty often appears here.
Debug.Log("[SceneDirtyDiag] Stack trace (managed calls only):\n" + new StackTrace(2, true));
}
}
/// <summary>
/// Menu: scan current scene for SerializedObject.hasModifiedProperties (heuristic for unsaved inspector state).
/// </summary>
[MenuItem("Tools/Scene Dirty Diagnostics/Scan active scene now", priority = 101)]
public static void ScanActiveSceneMenu() {
var scene = EditorSceneManager.GetActiveScene();
if (!scene.IsValid() || !scene.isLoaded) {
Debug.LogWarning("[SceneDirtyDiag] No valid active scene.");
return;
}
LogSerializedObjectScan(scene, maxLog: 200);
}
/// <summary>
/// Heuristic: components whose SerializedObject reports modified properties after Update().
/// Not 100% (Unity internals can dirty the scene without this), but catches many scripts.
/// </summary>
public static void LogSerializedObjectScan(Scene scene, int maxLog) {
var logged = 0;
var roots = scene.GetRootGameObjects();
var seen = new HashSet<long>();
foreach (var root in roots) {
var components = root.GetComponentsInChildren<Component>(true);
foreach (var c in components) {
if (c == null) continue;
long eid = c.GetEntityId();
if (!seen.Add(eid)) continue;
SerializedObject so = null;
try {
so = new SerializedObject(c);
so.Update();
if (!so.hasModifiedProperties) continue;
} catch {
continue;
}
if (logged < maxLog) {
Debug.LogWarning(
"[SceneDirtyDiag] <b>hasModifiedProperties</b>: " + BuildHierarchyPath(c.gameObject) +
" → <b>" + c.GetType().Name + "</b>",
c);
}
logged++;
}
}
if (logged == 0)
Debug.Log("[SceneDirtyDiag] Scan: no components reported SerializedObject.hasModifiedProperties (heuristic empty).");
else if (logged > maxLog)
Debug.LogWarning($"[SceneDirtyDiag] Scan: {logged} components matched; only first {maxLog} were logged.");
else
Debug.Log($"[SceneDirtyDiag] Scan complete: <b>{logged}</b> component(s) with hasModifiedProperties.");
}
static string BuildHierarchyPath(GameObject go) {
if (go == null) return "(null)";
var parts = new List<string>();
var t = go.transform;
while (t != null) {
parts.Add(t.name);
t = t.parent;
}
parts.Reverse();
return string.Join("/", parts);
}
sealed class SceneDirtyDiagnosticsWindow : EditorWindow {
void OnEnable() => SceneDirtyDiagnostics.RefreshSubscriptions();
void OnGUI() {
EditorGUILayout.HelpBox(
"When enabled, logs when Unity raises sceneDirtied (with optional stack trace) and optionally " +
"scans components after exiting Play Mode using SerializedObject.hasModifiedProperties " +
"(heuristic — not every dirty scene will show matches).",
MessageType.Info);
EditorGUI.BeginChangeCheck();
var en = EditorGUILayout.ToggleLeft("Enable scene dirty logging", EditorPrefs.GetBool(PrefKeyEnabled, false));
var st = EditorGUILayout.ToggleLeft("Log stack trace on sceneDirtied", EditorPrefs.GetBool(PrefKeyLogStack, true));
var sc = EditorGUILayout.ToggleLeft("Scan after exiting Play Mode (EnteredEditMode)", EditorPrefs.GetBool(PrefKeyScanOnExitPlay, true));
if (EditorGUI.EndChangeCheck()) {
EditorPrefs.SetBool(PrefKeyEnabled, en);
EditorPrefs.SetBool(PrefKeyLogStack, st);
EditorPrefs.SetBool(PrefKeyScanOnExitPlay, sc);
SceneDirtyDiagnostics.RefreshSubscriptions();
}
GUILayout.Space(8);
using (new EditorGUI.DisabledScope(!EditorSceneManager.GetActiveScene().isLoaded)) {
if (GUILayout.Button("Scan active scene now")) {
SceneDirtyDiagnostics.ScanActiveSceneMenu();
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment