// WL813i2_Probe.cs — #813i2(보스 연타 대비 피격 후 무적 창 · 보스 예고 중 자동 회피 스텝) 에디트 모드 검증 프로브 // 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 클론은 즉시 파괴) // // 상주 에디터: unity command run_script --project-path --file AgentScripts/WL813i2_Probe.cs --entry WL813i2_Probe.RunAll --timeout 300 // 결과: Console + AgentScripts/WL813i2_PROBE.txt (RESULT PASS / RESULT FAIL n) // // 검사 (발주서 WL-813i2 §1-4) // ⓐ 에셋·SOT: WLSurvivalSettings 신규 6필드 + autoPotionHpRatio 0.55 · WLRunGuideSettings 신규 9필드 // ⓑ 🔴 가짜 보스 5연타(0.52 s 등간격 · Q10 §6 실측 재현) → 실제로 들어간 타수 · HP 궤적 // ⓒ 무적 창 ms 실측(열림 → 경계 직전 → 경계 직후) // ⓓ 잡몹 5연타 → 무적 0(mobHitInvincible=0) · 옵션을 켜면 창이 열린다 // ⓔ 최소 실피해 게이트(hitInvincibleMinHpLoss) · 부활 창 우선순위 · 런 경계에서 창이 닫힌다 // ⓕ 예고 감지: 실제 Anubis 컨트롤러의 상태 이름 대조 + Animator 상태 판정 + 안전 폴백(null·컨트롤러 없음) // ⓖ 회피 스텝 판정 6분기 + 「예고 1건에 1회」(쿨다운) · NavMesh 밖 = 스텝 0 // ⓗ GC 0 (TakeDamage ×200k · IsHitInvincible ×200k · DodgeDecision ×200k) // ⓘ C8: bossHitInvincibleSeconds=0 → 원래(5연타 5타 전부) · dodgeStepEnabled=0 → 스텝 판정 kDodgeOff // ⓙ 정리(임시 오브젝트 0 · MyPC 원복 · 에셋 캐시 원복 · 씬 dirty 불변) // // 에디트 모드 한계(완료보고 「미확인」) // · NavMesh 가 없어 NavMeshAgent.Move 실이동(3 m)은 못 잰다 → SampleStep 이 실패해 DodgeFailedNavMesh 로 떨어진다. // 스텝 「판정」과 「1회만」은 순수 함수(DodgeDecision)로, 방향 계산은 별도 산술로 검증한다. // · 보스 차징 코루틴은 Play 에서만 돌아 「예고 → 발동 ms」는 인게임 QA 몫이다. using System; using System.IO; using System.Reflection; using System.Text; using UnityEngine; using WL.Combat.Run; using WL.Combat.Survival; public static class WL813i2_Probe { const string OutDir = "Screenshots_WL/WL813i2"; const string OutCommit = "AgentScripts/WL813i2_PROBE.txt"; const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; const BindingFlags NS = BindingFlags.NonPublic | BindingFlags.Static; // Q10 §6 실측 = 0.52 s 등간격 5연타 · 원 피해 222(death_200823_6.txt 14~16행) const float ComboInterval = 0.52f; const double BossRawDamage = 222d; const double PcMaxHp = 310d; const string AnubisController = "Assets/ResWork/Suriyun/Monster Pack Pyramid/Animations/Anubis/Anubis_Animations_Check.controller"; static WLSurvivalSettings s_clone; static WLRunGuideSettings s_rgClone; public static object RunAll() { var sb = new StringBuilder(); sb.AppendLine("# WL813i2 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); sb.AppendLine("playMode=" + Application.isPlaying); int fail = 0; var pc0 = MyValue.MyPC; bool dirty0 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty; try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓐ 예외 " + ex); } try { fail += BossCombo(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓑ 예외 " + ex); } try { fail += WindowMs(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓒ 예외 " + ex); } try { fail += MobCombo(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓓ 예외 " + ex); } try { fail += Gates(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓔ 예외 " + ex); } try { fail += Telegraph(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓕ 예외 " + ex); } try { fail += DodgeLogic(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓖ 예외 " + ex); } try { fail += Gc(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓗ 예외 " + ex); } try { fail += C8(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓘ 예외 " + ex); } RestoreAssets(); MyValue.MyPC = pc0; Survival.ResetDiagnostics(); try { fail += Cleanup(sb, dirty0); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓙ 예외 " + ex); } string result = fail == 0 ? "RESULT PASS" : ("RESULT FAIL " + fail); sb.AppendLine(); sb.AppendLine(result); string body = sb.ToString(); Write(body); Debug.Log(body); return result; } // ───────────────────────────────── ⓐ 에셋 (디스크 값 실측) static int Asset(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓐ 에셋 실측 (디스크 값 · 클론 아님)"); var st = WLSurvivalSettings.Instance; bool a1 = st != null; sb.AppendLine(P(a1) + " WLSurvivalSettings 로드 " + (a1 ? "OK" : "null")); if (!a1) return f + 1; bool a2 = st.hitInvincibleEnabled && Math.Abs(st.bossHitInvincibleSeconds - 0.6f) < 1e-4 && !st.mobHitInvincible && Math.Abs(st.mobHitInvincibleSeconds - 0.2f) < 1e-4 && Math.Abs(st.hitInvincibleMinHpLoss - 1f) < 1e-4 && !st.hitInvincibleLog; sb.AppendLine(P(a2) + " 신규 6필드 hitInv=" + st.hitInvincibleEnabled + " boss=" + st.bossHitInvincibleSeconds + "s mob=" + st.mobHitInvincible + "/" + st.mobHitInvincibleSeconds + "s minLoss=" + st.hitInvincibleMinHpLoss + " log=" + st.hitInvincibleLog); if (!a2) f++; bool a3 = Math.Abs(st.autoPotionHpRatio - 0.55f) < 1e-4; sb.AppendLine(P(a3) + " §1-3 값 autoPotionHpRatio = " + st.autoPotionHpRatio + " (0.45 → 0.55)"); if (!a3) f++; // 813m2 §4 병목 근거 — 회복량과 임계의 합이 1 을 넘으면 그만큼 회복이 낭비된다(값 판단은 Lead). float waste = st.autoPotionHpRatio + st.potionHealRate - 1f; sb.AppendLine(" · 참고: 임계 " + st.autoPotionHpRatio + " + 회복 " + st.potionHealRate + " = " + (st.autoPotionHpRatio + st.potionHealRate).ToString("F2") + (waste > 0f ? " → 만충 초과 " + (waste * 100f).ToString("F0") + " % 낭비(실측)" : " → 낭비 0")); bool a4 = Math.Abs(st.bossDamageMultiplier - 0.1f) < 1e-4 && Math.Abs(st.mobDamageMultiplier - 0.1f) < 1e-4; sb.AppendLine(P(a4) + " 기존 배율(F-17) boss=" + st.bossDamageMultiplier + " mob=" + st.mobDamageMultiplier); if (!a4) f++; var cfg = WLRunGuideSettings.Instance; bool a5 = cfg != null; sb.AppendLine(P(a5) + " WLRunGuideSettings 로드 " + (a5 ? "OK" : "null")); if (!a5) return f + 1; var names = cfg.dodgeTelegraphStates; bool a6 = cfg.dodgeStepEnabled && names != null && names.Length == 2 && names[0] == "boss_skill1charging" && names[1] == "boss_skill2charging" && Math.Abs(cfg.dodgeStepDistance - 3f) < 1e-4 && Math.Abs(cfg.dodgeStepSeconds - 0.3f) < 1e-4 && Math.Abs(cfg.dodgeStepSideBias) < 1e-4 && Math.Abs(cfg.dodgeStepBossRadius - 14f) < 1e-4 && Math.Abs(cfg.dodgeStepCooldownSeconds - 1.5f) < 1e-4 && Math.Abs(cfg.dodgeStepNavSampleRadius - 2f) < 1e-4 && cfg.dodgeStepLog; sb.AppendLine(P(a6) + " 신규 9필드 dodge=" + cfg.dodgeStepEnabled + " states=" + (names == null ? "null" : string.Join("|", names)) + " dist=" + cfg.dodgeStepDistance + "m/" + cfg.dodgeStepSeconds + "s side=" + cfg.dodgeStepSideBias + " radius=" + cfg.dodgeStepBossRadius + " cool=" + cfg.dodgeStepCooldownSeconds + " nav=" + cfg.dodgeStepNavSampleRadius + " log=" + cfg.dodgeStepLog); if (!a6) f++; return f; } // ───────────────────────────────── ⓑ 🔴 보스 5연타(0.52 s) 재현 static int BossCombo(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓑ 🔴 보스 5연타 0.52 s (Q10 §6 재현 · 원 피해 " + BossRawDamage + " ×0.10)"); var st = UseClone(); if (st == null) return f + 1; st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; st.takeMobDamage = true; st.takeBossDamage = true; st.mobDamageMultiplier = 0.1f; st.bossDamageMultiplier = 0.1f; st.unscaledDamageGuardEnabled = false; st.deathDiagEnabled = true; st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.mobHitInvincible = false; st.hitInvincibleMinHpLoss = 1f; var pc = MakePC("i2_pc", PcMaxHp, PcMaxHp); var boss = MakeBeater("i2_boss", eSubRol.Boss); var pc0 = MyValue.MyPC; MyValue.MyPC = pc; try { Survival.ResetDiagnostics(); DeathDiag.ResetDiagnostics(); sb.AppendLine("| # | t(s) | 원 피해 | 결과 | HP after | via |"); sb.AppendLine("|---|---|---|---|---|---|"); int landed = 0; for (int i = 0; i < 5; i++) { float t = i * ComboInterval; Survival.Tick(t); double before = pc.Get_HP(); Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); double after = pc.Get_HP(); bool hit = before - after > 1e-6; if (hit) landed++; var rec = DeathDiag.Get(i); sb.AppendLine("| " + (i + 1) + " | " + t.ToString("F2") + " | " + BossRawDamage.ToString("F0") + " | " + (hit ? "🔴 피격 −" + (before - after).ToString("F1") : "무적(흘림)") + " | " + after.ToString("F0") + "/" + PcMaxHp.ToString("F0") + " | " + rec.via + " |"); } double hpEnd = pc.Get_HP(); bool b1 = landed < 5 && landed >= 1; sb.AppendLine(P(b1) + " 🔴 5연타 중 **실제 피격 " + landed + "타** (창 " + st.bossHitInvincibleSeconds + "s · 발주서 §1-1 목표 「1~2타」 · 아래 스윕 표 참조)"); if (!b1) f++; bool b2 = Survival.BlockedHitInvincible == 5 - landed; sb.AppendLine(P(b2) + " 무적이 막은 타수 = " + Survival.BlockedHitInvincible + " (5 − 피격 " + landed + ")"); if (!b2) f++; bool b3 = Survival.HitInvincibleOpened == landed && Survival.HitInvincibleOpenedByBoss == landed; sb.AppendLine(P(b3) + " 창 연 횟수 " + Survival.HitInvincibleOpened + "(보스 " + Survival.HitInvincibleOpenedByBoss + ") = 피격 타수"); if (!b3) f++; bool b4 = hpEnd > 0d; sb.AppendLine(P(b4) + " 🔴 5연타 후 생존 HP = " + hpEnd.ToString("F0") + "/" + PcMaxHp.ToString("F0") + " (Q10 실측 = 5대에 사망)"); if (!b4) f++; // 대조군 — 무적 창을 끄면 5타 전부 들어간다(= Q10 실측 상황) st.hitInvincibleEnabled = false; var pcB = MakePC("i2_pcB", PcMaxHp, PcMaxHp); MyValue.MyPC = pcB; Survival.ResetDiagnostics(); int landedOff = 0; for (int i = 0; i < 5; i++) { Survival.Tick(i * ComboInterval); double before = pcB.Get_HP(); Survival.TakeDamage(pcB, Dmg(boss, BossRawDamage), ApplyToPc(pcB)); if (before - pcB.Get_HP() > 1e-6) landedOff++; } bool b5 = landedOff == 5; sb.AppendLine(P(b5) + " 대조군(창 off) = " + landedOff + "타 전부 피격 · HP " + pcB.Get_HP().ToString("F0") + "/" + PcMaxHp.ToString("F0") + " → 창이 만든 차이 " + (landedOff - landed) + "타"); if (!b5) f++; Kill(pcB); st.hitInvincibleEnabled = true; // 🔴 값 스윕 — 「1~2타」를 만드는 창 길이가 얼마인가(값 결정은 Lead · 여기서는 실측만). // 간격 0.52 s 등차 5타에서 창 W 는 W/0.52 타를 흘린다 → 필요한 W 는 0.52 의 배수 경계에 걸린다. sb.AppendLine(); sb.AppendLine("| 창(초) | 피격 타수 | 5연타 후 HP | 흘린 타 |"); sb.AppendLine("|---|---|---|---|"); float[] sweep = { 0f, 0.30f, 0.53f, 0.60f, 0.80f, 1.05f, 1.30f, 1.60f, 2.10f }; for (int k = 0; k < sweep.Length; k++) { st.hitInvincibleEnabled = sweep[k] > 0f; st.bossHitInvincibleSeconds = sweep[k]; var pcS = MakePC("i2_sw" + k, PcMaxHp, PcMaxHp); MyValue.MyPC = pcS; Survival.ResetDiagnostics(); int L = 0; for (int i = 0; i < 5; i++) { Survival.Tick(i * ComboInterval); double before = pcS.Get_HP(); Survival.TakeDamage(pcS, Dmg(boss, BossRawDamage), ApplyToPc(pcS)); if (before - pcS.Get_HP() > 1e-6) L++; } sb.AppendLine("| " + sweep[k].ToString("F2") + " | " + L + " | " + pcS.Get_HP().ToString("F0") + "/310 | " + Survival.BlockedHitInvincible + " |" + (L >= 1 && L <= 2 ? " ← 발주서 목표 1~2타" : "")); Kill(pcS); } st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; sb.AppendLine(" · 🔴 에셋 값은 발주서 제안대로 **0.6 s** 로 두었다. 목표(1~2타)를 만드는 최소 창은 위 표의 실측값이다 — **값 결정은 Lead**(C3)."); } finally { MyValue.MyPC = pc0; Kill(pc, boss); } return f; } // ───────────────────────────────── ⓒ 무적 창 ms 실측 static int WindowMs(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓒ 무적 창 ms 실측"); var st = UseClone(); if (st == null) return f + 1; st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f; st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.hitInvincibleMinHpLoss = 1f; st.unscaledDamageGuardEnabled = false; var pc = MakePC("i2_w_pc", PcMaxHp, PcMaxHp); var boss = MakeBeater("i2_w_boss", eSubRol.Boss); var pc0 = MyValue.MyPC; MyValue.MyPC = pc; try { Survival.ResetDiagnostics(); Survival.Tick(100f); Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); float until = Survival.HitInvincibleUntil; bool c1 = Math.Abs(until - 100.6f) < 1e-3 && Math.Abs(Survival.LastHitInvincibleSeconds - 0.6f) < 1e-4; sb.AppendLine(P(c1) + " 창 열림 t=100.000 → until=" + until.ToString("F3") + " · 길이 " + (Survival.LastHitInvincibleSeconds * 1000f).ToString("F0") + " ms"); if (!c1) f++; Survival.Tick(100.599f); bool c2 = Survival.IsHitInvincible(pc) && Survival.HitInvincibleRemaining > 0f; sb.AppendLine(P(c2) + " 경계 직전 t=100.599(+599 ms) → 무적 " + c2 + " · 잔여 " + (Survival.HitInvincibleRemaining * 1000f).ToString("F1") + " ms"); if (!c2) f++; double h1 = pc.Get_HP(); Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); bool c3 = Math.Abs(pc.Get_HP() - h1) < 1e-6; sb.AppendLine(P(c3) + " 경계 직전 피격 = 피해 0 (HP " + pc.Get_HP().ToString("F0") + " 유지)"); if (!c3) f++; Survival.Tick(100.601f); bool c4 = !Survival.IsHitInvincible(pc) && Survival.HitInvincibleRemaining <= 0f; sb.AppendLine(P(c4) + " 경계 직후 t=100.601(+601 ms) → 무적 " + Survival.IsHitInvincible(pc)); if (!c4) f++; double h2 = pc.Get_HP(); Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); double lost = h2 - pc.Get_HP(); bool c5 = Math.Abs(lost - BossRawDamage * 0.1d) < 1e-3; sb.AppendLine(P(c5) + " 경계 직후 피격 = 정상 피해 −" + lost.ToString("F1") + " (원 " + BossRawDamage + " ×0.10)"); if (!c5) f++; // 창 안에서 다시 맞아도 창이 연장되지 않는다(창은 실피해로만 열린다) Survival.Tick(100.700f); float untilB = Survival.HitInvincibleUntil; Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); bool c6 = Math.Abs(Survival.HitInvincibleUntil - untilB) < 1e-4; sb.AppendLine(P(c6) + " 창 안 피격은 창을 연장하지 않는다 until=" + Survival.HitInvincibleUntil.ToString("F3")); if (!c6) f++; } finally { MyValue.MyPC = pc0; Kill(pc, boss); } return f; } // ───────────────────────────────── ⓓ 잡몹 = 무적 0 static int MobCombo(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓓ 잡몹 피해 무적 0 (mobHitInvincible=0)"); var st = UseClone(); if (st == null) return f + 1; st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; st.takeMobDamage = true; st.mobDamageMultiplier = 0.1f; st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.mobHitInvincible = false; st.mobHitInvincibleSeconds = 0.2f; st.hitInvincibleMinHpLoss = 1f; st.unscaledDamageGuardEnabled = false; var pc = MakePC("i2_m_pc", PcMaxHp, PcMaxHp); var mob = MakeBeater("i2_m_mob", eSubRol.None); var pc0 = MyValue.MyPC; MyValue.MyPC = pc; try { Survival.ResetDiagnostics(); int landed = 0; for (int i = 0; i < 5; i++) { Survival.Tick(i * ComboInterval); double before = pc.Get_HP(); Survival.TakeDamage(pc, Dmg(mob, 120d), ApplyToPc(pc)); if (before - pc.Get_HP() > 1e-6) landed++; } bool d1 = landed == 5 && Survival.BlockedHitInvincible == 0 && Survival.HitInvincibleOpened == 0; sb.AppendLine(P(d1) + " 잡몹 5연타 = " + landed + "타 전부 피격 · 막은타 " + Survival.BlockedHitInvincible + " · 연 창 " + Survival.HitInvincibleOpened + " (실측상 잡몹은 한 방에 0.3~0.4 %)"); if (!d1) f++; // 옵션을 켜면 잡몹도 창이 열린다(SO 하나로 뒤집힌다 = C45) st.mobHitInvincible = true; var pcB = MakePC("i2_m_pcB", PcMaxHp, PcMaxHp); MyValue.MyPC = pcB; Survival.ResetDiagnostics(); int landedOn = 0; for (int i = 0; i < 5; i++) { Survival.Tick(i * 0.1f); // 0.2 s 창보다 촘촘하게 double before = pcB.Get_HP(); Survival.TakeDamage(pcB, Dmg(mob, 120d), ApplyToPc(pcB)); if (before - pcB.Get_HP() > 1e-6) landedOn++; } bool d2 = landedOn < 5 && Survival.HitInvincibleOpened == landedOn; sb.AppendLine(P(d2) + " mobHitInvincible=1 로 뒤집으면 0.1 s 간격 5타 중 " + landedOn + "타만 피격(창 0.2 s)"); if (!d2) f++; Kill(pcB); st.mobHitInvincible = false; } finally { MyValue.MyPC = pc0; Kill(pc, mob); } return f; } // ───────────────────────────────── ⓔ 게이트·우선순위·런 경계 static int Gates(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓔ 최소 피해 게이트 · 부활 창 우선 · 런 경계"); var st = UseClone(); if (st == null) return f + 1; st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f; st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.hitInvincibleMinHpLoss = 1f; st.unscaledDamageGuardEnabled = false; var pc = MakePC("i2_g_pc", PcMaxHp, PcMaxHp); var boss = MakeBeater("i2_g_boss", eSubRol.Boss); var pc0 = MyValue.MyPC; MyValue.MyPC = pc; try { // ① 순수 함수 판정표 (Actor 접근 0 · C8 증거) sb.AppendLine("| 입력(boss · hpLost) | 창(초) |"); sb.AppendLine("|---|---|"); float[] w = new float[5]; w[0] = Survival.HitInvincibleWindow(st, true, 30d); w[1] = Survival.HitInvincibleWindow(st, true, 0.5d); w[2] = Survival.HitInvincibleWindow(st, false, 30d); st.mobHitInvincible = true; w[3] = Survival.HitInvincibleWindow(st, false, 30d); st.mobHitInvincible = false; w[4] = Survival.HitInvincibleWindow(null, true, 30d); sb.AppendLine("| boss · 30 HP | " + w[0] + " |"); sb.AppendLine("| boss · 0.5 HP((AnubisController); bool t1 = rac != null; sb.AppendLine(P(t1) + " Anubis 컨트롤러 로드 " + (t1 ? rac.name : "null") + " (" + AnubisController + ")"); if (!t1) return f + 1; var go = new GameObject("__WL813i2_boss"); var actor = go.AddComponent(); actor.m_Role = eRole.Mob; actor.m_SubRole = eSubRol.Boss; var anim = go.AddComponent(); anim.runtimeAnimatorController = rac; actor.m_animation = anim; try { var names = cfg.dodgeTelegraphStates; sb.AppendLine(" · 컨트롤러 레이어 수 = " + anim.layerCount); bool t2 = true; for (int i = 0; i < names.Length; i++) { int hash = Animator.StringToHash(names[i]); int found = -1; for (int L = 0; L < anim.layerCount; L++) if (anim.HasState(L, hash)) { found = L; break; } sb.AppendLine(" · \"" + names[i] + "\" → 레이어 " + (found < 0 ? "없음" : found.ToString()) + " (" + (found < 0 ? "❌" : anim.GetLayerName(found)) + ")"); if (found < 0) t2 = false; } sb.AppendLine(P(t2) + " 🔴 SO 상태 이름 " + string.Join(" · ", names) + " 가 실제 Anubis 컨트롤러에 **존재**(Animator.HasState · 전 레이어)"); if (!t2) f++; // ② 그 상태로 재생하면 IsBossTelegraphing 이 true 인가(에디트 모드 Animator 평가) anim.Play(names[0], -1, 0f); anim.Update(0f); bool onCharging = RunGuide.IsBossTelegraphing(cfg, actor); anim.Play("idle", -1, 0f); anim.Update(0f); bool onIdle = RunGuide.IsBossTelegraphing(cfg, actor); bool t3 = onCharging && !onIdle; sb.AppendLine((t3 ? "[PASS]" : "[WARN]") + " Animator 상태 판정: charging=" + onCharging + " idle=" + onIdle + (t3 ? "" : " ← 에디트 모드 Animator 평가 한계 가능(인게임 QA 로 확인 · 「미확인」)")); // ②-b GC 0 — 예고 판정은 매 프레임 돈다(실제 Animator 로 잰다) anim.Play(names[0], -1, 0f); anim.Update(0f); for (int i = 0; i < 1000; i++) RunGuide.IsBossTelegraphing(cfg, actor); GC.Collect(); long g0 = GC.GetTotalMemory(true); for (int i = 0; i < 200000; i++) RunGuide.IsBossTelegraphing(cfg, actor); long g1 = GC.GetTotalMemory(false); bool tg = g1 - g0 == 0; sb.AppendLine(P(tg) + " IsBossTelegraphing ×200,000(실 Animator · 전 레이어) = " + (g1 - g0) + " byte"); if (!tg) f++; // ③ 안전 폴백 — 이 셋은 어떤 경우에도 예외 없이 false 여야 한다 bool t4 = !RunGuide.IsBossTelegraphing(cfg, null); var bare = new GameObject("__WL813i2_bare"); var bareActor = bare.AddComponent(); bool t5 = !RunGuide.IsBossTelegraphing(cfg, bareActor); // m_animation = null var noCtrlGo = new GameObject("__WL813i2_noctrl"); var noCtrlActor = noCtrlGo.AddComponent(); noCtrlActor.m_animation = noCtrlGo.AddComponent(); // 컨트롤러 없음 bool t6 = !RunGuide.IsBossTelegraphing(cfg, noCtrlActor); var empty = cfg.dodgeTelegraphStates; cfg.dodgeTelegraphStates = new string[0]; anim.Play(names[0], -1, 0f); anim.Update(0f); bool t7 = !RunGuide.IsBossTelegraphing(cfg, actor); // 목록이 비면 언제나 false(C8) cfg.dodgeTelegraphStates = empty; bool tOk = t4 && t5 && t6 && t7; sb.AppendLine(P(tOk) + " 안전 폴백 null=" + t4 + " 애니메이터 없음=" + t5 + " 컨트롤러 없음=" + t6 + " 이름목록 빔=" + t7); if (!tOk) f++; Kill(bare, noCtrlGo); // ④ 12보스 컨트롤러 전수 — 어느 보스에 예고 상태가 있는가(값 판단은 Lead · 여기서는 실측만) sb.AppendLine(); sb.AppendLine("| 컨트롤러 | boss_skill1charging | boss_skill2charging |"); sb.AppendLine("|---|---|---|"); var guids = UnityEditor.AssetDatabase.FindAssets("t:AnimatorController", new[] { "Assets/ResWork" }); int withAny = 0, total = 0; for (int i = 0; i < guids.Length; i++) { string path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); var c = UnityEditor.AssetDatabase.LoadAssetAtPath(path); if (c == null) continue; var probeGo = new GameObject("__WL813i2_scan"); var pa = probeGo.AddComponent(); pa.runtimeAnimatorController = c; bool h1 = false, h2 = false; for (int L = 0; L < pa.layerCount; L++) { if (pa.HasState(L, Animator.StringToHash("boss_skill1charging"))) h1 = true; if (pa.HasState(L, Animator.StringToHash("boss_skill2charging"))) h2 = true; } Kill(probeGo); if (!h1 && !h2) continue; total++; withAny++; sb.AppendLine("| " + c.name + " | " + (h1 ? "✅" : "—") + " | " + (h2 ? "✅" : "—") + " |"); } sb.AppendLine(" · 예고 상태를 가진 컨트롤러 " + withAny + "종 · Anubis(10006 · Q10 사망 14/14 의 보스)는 2종 다 보유"); } finally { Kill(go); } return f; } // ───────────────────────────────── ⓖ 회피 스텝 판정 static int DodgeLogic(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓖ 회피 스텝 판정 (순수 함수 · 「예고 1건에 1회」)"); var cfg = UseRgClone(); if (cfg == null) return f + 1; cfg.enabled = true; WLRunGuideSettings.RuntimeDisabled = false; cfg.dodgeStepEnabled = true; cfg.dodgeStepDistance = 3f; cfg.dodgeStepSeconds = 0.3f; cfg.dodgeStepCooldownSeconds = 1.5f; sb.AppendLine("| 입력(보스반경 · 예고 · PC준비 · 마지막스텝후) | 판정 |"); sb.AppendLine("|---|---|"); int g0 = RunGuide.DodgeDecision(cfg, true, true, true, 99f); int g1 = RunGuide.DodgeDecision(cfg, true, true, true, 0.5f); int g2 = RunGuide.DodgeDecision(cfg, false, true, true, 99f); int g3 = RunGuide.DodgeDecision(cfg, true, false, true, 99f); int g4 = RunGuide.DodgeDecision(cfg, true, true, false, 99f); cfg.dodgeStepEnabled = false; int g5 = RunGuide.DodgeDecision(cfg, true, true, true, 99f); cfg.dodgeStepEnabled = true; int g6 = RunGuide.DodgeDecision(null, true, true, true, 99f); sb.AppendLine("| 안 · 예고 · 준비 · 99 s | " + Name(g0) + " |"); sb.AppendLine("| 안 · 예고 · 준비 · 0.5 s(<쿨 1.5) | " + Name(g1) + " |"); sb.AppendLine("| 밖 · 예고 · 준비 · 99 s | " + Name(g2) + " |"); sb.AppendLine("| 안 · 예고아님 · 준비 · 99 s | " + Name(g3) + " |"); sb.AppendLine("| 안 · 예고 · PC바쁨 · 99 s | " + Name(g4) + " |"); sb.AppendLine("| dodgeStepEnabled=0 | " + Name(g5) + " |"); sb.AppendLine("| cfg=null | " + Name(g6) + " |"); bool ok = g0 == RunGuide.kDodgeGo && g1 == RunGuide.kDodgeCooldown && g2 == RunGuide.kDodgeNoBoss && g3 == RunGuide.kDodgeNoTelegraph && g4 == RunGuide.kDodgePcBusy && g5 == RunGuide.kDodgeOff && g6 == RunGuide.kDodgeOff; sb.AppendLine(P(ok) + " 판정 7분기 전부 기대값"); if (!ok) f++; // 「예고 1건에 1회」 — 실측 차징 1.2 s(813h JSON) < 쿨 1.5 s 이므로 한 예고 안에서는 1회뿐이다 bool g7 = cfg.dodgeStepCooldownSeconds > 1.2f; sb.AppendLine(P(g7) + " 🔴 쿨 " + cfg.dodgeStepCooldownSeconds + "s > 실측 차징 1.2 s → **예고 1건에 스텝 1회**"); if (!g7) f++; int within = RunGuide.DodgeDecision(cfg, true, true, true, 1.2f); bool g8 = within == RunGuide.kDodgeCooldown; sb.AppendLine(P(g8) + " 같은 예고 안(1.2 s 경과)의 두 번째 요청 = " + Name(within)); if (!g8) f++; // 방향 산술 — 「예고 방향과 반대」(보스→PC 의 반대쪽) · sideBias 0/1 을 직접 계산해 대조 Vector3 bossPos = new Vector3(10f, 0f, 0f), pcPos = new Vector3(13f, 0f, 0f); Vector3 away = (pcPos - bossPos).normalized; Vector3 side = Vector3.Cross(Vector3.up, away); bool g9 = Mathf.Abs(away.x - 1f) < 1e-4 && Mathf.Abs(Vector3.Dot(away, side)) < 1e-4; sb.AppendLine(P(g9) + " 방향: away=" + away.ToString("F2") + " (보스 반대) · side=" + side.ToString("F2") + " (직교 · dot=" + Vector3.Dot(away, side).ToString("F3") + ") · 스텝 지점 = PC + away×" + cfg.dodgeStepDistance + "m = " + (pcPos + away * cfg.dodgeStepDistance).ToString("F1")); if (!g9) f++; sb.AppendLine(" · 계획 속도 = " + cfg.dodgeStepDistance + "m / " + cfg.dodgeStepSeconds + "s = " + (cfg.dodgeStepDistance / cfg.dodgeStepSeconds).ToString("F1") + " m/s (NavMeshAgent.Move 등속)"); // NavMesh 없음 = 스텝 0(지형 뚫기 방지) — 에디트 모드에서 실제로 확인되는 값 RunGuide.ResetDiagnostics(); RunGuide.ResetDodgeStateForProbe(); bool moved = RunGuide.ForceDodgeTick(1000f, 0.016f); bool g10 = !moved && !RunGuide.Dodging && RunGuide.DodgeSteps == 0; sb.AppendLine(P(g10) + " NavMesh/PC 없음 → 스텝 0(Dodging=" + RunGuide.Dodging + " DodgeSteps=" + RunGuide.DodgeSteps + ") · 실이동 3 m 는 인게임 QA 몫(「미확인」)"); if (!g10) f++; return f; } static string Name(int d) { if (d == RunGuide.kDodgeGo) return "kDodgeGo"; if (d == RunGuide.kDodgeOff) return "kDodgeOff"; if (d == RunGuide.kDodgeCooldown) return "kDodgeCooldown"; if (d == RunGuide.kDodgeNoBoss) return "kDodgeNoBoss"; if (d == RunGuide.kDodgeNoTelegraph) return "kDodgeNoTelegraph"; return "kDodgePcBusy"; } // ───────────────────────────────── ⓗ GC 0 static int Gc(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓗ GC 0 (핫 경로)"); var st = UseClone(); if (st == null) return f + 1; st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f; st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0.6f; st.unscaledDamageGuardEnabled = false; st.deathDiagEnabled = true; st.hitInvincibleLog = false; var cfg = UseRgClone(); if (cfg == null) return f + 1; var pc = MakePC("i2_gc_pc", 1e12, 1e12); var boss = MakeBeater("i2_gc_boss", eSubRol.Boss); var pc0 = MyValue.MyPC; MyValue.MyPC = pc; var apply = ApplyToPc(pc); var dinfo = Dmg(boss, 10d); try { Survival.ResetDiagnostics(); Survival.Tick(0f); // 예열 for (int i = 0; i < 1000; i++) { dinfo.Damage = 10d; Survival.TakeDamage(pc, dinfo, apply); } for (int i = 0; i < 1000; i++) Survival.IsHitInvincible(pc); for (int i = 0; i < 1000; i++) RunGuide.DodgeDecision(cfg, true, true, true, 99f); GC.Collect(); long m0 = GC.GetTotalMemory(true); for (int i = 0; i < 200000; i++) { dinfo.Damage = 10d; Survival.TakeDamage(pc, dinfo, apply); } long m1 = GC.GetTotalMemory(false); for (int i = 0; i < 200000; i++) Survival.IsHitInvincible(pc); long m2 = GC.GetTotalMemory(false); for (int i = 0; i < 200000; i++) RunGuide.DodgeDecision(cfg, true, true, true, 99f); long m3 = GC.GetTotalMemory(false); bool h1 = m1 - m0 == 0; bool h2 = m2 - m1 == 0; bool h3 = m3 - m2 == 0; sb.AppendLine(P(h1) + " TakeDamage(무적 창 경로 포함) ×200,000 = " + (m1 - m0) + " byte"); if (!h1) f++; sb.AppendLine(P(h2) + " IsHitInvincible ×200,000 = " + (m2 - m1) + " byte"); if (!h2) f++; sb.AppendLine(P(h3) + " DodgeDecision ×200,000 = " + (m3 - m2) + " byte"); if (!h3) f++; // 대조군 — 의도적 할당이 잡히는지(계측 자체의 신뢰성) long m4 = GC.GetTotalMemory(false); var junk = new byte[65536]; junk[0] = 1; long m5 = GC.GetTotalMemory(false); bool h4 = m5 - m4 > 0; sb.AppendLine(P(h4) + " 대조군(64 KB 의도 할당) = " + (m5 - m4) + " byte(> 0 이어야 계측이 유효하다)"); if (!h4) f++; } finally { MyValue.MyPC = pc0; Kill(pc, boss); } return f; } // ───────────────────────────────── ⓘ C8 static int C8(StringBuilder sb) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓘ C8 롤백"); var st = UseClone(); if (st == null) return f + 1; st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; st.takeBossDamage = true; st.bossDamageMultiplier = 0.1f; st.unscaledDamageGuardEnabled = false; var pc = MakePC("i2_c8_pc", PcMaxHp, PcMaxHp); var boss = MakeBeater("i2_c8_boss", eSubRol.Boss); var pc0 = MyValue.MyPC; MyValue.MyPC = pc; try { // ① bossHitInvincibleSeconds = 0 → 813i 동작 100 %(5연타 5타 전부) st.hitInvincibleEnabled = true; st.bossHitInvincibleSeconds = 0f; Survival.ResetDiagnostics(); int landed = 0; for (int i = 0; i < 5; i++) { Survival.Tick(i * ComboInterval); double before = pc.Get_HP(); Survival.TakeDamage(pc, Dmg(boss, BossRawDamage), ApplyToPc(pc)); if (before - pc.Get_HP() > 1e-6) landed++; } bool i1 = landed == 5 && Survival.HitInvincibleOpened == 0 && Survival.BlockedHitInvincible == 0; sb.AppendLine(P(i1) + " bossHitInvincibleSeconds=0 → 5연타 " + landed + "타 전부 피격 · 창 0 = 813i 동작"); if (!i1) f++; // ② hitInvincibleEnabled = 0 → 같은 결과 st.bossHitInvincibleSeconds = 0.6f; st.hitInvincibleEnabled = false; var pcB = MakePC("i2_c8_pcB", PcMaxHp, PcMaxHp); MyValue.MyPC = pcB; Survival.ResetDiagnostics(); int landedB = 0; for (int i = 0; i < 5; i++) { Survival.Tick(i * ComboInterval); double before = pcB.Get_HP(); Survival.TakeDamage(pcB, Dmg(boss, BossRawDamage), ApplyToPc(pcB)); if (before - pcB.Get_HP() > 1e-6) landedB++; } bool i2 = landedB == 5 && Survival.HitInvincibleOpened == 0; sb.AppendLine(P(i2) + " hitInvincibleEnabled=0 → " + landedB + "타 전부 피격 · 창 0"); if (!i2) f++; Kill(pcB); // ③ 전체 스위치 off → 원본 100 %(배율조차 없다) WLSurvivalSettings.RuntimeOverride = -1; var pcC = MakePC("i2_c8_pcC", PcMaxHp, PcMaxHp); MyValue.MyPC = pcC; Survival.ResetDiagnostics(); Survival.Tick(0f); double h0 = pcC.Get_HP(); Survival.TakeDamage(pcC, Dmg(boss, 100d), ApplyAlways(pcC)); // SO off = 훅 무동작 → base 그대로 통과했는지만 본다 bool i3 = Math.Abs(h0 - pcC.Get_HP() - 100d) < 1e-6 && Survival.PassDisabled == 1 && Survival.HitInvincibleOpened == 0; sb.AppendLine(P(i3) + " SO off → 배율·창 전부 무동작(원 피해 100 그대로 · PassDisabled=" + Survival.PassDisabled + ")"); if (!i3) f++; Kill(pcC); WLSurvivalSettings.RuntimeOverride = 1; // ④ dodgeStepEnabled = 0 → 판정 자체가 kDodgeOff var cfg = UseRgClone(); if (cfg == null) return f + 1; cfg.dodgeStepEnabled = false; int d = RunGuide.DodgeDecision(cfg, true, true, true, 99f); RunGuide.ResetDodgeStateForProbe(); bool i4 = d == RunGuide.kDodgeOff && !RunGuide.ForceDodgeTick(2000f, 0.016f) && RunGuide.DodgeSteps == 0; sb.AppendLine(P(i4) + " dodgeStepEnabled=0 → 판정 " + Name(d) + " · 스텝 " + RunGuide.DodgeSteps); if (!i4) f++; cfg.dodgeStepEnabled = true; } finally { MyValue.MyPC = pc0; Kill(pc, boss); } return f; } // ───────────────────────────────── ⓙ 정리 static int Cleanup(StringBuilder sb, bool dirty0) { int f = 0; sb.AppendLine(); sb.AppendLine("## ⓙ 정리"); int leftovers = 0; var all = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < all.Length; i++) if (all[i] != null && all[i].name.StartsWith("__WL813i2")) leftovers++; var st = WLSurvivalSettings.Instance; bool assetBack = st != null && Math.Abs(st.bossHitInvincibleSeconds - 0.6f) < 1e-4 && Math.Abs(st.autoPotionHpRatio - 0.55f) < 1e-4 && st.hitInvincibleEnabled; var cfg = WLRunGuideSettings.Instance; bool cfgBack = cfg != null && cfg.dodgeStepEnabled && Math.Abs(cfg.dodgeStepDistance - 3f) < 1e-4; bool dirty = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty; bool ok = leftovers == 0 && assetBack && cfgBack && MyValue.MyPC == null && dirty == dirty0 && WLSurvivalSettings.RuntimeOverride == 0 && !WLRunGuideSettings.RuntimeDisabled; sb.AppendLine(P(ok) + " 임시 오브젝트 " + leftovers + " · 에셋 원복(Survival " + assetBack + " · RunGuide " + cfgBack + ") · MyPC=null " + (MyValue.MyPC == null) + " · 씬 dirty " + dirty + "(시작 " + dirty0 + ")" + " · Override " + WLSurvivalSettings.RuntimeOverride); if (!ok) f++; return f; } // ───────────────────────────────── 유틸 static string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; } static WLSurvivalSettings UseClone() { var orig = WLSurvivalSettings.Instance; if (orig == null) return null; if (s_clone == null) { s_clone = UnityEngine.Object.Instantiate(orig); s_clone.hideFlags = HideFlags.HideAndDontSave; s_clone.name = "WLSurvivalSettings(probe clone)"; } typeof(WLSurvivalSettings).GetField("s_cached", NS).SetValue(null, s_clone); typeof(WLSurvivalSettings).GetField("s_lookupDone", NS).SetValue(null, true); return s_clone; } static WLRunGuideSettings UseRgClone() { var orig = WLRunGuideSettings.Instance; if (orig == null) return null; if (s_rgClone == null) { s_rgClone = UnityEngine.Object.Instantiate(orig); s_rgClone.hideFlags = HideFlags.HideAndDontSave; s_rgClone.name = "WLRunGuideSettings(probe clone)"; } typeof(WLRunGuideSettings).GetField("s_cached", NS).SetValue(null, s_rgClone); typeof(WLRunGuideSettings).GetField("s_lookupDone", NS).SetValue(null, true); return s_rgClone; } static void RestoreAssets() { WLSurvivalSettings.ClearCache(); WLSurvivalSettings.RuntimeOverride = 0; WLRunGuideSettings.ClearCache(); WLRunGuideSettings.RuntimeDisabled = false; RunGuide.ResetDodgeStateForProbe(); RunGuide.ResetDiagnostics(); if (s_clone != null) { UnityEngine.Object.DestroyImmediate(s_clone); s_clone = null; } if (s_rgClone != null) { UnityEngine.Object.DestroyImmediate(s_rgClone); s_rgClone = null; } } static PCActor MakePC(string name, double maxHp, double hp) { var go = new GameObject("__WL813i2_" + name); var pc = go.AddComponent(); pc.m_Role = eRole.PC; pc.m_SubRole = eSubRol.None; var stat = new ActorStatInfo(eRole.PC); stat.Set_Stat(eStat.MaxHP, maxHp); stat.Set_Stat(eStat.HP, hp); typeof(Actor).GetField("m_Stat", NP).SetValue(pc, stat); typeof(Actor).GetField("m_Enemy", NP).SetValue(pc, false); return pc; } static Actor MakeBeater(string name, eSubRol sub) { var go = new GameObject("__WL813i2_" + name); var a = go.AddComponent(); a.m_Role = eRole.Mob; a.m_SubRole = sub; typeof(Actor).GetField("m_Enemy", NP).SetValue(a, true); return a; } static DamageInfo Dmg(Actor beater, double dmg) { var d = new DamageInfo(); d.Beater = beater; d.Damage = dmg; return d; } /// /// 가짜 base.Get_Damage = **원본 MyActor.Get_Damage(:301-309) 그대로**. /// `wlInvincible = IsRole(PC) && WLGameplaySettings.PlayerInvincible` 이면 base 를 건너뛰고(피해 0) /// 아니면 넘어온(배율 적용된) 피해만큼 PC HP 를 깎는다. /// 🔴 이 재현이 없으면 무적 창 경로를 검증할 수 없다 — 훅은 「플래그를 세운 채 base 를 부르는」 방식이라 /// base 를 모사한 쪽이 그 플래그를 안 보면 무적이 무적이 아니게 된다(1차 프로브에서 실제로 잡혔다). /// static Action ApplyToPc(PCActor pc) { return d => { if (WL.Settings.WLGameplaySettings.PlayerInvincible) return; // 원본 무적 경로 = 피해 0 if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage); }; } /// 플래그를 보지 않고 무조건 깎는다(플래그 자체를 검증하지 않는 절에서만 쓴다). static Action ApplyAlways(PCActor pc) { return d => { if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage); }; } /// 가짜 base.Get_Damage — 원본 무적 경로(피해 0). static Action NoDamage() { return d => { }; } static void Kill(params UnityEngine.Object[] objs) { foreach (var o in objs) { if (o == null) continue; var c = o as Component; UnityEngine.Object.DestroyImmediate(c != null ? c.gameObject : o); } } static void Write(string body) { try { string root = Path.GetDirectoryName(Application.dataPath); string dir = Path.Combine(root, OutDir); Directory.CreateDirectory(dir); File.WriteAllText(Path.Combine(dir, "probe_" + DateTime.Now.ToString("HHmmss") + ".txt"), body); File.WriteAllText(Path.Combine(root, OutCommit), body); } catch (Exception ex) { Debug.Log("[WL813i2] probe 파일 기록 실패: " + ex.Message); } } }