Project_WL/AgentScripts/WL813o2_Probe.cs

600 lines
30 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-813o2 프로브 — 선택스킬 카드 풀 검증(D-41) + 결과창 모달 차단 연장(D-42) 실측
// (에디트 모드 · Play 0 · 로그인 0 · 씬 저장 0 · 프리팹 저장 0)
// unity command run_script --file AgentScripts/WL813o2_Probe.cs --entry WL813o2_Probe.RunAll
// 산출물: <worktree>/AgentScripts/WL813o2_PROBE.txt (풀 검증 표 포함 · 커밋)
//
// 🔴 원본 `Assets/Script/**` 은 **읽기만** 한다(파싱·grep). 수정 0줄.
// 🔴 Addressables 키 = 이 프로젝트는 그룹이 **폴더 엔트리**(`m_Address: Assets/Res_Addr/<Folder>`)라
// 자식 에셋의 주소 = 그 에셋 경로다 → 키 존재 ⟺ 그 경로에 에셋이 있다(실측으로 확인한다).
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<table_selectskill>();
tbl.m_json = AssetDatabase.LoadAssetAtPath<TextAsset>(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>();
InGameInfo.Ins = info;
var actors = _fakeInfos.AddComponent<ActorInfo>();
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<string> _codeKeys = new List<string>();
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<Row> _rows = new List<Row>();
static bool AssetExists(string path)
{
return !string.IsNullOrEmpty(path) && AssetDatabase.LoadAssetAtPath<GameObject>(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<Row>();
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<int>();
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, int>();
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<int>();
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<int>(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<int, int>();
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<ErrorLogHookManager>();
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); }
}
}