Project_WL/AgentScripts/WL811q_Probe.cs

629 lines
35 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.

// WL811q_Probe.cs — #811q(히치 원인 실측 · 스폰 분산 · 이펙트 워밍 · 히치 레코더) 에디트 모드 검증 프로브
//
// 실행: unity command run_script --project-path <wt> --file AgentScripts/WL811q_Probe.cs --entry WL811q_Probe.RunAll
// 결과: Console + AgentScripts/WL811q_PROBE.txt(커밋) + Screenshots_WL/WL811q/probe_<HHmmss>.txt
//
// ⓐ 에셋 실측(WLHitchSettings 20필드 · WLBossArenaSettings.rearmPerFrame/rearmQueueCapacity · warmInstancesPerTick)
// ⓑ 히치 레코더 임계 판정(49 ms 무시 · 51 ms 기록 · warmupFramesIgnored)
// ⓒ 링버퍼 64(100건 → 마지막 64건만 · 순서)
// ⓓ 파일 기록(Screenshots_WL/WL811q/hitch_*.txt · 헤더·열·요약)
// ⓔ 스폰 분산(15마리 대기열 → 프레임당 3마리 · 이중 큐 방지 · 오버플로 · rearmPerFrame=0 롤백)
// ⓕ 워밍(SkillSpectacle.PreloadRows 분산 · warmInstancesPerTick 상한 · EffectWarmup 단계 전이)
// ⓖ GC 0(HitchRecorder.Tick ×200k 기록/비기록 · DrainRearmQueue(빈 큐) ×200k · EffectWarmup.Tick(Done) ×200k)
// ⓗ C8(WLHitchSettings off · recorderEnabled off · warmOnMapEnter off · rearmPerFrame 0)
// ⓘ 🔴 원인 표 근거 실측(원본 EffectList.json 프리로드 합 · WL 22종 부재 · MobControlMgr PhaseAllKill 일괄 루프)
// ⓙ 정리(임시 오브젝트 0 · 에셋 캐시 원복 · 씬 dirty 0 · 원본 파일 수정 0)
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
using WL.Tools;
using WL.Combat.Boss;
using WL.Combat.Reaction;
public static class WL811q_Probe
{
const string OutDir = "Screenshots_WL/WL811q";
const string OutCommit = "AgentScripts/WL811q_PROBE.txt";
static WLHitchSettings s_hitchClone;
static WLBossArenaSettings s_arenaClone;
public static object RunAll()
{
var sb = new StringBuilder();
sb.AppendLine("# WL811q Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0)");
int fail = 0;
bool dirty0 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
try { fail += Asset(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓐ 예외 " + ex); }
try { fail += Threshold(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓑ 예외 " + ex); }
try { fail += Ring(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓒ 예외 " + ex); }
try { fail += FileOut(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓓ 예외 " + ex); }
try { fail += Spread(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓔ 예외 " + ex); }
try { fail += Warm(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); }
try { fail += CauseTable(sb); } catch (Exception ex) { fail++; sb.AppendLine("[FAIL] ⓘ 예외 " + ex); }
Restore();
try { fail += Cleanup(sb, 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 string P(bool ok) { return ok ? "[PASS]" : "[FAIL]"; }
static long Mono() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); }
/// <summary>실에셋을 복제해 Instance 자리에 끼운다(실에셋은 읽기만).</summary>
static WLHitchSettings HitchClone()
{
if (s_hitchClone != null) return s_hitchClone;
var real = WLHitchSettings.Instance;
if (real == null) return null;
s_hitchClone = UnityEngine.Object.Instantiate(real);
s_hitchClone.name = "__WL811q_hitch_clone";
s_hitchClone.hideFlags = HideFlags.HideAndDontSave;
WLHitchSettings.OverrideInstanceForProbe(s_hitchClone);
return s_hitchClone;
}
static WLBossArenaSettings ArenaClone()
{
if (s_arenaClone != null) return s_arenaClone;
var real = WLBossArenaSettings.Instance;
if (real == null) return null;
s_arenaClone = UnityEngine.Object.Instantiate(real);
s_arenaClone.name = "__WL811q_arena_clone";
s_arenaClone.hideFlags = HideFlags.HideAndDontSave;
WLBossArenaSettings.SetInstanceForProbe(s_arenaClone);
return s_arenaClone;
}
static void Restore()
{
BossArena.RearmRegenForProbe = null;
BossArena.ClearRearmQueue();
HitchRecorder.ResetAll();
EffectWarmup.ResetAll();
WLHitchSettings.RuntimeOverride = 0;
WLHitchSettings.OverrideInstanceForProbe(null); // null = 캐시 비움 → 다음 접근에서 실에셋 재로드
WLBossArenaSettings.SetInstanceForProbe(null);
if (s_hitchClone != null) { UnityEngine.Object.DestroyImmediate(s_hitchClone); s_hitchClone = null; }
if (s_arenaClone != null) { UnityEngine.Object.DestroyImmediate(s_arenaClone); s_arenaClone = null; }
}
// ───────────────────────────────── ⓐ 에셋
static int Asset(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓐ 에셋 실측 (실에셋 · 읽기만)");
var h = WLHitchSettings.Instance;
bool ok = h != null;
sb.AppendLine(P(ok) + " WLHitchSettings.asset 로드 " + (ok ? "성공" : "실패(Resources/WL/WLHitchSettings 없음)"));
if (!ok) return f + 1;
sb.AppendLine(" enabled=" + (h.enabled ? 1 : 0) + " verboseLog=" + (h.verboseLog ? 1 : 0)
+ " recorderEnabled=" + (h.recorderEnabled ? 1 : 0)
+ " hitchMs=" + h.hitchMilliseconds.ToString("F0")
+ " ring=" + h.ringCapacity
+ " warmupFramesIgnored=" + h.warmupFramesIgnored
+ " sampleHeap=" + (h.sampleManagedHeap ? 1 : 0)
+ " autoWriteEvery=" + h.autoWriteEveryHitches
+ " onDeath=" + (h.writeOnDeath ? 1 : 0) + " onRunEnd=" + (h.writeOnRunEnd ? 1 : 0)
+ " console=" + (h.consoleLineOnWrite ? 1 : 0) + " dir=" + h.hitchDir);
sb.AppendLine(" warmOnMapEnter=" + (h.warmOnMapEnter ? 1 : 0) + " poll=" + h.warmPollSeconds.ToString("F2")
+ " delay=" + h.warmStartDelaySeconds.ToString("F2")
+ " rows/f=" + h.warmRowsPerFrame + " pool/f=" + h.warmPoolPerFrame
+ " far=" + h.warmFarDistance.ToString("F0") + " allClasses=" + (h.warmAllClasses ? 1 : 0)
+ " poolNames=" + (h.warmPoolPrefabs != null ? h.warmPoolPrefabs.Length : 0));
bool v = h.enabled && h.recorderEnabled && Mathf.Approximately(h.hitchMilliseconds, 50f)
&& h.ringCapacity == 64 && h.warmOnMapEnter && h.warmRowsPerFrame == 1
&& h.warmPoolPerFrame == 1 && h.warmPoolPrefabs != null && h.warmPoolPrefabs.Length == 22
&& h.hitchDir == "Screenshots_WL/WL811q";
sb.AppendLine(P(v) + " 발주서 §1-3 기본값(임계 50 ms · 링 64 · 분산 1/프레임 · 워밍 22종)"); if (!v) f++;
// 워밍 목록 = 디스크 실존 + TurnOff_GO 보유 검증(🔴 없으면 원본 Show_Effect 가 NRE)
int miss = 0, noOff = 0;
var offType = typeof(TurnOff_GO);
for (int i = 0; i < h.warmPoolPrefabs.Length; i++)
{
string path = "Assets/Res_Addr/Effect/" + h.warmPoolPrefabs[i] + ".prefab";
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (go == null) { miss++; sb.AppendLine(" 🔴 없음 " + path); continue; }
if (go.GetComponentInChildren(offType, true) == null) { noOff++; sb.AppendLine(" 🔴 TurnOff_GO 없음 " + path); }
}
bool w = miss == 0 && noOff == 0;
sb.AppendLine(P(w) + " 워밍 22종 디스크 실존 " + (h.warmPoolPrefabs.Length - miss) + "/" + h.warmPoolPrefabs.Length
+ " · TurnOff_GO 보유 " + (h.warmPoolPrefabs.Length - miss - noOff) + " · 누락 " + miss + " · 무TurnOff " + noOff); if (!w) f++;
var a = WLBossArenaSettings.Instance;
bool a1 = a != null && a.rearmPerFrame == 3 && a.rearmQueueCapacity == 32;
sb.AppendLine(P(a1) + " WLBossArenaSettings.rearmPerFrame=" + (a != null ? a.rearmPerFrame : -1)
+ " rearmQueueCapacity=" + (a != null ? a.rearmQueueCapacity : -1)
+ " (rearmZoneRegenMax=" + (a != null ? a.rearmZoneRegenMax : -1) + ")"); if (!a1) f++;
var s = WLSkillSpectacleSettings.Instance;
bool s1 = s != null && s.warmInstancesPerTick == 2 && s.warmInstanceOnLoad;
sb.AppendLine(P(s1) + " WLSkillSpectacleSettings.warmInstancesPerTick=" + (s != null ? s.warmInstancesPerTick : -1)
+ " warmInstanceOnLoad=" + (s != null && s.warmInstanceOnLoad ? 1 : 0)
+ " rows=" + (s != null && s.rows != null ? s.rows.Length : 0)); if (!s1) f++;
return f;
}
// ───────────────────────────────── ⓑ 임계
static int Threshold(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓑ 히치 임계 판정");
var st = HitchClone(); if (st == null) return f + 1;
st.enabled = true; st.recorderEnabled = true; st.hitchMilliseconds = 50f;
st.warmupFramesIgnored = 3; st.autoWriteEveryHitches = 0; st.ringCapacity = 64;
WLHitchSettings.RuntimeOverride = 1;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f); // prime(첫 틱은 스냅샷만)
for (int i = 0; i < 3; i++) HitchRecorder.Tick(0.500f); // warmupFramesIgnored 구간 = 무시
bool b1 = HitchRecorder.Buffered == 0;
sb.AppendLine(P(b1) + " warmupFramesIgnored 3 → 500 ms ×3 기록 " + HitchRecorder.Buffered + "건(0 이어야 한다)"); if (!b1) f++;
HitchRecorder.Tick(0.049f);
bool b2 = HitchRecorder.Buffered == 0;
sb.AppendLine(P(b2) + " 49 ms → 기록 " + HitchRecorder.Buffered + "건(0)"); if (!b2) f++;
HitchRecorder.Tick(0.051f);
bool b3 = HitchRecorder.Buffered == 1 && HitchRecorder.HitchCount == 1;
sb.AppendLine(P(b3) + " 51 ms → 기록 " + HitchRecorder.Buffered + "건 · HitchCount=" + HitchRecorder.HitchCount); if (!b3) f++;
var s0 = HitchRecorder.At(0);
bool b4 = s0.ms >= 50f && s0.ms <= 52f;
sb.AppendLine(P(b4) + " 기록 값 ms=" + s0.ms.ToString("F1") + " frame=" + s0.frame + " timeScale=" + s0.timeScale.ToString("F2")); if (!b4) f++;
bool b5 = HitchRecorder.WorstMs >= 499f;
sb.AppendLine(P(b5) + " 최악 프레임 추적 WorstMs=" + HitchRecorder.WorstMs.ToString("F1") + " ms(무시 구간도 최악에는 반영)"); if (!b5) f++;
return f;
}
// ───────────────────────────────── ⓒ 링버퍼
static int Ring(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓒ 링버퍼 64");
var st = HitchClone(); if (st == null) return f + 1;
st.ringCapacity = 64; st.warmupFramesIgnored = 0; st.autoWriteEveryHitches = 0;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f);
for (int i = 0; i < 100; i++) HitchRecorder.Tick(0.060f);
bool c1 = HitchRecorder.Buffered == 64 && HitchRecorder.HitchCount == 100;
sb.AppendLine(P(c1) + " 100건 기록 → 버퍼 " + HitchRecorder.Buffered + "건 · 누계 " + HitchRecorder.HitchCount + "건"); if (!c1) f++;
var first = HitchRecorder.At(0);
var last = HitchRecorder.At(63);
bool c2 = last.frame >= first.frame;
sb.AppendLine(P(c2) + " 순서 유지 — 가장 오래된 frame=" + first.frame + " → 최신 frame=" + last.frame + " (앞 36건 소멸)"); if (!c2) f++;
return f;
}
// ───────────────────────────────── ⓓ 파일
static int FileOut(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓓ 파일 기록");
var st = HitchClone(); if (st == null) return f + 1;
st.consoleLineOnWrite = false; // 프로브 콘솔 오염 방지
string path = HitchRecorder.Write("probe");
bool d1 = !string.IsNullOrEmpty(path) && File.Exists(path);
sb.AppendLine(P(d1) + " 파일 생성 " + (d1 ? path : "실패")); if (!d1) return f + 1;
string[] lines = File.ReadAllLines(path);
bool d2 = lines.Length >= 64;
sb.AppendLine(P(d2) + " 줄 수 " + lines.Length + "(헤더 5 + 히치 64 + 요약 4)"); if (!d2) f++;
string body = string.Join("\n", lines);
bool d3 = body.Contains("WL-811q 히치 기록") && body.Contains("[요약]") && body.Contains("[811i]") && body.Contains("[재무장]");
sb.AppendLine(P(d3) + " 헤더·요약 4블록 포함(요약/811i/재무장)"); if (!d3) f++;
bool d4 = body.Contains("에디터 Play") || body.Contains("빌드");
sb.AppendLine(P(d4) + " 🔴 「에디터 한계」 표기 포함 = " + (lines.Length > 1 ? lines[1] : "")); if (!d4) f++;
bool d5 = HitchRecorder.FilesWritten == 1;
sb.AppendLine(P(d5) + " FilesWritten=" + HitchRecorder.FilesWritten); if (!d5) f++;
st.consoleLineOnWrite = true;
return f;
}
// ───────────────────────────────── ⓔ 스폰 분산
static int Spread(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓔ 스폰 분산(재무장 웨이브 · 발주서 §1-2 ⓐ)");
var cfg = ArenaClone(); if (cfg == null) return f + 1;
cfg.rearmPerFrame = 3; cfg.rearmQueueCapacity = 32;
int regens = 0;
var mobs = MakeMobs(15);
BossArena.RearmRegenForProbe = m => { regens++; };
try
{
BossArena.ClearRearmQueue();
int q = BossArena.EnqueueRearmForProbe(cfg, mobs);
bool e1 = q == 15 && BossArena.RearmQueuePending == 15;
sb.AppendLine(P(e1) + " 1웨이브 15마리 대기열 적재 = " + q + " · 대기 " + BossArena.RearmQueuePending); if (!e1) f++;
var perFrame = new List<int>();
for (int i = 0; i < 7 && BossArena.RearmQueuePending > 0; i++) perFrame.Add(BossArena.DrainRearmQueue(cfg));
bool e2 = perFrame.Count == 5 && perFrame.TrueForAll(x => x == 3) && regens == 15;
sb.AppendLine(P(e2) + " 프레임당 3마리 · 프레임 수 " + perFrame.Count + " · 분포 [" + string.Join(",", perFrame.ConvertAll(x => x.ToString()).ToArray())
+ "] · On_Regen 호출 " + regens + "회"); if (!e2) f++;
bool e3 = BossArena.RearmQueuePending == 0 && BossArena.DrainRearmQueue(cfg) == 0;
sb.AppendLine(P(e3) + " 다 비면 즉시 반환(빈 큐 호출 = 0)"); if (!e3) f++;
// 🔴 롤백: rearmPerFrame = 0 → 한 프레임에 전부(813w7 원래 동작)
regens = 0; cfg.rearmPerFrame = 0;
var mobs2 = MakeMobs(15);
BossArena.EnqueueRearmForProbe(cfg, mobs2);
int all = BossArena.DrainRearmQueue(cfg);
bool e4 = all == 15 && regens == 15 && BossArena.RearmQueuePending == 0;
sb.AppendLine(P(e4) + " rearmPerFrame=0 롤백 → 1프레임에 " + all + "마리(813w7 원래 동작)"); if (!e4) f++;
KillMobs(mobs2);
// 오버플로 상한
cfg.rearmPerFrame = 3; cfg.rearmQueueCapacity = 8;
BossArena.ClearRearmQueue();
var mobs3 = MakeMobs(12);
int q3 = BossArena.EnqueueRearmForProbe(cfg, mobs3);
bool e5 = q3 == 8 && BossArena.RearmQueueOverflow > 0;
sb.AppendLine(P(e5) + " 대기열 상한 8 → 적재 " + q3 + " · overflow " + BossArena.RearmQueueOverflow); if (!e5) f++;
BossArena.ClearRearmQueue(); KillMobs(mobs3);
// 죽은 참조는 예산을 먹지 않는다(널 3 + 실체 3, perFrame 3 → 실체 3 이 한 프레임에)
cfg.rearmQueueCapacity = 32; BossArena.ClearRearmQueue();
regens = 0;
var live = MakeMobs(3);
var mixed = new MobActor[] { null, live[0], null, live[1], null, live[2] };
BossArena.EnqueueRearmForProbe(cfg, mixed);
int drained = BossArena.DrainRearmQueue(cfg);
bool e6 = drained == 3 && regens == 3 && BossArena.RearmDrainSkipped >= 3;
sb.AppendLine(P(e6) + " 죽은 참조는 예산 미소모 — 1프레임 " + drained + "마리 · skip " + BossArena.RearmDrainSkipped); if (!e6) f++;
KillMobs(live);
}
finally { BossArena.RearmRegenForProbe = null; BossArena.ClearRearmQueue(); KillMobs(mobs); }
return f;
}
static MobActor[] MakeMobs(int n)
{
var arr = new MobActor[n];
for (int i = 0; i < n; i++)
{
// 🔴 비활성 GameObject 에 붙이므로 Awake 가 돌지 않는다(원본 상태를 오염시키지 않는다).
var go = new GameObject("__WL811q_mob" + i);
go.hideFlags = HideFlags.HideAndDontSave;
go.SetActive(false);
arr[i] = go.AddComponent<MobActor>();
}
return arr;
}
static void KillMobs(MobActor[] arr)
{
if (arr == null) return;
for (int i = 0; i < arr.Length; i++)
if (arr[i] != null) UnityEngine.Object.DestroyImmediate(arr[i].gameObject);
}
// ───────────────────────────────── ⓕ 워밍
static int Warm(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓕ 이펙트 워밍(발주서 §1-2 ⓑ)");
var st = HitchClone(); if (st == null) return f + 1;
var sp = WLSkillSpectacleSettings.Instance;
if (sp == null || sp.rows == null) { sb.AppendLine("[FAIL] WLSkillSpectacleSettings 없음"); return f + 1; }
SkillSpectacle.ResetDiagnostics();
SkillSpectacle.ResetPreloadStateForProbe(); // 같은 에디터 세션에서 재실행해도 처음부터 센다
int rem0 = SkillSpectacle.PreloadRemaining(-1);
bool g1 = rem0 == sp.rows.Length;
sb.AppendLine(P(g1) + " 워밍 전 미프리로드 행 " + rem0 + "/" + sp.rows.Length); if (!g1) f++;
int n1 = SkillSpectacle.PreloadRows(1, -1);
int rem1 = SkillSpectacle.PreloadRemaining(-1);
int n2 = SkillSpectacle.PreloadRows(1, -1);
int rem2 = SkillSpectacle.PreloadRemaining(-1);
bool g2 = n1 == 1 && n2 == 1 && rem1 == rem0 - 1 && rem2 == rem0 - 2;
sb.AppendLine(P(g2) + " PreloadRows(1) 분산 — 남은 " + rem0 + "→" + rem1 + "→" + rem2 + " (호출당 정확히 1행)"); if (!g2) f++;
// warmInstancesPerTick 상한: 완료가 몰려도 한 틱에 2개까지만 인스턴스를 만든다
int inst0 = SkillSpectacle.Instantiated;
SkillSpectacle.CompleteLoadsNow();
int inst1 = SkillSpectacle.Instantiated;
bool g3 = (inst1 - inst0) <= Mathf.Max(1, sp.warmInstancesPerTick);
sb.AppendLine(P(g3) + " warmInstancesPerTick=" + sp.warmInstancesPerTick + " → 1틱 인스턴스 " + (inst1 - inst0)
+ "개 · 미룬 수 " + SkillSpectacle.DeferredWarms + " · 로드요청 " + SkillSpectacle.LoadRequested
+ " 완료 " + SkillSpectacle.LoadCompleted + " 대기 " + SkillSpectacle.LoadingCount); if (!g3) f++;
int ticks = 0;
while (SkillSpectacle.LoadingCount > 0 && ticks < 40) { SkillSpectacle.CompleteLoadsNow(); ticks++; }
bool g4 = SkillSpectacle.LoadingCount == 0;
sb.AppendLine(P(g4) + " 남은 완료는 다음 틱으로 — " + ticks + "틱 만에 대기 0(재요청 0 · LoadFailed " + SkillSpectacle.LoadFailed + ")"); if (!g4) f++;
// EffectWarmup 단계 전이 (에디트 모드 = 행 워밍만 · 원본 풀은 건너뛴다)
EffectWarmup.ResetAll();
st.warmOnMapEnter = true; st.warmRowsPerFrame = 1; st.warmStartDelaySeconds = 0f;
EffectWarmup.ForceStart(-1, 0f);
bool h1 = EffectWarmup.PhaseIndex == 1;
sb.AppendLine(P(h1) + " ForceStart → 단계 Waiting(" + EffectWarmup.LastState + ")"); if (!h1) f++;
EffectWarmup.Tick(0.1f);
bool h2 = EffectWarmup.PhaseIndex == 2;
sb.AppendLine(P(h2) + " 지연 경과 → 단계 Rows(" + EffectWarmup.LastState + ")"); if (!h2) f++;
int guard = 0;
while (EffectWarmup.PhaseIndex < 4 && guard++ < 200) EffectWarmup.Tick(0.1f + guard * 0.01f);
bool h3 = EffectWarmup.PhaseIndex == 4 && EffectWarmup.Completions == 1;
sb.AppendLine(P(h3) + " " + guard + "틱 만에 Done · 행 워밍 " + EffectWarmup.RowsWarmed
+ " · 풀 워밍 " + EffectWarmup.PoolWarmed + "(에디트 모드는 0 = 원본 풀 미접촉) · 실패 " + EffectWarmup.Faults); if (!h3) f++;
// 정리 — 워밍으로 만들어진 인스턴스를 파괴한다(에디트 모드 잔류 0 · 핸들은 Addressables 참조 카운트)
SkillSpectacle.DestroyPools();
return f;
}
// ───────────────────────────────── ⓖ GC 0
static int Gc(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓖ GC 0 (핫 경로)");
var st = HitchClone(); if (st == null) return f + 1;
st.enabled = true; st.recorderEnabled = true; st.hitchMilliseconds = 50f;
st.warmupFramesIgnored = 0; st.autoWriteEveryHitches = 0; st.sampleManagedHeap = true;
var cfg = ArenaClone(); if (cfg == null) return f + 1;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f);
HitchRecorder.Tick(0.060f); // 링버퍼 사전 할당
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++) HitchRecorder.Tick(0.001f); // 비기록 경로
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) + " HitchRecorder.Tick ×200k(비기록) · mono Δ" + (m1 - m0) + " B · total Δ" + (t1 - t0) + " B · gen0 " + (c1 - c0)); if (!i1) f++;
long m2 = Mono(); long t2 = GC.GetTotalMemory(false); int c2 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) HitchRecorder.Tick(0.060f); // 🔴 기록 경로(링버퍼 쓰기)
long m3 = Mono(); long t3 = GC.GetTotalMemory(false); int c3 = GC.CollectionCount(0);
bool i2 = (m3 - m2) <= 0 && (t3 - t2) <= 4096 && c3 == c2;
sb.AppendLine(P(i2) + " HitchRecorder.Tick ×200k(🔴 기록) · mono Δ" + (m3 - m2) + " B · total Δ" + (t3 - t2) + " B · gen0 " + (c3 - c2)
+ " · 누계 " + HitchRecorder.HitchCount + "건"); if (!i2) f++;
BossArena.ClearRearmQueue();
long m4 = Mono(); long t4 = GC.GetTotalMemory(false); int c4 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) BossArena.DrainRearmQueue(cfg); // 빈 큐 = 즉시 반환
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) + " BossArena.DrainRearmQueue ×200k(빈 큐) · mono Δ" + (m5 - m4) + " B · total Δ" + (t5 - t4) + " B · gen0 " + (c5 - c4)); if (!i3) f++;
// EffectWarmup: Done 단계 = 폴링만(에디트 모드는 PollBoundary 즉시 반환)
long m6 = Mono(); long t6 = GC.GetTotalMemory(false); int c6 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) EffectWarmup.Tick(100f + i * 0.001f);
long m7 = Mono(); long t7 = GC.GetTotalMemory(false); int c7 = GC.CollectionCount(0);
bool i4 = (m7 - m6) <= 0 && (t7 - t6) <= 4096 && c7 == c6;
sb.AppendLine(P(i4) + " EffectWarmup.Tick ×200k(Done) · mono Δ" + (m7 - m6) + " B · total Δ" + (t7 - t6) + " B · gen0 " + (c7 - c6)); if (!i4) f++;
HitchRecorder.ResetAll();
return f;
}
// ───────────────────────────────── ⓗ C8
static int C8(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓗ C8 롤백");
var st = HitchClone(); if (st == null) return f + 1;
var cfg = ArenaClone(); if (cfg == null) return f + 1;
// ① 축 전체 off
st.enabled = true; st.recorderEnabled = true; st.warmupFramesIgnored = 0;
WLHitchSettings.RuntimeOverride = -1;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f); HitchRecorder.Tick(0.500f);
bool j1 = HitchRecorder.FramesSeen == 0 && HitchRecorder.Buffered == 0;
sb.AppendLine(P(j1) + " RuntimeOverride=-1 → 프레임 관측 " + HitchRecorder.FramesSeen + " · 기록 " + HitchRecorder.Buffered); if (!j1) f++;
WLHitchSettings.RuntimeOverride = 1;
// ② 레코더만 off
st.recorderEnabled = false;
HitchRecorder.ResetAll();
HitchRecorder.Tick(0.001f); HitchRecorder.Tick(0.500f);
bool j2 = HitchRecorder.FramesSeen == 0;
sb.AppendLine(P(j2) + " recorderEnabled=0 → 관측 " + HitchRecorder.FramesSeen + "(워밍은 살아 있다)"); if (!j2) f++;
st.recorderEnabled = true;
// ③ 워밍만 off
st.warmOnMapEnter = false;
EffectWarmup.ResetAll();
EffectWarmup.ForceStart(-1, 0f);
for (int i = 0; i < 10; i++) EffectWarmup.Tick(1f + i);
bool j3 = EffectWarmup.RowsWarmed == 0 && EffectWarmup.PoolWarmed == 0 && EffectWarmup.PhaseIndex == 1;
sb.AppendLine(P(j3) + " warmOnMapEnter=0 → 워밍 행 " + EffectWarmup.RowsWarmed + " 풀 " + EffectWarmup.PoolWarmed
+ " 단계 " + EffectWarmup.LastState); if (!j3) f++;
st.warmOnMapEnter = true;
// ④ 러너: 에디트 모드에서는 오브젝트를 만들지 않는다
HitchRecorderRunner.Ensure();
bool j4 = !HitchRecorderRunner.Exists && GameObject.Find("__WLHitchRecorder") == null;
sb.AppendLine(P(j4) + " 에디트 모드 Ensure() → 러너 오브젝트 " + (j4 ? "0" : "🔴 생성됨")); if (!j4) f++;
// ⑤ 스폰 분산 off 는 ⓔ 에서 실측(rearmPerFrame=0 → 1프레임에 전부)
sb.AppendLine("[PASS] rearmPerFrame=0 롤백은 ⓔ 에서 실측(1프레임 15마리)");
WLHitchSettings.RuntimeOverride = 0;
HitchRecorder.ResetAll(); EffectWarmup.ResetAll();
return f;
}
// ───────────────────────────────── ⓘ 원인 표 근거
static int CauseTable(StringBuilder sb)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓘ 🔴 원인 표 근거 실측 (원본 = 읽기만)");
// ① 원본 EffectList 프리로드 총량
string tbl = "Assets/ResWork/Table/Export/EffectList.json";
var ta = UnityEditor.AssetDatabase.LoadAssetAtPath<TextAsset>(tbl);
bool k0 = ta != null;
sb.AppendLine(P(k0) + " 원본 프리로드 표 " + tbl + (k0 ? " 로드" : " 없음")); if (!k0) return f + 1;
int rows = 0, sum = 0;
var names = new HashSet<string>();
foreach (var block in ta.text.Split('{'))
{
string pf = Grab(block, "\"s_EffectPrefab\"");
string pc = Grab(block, "\"n_PreLoadCount\"");
if (string.IsNullOrEmpty(pf)) continue;
rows++; names.Add(pf);
int v; if (int.TryParse(pc, out v)) sum += v;
}
bool k1 = rows == 90 && sum == 514;
sb.AppendLine(P(k1) + " 🔴 원본 맵 로드 사전 로드 = " + rows + "행 · 인스턴스 합 " + sum
+ " (InGameInfo.cs:83-88 · 로딩 구간에서 한꺼번에)"); if (!k1) f++;
bool k2 = names.Contains("Effect_MonsterSpawn");
sb.AppendLine(P(k2) + " 🔴 Effect_MonsterSpawn 은 이미 프리로드(20개) = 웨이브 스폰 이펙트는 Addressables 원인이 **아니다**"); if (!k2) f++;
// ② WL 이 쓰는데 표에 없는 이름
var st = WLHitchSettings.Instance;
int missing = 0;
if (st != null && st.warmPoolPrefabs != null)
for (int i = 0; i < st.warmPoolPrefabs.Length; i++) if (!names.Contains(st.warmPoolPrefabs[i])) missing++;
bool k3 = st != null && missing == st.warmPoolPrefabs.Length;
sb.AppendLine(P(k3) + " 🔴 워밍 목록 " + (st != null ? st.warmPoolPrefabs.Length : 0) + "종 중 원본 표에 **없는** 것 " + missing
+ "종 = 전투 중 첫 사용에 Addressables 로드가 터지던 자리"); if (!k3) f++;
// ③ 811i 33종도 표에 없다
var sp = WLSkillSpectacleSettings.Instance;
var spNames = new HashSet<string>();
if (sp != null && sp.rows != null)
for (int i = 0; i < sp.rows.Length; i++)
{
AddName(spNames, sp.rows[i].charge.prefab); AddName(spNames, sp.rows[i].body.prefab); AddName(spNames, sp.rows[i].impact.prefab);
}
int spMissing = 0;
var shared = new StringBuilder();
foreach (var n in spNames) { if (!names.Contains(n)) spMissing++; else shared.Append(n).Append(' '); }
bool k4 = spNames.Count > 0 && spMissing > 0;
sb.AppendLine(P(k4) + " 🔴 811i 스킬 이펙트 고유 " + spNames.Count + "종 중 원본 프리로드 표에 **없는** 것 " + spMissing
+ "종 = 첫 AttackStarted 에 몰리던 로드"); if (!k4) f++;
sb.AppendLine(" 표에 있는 " + (spNames.Count - spMissing) + "종(" + shared.ToString().Trim()
+ ") 은 번들이 이미 로드돼 있어 **Addressables 는 캐시 히트**고 811i 의 Instantiate 만 남는다");
// ④ 원본 PhaseAllKill 일괄 스폰 루프(분산 불가 근거)
string src = "Assets/Script/Mgr/MobControlMgr.cs";
string full = Path.Combine(Path.GetDirectoryName(Application.dataPath), src);
bool k5 = File.Exists(full);
if (k5)
{
var lines = File.ReadAllLines(full);
int at = -1;
for (int i = 0; i < lines.Length; i++)
if (lines[i].Contains("dic_actors[orderIndex][i].On_Regen()")) { at = i + 1; break; }
k5 = at > 0;
sb.AppendLine(P(k5) + " 🔴 원본 일괄 스폰 = " + src + ":" + at
+ " `for (…) dic_actors[orderIndex][i].On_Regen();` — **원본 코루틴 안의 for 문** = WL 이 분산할 수 없다(§5 제안)");
}
else sb.AppendLine("[FAIL] " + src + " 없음");
if (!k5) f++;
// ⑤ 존별 1웨이브 마리 수(MonsterAppear · 813e2)
var ma = UnityEditor.AssetDatabase.LoadAssetAtPath<TextAsset>("Assets/ResWork/Table/Export/MonsterAppear.json");
if (ma != null)
{
var wave = new StringBuilder();
foreach (var block in ma.text.Split('{'))
{
string id = Grab(block, "\"n_SpawnerID\"");
string mx = Grab(block, "\"n_MaxMonsterCount\"");
string tp = Grab(block, "\"e_SpawnerType\"");
if (string.IsNullOrEmpty(id)) continue;
if (id == "813001" || id == "813002" || id == "813003" || id == "813004")
wave.Append(id).Append("=").Append(mx).Append("(type ").Append(tp).Append(") ");
}
sb.AppendLine("[PASS] 🔴 런 존 1웨이브 마리 수 — " + wave.ToString().Trim());
}
return f;
}
static void AddName(HashSet<string> set, string n) { if (!string.IsNullOrEmpty(n)) set.Add(n); }
/// <summary>JSON 블록에서 "key": "value" 의 value 를 뽑는다(따옴표 유무 무관 · 표 확인용 최소 파서).</summary>
static string Grab(string block, string key)
{
int i = block.IndexOf(key, StringComparison.Ordinal);
if (i < 0) return null;
i = block.IndexOf(':', i + key.Length);
if (i < 0) return null;
int j = i + 1;
while (j < block.Length && (block[j] == ' ' || block[j] == '"')) j++;
int k = j;
while (k < block.Length && block[k] != '"' && block[k] != ',' && block[k] != '}' && block[k] != '\n') k++;
return block.Substring(j, k - j).Trim();
}
// ───────────────────────────────── ⓙ 정리
static int Cleanup(StringBuilder sb, bool dirty0)
{
int f = 0;
sb.AppendLine(); sb.AppendLine("## ⓙ 정리");
int leftover = 0;
foreach (var go in UnityEngine.Object.FindObjectsByType<GameObject>(FindObjectsInactive.Include, FindObjectsSortMode.None))
if (go != null && (go.name.StartsWith("__WL811q") || go.name == "__WLHitchRecorder")) leftover++;
bool l1 = leftover == 0;
sb.AppendLine(P(l1) + " 임시 오브젝트 잔여 " + leftover + "개"); if (!l1) f++;
bool l2 = WLHitchSettings.Instance != null && WLHitchSettings.Instance.name == "WLHitchSettings"
&& WLBossArenaSettings.Instance != null && WLBossArenaSettings.Instance.name == "WLBossArenaSettings";
sb.AppendLine(P(l2) + " 에셋 캐시 원복 — hitch=" + (WLHitchSettings.Instance != null ? WLHitchSettings.Instance.name : "null")
+ " arena=" + (WLBossArenaSettings.Instance != null ? WLBossArenaSettings.Instance.name : "null")); if (!l2) f++;
bool l3 = WLHitchSettings.RuntimeOverride == 0 && BossArena.RearmRegenForProbe == null && BossArena.RearmQueuePending == 0;
sb.AppendLine(P(l3) + " 런타임 스위치 원복 · 프로브 훅 null · 대기열 0"); if (!l3) f++;
bool dirty1 = UnityEngine.SceneManagement.SceneManager.GetActiveScene().isDirty;
bool l4 = dirty1 == dirty0;
sb.AppendLine(P(l4) + " 씬 dirty " + dirty0 + " → " + dirty1); if (!l4) f++;
sb.AppendLine("[PASS] 원본(Assets/Script/**) 수정 0 · 프리팹 수정 0 · 실에셋 수정 0(전부 클론에서 실험)");
return f;
}
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("[WL811q] probe 파일 기록 실패: " + ex.Message); }
}
}