[WL-813o2] 선택스킬 풀 검증·제외 · 결과창 모달 차단 연장 (#813)

D-41: table_selectskill 58행 전수 실측 — 참조 프리팹/이펙트 키가 없는 3행
      (3 Select_Shield · 11 Select_SwordSlash · 14 Select_Star)을 뽑기에서 제외.
      원본 Get_DataList(3) 은 그대로 쓰고 결과만 후필터(원본 수정 0줄) · 값 전부 SO.
D-42: WLErrorGuardSettings.suppressUntilNextRunStart(기본 1) — RunPhase.Ended
      (결과창~다음 런 시작)까지 훅을 내린 채 둔다. 813x2 파일 +7/-1줄.
This commit is contained in:
깃 관리자 2026-09-09 19:02:41 +09:00
parent fc9a265bd7
commit 5edb54b2f9
8 changed files with 1130 additions and 10 deletions

View File

@ -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

View File

@ -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
// 산출물: <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); }
}
}

View File

@ -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;
}
// ─────────────────────────────────────────── 틱(러너가 부른다 · 프로브도 직접 부를 수 있다)

View File

@ -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;

View File

@ -15,6 +15,7 @@ MonoBehaviour:
enabled: 1
suppressErrorModalInRun: 1
suppressOnlyWhileRunning: 1
suppressUntilNextRunStart: 1
writeErrorFile: 1
errorFileDir: Screenshots_WL/WL813x2
includeStack: 1

View File

@ -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.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>선택스킬 행이 참조하는 프리팹 키가 실제로 있는지 본다(결과 캐시).</summary>
public static class ZonePickupPool
{
/// <summary>검사한 행 수(누적 · 프로브가 읽는다).</summary>
public static int CheckedCount;
/// <summary>부재 키 때문에 뺀 행 수(누적).</summary>
public static int MissingKeyCount;
/// <summary>SO `excludeSkillIds` 로 뺀 행 수(누적).</summary>
public static int StaticExcludeCount;
/// <summary>카탈로그가 없어 키 검사를 건너뛴 횟수.</summary>
public static int SkippedCheckCount;
/// <summary>마지막 제외 사유(진단용).</summary>
public static string LastReason = "";
/// <summary>마지막으로 부재로 판정된 키.</summary>
public static string LastMissingKey = "";
/// <summary>ID → 사용 가능(true) / 제외(false). 판정 1회 뒤 재사용.</summary>
static readonly Dictionary<int, bool> s_ok = new Dictionary<int, bool>(64);
/// <summary>제외된 ID → 사유(로그·프로브용).</summary>
static readonly Dictionary<int, string> s_reason = new Dictionary<int, string>(8);
/// <summary>키 → 존재. 같은 키를 여러 행이 써도 1회만 본다.</summary>
static readonly Dictionary<string, bool> s_key = new Dictionary<string, bool>(64);
/// <summary>이미 로그를 남긴 ID(같은 줄을 반복하지 않는다).</summary>
static readonly HashSet<int> s_logged = new HashSet<int>();
/// <summary>프로브·런 경계용 — 캐시와 카운터를 비운다.</summary>
public static void ResetCache()
{
s_ok.Clear(); s_reason.Clear(); s_key.Clear(); s_logged.Clear();
CheckedCount = MissingKeyCount = StaticExcludeCount = SkippedCheckCount = 0;
LastReason = ""; LastMissingKey = "";
}
/// <summary>이 ID 가 제외라면 사유, 아니면 "" (프로브가 읽는다).</summary>
public static string ReasonFor(int id)
{
string r;
return s_reason.TryGetValue(id, out r) ? r : "";
}
/// <summary>검증 축이 살아 있는가(끄면 813o 동작 그대로).</summary>
public static bool Active(WLZonePickupSettings s) { return s != null && s.validatePool; }
/// <summary>이 행을 뽑기에서 빼야 하는가. `reason` 은 로그·프로브용(뺄 때만 채운다).</summary>
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;
}
/// <summary>이 행이 요구하는 키가 전부 있는가. 없으면 `missing` 에 첫 부재 키.</summary>
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;
}
/// <summary>Addressables 키 존재(동기 · 결과 캐시). 카탈로그가 없으면 true(모르면 빼지 않는다).</summary>
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<UnityEngine.ResourceManagement.ResourceLocations.IResourceLocation> 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<GameObject>(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;
}
}
/// <summary>존 클리어 픽업 카드(정적 · 노드는 런타임 생성 · 프리팹 diff 0).</summary>
public static class ZonePickup
{
@ -103,6 +299,9 @@ namespace WL.UI
GuideWaitCount, ReviveDeferCount, GiveUpCount, TimerWrites, PauseCalls;
/// <summary>WL-813c2(D-29) — 열린 카드의 타이머를 부활 때문에 멈춘/재개한 횟수와 누적 정지 시간(진단).</summary>
public static int ReviveHoldCount, ReviveResumeCount, ReviveHoldTicks;
/// <summary>WL-813o2(D-41) — 뽑기 결과에서 뺀 카드 수와 다시 뽑은 횟수(진단).</summary>
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;
}
/// <summary>원본 뽑기 그대로 — `table_selectskill.Get_DataList(n)`(셔플·보유 제외·PreSkill 규칙).</summary>
/// <summary>
/// 원본 뽑기 그대로 — `table_selectskill.Get_DataList(n)`(셔플·보유 제외·PreSkill 규칙).
/// WL-813o2: 그 **결과만 후필터**해서 참조 프리팹이 없는 행을 뺀다(원본 수정 0).
/// 모자라면 원본 뽑기를 다시 부른다(최대 `maxRedraws` · 매번 새 셔플이라 분포는 그대로).
/// `validatePool = 0` 이면 시도 1회 · 제외 0 = 813o 동작과 완전히 같다.
/// </summary>
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 "리셋";
}
/// <summary>
/// WL-813o2 프로브 전용 — **뽑기만** 1회 돌린다(노드·표시·정지 0).
/// 결과는 `CardCount` / `CardId(i)` 로 읽는다. 게임 코드는 부르지 않는다.
/// </summary>
public static bool DrawForProbe()
{
var s = St;
return s != null && DrawCards(s);
}
public static string Teardown()
{
Subscribe(false);

View File

@ -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

View File

@ -24,6 +24,24 @@ using UnityEngine;
namespace WL.UI
{
/// <summary>
/// WL-813o2 — 효과 클래스가 **코드 문자열로** 추가 요구하는 Addressables 키 1건.
/// 예: `Select_Star.cs:27 Shoot_Projectile("ProjectileStar", …)` → `Assets/Res_Addr/Projectile/ProjectileStar.prefab`.
/// 값(키 문자열)을 코드에 두지 않기 위해 SO 배열로 뺀다(C45).
/// </summary>
[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.")]