diff --git a/AgentScripts/WL813o2_PROBE.txt b/AgentScripts/WL813o2_PROBE.txt new file mode 100644 index 000000000..993a50b0c --- /dev/null +++ b/AgentScripts/WL813o2_PROBE.txt @@ -0,0 +1,195 @@ +# WL813o2 Probe 2026-09-09 19:01:19 (edit mode · Play 0 · 로그인 0) +playMode=False + +── ⓪ 값(SO) — 코드 상수 0 (C45) + [PASS] WLZonePickupSettings.asset 로드 + [PASS] WLErrorGuardSettings.asset 로드 + validatePool=True runtimeKeyCheck=True maxRedraws=6 logExclusion=True cardCount=3 + excludeSkillIds = [3, 11, 14] + noPrefabEffects(10) = Select_DoubleShot, Select_TripleShot, Select_Bounce, Select_Bounce2, Select_LeafFairy2, Select_FrostFairy2, Select_WindFairy2, Select_HikikomoriFairy2, Select_Heal, Select_Shield + extraKeys[0] Select_Star → Assets/Res_Addr/Projectile/ProjectileStar.prefab (Select_Star.cs:27 Shoot_Projectile("ProjectileStar")) + extraKeys[1] Select_SwordSlash → Assets/Res_Addr/Projectile/SwordSlash.prefab (Select_SwordSlash.cs:29 Shoot_Projectile("SwordSlash")) + extraKeys[2] Select_Shield → Assets/Res_Addr/Skill/Skill_Shield.prefab (InGameInfo2.cs:46 Set_Skill_ToPet(eEffect.Skill_Shield)) + 경로형식: select=Assets/Res_Addr/SelectSkill/{0}.prefab · skill=Assets/Res_Addr/Skill/{0}.prefab · effect=Assets/Res_Addr/Effect/{0}.prefab + [813x2] suppressErrorModalInRun=True suppressOnlyWhileRunning=True suppressUntilNextRunStart=True + [PASS] validatePool=1(풀 검증 켜짐) + [PASS] excludeSkillIds 비어 있지 않음 + [PASS] extraKeys(코드 문자열 키) 채워짐 + [PASS] suppressUntilNextRunStart=1(D-42 연장 켜짐) + [PASS] 카드 3장(기획안 F-3) + +── ① Addressables 그룹 실측 — 키 존재 ⟺ 그 경로의 에셋 존재 + Projectile 그룹 m_Address = Assets/Res_Addr/Projectile (엔트리 1개) + [PASS] Projectile = 폴더 엔트리 1개(자식 주소 = 에셋 경로) + Skill 그룹 m_Address = Assets/Res_Addr/Skill (엔트리 1개) + [PASS] Skill = 폴더 엔트리 1개(자식 주소 = 에셋 경로) + Effect 그룹 m_Address = Assets/Res_Addr/Effect (엔트리 1개) + [PASS] Effect = 폴더 엔트리 1개(자식 주소 = 에셋 경로) + SelectSkill 그룹 m_Address = Assets/Res_Addr/SelectSkill (엔트리 1개) + [PASS] SelectSkill = 폴더 엔트리 1개(자식 주소 = 에셋 경로) + +── ② 원본 데이터 경로 — table_selectskill.Awake 를 그대로 태운다 + [PASS] 테이블 TextAsset Assets/ResWork/Table/table_selectskill.txt + [PASS] table_selectskill.Ins 설정(원본 Awake) + [PASS] InGameInfo.Ins · ActorInfo.Ins 설정 + 테이블 행 수 실측 = 58 (발주서 「기획안 42행」과 다르다 → 실측값을 쓴다) + [PASS] 행 파싱 성공 + +── ③ 원본 효과 클래스 grep — `Shoot_Projectile("…")` 코드 문자열 (읽기만 · 수정 0) + [PASS] 원본 폴더 Assets/Script/Character/Projectile/SelectSkill + 파일 16개 + (주석) Select_LeafFairy.cs:44 키="LeafBullet" + Select_Star.cs:27 키="ProjectileStar" + Select_SwordSlash.cs:29 키="SwordSlash" + [PASS] 살아 있는 Shoot_Projectile 문자열 2건 + [PASS] SO extraKeys 가 Select_Star → Assets/Res_Addr/Projectile/ProjectileStar.prefab 를 덮는다 + [PASS] SO extraKeys 가 Select_SwordSlash → Assets/Res_Addr/Projectile/SwordSlash.prefab 를 덮는다 + +── ④ 🔴 풀 검증 표 — 전 행 × 참조 키 × 실존 (에디트 모드 · AssetDatabase) + + | ID | SkillEffect | Stg | 뽑기가능 | 1차 키 | 실존 | 추가 키 | 실존 | 판정 | + |---|---|---|---|---|---|---|---|---| + | 1 | Select_DoubleShot | 2 | - | (없음) | - | - | - | 사용 | + | 2 | Select_Bounce | 4 | - | (없음) | - | - | - | 사용 | + | 3 | Select_Shield | 1 | O | (없음) | - | Skill/Skill_Shield.prefab | 🔴X | 🔴 제외 | + | 4 | Select_FireCircle | 1 | O | SelectSkill/Select_FireCircle.prefab | O | - | - | 사용 | + | 5 | Select_BarrelCircle | 1 | O | SelectSkill/Select_BarrelCircle.prefab | O | - | - | 사용 | + | 6 | Select_TripleShot | 5 | - | (없음) | - | - | - | 사용 | + | 7 | Select_Heal | 5 | - | (없음) | - | - | - | 사용 | + | 8 | Select_Bottle | 3 | - | SelectSkill/Select_Bottle.prefab | O | - | - | 사용 | + | 9 | Select_BodyonFire | 1 | O | SelectSkill/Select_BodyonFire.prefab | O | - | - | 사용 | + | 10 | Select_LeafFairy | 1 | O | SelectSkill/Select_LeafFairy.prefab | O | - | - | 사용 | + | 11 | Select_SwordSlash | 7 | - | SelectSkill/Select_SwordSlash.prefab | O | Projectile/SwordSlash.prefab | 🔴X | 🔴 제외 | + | 12 | Select_FrostFairy | 1 | O | SelectSkill/Select_FrostFairy.prefab | O | - | - | 사용 | + | 13 | Select_WindFairy | 1 | O | SelectSkill/Select_WindFairy.prefab | O | - | - | 사용 | + | 14 | Select_Star | 1 | O | SelectSkill/Select_Star.prefab | O | Projectile/ProjectileStar.prefab | 🔴X | 🔴 제외 | + | 15 | Select_MoveSpeedUp | 1 | O | SelectSkill/Select_MoveSpeedUp.prefab | O | - | - | 사용 | + | 16 | Select_MoveSpeedUp | 4 | - | SelectSkill/Select_MoveSpeedUp.prefab | O | - | - | 사용 | + | 17 | Select_HikikomoriFairy | 1 | O | SelectSkill/Select_HikikomoriFairy.prefab | O | - | - | 사용 | + | 18 | Select_LeafFairy2 | 4 | - | (없음) | - | - | - | 사용 | + | 19 | Select_FrostFairy2 | 3 | - | (없음) | - | - | - | 사용 | + | 20 | Select_WindFairy2 | 3 | - | (없음) | - | - | - | 사용 | + | 21 | Select_HikikomoriFairy2 | 2 | - | (없음) | - | - | - | 사용 | + | 22 | Select_Bounce2 | 5 | - | (없음) | - | - | - | 사용 | + | 101 | None | 1 | O | (없음) | - | - | - | 사용 | + | 102 | None | 1 | O | (없음) | - | - | - | 사용 | + | 103 | None | 1 | O | (없음) | - | - | - | 사용 | + | 104 | None | 1 | O | (없음) | - | - | - | 사용 | + | 105 | None | 1 | O | (없음) | - | - | - | 사용 | + | 106 | None | 1 | O | (없음) | - | - | - | 사용 | + | 107 | None | 1 | O | (없음) | - | - | - | 사용 | + | 108 | None | 1 | O | (없음) | - | - | - | 사용 | + | 109 | None | 1 | O | (없음) | - | - | - | 사용 | + | 110 | None | 1 | O | (없음) | - | - | - | 사용 | + | 111 | None | 1 | O | (없음) | - | - | - | 사용 | + | 112 | None | 1 | O | (없음) | - | - | - | 사용 | + | 113 | None | 3 | - | (없음) | - | - | - | 사용 | + | 114 | None | 3 | - | (없음) | - | - | - | 사용 | + | 115 | None | 3 | - | (없음) | - | - | - | 사용 | + | 116 | None | 3 | - | (없음) | - | - | - | 사용 | + | 117 | None | 3 | - | (없음) | - | - | - | 사용 | + | 118 | None | 3 | - | (없음) | - | - | - | 사용 | + | 119 | None | 3 | - | (없음) | - | - | - | 사용 | + | 120 | None | 3 | - | (없음) | - | - | - | 사용 | + | 121 | None | 3 | - | (없음) | - | - | - | 사용 | + | 122 | None | 3 | - | (없음) | - | - | - | 사용 | + | 123 | None | 3 | - | (없음) | - | - | - | 사용 | + | 124 | None | 3 | - | (없음) | - | - | - | 사용 | + | 125 | None | 6 | - | (없음) | - | - | - | 사용 | + | 126 | None | 6 | - | (없음) | - | - | - | 사용 | + | 127 | None | 6 | - | (없음) | - | - | - | 사용 | + | 128 | None | 6 | - | (없음) | - | - | - | 사용 | + | 129 | None | 6 | - | (없음) | - | - | - | 사용 | + | 130 | None | 6 | - | (없음) | - | - | - | 사용 | + | 131 | None | 6 | - | (없음) | - | - | - | 사용 | + | 132 | None | 6 | - | (없음) | - | - | - | 사용 | + | 133 | None | 6 | - | (없음) | - | - | - | 사용 | + | 134 | None | 6 | - | (없음) | - | - | - | 사용 | + | 135 | None | 6 | - | (없음) | - | - | - | 사용 | + | 136 | None | 6 | - | (없음) | - | - | - | 사용 | + + 전체 58행 · 부재 키 행 3건 + 🔴 행 3 Select_Shield (Stage 1 · 지금 뽑힘) — 부재 키 Assets/Res_Addr/Skill/Skill_Shield.prefab + 🔴 행 11 Select_SwordSlash (Stage 7 · 지금은 안 뽑힘) — 부재 키 Assets/Res_Addr/Projectile/SwordSlash.prefab + 🔴 행 14 Select_Star (Stage 1 · 지금 뽑힘) — 부재 키 Assets/Res_Addr/Projectile/ProjectileStar.prefab + [PASS] 부재 키 행 3(Select_Shield)이 SO excludeSkillIds 에 있다 + [PASS] 부재 키 행 11(Select_SwordSlash)이 SO excludeSkillIds 에 있다 + [PASS] 부재 키 행 14(Select_Star)이 SO excludeSkillIds 에 있다 + [PASS] SO excludeSkillIds[3] 는 실측 부재 행이다(과잉 제외 0) + [PASS] SO excludeSkillIds[11] 는 실측 부재 행이다(과잉 제외 0) + [PASS] SO excludeSkillIds[14] 는 실측 부재 행이다(과잉 제외 0) + [PASS] D-41 재현 — 부재 키 행이 실제로 있다 + +── ⑤ 런타임 검사 — 게임 코드 `ZonePickupPool` 이 같은 결론을 내는가 + [PASS] 에디트 모드 표와 런타임 판정 완전 일치(58행 · 불일치 0) + [PASS] 행 3 제외 판정 — 사유 "SO 제외 목록" + [PASS] 행 11 제외 판정 — 사유 "SO 제외 목록" + [PASS] 행 14 제외 판정 — 사유 "SO 제외 목록" + 카운터: checked=3 staticExclude=3 missingKey=0 skipped=0 + 정적 목록 없이 키 검사만으로 제외된 행 = [3, 11, 14] + [PASS] 키 검사만으로도 같은 수(3)가 걸린다 = 목록은 캐시일 뿐 + +── ⑥ 3장 뽑기 100회 — 제외 행이 한 번도 안 나오는가 + [PASS] 100회 전부 성공 — 실측 100 + [PASS] 카드 수 3장 미달 0 — 실측 0 + [PASS] 한 뽑기 안 ID 중복 0 — 실측 0 + 나온 ID 분포(300장): 4×12 5×12 9×18 10×15 12×22 13×12 15×19 17×13 101×11 102×15 103×7 104×16 105×18 106×15 107×15 108×21 109×6 110×18 111×16 112×19 + [PASS] 🔴 제외 행 3 등장 0회 — 실측 0회 + [PASS] 🔴 제외 행 11 등장 0회 — 실측 0회 + [PASS] 🔴 제외 행 14 등장 0회 — 실측 0회 + ExcludedCount=32 RedrawCount=27 last="행 14 — SO 제외 목록" + [PASS] 실제로 제외가 걸렸다(후필터가 살아 있다) — 32건 + +── ⑦ 대조군 — validatePool=0 (813o 동작) 이면 제외 행이 다시 뽑힌다 + validatePool=0 에서 제외 대상 등장 = 27장 / 300장 + [PASS] 대조군에서는 실제로 나온다 = ⑥의 0회가 필터 덕분임을 증명 + [PASS] validatePool=0 이면 제외 0 · 재뽑기 0(813o 동작 그대로) + [PASS] 재뽑기 0회 + +── ⑧ GC — 검증 코드 자체의 할당(캐시가 선 뒤) + 판정 6,000회(캐시 히트) Δ = 0 B + [PASS] 🔴 캐시가 선 뒤 판정 할당 = 0 B — 실측 0 + [PASS] 판정은 ID 당 1회 — 58행×51회 뒤에도 누적 58회(=행 수) + 검증 ON 뽑기 200회 Δ = 0 B · 원본 Get_DataList 호출 246회(재뽑기 46) + 검증 OFF 뽑기 200회 Δ = 0 B · 원본 Get_DataList 호출 200회 + 원본 호출 1회당 = ON 0 B · OFF 0 B → 차이는 검증 코드가 아니라 **원본 LINQ 셔플을 몇 번 더 부르느냐**다 + [PASS] 호출 1회당 할당이 대조군의 2배를 넘지 않는다(검증 코드 몫 ≈ 0) + 🔴 실사용 빈도 = 존 클리어 1회당 뽑기 1회(런당 최대 3장×존 수) — 매 프레임 경로 아님 + +── ⑨ C8 — 스위치 하나로 813o 이전 100% + [PASS] validatePool=0 → 검증 축 off + [PASS] 제외 0 · 재뽑기 0 + [PASS] RuntimeDisabled → 픽업 축 전체 off(813o C8 그대로) + [PASS] 복구 + [PASS] suppressUntilNextRunStart=0 → 813x2 원래 동작으로 되돌린다 + +── ⑩ 🔴 D-42 — RunEnded 뒤 결과창~다음 런 시작까지 모달 0 + [PASS] 813p WLRunSettings.Enabled(StartRun 전제) — 실측 True + 초기 Phase=Idle IsRunning=False + [PASS] Idle(런 밖 · 시작 전) → 차단 안 함 = 원본 모달 살아 있음 + [PASS] 훅 enabled=True + StartRun → Phase=Zones IsRunning=True + [PASS] 런 중 → 차단(813x2 그대로) + [PASS] 런 중 훅 enabled=False(원본 OnDisable 이 구독을 끊는다) + EndRun → Phase=Ended IsRunning=False + [PASS] 🔴 런 종료 뒤(결과창)에도 차단 유지 = D-42 해소 + [PASS] 🔴 결과창에서 훅 enabled=False 유지(모달 0) + 가짜 오류 1건 → captured=1 suppressed=1 written=1 + [PASS] 🔴 결과창 예외가 「모달차단」으로 기록된다(모달 0) + [PASS] 훅은 우리가 내려둔 상태 + 로그 파일 = E:\NerdNavis\WL_wt\ui\Screenshots_WL/WL813x2\errors_190120.txt + [PASS] 연장 off → 결과창에서 차단 해제(= Q9 가 본 D-42 상태 재현) + [PASS] 연장 off 면 결과창에서 훅 enabled=True(원본 모달 부활) + [PASS] 연장 on 으로 되돌리면 다시 내려간다 + 다음 StartRun → Phase=Zones + [PASS] Phase=Zones(Ended 를 벗어났다 = 연장 래치 해제) + [PASS] 연장분 없이도 런 중이라 차단 = 복원 뒤 곧바로 다시 off + [PASS] Phase=Idle(로비/타이틀) → 차단 해제 = 원본 모달 복귀 + [PASS] 훅 enabled=True 복원 · HookDisabledByUs=False + +── ⑪ 정리 + 임시 오브젝트 제거 · 구독 해제 · 디스크 SO 무변경(값은 메모리 인스턴스만 손댔다) + [PASS] 픽업 노드 0 + [PASS] 정리 완료 + +RESULT PASS — pass=67 fail=0 diff --git a/AgentScripts/WL813o2_Probe.cs b/AgentScripts/WL813o2_Probe.cs new file mode 100644 index 000000000..461e01586 --- /dev/null +++ b/AgentScripts/WL813o2_Probe.cs @@ -0,0 +1,599 @@ +// 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); } + } +} diff --git a/Assets/WL/Combat/Diagnostics/RunErrorModalGuard.cs b/Assets/WL/Combat/Diagnostics/RunErrorModalGuard.cs index 3b1389db1..36c2e447c 100644 --- a/Assets/WL/Combat/Diagnostics/RunErrorModalGuard.cs +++ b/Assets/WL/Combat/Diagnostics/RunErrorModalGuard.cs @@ -128,7 +128,9 @@ namespace WL.Combat.Diagnostics if (cfg == null || !cfg.enabled || WLErrorGuardSettings.RuntimeDisabled) return false; if (!cfg.suppressErrorModalInRun) return false; if (!cfg.suppressOnlyWhileRunning) return true; - return WL.Combat.Run.RunDirector.IsRunning; + if (WL.Combat.Run.RunDirector.IsRunning) return true; + // WL-813o2(Q9 D-42): 런 종료 뒤에도 결과창~다음 런 시작까지 훅을 내린 채 둔다. + return cfg.suppressUntilNextRunStart && WL.Combat.Run.RunDirector.Phase == WL.Combat.Run.RunPhase.Ended; } // ─────────────────────────────────────────── 틱(러너가 부른다 · 프로브도 직접 부를 수 있다) diff --git a/Assets/WL/Combat/Diagnostics/WLErrorGuardSettings.cs b/Assets/WL/Combat/Diagnostics/WLErrorGuardSettings.cs index d9d7ccff9..b532d9c26 100644 --- a/Assets/WL/Combat/Diagnostics/WLErrorGuardSettings.cs +++ b/Assets/WL/Combat/Diagnostics/WLErrorGuardSettings.cs @@ -67,6 +67,10 @@ namespace WL.Combat.Diagnostics [Tooltip("1 = 런 중(RunDirector.IsRunning)에만 차단 · 0 = Play 내내 차단. 시연 중 로비 오류까지 막으려면 0.")] public bool suppressOnlyWhileRunning = true; + [Tooltip("WL-813o2(Q9 D-42) — 런이 끝난 뒤(RunPhase.Ended)에도 훅을 내린 채 둔다. " + + "결과창과 [다음 런] 대기 구간까지 모달 0 · 다음 RunStarted(Phase=Zones)에 복원된다. 끄면 813x2 원래 동작.")] + public bool suppressUntilNextRunStart = true; + [Tooltip("차단한(그리고 차단하지 않은) Error/Exception 을 파일로 남긴다. 끄면 카운터만 센다.")] public bool writeErrorFile = true; diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLErrorGuardSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLErrorGuardSettings.asset index e0b061ae8..e9ca714ac 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLErrorGuardSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLErrorGuardSettings.asset @@ -15,6 +15,7 @@ MonoBehaviour: enabled: 1 suppressErrorModalInRun: 1 suppressOnlyWhileRunning: 1 + suppressUntilNextRunStart: 1 writeErrorFile: 1 errorFileDir: Screenshots_WL/WL813x2 includeStack: 1 diff --git a/Assets/WL/UI/Scripts/ZonePickup.cs b/Assets/WL/UI/Scripts/ZonePickup.cs index f9fd859b4..1adad9dcd 100644 --- a/Assets/WL/UI/Scripts/ZonePickup.cs +++ b/Assets/WL/UI/Scripts/ZonePickup.cs @@ -54,6 +54,202 @@ namespace WL.UI Open = 2, } + // ───────────────────────────────────────────────────────────────────────── + // WL-813o2 (Q9 D-41) — 카드 풀 검증 + // + // ■ 왜 + // 「별」(ID 14 · `Select_Star`)은 `Select_Star.cs:27` 에서 0.05 s 마다 32방향으로 + // `Shoot_Projectile("ProjectileStar", …)` 를 부른다. 그런데 + // `Assets/Res_Addr/Projectile/ProjectileStar.prefab` 이 **디스크에 없다** → + // `InvalidKeyException` **초당 20건 · 845건/런**(Q9 §7) → 결과창을 덮는 모달(D-42). + // + // ■ 키를 어떻게 얻나 (전부 원본 실측 · 원본 수정 0) + // 1차 키 = 원본 `InGameInfo.Get_SkillPrefabPath(eEffect)` 와 **같은 규칙**(형식 문자열은 SO). + // 단 원본 `Add_SelectSkill` 스위치가 프리팹을 로드하지 않는 효과는 1차 키가 없다 + // (`noPrefabEffects` · 예: `Select_Heal` · `Select_Shield` · `Select_*2` 계열). + // 추가 키 = 효과 클래스가 **코드 문자열로** 요구하는 키(`extraKeys` · 예 `ProjectileStar` · `SwordSlash` + // · `Select_Shield` → `Skill_Shield`). 에디트 모드 프로브가 grep 으로 실측해 SO 에 넣는다. + // + // ■ 존재 검사 + // 런타임 = `Addressables.ResourceLocators` 를 순회하며 `Locate(key, GameObject)`(동기 · 비동기 0). + // 카탈로그가 아직 없으면 **검사를 건너뛴다**(모르면 빼지 않는다). + // 에디터 = 카탈로그가 없을 때 `AssetDatabase` 로 대신 본다(에디트 모드 프로브가 이 경로를 쓴다). + // + // ■ GC — 판정은 **ID 당 1회**만 하고 결과를 캐시한다. 2번째 뽑기부터는 문자열 0 · 할당 0. + // ───────────────────────────────────────────────────────────────────────── + /// 선택스킬 행이 참조하는 프리팹 키가 실제로 있는지 본다(결과 캐시). + public static class ZonePickupPool + { + /// 검사한 행 수(누적 · 프로브가 읽는다). + public static int CheckedCount; + /// 부재 키 때문에 뺀 행 수(누적). + public static int MissingKeyCount; + /// SO `excludeSkillIds` 로 뺀 행 수(누적). + public static int StaticExcludeCount; + /// 카탈로그가 없어 키 검사를 건너뛴 횟수. + public static int SkippedCheckCount; + /// 마지막 제외 사유(진단용). + public static string LastReason = ""; + /// 마지막으로 부재로 판정된 키. + public static string LastMissingKey = ""; + + /// ID → 사용 가능(true) / 제외(false). 판정 1회 뒤 재사용. + static readonly Dictionary s_ok = new Dictionary(64); + /// 제외된 ID → 사유(로그·프로브용). + static readonly Dictionary s_reason = new Dictionary(8); + /// 키 → 존재. 같은 키를 여러 행이 써도 1회만 본다. + static readonly Dictionary s_key = new Dictionary(64); + /// 이미 로그를 남긴 ID(같은 줄을 반복하지 않는다). + static readonly HashSet s_logged = new HashSet(); + + /// 프로브·런 경계용 — 캐시와 카운터를 비운다. + public static void ResetCache() + { + s_ok.Clear(); s_reason.Clear(); s_key.Clear(); s_logged.Clear(); + CheckedCount = MissingKeyCount = StaticExcludeCount = SkippedCheckCount = 0; + LastReason = ""; LastMissingKey = ""; + } + + /// 이 ID 가 제외라면 사유, 아니면 "" (프로브가 읽는다). + public static string ReasonFor(int id) + { + string r; + return s_reason.TryGetValue(id, out r) ? r : ""; + } + + /// 검증 축이 살아 있는가(끄면 813o 동작 그대로). + public static bool Active(WLZonePickupSettings s) { return s != null && s.validatePool; } + + /// 이 행을 뽑기에서 빼야 하는가. `reason` 은 로그·프로브용(뺄 때만 채운다). + public static bool Excluded(WLZonePickupSettings s, SelectSkillTableData row, out string reason) + { + reason = null; + if (row == null || !Active(s)) return false; + + bool ok; + if (s_ok.TryGetValue(row.ID, out ok)) + { + if (ok) return false; + reason = ReasonFor(row.ID); + return true; + } + + CheckedCount++; + if (IsListed(s.excludeSkillIds, row.ID)) + { + StaticExcludeCount++; + s_ok[row.ID] = false; + reason = "SO 제외 목록"; + Remember(s, row.ID, reason, ""); + return true; + } + + string missing; + if (s.runtimeKeyCheck && !KeysExist(s, row, out missing)) + { + MissingKeyCount++; + s_ok[row.ID] = false; + reason = "참조 키 부재"; + LastMissingKey = missing; + Remember(s, row.ID, reason, missing); + return true; + } + + s_ok[row.ID] = true; + return false; + } + + /// 이 행이 요구하는 키가 전부 있는가. 없으면 `missing` 에 첫 부재 키. + public static bool KeysExist(WLZonePickupSettings s, SelectSkillTableData row, out string missing) + { + missing = ""; + if (s == null || row == null) return true; + + string effect = row.SkillEffect.ToString(); + + // 1차 키 — 원본 Get_SkillPrefabPath 와 같은 규칙(형식은 SO). + if (row.SkillEffect != eEffect.None && !IsListed(s.noPrefabEffects, effect)) + { + string fmt = effect.Contains("Select") ? s.selectSkillPathFormat + : effect.Contains("Skill") ? s.skillPathFormat + : s.effectPathFormat; + if (!string.IsNullOrEmpty(fmt) && !KeyExists(string.Format(fmt, effect))) + { missing = string.Format(fmt, effect); return false; } + } + + // 추가 키 — 효과 클래스가 코드 문자열로 요구하는 것들. + var extras = s.extraKeys; + if (extras != null) + { + for (int i = 0; i < extras.Length; i++) + { + var x = extras[i]; + if (x == null || string.IsNullOrEmpty(x.assetKey)) continue; + if (!string.Equals(x.skillEffect, effect)) continue; + if (!KeyExists(x.assetKey)) { missing = x.assetKey; return false; } + } + } + return true; + } + + /// Addressables 키 존재(동기 · 결과 캐시). 카탈로그가 없으면 true(모르면 빼지 않는다). + public static bool KeyExists(string key) + { + if (string.IsNullOrEmpty(key)) return true; + bool has; + if (s_key.TryGetValue(key, out has)) return has; + + bool anyLocator = false; + has = false; + foreach (var loc in UnityEngine.AddressableAssets.Addressables.ResourceLocators) + { + if (loc == null) continue; + anyLocator = true; + System.Collections.Generic.IList found; + // 🔴 type = null(아무 타입이나) — 타입 불일치로 **있는 카드를 잘못 빼는 것**이 더 나쁜 실패다. + if (loc.Locate(key, null, out found) && found != null && found.Count > 0) + { has = true; break; } + } + + if (!anyLocator) + { +#if UNITY_EDITOR + // 에디트 모드·카탈로그 미초기화 — 에디터에서는 AssetDatabase 로 같은 것을 본다. + has = UnityEditor.AssetDatabase.LoadAssetAtPath(key) != null; +#else + SkippedCheckCount++; + return true; // 모르면 빼지 않는다(캐시도 하지 않는다) +#endif + } + + s_key[key] = has; + return has; + } + + static void Remember(WLZonePickupSettings s, int id, string reason, string key) + { + LastReason = reason; + s_reason[id] = string.IsNullOrEmpty(key) ? reason : reason + ": " + key; + if (s == null || !s.logExclusion || !s_logged.Add(id)) return; + // 🔴 Debug.Log 만 쓴다(LogError/LogException = 원본 훅이 파란 모달로 만든다 · 813z 규칙). + Debug.Log("[ZonePickup] 카드 제외 — 행 " + id + " (" + s_reason[id] + ")"); + } + + static bool IsListed(int[] arr, int v) + { + if (arr == null) return false; + for (int i = 0; i < arr.Length; i++) if (arr[i] == v) return true; + return false; + } + + static bool IsListed(string[] arr, string v) + { + if (arr == null) return false; + for (int i = 0; i < arr.Length; i++) if (string.Equals(arr[i], v)) return true; + return false; + } + } + /// 존 클리어 픽업 카드(정적 · 노드는 런타임 생성 · 프리팹 diff 0). public static class ZonePickup { @@ -103,6 +299,9 @@ namespace WL.UI GuideWaitCount, ReviveDeferCount, GiveUpCount, TimerWrites, PauseCalls; /// WL-813c2(D-29) — 열린 카드의 타이머를 부활 때문에 멈춘/재개한 횟수와 누적 정지 시간(진단). public static int ReviveHoldCount, ReviveResumeCount, ReviveHoldTicks; + /// WL-813o2(D-41) — 뽑기 결과에서 뺀 카드 수와 다시 뽑은 횟수(진단). + public static int ExcludedCount, RedrawCount; + public static string LastExcludeReason = ""; public static string LastLog = ""; public static string LastSkipReason = ""; @@ -370,7 +569,12 @@ namespace WL.UI return true; } - /// 원본 뽑기 그대로 — `table_selectskill.Get_DataList(n)`(셔플·보유 제외·PreSkill 규칙). + /// + /// 원본 뽑기 그대로 — `table_selectskill.Get_DataList(n)`(셔플·보유 제외·PreSkill 규칙). + /// WL-813o2: 그 **결과만 후필터**해서 참조 프리팹이 없는 행을 뺀다(원본 수정 0). + /// 모자라면 원본 뽑기를 다시 부른다(최대 `maxRedraws` · 매번 새 셔플이라 분포는 그대로). + /// `validatePool = 0` 이면 시도 1회 · 제외 0 = 813o 동작과 완전히 같다. + /// private static bool DrawCards(WLZonePickupSettings s) { s_ids.Clear(); @@ -378,17 +582,32 @@ namespace WL.UI var t = table_selectskill.Ins; if (t == null || InGameInfo.Ins == null) return false; int want = Mathf.Max(1, s.cardCount); + bool filter = ZonePickupPool.Active(s); + int tries = filter ? 1 + Mathf.Max(0, s.maxRedraws) : 1; try { - var list = t.Get_DataList(want); - if (list == null || list.Count < want) return false; - for (int i = 0; i < list.Count; i++) + for (int a = 0; a < tries && s_ids.Count < want; a++) { - var row = list[i] as SelectSkillTableData; - if (row == null) return false; - s_ids.Add(row.ID); - s_rows.Add(row); + if (a > 0) RedrawCount++; + var list = t.Get_DataList(want); + if (list == null) break; + for (int i = 0; i < list.Count && s_ids.Count < want; i++) + { + var row = list[i] as SelectSkillTableData; + if (row == null) continue; + if (s_ids.Contains(row.ID)) continue; // 재뽑기 중복 방지 + string why; + if (filter && ZonePickupPool.Excluded(s, row, out why)) + { + ExcludedCount++; + LastExcludeReason = "행 " + row.ID + " — " + why; + continue; + } + s_ids.Add(row.ID); + s_rows.Add(row); + } } + if (s_ids.Count < want) { s_ids.Clear(); s_rows.Clear(); return false; } return true; } catch (System.Exception ex) @@ -795,11 +1014,22 @@ namespace WL.UI ZoneClearedSeen = OpenCount = PickCount = TimeoutCount = SkipCount = 0; GuideWaitCount = ReviveDeferCount = GiveUpCount = TimerWrites = PauseCalls = 0; ReviveHoldCount = ReviveResumeCount = ReviveHoldTicks = 0; + ExcludedCount = RedrawCount = 0; s_picksThisRun = 0; - LastLog = ""; LastSkipReason = ""; + LastLog = ""; LastSkipReason = ""; LastExcludeReason = ""; return "리셋"; } + /// + /// WL-813o2 프로브 전용 — **뽑기만** 1회 돌린다(노드·표시·정지 0). + /// 결과는 `CardCount` / `CardId(i)` 로 읽는다. 게임 코드는 부르지 않는다. + /// + public static bool DrawForProbe() + { + var s = St; + return s != null && DrawCards(s); + } + public static string Teardown() { Subscribe(false); diff --git a/Assets/WL/UI/Settings/Resources/WL/WLZonePickupSettings.asset b/Assets/WL/UI/Settings/Resources/WL/WLZonePickupSettings.asset index 99ed8b8ce..dd4f93686 100644 --- a/Assets/WL/UI/Settings/Resources/WL/WLZonePickupSettings.asset +++ b/Assets/WL/UI/Settings/Resources/WL/WLZonePickupSettings.asset @@ -27,6 +27,38 @@ MonoBehaviour: cardSortingOrder: 40 cardCount: 3 preferOriginalUi: 1 + validatePool: 1 + excludeSkillIds: + - 3 + - 11 + - 14 + runtimeKeyCheck: 1 + maxRedraws: 6 + logExclusion: 1 + selectSkillPathFormat: Assets/Res_Addr/SelectSkill/{0}.prefab + skillPathFormat: Assets/Res_Addr/Skill/{0}.prefab + effectPathFormat: Assets/Res_Addr/Effect/{0}.prefab + noPrefabEffects: + - Select_DoubleShot + - Select_TripleShot + - Select_Bounce + - Select_Bounce2 + - Select_LeafFairy2 + - Select_FrostFairy2 + - Select_WindFairy2 + - Select_HikikomoriFairy2 + - Select_Heal + - Select_Shield + extraKeys: + - skillEffect: Select_Star + assetKey: Assets/Res_Addr/Projectile/ProjectileStar.prefab + source: 'Select_Star.cs:27 Shoot_Projectile("ProjectileStar")' + - skillEffect: Select_SwordSlash + assetKey: Assets/Res_Addr/Projectile/SwordSlash.prefab + source: 'Select_SwordSlash.cs:29 Shoot_Projectile("SwordSlash")' + - skillEffect: Select_Shield + assetKey: Assets/Res_Addr/Skill/Skill_Shield.prefab + source: 'InGameInfo2.cs:46 Set_Skill_ToPet(eEffect.Skill_Shield)' pickTimeoutSeconds: 10 timeoutUnscaled: 1 autoPickFirstCard: 1 diff --git a/Assets/WL/UI/Settings/WLZonePickupSettings.cs b/Assets/WL/UI/Settings/WLZonePickupSettings.cs index e66460c3a..853078d83 100644 --- a/Assets/WL/UI/Settings/WLZonePickupSettings.cs +++ b/Assets/WL/UI/Settings/WLZonePickupSettings.cs @@ -24,6 +24,24 @@ using UnityEngine; namespace WL.UI { + /// + /// WL-813o2 — 효과 클래스가 **코드 문자열로** 추가 요구하는 Addressables 키 1건. + /// 예: `Select_Star.cs:27 Shoot_Projectile("ProjectileStar", …)` → `Assets/Res_Addr/Projectile/ProjectileStar.prefab`. + /// 값(키 문자열)을 코드에 두지 않기 위해 SO 배열로 뺀다(C45). + /// + [System.Serializable] + public sealed class WLPickupExtraKey + { + [Tooltip("eEffect 이름(table_selectskill 의 SkillEffect 열과 같은 문자열).")] + public string skillEffect = ""; + + [Tooltip("그 효과가 추가로 요구하는 Addressables 키(에셋 경로 그대로).")] + public string assetKey = ""; + + [Tooltip("메모 — 어느 파일 몇 줄에서 나온 키인지(실측 근거).")] + public string source = ""; + } + [CreateAssetMenu(fileName = "WLZonePickupSettings", menuName = "WL/Zone Pickup Settings", order = 34)] public sealed class WLZonePickupSettings : ScriptableObject { @@ -122,6 +140,45 @@ namespace WL.UI "🔴 실측(2026-09-09): 그 노드는 어떤 씬/프리팹에도 없다 = 항상 폴백.")] public bool preferOriginalUi = true; + // ── WL-813o2 (Q9 D-41) — 참조 프리팹이 없는 카드 제외 ───────────────── + // 「별」(ID 14 · Select_Star)은 `Assets/Res_Addr/Projectile/ProjectileStar.prefab` 을 + // 초당 20발 요구하는데 그 파일이 없다 → InvalidKeyException 845건/런 + 결과창 모달(D-42). + // 원본 `table_selectskill.Get_DataList(n)` 은 그대로 쓰고 **결과만 후필터**한다(원본 수정 0). + [Header("풀 검증 — 부재 프리팹 카드 제외 (WL-813o2 · Q9 D-41)")] + [Tooltip("끄면 813o 동작 그대로(제외 0 · 재뽑기 0 · 키 검사 0) = C8 롤백.")] + public bool validatePool = true; + + [Tooltip("뽑기에서 뺄 table_selectskill 행 ID. 에디트 모드 풀 검증(AgentScripts/WL813o2_Probe)이 실측한 목록. " + + "비우면 정적 제외 0(런타임 키 검사만 남는다).")] + public int[] excludeSkillIds = new int[] { 3, 11, 14 }; + + [Tooltip("런타임에도 카드마다 참조 키가 Addressables 에 있는지 검사해 없으면 뺀다(ID 당 1회 · 결과 캐시 · GC 0). " + + "카탈로그가 아직 없으면 검사를 건너뛴다(모르면 빼지 않는다).")] + public bool runtimeKeyCheck = true; + + [Tooltip("제외 때문에 카드가 모자라면 원본 뽑기를 다시 부르는 최대 횟수. 0 = 재뽑기 없음.")] + public int maxRedraws = 6; + + [Tooltip("제외·부재 키를 Console 에 1줄 남긴다(같은 ID 는 1회만 · Error 아님 = 모달 유발 0).")] + public bool logExclusion = true; + + [Tooltip("원본 InGameInfo.Get_SkillPrefabPath 와 같은 규칙 — eEffect 이름에 'Select' 가 있으면 이 형식.")] + public string selectSkillPathFormat = "Assets/Res_Addr/SelectSkill/{0}.prefab"; + + [Tooltip("원본 규칙 — eEffect 이름에 'Skill' 이 있으면 이 형식.")] + public string skillPathFormat = "Assets/Res_Addr/Skill/{0}.prefab"; + + [Tooltip("원본 규칙 — 그 외에는 이 형식.")] + public string effectPathFormat = "Assets/Res_Addr/Effect/{0}.prefab"; + + [Tooltip("원본 InGameInfo.Add_SelectSkill 스위치가 **프리팹을 로드하지 않는** eEffect 이름들(실측). " + + "이 목록의 행은 1차 키를 만들지 않는다(없는 프리팹으로 오판하지 않기 위해).")] + public string[] noPrefabEffects = new string[0]; + + [Tooltip("원본 효과 클래스(Select_*.cs)가 코드 문자열로 추가 요구하는 키(실측 grep). " + + "1차 키와 별개로 이것들도 존재 검사한다.")] + public WLPickupExtraKey[] extraKeys = new WLPickupExtraKey[0]; + // ── 타임아웃 ────────────────────────────────────────────────────────── [Header("타임아웃 — 자동 선택")] [Tooltip("카드가 열린 뒤 자동 선택까지의 시간(초). 발주서 §1-2 = 10s.")]