// WL-813c3 프로브 — Q7 결함 D-26 「계기는 맞고 화면은 틀리다」 실측(에디트 모드 · Play 0 · 로그인 0 · #813). // unity command run_script --file AgentScripts/WL813c3_Probe.cs --entry WL813c3_Probe.RunAll // 산출물: /AgentScripts/WL813c3_PROBE.txt // // 🔴 프리팹 저장 0 — LoadPrefabContents 로 열고 finally 에서 UnloadPrefabContents. SaveAsPrefabAsset 호출 없음. // 파일 크기·수정시각을 전후로 찍어 "저장 0" 을 증거로 남긴다. // 🔴 SO 값은 메모리에서만 바꾸고 finally 에서 원복(AssetDatabase.SaveAssets 0). // 🔴 Gameplay 는 읽기만. Play 진입 0. // 🔴 핵심 계측 = **fillAmount 가 아니라 실제로 생성되는 메쉬 폭**. // uGUI Image.OnPopulateMesh(VertexHelper) 를 리플렉션으로 직접 불러 정점 x 범위를 잰다. // sprite 가 없으면 Graphic.OnPopulateMesh(사각형 전체) 로 빠지므로 fillAmount 와 무관하게 폭 비율 1.000 이 나온다. // = D-26 「화면 만피」 의 기계적 증거. using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using UnityEngine.UI; using UnityEditor; using WL.UI; public static class WL813c3_Probe { const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; const string kHudPath = "IngameUIs/WL_HUD"; const string kBarName = "WL_BossHpBar"; static StringBuilder _o; static int _pass, _fail; 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; } } static string OutPath(string n) { return Path.Combine(Path.Combine(Root, "AgentScripts"), n); } // ── 메쉬 실측 ──────────────────────────────────────────────────────────── static MethodInfo _opm; /// Image 가 실제로 만드는 정점의 x 폭 / rect 폭. sprite 없으면 언제나 1.000 이 나온다. static float MeshWidthRatio(Image img, out int vertexCount, out string note) { vertexCount = 0; note = ""; if (img == null) { note = "Image 없음"; return -1f; } if (_opm == null) { _opm = typeof(Image).GetMethod("OnPopulateMesh", BindingFlags.Instance | BindingFlags.NonPublic, null, new Type[] { typeof(VertexHelper) }, null); if (_opm == null) _opm = typeof(Graphic).GetMethod("OnPopulateMesh", BindingFlags.Instance | BindingFlags.NonPublic, null, new Type[] { typeof(VertexHelper) }, null); } if (_opm == null) { note = "OnPopulateMesh 리플렉션 실패"; return -1f; } var vh = new VertexHelper(); try { _opm.Invoke(img, new object[] { vh }); } catch (Exception e) { note = "invoke 실패: " + e.GetBaseException().Message; vh.Dispose(); return -1f; } vertexCount = vh.currentVertCount; if (vertexCount == 0) { vh.Dispose(); note = "정점 0"; return 0f; } float minX = float.MaxValue, maxX = float.MinValue; var v = new UIVertex(); for (int i = 0; i < vertexCount; i++) { vh.PopulateUIVertex(ref v, i); if (v.position.x < minX) minX = v.position.x; if (v.position.x > maxX) maxX = v.position.x; } vh.Dispose(); float rectW = img.rectTransform.rect.width; if (rectW <= 0f) { note = "rect 폭 0"; return -1f; } note = "verts=" + vertexCount + " x[" + minX.ToString("F1") + "," + maxX.ToString("F1") + "] rectW=" + rectW.ToString("F1"); return (maxX - minX) / rectW; } // ── 노드 전수 덤프 ─────────────────────────────────────────────────────── static void DumpTree(Transform t, int depth) { var sb = new StringBuilder(); sb.Append(new string(' ', 4 + depth * 2)).Append("- ").Append(t.name); sb.Append(" active=").Append(t.gameObject.activeSelf).Append("/").Append(t.gameObject.activeInHierarchy); sb.Append(" parent=").Append(t.parent != null ? t.parent.name : "(없음)"); var img = t.GetComponent(); if (img != null) { sb.Append(" [Image type=").Append(img.type) .Append(" method=").Append(img.fillMethod) .Append(" origin=").Append(img.fillOrigin) .Append(" amount=").Append(img.fillAmount.ToString("F3")) .Append(" sprite=").Append(img.sprite != null ? img.sprite.name : "🔴없음") .Append(" enabled=").Append(img.enabled) .Append(" color=#").Append(ColorUtility.ToHtmlStringRGBA(img.color)) .Append(" raycast=").Append(img.raycastTarget).Append("]"); int vc; string note; float r = MeshWidthRatio(img, out vc, out note); sb.Append(" 메쉬폭비=").Append(r < 0f ? "?" : r.ToString("F3")).Append(" (").Append(note).Append(")"); } var cg = t.GetComponent(); if (cg != null) sb.Append(" [CanvasGroup alpha=").Append(cg.alpha.ToString("F2")).Append("]"); var cv = t.GetComponent(); if (cv != null) sb.Append(" [Canvas order=").Append(cv.sortingOrder).Append(" override=").Append(cv.overrideSorting) .Append(" enabled=").Append(cv.enabled).Append("]"); var tmp = t.GetComponent(); if (tmp != null) sb.Append(" [TMP \"").Append(tmp.text).Append("\" fs=").Append(tmp.fontSize.ToString("F1")).Append("]"); var bhb = t.GetComponent(); if (bhb != null) sb.Append(" [BossHpBar]"); _o.AppendLine(sb.ToString()); for (int i = 0; i < t.childCount; i++) DumpTree(t.GetChild(i), depth + 1); } static void DumpBarNodes(GameObject root, string title) { H(title); var all = root.GetComponentsInChildren(true); N("BossHpBar 인스턴스 = " + all.Length + "벌"); foreach (var b in all) { // 부모 사슬 + 캔버스 var chain = new List(); var p = b.transform; Canvas nearest = null; while (p != null) { chain.Add(p.name); if (nearest == null) { var c = p.GetComponent(); if (c != null) nearest = c; } p = p.parent; } chain.Reverse(); N("경로 = " + string.Join("/", chain.ToArray()) + " · 소속 캔버스 = " + (nearest != null ? nearest.name + "(order=" + nearest.sortingOrder + " override=" + nearest.overrideSorting + " enabled=" + nearest.enabled + " mode=" + nearest.renderMode + ")" : "🔴없음")); DumpTree(b.transform, 0); } } // ── 본체 ───────────────────────────────────────────────────────────────── public static string RunAll() { _o = new StringBuilder(); _pass = _fail = 0; _o.AppendLine("# WL813c3 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play " + Application.isPlaying + ")"); var boss = WLBossUiSettings.Instance; var hudS = WLHudLayoutSettings.Instance; if (boss == null || hudS == null) { _o.AppendLine("🔴 설정 에셋 없음 — boss=" + (boss != null) + " hud=" + (hudS != null)); return Flush(); } // SO 스냅샷(원복용) bool sForce = boss.bossBarForceFilledFill, sWhite = boss.bossBarFillWhiteFallback; bool sBgTick = boss.bossBarSpriteOnBgAndTicks, sDedup = boss.bossBarDedupInstances, sZero = boss.bossBarHideOnZeroHp; string abs = Path.Combine(Root, kPrefab.Replace('/', Path.DirectorySeparatorChar)); var fi0 = new FileInfo(abs); long len0 = fi0.Length; DateTime mt0 = fi0.LastWriteTimeUtc; N("프리팹 전 = " + len0 + " B · " + mt0.ToString("yyyy-MM-dd HH:mm:ss.fff") + " UTC"); GameObject root = null; BossHpBar bar = null; GameObject dupGo = null; try { root = PrefabUtility.LoadPrefabContents(kPrefab); var hud = root.transform.Find(kHudPath); if (hud == null) { _o.AppendLine("🔴 " + kHudPath + " 없음"); return Flush(); } var barT = hud.Find(kBarName); if (barT == null) { _o.AppendLine("🔴 " + kBarName + " 없음"); return Flush(); } bar = barT.GetComponent(); if (bar == null) { _o.AppendLine("🔴 BossHpBar 컴포넌트 없음"); return Flush(); } // ── ① 저장된 상태 그대로 (수리 전) ────────────────────────────── DumpBarNodes(root, "① 프리팹에 저장된 그대로 (수리 전 · Initialize 호출 0)"); var fillT = barT.Find("Bar/Fill"); var fillImg = fillT != null ? fillT.GetComponent() : null; Chk(fillImg != null, "Bar/Fill Image 존재"); Chk(fillImg != null && fillImg.sprite == null, "🔴 D-26 원인 재현: 저장된 Fill 의 sprite = 없음 (BossHpBar.cs:499 가 AddComponent 만 했다)"); Chk(fillImg != null && fillImg.type == Image.Type.Filled, "저장된 Fill 의 type = Filled (후보 ③ 「type 이 Filled 가 아니다」는 반증됨)"); Chk(root.GetComponentsInChildren(true).Length == 1, "바 노드 1벌뿐 (후보 ① 「2벌」 반증)"); // ── ② 메쉬 실측 (수리 전 · fillAmount 0.368 = Q7 캡처 그 값) ──── H("② 메쉬 실측 (수리 전) — fillAmount 를 Q7 D-26 그 값 0.368 로 두고 실제 정점을 만든다"); if (fillImg != null) { fillImg.fillAmount = 0.368f; int vc; string note; float r = MeshWidthRatio(fillImg, out vc, out note); N("fillAmount=0.368 → 메쉬폭비 = " + r.ToString("F3") + " (" + note + ")"); Chk(r > 0.99f, "🔴 D-26 기계적 증거: sprite 가 없어 fillAmount 0.368 인데 메쉬는 폭비 " + r.ToString("F3") + " = 만피 사각형"); } // ── ③ 수리 = 런타임과 같은 경로(Initialize) ───────────────────── H("③ 수리 — BossHpBar.Initialize() (런타임 OnEnable 과 같은 경로)"); boss.bossBarForceFilledFill = true; boss.bossBarFillWhiteFallback = true; string init = bar.Initialize(); N("Initialize → " + init); Chk(fillImg != null && fillImg.sprite != null, "고침: Fill.sprite = " + (fillImg != null && fillImg.sprite != null ? fillImg.sprite.name : "(없음)")); Chk(fillImg != null && fillImg.type == Image.Type.Filled && fillImg.fillMethod == Image.FillMethod.Horizontal && fillImg.fillOrigin == (int)Image.OriginHorizontal.Left, "고침: type=Filled · method=Horizontal · origin=Left 강제"); // ── ④ 메쉬 실측 (수리 후) ─────────────────────────────────────── H("④ 메쉬 실측 (수리 후) — 같은 fillAmount 0.368"); if (fillImg != null) { fillImg.fillAmount = 0.368f; int vc; string note; float r = MeshWidthRatio(fillImg, out vc, out note); N("fillAmount=0.368 → 메쉬폭비 = " + r.ToString("F3") + " (" + note + ")"); Chk(Mathf.Abs(r - 0.368f) < 0.01f, "화면 추종: 메쉬폭비 " + r.ToString("F3") + " ≈ fillAmount 0.368 (오차 " + Mathf.Abs(r - 0.368f).ToString("F4") + ")"); } // ── ⑤ 발주서 §1-2 프로브: 가짜 HP 1800 → 869 → 화면 노드 fill 0.483 ── H("⑤ 가짜 HP 1800 → 869 (발주서 §1-2) — 화면 노드가 따라오는가"); BossHpBar.DebugFallbackMaxHp = 1800f; BossHpBar.DebugFakeMaxHp = 1800f; BossHpBar.DebugFakeHp = 1800f; N(BossHpBar.RaiseFakeBossSpawned(1800f)); bar.PollBoss(true); N("스폰 직후 ratio=" + bar.LastRatio.ToString("F3") + " fillAmount=" + (fillImg != null ? fillImg.fillAmount.ToString("F3") : "-")); BossHpBar.DebugFakeHp = 869f; bar.PollBoss(true); float expect = 869f / 1800f; int vc2; string note2; float r2 = MeshWidthRatio(fillImg, out vc2, out note2); N("HP 869/1800 → LastRatio=" + bar.LastRatio.ToString("F3") + " · fillAmount=" + (fillImg != null ? fillImg.fillAmount.ToString("F3") : "-") + " · 메쉬폭비=" + r2.ToString("F3") + " (" + note2 + ")"); Chk(Mathf.Abs(bar.LastRatio - expect) < 0.002f, "계기 LastRatio = " + expect.ToString("F3")); Chk(fillImg != null && Mathf.Abs(fillImg.fillAmount - expect) < 0.002f, "fillAmount = " + expect.ToString("F3")); Chk(Mathf.Abs(r2 - expect) < 0.01f, "🔴 화면 노드 메쉬폭비 = " + r2.ToString("F3") + " (발주서 §1-2 목표 0.483)"); Chk(bar.Visible, "바가 보이는 상태(CanvasGroup alpha > 0)"); // 3칸 구분선(눈금)이 fill 위에 그대로 얹혀 있는가 var ticks = barT.Find("Bar/Ticks"); int tickN = ticks != null ? ticks.childCount : 0; N("눈금 " + tickN + "개 · Ticks 형제 순서 = " + (ticks != null ? ticks.GetSiblingIndex() : -1) + " / Fill = " + (fillT != null ? fillT.GetSiblingIndex() : -1)); Chk(tickN == 2 && ticks != null && fillT != null && ticks.GetSiblingIndex() > fillT.GetSiblingIndex(), "3칸 = fill 위에 얹힌 구분선 2개(Fill 뒤 형제) — 별도 세그먼트 이미지 아님"); // ── ⑥ C8 롤백 재현 ────────────────────────────────────────────── H("⑥ C8 롤백 — bossBarForceFilledFill = 0 → 813c2 상태(화면 만피)로 복귀"); boss.bossBarForceFilledFill = false; if (fillImg != null) fillImg.sprite = null; // 저장된 프리팹과 같은 조건 N("EnsureFillRenderable → " + bar.EnsureFillRenderable()); if (fillImg != null) fillImg.fillAmount = 0.483f; int vc3; string note3; float r3 = MeshWidthRatio(fillImg, out vc3, out note3); N("롤백 상태 fillAmount=0.483 → 메쉬폭비 = " + r3.ToString("F3") + " (" + note3 + ")"); Chk(r3 > 0.99f, "롤백 시 D-26 재현(메쉬폭비 " + r3.ToString("F3") + ") = 스위치 하나로 되돌아간다(C8)"); boss.bossBarForceFilledFill = true; N("복원 → " + bar.EnsureFillRenderable()); // ── ⑦ HP 0 숨김 ──────────────────────────────────────────────── H("⑦ 보스 HP 0 → 숨김 예약"); int zero0 = bar.ZeroHpHideCount; BossHpBar.DebugFakeHp = 0f; bar.PollBoss(true); N("HP 0 → ratio=" + bar.LastRatio.ToString("F3") + " · ZeroHpHideCount " + zero0 + "→" + bar.ZeroHpHideCount); Chk(bar.LastRatio <= 0.0001f, "HP 0 → ratio 0"); Chk(bar.ZeroHpHideCount > zero0, "HP 0 숨김 예약 1회 (bossBarHideDelaySec " + hudS.bossBarHideDelaySec + "s 뒤)"); boss.bossBarHideOnZeroHp = false; int zero1 = bar.ZeroHpHideCount; bar.PollBoss(true); Chk(bar.ZeroHpHideCount == zero1, "C8: bossBarHideOnZeroHp=0 → 예약 생략"); boss.bossBarHideOnZeroHp = sZero; // ── ⑧ 중복 노드 차단 ─────────────────────────────────────────── H("⑧ 중복 노드 차단 — 같은 캔버스에 바를 하나 더 만들어 본다"); dupGo = new GameObject(kBarName + "_DUP", typeof(RectTransform)); dupGo.layer = barT.gameObject.layer; dupGo.hideFlags = HideFlags.HideAndDontSave; ((RectTransform)dupGo.transform).SetParent(hud, false); var dup = dupGo.AddComponent(); string dupInit = dup.Initialize(); N("중복 Initialize → " + dupInit); N("원본 BarInstanceCount=" + bar.BarInstanceCount + " · 중복 숨김=" + dup.DedupHidden + " · 중복 Visible=" + dup.Visible); Chk(dup.BarInstanceCount >= 2, "중복 감지 " + dup.BarInstanceCount + "벌"); Chk(!dup.Visible, "중복 바는 숨김(alpha 0)"); dup.Subscribe(false); boss.bossBarDedupInstances = false; N("C8: bossBarDedupInstances=0 → " + dup.DedupInstances()); boss.bossBarDedupInstances = sDedup; // ── ⑨ GC ─────────────────────────────────────────────────────── H("⑨ GC — 폴링 20,000 회 할당"); BossHpBar.DebugFakeHp = 900f; bar.PollBoss(true); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long g0 = GC.GetTotalMemory(true); for (int i = 0; i < 20000; i++) bar.PollBoss(true); long g1 = GC.GetTotalMemory(false); N("GC " + g0 + " → " + g1 + " (Δ " + (g1 - g0) + " B)"); Chk(g1 - g0 <= 0, "폴링 20,000회 GC 증가 0 B (Δ " + (g1 - g0) + ")"); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long e0 = GC.GetTotalMemory(true); for (int i = 0; i < 20000; i++) bar.EnsureFillRenderable(); long e1 = GC.GetTotalMemory(false); N("EnsureFillRenderable 20,000회: GC " + e0 + " → " + e1 + " (Δ " + (e1 - e0) + " B · 문자열 반환 포함)"); // ── ⑩ 수리 후 노드 전수 재덤프 ───────────────────────────────── BossHpBar.DebugFakeHp = 869f; bar.PollBoss(true); DumpBarNodes(root, "⑩ 수리 후 노드 전수 (HP 869/1800)"); N(bar.Dump()); } finally { try { if (dupGo != null) UnityEngine.Object.DestroyImmediate(dupGo); } catch { } try { if (bar != null) bar.Subscribe(false); } catch { } BossHpBar.ClearFakeHp(); BossHpBar.DebugFallbackMaxHp = 100f; BossHpBar.RunBossHpRatio = -1f; BossHpBar.RunBossSeen = false; BossHpBar.RunResetCount = 0; boss.bossBarForceFilledFill = sForce; boss.bossBarFillWhiteFallback = sWhite; boss.bossBarSpriteOnBgAndTicks = sBgTick; boss.bossBarDedupInstances = sDedup; boss.bossBarHideOnZeroHp = sZero; try { if (root != null) PrefabUtility.UnloadPrefabContents(root); } catch { } } // ── ⑪ 저장 0 · 구독 0 ─────────────────────────────────────────────── H("⑪ 뒷정리 — 프리팹 저장 0 · 구독 0"); var fi1 = new FileInfo(abs); fi1.Refresh(); N("프리팹 후 = " + fi1.Length + " B · " + fi1.LastWriteTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff") + " UTC"); Chk(fi1.Length == len0 && fi1.LastWriteTimeUtc == mt0, "프리팹 파일 무변동 = 저장 0"); N("CombatEvents Spawned=" + WL.Combat.Core.CombatEvents.Spawned.Count + " HitConfirmed=" + WL.Combat.Core.CombatEvents.HitConfirmed.Count + " Killed=" + WL.Combat.Core.CombatEvents.Killed.Count + " · RunStarted=" + WL.Combat.Run.RunEvents.RunStarted.Count); N("SO 원복 = force:" + boss.bossBarForceFilledFill + " white:" + boss.bossBarFillWhiteFallback + " bgTick:" + boss.bossBarSpriteOnBgAndTicks + " dedup:" + boss.bossBarDedupInstances + " hp0:" + boss.bossBarHideOnZeroHp); N("DebugFakeHp=" + BossHpBar.DebugFakeHp + " DebugFakeMaxHp=" + BossHpBar.DebugFakeMaxHp + " DebugFallbackMaxHp=" + BossHpBar.DebugFallbackMaxHp); return Flush(); } static string Flush() { _o.AppendLine(); _o.AppendLine("== 합계 PASS " + _pass + " / FAIL " + _fail + " =="); string p = OutPath("WL813c3_PROBE.txt"); try { File.WriteAllText(p, _o.ToString(), new UTF8Encoding(true)); } catch { } return _o.ToString() + "\n(파일: " + p + ")"; } }