// WL-813z 프로브 — Q4-a 핫픽스 실측(에디트 모드 · Play 0 · #813). // run_script --file AgentScripts/WL813z_Probe.cs --entry WL813z_Probe. // Watchdog · Topmost · Toast · All // 로그는 /Logs/WL813z_*.txt (Assets 밖 · 미추적). // // 🔴 에디트 모드에서 `Time.timeScale` 에 **쓰지 않는다** — 쓰면 ProjectSettings/TimeManager.asset 이 더러워진다 // (811b-fix 실측 · DeathFlow.HoldWorld 도 같은 이유로 `Application.isPlaying` 가드를 둔다). // 세계 정지 복원은 ① `DeathFlow.WorldHeld` 전이 ② `MyValue.Get_GameSpeed()` 실측값 ③ `Time.timeScale` 읽기로 증명한다. // 🔴 씬·프리팹을 저장하지 않는다. 만든 노드는 finally 에서 전부 DestroyImmediate. // 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복한다(AssetDatabase.SaveAssets 호출 0). using System; using System.IO; using System.Reflection; using System.Text; using TMPro; using UnityEngine; using UnityEngine.UI; using UnityEditor; using WL.Combat.Survival; using WL.UI; public static class WL813z_Probe { const BindingFlags NP = BindingFlags.Instance | BindingFlags.NonPublic; // ─────────────────────────────────────────────────────────── 공통 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 Ok(bool b) { return b ? "PASS" : "**FAIL**"; } /// 세로 1080×1920 기준(참조 1920×1080 Expand)의 합성 캔버스 + Safe Area 부모. static RectTransform BuildCanvas(out GameObject root, out Canvas canvas, out float unitsPerPx) { root = new GameObject("__WL813z_Canvas", typeof(RectTransform)); root.hideFlags = HideFlags.DontSave; canvas = root.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; var scaler = root.AddComponent(); scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.referenceResolution = new Vector2(1920f, 1080f); // 원본 NewGameUI 와 같은 설정(802b 실측 1.7778) scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand; root.AddComponent(); var s = WLCombatTextSettings.Instance; unitsPerPx = s != null ? s.UnitsPerPx(canvas) : 1f; // WL_HUD 대역(SafeAreaFitter 아래 = Safe Area) — 배치가 화면 크기에 흔들리지 않게 크기를 못박는다. var hud = new GameObject("WL_HUD", typeof(RectTransform)).GetComponent(); hud.gameObject.hideFlags = HideFlags.DontSave; hud.SetParent(root.transform, false); hud.anchorMin = hud.anchorMax = new Vector2(0.5f, 0.5f); hud.pivot = new Vector2(0.5f, 0.5f); hud.anchoredPosition = Vector2.zero; hud.sizeDelta = new Vector2(1080f * unitsPerPx, 1920f * unitsPerPx); return hud; } static RectTransform Decoy(RectTransform parent, string n) { var rt = new GameObject(n, typeof(RectTransform)).GetComponent(); rt.gameObject.hideFlags = HideFlags.DontSave; rt.SetParent(parent, false); return rt; } /// 813y 프로브와 같은 가짜 메인 PC(사망 상태). 원본 회복 경로는 ReviveOverride 로 막는다. static PCActor MakePC() { var go = new GameObject("__WL813z_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 OnReq(in ReviveRequestEvent e) { s_reqSeen++; } static int s_reqSeen; static void Kill(GameObject go) { if (go != null) UnityEngine.Object.DestroyImmediate(go); } // ─────────────────────────────────────────────── ① D-3 부활 워치독 public static string Watchdog() { var sb = new StringBuilder(); sb.AppendLine("=== WL-813z ① D-3 부활 워치독 (에디트 모드 · Play 0) ==="); var st = WLSurvivalSettings.Instance; if (st == null) { var b0 = sb.AppendLine("**FAIL** WLSurvivalSettings.asset 없음").ToString(); return b0 + "\n→ " + Write("WL813z_watchdog.txt", b0); } float ts0 = Time.timeScale; int ov0 = WLSurvivalSettings.RuntimeOverride; float wd0 = st.reviveWatchdogSeconds; bool ig0 = st.reviveWatchdogIgnoresDisable; bool lg0 = st.reviveWatchdogLog; float to0 = st.autoReviveTimeoutSeconds; bool an0 = st.autoReviveWhenNoListener; bool hw0 = st.holdWorldWhileReviving; var ovr0 = DeathFlow.ReviveOverride; int overrideCalls = 0; PCActor pc = null; sb.AppendLine("에셋 값 실측: reviveWatchdogSeconds=" + wd0.ToString("F1") + "s · 무시disable=" + ig0 + " · log=" + lg0 + " · autoReviveTimeoutSeconds=" + to0.ToString("F1") + "s · holdWorld=" + hw0); sb.AppendLine("세계 복원값 실측: MyValue.Get_GameSpeed() = " + MyValue.Get_GameSpeed().ToString("F2") + " · 시작 Time.timeScale = " + ts0.ToString("F2") + " (에디트 모드에선 DeathFlow.HoldWorld 가 isPlaying 가드로 0 을 쓰지 않는다 = TimeManager.asset 무변경)"); sb.AppendLine(); try { WLSurvivalSettings.RuntimeOverride = 1; DeathFlow.ReviveOverride = (p, r) => { overrideCalls++; }; s_reqSeen = 0; DeathFlow.ReviveRequested.Add(OnReq); // 구독자 1 = autoReviveWhenNoListener 무력화(813y 와 같은 조건) pc = MakePC(); float disp = st.deathDisplaySeconds; // ── A. 기본 20 s 워치독 st.reviveWatchdogSeconds = 20f; st.reviveWatchdogIgnoresDisable = true; st.reviveWatchdogLog = true; DeathFlow.ResetDiagnostics(); float t = 1000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); sb.AppendLine("[A] 20 s 워치독 (정상 · 스위치 ON)"); sb.AppendLine(" 사망 → state=" + DeathFlow.State + " deathCount=" + DeathFlow.DeathCount); float req = t + disp + 0.01f; DeathFlow.Tick(req); sb.AppendLine(" +" + disp.ToString("F2") + "s → state=" + DeathFlow.State + " · ReviveRequested 수신=" + s_reqSeen + " · 구독자=" + DeathFlow.ReviveRequested.Count + " · WorldHeld=" + DeathFlow.WorldHeld); DeathFlow.Tick(req + 19.9f); sb.AppendLine(" +19.9s(팝업이 에러 모달에 가려 확인 못 누른 상태) → state=" + DeathFlow.State + " 대기=" + DeathFlow.AwaitingSeconds.ToString("F2") + "s · 워치독=" + DeathFlow.WatchdogCount + "회 · " + Ok(DeathFlow.State == DeathState.AwaitingRevive && DeathFlow.WatchdogCount == 0)); DeathFlow.Tick(req + 20.02f); bool a = DeathFlow.State == DeathState.None && DeathFlow.WatchdogCount == 1 && DeathFlow.AutoReviveCount == 1 && DeathFlow.ReviveCount == 1 && !DeathFlow.WorldHeld && overrideCalls == 1; sb.AppendLine(" +20.02s → state=" + DeathFlow.State + " · 워치독=" + DeathFlow.WatchdogCount + "회(" + DeathFlow.LastWatchdogSeconds.ToString("F2") + "s) · auto=" + DeathFlow.AutoReviveCount + " · reviveCount=" + DeathFlow.ReviveCount + " · 부활실행=" + overrideCalls + " · WorldHeld=" + DeathFlow.WorldHeld + " · timeScale=" + Time.timeScale.ToString("F2") + " · " + Ok(a)); sb.AppendLine(); // ── B. RuntimeDisabled 무관 (도중에 스위치가 꺼져도 워치독이 돈다) DeathFlow.ResetDiagnostics(); overrideCalls = 0; s_reqSeen = 0; t = 2000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); req = t + disp + 0.01f; DeathFlow.Tick(req); WLSurvivalSettings.RuntimeOverride = -1; // = QA 의 RuntimeDisabled sb.AppendLine("[B] 대기 중 RuntimeDisabled(-1) · reviveWatchdogIgnoresDisable=true"); sb.AppendLine(" Enabled=" + WLSurvivalSettings.Enabled + " state=" + DeathFlow.State); DeathFlow.Tick(req + 20.02f); bool bb = DeathFlow.State == DeathState.None && DeathFlow.WatchdogCount == 1 && DeathFlow.AbortCount == 0; sb.AppendLine(" +20.02s → state=" + DeathFlow.State + " · 워치독=" + DeathFlow.WatchdogCount + "회 · abort=" + DeathFlow.AbortCount + " · 부활실행=" + overrideCalls + " · WorldHeld=" + DeathFlow.WorldHeld + " · " + Ok(bb)); // ── B'. 무시disable=false 면 워치독 대신 Abort — 이때도 세계 정지는 풀린다(잠금 0) st.reviveWatchdogIgnoresDisable = false; DeathFlow.ResetDiagnostics(); overrideCalls = 0; WLSurvivalSettings.RuntimeOverride = 1; t = 3000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); req = t + disp + 0.01f; DeathFlow.Tick(req); WLSurvivalSettings.RuntimeOverride = -1; DeathFlow.Tick(req + 0.02f); bool bp = DeathFlow.State == DeathState.None && DeathFlow.AbortCount == 1 && !DeathFlow.WorldHeld; sb.AppendLine(" 무시disable=false → 대신 Abort(\"" + DeathFlow.LastAbortReason + "\") · state=" + DeathFlow.State + " · WorldHeld=" + DeathFlow.WorldHeld + " (ReleaseWorld 통과 = 잠금 0) · " + Ok(bp)); st.reviveWatchdogIgnoresDisable = true; WLSurvivalSettings.RuntimeOverride = 1; sb.AppendLine(); // ── C. C8 롤백 — reviveWatchdogSeconds = 0 이면 워치독이 없다(813i 원래 동작 100%) st.reviveWatchdogSeconds = 0f; DeathFlow.ResetDiagnostics(); overrideCalls = 0; t = 4000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); req = t + disp + 0.01f; DeathFlow.Tick(req); DeathFlow.Tick(req + 999f); bool c = DeathFlow.State == DeathState.AwaitingRevive && DeathFlow.WatchdogCount == 0; sb.AppendLine("[C] C8 롤백 reviveWatchdogSeconds=0 → +999s 뒤에도 state=" + DeathFlow.State + " · 워치독=" + DeathFlow.WatchdogCount + "회 · " + Ok(c) + " (= 813i 원래 동작)"); DeathFlow.Abort("probe cleanup"); sb.AppendLine(); // ── D. 정상 확인 버튼 경로가 워치독보다 먼저면 워치독은 안 돈다 st.reviveWatchdogSeconds = 20f; DeathFlow.ResetDiagnostics(); overrideCalls = 0; t = 5000f; DeathFlow.Tick(t); DeathFlow.OnPCDied(pc); req = t + disp + 0.01f; DeathFlow.Tick(req); DeathFlow.Tick(req + 3f); bool revived = DeathFlow.Revive(); bool d = revived && DeathFlow.State == DeathState.None && DeathFlow.WatchdogCount == 0 && DeathFlow.AutoReviveCount == 0; sb.AppendLine("[D] 3 s 뒤 확인 버튼(DeathFlow.Revive()) → " + revived + " · state=" + DeathFlow.State + " · 워치독=" + DeathFlow.WatchdogCount + "회 · auto=" + DeathFlow.AutoReviveCount + " · " + Ok(d)); } catch (Exception ex) { sb.AppendLine("**예외** " + ex); } finally { DeathFlow.ReviveRequested.Remove(OnReq); DeathFlow.Abort("probe finally"); DeathFlow.ReviveOverride = ovr0; DeathFlow.ResetDiagnostics(); st.reviveWatchdogSeconds = wd0; st.reviveWatchdogIgnoresDisable = ig0; st.reviveWatchdogLog = lg0; st.autoReviveTimeoutSeconds = to0; st.autoReviveWhenNoListener = an0; st.holdWorldWhileReviving = hw0; WLSurvivalSettings.RuntimeOverride = ov0; if (pc != null) Kill(pc.gameObject); } sb.AppendLine(); sb.AppendLine("정리: state=" + DeathFlow.State + " · WorldHeld=" + DeathFlow.WorldHeld + " · 구독 Req=" + DeathFlow.ReviveRequested.Count + " · timeScale " + ts0.ToString("F2") + " → " + Time.timeScale.ToString("F2") + " (에디트 모드 쓰기 0) · SO 원복 완료"); var body = sb.ToString(); return body + "\n→ " + Write("WL813z_watchdog.txt", body); } // ─────────────────────────────────── ② 부활 팝업 최상단 보장 + WL_btn_ok public static string Topmost() { var sb = new StringBuilder(); sb.AppendLine("=== WL-813z ② 부활 팝업 최상단 보장(D-3) + WL_btn_ok(D-9) ==="); var st = WLSurvivalSettings.Instance; var us = WLSurvivalUiSettings.Instance; if (st == null || us == null) { var b0 = sb.AppendLine("**FAIL** WLSurvivalSettings/WLSurvivalUiSettings 에셋 없음").ToString(); return b0 + "\n→ " + Write("WL813z_topmost.txt", b0); } bool tm0 = st.reviveTopmostEnabled; int so0 = st.reviveSortingOrder; float iv0 = st.reviveTopmostIntervalSeconds; bool es0 = st.reviveEscalateOnHijack; GameObject root = null; try { Canvas canvas; float u; var hud = BuildCanvas(out root, out canvas, out u); sb.AppendLine("합성 캔버스: unitsPerPx=" + u.ToString("F4") + " · WL_HUD size=" + hud.sizeDelta + " · 공용 팝업 프리팹 SortOrder_5 의 Canvas.m_SortingOrder = 5 (실측 · Assets/ResWork/UIPrefabs/Title/SortOrder_5.prefab:9159)"); sb.AppendLine("에셋 값: reviveTopmostEnabled=" + tm0 + " · reviveSortingOrder=" + so0 + " · 재보장 주기=" + iv0.ToString("F2") + "s · 승격=" + es0); sb.AppendLine(); Decoy(hud, "기존팝업_A"); var dlgRt = Decoy(hud, "WL_ReviveDialog"); Decoy(hud, "기존팝업_B"); var dlg = dlgRt.gameObject.AddComponent(); sb.AppendLine("[A] 표시 순간 최상단 · 레이캐스트"); sb.AppendLine(" init → " + dlg.Initialize().Split('·')[0].Trim()); sb.AppendLine(" 표시 전: 맨뒤형제=" + dlg.IsLastSibling + " sortingOrder=" + dlg.SortingOrder); string shown = dlg.ShowNow(); var cv = dlgRt.GetComponent(); var gr = dlgRt.GetComponent(); var cg = dlgRt.GetComponent(); bool a = dlg.IsLastSibling && cv != null && cv.overrideSorting && cv.sortingOrder == st.reviveSortingOrder && cv.sortingOrder > 5 && gr != null && gr.enabled && cg != null && cg.blocksRaycasts; sb.AppendLine(" ShowNow → " + shown); sb.AppendLine(" 맨뒤형제=" + dlg.IsLastSibling + " · overrideSorting=" + (cv != null && cv.overrideSorting) + " · sortingOrder=" + (cv != null ? cv.sortingOrder : -1) + "(> 공용 5) · GraphicRaycaster=" + (gr != null && gr.enabled) + " · CanvasGroup.blocksRaycasts=" + (cg != null && cg.blocksRaycasts) + " · " + Ok(a)); sb.AppendLine(); sb.AppendLine("[B] 표시 중 " + iv0.ToString("F2") + "s 마다 최상단 재보장"); float now = Time.unscaledTime; Decoy(hud, "나중에_뜬_에러팝업"); // 부활 팝업 위로 올라온다 bool before = dlg.IsLastSibling; int tc0 = dlg.TopmostCount; dlg.Tick(now + iv0 + 0.01f); bool b = !before && dlg.IsLastSibling && dlg.TopmostCount > tc0; sb.AppendLine(" 에러 팝업이 뒤에 붙음 → 맨뒤형제=" + before + " → Tick(+" + (iv0 + 0.01f).ToString("F2") + "s) → 맨뒤형제=" + dlg.IsLastSibling + " · 재보장 " + tc0 + "→" + dlg.TopmostCount + "회 · " + Ok(b)); sb.AppendLine(); sb.AppendLine("[C] 재보장 루프 GC 0"); for (int i = 0; i < 20; i++) dlg.Tick(now + 100f + i * (iv0 + 0.01f)); // 워밍업(첫 AddComponent 배제) long m0 = GC.GetTotalMemory(true); int n = 600; for (int i = 0; i < n; i++) dlg.Tick(now + 200f + i * (iv0 + 0.01f)); long m1 = GC.GetTotalMemory(false); long d = m1 - m0; sb.AppendLine(" Tick ×" + n + "(전부 재보장 실행) → GC.GetTotalMemory 델타 = " + d + " B · 재보장 누계 " + dlg.TopmostCount + "회 · " + Ok(d <= 0)); sb.AppendLine(); sb.AppendLine("[D] D-9 런타임 버튼 이름"); var okName = ReviveDialog.OkButtonName; Transform found = null, old = null; foreach (var t in dlgRt.GetComponentsInChildren(true)) { if (t.name == okName) found = t; if (t.name == "btn_ok") old = t; } bool dd = found != null && found.GetComponent