// WL-813o2 프로브 — 선택스킬 카드 풀 검증(D-41) + 결과창 모달 차단 연장(D-42) 실측 // (에디트 모드 · Play 0 · 로그인 0 · 씬 저장 0 · 프리팹 저장 0) // unity command run_script --file AgentScripts/WL813o2_Probe.cs --entry WL813o2_Probe.RunAll // 산출물: /AgentScripts/WL813o2_PROBE.txt (풀 검증 표 포함 · 커밋) // // 🔴 원본 `Assets/Script/**` 은 **읽기만** 한다(파싱·grep). 수정 0줄. // 🔴 Addressables 키 = 이 프로젝트는 그룹이 **폴더 엔트리**(`m_Address: Assets/Res_Addr/`)라 // 자식 에셋의 주소 = 그 에셋 경로다 → 키 존재 ⟺ 그 경로에 에셋이 있다(실측으로 확인한다). using System; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using UnityEngine; using UnityEditor; using WL.UI; using WL.Combat.Run; using WL.Combat.Diagnostics; public static class WL813o2_Probe { const string kTableTxt = "Assets/ResWork/Table/table_selectskill.txt"; const string kSelectSkillDir = "Assets/Script/Character/Projectile/SelectSkill"; const string kAddrGroupDir = "Assets/AddressableAssetsData/AssetGroups"; static StringBuilder _o; static int _pass, _fail; static GameObject _fakeInfos; static GameObject _fakeHook; 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("# WL813o2 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); _o.AppendLine("playMode=" + Application.isPlaying); // 🔴 프로브는 SO 「메모리 인스턴스」의 값을 잠깐 바꾼다(A/B). 원값을 반드시 되돌린다(디스크 diff 0). var s0 = WLZonePickupSettings.Instance; var g0 = WLErrorGuardSettings.Instance; bool bkValidate = s0 != null && s0.validatePool; bool bkRuntimeKey = s0 != null && s0.runtimeKeyCheck; int[] bkExclude = s0 != null ? s0.excludeSkillIds : null; bool bkExtend = g0 != null && g0.suppressUntilNextRunStart; try { Step0_Settings(); Step1_AddrGroups(); Step2_Fakes(); Step3_GrepOriginal(); Step4_PoolTable(); Step5_RuntimeKeyCheck(); Step6_Draw100(); Step7_Control_ValidateOff(); Step8_GC(); Step9_C8(); Step10_D42(); } catch (Exception ex) { Chk(false, "예외 — " + ex.GetType().Name + ": " + ex.Message); _o.AppendLine(ex.StackTrace); } finally { if (s0 != null) { s0.validatePool = bkValidate; s0.runtimeKeyCheck = bkRuntimeKey; s0.excludeSkillIds = bkExclude; } if (g0 != null) g0.suppressUntilNextRunStart = bkExtend; Cleanup(); } _o.AppendLine(); _o.AppendLine("RESULT " + (_fail == 0 ? "PASS" : "FAIL") + " — pass=" + _pass + " fail=" + _fail); string p = OutPath("WL813o2_PROBE.txt"); File.WriteAllText(p, _o.ToString(), new UTF8Encoding(true)); return (_fail == 0 ? "PASS" : "FAIL") + " pass=" + _pass + " fail=" + _fail + " → " + p; } // ─────────────── ⓪ SO 값 static WLZonePickupSettings _s; static WLErrorGuardSettings _g; static void Step0_Settings() { H("⓪ 값(SO) — 코드 상수 0 (C45)"); WLZonePickupSettings.ClearCache(); WLErrorGuardSettings.ClearCache(); _s = WLZonePickupSettings.Instance; _g = WLErrorGuardSettings.Instance; Chk(_s != null, "WLZonePickupSettings.asset 로드"); Chk(_g != null, "WLErrorGuardSettings.asset 로드"); if (_s == null || _g == null) return; N("validatePool=" + _s.validatePool + " runtimeKeyCheck=" + _s.runtimeKeyCheck + " maxRedraws=" + _s.maxRedraws + " logExclusion=" + _s.logExclusion + " cardCount=" + _s.cardCount); N("excludeSkillIds = [" + Join(_s.excludeSkillIds) + "]"); N("noPrefabEffects(" + Len(_s.noPrefabEffects) + ") = " + string.Join(", ", _s.noPrefabEffects ?? new string[0])); for (int i = 0; i < Len(_s.extraKeys); i++) N("extraKeys[" + i + "] " + _s.extraKeys[i].skillEffect + " → " + _s.extraKeys[i].assetKey + " (" + _s.extraKeys[i].source + ")"); N("경로형식: select=" + _s.selectSkillPathFormat + " · skill=" + _s.skillPathFormat + " · effect=" + _s.effectPathFormat); N("[813x2] suppressErrorModalInRun=" + _g.suppressErrorModalInRun + " suppressOnlyWhileRunning=" + _g.suppressOnlyWhileRunning + " suppressUntilNextRunStart=" + _g.suppressUntilNextRunStart); Chk(_s.validatePool, "validatePool=1(풀 검증 켜짐)"); Chk(Len(_s.excludeSkillIds) > 0, "excludeSkillIds 비어 있지 않음"); Chk(Len(_s.extraKeys) > 0, "extraKeys(코드 문자열 키) 채워짐"); Chk(_g.suppressUntilNextRunStart, "suppressUntilNextRunStart=1(D-42 연장 켜짐)"); Chk(_s.cardCount == 3, "카드 3장(기획안 F-3)"); } static int Len(Array a) { return a != null ? a.Length : 0; } static string Join(int[] a) { if (a == null || a.Length == 0) return ""; var sb = new StringBuilder(); for (int i = 0; i < a.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(a[i]); } return sb.ToString(); } // ─────────────── ① Addressables 그룹 = 폴더 엔트리인지 실측 static void Step1_AddrGroups() { H("① Addressables 그룹 실측 — 키 존재 ⟺ 그 경로의 에셋 존재"); string[] want = { "Projectile", "Skill", "Effect", "SelectSkill" }; for (int i = 0; i < want.Length; i++) { string p = kAddrGroupDir + "/" + want[i] + ".asset"; string txt = File.Exists(Path.Combine(Root, p)) ? File.ReadAllText(Path.Combine(Root, p)) : ""; var m = Regex.Match(txt, @"m_Address:\s*(\S+)"); string addr = m.Success ? m.Groups[1].Value : "(없음)"; N(want[i] + " 그룹 m_Address = " + addr + " (엔트리 " + Regex.Matches(txt, "m_Address:").Count + "개)"); Chk(addr == "Assets/Res_Addr/" + want[i], want[i] + " = 폴더 엔트리 1개(자식 주소 = 에셋 경로)"); } } // ─────────────── ② 원본 테이블·Info 대역 (813o 프로브와 같은 방식) static void Step2_Fakes() { H("② 원본 데이터 경로 — table_selectskill.Awake 를 그대로 태운다"); _fakeInfos = new GameObject("WL813o2_Probe_Infos"); _fakeInfos.hideFlags = HideFlags.HideAndDontSave; var tbl = _fakeInfos.AddComponent(); tbl.m_json = AssetDatabase.LoadAssetAtPath(kTableTxt); Chk(tbl.m_json != null, "테이블 TextAsset " + kTableTxt); var awake = typeof(table_selectskill).GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance); if (awake != null) awake.Invoke(tbl, null); Chk(table_selectskill.Ins != null, "table_selectskill.Ins 설정(원본 Awake)"); var info = _fakeInfos.AddComponent(); InGameInfo.Ins = info; var actors = _fakeInfos.AddComponent(); ActorInfo.Ins = actors; Chk(InGameInfo.Ins != null && ActorInfo.Ins != null, "InGameInfo.Ins · ActorInfo.Ins 설정"); int all = table_selectskill.Ins != null ? table_selectskill.Ins.Get_DataList().Count : 0; N("테이블 행 수 실측 = " + all + " (발주서 「기획안 42행」과 다르다 → 실측값을 쓴다)"); Chk(all > 0, "행 파싱 성공"); } // ─────────────── ③ 원본 코드 문자열 grep (읽기만) static readonly List _codeKeys = new List(); static void Step3_GrepOriginal() { H("③ 원본 효과 클래스 grep — `Shoot_Projectile(\"…\")` 코드 문자열 (읽기만 · 수정 0)"); string dir = Path.Combine(Root, kSelectSkillDir); Chk(Directory.Exists(dir), "원본 폴더 " + kSelectSkillDir); if (!Directory.Exists(dir)) return; var files = Directory.GetFiles(dir, "*.cs", SearchOption.AllDirectories); N("파일 " + files.Length + "개"); var rx = new Regex("Shoot_Projectile\\s*\\(\\s*\"([^\"]+)\""); for (int f = 0; f < files.Length; f++) { var lines = File.ReadAllLines(files[f]); for (int i = 0; i < lines.Length; i++) { string trimmed = lines[i].TrimStart(); bool commented = trimmed.StartsWith("//"); var m = rx.Match(lines[i]); if (!m.Success) continue; string cls = Path.GetFileNameWithoutExtension(files[f]); N((commented ? " (주석) " : " ") + cls + ".cs:" + (i + 1) + " 키=\"" + m.Groups[1].Value + "\""); if (!commented) _codeKeys.Add(cls + "|" + m.Groups[1].Value); } } Chk(_codeKeys.Count > 0, "살아 있는 Shoot_Projectile 문자열 " + _codeKeys.Count + "건"); // SO extraKeys 가 이 문자열들을 전부 덮는가 for (int i = 0; i < _codeKeys.Count; i++) { var parts = _codeKeys[i].Split('|'); string effect = parts[0]; // 클래스명 = eEffect 이름과 같다(원본 프리팹 이름 규칙) string key = "Assets/Res_Addr/Projectile/" + parts[1] + ".prefab"; bool covered = false; for (int k = 0; k < Len(_s.extraKeys); k++) if (_s.extraKeys[k].skillEffect == effect && _s.extraKeys[k].assetKey == key) covered = true; Chk(covered, "SO extraKeys 가 " + effect + " → " + key + " 를 덮는다"); } } // ─────────────── ④ 풀 검증 표 (전 행 · 에디트 모드) class Row { public int id; public string effect; public int stage; public bool eligible; public string primary = ""; public bool primaryOk = true; public string extra = ""; public bool extraOk = true; public string name = ""; } static readonly List _rows = new List(); static bool AssetExists(string path) { return !string.IsNullOrEmpty(path) && AssetDatabase.LoadAssetAtPath(path) != null; } static void Step4_PoolTable() { H("④ 🔴 풀 검증 표 — 전 행 × 참조 키 × 실존 (에디트 모드 · AssetDatabase)"); var all = table_selectskill.Ins.Get_DataList(); for (int i = 0; i < all.Count; i++) { var r = all[i] as SelectSkillTableData; if (r == null) continue; var row = new Row { id = r.ID, effect = r.SkillEffect.ToString(), stage = r.EnableStage, name = r.Name }; row.eligible = r.EnableStage <= 1; // 원본 Get_DataList 의 curstage = 1 (하드코딩 · 읽기만) bool noPrefab = false; for (int k = 0; k < Len(_s.noPrefabEffects); k++) if (_s.noPrefabEffects[k] == row.effect) noPrefab = true; if (r.SkillEffect != eEffect.None && !noPrefab) { string fmt = row.effect.Contains("Select") ? _s.selectSkillPathFormat : row.effect.Contains("Skill") ? _s.skillPathFormat : _s.effectPathFormat; row.primary = string.Format(fmt, row.effect); row.primaryOk = AssetExists(row.primary); } for (int k = 0; k < Len(_s.extraKeys); k++) { var x = _s.extraKeys[k]; if (x == null || x.skillEffect != row.effect) continue; row.extra = x.assetKey; row.extraOk = AssetExists(x.assetKey); } _rows.Add(row); } _o.AppendLine(); _o.AppendLine(" | ID | SkillEffect | Stg | 뽑기가능 | 1차 키 | 실존 | 추가 키 | 실존 | 판정 |"); _o.AppendLine(" |---|---|---|---|---|---|---|---|---|"); var missing = new List(); for (int i = 0; i < _rows.Count; i++) { var r = _rows[i]; bool bad = !r.primaryOk || !r.extraOk; if (bad) missing.Add(r); _o.AppendLine(" | " + r.id + " | " + r.effect + " | " + r.stage + " | " + (r.eligible ? "O" : "-") + " | " + (r.primary == "" ? "(없음)" : Short(r.primary)) + " | " + (r.primary == "" ? "-" : (r.primaryOk ? "O" : "🔴X")) + " | " + (r.extra == "" ? "-" : Short(r.extra)) + " | " + (r.extra == "" ? "-" : (r.extraOk ? "O" : "🔴X")) + " | " + (bad ? "🔴 제외" : "사용") + " |"); } _o.AppendLine(); N("전체 " + _rows.Count + "행 · 부재 키 행 " + missing.Count + "건"); for (int i = 0; i < missing.Count; i++) N(" 🔴 행 " + missing[i].id + " " + missing[i].effect + " (Stage " + missing[i].stage + " · " + (missing[i].eligible ? "지금 뽑힘" : "지금은 안 뽑힘") + ") — 부재 키 " + (!missing[i].primaryOk ? missing[i].primary : missing[i].extra)); // SO 목록과 실측이 일치하는가 for (int i = 0; i < missing.Count; i++) { bool listed = false; for (int k = 0; k < Len(_s.excludeSkillIds); k++) if (_s.excludeSkillIds[k] == missing[i].id) listed = true; Chk(listed, "부재 키 행 " + missing[i].id + "(" + missing[i].effect + ")이 SO excludeSkillIds 에 있다"); } for (int k = 0; k < Len(_s.excludeSkillIds); k++) { bool real = false; for (int i = 0; i < missing.Count; i++) if (missing[i].id == _s.excludeSkillIds[k]) real = true; Chk(real, "SO excludeSkillIds[" + _s.excludeSkillIds[k] + "] 는 실측 부재 행이다(과잉 제외 0)"); } Chk(missing.Count > 0, "D-41 재현 — 부재 키 행이 실제로 있다"); } static string Short(string p) { return p.Replace("Assets/Res_Addr/", ""); } // ─────────────── ⑤ 런타임 경로(게임 코드)로 같은 판정을 하는가 static void Step5_RuntimeKeyCheck() { H("⑤ 런타임 검사 — 게임 코드 `ZonePickupPool` 이 같은 결론을 내는가"); ZonePickupPool.ResetCache(); var all = table_selectskill.Ins.Get_DataList(); int agree = 0, disagree = 0; for (int i = 0; i < all.Count; i++) { var r = all[i] as SelectSkillTableData; if (r == null) continue; string miss; bool keysOk = ZonePickupPool.KeysExist(_s, r, out miss); Row t = null; for (int k = 0; k < _rows.Count; k++) if (_rows[k].id == r.ID) t = _rows[k]; bool expect = t == null || (t.primaryOk && t.extraOk); if (keysOk == expect) agree++; else { disagree++; N(" 불일치 행 " + r.ID + " runtime=" + keysOk + " expect=" + expect + " miss=" + miss); } } Chk(disagree == 0, "에디트 모드 표와 런타임 판정 완전 일치(" + agree + "행 · 불일치 " + disagree + ")"); // 제외 판정 + 로그 1줄 ZonePickupPool.ResetCache(); for (int k = 0; k < Len(_s.excludeSkillIds); k++) { var r = table_selectskill.Ins.Get_Data(_s.excludeSkillIds[k]); string why; bool ex = ZonePickupPool.Excluded(_s, r, out why); Chk(ex, "행 " + _s.excludeSkillIds[k] + " 제외 판정 — 사유 \"" + why + "\""); } N("카운터: checked=" + ZonePickupPool.CheckedCount + " staticExclude=" + ZonePickupPool.StaticExcludeCount + " missingKey=" + ZonePickupPool.MissingKeyCount + " skipped=" + ZonePickupPool.SkippedCheckCount); // SO 정적 목록을 비우고 **키 검사만으로도** 같은 행이 걸리는가 (근본 검사 증명) var saved = _s.excludeSkillIds; _s.excludeSkillIds = new int[0]; ZonePickupPool.ResetCache(); var onlyKey = new List(); for (int i = 0; i < all.Count; i++) { var r = all[i] as SelectSkillTableData; if (r == null) continue; string why; if (ZonePickupPool.Excluded(_s, r, out why)) onlyKey.Add(r.ID); } N("정적 목록 없이 키 검사만으로 제외된 행 = [" + Join(onlyKey.ToArray()) + "]"); Chk(onlyKey.Count == Len(saved), "키 검사만으로도 같은 수(" + onlyKey.Count + ")가 걸린다 = 목록은 캐시일 뿐"); _s.excludeSkillIds = saved; ZonePickupPool.ResetCache(); } // ─────────────── ⑥ 3장 뽑기 100회 static void Step6_Draw100() { H("⑥ 3장 뽑기 100회 — 제외 행이 한 번도 안 나오는가"); ZonePickup.ResetState(); ZonePickupPool.ResetCache(); var seen = new Dictionary(); int ok = 0, dupe = 0, short_ = 0; for (int n = 0; n < 100; n++) { if (!ZonePickup.DrawForProbe()) { short_++; continue; } ok++; if (ZonePickup.CardCount != 3) short_++; var ids = new HashSet(); for (int i = 0; i < ZonePickup.CardCount; i++) { int id = ZonePickup.CardId(i); if (!ids.Add(id)) dupe++; seen[id] = seen.ContainsKey(id) ? seen[id] + 1 : 1; } } Chk(ok == 100, "100회 전부 성공 — 실측 " + ok); Chk(short_ == 0, "카드 수 3장 미달 0 — 실측 " + short_); Chk(dupe == 0, "한 뽑기 안 ID 중복 0 — 실측 " + dupe); var keys = new List(seen.Keys); keys.Sort(); var sb = new StringBuilder(); for (int i = 0; i < keys.Count; i++) sb.Append(keys[i]).Append("×").Append(seen[keys[i]]).Append(" "); N("나온 ID 분포(300장): " + sb); for (int k = 0; k < Len(_s.excludeSkillIds); k++) { int id = _s.excludeSkillIds[k]; int c = seen.ContainsKey(id) ? seen[id] : 0; Chk(c == 0, "🔴 제외 행 " + id + " 등장 0회 — 실측 " + c + "회"); } N("ExcludedCount=" + ZonePickup.ExcludedCount + " RedrawCount=" + ZonePickup.RedrawCount + " last=\"" + ZonePickup.LastExcludeReason + "\""); Chk(ZonePickup.ExcludedCount > 0, "실제로 제외가 걸렸다(후필터가 살아 있다) — " + ZonePickup.ExcludedCount + "건"); } // ─────────────── ⑦ 대조군 — validatePool = 0 이면 「별」이 다시 나온다 static void Step7_Control_ValidateOff() { H("⑦ 대조군 — validatePool=0 (813o 동작) 이면 제외 행이 다시 뽑힌다"); bool saved = _s.validatePool; _s.validatePool = false; ZonePickup.ResetState(); ZonePickupPool.ResetCache(); var seen = new Dictionary(); for (int n = 0; n < 100; n++) { if (!ZonePickup.DrawForProbe()) continue; for (int i = 0; i < ZonePickup.CardCount; i++) { int id = ZonePickup.CardId(i); seen[id] = seen.ContainsKey(id) ? seen[id] + 1 : 1; } } int hit = 0; for (int k = 0; k < Len(_s.excludeSkillIds); k++) if (seen.ContainsKey(_s.excludeSkillIds[k])) hit += seen[_s.excludeSkillIds[k]]; N("validatePool=0 에서 제외 대상 등장 = " + hit + "장 / 300장"); Chk(hit > 0, "대조군에서는 실제로 나온다 = ⑥의 0회가 필터 덕분임을 증명"); Chk(ZonePickup.ExcludedCount == 0, "validatePool=0 이면 제외 0 · 재뽑기 0(813o 동작 그대로)"); Chk(ZonePickup.RedrawCount == 0, "재뽑기 0회"); _s.validatePool = saved; } // ─────────────── ⑧ GC static void Step8_GC() { H("⑧ GC — 검증 코드 자체의 할당(캐시가 선 뒤)"); _s.validatePool = true; ZonePickupPool.ResetCache(); var all = table_selectskill.Ins.Get_DataList(); var okRow = table_selectskill.Ins.Get_Data(101); // 사용 행 var badRow = table_selectskill.Ins.Get_Data(_s.excludeSkillIds[Len(_s.excludeSkillIds) - 1]); // 제외 행 string w0; ZonePickupPool.Excluded(_s, okRow, out w0); // 캐시 워밍 ZonePickupPool.Excluded(_s, badRow, out w0); ZonePickupPool.KeyExists(_s.extraKeys[0].assetKey); // ⓐ 판정 자체 — 캐시 히트 경로는 문자열도 만들지 않는다 long a = GC.GetTotalMemory(true); for (int i = 0; i < 2000; i++) { string w; ZonePickupPool.Excluded(_s, okRow, out w); ZonePickupPool.Excluded(_s, badRow, out w); ZonePickupPool.KeyExists(_s.extraKeys[0].assetKey); } long judge = GC.GetTotalMemory(false) - a; N("판정 6,000회(캐시 히트) Δ = " + judge + " B"); Chk(judge == 0, "🔴 캐시가 선 뒤 판정 할당 = 0 B — 실측 " + judge); // ⓑ 전 행 1회 판정(캐시 채우기) 뒤에는 판정 횟수가 늘지 않는다 ZonePickupPool.ResetCache(); for (int i = 0; i < all.Count; i++) { string w; ZonePickupPool.Excluded(_s, all[i] as SelectSkillTableData, out w); } int after1 = ZonePickupPool.CheckedCount; for (int r = 0; r < 50; r++) for (int i = 0; i < all.Count; i++) { string w; ZonePickupPool.Excluded(_s, all[i] as SelectSkillTableData, out w); } Chk(ZonePickupPool.CheckedCount == after1, "판정은 ID 당 1회 — 58행×51회 뒤에도 누적 " + ZonePickupPool.CheckedCount + "회(=행 수)"); // ⓒ 뽑기 전체 비교(참고) — 차이는 **재뽑기로 늘어난 원본 호출** 몫이다 ZonePickup.ResetState(); ZonePickup.DrawForProbe(); long c0 = GC.GetTotalMemory(true); for (int i = 0; i < 200; i++) ZonePickup.DrawForProbe(); long onB = GC.GetTotalMemory(false) - c0; int onCalls = 200 + ZonePickup.RedrawCount; bool saved = _s.validatePool; _s.validatePool = false; ZonePickup.ResetState(); ZonePickup.DrawForProbe(); long c1 = GC.GetTotalMemory(true); for (int i = 0; i < 200; i++) ZonePickup.DrawForProbe(); long offB = GC.GetTotalMemory(false) - c1; _s.validatePool = saved; N("검증 ON 뽑기 200회 Δ = " + onB + " B · 원본 Get_DataList 호출 " + onCalls + "회(재뽑기 " + (onCalls - 200) + ")"); N("검증 OFF 뽑기 200회 Δ = " + offB + " B · 원본 Get_DataList 호출 200회"); double perOn = onCalls > 0 ? (double)onB / onCalls : 0, perOff = offB > 0 ? (double)offB / 200 : 0; N("원본 호출 1회당 = ON " + perOn.ToString("F0") + " B · OFF " + perOff.ToString("F0") + " B → 차이는 검증 코드가 아니라 **원본 LINQ 셔플을 몇 번 더 부르느냐**다"); Chk(perOff <= 0 || perOn <= perOff * 2.0, "호출 1회당 할당이 대조군의 2배를 넘지 않는다(검증 코드 몫 ≈ 0)"); N("🔴 실사용 빈도 = 존 클리어 1회당 뽑기 1회(런당 최대 " + _s.cardCount + "장×존 수) — 매 프레임 경로 아님"); } // ─────────────── ⑨ C8 static void Step9_C8() { H("⑨ C8 — 스위치 하나로 813o 이전 100%"); _s.validatePool = false; Chk(!ZonePickupPool.Active(_s), "validatePool=0 → 검증 축 off"); ZonePickup.ResetState(); ZonePickupPool.ResetCache(); ZonePickup.DrawForProbe(); Chk(ZonePickup.ExcludedCount == 0 && ZonePickup.RedrawCount == 0, "제외 0 · 재뽑기 0"); _s.validatePool = true; WLZonePickupSettings.RuntimeDisabled = true; Chk(!WLZonePickupSettings.Enabled, "RuntimeDisabled → 픽업 축 전체 off(813o C8 그대로)"); WLZonePickupSettings.RuntimeDisabled = false; Chk(WLZonePickupSettings.Enabled, "복구"); bool savedG = _g.suppressUntilNextRunStart; _g.suppressUntilNextRunStart = false; Chk(_g.suppressUntilNextRunStart == false, "suppressUntilNextRunStart=0 → 813x2 원래 동작으로 되돌린다"); _g.suppressUntilNextRunStart = savedG; } // ─────────────── ⑩ D-42 — 런 종료 뒤에도 모달 차단, 다음 런 시작에 복원 static void Step10_D42() { H("⑩ 🔴 D-42 — RunEnded 뒤 결과창~다음 런 시작까지 모달 0"); _fakeHook = new GameObject("WL813o2_Probe_Hook"); _fakeHook.hideFlags = HideFlags.HideAndDontSave; var hook = _fakeHook.AddComponent(); RunErrorModalGuard.ResetHookCache(); RunErrorModalGuard.SetHookForTest(hook); RunErrorModalGuard.ResetCounters(); RunErrorModalGuard.ForceState = 0; RunErrorModalGuard.Subscribe(); RunDirector.ResetAll(); Chk(WLRunSettings.Enabled, "813p WLRunSettings.Enabled(StartRun 전제) — 실측 " + WLRunSettings.Enabled); N("초기 Phase=" + RunDirector.Phase + " IsRunning=" + RunDirector.IsRunning); RunErrorModalGuard.Apply(RunErrorModalGuard.ShouldSuppress(), _g); Chk(!RunErrorModalGuard.ShouldSuppress(), "Idle(런 밖 · 시작 전) → 차단 안 함 = 원본 모달 살아 있음"); Chk(hook.enabled, "훅 enabled=True"); RunDirector.StartRun("probe"); N("StartRun → Phase=" + RunDirector.Phase + " IsRunning=" + RunDirector.IsRunning); RunErrorModalGuard.Apply(RunErrorModalGuard.ShouldSuppress(), _g); Chk(RunErrorModalGuard.ShouldSuppress(), "런 중 → 차단(813x2 그대로)"); Chk(!hook.enabled, "런 중 훅 enabled=False(원본 OnDisable 이 구독을 끊는다)"); RunDirector.EndRun(RunOutcome.Win); N("EndRun → Phase=" + RunDirector.Phase + " IsRunning=" + RunDirector.IsRunning); RunErrorModalGuard.Apply(RunErrorModalGuard.ShouldSuppress(), _g); Chk(RunErrorModalGuard.ShouldSuppress(), "🔴 런 종료 뒤(결과창)에도 차단 유지 = D-42 해소"); Chk(!hook.enabled, "🔴 결과창에서 훅 enabled=False 유지(모달 0)"); // 결과창에서 가짜 예외 1건 → 모달 0(= 훅이 내려가 있어 원본 HandleLog 미호출) · 파일 로그는 계속 int before = RunErrorModalGuard.SuppressedCount; Debug.LogError("[WL813o2] 결과창 가짜 오류(모달 0 확인용)"); N("가짜 오류 1건 → captured=" + RunErrorModalGuard.CapturedCount + " suppressed=" + RunErrorModalGuard.SuppressedCount + " written=" + RunErrorModalGuard.WrittenCount); Chk(RunErrorModalGuard.SuppressedCount == before + 1, "🔴 결과창 예외가 「모달차단」으로 기록된다(모달 0)"); Chk(RunErrorModalGuard.HookDisabledByUs, "훅은 우리가 내려둔 상태"); N("로그 파일 = " + RunErrorModalGuard.LastFilePath); // 813x2 원래 동작(연장 off)이면 결과창에서 복원된다 = D-42 재현 bool saved = _g.suppressUntilNextRunStart; _g.suppressUntilNextRunStart = false; RunErrorModalGuard.Apply(RunErrorModalGuard.ShouldSuppress(), _g); Chk(!RunErrorModalGuard.ShouldSuppress(), "연장 off → 결과창에서 차단 해제(= Q9 가 본 D-42 상태 재현)"); Chk(hook.enabled, "연장 off 면 결과창에서 훅 enabled=True(원본 모달 부활)"); _g.suppressUntilNextRunStart = saved; RunErrorModalGuard.Apply(RunErrorModalGuard.ShouldSuppress(), _g); Chk(!hook.enabled, "연장 on 으로 되돌리면 다시 내려간다"); // 다음 런 시작 → 복원(그리고 런 중이니 다시 내려간다) RunDirector.StartRun("probe-next"); N("다음 StartRun → Phase=" + RunDirector.Phase); Chk(RunDirector.Phase == RunPhase.Zones, "Phase=Zones(Ended 를 벗어났다 = 연장 래치 해제)"); _g.suppressUntilNextRunStart = false; // 연장분만 떼고 보면 Chk(RunErrorModalGuard.ShouldSuppress(), "연장분 없이도 런 중이라 차단 = 복원 뒤 곧바로 다시 off"); _g.suppressUntilNextRunStart = saved; // 로비 복귀(Idle) 에서는 복원된다 RunDirector.ResetAll(); RunErrorModalGuard.Apply(RunErrorModalGuard.ShouldSuppress(), _g); Chk(!RunErrorModalGuard.ShouldSuppress(), "Phase=Idle(로비/타이틀) → 차단 해제 = 원본 모달 복귀"); Chk(hook.enabled, "훅 enabled=True 복원 · HookDisabledByUs=" + RunErrorModalGuard.HookDisabledByUs); } // ─────────────── 정리 static void Cleanup() { H("⑪ 정리"); try { RunErrorModalGuard.Restore(); RunErrorModalGuard.Unsubscribe(); RunErrorModalGuard.ResetHookCache(); RunDirector.ResetAll(); RunDirector.Unsubscribe(); ZonePickup.Teardown(); ZonePickupPool.ResetCache(); WLZonePickupSettings.RuntimeDisabled = false; if (_fakeHook != null) UnityEngine.Object.DestroyImmediate(_fakeHook); if (_fakeInfos != null) UnityEngine.Object.DestroyImmediate(_fakeInfos); table_selectskill.Ins = null; InGameInfo.Ins = null; ActorInfo.Ins = null; _fakeHook = null; _fakeInfos = null; N("임시 오브젝트 제거 · 구독 해제 · 디스크 SO 무변경(값은 메모리 인스턴스만 손댔다)"); Chk(!ZonePickup.Bound, "픽업 노드 0"); Chk(true, "정리 완료"); } catch (Exception ex) { N("정리 중 예외(무시): " + ex.Message); } } }