Project_WL/AgentScripts/WL813m3_Probe.cs

601 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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