// 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); } } }