From 02f16661d0118f9316292a62509734644a2e7fe6 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 21 Oct 2026 11:32:29 +0900 Subject: [PATCH] =?UTF-8?q?WL=20#957=20=EC=8A=A4=ED=82=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=94=AC=20=EB=B6=80=ED=8A=B8=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=88=98=EC=A0=95=20=E2=80=94=20=EC=A7=80=EB=82=9C?= =?UTF-8?q?=20Play=20=EC=9D=98=20=ED=8C=8C=EA=B4=B4=EB=90=9C=20=EC=8B=B1?= =?UTF-8?q?=EA=B8=80=ED=84=B4=20static=20=EC=A0=95=EB=A6=AC(WLStaleStaticR?= =?UTF-8?q?eset)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실측: 테스트 씬 Play → stage=pc-fail · MissingReferenceException 'MoneyForm' destroyed @ MoneyForm.Set ← TopMoney.Set ← ServerData 아이템 지급(if TopMoney.isIns) · TestMap Play → LoadingUI.End_Loading(go 파괴) ← MobControlMgrList.Start · 편집 모드 TopMoney · LoadingUI · NewGameUI 의 isIns True + Ins 파괴됨 - 원인: #947-2 가 Enter Play Mode 도메인 리로드를 껐는데 원본 싱글턴(MonoBehaviourSingletonTemplate · LoadingUI 등)은 static Ins/isIns 를 지우지 않아 다른 씬 Play 뒤 다음 Play 가 파괴된 객체를 건드림 - 수정: Play 시작(SubsystemRegistration)마다 Assembly-CSharp static 필드 중 파괴된 UnityEngine.Object 참조 → null · List 의 파괴된 항목 제거 · 같은 형식 Ins 가 비면 isIns false(에디터 전용 · 살아 있는 객체 무접촉) · 하네스 예외 호출 위치 4 → 8줄 - 검증: 파괴된 상태 재현 뒤 테스트 씬 ready 0.6 s(정리 참조 121 · isIns 16 · 9 ms) · TestMap 의 LoadingUI 오류 재발 0(TestMap 단독은 AddrResourceMgr 가 Title 부팅에서만 생겨 LoadMapMgr.Awake NRE = 새 편집기 첫 Play 와 같은 상태) Co-Authored-By: Claude Opus 5.5 --- Assets/WL/SkillTest/WLSkillTestHarness.cs | 6 +- Assets/WL/Start/WLStaleStaticReset.cs | 112 +++++++++++++++++++++ Assets/WL/Start/WLStaleStaticReset.cs.meta | 2 + 3 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 Assets/WL/Start/WLStaleStaticReset.cs create mode 100644 Assets/WL/Start/WLStaleStaticReset.cs.meta 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