Project_WL/AgentScripts/WL813z_Probe.cs

555 lines
32 KiB
C#
Raw Permalink Normal View History

// WL-813z 프로브 — Q4-a 핫픽스 실측(에디트 모드 · Play 0 · #813).
// run_script --file AgentScripts/WL813z_Probe.cs --entry WL813z_Probe.<Entry>
// Watchdog · Topmost · Toast · All
// 로그는 <worktree>/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**"; }
/// <summary>세로 1080×1920 기준(참조 1920×1080 Expand)의 합성 캔버스 + Safe Area 부모.</summary>
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>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = root.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f); // 원본 NewGameUI 와 같은 설정(802b 실측 1.7778)
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
root.AddComponent<GraphicRaycaster>();
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<RectTransform>();
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<RectTransform>();
rt.gameObject.hideFlags = HideFlags.DontSave;
rt.SetParent(parent, false);
return rt;
}
/// <summary>813y 프로브와 같은 가짜 메인 PC(사망 상태). 원본 회복 경로는 ReviveOverride 로 막는다.</summary>
static PCActor MakePC()
{
var go = new GameObject("__WL813z_PC");
go.hideFlags = HideFlags.DontSave;
var pc = go.AddComponent<PCActor>();
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<ReviveDialog>();
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<Canvas>();
var gr = dlgRt.GetComponent<GraphicRaycaster>();
var cg = dlgRt.GetComponent<CanvasGroup>();
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<Transform>(true))
{
if (t.name == okName) found = t;
if (t.name == "btn_ok") old = t;
}
bool dd = found != null && found.GetComponent<Button>() != null && old == null && okName == "WL_btn_ok";
sb.AppendLine(" ReviveDialog.OkButtonName=\"" + okName + "\" · 노드 존재=" + (found != null) +
" · Button=" + (found != null && found.GetComponent<Button>() != null) +
" · 옛 \"btn_ok\" 노드=" + (old != null ? old.name : "없음") + " · " + Ok(dd));
sb.AppendLine(" → QA 절차: `LightProbe.ClickButton [\"WL_btn_ok\"]` (원본 `NightmareResult/btn_ok` 와 이름 충돌 0)");
sb.AppendLine();
sb.AppendLine("[E] 공용 Popup 탈취 → 전용 팝업 승격");
dlg.HideNow();
sb.AppendLine(" " + dlg.ProbeSimulateCommonPopup("존 시작점에서 부활합니다.\n무료"));
sb.AppendLine(" (에디트 모드는 Popup.Ins=" + (Popup.Ins != null ? "있음" : "없음") + " → '공용 팝업이 사라졌다/덮였다'와 같은 조건)");
string r = dlg.ProbeCheckHijack();
bool e = dlg.HijackCount == 1 && dlg.Escalated && dlg.FallbackVisible && dlg.IsLastSibling && !dlg.UsedCommonPopup;
sb.AppendLine(" " + r + " · " + Ok(e));
sb.AppendLine();
sb.AppendLine("[F] C8 롤백");
st.reviveTopmostEnabled = false;
var dlg2Rt = Decoy(hud, "WL_ReviveDialog_C8");
var dlg2 = dlg2Rt.gameObject.AddComponent<ReviveDialog>();
dlg2.Initialize();
Decoy(hud, "C8_뒤형제");
dlg2.ShowNow();
bool f1 = dlg2Rt.GetComponent<Canvas>() == null && !dlg2.IsLastSibling;
sb.AppendLine(" reviveTopmostEnabled=0 → Canvas 부착=" + (dlg2Rt.GetComponent<Canvas>() != null) +
" · 맨뒤형제=" + dlg2.IsLastSibling + " · " + Ok(f1) + " (813y 동작 100%)");
st.reviveTopmostEnabled = tm0;
st.reviveEscalateOnHijack = false;
dlg2.HideNow();
dlg2.ProbeSimulateCommonPopup("존 시작점에서 부활합니다.");
int hj0 = dlg2.HijackCount;
dlg2.ProbeCheckHijack();
bool f2 = dlg2.HijackCount == hj0 && !dlg2.Escalated;
sb.AppendLine(" reviveEscalateOnHijack=0 → 탈취감지 " + hj0 + "→" + dlg2.HijackCount + " · 승격=" + dlg2.Escalated + " · " + Ok(f2));
st.reviveEscalateOnHijack = es0;
dlg2.HideNow();
sb.AppendLine();
// ── G. 공용 Popup 싱글턴을 가짜로 세워 D-3 사슬 전체를 재현한다.
// (에디트 모드엔 Popup·BackKeyMgr 인스턴스가 없다 — 둘 다 public static 필드라 직접 세운다.)
sb.AppendLine("[G] 공용 Popup 실물 재현 — 탈취 감지 / 남의 팝업 불간섭 / QA-4 닫힘");
var popup0 = Popup.Ins; var bk0 = BackKeyMgr.Ins;
GameObject fakeBk = null, fakePopupGo = null;
try
{
fakeBk = new GameObject("__WL813z_BackKeyMgr"); fakeBk.hideFlags = HideFlags.DontSave;
BackKeyMgr.Ins = fakeBk.AddComponent<BackKeyMgr>();
fakePopupGo = new GameObject("__WL813z_Popup"); fakePopupGo.hideFlags = HideFlags.DontSave;
var fp = fakePopupGo.AddComponent<Popup>();
var popBody = new GameObject("m_popup"); popBody.hideFlags = HideFlags.DontSave; popBody.transform.SetParent(fakePopupGo.transform, false);
var msgGo = new GameObject("label_msg", typeof(RectTransform)); msgGo.hideFlags = HideFlags.DontSave;
msgGo.transform.SetParent(popBody.transform, false);
fp.m_popup = popBody;
fp.label_msg = msgGo.AddComponent<TextMeshProUGUI>();
Popup.Ins = fp;
const string mine = "존 시작점에서 부활합니다.\n무료";
// G-1. 우리 본문이 그대로면 탈취 아님(오탐 0)
dlg.HideNow();
dlg.ProbeSimulateCommonPopup(mine);
popBody.SetActive(true); fp.label_msg.text = mine;
int hj = dlg.HijackCount;
dlg.ProbeCheckHijack();
bool g1 = dlg.HijackCount == hj && !dlg.Escalated && dlg.UsedCommonPopup;
sb.AppendLine(" G-1 본문 그대로 → 탈취감지 " + hj + "→" + dlg.HijackCount + " · 승격=" + dlg.Escalated + " · " + Ok(g1) + " (오탐 0)");
// G-2. QA-4 — 부활이 끝나면 공용 Popup 도 닫는다(우리 것일 때만)
int closed0 = dlg.CommonPopupClosed;
dlg.HideNow();
bool g2 = !popBody.activeSelf && dlg.CommonPopupClosed == closed0 + 1;
sb.AppendLine(" G-2 HideNow(부활 완료) → 공용 Popup active=" + popBody.activeSelf + " · 우리가 닫음 " +
closed0 + "→" + dlg.CommonPopupClosed + " · " + Ok(g2) + " (Q4-a QA-4 해소)");
// G-3. ErrorLogHookManager 가 같은 싱글턴을 덮어쓴 상태 = D-3 그 자체
dlg.ProbeSimulateCommonPopup(mine);
popBody.SetActive(true); fp.label_msg.text = mine;
fp.label_msg.text = "NullReferenceException: Object reference not set …"; // = ErrorLogHookManager.cs:33 이 덮어쓴 뒤
int hj2 = dlg.HijackCount;
dlg.ProbeCheckHijack();
bool g3 = dlg.HijackCount == hj2 + 1 && dlg.Escalated && dlg.FallbackVisible && dlg.IsLastSibling && !dlg.UsedCommonPopup;
sb.AppendLine(" G-3 에러 모달이 덮음(본문 교체 = m_ok 도 null 로 지워진 상태) → 탈취감지 " + hj2 + "→" + dlg.HijackCount +
" · 승격=" + dlg.Escalated + " · 전용 팝업 표시=" + dlg.FallbackVisible +
" · 맨뒤형제=" + dlg.IsLastSibling + " · sortingOrder=" + dlg.SortingOrder + " · " + Ok(g3));
// G-4. 남의 팝업은 닫지 않는다
int closed1 = dlg.CommonPopupClosed;
dlg.HideNow();
bool g4 = popBody.activeSelf && dlg.CommonPopupClosed == closed1;
sb.AppendLine(" G-4 남의(에러) 팝업 → HideNow 후에도 active=" + popBody.activeSelf + " · 닫은 수 " +
closed1 + "→" + dlg.CommonPopupClosed + " · " + Ok(g4) + " (불간섭)");
}
finally
{
Popup.Ins = popup0; BackKeyMgr.Ins = bk0;
Kill(fakePopupGo); Kill(fakeBk);
}
dlg.HideNow();
}
catch (Exception ex) { sb.AppendLine("**예외** " + ex); }
finally
{
st.reviveTopmostEnabled = tm0; st.reviveSortingOrder = so0;
st.reviveTopmostIntervalSeconds = iv0; st.reviveEscalateOnHijack = es0;
Kill(root);
}
sb.AppendLine("정리: 합성 노드 파기 · SO 원복 · 씬/프리팹 저장 0");
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_topmost.txt", body);
}
// ─────────────────────────────────── ③ D-7 전리품 토스트 Safe Area 클램프
public static string Toast()
{
var sb = new StringBuilder();
sb.AppendLine("=== WL-813z ③ D-7 전리품 토스트 Safe Area 클램프 ===");
var s = WLCombatTextSettings.Instance;
if (s == null) { var b0 = sb.AppendLine("**FAIL** WLCombatTextSettings.asset 없음").ToString(); return b0 + "\n→ " + Write("WL813z_toast.txt", b0); }
bool cl0 = s.lootToastClampToSafeArea; float mw0 = s.lootToastMaxWidthPx;
float sp0 = s.lootToastSafePadPx; bool el0 = s.lootToastEllipsis;
GameObject root = null;
try
{
Canvas canvas; float u;
var hud = BuildCanvas(out root, out canvas, out u);
var toastRt = Decoy(hud, "WL_LootToast");
var toast = toastRt.gameObject.AddComponent<LootToast>();
sb.AppendLine("합성 Safe Area: 폭 1080 px(=" + (1080f * u).ToString("F1") + " 유닛) · unitsPerPx=" + u.ToString("F4"));
sb.AppendLine("에셋 값: widthPx=" + s.lootToastWidthPx.ToString("F0") + " · rightMarginPx=" + s.lootToastRightMarginPx.ToString("F0") +
" · slideInPx=" + s.lootToastSlideInPx.ToString("F0") + " · panelPadXPx=" + s.lootToastPanelPadXPx.ToString("F0") +
" · centerOffsetPx=" + s.lootToastCenterOffsetPx.ToString("F0") + "(유지)");
sb.AppendLine();
// ── A. 클램프 OFF = Q4-a 가 본 상태를 그대로 재현
s.lootToastClampToSafeArea = false;
toast.Initialize();
sb.AppendLine("[A] 클램프 OFF(= 813y 현재 · D-7 재현)");
sb.AppendLine(" " + toast.LastClamp);
float overOff = s.lootToastSlideInPx + s.lootToastPanelPadXPx - s.lootToastRightMarginPx;
sb.AppendLine(" 등장 프레임 우측 침범 = slideIn " + s.lootToastSlideInPx.ToString("F0") + " + 패널여백 " +
s.lootToastPanelPadXPx.ToString("F0") + " 우여백 " + s.lootToastRightMarginPx.ToString("F0") +
" = **+" + overOff.ToString("F0") + " px**(Safe Area 밖 · " + s.lootToastSlideSeconds.ToString("F2") +
"s 동안) → Q4-a 캡처 23_arena_12 / 29_levelup_01 의 잘린 `[-]` 와 일치 · " + Ok(overOff > 0f));
sb.AppendLine();
// ── B. 클램프 ON
s.lootToastClampToSafeArea = true;
toast.Initialize();
sb.AppendLine("[B] 클램프 ON");
sb.AppendLine(" " + toast.LastClamp);
var lineRoot = toastRt.Find("Lines") as RectTransform;
var line0 = lineRoot != null ? lineRoot.Find("Line0") as RectTransform : null;
var label0 = line0 != null ? line0.Find("Label") as RectTransform : null;
float rightEdgeUnits = lineRoot != null ? lineRoot.anchoredPosition.x : 0f; // 부모 우측 기준(0 = Safe Area 우변)
float padUnits = (s.lootToastPanelEnabled ? s.lootToastPanelPadXPx : 0f) * u;
float slideMax = (float)typeof(LootToast).GetField("_slideMaxPx", NP).GetValue(toast);
float widthPx = (float)typeof(LootToast).GetField("_widthPx", NP).GetValue(toast);
float overOn = (rightEdgeUnits + slideMax * u + padUnits) / u; // > 0 이면 Safe Area 밖
bool b = overOn <= 0.001f && widthPx <= s.lootToastMaxWidthPx + 0.001f;
sb.AppendLine(" 줄 뿌리 우측 = " + (rightEdgeUnits / u).ToString("F1") + " px(0 = Safe Area 우변) · 슬라이드 상한 = " +
slideMax.ToString("F1") + " px · 패널 여백 " + s.lootToastPanelPadXPx.ToString("F0") + " px");
sb.AppendLine(" 등장 프레임 우측 침범 = **" + overOn.ToString("F1") + " px** (≤ 0 = Safe Area 안) · 실폭 " +
widthPx.ToString("F1") + " px ≤ 상한 " + s.lootToastMaxWidthPx.ToString("F0") + " px · " + Ok(b));
sb.AppendLine();
// ── C. 긴 이름 말줄임
sb.AppendLine("[C] 긴 아이템 이름 말줄임(…)");
var tmp = label0 != null ? label0.GetComponent<TextMeshProUGUI>() : null;
if (tmp == null) sb.AppendLine(" **FAIL** Line0/Label 없음");
else
{
string longName = "아주아주긴전설급아이템이름테스트문자열0123456789";
tmp.text = longName;
tmp.ForceMeshUpdate();
int shownChars = tmp.textInfo.characterCount;
// TMP 는 말줄임을 넣은 뒤 다음 칸에 0x03(End Of Text)을 넣는다(TMP_Text.cs:4067·4077 실측) —
// 그래서 마지막 칸이 아니라 **가시 구간 전체**에서 '…' 를 찾는다.
bool hasEllipsis = false; int ellipsisAt = -1;
for (int ci = 0; ci < shownChars; ci++)
if (tmp.textInfo.characterInfo[ci].character == '…') { hasEllipsis = true; ellipsisAt = ci; break; }
float lineW = tmp.textInfo.lineCount > 0 ? tmp.textInfo.lineInfo[0].width / u : 0f;
float rectW = tmp.rectTransform.rect.width / u;
bool c = tmp.textWrappingMode == TextWrappingModes.NoWrap && tmp.overflowMode == TextOverflowModes.Ellipsis
&& tmp.isTextTruncated && hasEllipsis && lineW <= rectW + 0.5f;
sb.AppendLine(" wrapping=" + tmp.textWrappingMode + " · overflow=" + tmp.overflowMode +
" · 원문 " + longName.Length + "자 → 표시 " + shownChars + "자 · isTextTruncated=" + tmp.isTextTruncated +
" · '…' 위치 " + ellipsisAt + " · 줄 폭 " + lineW.ToString("F1") + " px ≤ rect " +
rectW.ToString("F1") + " px · " + Ok(c));
tmp.text = "";
}
sb.AppendLine();
// ── D. C8 롤백
s.lootToastClampToSafeArea = false;
toast.Initialize();
float w2 = (float)typeof(LootToast).GetField("_widthPx", NP).GetValue(toast);
float sm2 = (float)typeof(LootToast).GetField("_slideMaxPx", NP).GetValue(toast);
bool d = Mathf.Approximately(w2, s.lootToastWidthPx) && Mathf.Approximately(sm2, s.lootToastSlideInPx);
sb.AppendLine("[D] C8 롤백 lootToastClampToSafeArea=0 → 폭 " + w2.ToString("F1") + " px(에셋 " +
s.lootToastWidthPx.ToString("F0") + ") · 슬라이드 " + sm2.ToString("F1") + " px(에셋 " +
s.lootToastSlideInPx.ToString("F0") + ") · " + Ok(d) + " (813y 배치 100%)");
toast.Subscribe(false);
}
catch (Exception ex) { sb.AppendLine("**예외** " + ex); }
finally
{
s.lootToastClampToSafeArea = cl0; s.lootToastMaxWidthPx = mw0;
s.lootToastSafePadPx = sp0; s.lootToastEllipsis = el0;
Kill(root);
}
sb.AppendLine("정리: 합성 노드 파기 · SO 원복 · Killed 구독 해제");
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_toast.txt", body);
}
// ─────────────────────────────────────────────────────────── 전부
public static string All()
{
var sb = new StringBuilder();
sb.AppendLine("################ WL-813z 프로브 전체 ################");
sb.AppendLine(Watchdog()); sb.AppendLine();
sb.AppendLine(Topmost()); sb.AppendLine();
sb.AppendLine(Toast());
var body = sb.ToString();
return body + "\n→ " + Write("WL813z_all.txt", body);
}
}