diff --git a/Assets/WL/SkillTest/WLSkillTestHarness.cs b/Assets/WL/SkillTest/WLSkillTestHarness.cs
index 40f73224b..eea9402d9 100644
--- a/Assets/WL/SkillTest/WLSkillTestHarness.cs
+++ b/Assets/WL/SkillTest/WLSkillTestHarness.cs
@@ -123,7 +123,7 @@ namespace WL.SkillTest
Lap("awake");
}
- // 단계마다 try — 예외는 상태 파일(stage=<단계>-fail · 예외 종류 · 메시지 · 호출 위치 4줄)에 남긴다. 코루틴이 조용히 죽으면 콘솔 스팸에 묻혀 찾기 어려웠다(#947 1차 Play · PC.Set NRE 가 Freeze 1000건 아래)
+ // 단계마다 try — 예외는 상태 파일(stage=<단계>-fail · 예외 종류 · 메시지 · 호출 위치 8줄)에 남긴다. 코루틴이 조용히 죽으면 콘솔 스팸에 묻혀 찾기 어려웠다(#947 1차 Play · PC.Set NRE 가 Freeze 1000건 아래)
IEnumerator Start()
{
var cfg = Cfg;
@@ -195,11 +195,11 @@ namespace WL.SkillTest
catch (System.Exception ex) { Fail(stage + "-fail", ex.GetType().Name + ": " + ex.Message + " @ " + Where(ex)); return false; }
}
- /// 예외 호출 위치 4줄 — 인자 목록·IL 오프셋·절대 경로를 지워 「at 클래스.메서드 파일.cs:줄」 만 남긴다.
+ /// 예외 호출 위치 8줄 — 인자 목록·IL 오프셋·절대 경로를 지워 「at 클래스.메서드 파일.cs:줄」 만 남긴다(#957: 4줄이면 Unity 내부 3줄에 묻혀 호출자가 안 보였다).
static string Where(System.Exception ex)
{
var lines = (ex.StackTrace ?? "").Split('\n'); var sb = new System.Text.StringBuilder();
- for (int i = 0; i < lines.Length && i < 4; i++)
+ for (int i = 0; i < lines.Length && i < 8; i++)
sb.Append(System.Text.RegularExpressions.Regex.Replace(lines[i].Trim(), @"\s*\(.*?\)\s*\[0x[0-9a-fA-F]+\]\s*in\s*.*[\\/]", " ")).Append(" | ");
return sb.ToString();
}
diff --git a/Assets/WL/Start/WLStaleStaticReset.cs b/Assets/WL/Start/WLStaleStaticReset.cs
new file mode 100644
index 000000000..0539a84cb
--- /dev/null
+++ b/Assets/WL/Start/WLStaleStaticReset.cs
@@ -0,0 +1,112 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// WLStaleStaticReset.cs — Play 시작마다 「지난 Play 에서 파괴된 객체」를 가리키는 static 참조를 비운다 (PD #957 · 2026-10-21)
+//
+// PD 원문(#957): 「스킬 테스트맵을 실행할 때 에러 때문에 진행이 안되는거 같아 왜 그런지 실측해보고 수정해줘.」
+// ■ 실측(2026-10-21): 스킬 테스트 씬 Play → stage=pc-fail · MissingReferenceException 'MoneyForm' destroyed @ MoneyForm.Set
+// ← TopMoney.Set ← ServerData 아이템 지급(`if (TopMoney.isIns) TopMoney.Ins.Set()`) = PC 생성(스타터 킷 지급) 단계에서 멈춤.
+// TestMap Play → LoadingUI.End_Loading(go 파괴) ← MobControlMgrList.Start(`if (LoadingUI.isIns)`).
+// 편집 모드 실측: TopMoney · LoadingUI · NewGameUI 의 isIns = True 인데 Ins = 파괴됨 · MoneyForm 목록 2개 모두 파괴됨.
+// ■ 원인: #947-2 가 Enter Play Mode 의 도메인 리로드를 껐다(EditorSettings · 테스트 씬 빠른 재시작). 원본 싱글턴(MonoBehaviourSingletonTemplate ·
+// LoadingUI 등)은 Awake 에서 static Ins · isIns 를 세우고 지우지 않는다 → 앞 Play(다른 씬)의 객체가 Play 종료 때 파괴돼도 static 이 남아
+// 다음 Play 의 `if (X.isIns) X.Ins…` 가 파괴된 객체를 건드린다(도메인 리로드가 켜져 있으면 static 이 초기화돼 생기지 않던 오류).
+// ■ 처리: Play 시작(SubsystemRegistration = 씬 Awake 전)에 Assembly-CSharp 형식(닫힌 제네릭 부모 포함)의 static 필드 중
+// ① UnityEngine.Object 형인데 값이 파괴된 것 → null ② List 의 파괴된 항목 제거
+// ③ 같은 형식의 static bool isIns 는 그 형식의 Ins 가 비었으면 false.
+// 살아 있는 객체(편집 모드 객체 · 에셋)는 건드리지 않는다 · 에디터 전용(빌드는 매번 새로 시작해 해당 없음) · readonly/const 제외 ·
+// 실패는 조용히 건너뜀(LogError 금지 — 원본 ErrorLogHook 팝업) · 결과 = LastLog(정리한 것이 있으면 콘솔 Log 1줄).
+// ─────────────────────────────────────────────────────────────────────────────
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Reflection;
+using UnityEngine;
+
+namespace WL.Start
+{
+ public static class WLStaleStaticReset
+ {
+ public static int Cleared, ListItems, Flags, TypesScanned;
+ public static float LastMs;
+ public static string LastLog = "";
+
+#if UNITY_EDITOR
+ const BindingFlags SF = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ static void Run()
+ {
+ Cleared = ListItems = Flags = TypesScanned = 0;
+ string err = "";
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var names = new List();
+ try
+ {
+ var seen = new HashSet();
+ foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (asm.GetName().Name != "Assembly-CSharp") continue;
+ Type[] types;
+ try { types = asm.GetTypes(); } catch (ReflectionTypeLoadException e) { types = e.Types; }
+ foreach (var t0 in types)
+ for (var t = t0; t != null && t.Assembly == asm; t = t.BaseType) // 닫힌 제네릭 부모(MonoBehaviourSingletonTemplate)의 static 은 형식마다 따로 있다
+ {
+ if (t.ContainsGenericParameters || !seen.Add(t)) break; // 이미 본 형식이면 그 부모 사슬도 이미 봤다
+ Clean(t, names);
+ }
+ }
+ }
+ catch (Exception ex) { err = " · 실패 " + ex.Message; }
+ LastMs = (float)sw.Elapsed.TotalMilliseconds;
+ LastLog = "형식 " + TypesScanned + " · 참조 " + Cleared + " · 목록 항목 " + ListItems + " · isIns " + Flags + " · " + LastMs.ToString("F0") + " ms"
+ + (names.Count > 0 ? " · " + string.Join(", ", names.GetRange(0, Mathf.Min(12, names.Count))) + (names.Count > 12 ? " 외 " + (names.Count - 12) : "") : "") + err;
+ if (Cleared + ListItems + Flags > 0) Debug.Log("[WL957] 지난 Play 에서 파괴된 static 참조 정리 — " + LastLog);
+ }
+
+ static void Clean(Type t, List names)
+ {
+ FieldInfo[] fs;
+ try { fs = t.GetFields(SF); } catch { return; }
+ if (fs.Length == 0) return;
+ TypesScanned++;
+ FieldInfo isIns = null; bool hasIns = false, insGone = false;
+ foreach (var f in fs)
+ {
+ if (f.IsLiteral || f.IsInitOnly) continue;
+ var ft = f.FieldType;
+ try
+ {
+ if (ft == typeof(bool)) { if (f.Name == "isIns") isIns = f; continue; }
+ if (typeof(UnityEngine.Object).IsAssignableFrom(ft))
+ {
+ var v = f.GetValue(null) as UnityEngine.Object;
+ bool gone = (object)v != null && v == null; // 참조는 남았는데 Unity 객체는 파괴됨
+ if (gone) { f.SetValue(null, null); Cleared++; names.Add(Name(t) + "." + f.Name); }
+ if (f.Name == "Ins") { hasIns = true; insGone = gone || (object)v == null; }
+ continue;
+ }
+ if (ft.IsGenericType && ft.GetGenericTypeDefinition() == typeof(List<>) && typeof(UnityEngine.Object).IsAssignableFrom(ft.GetGenericArguments()[0]))
+ {
+ var list = f.GetValue(null) as IList; if (list == null) continue;
+ int before = ListItems;
+ for (int i = list.Count - 1; i >= 0; i--) { var o = list[i] as UnityEngine.Object; if ((object)o != null && o == null) { list.RemoveAt(i); ListItems++; } }
+ if (ListItems > before) names.Add(Name(t) + "." + f.Name + "[" + (ListItems - before) + "]");
+ }
+ }
+ catch { }
+ }
+ if (isIns != null && hasIns && insGone)
+ {
+ try { if ((bool)isIns.GetValue(null)) { isIns.SetValue(null, false); Flags++; names.Add(Name(t) + ".isIns"); } } catch { }
+ }
+ }
+
+ /// 닫힌 제네릭은 「MonoBehaviourSingletonTemplate<TopMoney>」 꼴로.
+ static string Name(Type t)
+ {
+ if (!t.IsGenericType) return t.Name;
+ var n = t.Name; int k = n.IndexOf('`'); if (k > 0) n = n.Substring(0, k);
+ return n + "<" + string.Join(",", Array.ConvertAll(t.GetGenericArguments(), a => a.Name)) + ">";
+ }
+#endif
+ }
+}
diff --git a/Assets/WL/Start/WLStaleStaticReset.cs.meta b/Assets/WL/Start/WLStaleStaticReset.cs.meta
new file mode 100644
index 000000000..bc3c910b5
--- /dev/null
+++ b/Assets/WL/Start/WLStaleStaticReset.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 95e45dad8e3d8f844bbc338d67656dbf
\ No newline at end of file