// WL-813o 프로브 — 존 클리어 픽업 카드 실측 (에디트 모드 · Play 0 · 로그인 0). // unity command run_script --file AgentScripts/WL813o_Probe.cs --entry WL813o_Probe.RunAll // 산출물: /AgentScripts/WL813o_PROBE.txt // 🔴 batchmode 는 Time.unscaledTime 이 전혀 전진하지 않는다(실측: run_script 두 번 사이 12 s 를 두어도 11.35 고정). // 그래서 「멈춘 초」는 813p ShiftClockForProbe + 프로브 전용 리플렉션(s_pauseStart 되감기)으로 재현해 잰다(⑩). // 🔴 NewGameUI.prefab 은 LoadPrefabContents 로 열고 **저장하지 않는다**(UnloadPrefabContents(false)). using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using UnityEditor; using TMPro; using WL.UI; using WL.Combat.Run; public static class WL813o_Probe { const string kPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; const string kTableTxt = "Assets/ResWork/Table/table_selectskill.txt"; static StringBuilder _o; static int _pass, _fail; static GameObject _root; static GameObject _fakeInfos; // table_selectskill · InGameInfo · ActorInfo 대역 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 name) { return Path.Combine(Path.Combine(Root, "AgentScripts"), name); } public static string RunAll() { _o = new StringBuilder(); _pass = _fail = 0; _o.AppendLine("# WL813o Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); _o.AppendLine("playMode=" + Application.isPlaying); bool savedDisabled = WLZonePickupSettings.RuntimeDisabled; try { Step0_Asset(); Step1_OriginalMissing(); Step2_Fakes(); Step3_Bind(); Step4_OpenOnZoneCleared(); Step5_TimeoutAutoPick(); Step6_ClickPick(); Step7_FinalZoneExcluded(); Step8_RevivePriority(); Step9_GuideWait(); Step10_TimerPauseWiring(); Step11_Gc(); Step12_C8(); } catch (Exception e) { _fail++; _o.AppendLine(" [FAIL] 예외 " + e.GetType().Name + " : " + e.Message); _o.AppendLine(e.StackTrace); } finally { WLZonePickupSettings.RuntimeDisabled = savedDisabled; Step13_Cleanup(); } _o.AppendLine(); _o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " (pass=" + _pass + " fail=" + _fail + ")"); string path = OutPath("WL813o_PROBE.txt"); File.WriteAllText(path, _o.ToString(), new UTF8Encoding(false)); return _o.ToString() + "\n(파일: " + path + ")"; } // ─────────────────────────────────────────────── ⓪ 에셋 · 계약 static void Step0_Asset() { H("⓪ 에셋 (WLZonePickupSettings · NEW) · 813p 계약"); WLZonePickupSettings.ClearCache(); var s = WLZonePickupSettings.Instance; Chk(s != null, "Resources.Load(\"" + WLZonePickupSettings.ResourcesPath + "\")"); if (s == null) return; Chk(WLZonePickupSettings.Enabled, "Enabled = " + WLZonePickupSettings.Enabled); N("값: cardCount=" + s.cardCount + " timeout=" + s.pickTimeoutSeconds + "s(unscaled=" + s.timeoutUnscaled + ") first=" + s.autoPickFirstCard + " includeFinalZone=" + s.includeFinalZone + " guideWait=" + s.waitForGuideArrival + "(max " + s.maxGuideDelaySeconds + "s)" + " deferRevive=" + s.deferWhileRevive + " pauseTimer=" + s.pauseRunTimer + " holdWorld=" + s.holdWorld + " stopActors=" + s.stopActorsWhileOpen + " openDelay=" + s.openDelaySeconds + "s"); Chk(!s.includeFinalZone, "존4(마지막 존) 기본 제외 — 보스 흐름 방해 금지(발주서 §1-1)"); Chk(!s.holdWorld, "세계 정지 기본 off = 타이머만 정지(발주서 §1-3 권장)"); Chk(Math.Abs(s.pickTimeoutSeconds - 10f) < 0.001f, "타임아웃 10 s(발주서 §1-2) — 실측 " + s.pickTimeoutSeconds); Chk(s.cardCount == 3, "3장(기획안 F-3) — 실측 " + s.cardCount); Chk(s.edge.enabled && s.edge.outlineWidth > 0f, "남은 초·카드 글자 아웃라인(813y ApplyEdge) outlineWidth=" + s.edge.outlineWidth); var rs = WLRunSettings.Instance; Chk(rs != null, "813p WLRunSettings 존재(존 수·런 길이의 주인 · UI 는 수치를 갖지 않는다)"); if (rs != null) N("813p 값(읽기만): runSeconds=" + rs.runSeconds + " 존 " + rs.zoneSpawnerIds.Length + "개"); } // ─────────────── ① 🔴 원본 SelectSkillUI 노드 부재 실측(발주서 §1-1 과 다른 곳) static void Step1_OriginalMissing() { H("① 🔴 원본 SelectSkillUI 노드 실측 — 발주서 §1-1 전제와 다르다"); var root = PrefabUtility.LoadPrefabContents(kPrefab); _root = root; Chk(root != null, "LoadPrefabContents(" + kPrefab + ")"); if (root == null) return; var ui = root.GetComponentsInChildren(true); var cards = root.GetComponentsInChildren(true); var gameUi = root.GetComponentsInChildren(true); var nguiLabels = root.GetComponentsInChildren(true); var nguiSprites = root.GetComponentsInChildren(true); var tmp = root.GetComponentsInChildren(true); N("NewGameUI.prefab 안: SelectSkillUI=" + ui.Length + " SelectSkillCard=" + cards.Length + " GameUI=" + gameUi.Length + " UILabel=" + nguiLabels.Length + " UISprite=" + nguiSprites.Length + " TextMeshProUGUI=" + tmp.Length); Chk(ui.Length == 0, "원본 SelectSkillUI 노드 0개 = 열 대상이 없다"); Chk(gameUi.Length == 0 && GameUI.Ins == null, "GameUI 컴포넌트 0개 · GameUI.Ins=null → MyActor.cs:162 의 자동전투 정지 배선은 작동하지 않는다"); Chk(nguiLabels.Length == 0 && nguiSprites.Length == 0, "실제 인게임 UI 에 NGUI 위젯 0개(원본 카드 UI 는 NGUI) · TMP 는 " + tmp.Length + "개 = uGUI 트리"); N("→ 그래서 813o 는 ① 원본 노드가 있으면 그대로 열고, ② 없으면 같은 원본 데이터·효과 경로를 쓰는 런타임 uGUI 패널로 간다."); } // ─────────────── ② 원본 데이터·효과 경로 대역 세우기 (table/InGameInfo/ActorInfo) static void Step2_Fakes() { H("② 원본 데이터·효과 경로 — table_selectskill / InGameInfo / ActorInfo"); _fakeInfos = new GameObject("WL813o_Probe_Infos"); _fakeInfos.hideFlags = HideFlags.HideAndDontSave; var tbl = _fakeInfos.AddComponent(); tbl.m_json = AssetDatabase.LoadAssetAtPath(kTableTxt); Chk(tbl.m_json != null, "테이블 원본 TextAsset " + kTableTxt); // 에디트 모드는 Awake 를 부르지 않는다 → 원본 Awake 를 그대로 태운다(파싱 로직 무수정). // SendMessage 는 HideAndDontSave 오브젝트에서 'ShouldRunBehaviour()' Assert 를 남기므로 직접 호출한다. var awake = typeof(table_selectskill).GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance); Chk(awake != null, "원본 table_selectskill.Awake 확보"); if (awake != null) awake.Invoke(tbl, null); Chk(table_selectskill.Ins != null, "table_selectskill.Ins 설정(원본 Awake 경로)"); var info = _fakeInfos.AddComponent(); InGameInfo.Ins = info; // Awake 는 무거워서 부르지 않는다(Ins 만 세운다) Chk(InGameInfo.Ins != null, "InGameInfo.Ins 설정"); var actors = _fakeInfos.AddComponent(); ActorInfo.Ins = actors; Chk(ActorInfo.Ins != null, "ActorInfo.Ins 설정(All_Stop 대상 0 = 무해)"); int all = table_selectskill.Ins != null ? table_selectskill.Ins.Get_DataList().Count : 0; N("테이블 행 수 = " + all); Chk(all > 3, "카드 3장을 뽑기에 충분한 행 수"); var draw = table_selectskill.Ins.Get_DataList(3); Chk(draw != null && draw.Count == 3, "원본 뽑기 Get_DataList(3) = 3행(셔플·보유 제외·PreSkill 규칙 그대로)"); if (draw != null) for (int i = 0; i < draw.Count; i++) { var r = draw[i] as SelectSkillTableData; if (r != null) N(" 뽑기 예시 " + i + ": id=" + r.ID + " \"" + r.Name + "\" pet=" + r.PetSkill); } } // ─────────────── ③ 노드 조립 · 구독 static void Step3_Bind() { H("③ 노드 조립 — WL_ZonePickup(런타임 생성 · HideFlags.DontSave) · 구독 3종"); if (_root == null) { Chk(false, "프리팹 루트 없음"); return; } int before = RunEvents.ZoneCleared.Count; N("Subscribe : " + ZonePickup.Subscribe(true)); Chk(RunEvents.ZoneCleared.Count == before + 1, "ZoneCleared 구독 +1 — 실측 " + RunEvents.ZoneCleared.Count); N("Subscribe(중복): " + ZonePickup.Subscribe(true)); Chk(RunEvents.ZoneCleared.Count == before + 1, "2회 호출해도 +1(중복 구독 0)"); var hud = WLVignetteUtil.FindUiPath(_root.transform, "IngameUIs/WL_HUD"); Chk(hud != null, "IngameUIs/WL_HUD 경로 실측(813y/813q 와 같은 부모)"); if (hud != null) { bool safe = hud.GetComponentInParent(true) != null || hud.GetComponent() != null; Chk(safe, "WL_HUD 가 SafeAreaFitter 아래 = Safe Area 안"); } N("Bind : " + ZonePickup.Bind(_root.transform)); Chk(ZonePickup.Bound, "WL_ZonePickup 노드 생성"); N("배치 : " + ZonePickup.ApplyLayout()); Chk(!ZonePickup.IsOpen, "조립 직후에는 닫혀 있다"); } // ─────────────── ④ 존 클리어 → 열림 · 3장 static void Step4_OpenOnZoneCleared() { H("④ 가짜 ZoneCleared(존 0) → 카드 3장 열림"); var s = WLZonePickupSettings.Instance; ZonePickup.ResetState(); RunDirector.EnsureSubscribed(); RunDirector.StartRun("probe-813o"); N("런 시작: phase=" + RunDirector.Phase + " zoneCount=" + RunDirector.ZoneCount); WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 31.5f, 12, 12, false); Chk(ZonePickup.ZoneClearedSeen == 1, "ZoneCleared 수신 1 — 실측 " + ZonePickup.ZoneClearedSeen); Chk(ZonePickup.Phase == ZonePickupPhase.Pending, "예약 상태 = " + ZonePickup.Phase); Chk(ZonePickup.PendingZone == 0, "예약 존 = " + ZonePickup.PendingZone); float t0 = ZonePickup.NowClock(); N("Tick(지연 전): " + ZonePickup.Tick(t0)); Chk(!ZonePickup.IsOpen, "openDelaySeconds 전에는 열리지 않는다"); N("Tick(지연 후): " + ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f)); Chk(ZonePickup.IsOpen, "카드 열림 — " + ZonePickup.Dump()); Chk(!ZonePickup.UsedOriginalUi, "원본 노드가 없어 런타임 패널 경로(폴백)로 열렸다"); Chk(ZonePickup.CardCount == 3, "카드 3장 — 실측 " + ZonePickup.CardCount); for (int i = 0; i < ZonePickup.CardCount; i++) N(" 카드 " + i + ": id=" + ZonePickup.CardId(i) + " \"" + ZonePickup.CardNameText(i) + "\""); Chk(ZonePickup.CardId(0) != 0 && ZonePickup.CardId(1) != 0 && ZonePickup.CardId(2) != 0, "3장 전부 실제 스킬 ID"); Chk(ZonePickup.CardId(0) != ZonePickup.CardId(1) && ZonePickup.CardId(1) != ZonePickup.CardId(2), "중복 없음"); Chk(!string.IsNullOrEmpty(ZonePickup.CardNameText(0)), "카드 이름이 테이블 값으로 채워졌다"); Chk(ZonePickup.TitleText == s.titleText, "제목 = \"" + ZonePickup.TitleText + "\""); Chk(ZonePickup.TimerText.Contains("10"), "남은 초 첫 표시 = \"" + ZonePickup.TimerText + "\""); Chk(ZonePickup.ActorsStopped, "원본 ActorInfo.All_Stop(true) 도달 = 자동전투 정지"); Chk(RunDirector.ExternalTimerPause, "런 타이머 정지 요청 on(RunDirector.PauseTimer) — 세계 정지 없음"); Chk(Math.Abs(Time.timeScale - 1f) < 0.0001f, "timeScale 무접촉 = " + Time.timeScale.ToString("F2") + "(holdWorld off)"); } // ─────────────── ⑤ 타임아웃 → 첫 카드 자동 선택 · 닫힘 static void Step5_TimeoutAutoPick() { H("⑤ 타임아웃 → 첫 카드 자동 선택 · 닫힘 · 정지 해제"); var s = WLZonePickupSettings.Instance; if (!ZonePickup.IsOpen) { Chk(false, "④에서 열려 있어야 한다"); return; } int firstId = ZonePickup.CardId(0); float t0 = ZonePickup.NowClock(); // 카운트다운 표시 — 남은 초가 바뀔 때마다 1회씩만 쓴다 int w0 = ZonePickup.TimerWrites; ZonePickup.Tick(t0 + s.openDelaySeconds + 3.2f); N("6.8s 남음 표시 = \"" + ZonePickup.TimerText + "\" (sec=" + ZonePickup.ShownSecond + ")"); Chk(ZonePickup.ShownSecond == 7, "올림 표기 6.8 → 7 — 실측 " + ZonePickup.ShownSecond); ZonePickup.Tick(t0 + s.openDelaySeconds + 8.5f); N("1.5s 남음 표시 = \"" + ZonePickup.TimerText + "\" (경고색 구간)"); Chk(ZonePickup.ShownSecond == 2, "올림 표기 1.5 → 2 — 실측 " + ZonePickup.ShownSecond); Chk(ZonePickup.TimerWrites > w0, "표시 쓰기 발생 " + (ZonePickup.TimerWrites - w0) + "회"); N("Tick(타임아웃): " + ZonePickup.Tick(t0 + s.openDelaySeconds + s.pickTimeoutSeconds + 0.01f)); Chk(ZonePickup.TimeoutCount == 1, "타임아웃 1회 — 실측 " + ZonePickup.TimeoutCount); Chk(ZonePickup.PickCount == 1, "선택 1회 — 실측 " + ZonePickup.PickCount); Chk(!ZonePickup.IsOpen, "닫힘 — " + ZonePickup.Dump()); var dic = InGameInfo.Ins != null ? InGameInfo.Ins.Get_SelectSkills() : null; Chk(dic != null && dic.ContainsKey(firstId), "원본 InGameInfo.Add_SelectSkill(" + firstId + ") 도달 = 첫 카드가 적용됐다(효과는 원본 로직 그대로)"); Chk(!ZonePickup.ActorsStopped, "원본 All_Stop(false) 도달 = 자동전투 복귀"); Chk(!RunDirector.ExternalTimerPause, "런 타이머 정지 해제"); Chk(ZonePickup.PicksThisRun == 1, "이번 런의 픽업 수 = " + ZonePickup.PicksThisRun); } // ─────────────── ⑥ 카드 클릭 경로 static void Step6_ClickPick() { H("⑥ 카드 버튼 클릭 경로(자동이 아닌 사용자 선택)"); var s = WLZonePickupSettings.Instance; ZonePickup.ResetState(); WLZonePickupProbeHooks.RaiseZoneCleared(1, 813002, 62f, 14, 14, false); float t0 = ZonePickup.NowClock(); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); Chk(ZonePickup.IsOpen, "존 1 클리어로 다시 열림"); int id1 = ZonePickup.CardId(1); N("클릭 카드 1: id=" + id1 + " \"" + ZonePickup.CardNameText(1) + "\""); N("ClickCard(1): " + ZonePickup.ClickCard(1)); Chk(!ZonePickup.IsOpen, "클릭 즉시 닫힘"); Chk(ZonePickup.TimeoutCount == 0, "타임아웃 아님(클릭 선택) — 실측 " + ZonePickup.TimeoutCount); var dic = InGameInfo.Ins != null ? InGameInfo.Ins.Get_SelectSkills() : null; Chk(dic != null && dic.ContainsKey(id1), "클릭한 카드의 효과가 원본 경로로 적용됐다"); } // ─────────────── ⑦ 마지막 존 제외 static void Step7_FinalZoneExcluded() { H("⑦ 마지막 존(존4) 제외 · SO 로 포함 전환"); var s = WLZonePickupSettings.Instance; ZonePickup.ResetState(); int last = RunDirector.ZoneCount - 1; N("존 수 = " + RunDirector.ZoneCount + " (마지막 인덱스 " + last + ")"); WLZonePickupProbeHooks.RaiseZoneCleared(last, 813004, 150f, 8, 8, true); Chk(ZonePickup.Phase == ZonePickupPhase.Idle, "마지막 존은 카드를 띄우지 않는다 — " + ZonePickup.LastSkipReason); Chk(ZonePickup.SkipCount == 1, "건너뜀 1회 — 실측 " + ZonePickup.SkipCount); bool saved = s.includeFinalZone; s.includeFinalZone = true; try { WLZonePickupProbeHooks.RaiseZoneCleared(last, 813004, 150f, 8, 8, true); Chk(ZonePickup.Phase == ZonePickupPhase.Pending, "includeFinalZone=1 이면 마지막 존도 예약된다(코드 변경 0)"); } finally { s.includeFinalZone = saved; } ZonePickup.ResetState(); } // ─────────────── ⑧ 부활 팝업 우선 static void Step8_RevivePriority() { H("⑧ 813i/813z 부활 팝업과 동시 — 부활 우선(카드는 미룬다)"); var s = WLZonePickupSettings.Instance; var dlg = _root != null ? _root.GetComponentsInChildren(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); ZonePickup.ResetState(); WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 20f, 12, 12, false); float t0 = ZonePickup.NowClock(); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); Chk(!ZonePickup.IsOpen, "부활 팝업이 떠 있는 동안 카드는 열리지 않는다"); Chk(ZonePickup.ReviveDeferCount > 0, "미룸 카운트 " + ZonePickup.ReviveDeferCount); Chk(Math.Abs(Time.timeScale - 1f) < 0.0001f || ReviveDialog.Busy, "픽업이 timeScale 을 건드리지 않았다"); d.HideNow(); Chk(!ReviveDialog.Busy, "부활 팝업 닫힘 — Busy=" + ReviveDialog.Busy); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.02f); Chk(ZonePickup.IsOpen, "부활이 닫힌 뒤 카드가 뜬다"); ZonePickup.Close("프로브 정리"); Chk(!ZonePickup.IsOpen && !RunDirector.ExternalTimerPause, "정리 후 닫힘 · 타이머 정지 해제"); } // ─────────────── ⑨ 813w 가이드 이동 중 대기 static void Step9_GuideWait() { H("⑨ 813w RunGuide 이동 중이면 도착 뒤에 연다"); var s = WLZonePickupSettings.Instance; N("RunGuide.Active=" + RunGuide.Active + " State=" + RunGuide.State); ZonePickup.ResetState(); if (!RunGuide.Active) { N("가이드 축이 꺼져 있어 Travel 상태를 만들 수 없다 — 대기 분기는 「미확인」(인게임 QA)."); Chk(true, "가이드 off 면 대기 없이 바로 연다(발주서 §1-1 의 폴백 경로)"); return; } RunGuide.BeginZone(1, "probe-813o"); N("BeginZone(1) 후 RunGuide.State=" + RunGuide.State + " (" + RunGuide.LastLog + ")"); WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 20f, 12, 12, false); float t0 = ZonePickup.NowClock(); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); if (RunGuide.State == RunGuideState.Travel) { Chk(!ZonePickup.IsOpen, "이동 중에는 열리지 않는다"); Chk(ZonePickup.GuideWaitCount > 0, "가이드 대기 " + ZonePickup.GuideWaitCount + "회"); RunGuide.StopGuide("probe-813o"); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.02f); Chk(ZonePickup.IsOpen, "도착(가이드 종료) 뒤 열린다"); } else { N("Travel 상태가 만들어지지 않았다(에디트 모드엔 PC·NavMesh 가 없다) — 대기 분기는 「미확인」."); Chk(true, "대기 상한 " + s.maxGuideDelaySeconds + "s 를 넘으면 이동 중이어도 연다(코드 경로 존재)"); } ZonePickup.Close("프로브 정리"); } // ─────────────── ⑩ 런 타이머 정지 배선 static void Step10_TimerPauseWiring() { H("⑩ 런 타이머 정지(813p RunDirector.PauseTimer · 세계 정지 0)"); ZonePickup.ResetState(); Chk(!RunDirector.ExternalTimerPause, "시작 상태 = 정지 아님"); RunDirector.PauseTimer(true); Chk(RunDirector.ExternalTimerPause, "PauseTimer(true) → ExternalTimerPause=True"); Chk(Math.Abs(Time.timeScale - 1f) < 0.0001f, "timeScale 무접촉 = " + Time.timeScale.ToString("F2")); RunDirector.ForceTick(); Chk(RunDirector.ExternalTimerPause, "틱을 돌려도 정지가 유지된다(PollPause 가 외부 정지도 같은 층으로 본다)"); RunDirector.PauseTimer(false); Chk(!RunDirector.ExternalTimerPause, "PauseTimer(false) → 해제"); // 픽업이 열렸다 닫히면 반드시 짝이 맞는다(정지가 새지 않는다) var s = WLZonePickupSettings.Instance; WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 10f, 12, 12, false); float t0 = ZonePickup.NowClock(); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); bool onWhileOpen = RunDirector.ExternalTimerPause; ZonePickup.Tick(t0 + s.openDelaySeconds + s.pickTimeoutSeconds + 0.01f); Chk(onWhileOpen && !RunDirector.ExternalTimerPause, "열림=정지 · 닫힘=해제 (짝 맞음 · PauseCalls=" + ZonePickup.PauseCalls + ")"); // 🔴 「멈춘 초」 실측 — batchmode 는 Time.unscaledTime 이 전혀 전진하지 않는다(호출 사이에도 11.35 고정). // 그래서 813p 의 ShiftClockForProbe(시작 시각 되감기)에 더해, 프로브가 **정지 시작 시각도 같이 되감아** // 「정지 중에 N 초가 흘렀다」를 그대로 재현한다(게임 코드 수정 0 · 프로브 전용 리플렉션). RunDirector.ResetAll(); RunDirector.StartRun("probe-813o-pause"); RunDirector.ShiftClockForProbe(10f); float e0 = RunDirector.Elapsed; N("정지 전 경과 = " + e0.ToString("F2") + "s"); RunDirector.PauseTimer(true); Chk(PauseStartRaw() >= 0f, "정지 시작 시각이 잡혔다(s_pauseStart=" + PauseStartRaw().ToString("F2") + ")"); RunDirector.ShiftClockForProbe(5f); // 5초가 흘렀다 bool shifted = ShiftPauseStart(5f); // 그 5초는 「정지 중」이었다 Chk(shifted, "프로브가 정지 창을 5초로 벌렸다"); float e1 = RunDirector.Elapsed; N("정지 중 5초 경과 후 = " + e1.ToString("F2") + "s (정지가 들으면 그대로 " + e0.ToString("F2") + ")"); Chk(Math.Abs(e1 - e0) < 0.01f, "🔴 정지 중 5초가 흘러도 런 경과 불변 = " + e1.ToString("F2") + "s"); RunDirector.PauseTimer(false); float total = PausedTotalRaw(); Chk(Math.Abs(total - 5f) < 0.01f, "정지 누적 = " + total.ToString("F2") + "s 로 집계됐다"); RunDirector.ShiftClockForProbe(5f); float e2 = RunDirector.Elapsed; Chk(Math.Abs(e2 - (e0 + 5f)) < 0.01f, "해제 뒤 5초는 정상적으로 흐른다 = " + e2.ToString("F2") + "s (대조군)"); RunDirector.EndRun(RunOutcome.Abandon); RunDirector.ResetAll(); RunDirector.StartRun("probe-813o"); // 뒤 절차용으로 런을 되살린다 } static FieldInfo Fld(string name) { return typeof(RunDirector).GetField(name, BindingFlags.NonPublic | BindingFlags.Static); } static float PauseStartRaw() { var f = Fld("s_pauseStart"); return f == null ? -2f : (float)f.GetValue(null); } static float PausedTotalRaw() { var f = Fld("s_pausedTotal"); return f == null ? -2f : (float)f.GetValue(null); } static bool ShiftPauseStart(float seconds) { var f = Fld("s_pauseStart"); if (f == null) return false; float v = (float)f.GetValue(null); if (v < 0f) return false; f.SetValue(null, v - seconds); return true; } // ─────────────── ⑪ GC static void Step11_Gc() { H("⑪ GC — 같은 초 100틱 = 표시 쓰기 0회"); var s = WLZonePickupSettings.Instance; ZonePickup.ResetState(); WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 10f, 12, 12, false); float t0 = ZonePickup.NowClock(); ZonePickup.Tick(t0 + s.openDelaySeconds + 0.01f); Chk(ZonePickup.IsOpen, "GC 측정용으로 열림"); float t = t0 + s.openDelaySeconds + 2.51f; // 남은 7.5s → 올림 8 · 0.1s 동안 같은 초 ZonePickup.Tick(t); // 첫 쓰기 int w0 = ZonePickup.TimerWrites; long m0 = GC.GetTotalMemory(false); int gc0 = GC.CollectionCount(0); for (int i = 0; i < 100; i++) ZonePickup.Tick(t + i * 0.001f); // 같은 초 long m1 = GC.GetTotalMemory(false); int gc1 = GC.CollectionCount(0); N("같은 초 100틱: 표시 쓰기 " + (ZonePickup.TimerWrites - w0) + "회 · ΔGetTotalMemory=" + (m1 - m0) + " B · gc0 " + gc0 + "→" + gc1); Chk(ZonePickup.TimerWrites == w0, "표시 쓰기 0회(값이 안 바뀌면 SetText 를 아예 안 부른다)"); Chk(m1 - m0 == 0L, "ΔGetTotalMemory = 0 B"); // 대조군 — 계기 감도 확인(813q 실측대로 이 런타임은 16 MB 규모라야 잡힌다) long c0 = GC.GetTotalMemory(false); var junk = new byte[16 * 1024 * 1024]; junk[0] = 1; long c1 = GC.GetTotalMemory(false); N("대조군 16 MB: Δ=" + (c1 - c0) + " B (계기 감도 확인)"); Chk(c1 - c0 > 0L, "대조군은 잡힌다 = 「0」이 진짜 0"); ZonePickup.Close("GC 측정 종료"); } // ─────────────── ⑫ C8 롤백 static void Step12_C8() { H("⑫ C8 — enabled_ = 0 이면 아무것도 하지 않는다"); ZonePickup.ResetState(); WLZonePickupSettings.RuntimeDisabled = true; Chk(!WLZonePickupSettings.Enabled, "Enabled=False"); N("Tick: " + ZonePickup.Tick(ZonePickup.NowClock())); WLZonePickupProbeHooks.RaiseZoneCleared(0, 813001, 10f, 12, 12, false); Chk(ZonePickup.Phase == ZonePickupPhase.Idle, "ZoneCleared 를 받아도 예약 0 — " + ZonePickup.LastSkipReason); Chk(!ZonePickup.IsOpen, "표시 0"); Chk(!RunDirector.ExternalTimerPause, "런 타이머 정지 0"); N("Bind(off): " + ZonePickup.Bind(_root != null ? _root.transform : null)); // 813p 축은 그대로 살아 있어야 한다 Chk(WLRunSettings.Enabled, "813p WLRunSettings.Enabled 불변 = " + WLRunSettings.Enabled); WLZonePickupSettings.RuntimeDisabled = false; Chk(WLZonePickupSettings.Enabled, "복구 후 Enabled=True"); } // ─────────────── ⑬ 정리 static void Step13_Cleanup() { H("⑬ 정리 — 구독 해제 · 노드 파괴 · 프리팹 저장 0"); try { N("Teardown : " + ZonePickup.Teardown()); Chk(RunEvents.ZoneCleared.Count == 0, "ZoneCleared 구독자 0 — 실측 " + RunEvents.ZoneCleared.Count); RunDirector.EndRun(RunOutcome.Abandon); RunDirector.ResetAll(); RunDirector.Unsubscribe(); RunDirector.PauseTimer(false); Chk(!RunDirector.ExternalTimerPause, "타이머 정지 해제 확인"); table_selectskill.Ins = null; InGameInfo.Ins = null; ActorInfo.Ins = null; if (_fakeInfos != null) UnityEngine.Object.DestroyImmediate(_fakeInfos); if (_root != null) PrefabUtility.UnloadPrefabContents(_root); // 저장 0 _root = null; _fakeInfos = null; Chk(true, "프리팹 UnloadPrefabContents(저장 0) · 대역 오브젝트 파괴"); } catch (Exception e) { _fail++; _o.AppendLine(" [FAIL] 정리 예외 " + e.Message); } } }