diff --git a/AgentScripts/WL813q_PROBE.txt b/AgentScripts/WL813q_PROBE.txt
new file mode 100644
index 000000000..a8eb3904d
--- /dev/null
+++ b/AgentScripts/WL813q_PROBE.txt
@@ -0,0 +1,175 @@
+# WL813q Probe 2026-09-09 09:14:01 (edit mode · Play 0 · 로그인 0)
+playMode=False
+
+── ⓪ 에셋 (WLRunUiSettings · NEW)
+ [PASS] Resources.Load("WL/WLRunUiSettings")
+ [PASS] Enabled(에셋·enabled_·RuntimeDisabled) = True
+ 값: enabled_=True hud=True result=True topMargin=272px width=560px timerPx=56 warn=30s pulse=1.35×0.3s
+ 문구: win="런 클리어!" timeout="시간 종료" next="다음 런" close="닫기" zone="존 {0}" boss="BOSS"
+ 행 순서: TotalTime · BossClear · Kills · MaxChain · MaxDamage · Level · LootByGrade · Gold · DeathsPotions
+ [PASS] 본문 행 9종(발주서 §1-2 순서)
+ [PASS] Abandon 팝업 없음(기본값)
+ [PASS] 부활 팝업 우선(기본값)
+ [PASS] 813p WLRunSettings 존재(런 길이·존 수의 주인)
+ 813p 값(읽기만): runSeconds=240 zoneSpawnerIds=4 zoneClearKills=[12,14,12,8]
+ [PASS] WLLootSettings.GradeSlots = 9 → lootByGrade 길이 10(813p 계약)
+
+── ① 노드 — NewGameUI 프리팹(저장 0) · Safe Area · 보스 바와 겹침
+ [PASS] LoadPrefabContents(Assets/Res_Addr/MainUI/NewGameUI.prefab)
+ [PASS] IngameUIs/WL_HUD 경로 실측
+ [PASS] WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안 (813y 실측 재확인)
+ Bind HUD : 연결 IngameUIs/WL_HUD · pos(0.00, -483.56) size(995.56, 216.89) 칸=4 unitsPerPx=1.7778
+ Bind 결과 : 연결 IngameUIs/WL_HUD · box size(1528.89, 1920.00) 행틀=9 unitsPerPx=1.7778
+ [PASS] RunHud 노드 생성(WL_RunHud · HideFlags.DontSave)
+ [PASS] RunResultUI 노드 생성(WL_RunResult · HideFlags.DontSave)
+ [PASS] RunStarted 구독자 2(HUD+결과) — 실측 2
+ [PASS] 6종 구독 배선 = Tick1/Zone1/Gate1/Boss1/Ended2
+ 보스 바 = 상단 190 ~ 256 px · 런 HUD 상단 = 272 px
+ [PASS] 런 HUD 가 보스 HP 바 아래(겹침 0 · 여유 16.0 px)
+ HUD 배치 : pos(0.00, -483.56) size(995.56, 216.89) 칸=4 unitsPerPx=1.7778
+ 결과 배치: box size(1528.89, 1920.00) 행틀=9 unitsPerPx=1.7778
+
+── ② HUD — RunStarted → RunTick×3 → ZoneCleared×4 → BossGateOpened → BossStarted
+ [PASS] RunStarted 수신 1
+ [PASS] 존 칸 4개 생성 — 실측 4
+ [PASS] HUD 표시 on
+ [PASS] 타이머 리셋 = 04:00(runSeconds 240 · 페이로드에서) — 실측 "04:00"
+ [PASS] 단계 = "존 1" — 실측 "존 1"
+ tick1 timer="03:20" 존0 fill=0.50 단계="존 1"
+ [PASS] 틱1 mm:ss = 03:20
+ [PASS] 틱1 존0 게이지 = 6/12 = 0.50 — 실측 0.500
+ [PASS] 틱2 mm:ss = 02:30
+ [PASS] 틱2 존0=1.00(클리어) · 존1=0.50 — 실측 1.00/0.50
+ [PASS] 틱2 단계 = "존 2" — 실측 "존 2"
+ [PASS] 틱3 mm:ss = 00:25
+ [PASS] 마지막 30s 경고색 #FF4A47 — 실측 #FF4A47
+ [PASS] ZoneCleared 4회 수신
+ [PASS] 존 칸 4개 전부 채움
+ 펄스 실측: 시작 scale=1.350 → 0.3s 뒤 1.000
+ [PASS] 존 클리어 펄스 1.35× → 0.3s 안에 1.0 복귀
+ [PASS] 게이트 개방 → 단계 라벨 "BOSS"
+ [PASS] 보스 단계 색 #FF9E3D
+ [PASS] BossStarted 수신 1 (boss=null 계약 경로 · 예외 0)
+ [RunHud] 구독=True bound=True visible=True · started=1 tick=3 zoneCleared=4 gate=1 boss=1 ended=0
+ 타이머 "00:25" (남은 25.0s · 경고=True · 쓰기 4회) · 단계 "BOSS" (쓰기 4회)
+ 존 칸 4 (fill 쓰기 15회): [0]=1.00×1.00 [1]=1.00×1.00 [2]=1.00×1.00 [3]=1.00×1.00
+ 노드 pos(0.00, -483.56) size(995.56, 216.89) active=True 부모=WL_HUD SafeArea=True
+ edge = Font SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.280 outlineC=0D0805FF underlay=000000CC off(0.50,-0.50) soft=0.22 UNDERLAY_ON=True
+ 설정 enabled=True hud=True topMargin=272px width=560px timerPx=56 warn=30s pulse=1.35×0.3s
+
+── ③ 결과 화면 — RunEnded(Win)
+ [PASS] RunEnded 수신 1
+ [PASS] 결과 화면 표시(부활 팝업 없음)
+ [PASS] 제목 = "런 클리어!" — 실측 "런 클리어!"
+ [PASS] 런 종료 → HUD 숨김(runHudHideOnEnd)
+ [PASS] 복사본 스칼라 = 입력과 일치(outcome/runIndex/total/kills/boss/chain/level/gold/deaths/potions/zones)
+ 행 값 실측:
+ 총 시간 = "02:34"
+ 보스 처치 = "02:34"
+ 처치 수 = "99 (보스 1)"
+ 최대 연쇄 = "9"
+ 최대 피해 = "8889"
+ 레벨 = "11 → 14"
+ 등급별 = "7 3 2 1"
+ 골드 = "777"
+ 사망/물약 = "2 / 3"
+ [PASS] 총 시간 154s → 02:34
+ [PASS] 보스 처치 153.5s → 02:34(올림)
+ [PASS] 처치 수 = 99 (보스 1)
+ [PASS] 최대 연쇄 = 9
+ [PASS] 최대 피해 = 합산(maxDamageBurst 8888.5 → 8889 · 0.5 는 올림) · 단일(1234.5)이 아니다 — 실측 "8889"
+ [PASS] 레벨 11 → 14
+ [PASS] 골드 = 777
+ [PASS] 사망/물약 = 2 / 3
+ [PASS] 등급별 획득 = 1급 7 · 2급 3 · 4급 2 · 9급 1 (0 인 등급 생략 · 4칸)
+ [PASS] 등급 색 태그 4개(813f 팔레트 + 813y 밝기 하한) — 실측 4
+ 등급 색: 1=→ 2= 4= 9=
+ [PASS] 본문 9행 표시 — 실측 9
+ [RunResultUI] 구독=True bound=True open=True pending=False · ended=1 shows=1 defer=0 next=0 close=0 abandon생략=0 버퍼증설=0 공용팝업=False
+ 제목 "런 클리어!" · 행 9개 · 부활팝업Busy=False
+ - TotalTime : "02:34"
+ - BossClear : "02:34"
+ - Kills : "99 (보스 1)"
+ - MaxChain : "9"
+ - MaxDamage : "8889"
+ - Level : "11 → 14"
+ - LootByGrade : "7 3 2 1"
+ - Gold : "777"
+ - DeathsPotions : "2 / 3"
+ 복사본 outcome=Win runIndex=2 total=154.0s bossClear=153.5s kills=99(보스 1) chain=9 dmg=1234.5/8888.5 lv=11→14 lootTotal=13 gold=777 deaths=2 potions=3
+ 복사 배열 zone(4): [0]=31.5/12 [1]=62.3/14 [2]=95.0/12 [3]=-1.0/5 · loot(10): 0 7 3 0 2 0 0 0 0 1
+ 본문 = 런 클리어! | 총 시간 : 02:34 | 보스 처치 : 02:34 | 처치 수 : 99 (보스 1) | 최대 연쇄 : 9 | 최대 피해 : 8889 | 레벨 : 11 → 14 | 획득 : 7 3 2 1 | 골드 : 777 | 사망 / 물약 : 2 / 3 |
+ 설정 enabled=True result=True 공용팝업=False abandon표시=False 부활우선=True box=(860.00, 1080.00) 행수=9 합산피해=True
+
+── ④ 🔴 배열 3종 즉시 복사(813p 재사용 계약) — 원본을 덮어써도 화면 값 불변
+ [PASS] zoneClearSec 참조가 원본과 다르다(자기 버퍼)
+ [PASS] zoneKills 참조가 원본과 다르다(자기 버퍼)
+ [PASS] lootByGrade 참조가 원본과 다르다(자기 버퍼)
+ [PASS] 존 배열 값 불변 — clearSec[0]=31.50 kills[0]=12 (원본은 −999 로 덮어씀)
+ [PASS] loot 배열 값 불변 — [1]=7 (원본은 999)
+ [PASS] 결과 화면 등급 줄 문자열 불변
+ 원본 배열 현재값: zoneClearSec[0]=-999 zoneKills[0]=-999 loot[1]=999
+ [PASS] 두 번째 복사에서 버퍼 증설 0(런마다 배열 할당 0) — 증설 누계 0
+
+── ⑤ 시간 종료(Timeout)
+ [PASS] 결과 화면 표시
+ [PASS] 제목 = "시간 종료" — 실측 "시간 종료"
+ [PASS] 보스 처치 −1 → "—"(미발생 대시)
+ [PASS] 보스 처치 0
+
+── ⑥ 이탈(Abandon) — 팝업 없음 · HUD 리셋만
+ [PASS] 팝업 0 (open=False)
+ [PASS] 표시 호출 0 (누계 2)
+ [PASS] Abandon 생략 카운터 +1
+ [PASS] 대기(pending) 0 — 나중에 저절로 뜨지 않는다
+ [PASS] HUD 숨김(리셋)
+
+── ⑦ 813i/813tj 부활 팝업과 동시 — 부활 우선 · 결과는 닫힌 뒤
+ [PASS] 프리팹에서 ReviveDialog 발견 — 1개
+ ReviveDialog.Initialize : initialized · 폴백 팝업 box size(1351.11, 675.56) unitsPerPx=1.7778
+ ReviveDialog.ShowNowOnce: 폴백 팝업 표시 msg="존 시작점에서 부활합니다.
+무료"
+ [PASS] 부활 팝업 Busy = True
+ [PASS] 결과 화면 아직 안 뜬다(부활 우선)
+ [PASS] 결과 대기 중 = True
+ [PASS] 미룸 카운터 +1
+ [PASS] 부활 팝업 닫힘 → Busy=false
+ Tick: 런타임 패널 표시 outcome=Win 행=9
+ [PASS] 부활 팝업이 닫힌 뒤 결과 화면 표시
+ [PASS] 대기 해제
+ [다음 런] : 다음 런 — RunDirector.Restart() 호출
+ [PASS] [다음 런] 클릭 경로 = RunDirector.Restart() 1회
+ [PASS] [다음 런] 뒤 결과 화면 닫힘
+ RunDirector 상태: phase=Zones runIndex=4 restartCount=4
+ [닫기] : 닫기
+ [PASS] [닫기] 뒤 결과 화면 닫힘
+
+── ⑧ GC — 틱 100회 할당 0 (대조군 2종)
+ 계기 = GC.GetTotalMemory (Boehm 힙 크기) + GC.CollectionCount(0) · GetAllocatedBytesForCurrentThread 절대값=0 (0 이면 이 런타임에 미구현 → 쓰지 않는다)
+ 본시험(같은 초 100틱): ΔGetTotalMemory=0 B · gc0 340→340 · 표시 쓰기 31→31 (Δ할당 계기 참고값 0 B · 미구현)
+ [PASS] 🔴 구조 증명: 값이 그대로면 SetText/fillAmount 쓰기 0회 = 만들 문자열 자체가 없다
+ [PASS] ΔGC = 0 B · gc0 불변 (RunTick 100회)
+ 대조군 A(초가 바뀌는 100틱 · SetText 99회): ΔGetTotalMemory=0 B · gc0 343→343 — 우리 코드의 mm:ss 는 char 버퍼(문자열 0)이고, 에디터에서는 TMP 가 `#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로 인스펙터용 문자열을 만든다(빌드에는 없는 줄).
+ 대조군 B(40,000자 문자열 200개 ≈ 16 MB): ΔGetTotalMemory=16384000 B · gc0 346→346 (sink=200) — 계기 감도 확인. 100 KB 규모는 Boehm 힙 여유분에 묻혀 Δ0 이 나온다(실측).
+ [PASS] 측정 감도 있음(대조군 B ΔGetTotalMemory > 0)
+ [PASS] mm:ss 경계값(0 · 음수 · 올림 · 240 · 상한 99:59)
+
+── ⑨ C8 롤백 — enabled = 0 이면 HUD·결과 0 · 813p 는 그대로
+ [PASS] RuntimeDisabled=1 → Enabled=False
+ HUD Tick: off — 무동작
+ 결과 Tick: off — 무동작
+ [PASS] HUD 숨김 · 결과 닫힘
+ Bind(off): off — 조립 생략
+ [PASS] asset.enabled_=0 → Enabled=False (같은 게이트)
+ [PASS] 복구 후 Enabled=True
+ [PASS] 813p WLRunSettings.Enabled 불변 = True (런 구조는 그대로 동작)
+
+── ⑩ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0
+ RunHud : teardown 완료
+ RunResultUI: teardown 완료
+ [PASS] RunEvents 구독자 전부 0 (Started 0 Tick 0 Zone 0 Gate 0 Boss 0 Ended 0)
+ RunEvents.TotalRaised=11 last=RunTick · Dispatched(Tick)=820
+ [PASS] TimeScale 무접촉 = 1
+ UnloadPrefabContents — 프리팹 저장 0
+
+RESULT PASS (pass=87 fail=0)
diff --git a/AgentScripts/WL813q_Probe.cs b/AgentScripts/WL813q_Probe.cs
new file mode 100644
index 000000000..7912b4a39
--- /dev/null
+++ b/AgentScripts/WL813q_Probe.cs
@@ -0,0 +1,555 @@
+// 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");
+ }
+ }
+}
diff --git a/Assets/WL/UI/Scripts/ReviveDialog.cs b/Assets/WL/UI/Scripts/ReviveDialog.cs
index 38c907e9d..914f93d12 100644
--- a/Assets/WL/UI/Scripts/ReviveDialog.cs
+++ b/Assets/WL/UI/Scripts/ReviveDialog.cs
@@ -44,6 +44,12 @@ namespace WL.UI
/// 현재 씬에 살아 있는 인스턴스(정적 진입점이 찾는다).
public static ReviveDialog Active { get; private set; }
+ ///
+ /// 부활 팝업이 지금 떠 있거나 표시 예약 중인가(공용 Popup 경로 포함).
+ /// WL-813q 결과 화면이 「부활 팝업 우선」 순서를 지킬 때 본다(읽기 전용 · 동작 변경 0).
+ ///
+ public static bool Busy { get { return Active != null && (Active._open || Active._showAt >= 0f); } }
+
/// 813i DeathFlow 가 사망 연출 뒤 부르는 진입점. 인스턴스가 없으면 무동작.
public static string NotifyDeath()
{
diff --git a/Assets/WL/UI/Scripts/RunHud.cs b/Assets/WL/UI/Scripts/RunHud.cs
new file mode 100644
index 000000000..036d8c9d9
--- /dev/null
+++ b/Assets/WL/UI/Scripts/RunHud.cs
@@ -0,0 +1,635 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// RunHud.cs — 런 HUD: 남은 시간(mm:ss) · 존 게이지 · 단계 라벨 (WL-813q · #813)
+//
+// 기준서 §D-2 813q 행 · 발주서 WL-813q §1-1.
+// 구독 계약 = 813p 완료보고 §4 표 = `Assets/WL/Combat/Run/RunEvents.cs`(**읽기만** · 수정 0).
+// RunStarted / RunTick / ZoneCleared / BossGateOpened / BossStarted / RunEnded
+//
+// ■ 프리팹 diff 0 (813y 방식)
+// 노드를 프리팹에 굽지 않는다 — 러너도 HUD 노드도 **런타임 생성**(`HideFlags.DontSave`).
+// 부모는 `IngameUIs/WL_HUD`(813y 실측 SafeAreaFitter 아래 = Safe Area 안 · 경로는 SO).
+// 보스 HP 바(813c · 상단 여백 190 px + 높이 66 px = 256 px)와 겹치지 않게 **그 아래**에 앉는다
+// (기본 `runHudTopMarginPx` = 272 px · 값은 SO).
+// 전투 중 하단 메뉴 숨김(813y `HudCombatVisibility`)은 화면 **아래**를 만지므로 겹치지 않는다.
+//
+// ■ GC 0 (틱마다 문자열 0)
+// · mm:ss = char 버퍼 5글자를 직접 써서 `TMP_Text.SetText(char[], start, length)`.
+// · 그마저도 **표시값이 바뀐 초에만** 부른다(같은 초를 100번 틱해도 SetText 0회).
+// · 존 게이지 = `Image.fillAmount`(값이 바뀔 때만) · 펄스 = `localScale`.
+// · 단계 라벨 = 단계/존 번호가 바뀔 때만 `SetText(format, arg0)`(TMP 무할당 오버로드).
+// · 로그 문자열은 **만들기 전에** `verboseLog` 로 막는다(811b FIX-3 규약).
+// 🔴 에디터에서는 TMP 자체가 `#if UNITY_EDITOR m_text = InternalTextBackingArrayToString()` 로
+// SetText 1회당 문자열 1개를 만든다(TMP_Text.cs). 이는 **TMP 의 에디터 전용 줄**이고
+// 빌드에는 없다 — 프로브가 「같은 초 100틱 = 0 B」와 「변하는 초 100틱」을 나눠 잰다.
+//
+// ■ C8 롤백 — `WLRunUiSettings.enabled_ = 0`(또는 `runHudEnabled = 0`) → 구독 0 · 노드 0 · 표시 0.
+// 🔴 어셈블리 주의: Assets/WL/UI/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
+// ─────────────────────────────────────────────────────────────────────────────
+
+using System.Text;
+using TMPro;
+using UnityEngine;
+using UnityEngine.UI;
+using WL.Combat.Run;
+
+namespace WL.UI
+{
+ /// 런 진행 HUD(정적 · 노드는 런타임 생성 · 프리팹 diff 0).
+ public static class RunHud
+ {
+ // ── 노드 ──────────────────────────────────────────────────────────────
+ private static Transform s_uiRoot;
+ private static RectTransform s_root;
+ private static TextMeshProUGUI s_timer;
+ private static TextMeshProUGUI s_phase;
+ private static RectTransform s_zoneRoot;
+ private static Image[] s_zoneBg = new Image[0];
+ private static Image[] s_zoneFill = new Image[0];
+ private static RectTransform[] s_zoneCell = new RectTransform[0];
+ private static float[] s_zonePulseAt = new float[0];
+ private static float[] s_zoneFillValue = new float[0];
+ private static int s_zoneCells;
+ private static TMP_FontAsset s_font;
+
+ // ── 상태(값 캐시 = 변할 때만 그린다 = GC 0) ───────────────────────────
+ private static bool s_subscribed;
+ private static bool s_visible;
+ private static int s_shownSec = int.MinValue;
+ private static bool s_shownWarn;
+ private static int s_shownPhaseKind = -1; // 0 = 존 · 1 = 보스
+ private static int s_shownPhaseZone = -1;
+ private static float s_runSeconds;
+ private static int s_zoneCount;
+ private static float s_remaining;
+ private static bool s_bossPhase;
+
+ // ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
+ public static int StartedSeen, TickSeen, ZoneClearedSeen, GateSeen, BossSeen, EndedSeen;
+ public static int TimerWrites, PhaseWrites, FillWrites;
+ public static string LastEdge = "";
+ public static bool Subscribed { get { return s_subscribed; } }
+ public static bool Bound { get { return s_root != null; } }
+ public static bool Visible { get { return s_visible; } }
+ public static int ZoneCells { get { return s_zoneCells; } }
+ /// 지금 화면에 떠 있는 타이머 문자열(실측 덤프용 — 표시 경로가 아니다).
+ public static string TimerText { get { return s_timer != null ? s_timer.text : ""; } }
+ public static string PhaseText { get { return s_phase != null ? s_phase.text : ""; } }
+
+ private static WLRunUiSettings St { get { return WLRunUiSettings.Instance; } }
+
+ // ── 부팅 (노드 0 · 프리팹 diff 0) ─────────────────────────────────────
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
+ private static void Boot()
+ {
+ var s = St;
+ if (!WLRunUiSettings.Enabled || s == null || !s.runHudEnabled) return;
+ Subscribe(true);
+ WLRunUiRunner.Ensure();
+ }
+
+ // ── 구독 (813p RunEvents 6종) ─────────────────────────────────────────
+ public static string Subscribe(bool on)
+ {
+ if (on == s_subscribed) return "구독 변화 없음(" + s_subscribed + ")";
+ if (on)
+ {
+ RunEvents.RunStarted.Add(OnRunStarted);
+ RunEvents.RunTick.Add(OnRunTick);
+ RunEvents.ZoneCleared.Add(OnZoneCleared);
+ RunEvents.BossGateOpened.Add(OnBossGateOpened);
+ RunEvents.BossStarted.Add(OnBossStarted);
+ RunEvents.RunEnded.Add(OnRunEnded);
+ }
+ else
+ {
+ RunEvents.RunStarted.Remove(OnRunStarted);
+ RunEvents.RunTick.Remove(OnRunTick);
+ RunEvents.ZoneCleared.Remove(OnZoneCleared);
+ RunEvents.BossGateOpened.Remove(OnBossGateOpened);
+ RunEvents.BossStarted.Remove(OnBossStarted);
+ RunEvents.RunEnded.Remove(OnRunEnded);
+ }
+ s_subscribed = on;
+ return "구독=" + on + " (RunStarted/RunTick/ZoneCleared/BossGateOpened/BossStarted/RunEnded)";
+ }
+
+ // ── 이벤트 핸들러 (in 파라미터 = 복사 0 · 813p 계약) ──────────────────
+ private static void OnRunStarted(in RunStartedEvent e)
+ {
+ StartedSeen++;
+ s_runSeconds = e.runSeconds;
+ s_zoneCount = e.zoneCount;
+ s_remaining = e.runSeconds;
+ s_bossPhase = false;
+ s_shownSec = int.MinValue; s_shownWarn = false;
+ s_shownPhaseKind = -1; s_shownPhaseZone = -1;
+ BuildZoneCells(e.zoneCount);
+ ResetZoneCells();
+ SetVisible(true);
+ WriteTimer(e.runSeconds);
+ WritePhase(false, 0);
+ var s = St;
+ if (s != null && s.verboseLog) Debug.Log("[RunHud] 런 시작 #" + e.runIndex + " — " + e.runSeconds + "s · 존 " + e.zoneCount);
+ }
+
+ private static void OnRunTick(in RunTickEvent e)
+ {
+ TickSeen++;
+ s_remaining = e.remainingSec;
+ if (e.zoneCount != s_zoneCount) { s_zoneCount = e.zoneCount; BuildZoneCells(e.zoneCount); }
+ WriteTimer(e.remainingSec);
+ // 클리어한 칸은 가득 · 현재 칸은 진행률 · 나머지는 0 (전부 변할 때만 쓴다)
+ for (int i = 0; i < s_zoneCells; i++)
+ {
+ float v = i < e.zonesCleared ? 1f
+ : (i == e.zoneIndex && e.zoneRequired > 0 ? Mathf.Clamp01((float)e.zoneKills / e.zoneRequired) : 0f);
+ SetFill(i, v, i < e.zonesCleared);
+ }
+ bool boss = s_bossPhase || e.phase == RunPhase.BossGate || e.phase == RunPhase.Boss;
+ WritePhase(boss, e.zoneIndex);
+ }
+
+ private static void OnZoneCleared(in ZoneClearedEvent e)
+ {
+ ZoneClearedSeen++;
+ if (e.zoneIndex >= 0 && e.zoneIndex < s_zoneCells)
+ {
+ SetFill(e.zoneIndex, 1f, true);
+ s_zonePulseAt[e.zoneIndex] = Time.unscaledTime; // 펄스 시작(러너가 되돌린다)
+ }
+ var s = St;
+ if (s != null && s.verboseLog) Debug.Log("[RunHud] 존 클리어 #" + e.zoneIndex + " " + e.kills + "/" + e.required);
+ }
+
+ private static void OnBossGateOpened(in BossGateOpenedEvent e)
+ {
+ GateSeen++;
+ s_bossPhase = true;
+ WritePhase(true, 0);
+ }
+
+ private static void OnBossStarted(in BossStartedEvent e)
+ {
+ BossSeen++;
+ s_bossPhase = true; // e.boss 는 null 일 수 있다(813p 계약) — 여기서는 쓰지 않는다
+ WritePhase(true, 0);
+ }
+
+ private static void OnRunEnded(in RunResult e)
+ {
+ EndedSeen++;
+ s_bossPhase = false;
+ var s = St;
+ if (s != null && s.runHudHideOnEnd) SetVisible(false);
+ }
+
+ // ── 표시 (전부 "변할 때만" 쓴다) ──────────────────────────────────────
+ private static readonly char[] s_timeBuf = new char[8];
+
+ /// 남은 초 → "mm:ss" 를 char 버퍼에 직접 쓴다(문자열 0). 글자 수를 돌려준다.
+ public static int FormatMMSS(char[] buf, float seconds)
+ {
+ if (buf == null || buf.Length < 5) return 0;
+ int t = seconds <= 0f ? 0 : Mathf.CeilToInt(seconds);
+ int m = t / 60;
+ int sec = t - m * 60;
+ if (m > 99) { m = 99; sec = 59; }
+ buf[0] = (char)('0' + m / 10);
+ buf[1] = (char)('0' + m % 10);
+ buf[2] = ':';
+ buf[3] = (char)('0' + sec / 10);
+ buf[4] = (char)('0' + sec % 10);
+ return 5;
+ }
+
+ private static void WriteTimer(float remaining)
+ {
+ var s = St;
+ if (s_timer == null || s == null || !s.timerEnabled) return;
+ int t = remaining <= 0f ? 0 : Mathf.CeilToInt(remaining);
+ bool warn = s.timerWarnSeconds > 0f && remaining <= s.timerWarnSeconds;
+ if (t == s_shownSec && warn == s_shownWarn) return; // 같은 초 = 아무 것도 하지 않는다(GC 0)
+ s_shownSec = t;
+ if (warn != s_shownWarn) { s_shownWarn = warn; s_timer.color = warn ? s.timerWarnColor : s.timerColor; }
+ int n = FormatMMSS(s_timeBuf, remaining);
+ s_timer.SetText(s_timeBuf, 0, n);
+ TimerWrites++;
+ }
+
+ private static void WritePhase(bool boss, int zoneIndex)
+ {
+ var s = St;
+ if (s_phase == null || s == null || !s.phaseLabelEnabled) return;
+ int kind = boss ? 1 : 0;
+ int zone = boss ? -1 : zoneIndex;
+ if (kind == s_shownPhaseKind && zone == s_shownPhaseZone) return;
+ s_shownPhaseKind = kind; s_shownPhaseZone = zone;
+ if (boss)
+ {
+ s_phase.SetText(s.phaseBossText);
+ s_phase.color = s.phaseBossColor;
+ }
+ else
+ {
+ int shown = Mathf.Clamp(zoneIndex + 1, 1, Mathf.Max(1, s_zoneCount));
+ s_phase.SetText(s.phaseZoneFormat, shown); // TMP 무할당 오버로드({0} = float)
+ s_phase.color = s.phaseColor;
+ }
+ PhaseWrites++;
+ }
+
+ private static void SetFill(int i, float v, bool cleared)
+ {
+ if (i < 0 || i >= s_zoneCells || s_zoneFill[i] == null) return;
+ var s = St;
+ if (Mathf.Abs(v - s_zoneFillValue[i]) < 0.0005f) return; // 값이 그대로면 아무 것도 하지 않는다
+ s_zoneFillValue[i] = v;
+ s_zoneFill[i].fillAmount = v;
+ if (s != null)
+ {
+ var want = cleared || v >= 0.999f ? s.zoneCellClearColor : s.zoneCellFillColor;
+ if (s_zoneFill[i].color != want) s_zoneFill[i].color = want;
+ }
+ FillWrites++;
+ }
+
+ private static void ResetZoneCells()
+ {
+ for (int i = 0; i < s_zoneCells; i++)
+ {
+ s_zoneFillValue[i] = -1f; // 다음 SetFill 이 반드시 쓰게 한다
+ SetFill(i, 0f, false);
+ s_zonePulseAt[i] = -9999f;
+ if (s_zoneCell[i] != null) s_zoneCell[i].localScale = Vector3.one;
+ }
+ }
+
+ public static void SetVisible(bool on)
+ {
+ s_visible = on;
+ if (s_root != null && s_root.gameObject.activeSelf != on) s_root.gameObject.SetActive(on);
+ }
+
+ // ── 매 프레임(러너) — 펄스만 돌린다 ───────────────────────────────────
+ public static string Tick(float now)
+ {
+ var s = St;
+ if (s == null) return "WLRunUiSettings 에셋 없음";
+ if (!WLRunUiSettings.Enabled || !s.runHudEnabled) { if (s_visible) SetVisible(false); return "off — 무동작"; }
+ float dur = Mathf.Max(0f, s.zonePulseSeconds);
+ float peak = Mathf.Max(1f, s.zonePulseScale);
+ for (int i = 0; i < s_zoneCells; i++)
+ {
+ var rt = s_zoneCell[i];
+ if (rt == null) continue;
+ float k = dur <= 0f ? 1f : Mathf.Clamp01((now - s_zonePulseAt[i]) / dur);
+ float sc = k >= 1f ? 1f : Mathf.Lerp(peak, 1f, k * k);
+ if (!Mathf.Approximately(rt.localScale.x, sc)) rt.localScale = new Vector3(sc, sc, 1f);
+ }
+ return "tick ok";
+ }
+
+ // ── 조립 (런타임 생성 · 프리팹 diff 0) ────────────────────────────────
+ /// UI 루트(NewGameUI)에서 부모를 찾아 HUD 노드를 만든다. 이미 물려 있으면 그대로.
+ public static string Bind(Transform uiRoot)
+ {
+ var s = St;
+ if (s == null) return "WLRunUiSettings 에셋 없음";
+ if (!WLRunUiSettings.Enabled || !s.runHudEnabled) return "off — 조립 생략";
+ if (uiRoot == null) return "uiRoot=null";
+ if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name;
+
+ s_uiRoot = uiRoot;
+ var parent = WLVignetteUtil.FindUiPath(uiRoot, s.runHudParentPath);
+ var prt = parent as RectTransform;
+ if (prt == null) return "부모 없음 — 경로 \"" + s.runHudParentPath + "\"";
+
+ if (s_font == null) s_font = BorrowFont(uiRoot);
+
+ if (s_root == null || s_root.parent != prt)
+ {
+ var go = new GameObject("WL_RunHud", typeof(RectTransform));
+ go.layer = WLTextFxUtil.UILayer;
+ go.hideFlags = HideFlags.DontSave; // 씬·프리팹에 굽지 않는다
+ s_root = (RectTransform)go.transform;
+ s_root.SetParent(prt, false);
+ s_timer = WLTextFxUtil.NewText(s_root, "Timer", s_font, TextAlignmentOptions.Center);
+ s_zoneRoot = WLTextFxUtil.NewChild(s_root, "Zones");
+ s_phase = WLTextFxUtil.NewText(s_root, "Phase", s_font, TextAlignmentOptions.Center);
+ s_zoneCells = 0;
+ }
+ BuildZoneCells(Mathf.Max(s_zoneCount, RunDirector.ZoneCount));
+ string layout = ApplyLayout();
+ SetVisible(RunDirector.IsRunning);
+ return "연결 " + s.runHudParentPath + " · " + layout;
+ }
+
+ /// 기존 HUD 라벨에서 폰트를 빌린다(아트 에셋 참조 0 · 한글 글리프 확보).
+ public static TMP_FontAsset BorrowFont(Transform uiRoot)
+ {
+ if (uiRoot == null) return null;
+ var labels = uiRoot.GetComponentsInChildren(true);
+ for (int i = 0; i < labels.Length; i++)
+ if (labels[i] != null && labels[i].font != null) return labels[i].font;
+ return null;
+ }
+
+ /// 폰트를 직접 지정한다(프로브·QA).
+ public static void SetFont(TMP_FontAsset f)
+ {
+ s_font = f;
+ if (f == null) return;
+ if (s_timer != null) s_timer.font = f;
+ if (s_phase != null) s_phase.font = f;
+ }
+
+ /// 존 칸을 개수에 맞춰 만든다(개수가 그대로면 아무 것도 하지 않는다).
+ public static bool BuildZoneCells(int want)
+ {
+ want = Mathf.Max(0, want);
+ if (s_zoneRoot == null) return false;
+ if (want == s_zoneCells) return false; // 칸 수가 그대로면 다시 만들지 않는다
+
+ if (s_zoneCell.Length < want)
+ {
+ System.Array.Resize(ref s_zoneCell, want);
+ System.Array.Resize(ref s_zoneBg, want);
+ System.Array.Resize(ref s_zoneFill, want);
+ System.Array.Resize(ref s_zonePulseAt, want);
+ System.Array.Resize(ref s_zoneFillValue, want);
+ }
+ for (int i = 0; i < want; i++)
+ {
+ if (s_zoneCell[i] != null) continue;
+ var cell = WLTextFxUtil.NewChild(s_zoneRoot, "Zone" + i);
+ var bg = cell.GetComponent();
+ if (bg == null) bg = cell.gameObject.AddComponent();
+ bg.raycastTarget = false;
+ var fillRt = WLTextFxUtil.NewChild(cell, "Fill");
+ WLTextFxUtil.Stretch(fillRt);
+ var fill = fillRt.GetComponent();
+ if (fill == null) fill = fillRt.gameObject.AddComponent();
+ fill.raycastTarget = false;
+ fill.type = Image.Type.Filled;
+ fill.fillMethod = Image.FillMethod.Horizontal;
+ fill.fillOrigin = (int)Image.OriginHorizontal.Left;
+ fill.fillAmount = 0f;
+ s_zoneCell[i] = cell; s_zoneBg[i] = bg; s_zoneFill[i] = fill;
+ s_zonePulseAt[i] = -9999f; s_zoneFillValue[i] = -1f;
+ }
+ // 남는 칸은 파괴하지 않고 숨긴다(다음 런에서 다시 쓴다)
+ for (int i = want; i < s_zoneCell.Length; i++)
+ if (s_zoneCell[i] != null && s_zoneCell[i].gameObject.activeSelf) s_zoneCell[i].gameObject.SetActive(false);
+ for (int i = 0; i < want; i++)
+ if (s_zoneCell[i] != null && !s_zoneCell[i].gameObject.activeSelf) s_zoneCell[i].gameObject.SetActive(true);
+
+ s_zoneCells = want;
+ ApplyLayout();
+ return true;
+ }
+
+ /// SO 값으로 배치한다(px → 유닛 환산은 813c 산식 하나만 쓴다).
+ public static string ApplyLayout()
+ {
+ var s = St;
+ if (s == null) return "WLRunUiSettings 에셋 없음 — 배치 건너뜀";
+ if (s_root == null) return "노드 없음 — Bind 먼저";
+
+ var canvas = s_root.GetComponentInParent");
+ shown++;
+ }
+ if (shown == 0) sb.Append(s.rowLootEmptyText);
+ }
+
+ // ── 조립 (런타임 생성 · 프리팹 diff 0 · 1회) ──────────────────────────
+ public static string Bind(Transform uiRoot)
+ {
+ var s = St;
+ if (s == null) return "WLRunUiSettings 에셋 없음";
+ if (!WLRunUiSettings.Enabled || !s.resultEnabled) return "off — 조립 생략";
+ if (uiRoot == null) return "uiRoot=null";
+ if (s_root != null && s_uiRoot == uiRoot) return "이미 연결됨 — " + s_root.name;
+
+ s_uiRoot = uiRoot;
+ var prt = WLVignetteUtil.FindUiPath(uiRoot, s.resultParentPath) as RectTransform;
+ if (prt == null) return "부모 없음 — 경로 \"" + s.resultParentPath + "\"";
+ if (s_font == null) s_font = RunHud.BorrowFont(uiRoot);
+
+ if (s_root == null || s_root.parent != prt)
+ {
+ var go = new GameObject("WL_RunResult", typeof(RectTransform));
+ go.layer = WLTextFxUtil.UILayer;
+ go.hideFlags = HideFlags.DontSave;
+ s_root = (RectTransform)go.transform;
+ s_root.SetParent(prt, false);
+ s_group = go.AddComponent();
+
+ var dimRt = WLVignetteUtil.NewChild(s_root, "Dim");
+ WLVignetteUtil.Stretch(dimRt);
+ s_dim = dimRt.GetComponent();
+ if (s_dim == null) s_dim = dimRt.gameObject.AddComponent();
+ s_dim.raycastTarget = true;
+
+ s_box = WLVignetteUtil.NewChild(s_root, "Box");
+ s_boxBg = s_box.GetComponent();
+ if (s_boxBg == null) s_boxBg = s_box.gameObject.AddComponent();
+ s_boxBg.raycastTarget = true;
+
+ s_title = WLTextFxUtil.NewText(s_box, "Title", s_font, TextAlignmentOptions.Center);
+ s_rowRoot = WLTextFxUtil.NewChild(s_box, "Rows");
+ s_nextButton = NewButton(s_box, "btn_next", out s_nextBg, out s_nextLabel, OnNextRunListener);
+ s_closeButton = NewButton(s_box, "btn_close", out s_closeBg, out s_closeLabel, OnCloseListener);
+ }
+ EnsureRows(s);
+ string layout = ApplyLayout();
+ Hide();
+ return "연결 " + s.resultParentPath + " · " + layout;
+ }
+
+ private static Button NewButton(RectTransform parent, string name, out Image bg, out TextMeshProUGUI label,
+ UnityEngine.Events.UnityAction onClick)
+ {
+ var t = WLVignetteUtil.NewChild(parent, name);
+ bg = t.GetComponent();
+ if (bg == null) bg = t.gameObject.AddComponent();
+ bg.raycastTarget = true;
+ var b = t.GetComponent