From 02a6336298454a1384dc2226744e52a1f42249aa Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 19:04:07 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813m3]=20=EC=82=AC=EB=A7=9D=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=EC=A7=84=EB=8B=A8=20=EB=A1=9C=EA=B7=B8=20=C2=B7=20?= =?UTF-8?q?=ED=94=BC=ED=95=B4=20=EA=B2=BD=EB=A1=9C=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=C2=B7=20=EB=B9=88=20=EB=A8=B8=ED=8B=B0=EB=A6=AC=EC=96=BC=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL813m3_PROBE.txt | 74 +++ AgentScripts/WL813m3_Probe.cs | 600 ++++++++++++++++++ .../WL/Combat/Reaction/NullMaterialGuard.cs | 220 +++++++ .../Combat/Reaction/NullMaterialGuard.cs.meta | 11 + .../Reaction/NullMaterialGuardRunner.cs | 41 ++ .../Reaction/NullMaterialGuardRunner.cs.meta | 11 + .../Combat/Reaction/WLNullMaterialSettings.cs | 108 ++++ .../Reaction/WLNullMaterialSettings.cs.meta | 11 + .../Resources/WL/WLNullMaterialSettings.asset | 26 + .../WL/WLNullMaterialSettings.asset.meta | 8 + .../Resources/WL/WLSurvivalSettings.asset | 4 + Assets/WL/Combat/Survival/DamageGuard.cs | 183 ++++++ Assets/WL/Combat/Survival/DamageGuard.cs.meta | 11 + Assets/WL/Combat/Survival/DeathDiag.cs | 259 ++++++++ Assets/WL/Combat/Survival/DeathDiag.cs.meta | 11 + Assets/WL/Combat/Survival/Survival.cs | 41 +- .../WL/Combat/Survival/WLSurvivalSettings.cs | 19 + 17 files changed, 1635 insertions(+), 3 deletions(-) create mode 100644 AgentScripts/WL813m3_PROBE.txt create mode 100644 AgentScripts/WL813m3_Probe.cs create mode 100644 Assets/WL/Combat/Reaction/NullMaterialGuard.cs create mode 100644 Assets/WL/Combat/Reaction/NullMaterialGuard.cs.meta create mode 100644 Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs create mode 100644 Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs.meta create mode 100644 Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs create mode 100644 Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs.meta create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset.meta create mode 100644 Assets/WL/Combat/Survival/DamageGuard.cs create mode 100644 Assets/WL/Combat/Survival/DamageGuard.cs.meta create mode 100644 Assets/WL/Combat/Survival/DeathDiag.cs create mode 100644 Assets/WL/Combat/Survival/DeathDiag.cs.meta diff --git a/AgentScripts/WL813m3_PROBE.txt b/AgentScripts/WL813m3_PROBE.txt new file mode 100644 index 000000000..414f06d9c --- /dev/null +++ b/AgentScripts/WL813m3_PROBE.txt @@ -0,0 +1,74 @@ +# WL813m3 Probe 2026-09-09 19:01:01 (edit mode · Play 0) + +## ⓐ 에셋 실측 +[PASS] WLSurvivalSettings 로드 OK +[PASS] 신규 4필드 deathDiagEnabled=True dir=Screenshots_WL/WL813m3 guard=True guardLog=False +[PASS] 재사용 배율(기존 필드) mob=0.1 boss=0.15 (F-16 값) +[PASS] WLNullMaterialSettings 로드 OK +[PASS] NullMaterial 12필드 enabled=True action=DisableRenderer interval=0.5 maxPerSweep=24 roots(fx/objs/spectacle/pc)=True/True/True/True + +## ⓑ DeathDiag 링버퍼 16 +[PASS] 기록 20건 · RecordCount=20 +[PASS] 마지막 16건만 보존(raw 5~20 · 1~4 소멸) +[PASS] Unscaled 누계 5건 60 HP + +## ⓒ 사망 파일 · 콘솔 1줄 +[PASS] 파일 생성 E:\NerdNavis\WL_wt\gameplay2\Screenshots_WL/WL813m3\death_190101_7.txt +[PASS] 내용 25줄 · death #7 · via 열(Scaled/UNSCALED) 포함 +[PASS] 콘솔 1줄 = [WL813m3] death #7 t=0.0 last16 scaled=12(144hp) unscaled=4(56hp) potion=4/4 file=E:\NerdNavis\WL_wt\gameplay2\Screenshots_WL/WL813m3\death_190101_7.txt +[PASS] verboseLog=0 에서도 파일·1줄 생성 + +## ⓓ 배율 적용 표(PCActor.Get_Damage 훅 경유 · HookHpLoss 실측) +| 가해자 | 원 피해 | 배율 | 훅 후 HP 감소(실측) | DeathDiag via | +|---|---|---|---|---| +| 잡몹 | 100 | ×0.10 | 10.0 | Scaled | +[PASS] 잡몹 배율 적용 + 누계 반영 +| 보스 | 100 | ×0.15 | 15.0 | Scaled(boss) | +[PASS] 보스 배율 적용 + 가해자 종류 기록 +| 잡몹(스위치 off) | 100 | — | 0.0 | SwitchOff | +[PASS] 스위치 off = 원본 무적 그대로 · via=SwitchOff +| 잡몹(부활 무적 창) | 100 | — | 0.0 | ReviveInv | +[PASS] 부활 무적 창 = 피해 0 · via=ReviveInv + +## ⓔⓕ 배율 미적용 경로 감지 · 환급(도트/Cal_Damage 직접 호출 재현) +[PASS] 첫 폴링 = 기준선만 잡는다(감지 0) +[PASS] 감지 1건 · 미적용 피해 40.0 +[PASS] 환급 40×(1−0.10)=36 → HP 160 → 196.0 (순 피해 4 = 40×0.10) +[PASS] DeathDiag via=Unscaled 기록 raw=40.0 +[PASS] 훅 경유 30 감소 → 감지 0건(기대 0) · HP 166.0 +[PASS] 회복(+100) → 감지 0 · HP 266.0 +[PASS] SetReviveInvincible → Rebase 1회 + +## ⓖ NullMaterialGuard — 빈 슬롯 렌더러 비활성 +[PASS] 결함 렌더러 1 · 비활성 1 · 결함 오브젝트 1 +[PASS] 빈 슬롯 렌더러만 enabled=false(정상 렌더러 2개 무변경) +[PASS] 경로 표본 = __WL813m3_bad/Pos +[PASS] 캐시 = 2회차 재검사 0(스폰 1회 검사 · 캐시 2) +[PASS] MeasureOnly = 계측 1 · 비활성 0(A/B 비교용) +[PASS] 에디트 모드 러너 생성 0 + +## ⓗ Q9 §6 5종 프리팹 실측(디스크 원본 · 수정 0) = 아트 수정 목록 +| # | 프리팹 | 렌더러 수 | 🔴 빈 슬롯 렌더러(경로/슬롯) | +|---|---|---|---| +| 1 | Assets/Res_Addr/Effect/Effect_Hit_10501.prefab | 6 | 🔴 1건 — Effect_Hit_10501/Pos[slot 0] | +| 2 | Assets/Res_Addr/Effect/Effect_Hit_AnubisSkill2.prefab | 9 | 🔴 1건 — Effect_Hit_AnubisSkill2/vfx_Hit_Orb08_Orange/Light[slot 0] | +| 3 | Assets/Res_Addr/Effect/Effect_Poison.prefab | 5 | 🔴 1건 — Effect_Poison/GameObject[slot 0] | +| 4 | Assets/Res_Addr/SelectSkill/Select_WindFairy.prefab | 8 | 🔴 4건 — Select_WindFairy/PowerOrbSciFi05[slot 0] · Select_WindFairy/PowerOrbSciFi05/Sparks[slot 0] · Select_WindFairy/PowerOrbSciFi05/Sparks[slot 1] · Select_WindFairy/PowerOrbSciFi05/OrbGlow[slot 0] | +| 5 | Assets/Res_Addr/Effect/Mob/Hit_P_Batty_Meele.prefab | 7 | 🔴 1건 — Hit_P_Batty_Meele/FX_Yellow_Hit/Pos[slot 0] | +→ 5종 중 **5종**에서 빈 머티리얼 슬롯 실측(디스크 부재 0종). 근본 수리 = 아트/이펙트 발주(프리팹 슬롯 채우기 또는 그 렌더러 삭제). + +## ⓘ GC 0(핫 경로) +[PASS] DeathDiag.Record ×200k · mono Δ0 B · total Δ0 B · gen0 0 +[PASS] DamageGuard.Poll ×200k(변화 없음) · mono Δ0 B · total Δ0 B · gen0 0 +[PASS] NullMaterialGuard.Inspect ×200k(캐시 히트) · mono Δ0 B · total Δ0 B · gen0 0 + +## ⓙ C8 롤백 +[PASS] SO off(RuntimeOverride=-1) → 가드 무동작 · HP 150.0(환급 0) +[PASS] unscaledDamageGuardEnabled=0 → 무동작 · HP 100.0 +[PASS] deathDiagEnabled=0 → 기록 0 · 파일 0 +[PASS] NullMaterial off → 검사 0 · 스윕 0 · 렌더러 무변경 · 러너 0 + +## ⓚ 정리 +[PASS] 임시 오브젝트 0 · 에셋 원복 True · timeScale 1 · MyPC=null True · 러너 0 True · 씬 dirty False(시작 False) + +RESULT PASS diff --git a/AgentScripts/WL813m3_Probe.cs b/AgentScripts/WL813m3_Probe.cs new file mode 100644 index 000000000..997ddb2d7 --- /dev/null +++ b/AgentScripts/WL813m3_Probe.cs @@ -0,0 +1,600 @@ +// WL813m3_Probe.cs — #813m3(사망 원인 진단 · 배율 미적용 경로 가드 · 빈 머티리얼 가드) 에디트 모드 검증 프로브 +// +// 실행: unity command run_script --project-path --file AgentScripts/WL813m3_Probe.cs --entry WL813m3_Probe.RunAll +// 결과: Console + AgentScripts/WL813m3_PROBE.txt(커밋) + Screenshots_WL/WL813m3/probe_.txt +// +// ⓐ 에셋 실측(WLSurvivalSettings 신규 4필드 · WLNullMaterialSettings 12필드) +// ⓑ DeathDiag 링버퍼 16(20건 기록 → 마지막 16건만 남는가 · 순서) +// ⓒ 사망 파일 생성(Screenshots_WL/WL813m3/death_*.txt) + 콘솔 1줄 +// ⓓ 배율 적용 표(Survival.TakeDamage 경유 = HookHpLoss 실측 · DeathDiag via=Scaled) +// ⓔ 배율 미적용 경로 감지·환급(훅 없이 HP 를 깎으면 DamageGuard 가 잡아 (1−배율) 만큼 되돌리는가) +// ⓕ 훅을 거친 감소는 오검출 0 +// ⓖ NullMaterialGuard — 빈 슬롯 렌더러 비활성 · 정상 렌더러 무변경 · 캐시(2회차 무동작) +// ⓗ 🔴 Q9 §6 의 5종 프리팹 실측(디스크 원본 · 읽기만) — 어느 렌더러의 어느 슬롯이 null 인가 = 아트 수정 목록 +// ⓘ GC 0(Record ×200k · Poll ×200k · Inspect 캐시 히트 ×200k) +// ⓙ C8(스위치 off → 전부 무동작 · 러너 0) +// ⓚ 정리(임시 오브젝트 0 · MyPC 원복 · timeScale 원복 · 씬 dirty 0 · 에셋 캐시 원복 · 프리팹 무수정) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using UnityEngine; +using WL.Combat.Survival; +using WL.Combat.Reaction; + +public static class WL813m3_Probe +{ + const string OutDir = "Screenshots_WL/WL813m3"; + const string OutCommit = "AgentScripts/WL813m3_PROBE.txt"; + const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance; + const BindingFlags NS = BindingFlags.NonPublic | BindingFlags.Static; + + static WLSurvivalSettings s_clone; + static WLNullMaterialSettings s_nmClone; + + // Q9 §6 실측 경로 5종(런타임 클론 이름 → 디스크 프리팹) + static readonly string[] Q9Prefabs = + { + "Assets/Res_Addr/Effect/Effect_Hit_10501.prefab", + "Assets/Res_Addr/Effect/Effect_Hit_AnubisSkill2.prefab", + "Assets/Res_Addr/Effect/Effect_Poison.prefab", + "Assets/Res_Addr/SelectSkill/Select_WindFairy.prefab", + "Assets/Res_Addr/Effect/Mob/Hit_P_Batty_Meele.prefab", + }; + + public static object RunAll() + { + var sb = new StringBuilder(); + sb.AppendLine("# WL813m3 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)"); + int fail = 0; + float ts0 = Time.timeScale; + 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 += Ring(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓑ 예외 " + ex); } + try { fail += DeathFile(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓒ 예외 " + ex); } + try { fail += HookTable(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓓ 예외 " + ex); } + try { fail += GuardDetect(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓔⓕ 예외 " + ex); } + try { fail += NullMat(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓖ 예외 " + ex); } + try { fail += Q9Scan(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; + Time.timeScale = ts0; + try { fail += Cleanup(sb, ts0, 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.deathDiagEnabled && st.deathDiagDir == "Screenshots_WL/WL813m3" && st.unscaledDamageGuardEnabled && !st.unscaledDamageGuardLog; + sb.AppendLine(P(a2) + " 신규 4필드 deathDiagEnabled=" + st.deathDiagEnabled + " dir=" + st.deathDiagDir + + " guard=" + st.unscaledDamageGuardEnabled + " guardLog=" + st.unscaledDamageGuardLog); if (!a2) f++; + bool a3 = Math.Abs(st.mobDamageMultiplier - 0.1f) < 1e-4 && Math.Abs(st.bossDamageMultiplier - 0.15f) < 1e-4; + sb.AppendLine(P(a3) + " 재사용 배율(기존 필드) mob=" + st.mobDamageMultiplier + " boss=" + st.bossDamageMultiplier + " (F-16 값)"); if (!a3) f++; + + var nm = WLNullMaterialSettings.Instance; + bool a4 = nm != null; + sb.AppendLine(P(a4) + " WLNullMaterialSettings 로드 " + (a4 ? "OK" : "null")); if (!a4) return f + 1; + bool a5 = nm.enabled && nm.action == NullMaterialAction.DisableRenderer && Math.Abs(nm.sweepIntervalSeconds - 0.5f) < 1e-4 + && nm.maxInspectPerSweep == 24 && nm.inspectCacheMax == 4096 && nm.sweepIngameEffects && nm.sweepIngameObjs + && nm.sweepSkillSpectacle && nm.sweepMainPc && nm.pathSampleMax == 16 && !nm.treatEmptyArrayAsNull && !nm.verboseLog; + sb.AppendLine(P(a5) + " NullMaterial 12필드 enabled=" + nm.enabled + " action=" + nm.action + " interval=" + nm.sweepIntervalSeconds + + " maxPerSweep=" + nm.maxInspectPerSweep + " roots(fx/objs/spectacle/pc)=" + nm.sweepIngameEffects + "/" + nm.sweepIngameObjs + + "/" + nm.sweepSkillSpectacle + "/" + nm.sweepMainPc); if (!a5) f++; + return f; + } + + // ───────────────────────────────── ⓑ 링버퍼 16 + static int Ring(StringBuilder sb) + { + int f = 0; + sb.AppendLine(); sb.AppendLine("## ⓑ DeathDiag 링버퍼 16"); + DeathDiag.ResetDiagnostics(); + for (int i = 1; i <= 20; i++) + DeathDiag.Record(i % 4 == 0 ? DamageVia.Unscaled : DamageVia.Scaled, null, i, 0.1d, i, 310d - i, 310d); + + bool b1 = DeathDiag.RecordCount == 20; + sb.AppendLine(P(b1) + " 기록 20건 · RecordCount=" + DeathDiag.RecordCount); if (!b1) f++; + + // 링 16칸에는 5~20번만 남아야 한다 + var seen = new List(); + for (int i = 0; i < DeathDiag.Capacity; i++) seen.Add(DeathDiag.Get(i).raw); + seen.Sort(); + bool b2 = seen.Count == 16 && Math.Abs(seen[0] - 5d) < 1e-6 && Math.Abs(seen[15] - 20d) < 1e-6; + sb.AppendLine(P(b2) + " 마지막 16건만 보존(raw " + seen[0] + "~" + seen[15] + " · 1~4 소멸)"); if (!b2) f++; + + bool b3 = DeathDiag.UnscaledCount == 5 && Math.Abs(DeathDiag.UnscaledHpTotal - (4 + 8 + 12 + 16 + 20)) < 1e-6; + sb.AppendLine(P(b3) + " Unscaled 누계 " + DeathDiag.UnscaledCount + "건 " + DeathDiag.UnscaledHpTotal + " HP"); if (!b3) f++; + return f; + } + + // ───────────────────────────────── ⓒ 사망 파일 + static int DeathFile(StringBuilder sb) + { + int f = 0; + sb.AppendLine(); sb.AppendLine("## ⓒ 사망 파일 · 콘솔 1줄"); + int before = DeathDiag.FileCount; + DeathDiag.DumpForProbe(7); + bool c1 = DeathDiag.FileCount == before + 1 && !string.IsNullOrEmpty(DeathDiag.LastFilePath) && File.Exists(DeathDiag.LastFilePath); + sb.AppendLine(P(c1) + " 파일 생성 " + DeathDiag.LastFilePath); if (!c1) { return f + 1; } + + string txt = File.ReadAllText(DeathDiag.LastFilePath); + int lines = txt.Split('\n').Length; + bool c2 = txt.Contains("death #7") && txt.Contains("UNSCALED") && txt.Contains("Scaled") && lines >= 20; + sb.AppendLine(P(c2) + " 내용 " + lines + "줄 · death #7 · via 열(Scaled/UNSCALED) 포함"); if (!c2) f++; + + bool c3 = DeathDiag.LastSummary.StartsWith("[WL813m3] death #7") && !DeathDiag.LastSummary.Contains("\n"); + sb.AppendLine(P(c3) + " 콘솔 1줄 = " + DeathDiag.LastSummary); if (!c3) f++; + + // verboseLog 와 무관해야 한다 + var st = UseClone(); if (st == null) return f; + bool v0 = st.verboseLog; st.verboseLog = false; + int before2 = DeathDiag.FileCount; + DeathDiag.DumpForProbe(8); + bool c4 = DeathDiag.FileCount == before2 + 1 && DeathDiag.LastSummary.Contains("death #8"); + sb.AppendLine(P(c4) + " verboseLog=0 에서도 파일·1줄 생성"); if (!c4) f++; + st.verboseLog = v0; + return f; + } + + // ───────────────────────────────── ⓓ 배율 적용 표(훅 경유) + static int HookTable(StringBuilder sb) + { + int f = 0; + sb.AppendLine(); sb.AppendLine("## ⓓ 배율 적용 표(PCActor.Get_Damage 훅 경유 · HookHpLoss 실측)"); + var st = UseClone(); if (st == null) { sb.AppendLine("[FAIL] SO 클론 실패"); return f + 1; } + st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; + st.takeMobDamage = true; st.takeBossDamage = true; + st.mobDamageMultiplier = 0.1f; st.bossDamageMultiplier = 0.15f; + st.unscaledDamageGuardEnabled = false; // 이 절은 훅만 본다 + + var pc = MakePC("d_pc", 310, 310); + var mob = MakeBeater("d_mob", eSubRol.None); + var boss = MakeBeater("d_boss", eSubRol.Boss); + var pc0 = MyValue.MyPC; MyValue.MyPC = pc; + try + { + Survival.ResetDiagnostics(); DeathDiag.ResetDiagnostics(); + sb.AppendLine("| 가해자 | 원 피해 | 배율 | 훅 후 HP 감소(실측) | DeathDiag via |"); + sb.AppendLine("|---|---|---|---|---|"); + + double h0 = Survival.HookHpLoss; + var d = Dmg(mob, 100); + Survival.TakeDamage(pc, d, ApplyToPc(pc)); + double lost1 = Survival.HookHpLoss - h0; + var r1 = DeathDiag.Get(0); + bool d1 = Math.Abs(lost1 - 10d) < 1e-3 && r1.via == DamageVia.Scaled && Math.Abs(r1.mult - 0.1d) < 1e-6; + sb.AppendLine("| 잡몹 | 100 | ×0.10 | " + lost1.ToString("F1") + " | " + r1.via + " |"); + sb.AppendLine(P(d1) + " 잡몹 배율 적용 + 누계 반영"); if (!d1) f++; + + h0 = Survival.HookHpLoss; + d = Dmg(boss, 100); + Survival.TakeDamage(pc, d, ApplyToPc(pc)); + double lost2 = Survival.HookHpLoss - h0; + var r2 = DeathDiag.Get(1); + bool d2 = Math.Abs(lost2 - 15d) < 1e-3 && r2.via == DamageVia.Scaled && r2.attackerBoss; + sb.AppendLine("| 보스 | 100 | ×0.15 | " + lost2.ToString("F1") + " | " + r2.via + "(boss) |"); + sb.AppendLine(P(d2) + " 보스 배율 적용 + 가해자 종류 기록"); if (!d2) f++; + + st.takeMobDamage = false; + h0 = Survival.HookHpLoss; + d = Dmg(mob, 100); + Survival.TakeDamage(pc, d, NoDamage()); + double lost3 = Survival.HookHpLoss - h0; + var r3 = DeathDiag.Get(2); + bool d3 = Math.Abs(lost3) < 1e-6 && r3.via == DamageVia.SwitchOff; + sb.AppendLine("| 잡몹(스위치 off) | 100 | — | " + lost3.ToString("F1") + " | " + r3.via + " |"); + sb.AppendLine(P(d3) + " 스위치 off = 원본 무적 그대로 · via=SwitchOff"); if (!d3) f++; + st.takeMobDamage = true; + + Survival.SetReviveInvincible(pc, 5f); Survival.Tick(0f); + h0 = Survival.HookHpLoss; + d = Dmg(mob, 100); + Survival.TakeDamage(pc, d, NoDamage()); + var r4 = DeathDiag.Get(3); + bool d4 = Math.Abs(Survival.HookHpLoss - h0) < 1e-6 && r4.via == DamageVia.ReviveInv && Survival.BlockedReviveInvincible == 1; + sb.AppendLine("| 잡몹(부활 무적 창) | 100 | — | 0.0 | " + r4.via + " |"); + sb.AppendLine(P(d4) + " 부활 무적 창 = 피해 0 · via=ReviveInv"); if (!d4) f++; + Survival.SetReviveInvincible(null, 0f); + } + finally { MyValue.MyPC = pc0; Kill(pc, mob, boss); } + return f; + } + + // ───────────────────────────────── ⓔⓕ 배율 미적용 경로 감지·환급 + static int GuardDetect(StringBuilder sb) + { + int f = 0; + sb.AppendLine(); sb.AppendLine("## ⓔⓕ 배율 미적용 경로 감지 · 환급(도트/Cal_Damage 직접 호출 재현)"); + var st = UseClone(); if (st == null) return f + 1; + st.enabled = true; WLSurvivalSettings.RuntimeOverride = 1; + st.unscaledDamageGuardEnabled = true; st.unscaledDamageGuardLog = false; + st.mobDamageMultiplier = 0.1f; + + var pc = MakePC("g_pc", 310, 200); + var pc0 = MyValue.MyPC; MyValue.MyPC = pc; + try + { + DamageGuard.ResetDiagnostics(); DeathDiag.ResetDiagnostics(); + DamageGuard.Poll(); // 기준선 무장 + bool e0 = DamageGuard.DetectedCount == 0 && DamageGuard.PollCount == 1; + sb.AppendLine(P(e0) + " 첫 폴링 = 기준선만 잡는다(감지 0)"); if (!e0) f++; + + // 훅을 거치지 않고 HP 40 감소 = Actor.Co_DotDamage:1536 → Cal_Damage 재현 + pc.Get_StatInfo().Set_Stat(false, eStat.HP, 40d); + DamageGuard.Poll(); + double hp = pc.Get_HP(); + bool e1 = DamageGuard.DetectedCount == 1 && Math.Abs(DamageGuard.LastDetected - 40d) < 1e-3; + sb.AppendLine(P(e1) + " 감지 1건 · 미적용 피해 " + DamageGuard.LastDetected.ToString("F1")); if (!e1) f++; + bool e2 = Math.Abs(DamageGuard.LastRefund - 36d) < 1e-3 && Math.Abs(hp - 196d) < 1e-3; + sb.AppendLine(P(e2) + " 환급 40×(1−0.10)=36 → HP 160 → " + hp.ToString("F1") + " (순 피해 4 = 40×0.10)"); if (!e2) f++; + var rec = DeathDiag.Get(0); + bool e3 = rec.via == DamageVia.Unscaled && Math.Abs(rec.raw - 40d) < 1e-3; + sb.AppendLine(P(e3) + " DeathDiag via=Unscaled 기록 raw=" + rec.raw.ToString("F1")); if (!e3) f++; + + // ⓕ 훅을 거친 감소는 오검출 0 + int det0 = DamageGuard.DetectedCount; + double h0 = Survival.HookHpLoss; + pc.Get_StatInfo().Set_Stat(false, eStat.HP, 30d); + Survival.HookHpLoss = h0 + 30d; // 훅이 책임진 30 + DamageGuard.Poll(); + bool e4 = DamageGuard.DetectedCount == det0 && Math.Abs(pc.Get_HP() - 166d) < 1e-3; + sb.AppendLine(P(e4) + " 훅 경유 30 감소 → 감지 " + (DamageGuard.DetectedCount - det0) + "건(기대 0) · HP " + pc.Get_HP().ToString("F1")); if (!e4) f++; + + // 회복(부활·물약)은 기준선만 갱신 + pc.Get_StatInfo().Set_Stat(true, eStat.HP, 100d); + DamageGuard.Poll(); + bool e5 = DamageGuard.DetectedCount == det0; + sb.AppendLine(P(e5) + " 회복(+100) → 감지 0 · HP " + pc.Get_HP().ToString("F1")); if (!e5) f++; + + // 부활/런 경계 → 기준선 재설정 + int rb0 = DamageGuard.RebaseCount; + Survival.SetReviveInvincible(pc, 1f); + bool e6 = DamageGuard.RebaseCount == rb0 + 1; + sb.AppendLine(P(e6) + " SetReviveInvincible → Rebase " + (DamageGuard.RebaseCount - rb0) + "회"); if (!e6) f++; + Survival.SetReviveInvincible(null, 0f); + } + finally { MyValue.MyPC = pc0; Kill(pc); Survival.HookHpLoss = 0d; } + return f; + } + + // ───────────────────────────────── ⓖ NullMaterialGuard + static int NullMat(StringBuilder sb) + { + int f = 0; + sb.AppendLine(); sb.AppendLine("## ⓖ NullMaterialGuard — 빈 슬롯 렌더러 비활성"); + var nm = UseNmClone(); if (nm == null) { sb.AppendLine("[FAIL] NM SO 클론 실패"); return f + 1; } + nm.enabled = true; WLNullMaterialSettings.RuntimeOverride = 1; + nm.action = NullMaterialAction.DisableRenderer; nm.treatEmptyArrayAsNull = false; + + GameObject bad = null, good = null; + Material mat = null; + try + { + NullMaterialGuard.ResetDiagnostics(); + + bad = GameObject.CreatePrimitive(PrimitiveType.Quad); bad.name = "__WL813m3_bad"; + var badChild = GameObject.CreatePrimitive(PrimitiveType.Quad); badChild.name = "Pos"; + badChild.transform.SetParent(bad.transform, false); + var rBad = badChild.GetComponent(); + rBad.sharedMaterials = new Material[] { null }; // Q9 실측 = slot 0 이 null + var rBadRoot = bad.GetComponent(); + mat = new Material(Shader.Find("Sprites/Default")); mat.hideFlags = HideFlags.HideAndDontSave; + rBadRoot.sharedMaterial = mat; // 루트는 정상 + + good = GameObject.CreatePrimitive(PrimitiveType.Quad); good.name = "__WL813m3_good"; + good.GetComponent().sharedMaterial = mat; + + NullMaterialGuard.Inspect(bad); + NullMaterialGuard.Inspect(good); + + bool g1 = NullMaterialGuard.NullMaterialRenderers == 1 && NullMaterialGuard.DisabledRenderers == 1 && NullMaterialGuard.OffendingObjects == 1; + sb.AppendLine(P(g1) + " 결함 렌더러 " + NullMaterialGuard.NullMaterialRenderers + " · 비활성 " + NullMaterialGuard.DisabledRenderers + + " · 결함 오브젝트 " + NullMaterialGuard.OffendingObjects); if (!g1) f++; + bool g2 = !rBad.enabled && rBadRoot.enabled && good.GetComponent().enabled; + sb.AppendLine(P(g2) + " 빈 슬롯 렌더러만 enabled=false(정상 렌더러 2개 무변경)"); if (!g2) f++; + bool g3 = NullMaterialGuard.LastPath.EndsWith("/Pos") && NullMaterialGuard.Samples.Count == 1; + sb.AppendLine(P(g3) + " 경로 표본 = " + NullMaterialGuard.LastPath); if (!g3) f++; + + int insp0 = NullMaterialGuard.InspectedObjects; + NullMaterialGuard.Inspect(bad); NullMaterialGuard.Inspect(good); + bool g4 = NullMaterialGuard.InspectedObjects == insp0 && NullMaterialGuard.CacheCount == 2; + sb.AppendLine(P(g4) + " 캐시 = 2회차 재검사 0(스폰 1회 검사 · 캐시 " + NullMaterialGuard.CacheCount + ")"); if (!g4) f++; + + // MeasureOnly = 계측만 + NullMaterialGuard.ResetDiagnostics(); + nm.action = NullMaterialAction.MeasureOnly; + rBad.enabled = true; + NullMaterialGuard.Inspect(bad); + bool g5 = NullMaterialGuard.NullMaterialRenderers == 1 && NullMaterialGuard.DisabledRenderers == 0 && rBad.enabled; + sb.AppendLine(P(g5) + " MeasureOnly = 계측 1 · 비활성 0(A/B 비교용)"); if (!g5) f++; + nm.action = NullMaterialAction.DisableRenderer; + + // 러너는 Play 밖에서 만들어지지 않는다 + bool g6 = !NullMaterialGuardRunner.Exists; + sb.AppendLine(P(g6) + " 에디트 모드 러너 생성 0"); if (!g6) f++; + } + finally { Kill(bad, good); if (mat != null) UnityEngine.Object.DestroyImmediate(mat); } + return f; + } + + // ───────────────────────────────── ⓗ Q9 §6 5종 프리팹 실측(읽기만) + static int Q9Scan(StringBuilder sb) + { + sb.AppendLine(); sb.AppendLine("## ⓗ Q9 §6 5종 프리팹 실측(디스크 원본 · 수정 0) = 아트 수정 목록"); + sb.AppendLine("| # | 프리팹 | 렌더러 수 | 🔴 빈 슬롯 렌더러(경로/슬롯) |"); + sb.AppendLine("|---|---|---|---|"); + int found = 0, missing = 0; + var rends = new List(); + var mats = new List(); + for (int i = 0; i < Q9Prefabs.Length; i++) + { + var go = UnityEditor.AssetDatabase.LoadAssetAtPath(Q9Prefabs[i]); + if (go == null) { missing++; sb.AppendLine("| " + (i + 1) + " | " + Q9Prefabs[i] + " | — | ❌ 디스크에 없음 |"); continue; } + rends.Clear(); go.GetComponentsInChildren(true, rends); + var bad = new StringBuilder(); + int badCount = 0; + for (int k = 0; k < rends.Count; k++) + { + mats.Clear(); rends[k].GetSharedMaterials(mats); + for (int s = 0; s < mats.Count; s++) + { + if (mats[s] != null) continue; + badCount++; + if (bad.Length > 0) bad.Append(" · "); + bad.Append(PathIn(go.transform, rends[k].transform)).Append("[slot ").Append(s).Append(']'); + } + if (mats.Count == 0) { badCount++; if (bad.Length > 0) bad.Append(" · "); bad.Append(PathIn(go.transform, rends[k].transform)).Append("[슬롯 0개]"); } + } + if (badCount > 0) found++; + sb.AppendLine("| " + (i + 1) + " | " + Q9Prefabs[i] + " | " + rends.Count + " | " + (badCount > 0 ? "🔴 " + badCount + "건 — " + bad : "없음") + " |"); + } + sb.AppendLine("→ 5종 중 **" + found + "종**에서 빈 머티리얼 슬롯 실측(디스크 부재 " + missing + "종). 근본 수리 = 아트/이펙트 발주(프리팹 슬롯 채우기 또는 그 렌더러 삭제)."); + return 0; // 실측 보고 — PASS/FAIL 판정 대상 아님 + } + + // ───────────────────────────────── ⓘ 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.unscaledDamageGuardEnabled = true; + + var pc = MakePC("gc_pc", 310, 310); + var pc0 = MyValue.MyPC; MyValue.MyPC = pc; + var bad = GameObject.CreatePrimitive(PrimitiveType.Quad); bad.name = "__WL813m3_gc"; + try + { + DeathDiag.ResetDiagnostics(); DamageGuard.ResetDiagnostics(); NullMaterialGuard.ResetDiagnostics(); + NullMaterialGuard.Inspect(bad); // 캐시 채우기 + + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + long m0 = Mono(); long t0 = GC.GetTotalMemory(false); int c0 = GC.CollectionCount(0); + for (int i = 0; i < 200000; i++) DeathDiag.Record(DamageVia.Scaled, null, 1d, 0.1d, 1d, 300d, 310d); + long m1 = Mono(); long t1 = GC.GetTotalMemory(false); int c1 = GC.CollectionCount(0); + bool i1 = (m1 - m0) <= 0 && (t1 - t0) <= 4096 && c1 == c0; + sb.AppendLine(P(i1) + " DeathDiag.Record ×200k · mono Δ" + (m1 - m0) + " B · total Δ" + (t1 - t0) + " B · gen0 " + (c1 - c0)); if (!i1) f++; + + DamageGuard.Poll(); // 무장 + long m2 = Mono(); long t2 = GC.GetTotalMemory(false); int c2 = GC.CollectionCount(0); + for (int i = 0; i < 200000; i++) DamageGuard.Poll(); + long m3 = Mono(); long t3 = GC.GetTotalMemory(false); int c3 = GC.CollectionCount(0); + bool i2 = (m3 - m2) <= 0 && (t3 - t2) <= 4096 && c3 == c2 && DamageGuard.DetectedCount == 0; + sb.AppendLine(P(i2) + " DamageGuard.Poll ×200k(변화 없음) · mono Δ" + (m3 - m2) + " B · total Δ" + (t3 - t2) + " B · gen0 " + (c3 - c2)); if (!i2) f++; + + long m4 = Mono(); long t4 = GC.GetTotalMemory(false); int c4 = GC.CollectionCount(0); + for (int i = 0; i < 200000; i++) NullMaterialGuard.Inspect(bad); + long m5 = Mono(); long t5 = GC.GetTotalMemory(false); int c5 = GC.CollectionCount(0); + bool i3 = (m5 - m4) <= 0 && (t5 - t4) <= 4096 && c5 == c4; + sb.AppendLine(P(i3) + " NullMaterialGuard.Inspect ×200k(캐시 히트) · mono Δ" + (m5 - m4) + " B · total Δ" + (t5 - t4) + " B · gen0 " + (c5 - c4)); if (!i3) f++; + } + finally { MyValue.MyPC = pc0; Kill(pc, bad); } + 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; + + var pc = MakePC("c8_pc", 310, 200); + var pc0 = MyValue.MyPC; MyValue.MyPC = pc; + try + { + // ① 생존 축 자체 off + WLSurvivalSettings.RuntimeOverride = -1; + DamageGuard.ResetDiagnostics(); DeathDiag.ResetDiagnostics(); + DamageGuard.Poll(); pc.Get_StatInfo().Set_Stat(false, eStat.HP, 50d); DamageGuard.Poll(); + bool j1 = !DamageGuard.Active && DamageGuard.DetectedCount == 0 && Math.Abs(pc.Get_HP() - 150d) < 1e-3; + sb.AppendLine(P(j1) + " SO off(RuntimeOverride=-1) → 가드 무동작 · HP " + pc.Get_HP().ToString("F1") + "(환급 0)"); if (!j1) f++; + + // ② 생존 축은 on 인데 가드 스위치만 off + WLSurvivalSettings.RuntimeOverride = 1; st.enabled = true; st.unscaledDamageGuardEnabled = false; + DamageGuard.ResetDiagnostics(); + DamageGuard.Poll(); pc.Get_StatInfo().Set_Stat(false, eStat.HP, 50d); DamageGuard.Poll(); + bool j2 = !DamageGuard.Active && DamageGuard.DetectedCount == 0 && Math.Abs(pc.Get_HP() - 100d) < 1e-3; + sb.AppendLine(P(j2) + " unscaledDamageGuardEnabled=0 → 무동작 · HP " + pc.Get_HP().ToString("F1")); if (!j2) f++; + st.unscaledDamageGuardEnabled = true; + + // ③ 사망 로그 off + st.deathDiagEnabled = false; + DeathDiag.ResetDiagnostics(); + DeathDiag.Record(DamageVia.Scaled, null, 1d, 1d, 1d, 1d, 1d); + int fc = DeathDiag.FileCount; DeathDiag.DumpForProbe(99); + bool j3 = DeathDiag.RecordCount == 0 && DeathDiag.FileCount == fc; + sb.AppendLine(P(j3) + " deathDiagEnabled=0 → 기록 0 · 파일 0"); if (!j3) f++; + st.deathDiagEnabled = true; + + // ④ 빈 머티리얼 가드 off + var nm = UseNmClone(); + WLNullMaterialSettings.RuntimeOverride = -1; + NullMaterialGuard.ResetDiagnostics(); + var bad = GameObject.CreatePrimitive(PrimitiveType.Quad); bad.name = "__WL813m3_c8"; + bad.GetComponent().sharedMaterials = new Material[] { null }; + NullMaterialGuard.Inspect(bad); NullMaterialGuard.Tick(999f); + bool j4 = !WLNullMaterialSettings.Enabled && NullMaterialGuard.InspectedObjects == 0 && NullMaterialGuard.SweepCount == 0 + && bad.GetComponent().enabled && !NullMaterialGuardRunner.Exists; + sb.AppendLine(P(j4) + " NullMaterial off → 검사 0 · 스윕 0 · 렌더러 무변경 · 러너 0"); if (!j4) f++; + Kill(bad); + WLNullMaterialSettings.RuntimeOverride = 0; + } + finally { MyValue.MyPC = pc0; Kill(pc); WLSurvivalSettings.RuntimeOverride = 0; } + return f; + } + + // ───────────────────────────────── ⓚ 정리 + static int Cleanup(StringBuilder sb, float ts0, bool dirty0) + { + int f = 0; + sb.AppendLine(); sb.AppendLine("## ⓚ 정리"); + int leftovers = 0; + foreach (var go in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + if (go != null && go.name.StartsWith("__WL813m3")) leftovers++; + var st = WLSurvivalSettings.Instance; + var nm = WLNullMaterialSettings.Instance; + bool assetBack = st != null && st.deathDiagEnabled && st.unscaledDamageGuardEnabled + && Math.Abs(st.mobDamageMultiplier - 0.1f) < 1e-4 + && nm != null && nm.enabled && nm.action == NullMaterialAction.DisableRenderer; + bool dirty = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty; + bool ok = leftovers == 0 && assetBack && Math.Abs(Time.timeScale - ts0) < 1e-6 && MyValue.MyPC == null + && !SurvivalRunner.Exists && !NullMaterialGuardRunner.Exists && dirty == dirty0 + && WLSurvivalSettings.RuntimeOverride == 0 && WLNullMaterialSettings.RuntimeOverride == 0; + sb.AppendLine(P(ok) + " 임시 오브젝트 " + leftovers + " · 에셋 원복 " + assetBack + " · timeScale " + Time.timeScale + + " · MyPC=null " + (MyValue.MyPC == null) + " · 러너 0 " + (!SurvivalRunner.Exists && !NullMaterialGuardRunner.Exists) + + " · 씬 dirty " + dirty + "(시작 " + dirty0 + ")"); + if (!ok) f++; + return f; + } + + // ───────────────────────────────── helpers + static string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; } + static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } + + 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 WLNullMaterialSettings UseNmClone() + { + var orig = WLNullMaterialSettings.Instance; + if (orig == null) return null; + if (s_nmClone == null) + { + s_nmClone = UnityEngine.Object.Instantiate(orig); + s_nmClone.hideFlags = HideFlags.HideAndDontSave; s_nmClone.name = "WLNullMaterialSettings(probe clone)"; + } + typeof(WLNullMaterialSettings).GetField("s_cached", NS).SetValue(null, s_nmClone); + typeof(WLNullMaterialSettings).GetField("s_lookupDone", NS).SetValue(null, true); + return s_nmClone; + } + + static void RestoreAssets() + { + WLSurvivalSettings.ClearCache(); WLSurvivalSettings.RuntimeOverride = 0; + WLNullMaterialSettings.ClearCache(); WLNullMaterialSettings.RuntimeOverride = 0; + if (s_clone != null) { UnityEngine.Object.DestroyImmediate(s_clone); s_clone = null; } + if (s_nmClone != null) { UnityEngine.Object.DestroyImmediate(s_nmClone); s_nmClone = null; } + } + + static PCActor MakePC(string name, double maxHp, double hp) + { + var go = new GameObject("__WL813m3_" + 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("__WL813m3_" + 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 — 넘어온 (배율 적용된) 피해만큼 PC HP 를 실제로 깎는다. + static Action ApplyToPc(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 string PathIn(Transform root, Transform t) + { + if (t == root) return t.name; + var sb = new StringBuilder(t.name); + var p = t.parent; + while (p != null && p != root) { sb.Insert(0, '/'); sb.Insert(0, p.name); p = p.parent; } + sb.Insert(0, '/'); sb.Insert(0, root.name); + return sb.ToString(); + } + + 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("[WL813m3] probe 파일 기록 실패: " + ex.Message); } + } +} diff --git a/Assets/WL/Combat/Reaction/NullMaterialGuard.cs b/Assets/WL/Combat/Reaction/NullMaterialGuard.cs new file mode 100644 index 000000000..b5ae2474a --- /dev/null +++ b/Assets/WL/Combat/Reaction/NullMaterialGuard.cs @@ -0,0 +1,220 @@ +// ───────────────────────────────────────────────────────────────────────────── +// NullMaterialGuard.cs — 빈 머티리얼 슬롯 렌더러 비활성(D-40 마젠타 폴리곤) +// +// PD 지시 #813 · 발주서 WL-813m3 §1-4 · 근거 = Q9 `qa/W2-9_재채점_v7.md` §6 +// +// ■ Q9 가 특정한 5종(전부 `InGameInfo/Effects/**` · `slot=0` 이 null) +// Effect_Hit_10501(Clone)/Pos +// Effect_Hit_AnubisSkill2(Clone)/vfx_Hit_Orb08_Orange/Light +// Effect_Poison(Clone)/GameObject +// Select_WindFairy(Clone)/PowerOrbSciFi05 (+ /OrbGlow · /Sparks) +// Hit_P_Batty_Meele(Clone)/FX_Yellow_Hit/Pos +// 🔴 다섯 개 **전부 원본(힘민지) 이펙트 풀**이 스폰한 것이다 — WL 이 만든 것이 아니다. +// 그래서 이 가드는 WL 스폰 지점에 훅을 넣는 대신 **풀 루트를 스윕**한다 +// (그 스폰 파일들 = SkillSpectacle · LootBurst · LevelUpBurst 는 이번 발주의 수정 대상이 아니다). +// WL 스폰 지점에서 부를 공개 API `Inspect(GameObject)` 도 함께 둔다(후속 1줄 연결용). +// +// ■ 어떻게 +// 루트(`InGameInfo.tf_Effects` · `tf_Objs` · 811i Live/Pool · 메인 PC)의 **직속 자식**을 주기적으로 본다. +// 처음 보는 인스턴스만 하위 렌더러를 전수 검사하고(인스턴스 ID 캐시 = 스폰 1회 검사), +// `sharedMaterials` 에 null 슬롯이 있으면 `Renderer.enabled = false`(SO 로 변경 가능). +// 풀은 같은 인스턴스를 재사용하므로 한 번 끈 렌더러는 계속 꺼져 있다. +// +// ■ GC 0 +// `GetComponentsInChildren(true, List)` · `Renderer.GetSharedMaterials(List)` 둘 다 비할당 오버로드. +// 캐시는 `HashSet`(성장 때만 할당) · 문자열은 **증거 표본 pathSampleMax 개까지만** 만든다. +// +// ■ C8 롤백: `WLNullMaterialSettings.enabled = 0` → 러너도 스윕도 없다(마젠타 그대로 = 원본 100 %). +// 🔴 원본 프리팹 수정 0 — 근본 수리는 아트 발주(완료보고 「아트 수정 목록」). +// 🔴 Debug.LogError/LogException 금지(813z). +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace WL.Combat.Reaction +{ + /// 빈 머티리얼 슬롯 렌더러를 찾아 끈다. 정적 · 스폰 1회 검사 · GC 0. + public static class NullMaterialGuard + { + // ── 진단(프로브가 읽는다) + /// 스윕 횟수 · 검사한 오브젝트 수 · 검사한 렌더러 수. + public static int SweepCount, InspectedObjects, InspectedRenderers; + /// 빈 슬롯을 가진 렌더러 수 · 실제로 끈 수. + public static int NullMaterialRenderers, DisabledRenderers; + /// 결함이 있던 오브젝트 수(렌더러가 아니라 스폰 단위). + public static int OffendingObjects; + /// 마지막으로 끈 경로 · 마지막 스윕 시각(unscaled). + public static string LastPath = ""; + public static float LastSweepTime; + /// 증거 표본(경로 문자열 · pathSampleMax 개까지). + public static readonly List Samples = new List(16); + + static readonly HashSet s_inspected = new HashSet(); + static readonly List s_rends = new List(32); + static readonly List s_mats = new List(8); + static StringBuilder s_sb; + static float s_nextSweep; + + static WLNullMaterialSettings St { get { return WLNullMaterialSettings.Instance; } } + + /// 캐시에 담긴 인스턴스 수(프로브). + public static int CacheCount { get { return s_inspected.Count; } } + + // ───────────────────────────────────────── 스윕 + + /// 러너가 매 프레임 부른다. 주기·비활성 판정은 여기서 한다(C8 = 즉시 반환). + public static void Tick(float unscaledNow) + { + var st = St; + if (st == null || !WLNullMaterialSettings.Enabled) return; + if (unscaledNow < s_nextSweep) return; + s_nextSweep = unscaledNow + Mathf.Max(0f, st.sweepIntervalSeconds); + Sweep(st, unscaledNow); + } + + static void Sweep(WLNullMaterialSettings st, float unscaledNow) + { + SweepCount++; LastSweepTime = unscaledNow; + if (s_inspected.Count > Mathf.Max(64, st.inspectCacheMax)) s_inspected.Clear(); + + int budget = Mathf.Max(1, st.maxInspectPerSweep); + + var info = InGameInfo.Ins; + if (info != null) + { + if (st.sweepIngameEffects) budget = SweepRoot(info.tf_Effects, st, budget); + if (st.sweepIngameObjs) budget = SweepRoot(info.tf_Objs, st, budget); + } + + if (st.sweepSkillSpectacle && SkillSpectacleRunner.Exists) + { + var runner = SkillSpectacleRunner.Ensure(); // Exists 가 true 라 새로 만들지 않는다 + if (runner != null) + { + budget = SweepRoot(runner.LiveRoot, st, budget); + budget = SweepRoot(runner.PoolRoot, st, budget); + } + } + + if (st.sweepMainPc) + { + var pc = MyValue.MyPC; + if (pc != null) budget = SweepRoot(pc.transform, st, budget); + } + } + + /// 루트의 직속 자식 중 처음 보는 것만 검사한다. 남은 예산을 돌려준다. + static int SweepRoot(Transform root, WLNullMaterialSettings st, int budget) + { + if (root == null || budget <= 0) return budget; + int n = root.childCount; + for (int i = 0; i < n && budget > 0; i++) + { + var child = root.GetChild(i); + if (child == null) continue; + int id = child.GetInstanceID(); + if (s_inspected.Contains(id)) continue; + s_inspected.Add(id); + budget--; + InspectTransform(child, st); + } + return budget; + } + + // ───────────────────────────────────────── 검사 + + /// WL 스폰 지점용 공개 API — 스폰 직후 1회 부르면 된다(캐시 · 중복 호출 안전). + public static void Inspect(GameObject go) + { + var st = St; + if (st == null || !WLNullMaterialSettings.Enabled || go == null) return; + int id = go.transform.GetInstanceID(); + if (s_inspected.Contains(id)) return; + s_inspected.Add(id); + InspectTransform(go.transform, st); + } + + static void InspectTransform(Transform t, WLNullMaterialSettings st) + { + InspectedObjects++; + s_rends.Clear(); + try { t.GetComponentsInChildren(true, s_rends); } + catch (Exception) { return; } + + bool offending = false; + for (int i = 0; i < s_rends.Count; i++) + { + var r = s_rends[i]; + if (r == null) continue; + InspectedRenderers++; + if (!HasNullSlot(r, st)) continue; + + NullMaterialRenderers++; + offending = true; + + if (Samples.Count < Mathf.Max(0, st.pathSampleMax)) + { + string path = PathOf(r.transform); + Samples.Add(path); + LastPath = path; + } + + switch (st.action) + { + case NullMaterialAction.DisableRenderer: + if (r.enabled) { r.enabled = false; DisabledRenderers++; } + break; + case NullMaterialAction.DeactivateObject: + if (r.gameObject.activeSelf) { r.gameObject.SetActive(false); DisabledRenderers++; } + break; + default: + break; // MeasureOnly — 계측만 + } + + if (st.verboseLog) + Debug.Log("[WL813m3] nullMaterial " + (st.action == NullMaterialAction.MeasureOnly ? "measured " : "disabled ") + + (Samples.Count > 0 ? LastPath : r.name)); // 🔴 Log 만(813z) + } + if (offending) OffendingObjects++; + } + + static bool HasNullSlot(Renderer r, WLNullMaterialSettings st) + { + s_mats.Clear(); + try { r.GetSharedMaterials(s_mats); } + catch (Exception) { return false; } + if (s_mats.Count == 0) return st.treatEmptyArrayAsNull; + for (int i = 0; i < s_mats.Count; i++) + if (s_mats[i] == null) return true; + return false; + } + + static string PathOf(Transform t) + { + if (s_sb == null) s_sb = new StringBuilder(160); else s_sb.Length = 0; + s_sb.Append(t.name); + var p = t.parent; + int guard = 0; + while (p != null && guard++ < 12) + { + s_sb.Insert(0, '/'); + s_sb.Insert(0, p.name); + p = p.parent; + } + return s_sb.ToString(); + } + + /// 프로브용: 카운터·캐시 초기화. + public static void ResetDiagnostics() + { + SweepCount = InspectedObjects = InspectedRenderers = 0; + NullMaterialRenderers = DisabledRenderers = OffendingObjects = 0; + LastPath = ""; LastSweepTime = 0f; s_nextSweep = 0f; + Samples.Clear(); s_inspected.Clear(); + } + } +} diff --git a/Assets/WL/Combat/Reaction/NullMaterialGuard.cs.meta b/Assets/WL/Combat/Reaction/NullMaterialGuard.cs.meta new file mode 100644 index 000000000..fad3e7305 --- /dev/null +++ b/Assets/WL/Combat/Reaction/NullMaterialGuard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 813d3c3e6a9d6b2c1f4e8a0b3d5c7e9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs b/Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs new file mode 100644 index 000000000..b8abb43ee --- /dev/null +++ b/Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs @@ -0,0 +1,41 @@ +// ───────────────────────────────────────────────────────────────────────────── +// NullMaterialGuardRunner.cs — 빈 머티리얼 슬롯 가드 틱 러너(숨은 오브젝트 1개 · 코루틴 0) +// +// PD 지시 #813 · 발주서 WL-813m3 §1-4 +// +// · SO 가 꺼져 있으면 **오브젝트를 만들지 않는다**(C8: 꺼짐 = 흔적 0). +// · unscaled 시간으로 돈다 — 사망 중 세계 정지(timeScale 0)에도 결과창 위 마젠타를 잡아야 한다(Q9 §6 ④). +// · 기존 WLReactionRunner 를 건드리지 않는다(이번 발주 = Reaction 에 **새 파일만**). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Reaction +{ + /// NullMaterialGuard 전용 러너. DontDestroyOnLoad · Update 1개. + public sealed class NullMaterialGuardRunner : MonoBehaviour + { + static NullMaterialGuardRunner s_runner; + + public static bool Exists { get { return s_runner != null; } } + + public static void Ensure() + { + if (s_runner != null) return; + if (!Application.isPlaying) return; + var go = new GameObject("__WLNullMaterialGuard"); + DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + } + + void Update() { NullMaterialGuard.Tick(Time.unscaledTime); } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (WLNullMaterialSettings.Enabled) Ensure(); // 꺼져 있으면 오브젝트 0(C8) + } + } +} diff --git a/Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs.meta b/Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs.meta new file mode 100644 index 000000000..41686a2bc --- /dev/null +++ b/Assets/WL/Combat/Reaction/NullMaterialGuardRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 813d3d4f7b0e7c3d2a5f9b1c4e6d8a0b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs b/Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs new file mode 100644 index 000000000..006eb30a0 --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs @@ -0,0 +1,108 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLNullMaterialSettings.cs — 빈 머티리얼 슬롯 가드(D-40 마젠타) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-813m3 §1-4 · 근거 = Q9 `qa/W2-9_재채점_v7.md` §6 +// 실측: 런타임 렌더러 스캔 3회 전부 `errorShader=0 · nullShader=0` · **`nullMaterial` 4 → 8 → 52** +// (이펙트가 많이 재생될수록 는다) · 경로 5종 전부 `InGameInfo/Effects/**` 아래 · 전부 `slot=0` 이 null. +// 머티리얼 없는 렌더러를 Unity 는 **마젠타로 그린다** → 화면 1/4 크기 마젠타 사각형 · 결과창 위 마젠타 다각형. +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset +// C8 롤백: 에셋이 없거나 enabled = false → 스윕 0 · 오브젝트 0 · 코드 무수정 100 % 원본. +// +// 🔴 원본 프리팹은 고치지 않는다(발주서 금지). 이 가드는 **런타임에서 그 렌더러만 끈다** = 임시 봉합이고, +// 근본 수리는 아트/이펙트 발주(완료보고 「아트 수정 목록」)다. +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Reaction +{ + /// 빈 머티리얼 슬롯을 만났을 때 무엇을 끄나. + public enum NullMaterialAction + { + /// Renderer.enabled = false (최소 침습 · 기본). + DisableRenderer = 0, + /// 렌더러가 붙은 GameObject 를 비활성(자식까지 사라진다). + DeactivateObject = 1, + /// 아무것도 하지 않는다(계측만 · A/B 비교용). + MeasureOnly = 2 + } + + [CreateAssetMenu(fileName = "WLNullMaterialSettings", menuName = "WL/Null Material Guard Settings", order = 326)] + public sealed class WLNullMaterialSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLNullMaterialSettings"; + + static WLNullMaterialSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLNullMaterialSettings Instance + { + get + { + if (!s_lookupDone) { s_cached = Resources.Load(ResourcesPath); s_lookupDone = true; } + return s_cached; + } + } + + /// QA·A/B 전용 런타임 스위치. 0 = 에셋의 enabled · 1 = 강제 on · -1 = 강제 off. + public static int RuntimeOverride; + + /// 가드가 살아 있는가. false 면 스윕도 러너도 없다(C8). + public static bool Enabled + { + get + { + var i = Instance; + if (i == null || RuntimeOverride < 0) return false; + return RuntimeOverride > 0 || i.enabled; + } + } + + /// 테스트용: 캐시를 비운다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + [Header("전체 스위치")] + [Tooltip("끄면 스윕도 러너도 만들지 않는다 = 원본 100 %(마젠타 그대로). C8 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("검사·조치 결정을 Console 에 남긴다(오브젝트당 1줄 · 기본 off).")] + public bool verboseLog = false; + + [Header("조치")] + [Tooltip("빈 머티리얼 슬롯을 가진 렌더러에 무엇을 하나. DisableRenderer = Renderer.enabled 만 내린다(기본).")] + public NullMaterialAction action = NullMaterialAction.DisableRenderer; + + [Tooltip("머티리얼 슬롯이 0개인 렌더러도 결함으로 본다(Q9 실측은 slot=0 이 null 인 경우다 · 기본 off).")] + public bool treatEmptyArrayAsNull = false; + + [Header("스윕")] + [Tooltip("루트를 훑는 주기(초 · unscaled). 새로 생긴 자식만 본다(인스턴스 ID 캐시). 0 이면 매 프레임.")] + public float sweepIntervalSeconds = 0.5f; + + [Tooltip("한 번의 스윕에서 새로 검사할 오브젝트 상한(프레임 스파이크 방지 · 남으면 다음 스윕).")] + public int maxInspectPerSweep = 24; + + [Tooltip("검사 완료 인스턴스 ID 캐시 상한. 넘으면 비운다(풀 재사용 때 재검사 = 안전 · 비용 미미).")] + public int inspectCacheMax = 4096; + + [Header("스윕 루트 (Q9 §6 실측 = 5종 전부 InGameInfo/Effects 아래)")] + [Tooltip("원본 이펙트 풀 `InGameInfo.tf_Effects` 를 훑는다 — 🔴 D-40 의 5종이 전부 여기다. 끄면 D-40 은 안 잡힌다.")] + public bool sweepIngameEffects = true; + + [Tooltip("원본 오브젝트 풀 `InGameInfo.tf_Objs` 도 훑는다(드랍·813f 등급 빔이 드랍에 붙는다).")] + public bool sweepIngameObjs = true; + + [Tooltip("811i SkillSpectacle 의 Live/Pool 루트를 훑는다(러너가 이미 있을 때만 · 새로 만들지 않는다).")] + public bool sweepSkillSpectacle = true; + + [Tooltip("메인 PC 하위를 훑는다(813k 레벨업 폭발이 PC 에 붙고 · 811gh 잔상도 PC 근처에 생긴다).")] + public bool sweepMainPc = true; + + [Header("증거 수집")] + [Tooltip("결함 경로를 문자열로 몇 개까지 보관하나(프로브·보고서용 · 넘으면 카운터만 는다).")] + public int pathSampleMax = 16; + } +} diff --git a/Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs.meta b/Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs.meta new file mode 100644 index 000000000..36d859aef --- /dev/null +++ b/Assets/WL/Combat/Reaction/WLNullMaterialSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 813d3e508c1f8d4e3b6a0c2d5f7e9b1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset new file mode 100644 index 000000000..82ef09e27 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset @@ -0,0 +1,26 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 813d3e508c1f8d4e3b6a0c2d5f7e9b1c, type: 3} + m_Name: WLNullMaterialSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Reaction.WLNullMaterialSettings + enabled: 1 + verboseLog: 0 + action: 0 + treatEmptyArrayAsNull: 0 + sweepIntervalSeconds: 0.5 + maxInspectPerSweep: 24 + inspectCacheMax: 4096 + sweepIngameEffects: 1 + sweepIngameObjs: 1 + sweepSkillSpectacle: 1 + sweepMainPc: 1 + pathSampleMax: 16 \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset.meta new file mode 100644 index 000000000..75cffe265 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLNullMaterialSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 813d3f61ad2b0e9f4c7b1d3a6e8c0f25 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset index 8bffa5519..712cb30d2 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSurvivalSettings.asset @@ -30,6 +30,10 @@ MonoBehaviour: autoPotionRequireAutoCombat: 1 autoPotionCheckSeconds: 0.25 autoPotionLog: 1 + deathDiagEnabled: 1 + deathDiagDir: Screenshots_WL/WL813m3 + unscaledDamageGuardEnabled: 1 + unscaledDamageGuardLog: 0 deathDisplaySeconds: 2 holdWorldWhileReviving: 1 autoReviveWhenNoListener: 1 diff --git a/Assets/WL/Combat/Survival/DamageGuard.cs b/Assets/WL/Combat/Survival/DamageGuard.cs new file mode 100644 index 000000000..a1496f7ae --- /dev/null +++ b/Assets/WL/Combat/Survival/DamageGuard.cs @@ -0,0 +1,183 @@ +// ───────────────────────────────────────────────────────────────────────────── +// DamageGuard.cs — "배율을 타지 않는 PC 피해 경로" 에도 같은 배율을 먹인다(원본 훅 0 · 폴링 + 환급) +// +// PD 지시 #813 · 발주서 WL-813m3 §1-3 · 근거 = Q9 D-21(피해 배율을 0.15→0.1 · 0.25→0.15 로 내려도 사망이 안 줄었다) +// +// ■ 왜 필요한가 — 코드 실측(원본 읽기만 · Assets/Script 0줄 수정) +// 813i 게이트는 `PCActor.Get_Damage` **한 곳**에만 있다(Survival.TakeDamage). 그런데 원본에는 +// `Get_Damage` 를 **거치지 않고 곧장 `Actor.Cal_Damage(dinfo)` 로 HP 를 깎는** 경로가 있다: +// · `Actor.Co_DotDamage :1536` — 독/화상/출혈 도트. 매 틱 `Beater.Get_DamageInfo()` 로 **새 피해를 다시 만들어** +// `Cal_Damage` 를 직접 부른다 → 배율 0 · 방어 공식 0 · **원본 무적 플래그도 무시**된다. +// · `Actor.Check_CC_byAttack :1116`(스턴 추가타) · `Check_Extra :1191·1197·1202·1222·1229`(빙결/둔화/스턴 추가 피해) +// — 이쪽은 이미 배율이 곱해진 `_dinfo.Damage` 를 다시 쓰므로 **간접 적용**(2차 타격이라 총량은 2배가 된다). +// · `Check_EnchantOption :1402(LUK 저주) · :1412(STR 스플래시 · allies[k].Cal_Damage) · :1453(LUK 크리)` +// — 가해자 스탯에서 **피해를 새로 계산**하므로 배율 0. +// `Cal_Damage` 안에는 `IsDead()` 와 `NoDmgTime`(몹 전용 · PC 는 0) 말고 어떤 가드도 없다 — PC 무적조차 없다. +// +// ■ 어떻게 훅 0 으로 막나 — HP 델타 정산(폴링) + 환급 +// ① `Survival.TakeDamage` 가 base 를 부를 때마다 **호출 전후 HP 차이**를 `Survival.HookHpLoss` 에 누계한다 +// (중첩 `Cal_Damage` 까지 포함한 "훅이 책임지는 감소량" 실측치). +// ② 이 가드는 매 프레임(Update + **LateUpdate**) 메인 PC 의 HP 를 본다. +// `실제 감소량 − 훅 누계 증가분` = **훅을 안 거친 감소량**(= 배율 미적용 경로). +// ③ 그 차액에 같은 배율을 적용한 만큼을 되돌린다 — `Heal(차액 × (1 − 배율), showhud:false)`. +// 결과적으로 그 경로의 순 피해도 `차액 × 배율` 이 된다. +// · LateUpdate 를 함께 도는 이유: `WaitForSeconds` 코루틴(도트 틱)은 Update **뒤**에 재개된다 — +// LateUpdate 폴링이면 **같은 프레임 안**에 정산된다(지연 ≈ 0). +// · 🔴 한계(보고서 「미확인」): 한 틱이 곧바로 치명타여서 `Cal_Damage` 안에서 `Set_Die` 가 먼저 나면 +// 환급이 늦다. 이 가드는 **누적 감소 속도**를 배율만큼 낮춰 그 지점에 도달하지 않게 하는 것이 목적이다. +// +// ■ 값은 전부 SO — 배율은 `WLSurvivalSettings.mobDamageMultiplier` **기존 필드 재사용**(발주서 §1-3). +// 가해자를 알 수 없는 폴링이라 잡몹 배율을 쓴다(보스 배율보다 낮아 생존에 보수적) · 스위치 `unscaledDamageGuardEnabled`. +// +// ■ C8 롤백: `unscaledDamageGuardEnabled = 0` 또는 SO off → 폴링 자체를 하지 않는다(코드 무수정). +// ■ GC 0: 문자열·할당 0(로그는 SO 스위치가 켜졌을 때 · 사건당 1줄). +// 🔴 Debug.LogError/LogException 금지(813z). +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Combat.Survival +{ + /// 훅을 거치지 않은 PC HP 감소를 같은 배율로 되돌린다. 정적 · GC 0. + public static class DamageGuard + { + /// 이 값보다 작은 감소는 무시한다(부동소수 잡음 · 구조 상수). + const double Epsilon = 0.5d; + + // ── 진단(프로브가 읽는다) + /// 훅을 안 거친 감소를 잡은 횟수. + public static int DetectedCount; + /// 그 경로로 깎인 HP 누계(환급 전). + public static double DetectedHp; + /// 실제로 환급(Heal)한 횟수 · HP 누계. + public static int RefundCount; + public static double RefundedHp; + /// 죽어 있어서 환급하지 못한 횟수. + public static int SkippedDead; + /// 폴링 횟수 · 기준선 재설정 횟수. + public static int PollCount, RebaseCount; + public static double LastDetected, LastRefund; + public static float LastDetectTime; + + static PCActor s_pc; + static double s_lastHp; + static double s_lastHookLoss; + static bool s_armed; + + static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } + + /// 가드가 실제로 도는가 = 생존 축 on + 이 스위치 on. + public static bool Active + { + get { var st = St; return WLSurvivalSettings.Enabled && st != null && st.unscaledDamageGuardEnabled; } + } + + /// 폴링에 쓰는 배율(잡몹 배율 재사용 · 0~1 로 자른다). + public static double Multiplier + { + get + { + var st = St; + if (st == null) return 1d; + double m = st.mobDamageMultiplier; + if (m < 0d) m = 0d; + if (m > 1d) m = 1d; + return m; + } + } + + /// 기준선을 지금 HP 로 다시 잡는다(런 시작 · 부활 · PC 교체 · 회복). + public static void Rebase() + { + s_armed = false; RebaseCount++; + } + + /// 러너가 Update · LateUpdate 에서 부른다. 비활성이면 즉시 반환(C8). + public static void Poll() + { + var st = St; + if (st == null || !WLSurvivalSettings.Enabled || !st.unscaledDamageGuardEnabled) { s_armed = false; return; } + + var pc = MyValue.MyPC; + if (pc == null) { s_armed = false; s_pc = null; return; } + if (!ReferenceEquals(pc, s_pc)) { s_pc = pc; s_armed = false; } + + PollCount++; + double hp = pc.Get_HP(); + double hookLoss = Survival.HookHpLoss; + + if (!s_armed) + { + s_lastHp = hp; s_lastHookLoss = hookLoss; s_armed = true; + return; + } + + double drop = s_lastHp - hp; // > 0 = 잃었다 + double accounted = hookLoss - s_lastHookLoss; // 훅이 책임지는 감소량 + if (accounted < 0d) accounted = 0d; + + if (drop <= Epsilon) // 회복했거나 변화 없음 → 기준선만 갱신 + { + s_lastHp = hp; s_lastHookLoss = hookLoss; + return; + } + + double unscaled = drop - accounted; + if (unscaled <= Epsilon) // 전부 훅을 거쳤다 = 정상 + { + s_lastHp = hp; s_lastHookLoss = hookLoss; + return; + } + + // ── 배율 미적용 경로를 잡았다 + DetectedCount++; DetectedHp += unscaled; + LastDetected = unscaled; LastDetectTime = Application.isPlaying ? Time.time : Survival.Now; + + double mult = Multiplier; + double refund = unscaled * (1d - mult); + double maxHp = pc.Get_MaxHP(); + + if (pc.IsOnlyDead() || hp <= 0d) + { + SkippedDead++; LastRefund = 0d; // 이미 죽었다 — Heal 은 IsDead 로 막힌다 + } + else if (refund >= 1d) // Actor.Heal 은 1 미만도 1 로 올린다 → 과환급 방지 + { + // Actor.Heal 은 마지막에 HUD(Get_HUDUI)를 건드린다 — 인게임 밖(프로브·전환 중)에서는 그 줄이 던질 수 있다. + // HP 대입은 그보다 앞이므로 실제로 오른 만큼을 실측해 센다(예외가 나도 전투 흐름을 끊지 않는다). + try { pc.Heal(refund, false); } + catch (System.Exception) { } // 🔴 LogError 금지(813z) + double healed = pc.Get_HP() - hp; + if (healed > 0d) { RefundCount++; RefundedHp += healed; } + LastRefund = healed > 0d ? healed : 0d; + } + else LastRefund = 0d; + + double hpAfter = pc.Get_HP(); + DeathDiag.Record(DamageVia.Unscaled, null, unscaled, mult, unscaled, hpAfter, maxHp); + + if (st.unscaledDamageGuardLog) + Debug.Log("[WL813m3] unscaled dmg " + unscaled.ToString("F1") + " ×" + mult.ToString("F2") + + " → refund " + LastRefund.ToString("F1") + + " hp=" + hpAfter.ToString("F0") + "/" + maxHp.ToString("F0")); // 🔴 Log 만(813z) + + s_lastHp = hpAfter; s_lastHookLoss = hookLoss; + } + + /// 프로브용: 카운터 초기화 + 기준선 해제. + public static void ResetDiagnostics() + { + DetectedCount = RefundCount = SkippedDead = PollCount = RebaseCount = 0; + DetectedHp = RefundedHp = LastDetected = LastRefund = 0d; + LastDetectTime = 0f; + s_pc = null; s_armed = false; s_lastHp = 0d; s_lastHookLoss = 0d; + } + + /// 프로브용: 기준선을 직접 주입한다(Play 없이 폴링 로직 검증). + public static void ProbeArm(PCActor pc, double hp, double hookLoss) + { + s_pc = pc; s_lastHp = hp; s_lastHookLoss = hookLoss; s_armed = true; + } + } +} diff --git a/Assets/WL/Combat/Survival/DamageGuard.cs.meta b/Assets/WL/Combat/Survival/DamageGuard.cs.meta new file mode 100644 index 000000000..62bae8d5a --- /dev/null +++ b/Assets/WL/Combat/Survival/DamageGuard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 813d3b2d5f8c5a1b0e3d7f9a2c4e6b8d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/WL/Combat/Survival/DeathDiag.cs b/Assets/WL/Combat/Survival/DeathDiag.cs new file mode 100644 index 000000000..89969ff7e --- /dev/null +++ b/Assets/WL/Combat/Survival/DeathDiag.cs @@ -0,0 +1,259 @@ +// ───────────────────────────────────────────────────────────────────────────── +// DeathDiag.cs — 사망 직전 피해 로그(링버퍼 16 · 사망마다 파일 1개 + 콘솔 1줄) +// +// PD 지시 #813 · 발주서 WL-813m3 §1-2 · 근거 = Q9 `qa/W2-9_재채점_v7.md` D-21 +// (런당 사망 4/6/6 · 피해 배율 0.15→0.1 · 0.25→0.15 로 더 내려도 줄지 않았다 · 최대 피해 44~46 = MaxHP 310 의 15 %) +// +// ■ 무엇을 남기나 — PC 가 HP 를 잃을 때마다 1건: +// 시각(게임 시간) · 프레임 · 경로(via) · 가해자 종류/ID · 원 피해 · 적용 배율 · 실제 깎인 HP · HP 후/최대 +// 경로(via)는 "배율을 탔는가"를 그대로 말해 준다 — +// Scaled = Survival.TakeDamage 가 배율을 곱하고 base 를 불렀다(정상) +// ReviveInv = 부활 무적 창(피해 0) +// SwitchOff = 잡몹/보스 스위치 off(원본 무적 그대로) +// Passthru = SO off · 메인 PC 아님(원본 100 %) +// 🔴 Unscaled = **훅을 거치지 않고 HP 가 깎였다**(DamageGuard 폴링이 잡은 차액 = 배율 미적용 경로) +// +// ■ 기록 경로 GC 0 +// · 사전 할당 struct 배열 16칸(링) · 문자열 0 · 박싱 0 · 가해자는 참조 1개만 담는다(파괴돼도 null 로 읽힌다). +// · 문자열은 **사망 시에만** 만든다(런당 최대 사망 수 = 6회 · StringBuilder 1개 재사용). +// +// ■ 출력 +// · 파일 = <프로젝트 루트>/Screenshots_WL/WL813m3/death__.txt (Assets 밖 · 미추적 · SO 로 경로 변경) +// · 콘솔 = Debug.Log 1줄(`[WL813m3] death #n …`) — **verboseLog 와 무관**(발주서 §1-2 · Q10 이 읽는다). +// 🔴 Debug.LogError/LogException 금지(813z — 그 자체가 파란 모달이 된다). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Survival +{ + /// 피해가 들어온 경로 — "배율을 탔는가" 를 그대로 말한다. + public enum DamageVia : byte + { + Scaled = 0, // Survival.TakeDamage 가 배율을 곱했다 + ReviveInv = 1, // 부활 무적 창 → 원본 무적 경로(피해 0) + SwitchOff = 2, // takeMobDamage/takeBossDamage off → 원본 무적 그대로 + Passthru = 3, // SO off · 메인 PC 아님 → 원본 100 % + Unscaled = 4 // 🔴 훅을 거치지 않은 HP 감소(DoT · Cal_Damage 직접 호출 등) + } + + /// 사망 직전 피해 로그. 정적 · 링버퍼 16 · 기록 경로 GC 0. + public static class DeathDiag + { + /// 링버퍼 칸 수(발주서 WL-813m3 §1-2 고정값 = 구조 상수 · 튜닝 값 아님). + public const int Capacity = 16; + + public struct Hit + { + public float time; // Time.time(게임 시간) + public int frame; + public DamageVia via; + public Actor attacker; // 파괴되면 Unity == null 로 읽힌다 + public int attackerId; + public bool attackerBoss, attackerElite; + public double raw; // 원 피해(Unscaled 는 폴링이 잡은 HP 감소량) + public double mult; // 적용 배율(1 = 미적용) + public double lost; // 실제로 깎인 HP(nested Cal_Damage 포함 · 폴링 실측) + public double hpAfter, maxHp; + } + + static readonly Hit[] s_ring = new Hit[Capacity]; + static int s_next; // 다음에 쓸 칸 + static int s_written; // 지금까지 기록한 총 건수 + + // ── 진단(프로브가 읽는다) + public static int RecordCount { get { return s_written; } } + public static int FileCount; + public static string LastFilePath = ""; + public static string LastSummary = ""; + public static int UnscaledCount; // via = Unscaled 로 기록한 누계 + public static double UnscaledHpTotal; // 그 경로로 잃은 HP 누계 + + static bool s_subscribed; + static StringBuilder s_sb; // 사망 시에만 쓴다 + + static WLSurvivalSettings St { get { return WLSurvivalSettings.Instance; } } + + /// 러너가 매 프레임 부른다(첫 호출에 DeathFlow.Died 구독 · 이후 bool 1개). + public static void EnsureSubscribed() + { + if (s_subscribed) return; + s_subscribed = true; + DeathFlow.Died.Add(OnDied); + } + + /// 기록 1건(GC 0). Survival.TakeDamage · DamageGuard 가 부른다. + public static void Record(DamageVia via, Actor attacker, double raw, double mult, double lost, double hpAfter, double maxHp) + { + var st = St; + if (st == null || !st.deathDiagEnabled) return; + + int i = s_next; + s_ring[i].time = Application.isPlaying ? Time.time : Survival.Now; + s_ring[i].frame = Time.frameCount; + s_ring[i].via = via; + s_ring[i].attacker = attacker; + s_ring[i].attackerId = attacker != null ? attacker.Get_ID() : -1; + s_ring[i].attackerBoss = attacker != null && attacker.IsSubRole(eSubRol.Boss); + s_ring[i].attackerElite = attacker != null && attacker.IsSubRole(eSubRol.Elite); + s_ring[i].raw = raw; + s_ring[i].mult = mult; + s_ring[i].lost = lost; + s_ring[i].hpAfter = hpAfter; + s_ring[i].maxHp = maxHp; + + s_next = (i + 1) % Capacity; + s_written++; + if (via == DamageVia.Unscaled) { UnscaledCount++; UnscaledHpTotal += lost; } + } + + // ───────────────────────────────────────── 사망 시 기록 + + static void OnDied(in DeathEvent e) + { + var st = St; + if (st == null || !st.deathDiagEnabled) return; + try { Dump(e); } + catch (Exception ex) { LastSummary = "[WL813m3] death dump 실패: " + ex.Message; } // 🔴 LogError 금지 + } + + static void Dump(in DeathEvent e) + { + if (s_sb == null) s_sb = new StringBuilder(4096); else s_sb.Length = 0; + + int n = Mathf.Min(s_written, Capacity); + double sumScaled = 0d, sumUnscaled = 0d; + int cntScaled = 0, cntUnscaled = 0; + + s_sb.Append("# WL-813m3 사망 직전 피해 로그 (PD 지시 #813 · Q9 D-21)\n"); + s_sb.Append("death #").Append(e.deathCount) + .Append(" t=").Append(e.time.ToString("F2")) + .Append(" frame=").Append(e.frame) + .Append(" pos=").Append(e.position.ToString("F1")).Append('\n'); + var pc = e.pc; + if (pc != null) + s_sb.Append("pc hp=").Append(pc.Get_HP().ToString("F0")).Append('/').Append(pc.Get_MaxHP().ToString("F0")).Append('\n'); + var st = St; + if (st != null) + s_sb.Append("SO: mobMult=").Append(st.mobDamageMultiplier.ToString("F2")) + .Append(" bossMult=").Append(st.bossDamageMultiplier.ToString("F2")) + .Append(" takeMob=").Append(st.takeMobDamage ? 1 : 0) + .Append(" takeBoss=").Append(st.takeBossDamage ? 1 : 0) + .Append(" potion=").Append(PotionUse.Remaining).Append('/').Append(PotionUse.Max) + .Append(" autoPotion=").Append(st.autoPotionEnabled ? 1 : 0) + .Append('@').Append(st.autoPotionHpRatio.ToString("F2")) + .Append(" guard=").Append(st.unscaledDamageGuardEnabled ? 1 : 0).Append('\n'); + s_sb.Append("\n# t(s) frame via attacker(kind/id) raw mult lost hp_after/max\n"); + + // 링을 오래된 것 → 최신 순으로 + int start = s_written <= Capacity ? 0 : s_next; + for (int k = 0; k < n; k++) + { + int i = (start + k) % Capacity; + ref Hit h = ref s_ring[i]; + if (h.via == DamageVia.Unscaled) { cntUnscaled++; sumUnscaled += h.lost; } + else if (h.via == DamageVia.Scaled) { cntScaled++; sumScaled += h.lost; } + + s_sb.Append(k < 9 ? " " : "").Append(k + 1).Append(" ") + .Append(h.time.ToString("F2")).Append(" ") + .Append(h.frame).Append(" ") + .Append(ViaName(h.via)).Append(" ") + .Append(AttackerKind(ref h)).Append('/').Append(h.attackerId).Append(" ") + .Append(h.raw.ToString("F1")).Append(" ×") + .Append(h.mult.ToString("F2")).Append(" ") + .Append(h.lost.ToString("F1")).Append(" ") + .Append(h.hpAfter.ToString("F0")).Append('/').Append(h.maxHp.ToString("F0")); + if (h.attacker != null) s_sb.Append(" ").Append(h.attacker.name); + s_sb.Append('\n'); + } + + s_sb.Append("\n합계(최근 ").Append(n).Append("건): 배율적용 ").Append(cntScaled).Append("건 ").Append(sumScaled.ToString("F0")) + .Append(" HP · 🔴배율미적용(Unscaled) ").Append(cntUnscaled).Append("건 ").Append(sumUnscaled.ToString("F0")).Append(" HP\n") + .Append("누계: Unscaled ").Append(UnscaledCount).Append("건 ").Append(UnscaledHpTotal.ToString("F0")).Append(" HP · ") + .Append("DamageGuard 환급 ").Append(DamageGuard.RefundCount).Append("건 ").Append(DamageGuard.RefundedHp.ToString("F0")).Append(" HP\n"); + + string path = Write(s_sb.ToString(), e.deathCount); + + // 콘솔 1줄(verboseLog 무관 · Q10 이 읽는다) + LastSummary = "[WL813m3] death #" + e.deathCount + + " t=" + e.time.ToString("F1") + + " last" + n + + " scaled=" + cntScaled + "(" + sumScaled.ToString("F0") + "hp)" + + " unscaled=" + cntUnscaled + "(" + sumUnscaled.ToString("F0") + "hp)" + + " potion=" + PotionUse.Remaining + "/" + PotionUse.Max + + " file=" + (string.IsNullOrEmpty(path) ? "-" : path); + Debug.Log(LastSummary); + } + + static string ViaName(DamageVia v) + { + switch (v) + { + case DamageVia.Scaled: return "Scaled "; + case DamageVia.ReviveInv: return "ReviveInv"; + case DamageVia.SwitchOff: return "SwitchOff"; + case DamageVia.Passthru: return "Passthru "; + default: return "UNSCALED "; + } + } + + static string AttackerKind(ref Hit h) + { + if (h.attackerId < 0) return "none"; + if (h.attackerBoss) return "boss"; + if (h.attackerElite) return "elite"; + return "mob"; + } + + static string Write(string body, int deathCount) + { + try + { + var st = St; + string sub = st != null && !string.IsNullOrEmpty(st.deathDiagDir) ? st.deathDiagDir : "Screenshots_WL/WL813m3"; + string root = Application.isEditor ? Path.GetDirectoryName(Application.dataPath) : Application.persistentDataPath; + string dir = string.IsNullOrEmpty(root) ? sub : Path.Combine(root, sub); + Directory.CreateDirectory(dir); + string file = Path.Combine(dir, "death_" + DateTime.Now.ToString("HHmmss") + "_" + deathCount + ".txt"); + File.WriteAllText(file, body); + LastFilePath = file; FileCount++; + return file; + } + catch (Exception ex) + { + LastFilePath = ""; + LastSummary = "[WL813m3] death 파일 기록 실패: " + ex.Message; // 🔴 LogError 금지 + return ""; + } + } + + /// 프로브용: 링·카운터 초기화(구독은 유지). + public static void ResetDiagnostics() + { + Array.Clear(s_ring, 0, s_ring.Length); + s_next = 0; s_written = 0; FileCount = 0; + LastFilePath = ""; LastSummary = ""; + UnscaledCount = 0; UnscaledHpTotal = 0d; + } + + /// 프로브용: 링 내용을 읽는다(범위 밖이면 기본값). + public static Hit Get(int index) + { + if (index < 0 || index >= Capacity) return default(Hit); + return s_ring[index]; + } + + /// 프로브용: 사망을 강제로 덤프한다(Play 없이 파일 생성 검증). + public static void DumpForProbe(int deathCount) + { + var e = new DeathEvent { pc = null, position = Vector3.zero, deathCount = deathCount, displaySeconds = 0f, time = Survival.Now, frame = Time.frameCount }; + OnDied(in e); + } + } +} diff --git a/Assets/WL/Combat/Survival/DeathDiag.cs.meta b/Assets/WL/Combat/Survival/DeathDiag.cs.meta new file mode 100644 index 000000000..dfb06e2d5 --- /dev/null +++ b/Assets/WL/Combat/Survival/DeathDiag.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 813d3a1c4e7b4f0a9d2c6e8f1b3a5d79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/WL/Combat/Survival/Survival.cs b/Assets/WL/Combat/Survival/Survival.cs index 0236e530e..ad6aa8d30 100644 --- a/Assets/WL/Combat/Survival/Survival.cs +++ b/Assets/WL/Combat/Survival/Survival.cs @@ -45,6 +45,12 @@ namespace WL.Combat.Survival public static bool LastWasBoss; public static bool LastFlagDuringCall; // base 호출 직전 PlayerInvincible 값(기대 false) + /// + /// WL-813m3 — 이 훅을 거쳐 실제로 깎인 PC HP 누계(중첩 Cal_Damage 포함 · base 호출 전후 실측). + /// DamageGuard 가 "총 감소량 − 이 누계" 로 **훅을 안 거친 피해**(배율 미적용 경로)를 뽑는 기준선이다. + /// + public static double HookHpLoss; + static float s_now; // 게임 시간(러너 Tick · 프로브는 직접 넣는다) static float s_reviveInvincibleUntil = -1f; static Actor s_invinciblePc; @@ -66,6 +72,7 @@ namespace WL.Combat.Survival /// DeathFlow 가 부활 직후 부른다. seconds ≤ 0 이면 창 없음. public static void SetReviveInvincible(Actor pc, float seconds) { + DamageGuard.Rebase(); // WL-813m3 — 부활·런 경계로 HP 가 튀면 폴링 기준선을 다시 잡는다(오검출 0) if (pc == null || seconds <= 0f) { s_invinciblePc = null; s_reviveInvincibleUntil = -1f; return; } s_invinciblePc = pc; s_reviveInvincibleUntil = s_now + seconds; @@ -81,14 +88,14 @@ namespace WL.Combat.Survival if (!pc.IsMainPC()) { PassNotMainPC++; baseCall(dinfo); return; } EnsureRunner(); - if (IsReviveInvincible(pc)) { BlockedReviveInvincible++; baseCall(dinfo); return; } // 원본 무적 경로(피해 0 · Damaged(invincible=true)) + if (IsReviveInvincible(pc)) { BlockedReviveInvincible++; CallBase(pc, dinfo, baseCall, DamageVia.ReviveInv, dinfo.Damage, 0d); return; } // 원본 무적 경로(피해 0 · Damaged(invincible=true)) var st = St; bool boss = IsBossSource(dinfo, st); if (boss ? !st.takeBossDamage : !st.takeMobDamage) { if (boss) SkippedBossSwitch++; else SkippedMobSwitch++; - baseCall(dinfo); // 스위치 off = 그 진영엔 원본 무적 그대로 + CallBase(pc, dinfo, baseCall, DamageVia.SwitchOff, dinfo.Damage, 0d); // 스위치 off = 그 진영엔 원본 무적 그대로 return; } @@ -102,7 +109,7 @@ namespace WL.Combat.Survival bool lift = gs != null && gs.playerInvincible; // 이미 꺼져 있으면(PD 가 에셋에서 해제) 손대지 않는다 if (lift) { gs.playerInvincible = false; Lifted++; } LastFlagDuringCall = WL.Settings.WLGameplaySettings.PlayerInvincible; - try { baseCall(dinfo); } + try { CallBase(pc, dinfo, baseCall, DamageVia.Scaled, raw, mult); } finally { if (lift) gs.playerInvincible = true; } // 디스크 값 무변경 · 예외가 나도 되돌린다 Applied++; LastFinalDamage = dinfo.Damage; // Actor.Get_Damage 가 dinfo 를 제자리에서 갱신 → 방어 후 최종 피해 @@ -113,6 +120,25 @@ namespace WL.Combat.Survival + " hp=" + pc.Get_HP().ToString("F0") + "/" + pc.Get_MaxHP().ToString("F0") + (lift ? " (lifted)" : "")); } + /// + /// WL-813m3 — base 호출을 HP 실측으로 감싼다. 호출 전후 차이가 "훅이 책임지는 감소량" 이고 + /// (원본 Get_Damage 안의 중첩 Cal_Damage · 스턴 추가타 · Extra 까지 포함한다) 그 누계가 + /// DamageGuard 의 기준선이 된다. 같은 자리에서 사망 직전 로그 1건도 남긴다(GC 0). + /// + static void CallBase(PCActor pc, DamageInfo dinfo, Action baseCall, DamageVia via, double raw, double mult) + { + double before = pc.Get_HP(); + try { baseCall(dinfo); } + finally + { + double after = pc.Get_HP(); + double lost = before - after; + if (lost < 0d) lost = 0d; + HookHpLoss += lost; + DeathDiag.Record(via, dinfo != null ? dinfo.Beater : null, raw, mult, lost, after, pc.Get_MaxHP()); + } + } + static bool IsBossSource(DamageInfo dinfo, WLSurvivalSettings st) { var beater = dinfo.Beater; @@ -188,6 +214,7 @@ namespace WL.Combat.Survival LastRawDamage = LastScaledDamage = LastFinalDamage = 0d; LastWasBoss = false; LastFlagDuringCall = false; s_reviveInvincibleUntil = -1f; s_invinciblePc = null; RunStarts = 0; LastRunReason = ""; + HookHpLoss = 0d; // 813m3 } } @@ -213,8 +240,16 @@ namespace WL.Combat.Survival AutoPotion.Tick(Time.time); // 813m2 · PotionUse.Tick 뒤(쿨 잔량이 먼저 갱신돼야 IsReady 가 맞다) DeathFlow.Tick(Time.unscaledTime); Survival.PollRunBoundary(); + DeathDiag.EnsureSubscribed(); // 813m3 · 첫 호출에만 DeathFlow.Died 구독 + DamageGuard.Poll(); // 813m3 · 훅을 안 거친 HP 감소 정산 } + /// + /// 813m3 — 도트 코루틴(`WaitForSeconds`)은 Update **뒤**에 재개된다. 같은 프레임에 정산하려면 + /// LateUpdate 에서 한 번 더 봐야 한다(지연 ≈ 0). 비활성이면 Poll 이 즉시 반환한다(C8). + /// + void LateUpdate() { DamageGuard.Poll(); } + void OnDisable() { DeathFlow.ReleaseWorld(); } void OnApplicationQuit() { DeathFlow.ReleaseWorld(); } } diff --git a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs index 858ac2150..9de03524f 100644 --- a/Assets/WL/Combat/Survival/WLSurvivalSettings.cs +++ b/Assets/WL/Combat/Survival/WLSurvivalSettings.cs @@ -126,6 +126,25 @@ namespace WL.Combat.Survival [Tooltip("자동 사용 때 Console 에 1줄 남긴다(런당 최대 potionCountPerRun 회 · verboseLog 와 별개로 켤 수 있다).")] public bool autoPotionLog = true; + // ─────────────────────────────────────────── 사망 원인 진단 · 배율 미적용 경로 가드 (Q9 D-21 · WL-813m3) + // 실측 근거(2026-09-09 · 원본 읽기만): 813i 게이트는 PCActor.Get_Damage 한 곳뿐인데 + // Actor.Co_DotDamage:1536(독/화상/출혈) · Check_CC_byAttack:1116(스턴 추가타) · + // Check_Extra:1191·1197·1202·1222·1229 · Check_EnchantOption:1402·1412·1453 가 + // **Get_Damage 를 거치지 않고 Cal_Damage 로 곧장 HP 를 깎는다**(배율 0 · 방어 공식 0 · PC 무적조차 무시). + // → Q9 D-21 "배율을 0.1/0.15 로 내려도 사망이 안 줄었다" 와 일치한다. + [Header("사망 원인 진단 · 배율 미적용 경로 가드 (Q9 D-21 · WL-813m3)")] + [Tooltip("PC 피해를 링버퍼 16칸에 기록하고 사망마다 파일 1개 + 콘솔 1줄을 남긴다(verboseLog 와 무관). C8 롤백 = 0.")] + public bool deathDiagEnabled = true; + + [Tooltip("사망 로그 폴더(프로젝트 루트 기준 · Assets 밖 · 미추적). 빌드에서는 persistentDataPath 아래.")] + public string deathDiagDir = "Screenshots_WL/WL813m3"; + + [Tooltip("훅(PCActor.Get_Damage)을 거치지 않고 깎인 HP 를 매 프레임 정산해 같은 배율만큼 되돌린다(도트·추가타·인챈트 경로). 배율은 mobDamageMultiplier 를 재사용한다. C8 롤백 = 0.")] + public bool unscaledDamageGuardEnabled = true; + + [Tooltip("가드가 배율 미적용 피해를 잡을 때마다 Console 에 1줄 남긴다(도트는 초당 1~2건이라 기본 off · 사망 파일에는 항상 남는다).")] + public bool unscaledDamageGuardLog = false; + // ─────────────────────────────────────────── 사망 · 부활 [Header("사망 · 부활 (기준서 사망 연출 2 s → 부활 요청 → 존 시작점 On_Regen · 부활 후 무적 1.5 s)")] [Tooltip("사망 연출 길이(초 · unscaled). 이 시간이 지나면 ReviveRequested 를 낸다(813j 팝업).")]