Project_WL/AgentScripts/WL813q_Probe.cs

556 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// WL-813q 프로브 — 런 HUD + 런 결과 화면 실측 (에디트 모드 · Play 0 · 로그인 0).
// unity command run_script --file AgentScripts/WL813q_Probe.cs --entry WL813q_Probe.RunAll
// 산출물: <worktree>/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<SafeAreaFitter>(true) != null || hud.GetComponent<SafeAreaFitter>() != 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<TextMeshProUGUI>(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) == "<color=") colorTags++;
Chk(colorTags == 4, "등급 색 태그 4개(813f 팔레트 + 813y 밝기 하한) — 실측 " + colorTags);
var text = WLCombatTextSettings.Instance;
if (text != null)
N(" 등급 색: 1=" + text.GradeColorTag(1) + "→" + text.GradeColorTagBright(1) +
" 2=" + text.GradeColorTagBright(2) + " 4=" + text.GradeColorTagBright(4) + " 9=" + text.GradeColorTagBright(9));
Chk(RunResultUI.VisibleRows == 9, "본문 9행 표시 — 실측 " + RunResultUI.VisibleRows);
N(RunResultUI.Dump().TrimEnd());
}
// ─────────────────────────────────────────────── ④ 배열 복사 증명
static void Step4_ArrayCopy()
{
H("④ 🔴 배열 3종 즉시 복사(813p 재사용 계약) — 원본을 덮어써도 화면 값 불변");
string beforeLoot = RunResultUI.RowValueText(WLRunResultRow.LootByGrade);
float beforeZ0 = RunResultUI.CopiedZoneClearSec(0);
int beforeK0 = RunResultUI.CopiedZoneKills(0);
int beforeG1 = RunResultUI.CopiedLootByGrade(1);
int growBefore = RunResultUI.BufferGrowCount;
// 813p RunDirector 가 다음 런에서 하는 그대로 — **같은 배열 인스턴스**를 덮어쓴다
for (int i = 0; i < _srcZoneSec.Length; i++) { _srcZoneSec[i] = -999f; _srcZoneKills[i] = -999; }
for (int g = 0; g < _srcLoot.Length; g++) _srcLoot[g] = 999;
var c = RunResultUI.Copied;
Chk(!ReferenceEquals(c.zoneClearSec, _srcZoneSec), "zoneClearSec 참조가 원본과 다르다(자기 버퍼)");
Chk(!ReferenceEquals(c.zoneKills, _srcZoneKills), "zoneKills 참조가 원본과 다르다(자기 버퍼)");
Chk(!ReferenceEquals(c.lootByGrade, _srcLoot), "lootByGrade 참조가 원본과 다르다(자기 버퍼)");
Chk(Mathf.Approximately(RunResultUI.CopiedZoneClearSec(0), beforeZ0) && RunResultUI.CopiedZoneKills(0) == beforeK0,
"존 배열 값 불변 — clearSec[0]=" + RunResultUI.CopiedZoneClearSec(0).ToString("F2") +
" kills[0]=" + RunResultUI.CopiedZoneKills(0) + " (원본은 999 로 덮어씀)");
Chk(RunResultUI.CopiedLootByGrade(1) == beforeG1, "loot 배열 값 불변 — [1]=" + RunResultUI.CopiedLootByGrade(1) + " (원본은 999)");
Chk(RunResultUI.RowValueText(WLRunResultRow.LootByGrade) == beforeLoot, "결과 화면 등급 줄 문자열 불변");
N(" 원본 배열 현재값: zoneClearSec[0]=" + _srcZoneSec[0] + " zoneKills[0]=" + _srcZoneKills[0] + " loot[1]=" + _srcLoot[1]);
// 두 번째 런: 버퍼를 다시 잡지 않는다(= 런마다 할당 0)
var r2 = MakeResult(RunOutcome.Win);
RunResultUI.CopyResult(in r2);
Chk(RunResultUI.BufferGrowCount == growBefore, "두 번째 복사에서 버퍼 증설 0(런마다 배열 할당 0) — 증설 누계 " + RunResultUI.BufferGrowCount);
}
// ─────────────────────────────────────────────── ⑤ Timeout
static void Step5_Timeout()
{
H("⑤ 시간 종료(Timeout)");
var s = WLRunUiSettings.Instance;
RunResultUI.Hide();
var r = MakeResult(RunOutcome.Timeout);
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(RunResultUI.IsOpen, "결과 화면 표시");
Chk(RunResultUI.TitleText == (s != null ? s.resultTimeoutTitle : ""),
"제목 = \"" + (s != null ? s.resultTimeoutTitle : "") + "\" — 실측 \"" + RunResultUI.TitleText + "\"");
Chk(RunResultUI.RowValueText(WLRunResultRow.BossClear) == (s != null ? s.resultDashText : "—"),
"보스 처치 1 → \"" + RunResultUI.RowValueText(WLRunResultRow.BossClear) + "\"(미발생 대시)");
Chk(RunResultUI.RowValueText(WLRunResultRow.Kills) == "99 (보스 0)", "보스 처치 0");
}
// ─────────────────────────────────────────────── ⑥ Abandon
static void Step6_Abandon()
{
H("⑥ 이탈(Abandon) — 팝업 없음 · HUD 리셋만");
RunResultUI.Hide();
int shownBefore = RunResultUI.ShownCount;
int skipBefore = RunResultUI.SkipAbandonCount;
var r = MakeResult(RunOutcome.Abandon);
WLRunUiProbeHooks.RaiseRunEnded(in r);
Chk(!RunResultUI.IsOpen, "팝업 0 (open=" + RunResultUI.IsOpen + ")");
Chk(RunResultUI.ShownCount == shownBefore, "표시 호출 0 (누계 " + RunResultUI.ShownCount + ")");
Chk(RunResultUI.SkipAbandonCount == skipBefore + 1, "Abandon 생략 카운터 +1");
Chk(!RunResultUI.Pending, "대기(pending) 0 — 나중에 저절로 뜨지 않는다");
Chk(!RunHud.Visible, "HUD 숨김(리셋)");
}
// ─────────────────────────────────────────────── ⑦ 부활 팝업 우선
static void Step7_RevivePriority()
{
H("⑦ 813i/813tj 부활 팝업과 동시 — 부활 우선 · 결과는 닫힌 뒤");
var dlg = _root != null ? _root.GetComponentsInChildren<ReviveDialog>(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<string>(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");
}
}
}