// WL-813q2 프로브 — 결과 화면 핫픽스 실측(에디트 모드 · Play 0 · #813). // run_script --file AgentScripts/WL813q2_Probe.cs --entry WL813q2_Probe. // Overlap · Watchdog · Reset · Rows · Gc · All // 로그는 /Logs/WL813q2_*.txt (Assets 밖 · 미추적). // // 🔴 에디트 모드에서 `Time.timeScale` 에 쓰지 않는다(ProjectSettings/TimeManager.asset 오염 · 811b-fix). // DeathFlow.HoldWorld 도 `Application.isPlaying` 가드가 있어 에디트 모드에선 대입하지 않는다. // 🔴 씬·프리팹을 저장하지 않는다. 만든 노드는 finally 에서 전부 DestroyImmediate. // 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복한다(AssetDatabase.SaveAssets 호출 0). // 🔴 Gameplay 는 읽기·구독·공개 API 호출만 한다(파일 수정 0). `RunDirector.s_phase` 는 리플렉션 **읽기/쓰기**로 // "런 중/런 밖"만 흉내 낸다(런 시작·종료 부작용을 피하려고 StartRun/EndRun 을 쓰지 않는다). using System; using System.IO; using System.Reflection; using System.Text; using TMPro; using UnityEngine; using UnityEngine.UI; using UnityEditor; using WL.Combat.Core; using WL.Combat.Run; using WL.Combat.Survival; using WL.UI; public static class WL813q2_Probe { const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic; const BindingFlags SP = BindingFlags.Static | BindingFlags.NonPublic; static int s_pass, s_fail; // ─────────────────────────────────────────────────────────── 공통 static string LogDir { get { string d = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Logs"); if (!Directory.Exists(d)) Directory.CreateDirectory(d); return d; } } static string Write(string name, string body) { string p = Path.Combine(LogDir, name); File.WriteAllText(p, body, new UTF8Encoding(false)); return p; } static string Chk(StringBuilder sb, bool ok, string what) { if (ok) s_pass++; else s_fail++; sb.AppendLine((ok ? " PASS " : " **FAIL** ") + what); return what; } /// 세로 1080×1920 기준 합성 캔버스 + `IngameUIs/WL_HUD` 대역(SO 경로와 같게). static Transform BuildUiRoot(out GameObject root) { root = new GameObject("__WL813q2_Canvas", typeof(RectTransform)); root.hideFlags = HideFlags.DontSave; var canvas = root.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; var scaler = root.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920f, 1080f); scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand; root.AddComponent(); var ing = new GameObject("IngameUIs", typeof(RectTransform)).GetComponent(); ing.gameObject.hideFlags = HideFlags.DontSave; ing.SetParent(root.transform, false); var hud = new GameObject("WL_HUD", typeof(RectTransform)).GetComponent(); hud.gameObject.hideFlags = HideFlags.DontSave; hud.SetParent(ing, false); hud.anchorMin = hud.anchorMax = new Vector2(0.5f, 0.5f); hud.pivot = new Vector2(0.5f, 0.5f); hud.sizeDelta = new Vector2(1080f, 1920f); // 결과 패널이 형제 맨 뒤/맨 앞으로 움직이는 것을 볼 수 있게 미끼 형제 2개 for (int i = 0; i < 2; i++) { var d = new GameObject("Decoy" + i, typeof(RectTransform)).GetComponent(); d.gameObject.hideFlags = HideFlags.DontSave; d.SetParent(hud, false); } return root.transform; } /// 813z 프로브와 같은 가짜 메인 PC(사망 상태). static PCActor MakePC() { var go = new GameObject("__WL813q2_PC"); go.hideFlags = HideFlags.DontSave; var pc = go.AddComponent(); pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None; var stat = new ActorStatInfo(eRole.PC); stat.Set_Stat(eStat.MaxHP, 1000d); stat.Set_Stat(eStat.HP, 0d); typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat); typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false); typeof(Actor).GetField("DeadStatus", NP).SetValue(pc, true); return pc; } static void SetPhase(RunPhase p) { typeof(RunDirector).GetField("s_phase", SP).SetValue(null, p); } static RunPhase GetPhase() { return (RunPhase)typeof(RunDirector).GetField("s_phase", SP).GetValue(null); } static void OnReq(in ReviveRequestEvent e) { s_reqSeen++; } static int s_reqSeen; static void Kill(GameObject go) { if (go != null) UnityEngine.Object.DestroyImmediate(go); } static RunResult FakeResult(RunOutcome outcome, float total, float bossClear, int kills, int bossKills) { return new RunResult { outcome = outcome, runIndex = 1, totalSec = total, runSeconds = 240f, zoneClearSec = new float[] { 40f, -1f }, zoneKills = new int[] { 24, 11 }, zoneCount = 2, zonesCleared = 1, bossGateSec = -1f, bossStartSec = -1f, bossClearSec = bossClear, kills = kills, bossKills = bossKills, maxChain = 7, maxDamage = 1234.4, maxDamageBurst = 8888.5, levelFrom = 11, levelTo = 12, lootByGrade = new int[] { 0, 5, 2, 0, 0, 0, 0, 0, 0, 0 }, lootTotal = 7, gold = 777, deaths = 1, potionsUsed = 2, skillCasts = 9, time = Time.unscaledTime, frame = Time.frameCount, }; } // ────────────────────────────────────────── ① D-18 결과 위 부활 겹침 public static string Overlap() { s_pass = s_fail = 0; var sb = new StringBuilder(); sb.AppendLine("=== WL-813q2 ① D-18 결과 화면 위 부활 팝업 겹침 (에디트 모드 · Play 0) ==="); var ui = WLRunUiSettings.Instance; var sv = WLSurvivalSettings.Instance; if (ui == null || sv == null) { string b = sb.AppendLine("**FAIL** 설정 에셋 없음").ToString(); return b + "\n→ " + Write("WL813q2_overlap.txt", b); } GameObject root = null; PCActor pc = null; var phase0 = GetPhase(); int ov0 = WLSurvivalSettings.RuntimeOverride; var ovr0 = DeathFlow.ReviveOverride; bool skip0 = ui.skipRevivePopupAfterRunEnd, dying0 = ui.resultDeferWhileDying, lower0 = ui.resultLowerWhileRevive; sb.AppendLine("근거: Q5 캡처 PD2_07 — 제목 \"시간 종료\"(= 결과가 **먼저** 떴다) 위에 부활 팝업이 겹쳐 4행이 가려졌다."); sb.AppendLine("실측 전제: 813z 부활 팝업 sortingOrder = " + sv.reviveSortingOrder + " · 결과 화면 SO sortingOrder = " + ui.resultSortingOrder + "(부활 중 " + ui.resultSortingOrderWhileRevive + ")"); sb.AppendLine(); try { WLSurvivalSettings.RuntimeOverride = 1; DeathFlow.ReviveOverride = (p, r) => { }; DeathFlow.ReviveRequested.Add(OnReq); s_reqSeen = 0; var uiRoot = BuildUiRoot(out root); sb.AppendLine("Bind: " + RunResultUI.Bind(uiRoot)); RunResultUI.Subscribe(true); RunResultUI.ResetState(); DeathFlow.ResetDiagnostics(); pc = MakePC(); Chk(sb, RunResultUI.Bound, "결과 화면 노드 조립됨"); Chk(sb, RunResultUI.SortingOrder == ui.resultSortingOrder, "전용 Canvas sortingOrder = " + RunResultUI.SortingOrder + " (SO " + ui.resultSortingOrder + ") < 부활 " + sv.reviveSortingOrder); // ── A. 캡처와 같은 순서: RunEnded(결과 표시) → 그 뒤 사망 ──────── sb.AppendLine(); sb.AppendLine("A. 캡처 순서 재현 — RunEnded → 결과 표시 → **그 뒤** 사망(런 밖)"); SetPhase(RunPhase.Zones); var r = FakeResult(RunOutcome.Timeout, 241f, -1f, 29, 0); SetPhase(RunPhase.Ended); // EndRun 과 같은 상태(IsRunning=false) WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(sb, RunResultUI.IsOpen, "RunEnded 로 결과 화면이 떴다(open=" + RunResultUI.IsOpen + " 제목 \"" + RunResultUI.TitleText + "\")"); Chk(sb, !RunDirector.IsRunning, "런 밖 상태(IsRunning=" + RunDirector.IsRunning + ")"); float t = 1000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); // ← 결과가 떠 있는 상태에서 사망 sb.AppendLine(" 사망 직후: DeathFlow=" + DeathFlow.State + " · 즉시부활 호출 " + RunResultUI.SkipPopupReviveCount + "회 · " + RunResultUI.LastDeathLog); Chk(sb, RunResultUI.SkipPopupReviveCount >= 1, "런 밖 사망 → 팝업 생략 즉시 부활을 불렀다(skipRevivePopupAfterRunEnd=1)"); int reqBefore = s_reqSeen; DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); // 연출 끝 → acceptEarly 로 바로 부활 sb.AppendLine(" 연출 끝: DeathFlow=" + DeathFlow.State + " · ReviveRequested " + (s_reqSeen - reqBefore) + "회(팝업이 1프레임 안에 닫힌다) · Revived=" + RunResultUI.RevivedSeen); Chk(sb, DeathFlow.State == DeathState.None, "부활 완료 — DeathFlow.State=None(부활 대기 0 = 팝업이 남지 않는다)"); Chk(sb, DeathFlow.ReviveCount >= 1, "DeathFlow.ReviveCount=" + DeathFlow.ReviveCount); Chk(sb, RunResultUI.IsOpen && !RunResultUI.Lowered, "결과 화면은 그대로 · 재정렬 없음(open=" + RunResultUI.IsOpen + " lowered=" + RunResultUI.Lowered + ")"); // ── B. 스위치를 끄면 813q 동작(팝업이 뜬다) → 재정렬로 막는다 ── sb.AppendLine(); sb.AppendLine("B. skipRevivePopupAfterRunEnd=0 (C8 롤백) — 팝업이 뜨는 경우 결과를 부활 **뒤로 재정렬**"); ui.skipRevivePopupAfterRunEnd = false; DeathFlow.ResetDiagnostics(); RunResultUI.ResetState(); SetPhase(RunPhase.Ended); WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(sb, RunResultUI.IsOpen, "결과 화면 재표시(open=" + RunResultUI.IsOpen + ")"); int sib0 = RunResultUI.SiblingIndex; t = 2000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); sb.AppendLine(" 사망 후: " + RunResultUI.LastDeathLog); Chk(sb, RunResultUI.SkipPopupReviveCount == 0, "즉시 부활 호출 0(스위치 off = 813q 동작)"); Chk(sb, RunResultUI.Lowered && RunResultUI.SortingOrder == ui.resultSortingOrderWhileRevive, "결과를 부활 뒤로 재정렬 sortingOrder " + ui.resultSortingOrder + " → " + RunResultUI.SortingOrder + " (부활 " + sv.reviveSortingOrder + " 아래)"); Chk(sb, RunResultUI.SiblingIndex == 0 && sib0 != 0, "형제 순서 " + sib0 + " → " + RunResultUI.SiblingIndex + " (SetAsFirstSibling)"); DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); // AwaitingRevive Chk(sb, DeathFlow.State == DeathState.AwaitingRevive, "부활 대기 상태(DeathFlow=" + DeathFlow.State + ")"); Chk(sb, RunResultUI.ReviveBusy, "「부활 우선」 판정 ON — 이유 = " + RunResultUI.ReviveBusyReason); DeathFlow.Revive(); Chk(sb, !RunResultUI.Lowered && RunResultUI.SortingOrder == ui.resultSortingOrder, "부활 완료 → 결과 화면 복귀 sortingOrder=" + RunResultUI.SortingOrder + " 형제=" + RunResultUI.SiblingIndex); // ── C. 813q 원래 판정(ReviveDialog.Busy)만으로는 못 잡는다는 증명 ── sb.AppendLine(); sb.AppendLine("C. 왜 813q 판정이 못 잡았나 — RunEnded 시점에는 아무도 죽지 않아 Busy=false 였다"); DeathFlow.ResetDiagnostics(); RunResultUI.ResetState(); SetPhase(RunPhase.Ended); Chk(sb, !ReviveDialog.Busy && DeathFlow.State == DeathState.None, "RunEnded 직전 실측: ReviveDialog.Busy=" + ReviveDialog.Busy + " · DeathFlow=" + DeathFlow.State + " → 813q 의 미룸 조건은 어느 쪽으로도 걸리지 않는다"); ui.skipRevivePopupAfterRunEnd = true; // ── D. 반대 순서(사망 먼저 → RunEnded)는 813q2 에서도 미뤄진다 ── sb.AppendLine(); sb.AppendLine("D. 반대 순서 — 사망(런 중) → RunEnded → 부활 뒤에 결과가 뜬다"); RunResultUI.ResetState(); DeathFlow.ResetDiagnostics(); SetPhase(RunPhase.Zones); t = 3000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); Chk(sb, DeathFlow.State == DeathState.Dying, "사망 연출 중(DeathFlow=" + DeathFlow.State + ")"); Chk(sb, RunResultUI.ReviveBusy, "「부활 우선」 ON — " + RunResultUI.ReviveBusyReason); SetPhase(RunPhase.Ended); WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(sb, !RunResultUI.IsOpen && RunResultUI.Pending, "결과 화면 미룸(open=" + RunResultUI.IsOpen + " pending=" + RunResultUI.Pending + " defer=" + RunResultUI.DeferredCount + ")"); DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); DeathFlow.Revive(); RunResultUI.Tick(Time.unscaledTime); Chk(sb, RunResultUI.IsOpen && !RunResultUI.Pending, "부활 뒤 결과 화면 표시(open=" + RunResultUI.IsOpen + " shows=" + RunResultUI.ShownCount + ")"); sb.AppendLine(); sb.AppendLine(RunResultUI.Dump()); } catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } finally { ui.skipRevivePopupAfterRunEnd = skip0; ui.resultDeferWhileDying = dying0; ui.resultLowerWhileRevive = lower0; DeathFlow.ReviveRequested.Remove(OnReq); DeathFlow.ReviveOverride = ovr0; DeathFlow.Abort("probe cleanup"); WLSurvivalSettings.RuntimeOverride = ov0; RunResultUI.Teardown(); Kill(pc != null ? pc.gameObject : null); Kill(root); SetPhase(phase0); } sb.AppendLine(); sb.AppendLine("① 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); string body = sb.ToString(); return body + "\n→ " + Write("WL813q2_overlap.txt", body); } // ────────────────────────────────────── ② 워치독 자동 부활 경로(813z) public static string Watchdog() { s_pass = s_fail = 0; var sb = new StringBuilder(); sb.AppendLine("=== WL-813q2 ② 워치독 자동 부활 경로에서도 「부활 우선」이 걸리는가 (813z §1) ==="); var ui = WLRunUiSettings.Instance; var sv = WLSurvivalSettings.Instance; if (ui == null || sv == null) { string b = sb.AppendLine("**FAIL** 설정 에셋 없음").ToString(); return b + "\n→ " + Write("WL813q2_watchdog.txt", b); } GameObject root = null; PCActor pc = null; var phase0 = GetPhase(); int ov0 = WLSurvivalSettings.RuntimeOverride; var ovr0 = DeathFlow.ReviveOverride; bool dying0 = ui.resultDeferWhileDying; try { WLSurvivalSettings.RuntimeOverride = 1; DeathFlow.ReviveOverride = (p, r) => { }; DeathFlow.ReviveRequested.Add(OnReq); s_reqSeen = 0; var uiRoot = BuildUiRoot(out root); RunResultUI.Bind(uiRoot); RunResultUI.Subscribe(true); RunResultUI.ResetState(); DeathFlow.ResetDiagnostics(); pc = MakePC(); sb.AppendLine("워치독 SO 실측: reviveWatchdogSeconds=" + sv.reviveWatchdogSeconds.ToString("F1") + "s"); SetPhase(RunPhase.Zones); float t = 5000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); float req = t + sv.deathDisplaySeconds + 0.01f; DeathFlow.Tick(req); Chk(sb, DeathFlow.State == DeathState.AwaitingRevive, "부활 대기(DeathFlow=" + DeathFlow.State + ")"); // 런 종료가 부활 대기 중에 온다 → 결과는 미뤄진다(813q 는 Busy 로만 봤다) SetPhase(RunPhase.Ended); var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 31, 0); WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(sb, !RunResultUI.IsOpen && RunResultUI.Pending, "결과 미룸 — 이유 = " + RunResultUI.ReviveBusyReason + " (ReviveDialog.Busy=" + ReviveDialog.Busy + " 이라 813q 판정만으로는 못 막는다)"); // 워치독 시각 직전/직후 DeathFlow.Tick(req + sv.reviveWatchdogSeconds - 0.1f); RunResultUI.Tick(Time.unscaledTime); Chk(sb, !RunResultUI.IsOpen, "워치독 직전까지 계속 미룸(defer=" + RunResultUI.DeferredCount + ")"); DeathFlow.Tick(req + sv.reviveWatchdogSeconds + 0.02f); sb.AppendLine(" 워치독: WatchdogCount=" + DeathFlow.WatchdogCount + " 대기 " + DeathFlow.LastWatchdogSeconds.ToString("F2") + "s · auto 부활=" + DeathFlow.AutoReviveCount + " · RunResultUI.Revived=" + RunResultUI.RevivedSeen); Chk(sb, DeathFlow.WatchdogCount == 1 && DeathFlow.State == DeathState.None, "워치독 자동 부활 1회 · 상태 None"); Chk(sb, RunResultUI.RevivedSeen >= 1, "RunResultUI 가 Revived 를 받았다(워치독 경로 포함)"); RunResultUI.Tick(Time.unscaledTime); Chk(sb, RunResultUI.IsOpen && !RunResultUI.Pending, "워치독 부활 뒤 결과 화면 표시(shows=" + RunResultUI.ShownCount + ")"); // C8 — resultDeferWhileDying=0 이면 813q 동작(미룸 없음) sb.AppendLine(); sb.AppendLine("C8 롤백 — resultDeferWhileDying=0 (813q 동작 복귀)"); ui.resultDeferWhileDying = false; RunResultUI.ResetState(); DeathFlow.ResetDiagnostics(); SetPhase(RunPhase.Zones); t = 6000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); DeathFlow.Tick(t + sv.deathDisplaySeconds + 0.01f); Chk(sb, DeathFlow.State == DeathState.AwaitingRevive && !RunResultUI.ReviveBusy, "DeathFlow=" + DeathFlow.State + " 인데 부활우선=" + RunResultUI.ReviveBusy + " (스위치 off = 813q 판정만)"); DeathFlow.Revive(); } catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } finally { ui.resultDeferWhileDying = dying0; DeathFlow.ReviveRequested.Remove(OnReq); DeathFlow.ReviveOverride = ovr0; DeathFlow.Abort("probe cleanup"); WLSurvivalSettings.RuntimeOverride = ov0; RunResultUI.Teardown(); Kill(pc != null ? pc.gameObject : null); Kill(root); SetPhase(phase0); } sb.AppendLine(); sb.AppendLine("② 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); string body2 = sb.ToString(); return body2 + "\n→ " + Write("WL813q2_watchdog.txt", body2); } // ─────────────────────────────── ③ D-23 「다음 런」 뒤 상태 리셋 public static string Reset() { s_pass = s_fail = 0; var sb = new StringBuilder(); sb.AppendLine("=== WL-813q2 ③ D-23 「다음 런」 뒤 타이머 색 · 보스 바 · 배너 리셋 ==="); var ui = WLRunUiSettings.Instance; if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_reset.txt", b); } GameObject root = null, barGo = null, bannerGo = null; bool reset0 = ui.timerResetColorOnRunStart; var phase0 = GetPhase(); try { var uiRoot = BuildUiRoot(out root); sb.AppendLine("RunHud Bind: " + RunHud.Bind(uiRoot)); RunHud.Subscribe(true); RunHud.ResetState(); string warnHex = ColorUtility.ToHtmlStringRGB(ui.timerWarnColor); string normHex = ColorUtility.ToHtmlStringRGB(ui.timerColor); sb.AppendLine("SO 색 실측: 평상 #" + normHex + " · 경고 #" + warnHex + " (경고 구간 " + ui.timerWarnSeconds + "s)"); // 런 1 — 시작 → 마지막 30 s 경고색 WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe"); Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, "런 1 시작 타이머 색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " = 평상색 · 표시 \"" + RunHud.TimerText + "\""); WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 1, 3, 14, 1, 2, RunPhase.Zones, 1); Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == warnHex && RunHud.TimerWarn, "남은 25 s → 경고색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " · 표시 \"" + RunHud.TimerText + "\""); var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0); WLRunUiProbeHooks.RaiseRunEnded(in r); // 런 2 (= 「다음 런」) — 색이 평상색으로 돌아와야 한다 WLRunUiProbeHooks.RaiseRunStarted(2, 240f, 2, "restart"); Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, "🔴 D-23 — 다음 런 시작 타이머 색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " = 평상색 · 표시 \"" + RunHud.TimerText + "\" (색 쓰기 " + RunHud.TimerColorWrites + "회)"); bool zonesZero = true; for (int i = 0; i < RunHud.ZoneCells; i++) if (RunHud.ZoneFill(i) > 0.0005f) zonesZero = false; Chk(sb, zonesZero && RunHud.ZoneCells == 2, "존 게이지 리셋 — 칸 " + RunHud.ZoneCells + "개 전부 fill 0"); // C8 — 스위치를 끄면 813q 버그가 그대로 재현된다(= 이 스위치가 원인 지점이다) sb.AppendLine(); sb.AppendLine("C8 롤백 — timerResetColorOnRunStart=0 (813q 동작 = D-23 재현)"); ui.timerResetColorOnRunStart = false; WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 1, 3, 14, 1, 2, RunPhase.Zones, 2); WLRunUiProbeHooks.RaiseRunStarted(3, 240f, 2, "restart"); Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == warnHex, "스위치 off → 이전 런의 경고색 #" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) + " 이 그대로 남는다 = D-23 원인 지점 확정"); ui.timerResetColorOnRunStart = true; WLRunUiProbeHooks.RaiseRunStarted(4, 240f, 2, "restart"); Chk(sb, ColorUtility.ToHtmlStringRGB(RunHud.TimerColor) == normHex, "스위치 on 복귀 → 평상색"); // ── 보스 HP 바 sb.AppendLine(); sb.AppendLine("보스 HP 바 — 이전 런의 바가 남는가"); barGo = new GameObject("__WL813q2_BossBar", typeof(RectTransform)); barGo.hideFlags = HideFlags.DontSave; barGo.transform.SetParent(root.transform, false); var bar = barGo.AddComponent(); sb.AppendLine(" " + bar.Initialize()); sb.AppendLine(" " + bar.Bind(null, "테스트 보스", 200f)); // 합성(actor=null) 경로 Chk(sb, bar.Visible && bar.LastRatio > 0.99f, "바 표시 · ratio=" + bar.LastRatio.ToString("F3")); bar.SetRatio(0.37f); Chk(sb, Mathf.Abs(BossHpBar.RunBossHpRatio - 0.37f) < 0.001f, "런 단위 보스 HP 실측값 = " + (BossHpBar.RunBossHpRatio * 100f).ToString("F1") + "% (결과 화면이 읽는 값)"); int reset1 = BossHpBar.RunResetCount; WLRunUiProbeHooks.RaiseRunStarted(5, 240f, 2, "restart"); sb.AppendLine(" RunStarted 뒤: 리셋 " + (BossHpBar.RunResetCount - reset1) + "회 · visible=" + bar.Visible + " · 런 HP=" + BossHpBar.RunBossHpRatio.ToString("F2") + " · 늦은구독 스캔=" + bar.LateScanCount); Chk(sb, BossHpBar.RunResetCount == reset1 + 1, "RunStarted 를 받아 리셋했다"); Chk(sb, !bar.Visible, "🔴 D-23 — 이전 런의 보스 바가 사라졌다(visible=" + bar.Visible + ")"); Chk(sb, BossHpBar.RunBossHpRatio < 0f, "런 단위 보스 HP 값도 비워졌다(" + BossHpBar.RunBossHpRatio.ToString("F2") + ")"); ui.bossResetOnRunStart = false; bar.Bind(null, "테스트 보스", 200f); WLRunUiProbeHooks.RaiseRunStarted(6, 240f, 2, "restart"); Chk(sb, bar.Visible, "C8 롤백 bossResetOnRunStart=0 → 813q 동작(바가 남는다 · visible=" + bar.Visible + ")"); ui.bossResetOnRunStart = true; // ── 보스 배너(재트리거 가드) sb.AppendLine(); sb.AppendLine("보스 등장 배너 — 재트리거 가드가 다음 런에서 풀리는가"); bannerGo = new GameObject("__WL813q2_BossBanner", typeof(RectTransform)); bannerGo.hideFlags = HideFlags.DontSave; bannerGo.transform.SetParent(root.transform, false); var banner = bannerGo.AddComponent(); sb.AppendLine(" " + banner.Initialize()); sb.AppendLine(" " + banner.Trigger("테스트 보스", true)); int show1 = banner.ShowCount; sb.AppendLine(" 같은 런에서 재트리거: " + banner.Trigger("테스트 보스", true)); Chk(sb, banner.ShowCount == show1, "같은 런에서는 가드가 막는다(shows=" + banner.ShowCount + ")"); WLRunUiProbeHooks.RaiseRunStarted(7, 240f, 2, "restart"); Chk(sb, !banner.Visible, "다음 런 시작 → 배너 숨김(alpha=" + banner.BannerAlpha.ToString("F2") + ")"); sb.AppendLine(" 다음 런에서 재트리거: " + banner.Trigger("테스트 보스", true)); Chk(sb, banner.ShowCount == show1 + 1, "다음 런에서는 다시 뜬다 = 가드 해제됨(shows=" + banner.ShowCount + ")"); sb.AppendLine(); sb.AppendLine(RunHud.Dump()); } catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } finally { ui.timerResetColorOnRunStart = reset0; ui.bossResetOnRunStart = true; BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; RunHud.Teardown(); Kill(barGo); Kill(bannerGo); Kill(root); SetPhase(phase0); } sb.AppendLine(); sb.AppendLine("③ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); string body3 = sb.ToString(); return body3 + "\n→ " + Write("WL813q2_reset.txt", body3); } // ─────────────────────────────── ④ 결과 화면 행 보강(보스 미처치 · 엘리트) public static string Rows() { s_pass = s_fail = 0; var sb = new StringBuilder(); sb.AppendLine("=== WL-813q2 ④ 결과 화면 행 — 보스 미처치 문구 · 엘리트 처치 수 ==="); var ui = WLRunUiSettings.Instance; if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_rows.txt", b); } GameObject root = null, barGo = null; var phase0 = GetPhase(); try { var uiRoot = BuildUiRoot(out root); RunResultUI.Bind(uiRoot); RunResultUI.Subscribe(true); RunResultUI.ResetState(); BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; // 앞 항목의 잔값 제거 sb.AppendLine("SO 행 배열 = " + ui.resultRows.Length + "개 · 엘리트 행 포함=" + ui.HasRow(WLRunResultRow.EliteKills)); Chk(sb, ui.HasRow(WLRunResultRow.EliteKills), "엘리트 처치 행이 SO 배열에 있다"); // 엘리트 집계 — 런 중에만 센다 SetPhase(RunPhase.Zones); WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe"); for (int i = 0; i < 3; i++) WLRunUiProbeHooks.RaiseKilled(eSubRol.Elite); for (int i = 0; i < 5; i++) WLRunUiProbeHooks.RaiseKilled(eSubRol.None); WLRunUiProbeHooks.RaiseKilled(eSubRol.Boss); Chk(sb, RunResultUI.EliteKills == 3, "런 중 엘리트 처치 " + RunResultUI.EliteKills + "건(일반 5 · 보스 1 은 세지 않는다)"); SetPhase(RunPhase.Ended); WLRunUiProbeHooks.RaiseKilled(eSubRol.Elite); // 런 밖 처치는 세지 않는다 Chk(sb, RunResultUI.EliteKills == 3, "런 밖 엘리트 처치는 무시(" + RunResultUI.EliteKills + ")"); // 보스 미처치 + HP 를 모르는 경우 var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0); WLRunUiProbeHooks.RaiseRunEnded(in r); string boss1 = RunResultUI.RowValueText(WLRunResultRow.BossClear); string elite1 = RunResultUI.RowValueText(WLRunResultRow.EliteKills); sb.AppendLine(" 보스 처치 행 = \"" + boss1 + "\" · 엘리트 처치 행 = \"" + elite1 + "\""); Chk(sb, boss1 == ui.resultBossNotClearedText, "보스 미처치(HP 모름) → \"" + boss1 + "\""); Chk(sb, elite1 == "3", "엘리트 처치 행 = \"" + elite1 + "\" (RunEnded 순간 스냅샷)"); // 보스 미처치 + 813c 바가 남긴 HP 실측값 barGo = new GameObject("__WL813q2_BossBar2", typeof(RectTransform)); barGo.hideFlags = HideFlags.DontSave; barGo.transform.SetParent(root.transform, false); var bar = barGo.AddComponent(); bar.Initialize(); bar.Bind(null, "테스트 보스", 200f); bar.SetRatio(0.436f); RunResultUI.ResetState(); WLRunUiProbeHooks.RaiseRunEnded(in r); string boss2 = RunResultUI.RowValueText(WLRunResultRow.BossClear); sb.AppendLine(" 바 실측 HP 43.6% 일 때 보스 처치 행 = \"" + boss2 + "\""); Chk(sb, boss2.Contains("44"), "보스 미처치 + HP 남음 44 % 표기 — \"" + boss2 + "\""); // 보스 처치한 런은 시간이 그대로 나온다 var r2 = FakeResult(RunOutcome.Win, 154f, 154f, 99, 1); RunResultUI.ResetState(); WLRunUiProbeHooks.RaiseRunEnded(in r2); string boss3 = RunResultUI.RowValueText(WLRunResultRow.BossClear); Chk(sb, boss3 == "02:34", "보스 처치 런 = \"" + boss3 + "\" (mm:ss 그대로 · 813q 동작 불변)"); // C8 — UI 집계·UI HP 를 끄면 이전 동작 ui.resultBossHpFromUi = false; ui.resultCountElitesInUi = false; RunResultUI.ResetState(); WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(sb, RunResultUI.RowValueText(WLRunResultRow.BossClear) == ui.resultBossNotClearedText && RunResultUI.RowValueText(WLRunResultRow.EliteKills) == "0", "C8 롤백 — 보스 행 \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\" · 엘리트 행 \"" + RunResultUI.RowValueText(WLRunResultRow.EliteKills) + "\""); ui.resultBossHpFromUi = true; ui.resultCountElitesInUi = true; sb.AppendLine(); sb.AppendLine(RunResultUI.Dump()); } catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } finally { ui.resultBossHpFromUi = true; ui.resultCountElitesInUi = true; RunResultUI.Teardown(); Kill(barGo); Kill(root); SetPhase(phase0); } sb.AppendLine(); sb.AppendLine("④ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); string body4 = sb.ToString(); return body4 + "\n→ " + Write("WL813q2_rows.txt", body4); } // ────────────────────────────────────────────────── ⑤ GC 0 · C8 public static string Gc() { s_pass = s_fail = 0; var sb = new StringBuilder(); sb.AppendLine("=== WL-813q2 ⑤ GC 0 · C8 ==="); var ui = WLRunUiSettings.Instance; if (ui == null) { string b = sb.AppendLine("**FAIL** WLRunUiSettings 없음").ToString(); return b + "\n→ " + Write("WL813q2_gc.txt", b); } GameObject root = null; var phase0 = GetPhase(); try { var uiRoot = BuildUiRoot(out root); RunHud.Bind(uiRoot); RunHud.Subscribe(true); RunHud.ResetState(); RunResultUI.Bind(uiRoot); RunResultUI.Subscribe(true); RunResultUI.ResetState(); SetPhase(RunPhase.Zones); WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 2, "probe"); WLRunUiProbeHooks.RaiseRunTick(60f, 180f, 0, 5, 24, 0, 2, RunPhase.Zones, 1); // 같은 초 100틱 = 표시 쓰기 0회 · 델타 0 B GC.Collect(); GC.WaitForPendingFinalizers(); long m0 = GC.GetTotalMemory(true); int tw0 = RunHud.TimerWrites, cw0 = RunHud.TimerColorWrites; for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseRunTick(60f, 180f, 0, 5, 24, 0, 2, RunPhase.Zones, 1); long m1 = GC.GetTotalMemory(false); Chk(sb, RunHud.TimerWrites == tw0 && RunHud.TimerColorWrites == cw0, "같은 초 100틱 — 타이머 쓰기 " + (RunHud.TimerWrites - tw0) + "회 · 색 쓰기 " + (RunHud.TimerColorWrites - cw0) + "회"); Chk(sb, m1 - m0 == 0, "Δ GetTotalMemory = " + (m1 - m0) + " B"); // Killed 100건(엘리트 집계 경로) — 문자열 0 GC.Collect(); GC.WaitForPendingFinalizers(); long k0 = GC.GetTotalMemory(true); for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseKilled(i % 2 == 0 ? eSubRol.Elite : eSubRol.None); long k1 = GC.GetTotalMemory(false); Chk(sb, k1 - k0 == 0, "Killed 100건(엘리트 50) Δ = " + (k1 - k0) + " B · 집계=" + RunResultUI.EliteKills); // 재정렬 100회 왕복 — 노드 재생성 0(Canvas·Raycaster 는 1개씩 재사용) // 🔴 재정렬 함수는 진단 문자열을 돌려주므로 그 자체는 할당이 있다. 다만 **매 프레임 경로가 아니다** // (사망 1회 · 부활 1회). 여기서는 "노드를 다시 만들지 않는다"만 PASS 기준으로 둔다. long l0 = GC.GetTotalMemory(true); for (int i = 0; i < 100; i++) { RunResultUI.LowerBehindRevive(); RunResultUI.RaiseToFront(); } long l1 = GC.GetTotalMemory(false); var resRoot = root.transform.Find("IngameUIs/WL_HUD/WL_RunResult"); int canvasN = resRoot != null ? resRoot.GetComponents().Length : -1; int rayN = resRoot != null ? resRoot.GetComponents().Length : -1; Chk(sb, RunResultUI.LowerCount == 100 && RunResultUI.RaiseCount == 100, "재정렬 왕복 100회(내림 " + RunResultUI.LowerCount + " / 복귀 " + RunResultUI.RaiseCount + ") · Δ = " + (l1 - l0) + " B(진단 문자열분)"); Chk(sb, canvasN == 1 && rayN == 1, "노드 재생성 0 — Canvas " + canvasN + "개 · GraphicRaycaster " + rayN + "개"); // C8 — 전체 off sb.AppendLine(); WLRunUiSettings.RuntimeDisabled = true; RunResultUI.ResetState(); var r = FakeResult(RunOutcome.Timeout, 240f, -1f, 29, 0); SetPhase(RunPhase.Ended); WLRunUiProbeHooks.RaiseRunEnded(in r); RunResultUI.Tick(Time.unscaledTime); Chk(sb, !RunResultUI.IsOpen, "C8 RuntimeDisabled — 결과 화면 표시 0(open=" + RunResultUI.IsOpen + ")"); Chk(sb, !RunResultUI.ReviveBusy, "C8 중 부활우선 판정도 무동작(" + RunResultUI.ReviveBusyReason + ")"); WLRunUiSettings.RuntimeDisabled = false; Chk(sb, WLRunUiSettings.Enabled, "C8 복구 — Enabled=" + WLRunUiSettings.Enabled); } catch (Exception ex) { s_fail++; sb.AppendLine("**FAIL** 예외 " + ex.GetType().Name + " — " + ex.Message + "\n" + ex.StackTrace); } finally { WLRunUiSettings.RuntimeDisabled = false; RunHud.Teardown(); RunResultUI.Teardown(); Kill(root); SetPhase(phase0); } sb.AppendLine(); sb.AppendLine("⑤ 결과 = " + s_pass + " PASS / " + s_fail + " FAIL"); string body5 = sb.ToString(); return body5 + "\n→ " + Write("WL813q2_gc.txt", body5); } // ───────────────────────────────────────────────────────── 전체 public static string All() { var sb = new StringBuilder(); int pass = 0, fail = 0; string[] parts = new string[5]; parts[0] = Overlap(); pass += s_pass; fail += s_fail; parts[1] = Watchdog(); pass += s_pass; fail += s_fail; parts[2] = Reset(); pass += s_pass; fail += s_fail; parts[3] = Rows(); pass += s_pass; fail += s_fail; parts[4] = Gc(); pass += s_pass; fail += s_fail; for (int i = 0; i < parts.Length; i++) sb.AppendLine(parts[i]).AppendLine(); sb.AppendLine("======================================================"); sb.AppendLine("WL-813q2 전체 = " + pass + " PASS / " + fail + " FAIL"); string body = sb.ToString(); return "WL-813q2 전체 = " + pass + " PASS / " + fail + " FAIL\n→ " + Write("WL813q2_all.txt", body); } }