// WL-813q 프로브 — 런 HUD + 런 결과 화면 실측 (에디트 모드 · Play 0 · 로그인 0). // unity command run_script --file AgentScripts/WL813q_Probe.cs --entry WL813q_Probe.RunAll // 산출물: /AgentScripts/WL813q_PROBE.txt // 🔴 NewGameUI.prefab 은 LoadPrefabContents 로 열고 **저장하지 않는다**(UnloadPrefabContents(false)). // HUD·결과 노드는 HideFlags.DontSave 라 프리팹에 굽히지 않는다. using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEngine; using UnityEngine.Profiling; using UnityEditor; using TMPro; using WL.UI; using WL.Combat.Run; using WL.Combat.Loot; public static class WL813q_Probe { const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; static StringBuilder _o; static int _pass, _fail; static GameObject _root; // 프리팹 미리보기 씬 루트(FindObjectsByType 는 미리보기 씬을 못 본다) static void H(string s) { _o.AppendLine(); _o.AppendLine("── " + s); } static void N(string s) { _o.AppendLine(" " + s); } static bool Chk(bool ok, string what) { if (ok) { _pass++; _o.AppendLine(" [PASS] " + what); } else { _fail++; _o.AppendLine(" [FAIL] " + what); } return ok; } static string Root { get { return Directory.GetParent(Application.dataPath).FullName; } } public static string RunAll() { _o = new StringBuilder(); _pass = _fail = 0; _o.AppendLine("# WL813q Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); _o.AppendLine("playMode=" + Application.isPlaying); GameObject prefabRoot = null; bool savedRuntimeDisabled = WLRunUiSettings.RuntimeDisabled; try { Step0_Asset(); prefabRoot = Step1_Nodes(); Step2_Hud(); Step3_ResultWin(); Step4_ArrayCopy(); Step5_Timeout(); Step6_Abandon(); Step7_RevivePriority(); Step8_Gc(); Step9_C8(); } catch (Exception e) { _fail++; _o.AppendLine(" [FAIL] 예외 " + e.GetType().Name + " : " + e.Message); _o.AppendLine(e.StackTrace); } finally { WLRunUiSettings.RuntimeDisabled = savedRuntimeDisabled; Step10_Cleanup(prefabRoot); } _o.AppendLine(); _o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " (pass=" + _pass + " fail=" + _fail + ")"); string path = Path.Combine(Path.Combine(Root, "AgentScripts"), "WL813q_PROBE.txt"); File.WriteAllText(path, _o.ToString(), new UTF8Encoding(false)); return _o.ToString() + "\n(파일: " + path + ")"; } // ─────────────────────────────────────────────── ⓪ 에셋 · 계약 static void Step0_Asset() { H("⓪ 에셋 (WLRunUiSettings · NEW)"); WLRunUiSettings.ClearCache(); var s = WLRunUiSettings.Instance; Chk(s != null, "Resources.Load(\"" + WLRunUiSettings.ResourcesPath + "\")"); if (s == null) return; Chk(WLRunUiSettings.Enabled, "Enabled(에셋·enabled_·RuntimeDisabled) = " + WLRunUiSettings.Enabled); N("값: enabled_=" + s.enabled_ + " hud=" + s.runHudEnabled + " result=" + s.resultEnabled + " topMargin=" + s.runHudTopMarginPx + "px width=" + s.runHudWidthPx + "px timerPx=" + s.timerFontPx + " warn=" + s.timerWarnSeconds + "s pulse=" + s.zonePulseScale + "×" + s.zonePulseSeconds + "s"); N("문구: win=\"" + s.resultWinTitle + "\" timeout=\"" + s.resultTimeoutTitle + "\" next=\"" + s.resultNextRunText + "\" close=\"" + s.resultCloseText + "\" zone=\"" + s.phaseZoneFormat + "\" boss=\"" + s.phaseBossText + "\""); N("행 순서: " + string.Join(" · ", Array.ConvertAll(s.resultRows, r => r.ToString()))); Chk(s.resultRows != null && s.resultRows.Length == 9, "본문 행 9종(발주서 §1-2 순서)"); Chk(!s.resultShowOnAbandon, "Abandon 팝업 없음(기본값)"); Chk(s.resultDeferWhileRevive, "부활 팝업 우선(기본값)"); // 813p 계약 — UI 는 수치를 갖지 않는다 var rs = WLRunSettings.Instance; Chk(rs != null, "813p WLRunSettings 존재(런 길이·존 수의 주인)"); if (rs != null) N("813p 값(읽기만): runSeconds=" + rs.runSeconds + " zoneSpawnerIds=" + rs.zoneSpawnerIds.Length + " zoneClearKills=[" + string.Join(",", Array.ConvertAll(rs.zoneClearKills, x => x.ToString())) + "]"); Chk(WLLootSettings.GradeSlots == 9, "WLLootSettings.GradeSlots = 9 → lootByGrade 길이 10(813p 계약)"); } // ─────────────────────────────────────────────── ① 노드 조립 static GameObject Step1_Nodes() { H("① 노드 — NewGameUI 프리팹(저장 0) · Safe Area · 보스 바와 겹침"); var root = PrefabUtility.LoadPrefabContents(kPrefab); _root = root; Chk(root != null, "LoadPrefabContents(" + kPrefab + ")"); if (root == null) return null; var hud = WLVignetteUtil.FindUiPath(root.transform, "IngameUIs/WL_HUD"); Chk(hud != null, "IngameUIs/WL_HUD 경로 실측"); if (hud != null) { bool safe = hud.GetComponentInParent(true) != null || hud.GetComponent() != null; Chk(safe, "WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안 (813y 실측 재확인)"); } RunHud.Subscribe(true); RunResultUI.Subscribe(true); N("Bind HUD : " + RunHud.Bind(root.transform)); N("Bind 결과 : " + RunResultUI.Bind(root.transform)); Chk(RunHud.Bound, "RunHud 노드 생성(WL_RunHud · HideFlags.DontSave)"); Chk(RunResultUI.Bound, "RunResultUI 노드 생성(WL_RunResult · HideFlags.DontSave)"); Chk(RunEvents.RunStarted.Count == 2, "RunStarted 구독자 2(HUD+결과) — 실측 " + RunEvents.RunStarted.Count); Chk(RunEvents.RunTick.Count == 1 && RunEvents.ZoneCleared.Count == 1 && RunEvents.BossGateOpened.Count == 1 && RunEvents.BossStarted.Count == 1 && RunEvents.RunEnded.Count == 2, "6종 구독 배선 = Tick1/Zone1/Gate1/Boss1/Ended2"); // 813c 보스 HP 바와 겹치지 않는가(둘 다 상단 · px 기준 산술) var hs = WLHudLayoutSettings.Instance; var s = WLRunUiSettings.Instance; if (hs != null && s != null) { float barTop = hs.bossBarTopMarginPx; float barBottom = barTop + hs.bossBarSizePx.y + hs.bossBarNameFontPx + hs.bossBarNameGapPx; N("보스 바 = 상단 " + barTop + " ~ " + barBottom + " px · 런 HUD 상단 = " + s.runHudTopMarginPx + " px"); Chk(s.runHudTopMarginPx >= barBottom, "런 HUD 가 보스 HP 바 아래(겹침 0 · 여유 " + (s.runHudTopMarginPx - barBottom).ToString("F1") + " px)"); } N("HUD 배치 : " + RunHud.ApplyLayout()); N("결과 배치: " + RunResultUI.ApplyLayout()); return root; } // ─────────────────────────────────────────────── ② HUD 시퀀스 static void Step2_Hud() { H("② HUD — RunStarted → RunTick×3 → ZoneCleared×4 → BossGateOpened → BossStarted"); var s = WLRunUiSettings.Instance; RunHud.ResetState(); WLRunUiProbeHooks.RaiseRunStarted(1, 240f, 4, "probe"); Chk(RunHud.StartedSeen == 1, "RunStarted 수신 1"); Chk(RunHud.ZoneCells == 4, "존 칸 4개 생성 — 실측 " + RunHud.ZoneCells); Chk(RunHud.Visible, "HUD 표시 on"); Chk(RunHud.TimerText == "04:00", "타이머 리셋 = 04:00(runSeconds 240 · 페이로드에서) — 실측 \"" + RunHud.TimerText + "\""); Chk(RunHud.PhaseText == "존 1", "단계 = \"존 1\" — 실측 \"" + RunHud.PhaseText + "\""); // 틱 3회: 남은 200s(존0 6/12) → 남은 150s(존1 7/14) → 남은 25s(경고색) WLRunUiProbeHooks.RaiseRunTick(40f, 200f, 0, 6, 12, 0, 4, RunPhase.Zones, 1); N("tick1 timer=\"" + RunHud.TimerText + "\" 존0 fill=" + RunHud.ZoneFill(0).ToString("F2") + " 단계=\"" + RunHud.PhaseText + "\""); Chk(RunHud.TimerText == "03:20", "틱1 mm:ss = 03:20"); Chk(Mathf.Abs(RunHud.ZoneFill(0) - 0.5f) < 0.001f, "틱1 존0 게이지 = 6/12 = 0.50 — 실측 " + RunHud.ZoneFill(0).ToString("F3")); WLRunUiProbeHooks.RaiseRunTick(90f, 150f, 1, 7, 14, 1, 4, RunPhase.Zones, 1); Chk(RunHud.TimerText == "02:30", "틱2 mm:ss = 02:30"); Chk(Mathf.Abs(RunHud.ZoneFill(0) - 1f) < 0.001f && Mathf.Abs(RunHud.ZoneFill(1) - 0.5f) < 0.001f, "틱2 존0=1.00(클리어) · 존1=0.50 — 실측 " + RunHud.ZoneFill(0).ToString("F2") + "/" + RunHud.ZoneFill(1).ToString("F2")); Chk(RunHud.PhaseText == "존 2", "틱2 단계 = \"존 2\" — 실측 \"" + RunHud.PhaseText + "\""); WLRunUiProbeHooks.RaiseRunTick(215f, 25f, 2, 3, 12, 2, 4, RunPhase.Zones, 1); Chk(RunHud.TimerText == "00:25", "틱3 mm:ss = 00:25"); var timerColor = FindTimerColor(); Chk(s != null && ApproxColor(timerColor, s.timerWarnColor), "마지막 " + (s != null ? s.timerWarnSeconds : 0f) + "s 경고색 #" + (s != null ? ColorUtility.ToHtmlStringRGB(s.timerWarnColor) : "?") + " — 실측 #" + ColorUtility.ToHtmlStringRGB(timerColor)); // 존 클리어 4회 + 펄스 float now = Time.unscaledTime; for (int i = 0; i < 4; i++) WLRunUiProbeHooks.RaiseZoneCleared(i, 813001 + i, 30f + i * 20f, 12, 12, i == 3); Chk(RunHud.ZoneClearedSeen == 4, "ZoneCleared 4회 수신"); bool allFull = true; for (int i = 0; i < 4; i++) if (RunHud.ZoneFill(i) < 0.999f) allFull = false; Chk(allFull, "존 칸 4개 전부 채움"); RunHud.Tick(now); float peak = RunHud.ZoneScale(3); RunHud.Tick(now + (s != null ? s.zonePulseSeconds : 0.3f)); float back = RunHud.ZoneScale(3); N("펄스 실측: 시작 scale=" + peak.ToString("F3") + " → " + (s != null ? s.zonePulseSeconds : 0.3f) + "s 뒤 " + back.ToString("F3")); Chk(peak > 1.001f && Mathf.Abs(back - 1f) < 0.001f, "존 클리어 펄스 " + (s != null ? s.zonePulseScale : 0f) + "× → " + (s != null ? s.zonePulseSeconds : 0f) + "s 안에 1.0 복귀"); WLRunUiProbeHooks.RaiseBossGateOpened(150f, RunGateReason.TimeLimit, 813010, true); Chk(RunHud.GateSeen == 1 && RunHud.PhaseText == (s != null ? s.phaseBossText : "BOSS"), "게이트 개방 → 단계 라벨 \"" + RunHud.PhaseText + "\""); var phaseColor = FindPhaseColor(); Chk(s != null && ApproxColor(phaseColor, s.phaseBossColor), "보스 단계 색 #" + ColorUtility.ToHtmlStringRGB(phaseColor)); WLRunUiProbeHooks.RaiseBossStarted(155f, 813010); Chk(RunHud.BossSeen == 1, "BossStarted 수신 1 (boss=null 계약 경로 · 예외 0)"); N(RunHud.Dump().TrimEnd()); } static Color FindTimerColor() { var t = FindLabel("Timer"); return t != null ? t.color : Color.magenta; } static Color FindPhaseColor() { var t = FindLabel("Phase"); return t != null ? t.color : Color.magenta; } static TMP_Text FindLabel(string name) { if (_root == null) return null; var all = _root.GetComponentsInChildren(true); for (int i = 0; i < all.Length; i++) if (all[i] != null && all[i].name == name && all[i].transform.parent != null && all[i].transform.parent.name == "WL_RunHud") return all[i]; return null; } static bool ApproxColor(Color a, Color b) { return Mathf.Abs(a.r - b.r) < 0.01f && Mathf.Abs(a.g - b.g) < 0.01f && Mathf.Abs(a.b - b.b) < 0.01f; } // ─────────────────────────────────────────────── ③ 결과 Win static float[] _srcZoneSec; static int[] _srcZoneKills; static int[] _srcLoot; static RunResult MakeResult(RunOutcome outcome) { // 813p 처럼 **같은 배열을 재사용**한다(참조 보관 금지 계약을 그대로 재현) if (_srcZoneSec == null) { _srcZoneSec = new float[4]; _srcZoneKills = new int[4]; _srcLoot = new int[10]; } _srcZoneSec[0] = 31.5f; _srcZoneSec[1] = 62.25f; _srcZoneSec[2] = 95f; _srcZoneSec[3] = -1f; _srcZoneKills[0] = 12; _srcZoneKills[1] = 14; _srcZoneKills[2] = 12; _srcZoneKills[3] = 5; for (int g = 0; g < 10; g++) _srcLoot[g] = 0; _srcLoot[1] = 7; _srcLoot[2] = 3; _srcLoot[4] = 2; _srcLoot[9] = 1; // 등급 3·5~8 은 0 → 생략돼야 한다 return new RunResult { outcome = outcome, runIndex = 2, totalSec = 154f, // 02:34 runSeconds = 240f, zoneClearSec = _srcZoneSec, zoneKills = _srcZoneKills, zoneCount = 4, zonesCleared = 3, bossGateSec = 150f, bossStartSec = 152f, bossClearSec = outcome == RunOutcome.Win ? 153.5f : -1f, kills = 99, bossKills = outcome == RunOutcome.Win ? 1 : 0, maxChain = 9, maxDamage = 1234.5, maxDamageBurst = 8888.5, levelFrom = 11, levelTo = 14, lootByGrade = _srcLoot, lootTotal = 13, gold = 777, deaths = 2, potionsUsed = 3, skillCasts = 7, time = Time.unscaledTime, frame = Time.frameCount, }; } static void Step3_ResultWin() { H("③ 결과 화면 — RunEnded(Win)"); var s = WLRunUiSettings.Instance; RunResultUI.ResetState(); var r = MakeResult(RunOutcome.Win); WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(RunResultUI.EndedSeen == 1, "RunEnded 수신 1"); Chk(RunResultUI.IsOpen, "결과 화면 표시(부활 팝업 없음)"); Chk(RunResultUI.TitleText == (s != null ? s.resultWinTitle : ""), "제목 = \"" + (s != null ? s.resultWinTitle : "") + "\" — 실측 \"" + RunResultUI.TitleText + "\""); Chk(!RunHud.Visible, "런 종료 → HUD 숨김(runHudHideOnEnd)"); var c = RunResultUI.Copied; Chk(c.outcome == RunOutcome.Win && c.runIndex == 2 && Mathf.Approximately(c.totalSec, 154f) && c.kills == 99 && c.bossKills == 1 && c.maxChain == 9 && c.levelFrom == 11 && c.levelTo == 14 && c.gold == 777 && c.deaths == 2 && c.potionsUsed == 3 && c.zonesCleared == 3, "복사본 스칼라 = 입력과 일치(outcome/runIndex/total/kills/boss/chain/level/gold/deaths/potions/zones)"); N("행 값 실측:"); N(" 총 시간 = \"" + RunResultUI.RowValueText(WLRunResultRow.TotalTime) + "\""); N(" 보스 처치 = \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\""); N(" 처치 수 = \"" + RunResultUI.RowValueText(WLRunResultRow.Kills) + "\""); N(" 최대 연쇄 = \"" + RunResultUI.RowValueText(WLRunResultRow.MaxChain) + "\""); N(" 최대 피해 = \"" + RunResultUI.RowValueText(WLRunResultRow.MaxDamage) + "\""); N(" 레벨 = \"" + RunResultUI.RowValueText(WLRunResultRow.Level) + "\""); N(" 등급별 = \"" + RunResultUI.RowValueText(WLRunResultRow.LootByGrade) + "\""); N(" 골드 = \"" + RunResultUI.RowValueText(WLRunResultRow.Gold) + "\""); N(" 사망/물약 = \"" + RunResultUI.RowValueText(WLRunResultRow.DeathsPotions) + "\""); Chk(RunResultUI.RowValueText(WLRunResultRow.TotalTime) == "02:34", "총 시간 154s → 02:34"); Chk(RunResultUI.RowValueText(WLRunResultRow.BossClear) == "02:34", "보스 처치 153.5s → 02:34(올림)"); Chk(RunResultUI.RowValueText(WLRunResultRow.Kills) == "99 (보스 1)", "처치 수 = 99 (보스 1)"); Chk(RunResultUI.RowValueText(WLRunResultRow.MaxChain) == "9", "최대 연쇄 = 9"); Chk(RunResultUI.RowValueText(WLRunResultRow.MaxDamage) == "8889", "최대 피해 = 합산(maxDamageBurst 8888.5 → 8889 · 0.5 는 올림) · 단일(1234.5)이 아니다 — 실측 \"" + RunResultUI.RowValueText(WLRunResultRow.MaxDamage) + "\""); Chk(RunResultUI.RowValueText(WLRunResultRow.Level) == "11 → 14", "레벨 11 → 14"); Chk(RunResultUI.RowValueText(WLRunResultRow.Gold) == "777", "골드 = 777"); Chk(RunResultUI.RowValueText(WLRunResultRow.DeathsPotions) == "2 / 3", "사망/물약 = 2 / 3"); string loot = RunResultUI.RowValueText(WLRunResultRow.LootByGrade); Chk(loot.Contains(">7") && loot.Contains(">3") && loot.Contains(">2") && loot.Contains(">1"), "등급별 획득 = 1급 7 · 2급 3 · 4급 2 · 9급 1 (0 인 등급 생략 · 4칸)"); int colorTags = 0; for (int i = 0; i + 6 < loot.Length; i++) if (loot.Substring(i, 7) == "(true) : new ReviveDialog[0]; Chk(dlg != null && dlg.Length > 0, "프리팹에서 ReviveDialog 발견 — " + (dlg != null ? dlg.Length : 0) + "개"); if (dlg == null || dlg.Length == 0) return; var d = dlg[0]; N("ReviveDialog.Initialize : " + d.Initialize()); N("ReviveDialog.ShowNowOnce: " + d.ShowNowOnce()); Chk(ReviveDialog.Busy, "부활 팝업 Busy = " + ReviveDialog.Busy); RunResultUI.Hide(); int deferBefore = RunResultUI.DeferredCount; var r = MakeResult(RunOutcome.Win); WLRunUiProbeHooks.RaiseRunEnded(in r); Chk(!RunResultUI.IsOpen, "결과 화면 아직 안 뜬다(부활 우선)"); Chk(RunResultUI.Pending, "결과 대기 중 = " + RunResultUI.Pending); Chk(RunResultUI.DeferredCount > deferBefore, "미룸 카운터 +" + (RunResultUI.DeferredCount - deferBefore)); d.HideNow(); Chk(!ReviveDialog.Busy, "부활 팝업 닫힘 → Busy=false"); N("Tick: " + RunResultUI.Tick(Time.unscaledTime)); Chk(RunResultUI.IsOpen, "부활 팝업이 닫힌 뒤 결과 화면 표시"); Chk(!RunResultUI.Pending, "대기 해제"); // [다음 런] · [닫기] int nextBefore = RunResultUI.NextRunCount; N("[다음 런] : " + RunResultUI.OnNextRun()); Chk(RunResultUI.NextRunCount == nextBefore + 1, "[다음 런] 클릭 경로 = RunDirector.Restart() 1회"); Chk(!RunResultUI.IsOpen, "[다음 런] 뒤 결과 화면 닫힘"); N("RunDirector 상태: phase=" + RunDirector.Phase + " runIndex=" + RunDirector.RunIndex + " restartCount=" + RunDirector.RestartCount); RunResultUI.Show(); N("[닫기] : " + RunResultUI.OnClose()); Chk(!RunResultUI.IsOpen, "[닫기] 뒤 결과 화면 닫힘"); } // ─────────────────────────────────────────────── ⑧ GC static void Step8_Gc() { H("⑧ GC — 틱 100회 할당 0 (대조군 2종)"); // 계기: GC.GetAllocatedBytesForCurrentThread() = 이 스레드가 지금까지 할당한 누적 바이트(정밀). // GC.GetTotalMemory 는 힙 페이지 단위라 이 규모에서 감도가 없다(대조군 B 로 실측 확인). N("계기 = GC.GetTotalMemory (Boehm 힙 크기) + GC.CollectionCount(0) · " + "GetAllocatedBytesForCurrentThread 절대값=" + GC.GetAllocatedBytesForCurrentThread() + " (0 이면 이 런타임에 미구현 → 쓰지 않는다)"); // 본시험: 같은 초를 100번 틱한다 → 표시값이 안 바뀌므로 SetText 0회 RunHud.SetVisible(true); WLRunUiProbeHooks.RaiseRunTick(40f, 200f, 0, 6, 12, 0, 4, RunPhase.Zones, 1); // 준비(첫 쓰기) int wBefore = RunHud.TimerWrites + RunHud.PhaseWrites + RunHud.FillWrites; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long n0 = GC.GetAllocatedBytesForCurrentThread(); long m0 = GC.GetTotalMemory(true); int g0 = GC.CollectionCount(0); for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseRunTick(40f, 200f, 0, 6, 12, 0, 4, RunPhase.Zones, 1); long n1 = GC.GetAllocatedBytesForCurrentThread(); long m1 = GC.GetTotalMemory(false); int g1 = GC.CollectionCount(0); int wAfter = RunHud.TimerWrites + RunHud.PhaseWrites + RunHud.FillWrites; N("본시험(같은 초 100틱): ΔGetTotalMemory=" + (m1 - m0) + " B · gc0 " + g0 + "→" + g1 + " · 표시 쓰기 " + wBefore + "→" + wAfter + " (Δ할당 계기 참고값 " + (n1 - n0) + " B · 미구현)"); Chk(wAfter == wBefore, "🔴 구조 증명: 값이 그대로면 SetText/fillAmount 쓰기 0회 = 만들 문자열 자체가 없다"); Chk(m1 - m0 == 0 && g1 == g0, "ΔGC = 0 B · gc0 불변 (RunTick 100회)"); // 대조군 A: 초가 매번 바뀌는 100틱 — 값이 바뀌므로 SetText 를 100번 부른다 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long a0 = GC.GetTotalMemory(true); int ag0 = GC.CollectionCount(0); int wa0 = RunHud.TimerWrites; for (int i = 0; i < 100; i++) WLRunUiProbeHooks.RaiseRunTick(i, 200f - i, 0, 6, 12, 0, 4, RunPhase.Zones, 1); long a1 = GC.GetTotalMemory(false); N("대조군 A(초가 바뀌는 100틱 · SetText " + (RunHud.TimerWrites - wa0) + "회): ΔGetTotalMemory=" + (a1 - a0) + " B · gc0 " + ag0 + "→" + GC.CollectionCount(0) + " — 우리 코드의 mm:ss 는 char 버퍼(문자열 0)이고, 에디터에서는 TMP 가 " + "`#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로 인스펙터용 문자열을 만든다(빌드에는 없는 줄)."); // 대조군 B: 측정 감도(813p·813i 선례) GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long bt0 = GC.GetTotalMemory(true); int bg0 = GC.CollectionCount(0); var sink = new List(200); for (int i = 0; i < 200; i++) sink.Add(new string((char)('a' + (i % 26)), 40000)); // ≈ 16 MB long bt1 = GC.GetTotalMemory(false); int bg1 = GC.CollectionCount(0); N("대조군 B(40,000자 문자열 200개 ≈ 16 MB): ΔGetTotalMemory=" + (bt1 - bt0) + " B · gc0 " + bg0 + "→" + bg1 + " (sink=" + sink.Count + ") — 계기 감도 확인. 100 KB 규모는 Boehm 힙 여유분에 묻혀 Δ0 이 나온다(실측)."); Chk(bt1 - bt0 > 0, "측정 감도 있음(대조군 B ΔGetTotalMemory > 0)"); sink.Clear(); // mm:ss 포맷 자체 검증 var buf = new char[8]; Chk(new string(buf, 0, RunHud.FormatMMSS(buf, 0f)) == "00:00" && new string(buf, 0, RunHud.FormatMMSS(buf, -5f)) == "00:00" && new string(buf, 0, RunHud.FormatMMSS(buf, 59.4f)) == "01:00" && new string(buf, 0, RunHud.FormatMMSS(buf, 240f)) == "04:00" && new string(buf, 0, RunHud.FormatMMSS(buf, 6000f)) == "99:59", "mm:ss 경계값(0 · 음수 · 올림 · 240 · 상한 99:59)"); } // ─────────────────────────────────────────────── ⑨ C8 static void Step9_C8() { H("⑨ C8 롤백 — enabled = 0 이면 HUD·결과 0 · 813p 는 그대로"); var s = WLRunUiSettings.Instance; bool runBefore = WLRunSettings.Enabled; WLRunUiSettings.RuntimeDisabled = true; Chk(!WLRunUiSettings.Enabled, "RuntimeDisabled=1 → Enabled=False"); N("HUD Tick: " + RunHud.Tick(Time.unscaledTime)); N("결과 Tick: " + RunResultUI.Tick(Time.unscaledTime)); Chk(!RunHud.Visible && !RunResultUI.IsOpen, "HUD 숨김 · 결과 닫힘"); N("Bind(off): " + RunHud.Bind(null)); WLRunUiSettings.RuntimeDisabled = false; // 에셋 필드 자체(enabled_)도 같은 게이트를 탄다 — 메모리에서만 껐다가 즉시 되돌린다(저장 0) if (s != null) { bool saved = s.enabled_; s.enabled_ = false; Chk(!WLRunUiSettings.Enabled, "asset.enabled_=0 → Enabled=False (같은 게이트)"); s.enabled_ = saved; Chk(WLRunUiSettings.Enabled, "복구 후 Enabled=True"); } Chk(WLRunSettings.Enabled == runBefore, "813p WLRunSettings.Enabled 불변 = " + WLRunSettings.Enabled + " (런 구조는 그대로 동작)"); } // ─────────────────────────────────────────────── ⑩ 정리 static void Step10_Cleanup(GameObject prefabRoot) { H("⑩ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0"); try { N("RunHud : " + RunHud.Teardown()); N("RunResultUI: " + RunResultUI.Teardown()); bool zero = RunEvents.RunStarted.Count == 0 && RunEvents.RunTick.Count == 0 && RunEvents.ZoneCleared.Count == 0 && RunEvents.BossGateOpened.Count == 0 && RunEvents.BossStarted.Count == 0 && RunEvents.RunEnded.Count == 0; Chk(zero, "RunEvents 구독자 전부 0 (Started " + RunEvents.RunStarted.Count + " Tick " + RunEvents.RunTick.Count + " Zone " + RunEvents.ZoneCleared.Count + " Gate " + RunEvents.BossGateOpened.Count + " Boss " + RunEvents.BossStarted.Count + " Ended " + RunEvents.RunEnded.Count + ")"); N("RunEvents.TotalRaised=" + RunEvents.TotalRaised + " last=" + RunEvents.LastEvent + " · Dispatched(Tick)=" + RunEvents.RunTick.Dispatched); Chk(Mathf.Approximately(Time.timeScale, 1f), "TimeScale 무접촉 = " + Time.timeScale); } catch (Exception e) { _o.AppendLine(" [FAIL] 정리 예외 " + e.GetType().Name + " : " + e.Message); _fail++; } if (prefabRoot != null) { PrefabUtility.UnloadPrefabContents(prefabRoot); // 저장 인자 없음 = 저장하지 않는다 _root = null; N("UnloadPrefabContents — 프리팹 저장 0"); } } }