// WL-815d 프로브 — 스테이지 UI 실측 (에디트 모드 · Play 0 · 로그인 0). // unity command run_script --file AgentScripts/WL815d_Probe.cs --entry WL815d_Probe.RunAll // unity command run_script --file AgentScripts/WL815d_Probe.cs --entry WL815d_Probe.Capture // 산출물: /AgentScripts/WL815d_PROBE.txt · Screenshots_WL/WL815d/*.png // 🔴 NewGameUI.prefab 은 LoadPrefabContents 로 열고 **저장하지 않는다**(UnloadPrefabContents(false)). using System; using System.IO; using System.Text; using UnityEngine; using UnityEditor; using TMPro; using WL.UI; using WL.Combat.Survival; public static class WL815d_Probe { const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; static StringBuilder _o; static int _pass, _fail; static GameObject _root; static long _prefabLen; static DateTime _prefabTime; 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("# WL815d Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); _o.AppendLine("playMode=" + Application.isPlaying); bool savedStageUiDisabled = WLStageUiSettings.RuntimeDisabled; try { Step0_Assets(); Step1_BarrierHook(); Step2_Nodes(); Step3_HudThreeItems(); Step4_Menus(); Step5_Clear(); Step6_Fail(); Step7_Counters(); Step8_Gc(); Step9_C8(); } catch (Exception e) { _fail++; _o.AppendLine(" [FAIL] 예외 " + e.GetType().Name + " : " + e.Message); _o.AppendLine(e.StackTrace); } finally { WLStageUiSettings.RuntimeDisabled = savedStageUiDisabled; Cleanup(); } _o.AppendLine(); _o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " (pass=" + _pass + " fail=" + _fail + ")"); string path = Path.Combine(Path.Combine(Root, "AgentScripts"), "WL815d_PROBE.txt"); File.WriteAllText(path, _o.ToString(), new UTF8Encoding(false)); return _o.ToString() + "\n(파일: " + path + ")"; } // ─────────────────────────────────────────────── ⓪ 에셋 · SO 값 static void Step0_Assets() { H("⓪ 에셋 · SO 값 (전부 C45)"); var pf = new FileInfo(Path.Combine(Root, kPrefab.Replace('/', Path.DirectorySeparatorChar))); _prefabLen = pf.Length; _prefabTime = pf.LastWriteTimeUtc; N("원본 프리팹 기준선: " + kPrefab + " " + _prefabLen + "B · " + _prefabTime.ToString("yyyy-MM-dd HH:mm:ss") + "Z"); WLStageUiSettings.ClearCache(); var g = WLStageUiSettings.Instance; Chk(g != null, "Resources.Load(\"" + WLStageUiSettings.ResourcesPath + "\") — NEW 에셋"); if (g == null) return; Chk(WLStageUiSettings.Enabled, "WLStageUiSettings.Enabled = " + WLStageUiSettings.Enabled); N("HUD: stageLabel=\"" + g.stageLabelFormat + "\" enemy=\"" + g.enemyLabelFormat + "\" warn=" + g.timerWarnSeconds + "s countdown=\"" + g.countdownText + "\" 존게이지숨김=" + g.hideZoneGaugeInStage); N("메뉴: hide=" + g.hideMenusInStage + " 목록 " + (g.stageHidePaths != null ? g.stageHidePaths.Length : 0) + "개 · 유지 표기 " + (g.stageKeepPaths != null ? g.stageKeepPaths.Length : 0) + "개 · 복원=" + g.restoreMenusOnExit + " · 룩토글유지=" + g.keepLookToggleButtons); N("결과: 제목=\"" + g.stageClearTitleFormat + "\" 행 " + (g.stageResultRows != null ? g.stageResultRows.Length : 0) + "개 [" + string.Join(" · ", Array.ConvertAll(g.stageResultRows, r => r.ToString())) + "] · 버튼 [" + g.nextStageText + "] [" + g.toLobbyText + "] · 배지=\"" + g.noHitBadgeText + "\""); N("실패: [" + g.failRetryText + "] [" + g.failLobbyText + "] · 사망=\"" + g.failDeathMessage.Replace("\n", " / ") + "\" · 시간초과=\"" + g.failTimeoutMessage.Replace("\n", " / ") + "\" · 결과창억제=" + g.suppressResultOnFail); N("로비: lobbyMode=" + g.lobbyMode + " titleScene=\"" + g.titleSceneName + "\""); Chk(Mathf.Approximately(g.timerWarnSeconds, 10f), "타이머 경고 = 마지막 10 s(PD 60 s 규칙) — 실측 " + g.timerWarnSeconds); var c = WL.Combat.Stage.WLStageSettings.Instance; Chk(c != null, "815b WLStageSettings 존재"); if (c != null) { Chk(c.autoEnterOnMapEnter, "F-27 autoEnterOnMapEnter = 1 (맵 진입 = 스테이지 1) — 실측 " + c.autoEnterOnMapEnter); Chk(!c.bindReviveOkToRetry, "bindReviveOkToRetry = 0 (815d 2버튼 팝업이 대신한다) — 실측 " + c.bindReviveOkToRetry); Chk(c.toLobbyCallsReturnToLobby, "toLobbyCallsReturnToLobby = 1 (로비 ⓐ안) — 실측 " + c.toLobbyCallsReturnToLobby); Chk(c.useBarrierHook && c.clearBarrierOnExit, "장벽 훅 사용 on/off = " + c.useBarrierHook + "/" + c.clearBarrierOnExit); N("기타: mirrorRunEnded=" + c.mirrorRunEnded + " mirrorRunHudEvents=" + c.mirrorRunHudEvents + " clearHoldScale=" + c.clearHoldScale + " wrapToFirstStage=" + c.wrapToFirstStage); } var sv = WLSurvivalSettings.Instance; if (sv != null) Chk(Mathf.Approximately(sv.reviveWatchdogSeconds, 0f), "자동 부활 0 (reviveWatchdogSeconds) — 실측 " + sv.reviveWatchdogSeconds); var bs = WL.Combat.Stage.WLStageBarrierSettings.Instance; if (bs != null) { N("815c 장벽: style=" + bs.style + "(0 None/1 Ring/2 Props/3 Fire) enabled=" + bs.enabled + " showVisual=" + bs.showVisual + " blockEnabled=" + bs.blockEnabled); Chk((int)bs.style == 2, "F-26 style = 2(Props · 울타리) — 실측 " + (int)bs.style); } } // ─────────────────────────────────────────────── ① 장벽 훅 2줄 static void Step1_BarrierHook() { H("① 장벽 훅 — StageBarrierHook.Set/Clear = StageBarrier.Set/Clear"); N(WL.Combat.Stage.StageBarrierBinding.Bind()); Chk(WL.Combat.Stage.StageBarrierHook.HasSet, "StageBarrierHook.Set 대입됨"); Chk(WL.Combat.Stage.StageBarrierHook.HasClear, "StageBarrierHook.Clear 대입됨"); WL.Combat.Stage.StageBarrierHook.ResetDiagnostics(); int set0 = WL.Combat.Stage.StageBarrier.SetCount, clr0 = WL.Combat.Stage.StageBarrier.ClearCount; var center = new Vector3(115f, 0.4f, -22f); WL.Combat.Stage.StageBarrierHook.RaiseSet(center, 14f); Chk(WL.Combat.Stage.StageBarrierHook.SetCalls == 1, "훅 Set 호출 1 — 실측 " + WL.Combat.Stage.StageBarrierHook.SetCalls); Chk(WL.Combat.Stage.StageBarrier.SetCount == set0 + 1, "815c StageBarrier.Set 실제 실행 +1 — 실측 " + (WL.Combat.Stage.StageBarrier.SetCount - set0)); Chk(WL.Combat.Stage.StageBarrier.Active, "장벽 활성 = " + WL.Combat.Stage.StageBarrier.Active + " 중심 " + WL.Combat.Stage.StageBarrier.Center.ToString("F1") + " r=" + WL.Combat.Stage.StageBarrier.Radius.ToString("F1")); WL.Combat.Stage.StageBarrierHook.RaiseClear(); Chk(WL.Combat.Stage.StageBarrier.ClearCount == clr0 + 1, "815c StageBarrier.Clear 실제 실행 +1 — 실측 " + (WL.Combat.Stage.StageBarrier.ClearCount - clr0)); Chk(!WL.Combat.Stage.StageBarrier.Active, "장벽 해제 = " + (!WL.Combat.Stage.StageBarrier.Active)); Chk(WL.Combat.Stage.StageBarrierHook.Faults == 0, "훅 예외 0 — 실측 " + WL.Combat.Stage.StageBarrierHook.Faults); // 실입장 경로(815b StageDirector) 가 훅을 부르는가 WL.Combat.Stage.StageBarrierHook.ResetDiagnostics(); WL.Combat.Stage.StageDirector.ResetAll(); bool entered = false; try { entered = WL.Combat.Stage.StageDirector.Enter(0, "815d probe"); } catch (Exception e) { N("Enter 예외(에디트 모드) " + e.GetType().Name + " : " + e.Message); } N("StageDirector.Enter(0) = " + entered + " · 상태 " + WL.Combat.Stage.StageDirector.State + " · 고정 수 " + WL.Combat.Stage.StageDirector.TotalEnemies + " · 제한 " + WL.Combat.Stage.StageDirector.TimeLimit + "s"); Chk(WL.Combat.Stage.StageBarrierHook.SetCalls >= 1, "입장 시 훅 Set 호출 ≥1 — 실측 " + WL.Combat.Stage.StageBarrierHook.SetCalls + " 중심 " + WL.Combat.Stage.StageBarrierHook.LastCenter.ToString("F1") + " r=" + WL.Combat.Stage.StageBarrierHook.LastRadius.ToString("F1")); WL.Combat.Stage.StageDirector.ToLobby(); Chk(WL.Combat.Stage.StageBarrierHook.ClearCalls >= 1, "이탈 시 훅 Clear 호출 ≥1 — 실측 " + WL.Combat.Stage.StageBarrierHook.ClearCalls); // C8 ① — 훅을 떼면 no-op N(WL.Combat.Stage.StageBarrierBinding.Unbind()); int s1 = WL.Combat.Stage.StageBarrier.SetCount; WL.Combat.Stage.StageBarrierHook.RaiseSet(center, 14f); Chk(WL.Combat.Stage.StageBarrier.SetCount == s1, "Unbind 뒤 815c 실행 0 = no-op(C8 ①)"); N(WL.Combat.Stage.StageBarrierBinding.Bind()); WL.Combat.Stage.StageBarrier.Clear(); N(WL.Combat.Stage.StageBarrierBinding.Dump()); } // ─────────────────────────────────────────────── ② 노드 static void Step2_Nodes() { H("② 노드 — NewGameUI 프리팹(저장 0)에 HUD·결과·실패 팝업 조립"); var root = PrefabUtility.LoadPrefabContents(kPrefab); _root = root; Chk(root != null, "LoadPrefabContents(" + kPrefab + ")"); if (root == null) return; StageUiBridge.SetUiRootForProbe(root.transform); RunHud.Subscribe(true); RunResultUI.Subscribe(true); StageUiBridge.Subscribe(true); N("Bind HUD : " + RunHud.Bind(root.transform)); N("Bind 결과 : " + RunResultUI.Bind(root.transform)); Chk(RunHud.Bound && RunResultUI.Bound, "813q 노드 조립(WL_RunHud · WL_RunResult · HideFlags.DontSave)"); Chk(StageUiBridge.Subscribed, "StageUiBridge 구독 on"); Chk(WL.Combat.Stage.StageEvents.StageEntered.Count >= 1 && WL.Combat.Stage.StageEvents.StageCleared.Count >= 1 && WL.Combat.Stage.StageEvents.StageFailed.Count >= 1 && WL.Combat.Stage.StageEvents.StageExited.Count >= 1, "StageEvents 구독 배선 — Entered " + WL.Combat.Stage.StageEvents.StageEntered.Count + " Countdown " + WL.Combat.Stage.StageEvents.CountdownTick.Count + " Started " + WL.Combat.Stage.StageEvents.StageStarted.Count + " Enemy " + WL.Combat.Stage.StageEvents.EnemyCountChanged.Count + " Time " + WL.Combat.Stage.StageEvents.TimeChanged.Count + " Cleared " + WL.Combat.Stage.StageEvents.StageCleared.Count + " Failed " + WL.Combat.Stage.StageEvents.StageFailed.Count + " Exited " + WL.Combat.Stage.StageEvents.StageExited.Count); var dlg = FindRevive(root.transform); Chk(dlg != null, "813z ReviveDialog 컴포넌트 발견(IngameUIs/WL_HUD/WL_ReviveDialog)"); if (dlg != null) N("ReviveDialog init: " + dlg.Initialize()); Chk(ReviveDialog.Active != null, "ReviveDialog.Active 세팅"); } static ReviveDialog FindRevive(Transform root) { var all = root.GetComponentsInChildren(true); return all != null && all.Length > 0 ? all[0] : null; } // ─────────────────────────────────────────────── ③ 상단 3요소 static void Step3_HudThreeItems() { H("③ 상단 3요소 — 스테이지 N · 남은 시간 mm:ss · 남은 적 M/T (+ 카운트다운)"); RunHud.ResetState(); StageUiBridge.ResetState(); WLStageUiProbeHooks.RaiseEntered(0, 1, 8, 60f, new Vector3(115f, 0.4f, -22f), 14f, 1.5f, 0); Chk(StageUiBridge.EnteredSeen == 1, "StageEntered 수신 1"); Chk(RunHud.StageMode, "RunHud 스테이지 모드 on"); Chk(RunHud.PhaseText == "스테이지 1", "① 단계 = \"스테이지 1\" — 실측 \"" + RunHud.PhaseText + "\""); Chk(RunHud.TimerText == "01:00", "② 타이머 = 01:00(제한 60 s) — 실측 \"" + RunHud.TimerText + "\""); Chk(RunHud.EnemyText == "남은 적 8 / 8", "③ 남은 적 = \"남은 적 8 / 8\" — 실측 \"" + RunHud.EnemyText + "\""); Chk(!RunHud.ZoneGaugeVisible, "존 게이지 접힘(기준서 §F-2) — 실측 활성=" + RunHud.ZoneGaugeVisible); WLStageUiProbeHooks.RaiseCountdown(0, 1.5f, 1.5f); Chk(RunHud.NoticeVisible && RunHud.NoticeText == "준비", "카운트다운 = \"준비\" 표시 — 실측 보임=" + RunHud.NoticeVisible + " \"" + RunHud.NoticeText + "\""); WLStageUiProbeHooks.RaiseStarted(0, 1, 8, 60f, 0); Chk(!RunHud.NoticeVisible, "전투 시작 = 카운트다운 줄 접힘 — 실측 " + RunHud.NoticeVisible); WLStageUiProbeHooks.RaiseEnemyCount(0, 5, 8); Chk(RunHud.EnemyText == "남은 적 5 / 8", "처치 3 → \"남은 적 5 / 8\" — 실측 \"" + RunHud.EnemyText + "\""); WLStageUiProbeHooks.RaiseTime(0, 42f, 60f); Chk(RunHud.TimerText == "00:42" && !RunHud.TimerWarn, "42 s → 00:42 · 평상색 — 실측 \"" + RunHud.TimerText + "\" warn=" + RunHud.TimerWarn); WLStageUiProbeHooks.RaiseTime(0, 9.4f, 60f); Chk(RunHud.TimerText == "00:10" && RunHud.TimerWarn, "마지막 10 s → 붉은색(경고) — 실측 \"" + RunHud.TimerText + "\" warn=" + RunHud.TimerWarn + " color=#" + ColorUtility.ToHtmlStringRGB(RunHud.TimerColor)); WLStageUiProbeHooks.RaiseEnemyCount(0, 2, 8); Chk(RunHud.EnemyText == "남은 적 2 / 8", "남은 적 2 → 강조색 — 실측 \"" + RunHud.EnemyText + "\""); // 815b 병행 발행(RunStarted/RunTick)이 스테이지 표시를 덮지 않는가 int ignored0 = RunHud.StageEventsIgnored; WLRunUiProbeHooks.RaiseRunStarted(1, 60f, 1, "stage mirror"); WLRunUiProbeHooks.RaiseRunTick(18f, 42f, 0, 6, 8, 0, 1, WL.Combat.Run.RunPhase.Zones, 1); Chk(RunHud.StageEventsIgnored - ignored0 == 2, "병행 RunEvents 2건 무시 — 실측 " + (RunHud.StageEventsIgnored - ignored0)); Chk(RunHud.PhaseText == "스테이지 1" && RunHud.EnemyText == "남은 적 2 / 8" && RunHud.TimerText == "00:10", "🔴 병행 발행이 상단 3요소를 덮지 않는다 — \"" + RunHud.PhaseText + "\" \"" + RunHud.TimerText + "\" \"" + RunHud.EnemyText + "\""); N("HUD 배치: " + RunHud.ApplyLayout()); N(RunHud.Dump()); } // ─────────────────────────────────────────────── ④ 메뉴 숨김 / 복원 static void Step4_Menus() { H("④ 불필요 메뉴 숨김 · 복원 (§J ① · 원본 프리팹 diff 0)"); var g = WLStageUiSettings.Instance; if (_root == null || g == null) { Chk(false, "프리팹 루트 없음"); return; } var paths = g.stageHidePaths; var found = new Transform[paths.Length]; var saved = new bool[paths.Length]; int missing = 0; for (int i = 0; i < paths.Length; i++) { found[i] = WLVignetteUtil.FindUiPath(_root.transform, paths[i]); if (found[i] == null) { missing++; continue; } saved[i] = found[i].gameObject.activeSelf; // 프리팹 저장 상태 } Chk(missing == 0, "숨김 목록 " + paths.Length + "개 전부 실존(경로 해석 성공) — 못 찾음 " + missing); // 🔴 프리팹 **저장 상태**로는 인게임 화면을 재현할 수 없다(IngameUIs 는 저장 시 꺼져 있다). // 실기에서 켜져 있는 상태를 만든 뒤 숨김 → 복원 왕복을 재는 것이 유일한 실측이다. for (int i = 0; i < paths.Length; i++) if (found[i] != null && !found[i].gameObject.activeSelf) found[i].gameObject.SetActive(true); N("숨김: " + StageUiBridge.ApplyMenuHide(true)); var mid = new bool[paths.Length]; for (int i = 0; i < paths.Length; i++) mid[i] = found[i] != null && found[i].gameObject.activeSelf; // 🔴 재보장 — 원본 `NewGameUI.SetUI_byGameMode` 가 스테이지 중에 같은 노드를 다시 켜는 경우 int reOn = 0; for (int i = 0; i < paths.Length && reOn < 3; i++) if (found[i] != null) { found[i].gameObject.SetActive(true); reOn++; } int hit = StageUiBridge.ReassertHide(); int stillOn = 0; for (int i = 0; i < paths.Length; i++) if (found[i] != null && found[i].gameObject.activeSelf) stillOn++; Chk(hit == reOn && stillOn == 0, "원본이 다시 켠 " + reOn + "건을 재보장으로 도로 끔 — 실측 " + hit + "건 · 남은 on " + stillOn + " (주기 " + g.menuReassertSeconds + "s)"); Chk(StageUiBridge.ReassertHide() == 0, "이미 꺼져 있으면 쓰기 0회(GC 0)"); N("복원: " + StageUiBridge.ApplyMenuHide(false)); int restored = 0, hidOk = 0; _o.AppendLine(" | # | 경로(NewGameUI 루트 기준) | 실제 노드 경로 | 프리팹 저장 | 스테이지 중 | 이탈 뒤 |"); _o.AppendLine(" |---|---|---|---|---|---|"); for (int i = 0; i < paths.Length; i++) { bool after = found[i] != null && found[i].gameObject.activeSelf; if (found[i] != null) { if (!mid[i]) hidOk++; if (after) restored++; } _o.AppendLine(" | " + i + " | `" + paths[i] + "` | " + (found[i] != null ? "`" + FullPath(found[i], _root.transform) + "`" : "**없음**") + " | " + (found[i] != null ? (saved[i] ? "on" : "off") : "-") + " | " + (found[i] != null ? (mid[i] ? "on" : "**off**") : "-") + " | " + (found[i] != null ? (after ? "**on**" : "off") : "-") + " |"); } Chk(hidOk == paths.Length - missing, "스테이지 중 전부 SetActive(false) — " + hidOk + "/" + (paths.Length - missing)); Chk(restored == paths.Length - missing, "이탈 시 전부 켜진 값으로 복원 — " + restored + "/" + (paths.Length - missing)); // 프리팹 저장 상태로 되돌린다(미리보기 사본이지만 뒤 단계에 영향 0) for (int i = 0; i < paths.Length; i++) if (found[i] != null && found[i].gameObject.activeSelf != saved[i]) found[i].gameObject.SetActive(saved[i]); // 유지 대상은 건드리지 않았는가 int keptOk = 0, keptMissing = 0; for (int i = 0; i < g.stageKeepPaths.Length; i++) { var t = WLVignetteUtil.FindUiPath(_root.transform, g.stageKeepPaths[i]); if (t == null) { keptMissing++; N(" (유지) " + g.stageKeepPaths[i] + " : 없음"); continue; } keptOk++; N(" (유지) " + g.stageKeepPaths[i] + " activeSelf=" + t.gameObject.activeSelf); } Chk(keptMissing == 0, "유지 목록 " + g.stageKeepPaths.Length + "개 전부 실존 — 없음 " + keptMissing); N("유지 확인 " + keptOk + "개 (조이스틱·스킬 패드 BattleUI · HP/MP MyInfoUI · 813 WL HUD 요소)"); // 🔴 원본 프리팹 **파일**이 바뀌지 않았는가(LoadPrefabContents 는 메모리 사본이라 dirty 플래그는 뜬다 — // 실제 판정은 디스크 파일의 길이·수정시각이다. 저장은 하지 않는다 = UnloadPrefabContents). var fi = new FileInfo(Path.Combine(Root, kPrefab.Replace('/', Path.DirectorySeparatorChar))); Chk(fi.Length == _prefabLen && fi.LastWriteTimeUtc == _prefabTime, "원본 NewGameUI.prefab 파일 무변경 — 길이 " + fi.Length + "B(=" + _prefabLen + ") 수정시각 " + fi.LastWriteTimeUtc.ToString("HH:mm:ss") + "(=" + _prefabTime.ToString("HH:mm:ss") + ")"); N("(참고) 미리보기 사본 dirty=" + EditorUtility.IsDirty(_root) + " — 런타임 노드는 HideFlags.DontSave 라 저장해도 굽히지 않는다(813y 규약)"); } // ─────────────────────────────────────────────── ⑤ 클리어 화면 static void Step5_Clear() { H("⑤ 클리어 화면 — StageCleared → 813q RunResultUI (기준서 §F-1)"); var g = WLStageUiSettings.Instance; RunResultUI.ResetState(); StageUiBridge.ResetState(); StageUiBridge.ProbeSetInStage(true); // 피격 3회 · 연쇄 5 를 스테이지 구간 동안 실측 WLStageUiProbeHooks.RaiseDamaged(null); // victim 이 PC 가 아니면 세지 않는다 Chk(StageUiBridge.Hits == 0, "Damaged(victim=null)는 피격으로 세지 않는다 — 실측 " + StageUiBridge.Hits); StageUiBridge.ProbeNoteHit(); StageUiBridge.ProbeNoteHit(); StageUiBridge.ProbeNoteHit(); WLStageUiProbeHooks.RaiseChainEnded(5, 2, 1.2f); WLStageUiProbeHooks.RaiseChainEnded(3, 1, 0.8f); Chk(StageUiBridge.Hits == 3, "피격 3회 집계 — 실측 " + StageUiBridge.Hits); Chk(StageUiBridge.MaxChain == 5, "최대 연쇄 = 5(3 은 갱신 안 함) — 실측 " + StageUiBridge.MaxChain); WLStageUiProbeHooks.RaiseCleared(0, 1, 34.2f, 60f, 8, 8, 1, false, false); Chk(StageUiBridge.ClearedSeen == 1, "StageCleared 수신 1"); Chk(RunResultUI.IsOpen, "결과 화면 표시 — 실측 open=" + RunResultUI.IsOpen); Chk(RunResultUI.StageMode, "결과창 스테이지 모드 on"); Chk(RunResultUI.TitleText == "스테이지 1 클리어!", "제목 = \"스테이지 1 클리어!\" — 실측 \"" + RunResultUI.TitleText + "\""); N("본문 =\n" + Indent(RunResultUI.BodyText)); Chk(RunResultUI.VisibleRows == g.stageResultRows.Length, "행 " + g.stageResultRows.Length + "개 — 실측 " + RunResultUI.VisibleRows); Chk(!RunResultUI.BadgeVisible, "피격 3회 → 무피격 배지 없음 — 실측 " + RunResultUI.BadgeVisible); N("배치: " + RunResultUI.ApplyLayout()); // 병행 RunEnded(Win) 이 결과창을 두 번 띄우지 않는가 int shows0 = RunResultUI.ShownCount; var mirror = new WL.Combat.Run.RunResult { outcome = WL.Combat.Run.RunOutcome.Win, runIndex = 1, totalSec = 34.2f, kills = 8 }; WLRunUiProbeHooks.RaiseRunEnded(in mirror); Chk(RunResultUI.ShownCount == shows0, "🔴 병행 RunEnded(Win) 로 결과창 중복 표시 0 — 실측 +" + (RunResultUI.ShownCount - shows0) + " · 억제 " + RunResultUI.SuppressedRunEndedCount + "회"); Chk(RunResultUI.TitleText == "스테이지 1 클리어!", "제목이 「런 클리어!」로 덮이지 않았다 — \"" + RunResultUI.TitleText + "\""); // 무피격 클리어 → 배지 StageUiBridge.ResetState(); StageUiBridge.ProbeSetInStage(true); WLStageUiProbeHooks.RaiseCleared(1, 2, 21.5f, 60f, 10, 10, 0, false, false); Chk(RunResultUI.BadgeVisible && RunResultUI.BadgeText == "무피격 클리어!", "피격 0회 → 무피격 배지 — 실측 " + RunResultUI.BadgeVisible + " \"" + RunResultUI.BadgeText + "\""); // 마지막 스테이지 → [처음부터] WLStageUiProbeHooks.RaiseCleared(9, 10, 55.0f, 60f, 15, 15, 2, true, true); Chk(RunResultUI.TitleText == "전 스테이지 클리어!", "마지막 스테이지 제목 — 실측 \"" + RunResultUI.TitleText + "\""); N("마지막 스테이지 배치: " + RunResultUI.ApplyLayout()); N("본문(보스 스테이지) =\n" + Indent(RunResultUI.BodyText)); } // ─────────────────────────────────────────────── ⑥ 실패 팝업 static void Step6_Fail() { H("⑥ 실패 팝업 — StageFailed → 813z ReviveDialog 2버튼 (기준서 §E)"); var dlg = ReviveDialog.Active; if (dlg == null) { Chk(false, "ReviveDialog.Active 없음"); return; } RunResultUI.ResetState(); StageUiBridge.ResetState(); StageUiBridge.ProbeSetInStage(true); WLStageUiProbeHooks.RaiseFailed(0, 1, WL.Combat.Stage.StageFailReason.TimeLimit, 3, 8, 60f, 0); Chk(StageUiBridge.FailedSeen == 1, "StageFailed 수신 1"); Chk(dlg.IsOpen, "실패 팝업 표시 — 실측 open=" + dlg.IsOpen); Chk(ReviveDialog.StageMode, "ReviveDialog 스테이지 모드 on"); Chk(!dlg.UsedCommonPopup, "공용 Popup 미사용(2버튼 불가) — 실측 " + dlg.UsedCommonPopup); N("배치: " + dlg.ApplyLayout()); Chk(dlg.SecondaryVisible, "두 번째 버튼 노출 — 실측 " + dlg.SecondaryVisible); Chk(dlg.PrimaryButtonText == "다시 도전" && dlg.SecondaryButtonText == "로비로", "버튼 = [다시 도전] [로비로] — 실측 [" + dlg.PrimaryButtonText + "] [" + dlg.SecondaryButtonText + "]"); Chk(dlg.LastMessage.Contains("시간 초과") && dlg.LastMessage.Contains("3"), "시간 초과 문구 + 남은 적 3 — 실측 \"" + dlg.LastMessage.Replace("\n", " / ") + "\""); // 🔴 813z 3층 방어 — 전용 Canvas 는 붙었지만 **부모 계층이 비활성인 동안 Unity 가 overrideSorting 을 되돌린다** // (814d §4 실측). 그래서 에디트 모드의 SortingOrder 게터는 −1 이다. SO 값과 재보장 횟수로 확인한다. var sv2 = WLSurvivalSettings.Instance; Chk(sv2 != null && sv2.reviveSortingOrder == 60 && dlg.TopmostCount >= 1, "813z 3층 방어 유지 — SO sortingOrder " + (sv2 != null ? sv2.reviveSortingOrder : -1) + " · 최상단 재보장 " + dlg.TopmostCount + "회 · 실측 게터=" + dlg.SortingOrder + "(부모 계층 비활성 → Unity 가 overrideSorting 되돌림 · 814d §4)"); Chk(RunResultUI.SuppressedRunEndedCount == 0 && !RunResultUI.IsOpen, "실패 시점에 결과창 0"); // 병행 RunEnded(Timeout) 억제 var mirror = new WL.Combat.Run.RunResult { outcome = WL.Combat.Run.RunOutcome.Timeout, runIndex = 1, totalSec = 60f, kills = 5 }; WLRunUiProbeHooks.RaiseRunEnded(in mirror); Chk(!RunResultUI.IsOpen && RunResultUI.SuppressedRunEndedCount == 1, "🔴 병행 RunEnded(Timeout) 억제 → 결과창 안 뜸 — 실측 open=" + RunResultUI.IsOpen + " 억제 " + RunResultUI.SuppressedRunEndedCount + "회"); // 버튼 → StageDirector // 🔴 순서 주의: 실기 순서는 「입장 → 실패 → 클릭」이다. 프로브도 같은 순서로 태운다 // (입장(StageEntered)이 실패 팝업 모드를 지우기 때문 — 이것이 정상 동작이다). WL.Combat.Stage.StageDirector.ResetAll(); try { WL.Combat.Stage.StageDirector.Enter(0, "815d fail probe"); } catch (Exception e) { N("Enter 예외 " + e.GetType().Name); } StageUiBridge.ProbeSetInStage(true); // 사망 문구 WLStageUiProbeHooks.RaiseFailed(0, 1, WL.Combat.Stage.StageFailReason.PlayerDied, 4, 8, 22f, 1); Chk(dlg.LastMessage.Contains("쓰러졌"), "사망 문구 구분 — 실측 \"" + dlg.LastMessage.Replace("\n", " / ") + "\""); Chk(ReviveDialog.StageMode, "사망 실패도 2버튼 모드 — 실측 " + ReviveDialog.StageMode); int retry0 = WL.Combat.Stage.StageDirector.RetryCount; N("클릭 [다시 도전] : " + dlg.ProbeClickPrimary()); Chk(WL.Combat.Stage.StageDirector.RetryCount == retry0 + 1, "[다시 도전] → StageDirector.Retry() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.RetryCount - retry0)); Chk(!dlg.IsOpen, "누르면 팝업 닫힘 — 실측 open=" + dlg.IsOpen); WLStageUiProbeHooks.RaiseFailed(0, 1, WL.Combat.Stage.StageFailReason.PlayerDied, 4, 8, 22f, 2); int lobby0 = WL.Combat.Stage.StageDirector.LobbyCount; N("클릭 [로비로] : " + dlg.ProbeClickSecondary()); Chk(WL.Combat.Stage.StageDirector.LobbyCount == lobby0 + 1, "[로비로] → StageDirector.ToLobby() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.LobbyCount - lobby0)); N(dlg.Dump()); } // ─────────────────────────────────────────────── ⑦ 결과창 버튼 static void Step7_Counters() { H("⑦ 클리어 화면 버튼 → StageDirector.Next() / ToLobby()"); StageUiBridge.ResetState(); StageUiBridge.ProbeSetInStage(true); WL.Combat.Stage.StageDirector.ResetAll(); try { WL.Combat.Stage.StageDirector.Enter(0, "815d btn probe"); } catch (Exception e) { N("Enter 예외 " + e.GetType().Name); } WLStageUiProbeHooks.RaiseCleared(0, 1, 30f, 60f, 8, 8, 0, false, false); int next0 = WL.Combat.Stage.StageDirector.NextCount; N("클릭 [다음 스테이지] : " + RunResultUI.OnNextRun()); Chk(WL.Combat.Stage.StageDirector.NextCount == next0 + 1, "[다음 스테이지] → StageDirector.Next() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.NextCount - next0)); Chk(!RunResultUI.IsOpen, "누르면 결과창 닫힘"); WLStageUiProbeHooks.RaiseCleared(0, 1, 30f, 60f, 8, 8, 0, false, false); int lobby0 = WL.Combat.Stage.StageDirector.LobbyCount; N("클릭 [로비로] : " + RunResultUI.OnClose()); Chk(WL.Combat.Stage.StageDirector.LobbyCount == lobby0 + 1, "[로비로] → StageDirector.ToLobby() 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.LobbyCount - lobby0)); N("로비 동작: " + StageUiBridge.LastLog); // 마지막 스테이지 → [처음부터] = Enter(0) WLStageUiProbeHooks.RaiseCleared(9, 10, 50f, 60f, 15, 15, 0, true, true); int enter0 = WL.Combat.Stage.StageDirector.EnterCount; N("클릭 [처음부터] : " + RunResultUI.OnNextRun()); Chk(WL.Combat.Stage.StageDirector.EnterCount == enter0 + 1, "[처음부터] → StageDirector.Enter(0) 1회 — 실측 +" + (WL.Combat.Stage.StageDirector.EnterCount - enter0)); // 이탈 → 스테이지 모드 해제 + 메뉴 복원 WLStageUiProbeHooks.RaiseExited(0, true, "probe lobby"); Chk(!RunHud.StageMode, "이탈 → RunHud 스테이지 모드 off"); Chk(!RunResultUI.StageMode, "이탈 → 결과창 스테이지 모드 off"); Chk(!ReviveDialog.StageMode, "이탈 → 실패 팝업 모드 off(813z 원래 1버튼)"); N(StageUiBridge.Dump()); } // ─────────────────────────────────────────────── ⑧ GC static void Step8_Gc() { H("⑧ GC — 같은 값 100틱 = 표시 쓰기 0회"); StageUiBridge.ResetState(); WLStageUiProbeHooks.RaiseEntered(0, 3, 12, 60f, Vector3.zero, 14f, 1.5f, 0); WLStageUiProbeHooks.RaiseStarted(0, 3, 12, 60f, 0); WLStageUiProbeHooks.RaiseTime(0, 30f, 60f); WLStageUiProbeHooks.RaiseEnemyCount(0, 7, 12); int t0 = RunHud.TimerWrites, e0 = RunHud.EnemyWrites, p0 = RunHud.StageLabelWrites; long m0 = GC.GetTotalMemory(false); for (int i = 0; i < 100; i++) { WLStageUiProbeHooks.RaiseTime(0, 30f, 60f); WLStageUiProbeHooks.RaiseEnemyCount(0, 7, 12); } long m1 = GC.GetTotalMemory(false); Chk(RunHud.TimerWrites == t0, "같은 초 100틱 → 타이머 쓰기 0회 — 실측 +" + (RunHud.TimerWrites - t0)); Chk(RunHud.EnemyWrites == e0, "같은 수 100틱 → 남은 적 쓰기 0회 — 실측 +" + (RunHud.EnemyWrites - e0)); Chk(RunHud.StageLabelWrites == p0, "같은 스테이지 → 라벨 쓰기 0회 — 실측 +" + (RunHud.StageLabelWrites - p0)); N("ΔGetTotalMemory = " + (m1 - m0) + " B (에디터 TMP 문자열 포함 · 빌드에는 없는 줄)"); WLStageUiProbeHooks.RaiseExited(0, false, "gc probe end"); } // ─────────────────────────────────────────────── ⑨ C8 static void Step9_C8() { H("⑨ C8 — WLStageUiSettings 를 끄면 813q/813z 가 원래대로"); StageUiBridge.ResetState(); RunHud.ResetState(); RunResultUI.ResetState(); WLStageUiSettings.RuntimeDisabled = true; Chk(!WLStageUiSettings.Enabled, "RuntimeDisabled → Enabled=false"); WLStageUiProbeHooks.RaiseEntered(0, 4, 15, 60f, Vector3.zero, 14f, 1.5f, 0); Chk(!RunHud.StageMode, "축 off → 스테이지 모드 0 — 실측 " + RunHud.StageMode); Chk(StageUiBridge.HiddenCount == 0, "축 off → 메뉴 숨김 0 — 실측 " + StageUiBridge.HiddenCount); WLStageUiProbeHooks.RaiseCleared(0, 4, 12f, 60f, 15, 15, 0, false, false); Chk(!RunResultUI.StageMode, "축 off → 결과창 스테이지 모드 0"); WLStageUiProbeHooks.RaiseFailed(0, 4, WL.Combat.Stage.StageFailReason.TimeLimit, 5, 15, 60f, 0); Chk(!ReviveDialog.StageMode, "축 off → 실패 팝업 2버튼 0(813z 원래 부활 팝업)"); // 813q 런 축은 그대로 돈다 Chk(WLRunUiSettings.Enabled, "813q WLRunUiSettings.Enabled 불변 = " + WLRunUiSettings.Enabled); WLRunUiProbeHooks.RaiseRunStarted(2, 240f, 4, "c8 probe"); Chk(RunHud.TimerText == "04:00" && RunHud.PhaseText == "존 1", "813q 런 HUD 원래대로 — 실측 \"" + RunHud.TimerText + "\" \"" + RunHud.PhaseText + "\""); WLStageUiSettings.RuntimeDisabled = false; Chk(WLStageUiSettings.Enabled, "복구 → Enabled=true"); } /// root 기준 전체 경로(실제로 어느 노드를 잡았는지 실측 표기). static string FullPath(Transform t, Transform root) { var sb = new StringBuilder(t.name); var p = t.parent; while (p != null && p != root) { sb.Insert(0, p.name + "/"); p = p.parent; } return sb.ToString(); } static string Indent(string s) { if (string.IsNullOrEmpty(s)) return " (없음)"; return " " + s.TrimEnd('\n').Replace("\n", "\n "); } static void Cleanup() { try { StageUiBridge.Teardown(); RunHud.Teardown(); RunResultUI.Teardown(); WL.Combat.Stage.StageDirector.ResetAll(); WL.Combat.Stage.StageBarrier.Clear(); } catch (Exception e) { _o.AppendLine(" (정리 예외 " + e.GetType().Name + ")"); } if (_root != null) { PrefabUtility.UnloadPrefabContents(_root); _root = null; } } // ═════════════════════════════════════════════════════════════════════ // 캡처 3장 — 1080×1920 목업(실 노드 · 실 값 · RenderTexture 렌더) // ═════════════════════════════════════════════════════════════════════ public static string Capture() { var sb = new StringBuilder(); string dir = Path.Combine(Path.Combine(Root, "Screenshots_WL"), "WL815d"); Directory.CreateDirectory(dir); GameObject rig = null, prefabRoot = null; try { prefabRoot = PrefabUtility.LoadPrefabContents(kPrefab); var revive = FindRevive(prefabRoot.transform); rig = new GameObject("WL815d_CaptureRig"); rig.hideFlags = HideFlags.HideAndDontSave; var camGo = new GameObject("Cam"); camGo.transform.SetParent(rig.transform, false); var cam = camGo.AddComponent(); cam.clearFlags = CameraClearFlags.SolidColor; cam.backgroundColor = new Color(0.09f, 0.10f, 0.12f, 1f); cam.orthographic = true; cam.orthographicSize = 960f; cam.nearClipPlane = 0.1f; cam.farClipPlane = 5000f; camGo.transform.position = new Vector3(0f, 0f, -1000f); var canvasGo = new GameObject("Canvas", typeof(RectTransform)); canvasGo.transform.SetParent(rig.transform, false); canvasGo.layer = 5; var canvas = canvasGo.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceCamera; canvas.worldCamera = cam; canvas.planeDistance = 500f; var scaler = canvasGo.AddComponent(); scaler.uiScaleMode = UnityEngine.UI.CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1080f, 1920f); scaler.matchWidthOrHeight = 0.5f; canvasGo.AddComponent(); var crt = (RectTransform)canvasGo.transform; crt.sizeDelta = new Vector2(1080f, 1920f); // 813q/813z 가 찾는 부모 경로(IngameUIs/WL_HUD)를 목업 캔버스 안에 그대로 만든다 var ingame = NewRect(crt, "IngameUIs"); var wlHud = NewRect(ingame, "WL_HUD"); // 폰트는 원본 UI 에서 빌린다(한글 글리프) var font = RunHud.BorrowFont(prefabRoot.transform); RunHud.SetFont(font); RunResultUI.SetFont(font); StageUiBridge.SetUiRootForProbe(prefabRoot.transform); RunHud.Subscribe(true); RunResultUI.Subscribe(true); StageUiBridge.Subscribe(true); sb.AppendLine("HUD : " + RunHud.Bind(crt)); sb.AppendLine("결과 : " + RunResultUI.Bind(crt)); // 실패 팝업은 목업 캔버스 안에 새 인스턴스로 만든다(원본 프리팹 무수정) var dlgGo = new GameObject("WL_ReviveDialog", typeof(RectTransform)); dlgGo.layer = 5; dlgGo.transform.SetParent(wlHud, false); revive = dlgGo.AddComponent(); revive.SetFont(font); sb.AppendLine("팝업 : " + revive.Initialize()); var rt = new RenderTexture(1080, 1920, 24, RenderTextureFormat.ARGB32); cam.targetTexture = rt; // ① HUD (스테이지 3요소) WLStageUiProbeHooks.RaiseEntered(0, 3, 12, 60f, new Vector3(147f, 2.6f, 30f), 14f, 1.5f, 0); WLStageUiProbeHooks.RaiseStarted(0, 3, 12, 60f, 0); WLStageUiProbeHooks.RaiseEnemyCount(0, 5, 12); WLStageUiProbeHooks.RaiseTime(0, 8f, 60f); RunHud.ApplyLayout(); RunResultUI.Hide(); if (revive != null) revive.HideNow(); sb.AppendLine("① HUD \"" + RunHud.PhaseText + "\" \"" + RunHud.TimerText + "\" \"" + RunHud.EnemyText + "\""); sb.AppendLine(Shot(cam, rt, Path.Combine(dir, "01_hud_stage.png"))); // ② 클리어 화면 StageUiBridge.ProbeSetInStage(true); StageUiBridge.ProbeNoteHit(); WLStageUiProbeHooks.RaiseChainEnded(6, 2, 1.4f); WLStageUiProbeHooks.RaiseCleared(0, 3, 34.2f, 60f, 12, 12, 1, false, false); RunResultUI.ApplyLayout(); sb.AppendLine("② 클리어 \"" + RunResultUI.TitleText + "\" 행=" + RunResultUI.VisibleRows + " 배지=" + RunResultUI.BadgeVisible); sb.AppendLine(Shot(cam, rt, Path.Combine(dir, "02_clear.png"))); // ③ 실패 팝업 RunResultUI.Hide(); StageUiBridge.ProbeSetInStage(true); WLStageUiProbeHooks.RaiseFailed(0, 3, WL.Combat.Stage.StageFailReason.TimeLimit, 4, 12, 60f, 0); if (revive != null) revive.ApplyLayout(); sb.AppendLine("③ 실패 \"" + (revive != null ? revive.LastMessage.Replace("\n", " / ") : "-") + "\" 버튼=[" + (revive != null ? revive.PrimaryButtonText : "") + "] [" + (revive != null ? revive.SecondaryButtonText : "") + "]"); sb.AppendLine(Shot(cam, rt, Path.Combine(dir, "03_fail.png"))); cam.targetTexture = null; UnityEngine.Object.DestroyImmediate(rt); } catch (Exception e) { sb.AppendLine("예외 " + e.GetType().Name + " : " + e.Message + "\n" + e.StackTrace); } finally { try { StageUiBridge.Teardown(); RunHud.Teardown(); RunResultUI.Teardown(); } catch { } if (rig != null) UnityEngine.Object.DestroyImmediate(rig); if (prefabRoot != null) PrefabUtility.UnloadPrefabContents(prefabRoot); } return sb.ToString(); } static RectTransform NewRect(RectTransform parent, string name) { var go = new GameObject(name, typeof(RectTransform)); go.layer = 5; var rt = (RectTransform)go.transform; rt.SetParent(parent, false); rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; rt.localScale = Vector3.one; return rt; } static string Shot(Camera cam, RenderTexture rt, string path) { Canvas.ForceUpdateCanvases(); cam.Render(); var prev = RenderTexture.active; RenderTexture.active = rt; var tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false); tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); tex.Apply(); RenderTexture.active = prev; var png = tex.EncodeToPNG(); UnityEngine.Object.DestroyImmediate(tex); File.WriteAllBytes(path, png); return " → " + path + " (" + png.Length + " B · " + rt.width + "×" + rt.height + ")"; } }