[WL-813m3] 사망 원인 진단 로그 · 피해 경로 가드 · 빈 머티리얼 가드 (#813)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-09 19:04:07 +09:00
parent fc9a265bd7
commit 02a6336298
17 changed files with 1635 additions and 3 deletions

View File

@ -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×(10.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

View File

@ -0,0 +1,600 @@
// WL813m3_Probe.cs — #813m3(사망 원인 진단 · 배율 미적용 경로 가드 · 빈 머티리얼 가드) 에디트 모드 검증 프로브
//
// 실행: unity command run_script --project-path <wt> --file AgentScripts/WL813m3_Probe.cs --entry WL813m3_Probe.RunAll
// 결과: Console + AgentScripts/WL813m3_PROBE.txt(커밋) + Screenshots_WL/WL813m3/probe_<HHmmss>.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<double>();
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×(10.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<MeshRenderer>();
rBad.sharedMaterials = new Material[] { null }; // Q9 실측 = slot 0 이 null
var rBadRoot = bad.GetComponent<MeshRenderer>();
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<MeshRenderer>().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<MeshRenderer>().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<Renderer>();
var mats = new List<Material>();
for (int i = 0; i < Q9Prefabs.Length; i++)
{
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(Q9Prefabs[i]);
if (go == null) { missing++; sb.AppendLine("| " + (i + 1) + " | " + Q9Prefabs[i] + " | — | ❌ 디스크에 없음 |"); continue; }
rends.Clear(); go.GetComponentsInChildren<Renderer>(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<MeshRenderer>().sharedMaterials = new Material[] { null };
NullMaterialGuard.Inspect(bad); NullMaterialGuard.Tick(999f);
bool j4 = !WLNullMaterialSettings.Enabled && NullMaterialGuard.InspectedObjects == 0 && NullMaterialGuard.SweepCount == 0
&& bad.GetComponent<MeshRenderer>().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<GameObject>(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<PCActor>();
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<Actor>();
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;
}
/// <summary>가짜 base.Get_Damage — 넘어온 (배율 적용된) 피해만큼 PC HP 를 실제로 깎는다.</summary>
static Action<DamageInfo> ApplyToPc(PCActor pc)
{
return d => { if (d != null) pc.Get_StatInfo().Set_Stat(false, eStat.HP, d.Damage); };
}
/// <summary>가짜 base.Get_Damage — 원본 무적 경로(피해 0).</summary>
static Action<DamageInfo> 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); }
}
}

View File

@ -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<Renderer>(true, List)` · `Renderer.GetSharedMaterials(List)` 둘 다 비할당 오버로드.
// 캐시는 `HashSet<int>`(성장 때만 할당) · 문자열은 **증거 표본 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
{
/// <summary>빈 머티리얼 슬롯 렌더러를 찾아 끈다. 정적 · 스폰 1회 검사 · GC 0.</summary>
public static class NullMaterialGuard
{
// ── 진단(프로브가 읽는다)
/// <summary>스윕 횟수 · 검사한 오브젝트 수 · 검사한 렌더러 수.</summary>
public static int SweepCount, InspectedObjects, InspectedRenderers;
/// <summary>빈 슬롯을 가진 렌더러 수 · 실제로 끈 수.</summary>
public static int NullMaterialRenderers, DisabledRenderers;
/// <summary>결함이 있던 오브젝트 수(렌더러가 아니라 스폰 단위).</summary>
public static int OffendingObjects;
/// <summary>마지막으로 끈 경로 · 마지막 스윕 시각(unscaled).</summary>
public static string LastPath = "";
public static float LastSweepTime;
/// <summary>증거 표본(경로 문자열 · pathSampleMax 개까지).</summary>
public static readonly List<string> Samples = new List<string>(16);
static readonly HashSet<int> s_inspected = new HashSet<int>();
static readonly List<Renderer> s_rends = new List<Renderer>(32);
static readonly List<Material> s_mats = new List<Material>(8);
static StringBuilder s_sb;
static float s_nextSweep;
static WLNullMaterialSettings St { get { return WLNullMaterialSettings.Instance; } }
/// <summary>캐시에 담긴 인스턴스 수(프로브).</summary>
public static int CacheCount { get { return s_inspected.Count; } }
// ───────────────────────────────────────── 스윕
/// <summary>러너가 매 프레임 부른다. 주기·비활성 판정은 여기서 한다(C8 = 즉시 반환).</summary>
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);
}
}
/// <summary>루트의 직속 자식 중 처음 보는 것만 검사한다. 남은 예산을 돌려준다.</summary>
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;
}
// ───────────────────────────────────────── 검사
/// <summary>WL 스폰 지점용 공개 API — 스폰 직후 1회 부르면 된다(캐시 · 중복 호출 안전).</summary>
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<Renderer>(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();
}
/// <summary>프로브용: 카운터·캐시 초기화.</summary>
public static void ResetDiagnostics()
{
SweepCount = InspectedObjects = InspectedRenderers = 0;
NullMaterialRenderers = DisabledRenderers = OffendingObjects = 0;
LastPath = ""; LastSweepTime = 0f; s_nextSweep = 0f;
Samples.Clear(); s_inspected.Clear();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 813d3c3e6a9d6b2c1f4e8a0b3d5c7e9f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>NullMaterialGuard 전용 러너. DontDestroyOnLoad · Update 1개.</summary>
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<NullMaterialGuardRunner>();
}
void Update() { NullMaterialGuard.Tick(Time.unscaledTime); }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (WLNullMaterialSettings.Enabled) Ensure(); // 꺼져 있으면 오브젝트 0(C8)
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 813d3d4f7b0e7c3d2a5f9b1c4e6d8a0b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
{
/// <summary>빈 머티리얼 슬롯을 만났을 때 무엇을 끄나.</summary>
public enum NullMaterialAction
{
/// <summary>Renderer.enabled = false (최소 침습 · 기본).</summary>
DisableRenderer = 0,
/// <summary>렌더러가 붙은 GameObject 를 비활성(자식까지 사라진다).</summary>
DeactivateObject = 1,
/// <summary>아무것도 하지 않는다(계측만 · A/B 비교용).</summary>
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;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLNullMaterialSettings Instance
{
get
{
if (!s_lookupDone) { s_cached = Resources.Load<WLNullMaterialSettings>(ResourcesPath); s_lookupDone = true; }
return s_cached;
}
}
/// <summary>QA·A/B 전용 런타임 스위치. 0 = 에셋의 enabled · 1 = 강제 on · -1 = 강제 off.</summary>
public static int RuntimeOverride;
/// <summary>가드가 살아 있는가. false 면 스윕도 러너도 없다(C8).</summary>
public static bool Enabled
{
get
{
var i = Instance;
if (i == null || RuntimeOverride < 0) return false;
return RuntimeOverride > 0 || i.enabled;
}
}
/// <summary>테스트용: 캐시를 비운다.</summary>
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;
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 813d3e508c1f8d4e3b6a0c2d5f7e9b1c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 813d3f61ad2b0e9f4c7b1d3a6e8c0f25
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@ -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
{
/// <summary>훅을 거치지 않은 PC HP 감소를 같은 배율로 되돌린다. 정적 · GC 0.</summary>
public static class DamageGuard
{
/// <summary>이 값보다 작은 감소는 무시한다(부동소수 잡음 · 구조 상수).</summary>
const double Epsilon = 0.5d;
// ── 진단(프로브가 읽는다)
/// <summary>훅을 안 거친 감소를 잡은 횟수.</summary>
public static int DetectedCount;
/// <summary>그 경로로 깎인 HP 누계(환급 전).</summary>
public static double DetectedHp;
/// <summary>실제로 환급(Heal)한 횟수 · HP 누계.</summary>
public static int RefundCount;
public static double RefundedHp;
/// <summary>죽어 있어서 환급하지 못한 횟수.</summary>
public static int SkippedDead;
/// <summary>폴링 횟수 · 기준선 재설정 횟수.</summary>
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; } }
/// <summary>가드가 실제로 도는가 = 생존 축 on + 이 스위치 on.</summary>
public static bool Active
{
get { var st = St; return WLSurvivalSettings.Enabled && st != null && st.unscaledDamageGuardEnabled; }
}
/// <summary>폴링에 쓰는 배율(잡몹 배율 재사용 · 0~1 로 자른다).</summary>
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;
}
}
/// <summary>기준선을 지금 HP 로 다시 잡는다(런 시작 · 부활 · PC 교체 · 회복).</summary>
public static void Rebase()
{
s_armed = false; RebaseCount++;
}
/// <summary>러너가 Update · LateUpdate 에서 부른다. 비활성이면 즉시 반환(C8).</summary>
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;
}
/// <summary>프로브용: 카운터 초기화 + 기준선 해제.</summary>
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;
}
/// <summary>프로브용: 기준선을 직접 주입한다(Play 없이 폴링 로직 검증).</summary>
public static void ProbeArm(PCActor pc, double hp, double hookLoss)
{
s_pc = pc; s_lastHp = hp; s_lastHookLoss = hookLoss; s_armed = true;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 813d3b2d5f8c5a1b0e3d7f9a2c4e6b8d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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_<HHmmss>_<n>.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
{
/// <summary>피해가 들어온 경로 — "배율을 탔는가" 를 그대로 말한다.</summary>
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 직접 호출 등)
}
/// <summary>사망 직전 피해 로그. 정적 · 링버퍼 16 · 기록 경로 GC 0.</summary>
public static class DeathDiag
{
/// <summary>링버퍼 칸 수(발주서 WL-813m3 §1-2 고정값 = 구조 상수 · 튜닝 값 아님).</summary>
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; } }
/// <summary>러너가 매 프레임 부른다(첫 호출에 DeathFlow.Died 구독 · 이후 bool 1개).</summary>
public static void EnsureSubscribed()
{
if (s_subscribed) return;
s_subscribed = true;
DeathFlow.Died.Add(OnDied);
}
/// <summary>기록 1건(GC 0). Survival.TakeDamage · DamageGuard 가 부른다.</summary>
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 "";
}
}
/// <summary>프로브용: 링·카운터 초기화(구독은 유지).</summary>
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;
}
/// <summary>프로브용: 링 내용을 읽는다(범위 밖이면 기본값).</summary>
public static Hit Get(int index)
{
if (index < 0 || index >= Capacity) return default(Hit);
return s_ring[index];
}
/// <summary>프로브용: 사망을 강제로 덤프한다(Play 없이 파일 생성 검증).</summary>
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);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 813d3a1c4e7b4f0a9d2c6e8f1b3a5d79
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -45,6 +45,12 @@ namespace WL.Combat.Survival
public static bool LastWasBoss;
public static bool LastFlagDuringCall; // base 호출 직전 PlayerInvincible 값(기대 false)
/// <summary>
/// WL-813m3 — 이 훅을 거쳐 실제로 깎인 PC HP 누계(중첩 Cal_Damage 포함 · base 호출 전후 실측).
/// DamageGuard 가 "총 감소량 이 누계" 로 **훅을 안 거친 피해**(배율 미적용 경로)를 뽑는 기준선이다.
/// </summary>
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
/// <summary>DeathFlow 가 부활 직후 부른다. seconds ≤ 0 이면 창 없음.</summary>
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)" : ""));
}
/// <summary>
/// WL-813m3 — base 호출을 HP 실측으로 감싼다. 호출 전후 차이가 "훅이 책임지는 감소량" 이고
/// (원본 Get_Damage 안의 중첩 Cal_Damage · 스턴 추가타 · Extra 까지 포함한다) 그 누계가
/// DamageGuard 의 기준선이 된다. 같은 자리에서 사망 직전 로그 1건도 남긴다(GC 0).
/// </summary>
static void CallBase(PCActor pc, DamageInfo dinfo, Action<DamageInfo> 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 감소 정산
}
/// <summary>
/// 813m3 — 도트 코루틴(`WaitForSeconds`)은 Update **뒤**에 재개된다. 같은 프레임에 정산하려면
/// LateUpdate 에서 한 번 더 봐야 한다(지연 ≈ 0). 비활성이면 Poll 이 즉시 반환한다(C8).
/// </summary>
void LateUpdate() { DamageGuard.Poll(); }
void OnDisable() { DeathFlow.ReleaseWorld(); }
void OnApplicationQuit() { DeathFlow.ReleaseWorld(); }
}

View File

@ -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 팝업).")]