// PD 지시 #813 · 발주서 WL-811gh — 임팩트 티어 · PC 피격 림라이트/넉백 · 대시 잔상 검증 프로브 // (에디트 모드 전용 · Play 0 · 로그인 0 · 에셋 무수정) // // 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) // unity command run_script --file AgentScripts/WL811gh_Probe.cs --entry WL811gh_Probe.Edit --timeout 600 // // 무엇을 재는가 // A. 티어 4단 판정 — 가짜 HitConfirmed 4묶음(평타 · 크리 · 킬 · 보스킬) → 티어 인덱스 + 티어별 값 표 // (히트스톱 ms · 셰이크 배수 · 펀치 배수 · 점멸 색/시간 배수 · 초당 상한) // B. PC 피격 — 가짜 Damaged(PC) → MPB 플래시 on → 만료 후 off(잔류 0) + 넉백 벡터(방향·거리·NavMesh 가드) // C. 대시 잔상 — 가짜 Dash(began) → 고스트 3개 스폰 · 알파 페이드 · 풀 반환 · EffectBudget 반환 // D. GC — 각 경로 100회 반복의 GC.GetAllocatedBytesForCurrentThread() 증가분(0 B 목표) // E. C8 — WLImpactTierSettings.RuntimeDisabled = true(= 에셋 enabled 0 과 같은 Enabled 게이트) // → 티어 off(기존 WLHitFeel 2단 값) · 림라이트 0 · 고스트 0 // F. 소스 확인 — WLHitFeel.cs 가 티어를 읽는지 · MobHitFlash.cs 가 점멸 배수를 쓰는지 · 원본 diff 0 // // 에디트 모드 주의 // · [RuntimeInitializeOnLoadMethod] 는 Play 에서만 돈다 → 구독이 없다. 그래서 Probe* 헬퍼로 핸들러를 직접 부른다. // · 프로브가 만드는 오브젝트는 전부 HideAndDontSave + DestroyImmediate. NavMeshData 는 Add 후 Remove. // · 게임 수치는 전부 SO 에서 읽는다(C45). 이 파일은 어떤 에셋도 쓰지 않는다. public static class WL811gh_Probe { const string kOut = "AgentScripts/staging/WL811gh/EDIT_PROBE.txt"; const string kHitFeel = "Assets/WL/Feel/WLHitFeel.cs"; const string kBlink = "Assets/WL/Combat/Reaction/MobHitFlash.cs"; static System.Collections.Generic.List s_log = new System.Collections.Generic.List(); static bool s_hooked; static void OnLog(string cond, string stack, UnityEngine.LogType t) { if (t == UnityEngine.LogType.Error || t == UnityEngine.LogType.Exception || t == UnityEngine.LogType.Assert) s_log.Add(t + " | " + cond); } static void Hook() { if (!s_hooked) { UnityEngine.Application.logMessageReceived += OnLog; s_hooked = true; } } static void Unhook() { if (s_hooked) { UnityEngine.Application.logMessageReceived -= OnLog; s_hooked = false; } } // ── 프로브가 만든 임시 오브젝트 static UnityEngine.GameObject s_pcGo, s_mobGo, s_attackerGo, s_modelGo; static PCActor s_pc; static MobActor s_mob, s_bossMob, s_attacker; static UnityEngine.AI.NavMeshDataInstance s_navInstance; static bool s_navAdded; static string s_navPath = "(none)"; public static string Edit() { var sb = new System.Text.StringBuilder(); sb.Append("# WL-811gh 프로브 (에디트 모드 · Play 0 · 로그인 0)\n"); sb.Append("unity=").Append(UnityEngine.Application.unityVersion) .Append(" isPlaying=").Append(UnityEngine.Application.isPlaying) .Append(" utc=").Append(System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append("\n\n"); Hook(); s_log.Clear(); try { Section0(sb); BuildFakes(sb); SectionA(sb); SectionB(sb); SectionC(sb); SectionD(sb); SectionE(sb); } catch (System.Exception ex) { sb.Append("\n!! EXCEPTION ").Append(ex.ToString()).Append("\n"); } finally { Cleanup(); } Unhook(); SectionF(sb); sb.Append("\n## 프로브 실행 중 Error/Exception 로그: ").Append(s_log.Count).Append("\n"); for (int i = 0; i < s_log.Count && i < 20; i++) sb.Append(" ").Append(s_log[i]).Append("\n"); var dir = System.IO.Path.GetDirectoryName(kOut); if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); System.IO.File.WriteAllText(kOut, sb.ToString(), new System.Text.UTF8Encoding(false)); return sb.ToString(); } // ───────────────────────────────────────────────────────────────────────── // 0. SO 로드 // ───────────────────────────────────────────────────────────────────────── static void Section0(System.Text.StringBuilder sb) { sb.Append("## 0. 설정 SO\n"); var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; sb.Append("WLImpactTierSettings = ").Append(st != null ? "found" : "NULL") .Append(" · Enabled=").Append(WL.Combat.Reaction.WLImpactTierSettings.Enabled) .Append(" · ImpactTier.Active=").Append(WL.Combat.Reaction.ImpactTier.Active).Append("\n"); var feel = WL.Feel.WLHitFeel.Settings; sb.Append("WLHitFeelSettings = ").Append(feel != null ? "found" : "NULL"); if (feel != null) sb.Append(" · 기존 2단 hitStop=").Append(feel.hitStopDuration).Append("/").Append(feel.hitStopKillDuration) .Append(" shakeKillMul=").Append(feel.shakeKillMultiplier).Append(" shakeAmp=").Append(feel.shakeAmplitude); sb.Append("\n"); var core = WL.Combat.Core.WLCombatCoreSettings.Instance; sb.Append("WLCombatCoreSettings = ").Append(core != null ? "found" : "NULL"); if (core != null) sb.Append(" · afterImageMax=").Append(core.afterImageMax) .Append(" hitStopPerSecondMax=").Append(core.hitStopPerSecondMax) .Append(" layerMaxSeconds=").Append(core.layerMaxSeconds); sb.Append("\n"); var blink = WL.Combat.Reaction.WLHitBlinkSettings.Instance; sb.Append("WLHitBlinkSettings = ").Append(blink != null ? "found" : "NULL"); if (blink != null) sb.Append(" · blinkOnSeconds=").Append(blink.blinkOnSeconds).Append(" blinkColor=").Append(blink.blinkColor); sb.Append("\n\n"); } // ───────────────────────────────────────────────────────────────────────── // 가짜 액터 · NavMesh // ───────────────────────────────────────────────────────────────────────── static void BuildFakes(System.Text.StringBuilder sb) { sb.Append("## 준비: 가짜 액터 · NavMesh\n"); // 비활성 GameObject 에 붙이면 Awake 가 돌지 않아 서버 데이터·테이블 의존이 생기지 않는다. s_mobGo = new UnityEngine.GameObject("[811gh] mob"); s_mobGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; s_mobGo.SetActive(false); s_mob = s_mobGo.AddComponent(); s_mob.m_Role = eRole.Mob; s_mob.m_SubRole = eSubRol.None; var bossGo = new UnityEngine.GameObject("[811gh] boss"); bossGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; bossGo.SetActive(false); s_bossMob = bossGo.AddComponent(); s_bossMob.m_Role = eRole.Mob; s_bossMob.m_SubRole = eSubRol.Boss; s_bossMob.transform.SetParent(s_mobGo.transform, false); // Cleanup 한 번에 s_attackerGo = new UnityEngine.GameObject("[811gh] attacker(boss)"); s_attackerGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; s_attackerGo.SetActive(false); s_attacker = s_attackerGo.AddComponent(); s_attacker.m_Role = eRole.Mob; s_attacker.m_SubRole = eSubRol.Boss; // PC — SkinnedMeshRenderer 가 있어야 잔상을 구울 수 있다 → 프로젝트의 실제 캐릭터 프리팹을 붙여 실측한다. s_pcGo = new UnityEngine.GameObject("[811gh] pc"); s_pcGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; s_pcGo.SetActive(false); s_pc = s_pcGo.AddComponent(); s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; string modelPath = FindSkinnedPrefab(); if (!string.IsNullOrEmpty(modelPath)) { var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(modelPath); if (prefab != null) { s_modelGo = UnityEngine.Object.Instantiate(prefab, s_pcGo.transform); s_modelGo.hideFlags = UnityEngine.HideFlags.HideAndDontSave; } } var smrs = s_pcGo.GetComponentsInChildren(true); var rends = s_pcGo.GetComponentsInChildren(true); sb.Append("PC 모델 = ").Append(string.IsNullOrEmpty(modelPath) ? "(못 찾음)" : modelPath) .Append(" · SkinnedMeshRenderer=").Append(smrs.Length).Append(" · Renderer=").Append(rends.Length).Append("\n"); if (rends.Length > 0 && rends[0].sharedMaterial != null && rends[0].sharedMaterial.shader != null) sb.Append("PC 셰이더[0] = ").Append(rends[0].sharedMaterial.shader.name).Append("\n"); // NavMesh — 넉백의 NavMesh 가드를 실제로 측정하기 위해 프로젝트의 NavMeshData 를 임시로 올린다. AddNavMesh(sb); sb.Append("\n"); } static string FindSkinnedPrefab() { string[] roots = new string[] { "Assets/Res_Addr/PC", "Assets/Res_Addr", "Assets/ResWork" }; for (int r = 0; r < roots.Length; r++) { if (!UnityEditor.AssetDatabase.IsValidFolder(roots[r])) continue; var guids = UnityEditor.AssetDatabase.FindAssets("t:Prefab", new string[] { roots[r] }); for (int i = 0; i < guids.Length && i < 400; i++) { var p = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); var go = UnityEditor.AssetDatabase.LoadAssetAtPath(p); if (go == null) continue; var smr = go.GetComponentInChildren(true); if (smr != null && smr.sharedMesh != null) return p; } } return null; } static void AddNavMesh(System.Text.StringBuilder sb) { var guids = UnityEditor.AssetDatabase.FindAssets("t:NavMeshData", new string[] { "Assets/WL", "Assets" }); for (int i = 0; i < guids.Length && i < 40; i++) { var p = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[i]); var data = UnityEditor.AssetDatabase.LoadAssetAtPath(p); if (data == null) continue; s_navInstance = UnityEngine.AI.NavMesh.AddNavMeshData(data); if (s_navInstance.valid) { s_navAdded = true; s_navPath = p; break; } } UnityEngine.AI.NavMeshHit hit; bool any = UnityEngine.AI.NavMesh.SamplePosition(UnityEngine.Vector3.zero, out hit, 2000f, UnityEngine.AI.NavMesh.AllAreas); sb.Append("NavMeshData = ").Append(s_navPath).Append(" · added=").Append(s_navAdded) .Append(" · SamplePosition(0,0,0 r=2000)=").Append(any); if (any) { sb.Append(" → ").Append(hit.position.ToString("F2")); s_pcGo.transform.position = hit.position; s_attackerGo.transform.position = hit.position + new UnityEngine.Vector3(1f, 0f, 0f); // PC 의 +X 쪽에 서 있다 → 넉백은 -X } sb.Append("\n"); } static void Cleanup() { WL.Combat.Reaction.PcHitReaction.RestoreAll(); WL.Combat.Reaction.DashAfterImage.RestoreAll(); WL.Combat.Reaction.DashAfterImage.DestroyPool(); WL.Combat.Reaction.PcHitReaction.ClearAll(); WL.Combat.Reaction.DashAfterImage.ClearAll(); WL.Combat.Reaction.WLImpactTierSettings.RuntimeDisabled = false; if (s_navAdded) { UnityEngine.AI.NavMesh.RemoveNavMeshData(s_navInstance); s_navAdded = false; } DestroyGo(ref s_pcGo); DestroyGo(ref s_mobGo); DestroyGo(ref s_attackerGo); s_modelGo = null; s_pc = null; s_mob = null; s_bossMob = null; s_attacker = null; } static void DestroyGo(ref UnityEngine.GameObject go) { if (go != null) UnityEngine.Object.DestroyImmediate(go); go = null; } // ───────────────────────────────────────────────────────────────────────── // A. 티어 4단 + 티어별 값 // ───────────────────────────────────────────────────────────────────────── static void SectionA(System.Text.StringBuilder sb) { sb.Append("## A. 임팩트 티어 4단 (가짜 HitConfirmed 4묶음)\n"); WL.Combat.Reaction.ImpactTier.ResetDiagnostics(); var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; if (st == null) { sb.Append("SO 없음 — 건너뜀\n\n"); return; } // 평균 기준선을 만든다(평타만 적재된다 · damageAverageSamples 만큼). for (int i = 0; i < st.damageAverageSamples; i++) { WL.Combat.Reaction.ImpactTier.ClearStamp(); WL.Combat.Reaction.ImpactTier.Resolve(s_mob, MakeDinfo(100d, false, false, false), false); } sb.Append("평균 기준선 = ").Append(WL.Combat.Reaction.ImpactTier.Average.ToString("F1")) .Append(" (표본 ").Append(st.damageAverageSamples).Append("회 × 100 dmg)\n"); var t0 = Tier(s_mob, 100d, false, false, false); var t1 = Tier(s_mob, 100d, true, false, false); var t1b = Tier(s_mob, 100d, false, true, false); var t1c = Tier(s_mob, 400d, false, false, false); var t2 = Tier(s_mob, 100d, false, false, true); var t3 = Tier(s_bossMob, 100d, false, false, true); sb.Append("판정: 평타=").Append(t0).Append(" · 크리=").Append(t1).Append(" · 스킬=").Append(t1b) .Append(" · 평균×4(400dmg)=").Append(t1c).Append(" · 킬=").Append(t2).Append(" · 보스킬=").Append(t3).Append("\n"); sb.Append("기대: Light · Heavy · Heavy · Heavy · Kill · BossKill → ") .Append(t0 == WL.Combat.Reaction.ImpactTierKind.Light && t1 == WL.Combat.Reaction.ImpactTierKind.Heavy && t1b == WL.Combat.Reaction.ImpactTierKind.Heavy && t1c == WL.Combat.Reaction.ImpactTierKind.Heavy && t2 == WL.Combat.Reaction.ImpactTierKind.Kill && t3 == WL.Combat.Reaction.ImpactTierKind.BossKill ? "PASS" : "FAIL").Append("\n\n"); sb.Append("| 티어 | 히트스톱 ms | 셰이크 배수 | 셰이크 진폭(m) | 펀치 배수 | 실펀치 | 점멸 색 배수 | 점멸 시간(s) | 초당 상한 |\n"); sb.Append("|---|---|---|---|---|---|---|---|---|\n"); var feel = WL.Feel.WLHitFeel.Settings; var blink = WL.Combat.Reaction.WLHitBlinkSettings.Instance; for (int i = 0; i < 4; i++) { var k = (WL.Combat.Reaction.ImpactTierKind)i; float hs = WL.Combat.Reaction.ImpactTier.HitStopSeconds(k); float sm = WL.Combat.Reaction.ImpactTier.ShakeMultiplier(k); float pm = WL.Combat.Reaction.ImpactTier.PunchMultiplier(k); float bc = WL.Combat.Reaction.ImpactTier.BlinkColorMultiplier(k); float bt = WL.Combat.Reaction.ImpactTier.BlinkTimeMultiplier(k); float amp = feel != null ? feel.shakeAmplitude * sm : 0f; float realPunch = feel != null ? 1f + (UnityEngine.Mathf.Max(1f, feel.scalePunch) - 1f) * pm : 0f; float bTime = blink != null ? blink.blinkOnSeconds * bt : 0f; sb.Append("| ").Append(k).Append(" | ").Append((hs * 1000f).ToString("F0")) .Append(" | ").Append(sm.ToString("F2")).Append(" | ").Append(amp.ToString("F3")) .Append(" | ").Append(pm.ToString("F2")).Append(" | ").Append(realPunch.ToString("F3")) .Append(" | ").Append(bc.ToString("F2")).Append(" | ").Append(bTime.ToString("F3")) .Append(" | ").Append(st.tierMaxPerSecond != null && i < st.tierMaxPerSecond.Length ? st.tierMaxPerSecond[i].ToString("F0") : "-") .Append(" |\n"); } sb.Append("티어 카운트 Light/Heavy/Kill/BossKill = ") .Append(WL.Combat.Reaction.ImpactTier.TierCount[0]).Append("/") .Append(WL.Combat.Reaction.ImpactTier.TierCount[1]).Append("/") .Append(WL.Combat.Reaction.ImpactTier.TierCount[2]).Append("/") .Append(WL.Combat.Reaction.ImpactTier.TierCount[3]) .Append(" · 초당상한 강등=").Append(WL.Combat.Reaction.ImpactTier.DowngradedCount).Append("\n\n"); } /// 도장을 지우고 1건 판정한다(에디트 모드는 프레임이 멈춰 있어 메모이즈에 걸린다). static WL.Combat.Reaction.ImpactTierKind Tier(Actor victim, double dmg, bool crit, bool skill, bool kill) { WL.Combat.Reaction.ImpactTier.ClearStamp(); return WL.Combat.Reaction.ImpactTier.Resolve(victim, MakeDinfo(dmg, crit, skill, kill), kill); } static DamageInfo MakeDinfo(double dmg, bool critical, bool skill, bool kill) { var d = new DamageInfo(); d.Beater = s_pc; d.Damage = dmg; d.Critical = critical ? eStat.CRI : eStat.None; d.IsDeadByThisDamage = kill; if (skill) { var pd = new ProjectileData(); pd.m_SkillListTableData = new SkillListTableData(); d.Beater_pd = pd; } return d; } // ───────────────────────────────────────────────────────────────────────── // B. PC 피격 — MPB 플래시 + 넉백 // ───────────────────────────────────────────────────────────────────────── static void SectionB(System.Text.StringBuilder sb) { sb.Append("## B. PC 피격 (가짜 Damaged)\n"); WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; if (st == null) { sb.Append("SO 없음 — 건너뜀\n\n"); return; } var before = s_pcGo.transform.position; // ① 무적 피격 = 걸러진다(기준서 8-1 · 813tj HitVignette 의 ignoreInvincibleHits 와 같은 기본값) WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 0d, true); sb.Append("무적 피격 → flashOn=").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount) .Append(" (기대 0 · pcHitSkipWhenInvincible=").Append(st.pcHitSkipWhenInvincible).Append(")\n"); // ② 보스 피격 = 림라이트 + 넉백 WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); bool blockOn = WL.Combat.Reaction.PcHitReaction.HasAnyBlock(s_pc); sb.Append("보스 피격 → flashOn=").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount) .Append(" · MPB 붙음=").Append(blockOn) .Append(" · 넉백=").Append(WL.Combat.Reaction.PcHitReaction.KnockbackCount) .Append(" · NavMesh 스킵=").Append(WL.Combat.Reaction.PcHitReaction.SkippedNoNavMesh).Append("\n"); sb.Append("넉백 벡터 = ").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.ToString("F3")) .Append(" · 거리=").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackDistance.ToString("F3")) .Append("m (SO ").Append(st.pcHitKnockbackDistance).Append("m) · agentMove=") .Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackUsedAgent).Append("\n"); sb.Append("방향 검사(계획 벡터): 가해자 반대(-X) 기대 · x=") .Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.x.ToString("F3")) .Append(" → ").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.x < 0f ? "PASS" : "FAIL").Append("\n"); // 위 가짜 PC 는 **비활성**이라 Tick 의 생산 가드(!isActiveAndEnabled)가 이동을 즉시 접는다 → 실이동은 B-2 에서 잰다. float dur = UnityEngine.Mathf.Max(0.01f, st.pcHitKnockbackSeconds); for (int i = 0; i < 10; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(dur / 10f); sb.Append("비활성 PC 실이동 = ").Append((s_pcGo.transform.position - before).magnitude.ToString("F3")) .Append("m (기대 0 — 비활성 액터는 밀지 않는다 = 생산 가드)\n"); // 림라이트 만료 — flashSeconds 를 넘겨 틱하면 off 되고 MPB 가 떨어진다. double t0 = UnityEngine.Time.realtimeSinceStartupAsDouble; int guard = 0; while (WL.Combat.Reaction.PcHitReaction.FlashOffCount == 0 && guard++ < 2000 && UnityEngine.Time.realtimeSinceStartupAsDouble - t0 < st.pcHitFlashSeconds + 1.0) WL.Combat.Reaction.ImpactRunner.ForceTick(0.016f); bool blockOff = WL.Combat.Reaction.PcHitReaction.HasAnyBlock(s_pc); sb.Append("만료 → flashOff=").Append(WL.Combat.Reaction.PcHitReaction.FlashOffCount) .Append(" · MPB 잔류=").Append(blockOff).Append(" (기대 false)\n"); sb.Append("B-1 판정: ").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount == 1 && WL.Combat.Reaction.PcHitReaction.FlashOffCount >= 1 && !blockOff && WL.Combat.Reaction.PcHitReaction.LastKnockbackVector.x < 0f ? "PASS" : "FAIL").Append("\n"); SectionB2(sb, st); sb.Append("\n"); } // B-2. 활성 PC 로 넉백 **실이동** 측정(에이전트 경로 · transform 경로 둘 다) static void SectionB2(System.Text.StringBuilder sb, WL.Combat.Reaction.WLImpactTierSettings st) { sb.Append("### B-2. 넉백 실이동 (활성 PC · 최소 구성)\n"); int errBefore = s_log.Count; UnityEngine.GameObject go = null; try { go = new UnityEngine.GameObject("[811gh] pc2"); go.hideFlags = UnityEngine.HideFlags.HideAndDontSave; go.SetActive(false); UnityEngine.AI.NavMeshHit nh; if (UnityEngine.AI.NavMesh.SamplePosition(UnityEngine.Vector3.zero, out nh, 2000f, UnityEngine.AI.NavMesh.AllAreas)) go.transform.position = nh.position; var cube = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube); cube.transform.SetParent(go.transform, false); var agent = go.AddComponent(); // Actor.Awake 가 m_NavMeshAgent.speed 를 읽는다 var pc2 = go.AddComponent(); pc2.m_Role = eRole.PC; pc2.m_SubRole = eSubRol.None; go.SetActive(true); // 여기서 원본 Actor.Awake 가 돈다 s_attackerGo.transform.position = go.transform.position + new UnityEngine.Vector3(1f, 0f, 0f); // ① 에이전트 경로(SO 기본 pcHitKnockbackUseAgentMove=1) WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); var p0 = go.transform.position; WL.Combat.Reaction.PcHitReaction.ProbeDamaged(pc2, s_attacker, 120d, false); bool usedAgent = WL.Combat.Reaction.PcHitReaction.LastKnockbackUsedAgent; float plan = WL.Combat.Reaction.PcHitReaction.LastKnockbackDistance; float step = UnityEngine.Mathf.Max(0.01f, st.pcHitKnockbackSeconds) / 10f; for (int i = 0; i < 12; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(step); var mv1 = go.transform.position - p0; sb.Append("① agentMove=").Append(usedAgent).Append(" · isOnNavMesh=").Append(agent.isOnNavMesh) .Append(" · 계획=").Append(plan.ToString("F3")).Append("m · 실이동=").Append(mv1.ToString("F3")) .Append(" 길이=").Append(mv1.magnitude.ToString("F3")).Append("m\n"); // ② transform 경로(에이전트를 끄면 SO 스위치 pcHitKnockbackUseAgentMove=0 과 같은 분기) agent.enabled = false; WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); var p1 = go.transform.position; WL.Combat.Reaction.PcHitReaction.ProbeDamaged(pc2, s_attacker, 120d, false); float plan2 = WL.Combat.Reaction.PcHitReaction.LastKnockbackDistance; for (int i = 0; i < 12; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(step); var mv2 = go.transform.position - p1; sb.Append("② agentMove=").Append(WL.Combat.Reaction.PcHitReaction.LastKnockbackUsedAgent) .Append(" · 계획=").Append(plan2.ToString("F3")).Append("m · 실이동=").Append(mv2.ToString("F3")) .Append(" 길이=").Append(mv2.magnitude.ToString("F3")).Append("m\n"); sb.Append("B-2 판정(둘 중 하나라도 계획 거리 ±10% 이내 · 방향 -X): ") .Append(((Near(mv1.magnitude, plan) && mv1.x < 0f) || (Near(mv2.magnitude, plan2) && mv2.x < 0f)) ? "PASS" : "FAIL").Append("\n"); } catch (System.Exception ex) { sb.Append("B-2 예외: ").Append(ex.Message).Append("\n"); } finally { if (go != null) UnityEngine.Object.DestroyImmediate(go); } sb.Append("B-2 구간 Error/Exception 로그 = ").Append(s_log.Count - errBefore) .Append(" (원본 Actor.Awake 를 최소 구성으로 돌린 구간 · 그 외 구간과 분리해 센다)\n"); } static bool Near(float a, float b) { return b > 0.0001f && UnityEngine.Mathf.Abs(a - b) <= b * 0.1f; } // ───────────────────────────────────────────────────────────────────────── // C. 대시 잔상 // ───────────────────────────────────────────────────────────────────────── static void SectionC(System.Text.StringBuilder sb) { sb.Append("## C. 대시 잔상 (가짜 Dash began)\n"); WL.Combat.Reaction.DashAfterImage.ResetDiagnostics(); WL.Combat.Core.EffectBudget.ResetAll(); var st = WL.Combat.Reaction.WLImpactTierSettings.Instance; if (st == null) { sb.Append("SO 없음 — 건너뜀\n\n"); return; } WL.Combat.Reaction.DashAfterImage.ProbeDashBegan(s_pc); sb.Append("예약 = ").Append(WL.Combat.Reaction.DashAfterImage.PendingCount) .Append(" (SO ghostCount=").Append(st.ghostCount).Append(")\n"); WL.Combat.Reaction.DashAfterImage.ProbeFlushPending(s_pc); sb.Append("스폰 = ").Append(WL.Combat.Reaction.DashAfterImage.SpawnCount) .Append(" · 활성=").Append(WL.Combat.Reaction.DashAfterImage.ActiveCount) .Append(" · 풀=").Append(WL.Combat.Reaction.DashAfterImage.PoolSize) .Append(" · BakeMesh=").Append(WL.Combat.Reaction.DashAfterImage.BakeCount).Append("\n"); sb.Append("스킵: 예산=").Append(WL.Combat.Reaction.DashAfterImage.SkippedBudget) .Append(" 화면밖=").Append(WL.Combat.Reaction.DashAfterImage.SkippedOffscreen) .Append(" 렌더러없음=").Append(WL.Combat.Reaction.DashAfterImage.SkippedNoRenderer) .Append(" 셰이더없음=").Append(WL.Combat.Reaction.DashAfterImage.SkippedNoShader) .Append(" 풀참=").Append(WL.Combat.Reaction.DashAfterImage.SkippedPoolFull).Append("\n"); sb.Append("머티리얼 = ").Append(WL.Combat.Reaction.DashAfterImage.MaterialReady ? WL.Combat.Reaction.DashAfterImage.MaterialShaderName : "(없음)") .Append(" · 런타임 머티리얼 수=").Append(WL.Combat.Reaction.DashAfterImage.MaterialReady ? 1 : 0) .Append(" · 색=").Append(WL.Combat.Reaction.DashAfterImage.LastColor.ToString("F2")).Append("\n"); sb.Append("EffectBudget(AfterImage) 사용=").Append(WL.Combat.Core.EffectBudget.Count(WL.Combat.Core.EffectBudgetKind.AfterImage)) .Append("/").Append(WL.Combat.Core.EffectBudget.Limit(WL.Combat.Core.EffectBudgetKind.AfterImage)) .Append(" · 거부=").Append(WL.Combat.Core.EffectBudget.DeniedCount).Append("\n"); // 페이드 → 반환 double t0 = UnityEngine.Time.realtimeSinceStartupAsDouble; int guard = 0; while (WL.Combat.Reaction.DashAfterImage.ActiveCount > 0 && guard++ < 4000 && UnityEngine.Time.realtimeSinceStartupAsDouble - t0 < st.ghostFadeSeconds + 1.0) WL.Combat.Reaction.ImpactRunner.ForceTick(0.016f); sb.Append("페이드 후 활성=").Append(WL.Combat.Reaction.DashAfterImage.ActiveCount) .Append(" · 반환=").Append(WL.Combat.Reaction.DashAfterImage.ReleasedCount) .Append(" · 예산 잔여=").Append(WL.Combat.Core.EffectBudget.Count(WL.Combat.Core.EffectBudgetKind.AfterImage)).Append("\n"); int expect = UnityEngine.Mathf.Min(st.ghostCount, WL.Combat.Core.EffectBudget.Limit(WL.Combat.Core.EffectBudgetKind.AfterImage)); sb.Append("판정: 스폰 ").Append(WL.Combat.Reaction.DashAfterImage.SpawnCount).Append("/기대 ").Append(expect) .Append(" · 활성 0 · 예산 0 → ") .Append(WL.Combat.Reaction.DashAfterImage.SpawnCount == expect && WL.Combat.Reaction.DashAfterImage.ActiveCount == 0 && WL.Combat.Core.EffectBudget.Count(WL.Combat.Core.EffectBudgetKind.AfterImage) == 0 ? "PASS" : "FAIL").Append("\n\n"); } // ───────────────────────────────────────────────────────────────────────── // D. GC 100회 // ───────────────────────────────────────────────────────────────────────── static void SectionD(System.Text.StringBuilder sb) { sb.Append("## D. GC (100회 반복 · GC.GetAllocatedBytesForCurrentThread)\n"); var dinfo = MakeDinfo(100d, false, false, false); // 워밍업 — 첫 호출의 캐시(상태 dict · MPB · 셰이더 실측 · 풀·머티리얼)는 제외한다. for (int i = 0; i < 5; i++) { WL.Combat.Reaction.ImpactTier.ClearStamp(); WL.Combat.Reaction.ImpactTier.Resolve(s_mob, dinfo, false); } long a0 = System.GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 100; i++) { WL.Combat.Reaction.ImpactTier.ClearStamp(); WL.Combat.Reaction.ImpactTier.Resolve(s_mob, dinfo, false); } long a1 = System.GC.GetAllocatedBytesForCurrentThread(); sb.Append("ImpactTier.Resolve ×100 = ").Append(a1 - a0).Append(" B\n"); for (int i = 0; i < 5; i++) { WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); WL.Combat.Reaction.ImpactRunner.ForceTick(0.5f); } long b0 = System.GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 100; i++) { WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); WL.Combat.Reaction.ImpactRunner.ForceTick(0.5f); } long b1 = System.GC.GetAllocatedBytesForCurrentThread(); sb.Append("PcHitReaction 피격+틱 ×100 = ").Append(b1 - b0).Append(" B\n"); long c0 = System.GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 100; i++) WL.Combat.Reaction.ImpactRunner.ForceTick(0.016f); long c1 = System.GC.GetAllocatedBytesForCurrentThread(); sb.Append("ImpactRunner.ForceTick ×100 = ").Append(c1 - c0).Append(" B\n"); // 잔상 — 풀·머티리얼이 이미 만들어진 뒤(2회차부터)의 스폰·반환 할당 for (int i = 0; i < 3; i++) { WL.Combat.Reaction.DashAfterImage.ForceSpawn(s_pc); WL.Combat.Reaction.DashAfterImage.ProbeExpireAll(); } long d0 = System.GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 100; i++) { WL.Combat.Reaction.DashAfterImage.ForceSpawn(s_pc); WL.Combat.Reaction.DashAfterImage.ProbeExpireAll(); } long d1 = System.GC.GetAllocatedBytesForCurrentThread(); sb.Append("DashAfterImage 스폰+반환 ×100 = ").Append(d1 - d0).Append(" B (BakeMesh 내부 할당 포함)\n"); sb.Append("판정(티어·틱 0 B): ").Append((a1 - a0) == 0 && (c1 - c0) == 0 ? "PASS" : "FAIL").Append("\n\n"); } // ───────────────────────────────────────────────────────────────────────── // E. C8 롤백 // ───────────────────────────────────────────────────────────────────────── static void SectionE(System.Text.StringBuilder sb) { sb.Append("## E. C8 롤백 (RuntimeDisabled = 에셋 enabled 0 과 같은 Enabled 게이트)\n"); WL.Combat.Reaction.WLImpactTierSettings.RuntimeDisabled = true; WL.Combat.Reaction.ImpactTier.ResetDiagnostics(); WL.Combat.Reaction.PcHitReaction.ResetDiagnostics(); WL.Combat.Reaction.DashAfterImage.ResetDiagnostics(); WL.Combat.Reaction.DashAfterImage.RestoreAll(); sb.Append("ImpactTier.Active = ").Append(WL.Combat.Reaction.ImpactTier.Active).Append(" (기대 false)\n"); var t = Tier(s_bossMob, 100d, true, true, true); sb.Append("보스킬 상황 판정 = ").Append(t).Append(" (기대 Light = 티어 미사용)\n"); WL.Combat.Reaction.PcHitReaction.ProbeDamaged(s_pc, s_attacker, 120d, false); sb.Append("PC 피격 → flashOn=").Append(WL.Combat.Reaction.PcHitReaction.FlashOnCount) .Append(" 넉백=").Append(WL.Combat.Reaction.PcHitReaction.KnockbackCount).Append(" (기대 0/0)\n"); WL.Combat.Reaction.DashAfterImage.ProbeDashBegan(s_pc); WL.Combat.Reaction.DashAfterImage.ProbeFlushPending(s_pc); sb.Append("대시 → 스폰=").Append(WL.Combat.Reaction.DashAfterImage.SpawnCount) .Append(" 활성=").Append(WL.Combat.Reaction.DashAfterImage.ActiveCount).Append(" (기대 0/0)\n"); // 기존 2단(WLHitFeel) 값이 그대로 살아 있는가 var feel = WL.Feel.WLHitFeel.Settings; if (feel != null) sb.Append("기존 2단 유지: hitStop=").Append(feel.hitStopDuration).Append("/").Append(feel.hitStopKillDuration) .Append(" · shakeKillMul=").Append(feel.shakeKillMultiplier).Append(" · hitStopMax=").Append(feel.hitStopMax).Append("\n"); bool pass = !WL.Combat.Reaction.ImpactTier.Active && t == WL.Combat.Reaction.ImpactTierKind.Light && WL.Combat.Reaction.PcHitReaction.FlashOnCount == 0 && WL.Combat.Reaction.PcHitReaction.KnockbackCount == 0 && WL.Combat.Reaction.DashAfterImage.SpawnCount == 0; sb.Append("판정: ").Append(pass ? "PASS" : "FAIL").Append("\n"); WL.Combat.Reaction.WLImpactTierSettings.RuntimeDisabled = false; sb.Append("\n"); } // ───────────────────────────────────────────────────────────────────────── // F. 소스 확인 // ───────────────────────────────────────────────────────────────────────── static void SectionF(System.Text.StringBuilder sb) { sb.Append("## F. 소스 확인 (원본 훅 0 · 배선)\n"); string feel = ReadAll(kHitFeel); string blink = ReadAll(kBlink); sb.Append("WLHitFeel.cs ImpactTier.Resolve 호출 = ").Append(Has(feel, "ImpactTier.Resolve")).Append("\n"); sb.Append("WLHitFeel.cs ImpactTier.HitStopSeconds = ").Append(Has(feel, "ImpactTier.HitStopSeconds")).Append("\n"); sb.Append("WLHitFeel.cs ImpactTier.ShakeMultiplier = ").Append(Has(feel, "ImpactTier.ShakeMultiplier")).Append("\n"); sb.Append("WLHitFeel.cs ImpactTier.PunchMultiplier = ").Append(Has(feel, "ImpactTier.PunchMultiplier")).Append("\n"); sb.Append("WLHitFeel.cs 기존 2단 폴백 보존(hitStopKillDuration) = ").Append(Has(feel, "st.hitStopKillDuration")).Append("\n"); sb.Append("MobHitFlash.cs 점멸 배수 적용(GetBlinkModifiers) = ").Append(Has(blink, "ImpactTier.GetBlinkModifiers")).Append("\n"); string[] originals = new string[] { "Assets/Script/Character/Actor.cs", "Assets/Script/Character/PCActor.cs", "Assets/Script/Character/MyActor.cs", "Assets/Script/Character/Mob/MobActor.cs" }; for (int i = 0; i < originals.Length; i++) { string src = ReadAll(originals[i]); sb.Append(System.IO.Path.GetFileName(originals[i])).Append(": 811gh 문자열 = ") .Append(src != null && src.Contains("811gh") ? "있음(!)" : "없음").Append("\n"); } sb.Append("\n"); } static string ReadAll(string p) { return System.IO.File.Exists(p) ? System.IO.File.ReadAllText(p) : null; } static string Has(string src, string needle) { return src != null && src.Contains(needle) ? "있음" : "없음(!)"; } }