diff --git a/AgentScripts/WL811q_PROBE.txt b/AgentScripts/WL811q_PROBE.txt new file mode 100644 index 000000000..e21afdf15 --- /dev/null +++ b/AgentScripts/WL811q_PROBE.txt @@ -0,0 +1,77 @@ +# WL811q Probe 2026-09-10 00:57:32 (edit mode · Play 0) + +## ⓐ 에셋 실측 (실에셋 · 읽기만) +[PASS] WLHitchSettings.asset 로드 성공 + enabled=1 verboseLog=0 recorderEnabled=1 hitchMs=50 ring=64 warmupFramesIgnored=30 sampleHeap=1 autoWriteEvery=16 onDeath=1 onRunEnd=1 console=1 dir=Screenshots_WL/WL811q + warmOnMapEnter=1 poll=0.25 delay=1.50 rows/f=1 pool/f=1 far=100000 allClasses=0 poolNames=22 +[PASS] 발주서 §1-3 기본값(임계 50 ms · 링 64 · 분산 1/프레임 · 워밍 22종) +[PASS] 워밍 22종 디스크 실존 22/22 · TurnOff_GO 보유 22 · 누락 0 · 무TurnOff 0 +[PASS] WLBossArenaSettings.rearmPerFrame=3 rearmQueueCapacity=32 (rearmZoneRegenMax=15) +[PASS] WLSkillSpectacleSettings.warmInstancesPerTick=2 warmInstanceOnLoad=1 rows=16 + +## ⓑ 히치 임계 판정 +[PASS] warmupFramesIgnored 3 → 500 ms ×3 기록 0건(0 이어야 한다) +[PASS] 49 ms → 기록 0건(0) +[PASS] 51 ms → 기록 1건 · HitchCount=1 +[PASS] 기록 값 ms=51.0 frame=12 timeScale=1.00 +[PASS] 최악 프레임 추적 WorstMs=500.0 ms(무시 구간도 최악에는 반영) + +## ⓒ 링버퍼 64 +[PASS] 100건 기록 → 버퍼 64건 · 누계 100건 +[PASS] 순서 유지 — 가장 오래된 frame=12 → 최신 frame=12 (앞 36건 소멸) + +## ⓓ 파일 기록 +[PASS] 파일 생성 E:\NerdNavis\WL_wt\gameplay2\Screenshots_WL/WL811q\hitch_005732.txt +[PASS] 줄 수 73(헤더 5 + 히치 64 + 요약 4) +[PASS] 헤더·요약 4블록 포함(요약/811i/재무장) +[PASS] 🔴 「에디터 한계」 표기 포함 = # 실행 = 에디터 Play(🔴 실기보다 느리다 · 실기 판정은 APK 빌드) +[PASS] FilesWritten=1 + +## ⓔ 스폰 분산(재무장 웨이브 · 발주서 §1-2 ⓐ) +[PASS] 1웨이브 15마리 대기열 적재 = 15 · 대기 15 +[PASS] 프레임당 3마리 · 프레임 수 5 · 분포 [3,3,3,3,3] · On_Regen 호출 15회 +[PASS] 다 비면 즉시 반환(빈 큐 호출 = 0) +[PASS] rearmPerFrame=0 롤백 → 1프레임에 15마리(813w7 원래 동작) +[PASS] 대기열 상한 8 → 적재 8 · overflow 1 +[PASS] 죽은 참조는 예산 미소모 — 1프레임 3마리 · skip 3 + +## ⓕ 이펙트 워밍(발주서 §1-2 ⓑ) +[PASS] 워밍 전 미프리로드 행 16/16 +[PASS] PreloadRows(1) 분산 — 남은 16→15→14 (호출당 정확히 1행) +[PASS] warmInstancesPerTick=2 → 1틱 인스턴스 2개 · 미룬 수 1 · 로드요청 4 완료 2 대기 2 +[PASS] 남은 완료는 다음 틱으로 — 1틱 만에 대기 0(재요청 0 · LoadFailed 0) +[PASS] ForceStart → 단계 Waiting(Waiting) +[PASS] 지연 경과 → 단계 Rows(Rows) +[PASS] 15틱 만에 Done · 행 워밍 14 · 풀 워밍 0(에디트 모드는 0 = 원본 풀 미접촉) · 실패 0 + +## ⓖ GC 0 (핫 경로) +[PASS] HitchRecorder.Tick ×200k(비기록) · mono Δ0 B · total Δ0 B · gen0 0 +[PASS] HitchRecorder.Tick ×200k(🔴 기록) · mono Δ0 B · total Δ0 B · gen0 0 · 누계 200001건 +[PASS] BossArena.DrainRearmQueue ×200k(빈 큐) · mono Δ0 B · total Δ0 B · gen0 0 +[PASS] EffectWarmup.Tick ×200k(Done) · mono Δ0 B · total Δ0 B · gen0 0 + +## ⓗ C8 롤백 +[PASS] RuntimeOverride=-1 → 프레임 관측 0 · 기록 0 +[PASS] recorderEnabled=0 → 관측 0(워밍은 살아 있다) +[PASS] warmOnMapEnter=0 → 워밍 행 0 풀 0 단계 Waiting +[PASS] 에디트 모드 Ensure() → 러너 오브젝트 0 +[PASS] rearmPerFrame=0 롤백은 ⓔ 에서 실측(1프레임 15마리) + +## ⓘ 🔴 원인 표 근거 실측 (원본 = 읽기만) +[PASS] 원본 프리로드 표 Assets/ResWork/Table/Export/EffectList.json 로드 +[PASS] 🔴 원본 맵 로드 사전 로드 = 90행 · 인스턴스 합 514 (InGameInfo.cs:83-88 · 로딩 구간에서 한꺼번에) +[PASS] 🔴 Effect_MonsterSpawn 은 이미 프리로드(20개) = 웨이브 스폰 이펙트는 Addressables 원인이 **아니다** +[PASS] 🔴 워밍 목록 22종 중 원본 표에 **없는** 것 22종 = 전투 중 첫 사용에 Addressables 로드가 터지던 자리 +[PASS] 🔴 811i 스킬 이펙트 고유 33종 중 원본 프리로드 표에 **없는** 것 27종 = 첫 AttackStarted 에 몰리던 로드 + 표에 있는 6종(Effect_Hit_10101 Effect_PCDash Effect_Hit_10102 Effect_SwordShield Effect_STR_EXTRA_SPLASH Effect_Hit_10501) 은 번들이 이미 로드돼 있어 **Addressables 는 캐시 히트**고 811i 의 Instantiate 만 남는다 +[PASS] 🔴 원본 일괄 스폰 = Assets/Script/Mgr/MobControlMgr.cs:221 `for (…) dic_actors[orderIndex][i].On_Regen();` — **원본 코루틴 안의 for 문** = WL 이 분산할 수 없다(§5 제안) +[PASS] 🔴 런 존 1웨이브 마리 수 — 813001=12(type PhaseAllKill) 813002=15(type PhaseAllKill) 813003=14(type PhaseAllKill) 813004=8(type PhaseAllKill) + +## ⓙ 정리 +[PASS] 임시 오브젝트 잔여 0개 +[PASS] 에셋 캐시 원복 — hitch=WLHitchSettings arena=WLBossArenaSettings +[PASS] 런타임 스위치 원복 · 프로브 훅 null · 대기열 0 +[PASS] 씬 dirty False → False +[PASS] 원본(Assets/Script/**) 수정 0 · 프리팹 수정 0 · 실에셋 수정 0(전부 클론에서 실험) + +RESULT PASS diff --git a/AgentScripts/WL811q_Probe.cs b/AgentScripts/WL811q_Probe.cs new file mode 100644 index 000000000..cafd2a3dc --- /dev/null +++ b/AgentScripts/WL811q_Probe.cs @@ -0,0 +1,628 @@ +// WL811q_Probe.cs — #811q(히치 원인 실측 · 스폰 분산 · 이펙트 워밍 · 히치 레코더) 에디트 모드 검증 프로브 +// +// 실행: unity command run_script --project-path --file AgentScripts/WL811q_Probe.cs --entry WL811q_Probe.RunAll +// 결과: Console + AgentScripts/WL811q_PROBE.txt(커밋) + Screenshots_WL/WL811q/probe_.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(); } + + /// 실에셋을 복제해 Instance 자리에 끼운다(실에셋은 읽기만). + 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(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(); + 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(); + } + 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(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(); + 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(); + 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("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 set, string n) { if (!string.IsNullOrEmpty(n)) set.Add(n); } + + /// JSON 블록에서 "key": "value" 의 value 를 뽑는다(따옴표 유무 무관 · 표 확인용 최소 파서). + 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(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); } + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs index dd126421f..d744a70d0 100644 --- a/Assets/WL/Combat/Boss/BossArena.cs +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -189,6 +189,13 @@ namespace WL.Combat.Boss const int kMaxRearmZones = 16; // 버퍼 크기지 게임 수치가 아니다 static readonly int[] s_rearmIds = new int[kMaxRearmZones]; static readonly float[] s_rearmEmptySince = new float[kMaxRearmZones]; + + // ── 811q §1-2 ⓐ 스폰 분산: 재무장 대상 몹을 담아 두고 프레임당 N 마리씩 깨운다. + static MobActor[] s_rearmQueue; + static int s_rearmQueueCount, s_rearmQueueNext; + // 대기열에 이미 들어 있는 존 id — 같은 존을 두 번 얹지 않는다(이중 스폰 방지). 존은 서로 겹치지 않고 같이 들어갈 수 있다. + static readonly int[] s_rearmQueueZoneIds = new int[kMaxRearmZones]; + static int s_rearmQueueZoneCount; static int s_rearmCount = -1; // −1 = 아직 목록을 안 잡았다 static float s_nextZoneRearmCheck; @@ -219,6 +226,14 @@ namespace WL.Combat.Boss public static int ZoneSpawnHoldTicks, ZoneSpawnHoldTargets; /// 813w7 §1-2 ②: 차단을 건너뛴 횟수(게이트 전 · 런 존 제외) — D-45 수리가 실제로 도는지의 증거. public static int ZoneSpawnHoldSkipped; + + // ───────────────────────────────────────── 811q 스폰 분산 진단 + /// 분산 대기열에 남은 마리 수(0 = 분산 없음/완료). 히치 레코더가 프레임 델타로 읽는다. + public static int RearmQueuePending { get { return s_rearmQueueCount - s_rearmQueueNext; } } + /// 분산으로 대기열에 넣은 총 마리 수 · 대기열을 통해 실제로 깨운 총 마리 수 · 대기열 상한에 걸려 버린 수. + public static int RearmQueued, RearmDrained, RearmQueueOverflow; + /// 대기열 드레인 중 이미 살아나 있어 건너뛴 수(중복 방지가 실제로 걸린 증거). + public static int RearmDrainSkipped; /// 이 런의 게이트 판정 기준 처치 수(= TotalZoneKills − 런 시작 스냅샷). public static int GateKills { get { int v = TotalZoneKills - s_totalKillBase; return v > 0 ? v : 0; } } @@ -1232,6 +1247,10 @@ namespace WL.Combat.Boss var info = ActorInfo.Ins; if (DSUtil.CheckNull(info)) return 0; + // 🔴 811q §1-2 ⓐ — 이 존의 앞 웨이브가 아직 프레임 분산 중이면 새 웨이브를 얹지 않는다(이중 스폰 방지). + // 다른 존은 같이 대기열에 들어갈 수 있다(「다음 런」에서 존 2개를 한 번에 재무장하는 813w7 동작 보존). + if (IsZoneQueued(spawnerId)) return 0; + int want = WaveSize(cfg, spawnerId); if (want <= 0) return 0; @@ -1251,11 +1270,28 @@ namespace WL.Combat.Boss if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) return 0; } + // 🔴 811q §1-2 ⓐ — perFrame > 0 이면 여기서 깨우지 않고 **대기열에 담기만** 한다. + // On_Regen() 1회 = SetActive(true)(애니메이터 리바인드) + NavMeshAgent.enabled + Set_Warp + // + 스폰 이펙트 1개 + Spawned 이벤트. 15마리를 한 프레임에 하면 그 프레임이 통째로 길어진다. + int perFrame = cfg.rearmPerFrame; + if (perFrame > 0) EnsureRearmQueue(cfg); + for (int i = 0; i < actors.Count && done < want; i++) { var mob = actors[i] as MobActor; if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue; - mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로 + + if (perFrame > 0) + { + if (s_rearmQueueCount >= s_rearmQueue.Length) { RearmQueueOverflow++; break; } + s_rearmQueue[s_rearmQueueCount++] = mob; + RearmQueued++; + } + else + { + mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221) — 좌표·이펙트·Spawned 이벤트까지 원본 그대로 + ZoneRearmMobs++; + } done++; } } @@ -1265,13 +1301,94 @@ namespace WL.Combat.Boss if (done > 0) { ZoneRearmCount++; - ZoneRearmMobs += done; LastRearmZoneId = spawnerId; - Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리"); + if (cfg.rearmPerFrame > 0 && s_rearmQueueZoneCount < s_rearmQueueZoneIds.Length) + s_rearmQueueZoneIds[s_rearmQueueZoneCount++] = spawnerId; + Log("존 " + spawnerId + " 재무장(" + reason + ") — " + done + "/" + want + "마리" + + (cfg.rearmPerFrame > 0 ? " (프레임당 " + cfg.rearmPerFrame + "마리 분산)" : "")); } return done; } + /// 이 존이 이미 분산 대기열에 들어 있는가. + static bool IsZoneQueued(int spawnerId) + { + for (int i = 0; i < s_rearmQueueZoneCount; i++) if (s_rearmQueueZoneIds[i] == spawnerId) return true; + return false; + } + + /// 분산 대기열 확보(용량은 SO · 버퍼 크기). + static void EnsureRearmQueue(WLBossArenaSettings cfg) + { + int cap = Mathf.Clamp(cfg.rearmQueueCapacity, 4, 256); + if (s_rearmQueue == null || s_rearmQueue.Length != cap) { s_rearmQueue = new MobActor[cap]; s_rearmQueueCount = s_rearmQueueNext = 0; s_rearmQueueZoneCount = 0; } + if (s_rearmQueueNext >= s_rearmQueueCount) { s_rearmQueueCount = s_rearmQueueNext = 0; s_rearmQueueZoneCount = 0; } // 다 비었으면 앞으로 되감는다 + } + + /// + /// 811q §1-2 ⓐ — 대기열에서 프레임당 rearmPerFrame 마리만 깨운다. Tick 이 매 프레임 부른다. + /// 반환 = 이번 프레임에 깨운 마리 수. 🔴 대기열이 비어 있으면 할당·호출 0(즉시 반환). + /// 프로브가 직접 부를 수 있다. + /// + public static int DrainRearmQueue(WLBossArenaSettings cfg) + { + if (cfg == null || s_rearmQueue == null) return 0; + int pending = s_rearmQueueCount - s_rearmQueueNext; + if (pending <= 0) return 0; + + int per = cfg.rearmPerFrame; + if (per <= 0) per = pending; // 분산을 끄면 남은 것을 한 번에 비운다 + + int done = 0; + while (done < per && s_rearmQueueNext < s_rearmQueueCount) + { + var mob = s_rearmQueue[s_rearmQueueNext]; + s_rearmQueue[s_rearmQueueNext] = null; // 참조를 남기지 않는다(씬 전환 누수 0) + s_rearmQueueNext++; + if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) { RearmDrainSkipped++; continue; } + try + { + var probe = RearmRegenForProbe; + if (probe != null) probe(mob); else mob.On_Regen(); // 실기는 항상 원본 호출(MobControlMgr.cs:221 과 동일) + ZoneRearmMobs++; RearmDrained++; + } + catch (System.Exception ex) { Log("분산 재무장 실패 — " + ex.Message); } + done++; + } + if (s_rearmQueueNext >= s_rearmQueueCount) { s_rearmQueueCount = s_rearmQueueNext = 0; s_rearmQueueZoneCount = 0; } + return done; + } + + /// + /// 프로브 전용 대체 호출 — null 이 아니면 On_Regen() 대신 이것을 부른다. + /// 에디트 모드에는 살아 있는 MobActor(스탯·NavMeshAgent·애니메이터)가 없어 원본 호출을 태울 수 없다. + /// 🔴 실기에서는 항상 null 이다(누구도 대입하지 않는다). Play 종료 시 도메인 리로드로 자동 null. + /// + public static System.Action RearmRegenForProbe; + + /// 프로브 전용 — 대기열에 직접 넣는다(실기 경로는 RearmZone). 반환 = 넣은 수. + public static int EnqueueRearmForProbe(WLBossArenaSettings cfg, MobActor[] mobs) + { + if (cfg == null || mobs == null) return 0; + EnsureRearmQueue(cfg); + int n = 0; + for (int i = 0; i < mobs.Length; i++) + { + if (s_rearmQueueCount >= s_rearmQueue.Length) { RearmQueueOverflow++; break; } + s_rearmQueue[s_rearmQueueCount++] = mobs[i]; + RearmQueued++; n++; + } + return n; + } + + /// 대기열을 비운다(씬 전환 · 런 종료 · C8). + public static void ClearRearmQueue() + { + if (s_rearmQueue != null) System.Array.Clear(s_rearmQueue, 0, s_rearmQueue.Length); + s_rearmQueueCount = s_rearmQueueNext = 0; + s_rearmQueueZoneCount = 0; + } + /// /// 1웨이브 마리 수 — 원본 테이블 `n_MaxMonsterCount`(= `MobControlMgr` 이 쓰는 그 값)를 읽고, /// 못 읽으면 SO `rearmZoneRegenMax`. SO 값은 언제나 상한으로 쓴다(안전). @@ -1461,6 +1578,14 @@ namespace WL.Combat.Boss // 🔴 813w6 §1-2 (D-44) — RunEnded 발행이 끝난 뒤로 미뤄 둔 고정을 여기서 실행한다. if (s_parkPending) PumpDeferredPark(); + // 🔴 811q §1-2 ⓐ — 재무장 웨이브를 프레임당 rearmPerFrame 마리로 나눠 깨운다. + // 대기열이 비어 있으면 즉시 반환(할당 0 · 프레임당 비용 0). + if (RearmQueuePending > 0) + { + try { DrainRearmQueue(cfg); } + catch (System.Exception ex) { ClearRearmQueue(); Log("분산 드레인 중단 — " + ex.Message); } + } + // 🔴 813w7 §1-2 ② (D-45) — 빈 존 감시(자체 주기 · 리쉬 주기와 독립). if (cfg.rearmZoneSpawnsEnabled && now >= s_nextZoneRearmCheck) { @@ -1510,6 +1635,9 @@ namespace WL.Combat.Boss ZoneSpawnHoldSkipped = ZoneRearmCount = ZoneRearmMobs = LastRearmZoneId = 0; s_rearmCount = -1; s_nextZoneRearmCheck = 0f; for (int i = 0; i < kMaxRearmZones; i++) s_rearmEmptySince[i] = -1f; + // 811q 스폰 분산 + ClearRearmQueue(); + RearmQueued = RearmDrained = RearmQueueOverflow = RearmDrainSkipped = 0; // 813w5 s_bossHoldUntil = -1f; s_probeTime = -1f; ParkCount = BossHoldTicks = OutsideRunLeashResets = GateMinBlocked = RunEndNotices = 0; diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs index 8daa35a07..900c74e8f 100644 --- a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -350,6 +350,17 @@ namespace WL.Combat.Boss + "다음 런 시작 시점에 되살린다(D-45 의 직접 처방). 끄면 위의 빈 존 감시로만 되살아난다.")] public bool rearmZoneSpawnsOnRestart = true; + [Tooltip("🔴 WL-811q §1-2 ⓐ 스폰 분산 — 재무장 웨이브(12~15마리)를 **프레임당 이 마리 수**로 나눠 깨운다. " + + "MobActor.On_Regen() 1회 = SetActive(true)(애니메이터 리바인드) + NavMeshAgent.enabled + Set_Warp + " + + "스폰 이펙트 1개 + Spawned 이벤트다 — 15마리를 한 프레임에 하면 그 프레임이 통째로 길어진다. " + + "0 = 예전처럼 한 프레임에 전부(C8 롤백 값 = 813w7 원래 동작). " + + "🔴 원본 `MobControlMgr` 의 PhaseAllKill 일괄 스폰(:220-221)은 이 값으로 나눌 수 없다 — " + + "그것은 원본 코루틴 안의 for 문이다(811q 보고 §5 원본 후보).")] + public int rearmPerFrame = 3; + + [Tooltip("분산 대기열의 칸 수(버퍼 크기지 게임 수치가 아니다). 1웨이브 상한 15 보다 넉넉해야 한다.")] + public int rearmQueueCapacity = 32; + // ─────────────────────────────────────────── 813w8 §1-2 게이트 전 보스 UI 숨김(D-23 재발) [Header("게이트 전 보스 UI 숨김 (813w8 · D-23 다음 런 존 사냥 중 만피 보스 바)")] [Tooltip("🔴 게이트가 열리기 전에는 보스 HP 바·등장 배너를 띄우지 않는다. " diff --git a/Assets/WL/Combat/Reaction/SkillSpectacle.cs b/Assets/WL/Combat/Reaction/SkillSpectacle.cs index 777ff5e15..ee3b58b69 100644 --- a/Assets/WL/Combat/Reaction/SkillSpectacle.cs +++ b/Assets/WL/Combat/Reaction/SkillSpectacle.cs @@ -83,7 +83,9 @@ namespace WL.Combat.Reaction SkippedDisabled, SkippedNoRow, SkippedNotMainPC, SkippedOutOfView, SkippedHeavyBudget, SkippedMediumBudget, SkippedImpactCap, SkippedImpactInterval, SkippedNoDamage, SkippedNotLoaded, SkippedNoPrefab, SkippedPoolFull, SkippedTrackFull, LoadRequested, LoadCompleted, LoadFailed, Instantiated, Expired, HeavyAcquired, HeavyReleased, - TintApplied, PreloadCalls; + TintApplied, PreloadCalls, + // 811q: 워밍 인스턴스를 다음 프레임으로 미룬 횟수(= 완료가 한 프레임에 몰렸다는 증거) + DeferredWarms; public static string LastInfo = ""; public static int ActiveCount { get { return s_activeCount; } } public static int MediumActive { get { return s_mediumActive; } } @@ -305,6 +307,48 @@ namespace WL.Combat.Reaction for (int i = 0; i < st.rows.Length; i++) ResolveRow(st.rows[i].skillId, st.rows[i].classId, st); } + // ───────────────────────────────────────── WL-811q §1-2 ⓑ 사전 워밍(프레임 분산) + // + // 🔴 왜 필요한가: 811i 의 33종은 원본 `EffectList.json` 프리로드 표(90행 · 인스턴스 514)에 + // **한 종도 들어 있지 않다**(실측). 지금은 첫 `AttackStarted` 에 그 클래스 4행(프리팹 최대 12개)이 + // 한꺼번에 로드되고, 완료 콜백이 몰린 프레임에 `warmInstanceOnLoad` 인스턴스가 같이 만들어진다. + // 맵 진입(로딩 구간)으로 앞당기고, 그때도 **프레임당 N 행**으로 나눈다. + + /// 아직 프리로드하지 않은 행 수(워밍 진행률). + public static int PreloadRemaining(int classId) + { + var st = St; + if (st == null || st.rows == null) return 0; + EnsureTables(st); + int n = 0; + for (int i = 0; i < st.rows.Length; i++) + if (!s_preloadedRow[i] && (classId < 0 || st.rows[i].classId == classId)) n++; + return n; + } + + /// + /// 아직 프리로드하지 않은 행을 최대 개만 프리로드한다. + /// < 0 이면 클래스 무관 전 행. 반환 = 이번에 건 행 수. + /// 🔴 할당 = 행당 엔트리 3개(프리팹당 1회 · 이미 있으면 0) — 프레임당 할당은 maxRows 로 제한된다. + /// + public static int PreloadRows(int maxRows, int classId) + { + var st = St; + if (st == null || st.rows == null || maxRows == 0) return 0; + EnsureTables(st); + int done = 0; + for (int i = 0; i < st.rows.Length; i++) + { + if (s_preloadedRow[i]) continue; + if (classId >= 0 && st.rows[i].classId != classId) continue; + s_preloadedRow[i] = true; + ResolveRow(st.rows[i].skillId, st.rows[i].classId, st); + PreloadCalls++; + if (maxRows > 0 && ++done >= maxRows) break; + } + return done; + } + // ───────────────────────────────────────── 스폰 static Vector3 Anchor(Actor a, eEffectLocation loc) { @@ -520,11 +564,25 @@ namespace WL.Combat.Reaction static void PollLoads() { var st = St; + // 🔴 WL-811q §1-2 ⓑ — 한 틱에 만들 워밍 인스턴스 수를 제한한다. + // Addressables 완료 콜백은 같은 프레임 끝에 몰려 들어온다(33종 중 여러 개가 한 프레임에 끝난다). + // 예산을 다 쓰면 남은 완료는 **다음 프레임으로 미룬다**(entry 는 s_loading 에 그대로 = 재요청 0). + // 0 = 무제한 = 811i 원래 동작(C8 롤백 값). + int budget = st != null ? st.warmInstancesPerTick : 0; for (int i = s_loading.Count - 1; i >= 0; i--) { var e = s_loading[i]; if (!e.handle.IsValid()) { e.loading = false; e.failed = true; LoadFailed++; s_loading.RemoveAt(i); continue; } if (!e.handle.IsDone) continue; + + bool willWarm = st != null && st.warmInstanceOnLoad && e.count == 0 + && e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null; + if (willWarm && st.warmInstancesPerTick > 0) + { + if (budget <= 0) { DeferredWarms++; break; } // 예산 소진 — 다음 프레임에 마저 만든다 + budget--; + } + e.loading = false; if (e.handle.Status == AsyncOperationStatus.Succeeded && e.handle.Result != null) { @@ -591,13 +649,23 @@ namespace WL.Combat.Reaction } } + /// + /// 프로브용(811q) — 「이 행은 이미 프리로드했다」 표시를 지운다. 다음 PreloadRows 가 처음부터 다시 센다. + /// 🔴 게임 경로에서는 부르지 않는다(같은 프리팹을 두 번 로드할 이유가 없다 — Addressables 참조 카운트만 는다). + /// + public static void ResetPreloadStateForProbe() + { + if (s_preloadedRow != null) System.Array.Clear(s_preloadedRow, 0, s_preloadedRow.Length); + if (s_rows != null) for (int i = 0; i < s_rows.Length; i++) s_rows[i].resolved = false; + } + /// 프로브용 카운터 초기화(구독·풀 유지). public static void ResetDiagnostics() { CastSeen = FiredSeen = HitSeen = ChargeSpawned = BodySpawned = ImpactSpawned = ImpactFallback = 0; SkippedDisabled = SkippedNoRow = SkippedNotMainPC = SkippedOutOfView = SkippedHeavyBudget = SkippedMediumBudget = 0; SkippedImpactCap = SkippedImpactInterval = SkippedNoDamage = SkippedNotLoaded = SkippedNoPrefab = SkippedPoolFull = SkippedTrackFull = 0; - Instantiated = Expired = HeavyAcquired = HeavyReleased = TintApplied = PreloadCalls = 0; + Instantiated = Expired = HeavyAcquired = HeavyReleased = TintApplied = PreloadCalls = DeferredWarms = 0; LastInfo = ""; } diff --git a/Assets/WL/Combat/Reaction/WLSkillSpectacleSettings.cs b/Assets/WL/Combat/Reaction/WLSkillSpectacleSettings.cs index e9f729d98..b7c8e9e43 100644 --- a/Assets/WL/Combat/Reaction/WLSkillSpectacleSettings.cs +++ b/Assets/WL/Combat/Reaction/WLSkillSpectacleSettings.cs @@ -143,6 +143,13 @@ namespace WL.Combat.Reaction [Tooltip("프리팹 로드가 끝나면 풀 인스턴스 1개를 미리 만들어 둔다(첫 스폰의 Instantiate 스파이크 제거).")] public bool warmInstanceOnLoad = true; + [Tooltip("🔴 WL-811q §1-2 ⓑ — 한 틱(프레임)에 만들 워밍 인스턴스 수의 상한. " + + "Addressables 완료 콜백은 프레임 끝에 **몰려서** 들어온다(33종 중 여러 개가 같은 프레임에 끝난다) — " + + "그때 warmInstanceOnLoad 가 Instantiate 를 한꺼번에 돌리면 그 자체가 히치가 된다. " + + "예산을 넘긴 완료는 다음 프레임으로 미뤄진다(재요청 0 · 지연 1~2 프레임). " + + "0 = 무제한 = 811i 원래 동작(C8 롤백 값).")] + public int warmInstancesPerTick = 2; + // ─────────────────────────────────────────── 차지 [Header("차지 (SkillCast · f_CastingTime>0 이면 그 시간 · 0 이면 발동 플래시)")] [Tooltip("f_CastingTime = 0 인 슬롯 1~3 의 차지 플래시 수명(초). 기획안 F-0B '차지 0.3 s'.")] diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset index 065c8485c..689f9cf0b 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -80,4 +80,6 @@ MonoBehaviour: rearmZoneRegenMax: 15 rearmZoneRunZonesOnly: 1 rearmZoneSpawnsOnRestart: 1 + rearmPerFrame: 3 + rearmQueueCapacity: 32 hideBossUiBeforeGate: 1 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset new file mode 100644 index 000000000..6f64df75e --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset @@ -0,0 +1,56 @@ +%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: 811c0a1b2c3d4e5f60718293a4b5c6d7, type: 3} + m_Name: WLHitchSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Tools.WLHitchSettings + enabled: 1 + verboseLog: 0 + recorderEnabled: 1 + hitchMilliseconds: 50 + ringCapacity: 64 + warmupFramesIgnored: 30 + sampleManagedHeap: 1 + autoWriteEveryHitches: 16 + writeOnDeath: 1 + writeOnRunEnd: 1 + consoleLineOnWrite: 1 + hitchDir: Screenshots_WL/WL811q + warmOnMapEnter: 1 + warmPollSeconds: 0.25 + warmStartDelaySeconds: 1.5 + warmRowsPerFrame: 1 + warmPoolPerFrame: 1 + warmPoolPrefabs: + - Effect_Slash_10101_1 + - Effect_Slash_10101_2 + - Effect_Slash_10101_3 + - Effect_Slash_10102_1 + - Effect_Slash_10102_2 + - Effect_Slash_10102_3 + - Effect_Slash_10102_4 + - Effect_Slash_10201_1 + - Effect_Slash_10201_2 + - Effect_Slash_10201_3 + - Effect_Slash_10202_1 + - Effect_Slash_10202_2 + - Effect_Slash_10202_3 + - Effect_Slash_10202_4 + - Effect_Slash_10501_1 + - Effect_Slash_10501_2 + - Effect_Slash_10501_3 + - Effect_Slash_10502_1 + - Effect_Slash_10502_2 + - Effect_WLStab + - Effect_WLSwingArc + - BoomHit + warmFarDistance: 100000 + warmAllClasses: 0 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset.meta new file mode 100644 index 000000000..5b5b966ee --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 811c0a1b2c3d4e5f60718293a4b5c6da +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset index 2861f11b1..91ff28244 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset @@ -19,6 +19,7 @@ MonoBehaviour: preloadOnFirstAttack: 1 preloadClassOnFirstCast: 1 warmInstanceOnLoad: 1 + warmInstancesPerTick: 2 chargeFlashSeconds: 0.35 chargeFollowCaster: 1 chargeMaxLifetime: 2 diff --git a/Assets/WL/Tools/EffectWarmup.cs b/Assets/WL/Tools/EffectWarmup.cs new file mode 100644 index 000000000..36feabeb6 --- /dev/null +++ b/Assets/WL/Tools/EffectWarmup.cs @@ -0,0 +1,206 @@ +// ───────────────────────────────────────────────────────────────────────────── +// EffectWarmup.cs — 맵 진입 시 이펙트 사전 워밍(프레임 분산) · 히치 1차 완화 ⓑ +// +// PD 지시 #813(버벅임) · 발주서 WL-811q §1-2 ⓑ (2026-09-10) +// +// ■ 관측된 원인 (코드·테이블 실측) +// ① 원본은 맵 로드 때 `EffectList.json` 90행을 돌며 **인스턴스 514개**를 미리 만든다 +// (`InGameInfo.cs:83-88` · `n_PreLoadCount` 합 = 이펙트 312 + 투사체 202). +// 그래서 `Effect_MonsterSpawn`(프리로드 20)·`Effect_Hit_*`(각 10) 같은 것은 **이미 워밍돼 있다**. +// ② 🔴 그런데 **WL 이 쓰는 원본 풀 이펙트 22종**(검기 21 + `BoomHit`)은 그 표에 **한 종도 없다**(실측). +// → 전투 중 첫 사용에서 `InGameInfo.Get_Effect` → `Load_Effect` → **Addressables 로드 + Instantiate**. +// ③ 🔴 811i 스킬 이펙트 **33종**도 그 표에 없다 — 지금은 **첫 `AttackStarted`** 에 그 클래스 4행이 +// 한꺼번에 로드되고(811i §1 ④), 완료 콜백이 몰린 프레임에 워밍 인스턴스가 같이 만들어진다. +// +// ■ 무엇을 하나 (원본 훅 0 · 원본 파일 수정 0) +// 맵 진입 경계(메인 PC 교체 또는 맵 ID 변경 — `AutoCombatOnEnter` 와 **같은 판정**)를 보고, +// `warmStartDelaySeconds` 뒤부터 **프레임당 N 개씩** 나눠서 +// ⓐ 811i 행 → `SkillSpectacle.PreloadRows(warmRowsPerFrame, classId)` +// ⓑ 원본 풀 22종 → `InGameInfo.Show_Effect(name, Vector3.one * warmFarDistance)` +// (원본 자신의 사전 로드 `InGameInfo.cs:87` 과 **같은 좌표·같은 경로**. 프리팹의 `TurnOff_GO.OnEnable` +// → `Set_Off()` 가 수명 뒤 스스로 꺼서 풀로 돌려놓는다 = 화면에 보이지 않고 잔류도 없다.) +// ⇒ 첫 전투에서 터지던 로드·Instantiate 를 **로딩 직후 조용한 구간**으로 옮기고, 그 구간에서도 나눈다. +// +// ■ 🔴 안전 +// · `warmPoolPrefabs` 의 이름은 프리팹에 `TurnOff_GO` 가 있어야 한다(없으면 원본 `Show_Effect` 가 +// `GetComponent()` null 에서 NRE). **기본 22종은 디스크에서 전수 확인**했다(811q 보고 §4). +// · 워밍은 맵 경계당 1회. 실패해도 런을 죽이지 않게 전체를 try/catch 로 감싸고 한 번 실패하면 그 판은 멈춘다. +// · `Application.isPlaying` 이 아니면 아무것도 하지 않는다(에디트 모드 오염 0 · 프로브는 별도 진입점). +// +// ■ C8 롤백 +// `WLHitchSettings.enabled = 0` → 러너 자체가 없다 · `warmOnMapEnter = 0` → 워밍 0 +// (그러면 811i 는 예전처럼 첫 `AttackStarted` 에 프리로드하고 22종은 첫 사용에 로드된다 = 100 % 기존 동작). +// +// 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것. +// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Reaction; + +namespace WL.Tools +{ + /// 맵 진입 이펙트 워밍. 정적 · 러너는 HitchRecorderRunner 가 겸한다(새 오브젝트 0). + public static class EffectWarmup + { + enum Phase { Idle = 0, Waiting = 1, Rows = 2, Pool = 3, Done = 4, Faulted = 5 } + + static Phase s_phase; + static PCActor s_lastPc; + static int s_lastMapId = int.MinValue; + static float s_nextPoll, s_startAt; + static int s_poolIndex, s_classId = -1; + + // ───────────────────────────────────────── 진단(프로브·레코더가 읽는다) + /// 맵 경계를 본 횟수 · 워밍한 811i 행 수 · 워밍한 원본 풀 이름 수. + public static int Boundaries, RowsWarmed, PoolWarmed; + /// 워밍 완료(=Done) 도달 횟수 · 실패 횟수. + public static int Completions, Faults; + /// 레코더가 프레임 델타로 쓰는 진행 총계. + public static int WarmedTotal { get { return RowsWarmed + PoolWarmed; } } + /// 현재 단계 이름(파일 요약에 그대로 들어간다). + public static string LastState { get { return s_phase.ToString(); } } + public static string LastLog = ""; + + static WLHitchSettings St { get { return WLHitchSettings.Instance; } } + + // ───────────────────────────────────────────────────────────────────── + // 틱 (HitchRecorderRunner.Update 가 부른다) + // ───────────────────────────────────────────────────────────────────── + + /// 1프레임. = Time.unscaledTime. + public static void Tick(float now) + { + var st = St; + if (st == null || !WLHitchSettings.Enabled || !st.warmOnMapEnter) return; + if (s_phase == Phase.Faulted) return; + // 🔴 에디트 모드에서는 경계 감시·원본 풀 접근을 하지 않는다(오염 0). 행 워밍만 프로브가 돌릴 수 있다. + + try + { + if (now >= s_nextPoll) + { + s_nextPoll = now + Mathf.Max(0.05f, st.warmPollSeconds); + PollBoundary(st, now); + } + + switch (s_phase) + { + case Phase.Waiting: + if (now >= s_startAt) { s_phase = Phase.Rows; } + break; + case Phase.Rows: + StepRows(st); + break; + case Phase.Pool: + StepPool(st); + break; + } + } + catch (System.Exception ex) + { + s_phase = Phase.Faulted; Faults++; + LastLog = "중단(예외) — " + ex.Message; + if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog); // 🔴 LogError 금지(813z) + } + } + + /// 맵 진입 경계 판정 — AutoCombatOnEnter 와 같은 규칙(메인 PC 교체 또는 맵 ID 변경). + static void PollBoundary(WLHitchSettings st, float now) + { + if (!Application.isPlaying) return; // 에디트 모드 오염 0(프로브는 ForceStart 로 들어온다) + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; } + + // InGameInfo 가 아직 없으면 경계를 「소비」하지 않는다 — 다음 폴링에서 같은 경계를 다시 본다. + var info = InGameInfo.Ins; + if (DSUtil.CheckNull(info)) return; + + int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; + bool pcChanged = !ReferenceEquals(pc, s_lastPc); + bool mapChanged = map != s_lastMapId; + if (!pcChanged && !mapChanged) return; + + s_lastPc = pc; + s_lastMapId = map; + Boundaries++; + + s_classId = st.warmAllClasses ? -1 : pc.Get_ID(); + s_poolIndex = 0; + s_startAt = now + Mathf.Max(0f, st.warmStartDelaySeconds); + s_phase = Phase.Waiting; + LastLog = "맵 진입 경계 — 워밍 예약"; + if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog + " map=" + map + " class=" + s_classId); + } + + /// 811i 행 워밍 1프레임분. + static void StepRows(WLHitchSettings st) + { + int n = SkillSpectacle.PreloadRows(Mathf.Max(0, st.warmRowsPerFrame), s_classId); + RowsWarmed += n; + if (n == 0 || SkillSpectacle.PreloadRemaining(s_classId) <= 0) s_phase = Phase.Pool; + } + + /// 원본 풀 워밍 1프레임분. + static void StepPool(WLHitchSettings st) + { + var names = st.warmPoolPrefabs; + if (names == null || names.Length == 0) { Finish(st); return; } + if (!Application.isPlaying) { Finish(st); return; } // 에디트 모드는 원본 풀을 만들지 않는다 + + var info = InGameInfo.Ins; + if (DSUtil.CheckNull(info)) { Finish(st); return; } + + int per = st.warmPoolPerFrame; + if (per <= 0) per = names.Length; + + Vector3 far = Vector3.one * st.warmFarDistance; + for (int k = 0; k < per && s_poolIndex < names.Length; k++) + { + string name = names[s_poolIndex++]; + if (string.IsNullOrEmpty(name)) continue; + // 원본 경로 그대로 — 풀에 없으면 Load_Effect(Addressables) 후 인스턴스 1개가 남는다. + info.Show_Effect(name, far); + PoolWarmed++; + } + + if (s_poolIndex >= names.Length) Finish(st); + } + + static void Finish(WLHitchSettings st) + { + s_phase = Phase.Done; + Completions++; + LastLog = "워밍 완료 — 행 " + RowsWarmed + " · 풀 " + PoolWarmed; + if (st.verboseLog) Debug.Log("[EffectWarmup] " + LastLog); + } + + // ───────────────────────────────────────────────────────────────────── + // 프로브 진입점 · 초기화 + // ───────────────────────────────────────────────────────────────────── + + /// 프로브용: 경계 폴링을 건너뛰고 지금 바로 워밍을 시작한다(classId < 0 = 전 클래스). + public static void ForceStart(int classId, float now) + { + s_classId = classId; + s_poolIndex = 0; + s_startAt = now; + s_phase = Phase.Waiting; + Boundaries++; + } + + /// 프로브용: 지금 단계(0 Idle · 1 Waiting · 2 Rows · 3 Pool · 4 Done · 5 Faulted). + public static int PhaseIndex { get { return (int)s_phase; } } + + /// 프로브·재시작용. + public static void ResetAll() + { + s_phase = Phase.Idle; + s_lastPc = null; s_lastMapId = int.MinValue; + s_nextPoll = s_startAt = 0f; + s_poolIndex = 0; s_classId = -1; + Boundaries = RowsWarmed = PoolWarmed = Completions = Faults = 0; + LastLog = ""; + } + } +} diff --git a/Assets/WL/Tools/EffectWarmup.cs.meta b/Assets/WL/Tools/EffectWarmup.cs.meta new file mode 100644 index 000000000..eb8c6c94c --- /dev/null +++ b/Assets/WL/Tools/EffectWarmup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 811c0a1b2c3d4e5f60718293a4b5c6d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Tools/HitchRecorder.cs b/Assets/WL/Tools/HitchRecorder.cs new file mode 100644 index 000000000..de334eaf0 --- /dev/null +++ b/Assets/WL/Tools/HitchRecorder.cs @@ -0,0 +1,364 @@ +// ───────────────────────────────────────────────────────────────────────────── +// HitchRecorder.cs — 히치(버벅임) 런타임 레코더 · 링버퍼 64 · GC 0 +// +// PD 지시 #813(「게임이 자꾸 버벅대는 듯 멈추는 렉」) · 발주서 WL-811q §1-3 (2026-09-10) +// +// ■ 무엇을 하나 (원본 훅 0 · 새 Manager/Singleton 0 · 러너 = 숨은 오브젝트 1개) +// 매 프레임 `Time.unscaledDeltaTime` 을 보고 임계(SO `hitchMilliseconds` 기본 50 ms)를 넘으면 +// 그 프레임의 **시각 · 직전 이벤트**를 링버퍼(SO `ringCapacity` 기본 64)에 1칸 적는다. +// · 이 프레임에 등장한 몹 수 (`CombatEvents.Spawned.Raised` 델타 = 일괄 스폰의 직접 증거) +// · 이 프레임에 죽은 몹 수 (`CombatEvents.Killed.Raised` 델타) +// · 원본 이펙트 풀이 이 프레임에 몇 개 늘었나 (`InGameInfo.tf_Effects.childCount` 델타 +// = Addressables 로드 + Instantiate 가 실제로 일어난 수 · 811q 원인 표 ②의 계기) +// · WL 스킬 이펙트 인스턴스 증가 (`SkillSpectacle.Instantiated` 델타 = 811i) +// · GC 0세대 수거 횟수 델타 · 관리 힙 델타(KB) +// · 재무장(`BossArena.ZoneRearmMobs`) · 워밍(`EffectWarmup`) 델타 — 우리 완화가 스스로 히치를 +// 만들고 있지 않은지 같은 줄에서 보이게 한다. +// → `Screenshots_WL/WL811q/hitch_.txt` 로 쓴다. **QA 가 읽는다**(발주서 §1-3). +// +// ■ GC 0 (발주서 §2 ⓓ) +// 기록 경로에 `new`·문자열 결합·박싱이 없다 — 사전 할당 구조체 배열 1개에 값만 쓴다. +// 카운터는 전부 이미 존재하는 public static int 를 **읽기만** 한다(구독 0 = 이벤트 디스패치 비용 0). +// 문자열은 **파일을 쓸 때만** 만든다(드문 경로 · StringBuilder 1개 재사용). +// +// ■ 왜 unscaledDeltaTime 인가 +// 사망 연출·결과창은 `timeScale = 0` 이다(813z/813w8). scaled 로 재면 그 구간의 히치가 통째로 사라진다. +// Q8 의 최악 831 ms 가 바로 사망 구간이었다. +// +// ■ 🔴 에디터 한계 (발주서 §1-4) +// 에디터 Play 는 도메인 리로드·디버거·프로파일러·씬뷰 렌더가 얹혀 실기보다 느리다. +// Q8 QA-20 은 **샘플 도중 에디터 명령을 넣으면 최악 프레임이 1104 ms 로 왜곡**된다는 것을 실증했다. +// 이 레코더의 숫자는 **에디터 값**이다 — 실기 판정은 APK 빌드 실측(Lead/PD)이 해야 한다. +// 빌드에서도 이 파일은 그대로 돌고 `persistentDataPath/Screenshots_WL/WL811q/` 에 쓴다. +// +// ■ C8 롤백 +// `WLHitchSettings` 에셋이 없거나 `enabled = 0` → 러너를 만들지 않는다(오브젝트 0 · Update 0). +// `recorderEnabled = 0` → Tick 즉시 반환. +// +// 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것. +// 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙 — 그 자체가 플레이어 모달이 된다). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Tools +{ + /// 프레임 히치 레코더. 정적 · 링버퍼 · GC 0. + public static class HitchRecorder + { + /// 히치 1건. struct(배열에 값으로 들어간다 = 할당 0). + public struct Sample + { + public int frame; // Time.frameCount + public float realtime; // Time.realtimeSinceStartup + public float ms; // 이 프레임 길이(ms · unscaled) + public int spawned; // 이 프레임 Spawned 델타 + public int killed; // 이 프레임 Killed 델타 + public int poolGrew; // 원본 이펙트 풀 childCount 델타 + public int wlEffects; // 811i SkillSpectacle.Instantiated 델타 + public int gc0; // GC.CollectionCount(0) 델타 + public int heapKb; // 관리 힙 델타(KB) + public int rearmed; // BossArena.ZoneRearmMobs 델타 + public int warmed; // EffectWarmup 진행 델타 + public float timeScale; // 그 순간 Time.timeScale(사망·결과창 구간 식별) + } + + // ───────────────────────────────────────── 링버퍼 + static Sample[] s_ring; + static int s_next, s_count; + + // ───────────────────────────────────────── 직전 프레임 스냅샷 + static int s_prevSpawned, s_prevKilled, s_prevPool, s_prevWlEffects, s_prevGc0, s_prevRearm, s_prevWarm; + static long s_prevHeap; + static int s_frames; + static bool s_primed; + + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) + /// 본 프레임 수 · 히치 수 · 파일로 쓴 횟수. + public static int FramesSeen, HitchCount, FilesWritten; + /// 최악 프레임(ms) · 그 프레임 번호. + public static float WorstMs; + public static int WorstFrame; + /// 임계 이하 프레임까지 포함한 누계 시간(초 · unscaled) — 히치 비율 계산용. + public static float ElapsedSeconds; + /// 마지막에 쓴 파일 경로 · 마지막 콘솔 1줄. + public static string LastFile = "", LastSummary = ""; + /// 버퍼에 담긴 건수(덮어쓰기 반영). + public static int Buffered { get { return s_count; } } + + static WLHitchSettings St { get { return WLHitchSettings.Instance; } } + + // ───────────────────────────────────────────────────────────────────── + // 틱 — 러너가 매 프레임 부른다(프로브는 가짜 dt 로 직접 부를 수 있다) + // ───────────────────────────────────────────────────────────────────── + + /// 1프레임 관측. = unscaled 프레임 길이(초). + public static void Tick(float dt) + { + var st = St; + if (st == null || !WLHitchSettings.Enabled || !st.recorderEnabled) return; + + FramesSeen++; + ElapsedSeconds += dt; + + // ── 델타 산출(전부 읽기만 · 할당 0) + int spawned = CombatEvents.Spawned.Raised; + int killed = CombatEvents.Killed.Raised; + int pool = PoolChildCount(); + int wlfx = WL.Combat.Reaction.SkillSpectacle.Instantiated; + int gc0 = GC.CollectionCount(0); + int rearm = WL.Combat.Boss.BossArena.ZoneRearmMobs; + int warm = EffectWarmup.WarmedTotal; + long heap = st.sampleManagedHeap ? GC.GetTotalMemory(false) : 0L; + + if (!s_primed) + { + s_prevSpawned = spawned; s_prevKilled = killed; s_prevPool = pool; s_prevWlEffects = wlfx; + s_prevGc0 = gc0; s_prevRearm = rearm; s_prevWarm = warm; s_prevHeap = heap; + s_primed = true; + return; + } + + float ms = dt * 1000f; + if (ms > WorstMs) { WorstMs = ms; WorstFrame = Time.frameCount; } + + s_frames++; + if (s_frames > Mathf.Max(0, st.warmupFramesIgnored) && ms >= Mathf.Max(1f, st.hitchMilliseconds)) + Record(st, ms, spawned, killed, pool, wlfx, gc0, rearm, warm, heap); + + s_prevSpawned = spawned; s_prevKilled = killed; s_prevPool = pool; s_prevWlEffects = wlfx; + s_prevGc0 = gc0; s_prevRearm = rearm; s_prevWarm = warm; s_prevHeap = heap; + } + + static void Record(WLHitchSettings st, float ms, int spawned, int killed, int pool, + int wlfx, int gc0, int rearm, int warm, long heap) + { + int cap = Mathf.Clamp(st.ringCapacity, 4, 512); + if (s_ring == null || s_ring.Length != cap) { s_ring = new Sample[cap]; s_next = 0; s_count = 0; } + + ref Sample s = ref s_ring[s_next]; + s.frame = Time.frameCount; + s.realtime = Time.realtimeSinceStartup; + s.ms = ms; + s.spawned = spawned - s_prevSpawned; + s.killed = killed - s_prevKilled; + s.poolGrew = pool - s_prevPool; + s.wlEffects = wlfx - s_prevWlEffects; + s.gc0 = gc0 - s_prevGc0; + s.heapKb = (int)((heap - s_prevHeap) / 1024L); + s.rearmed = rearm - s_prevRearm; + s.warmed = warm - s_prevWarm; + s.timeScale = Time.timeScale; + + s_next = (s_next + 1) % cap; + if (s_count < cap) s_count++; + HitchCount++; + + int every = st.autoWriteEveryHitches; + if (every > 0 && HitchCount % every == 0) Write("auto"); + } + + /// 원본 이펙트 풀의 인스턴스 수. 없으면 직전 값을 그대로 돌려준다(델타 0). + static int PoolChildCount() + { + // 🔴 InGameInfo.Ins 는 MonoBehaviourSingletonTemplate 의 **정적 필드**다(실측) — 읽어도 아무것도 만들지 않는다. + var info = InGameInfo.Ins; + if (info == null) return s_prevPool; + var tf = info.tf_Effects; + return tf == null ? s_prevPool : tf.childCount; + } + + // ───────────────────────────────────────────────────────────────────── + // 파일 기록 (드문 경로 = 여기서만 문자열을 만든다) + // ───────────────────────────────────────────────────────────────────── + + static readonly StringBuilder s_sb = new StringBuilder(4096); + + /// 지금까지 버퍼에 담긴 히치를 파일로 쓴다. 반환 = 파일 경로(실패 시 빈 문자열). + public static string Write(string reason) + { + var st = St; + if (st == null || s_count <= 0) return ""; + + s_sb.Length = 0; + s_sb.Append("# WL-811q 히치 기록 (").Append(reason).Append(")\n"); + s_sb.Append("# 실행 = ").Append(Application.isEditor ? "에디터 Play(🔴 실기보다 느리다 · 실기 판정은 APK 빌드)" : "빌드").Append('\n'); + s_sb.Append("# 임계 ").Append(st.hitchMilliseconds.ToString("F0")).Append(" ms · 관측 ") + .Append(FramesSeen).Append(" 프레임 / ").Append(ElapsedSeconds.ToString("F1")).Append(" s · 히치 ") + .Append(HitchCount).Append("건"); + if (FramesSeen > 0) + s_sb.Append(" (").Append((100f * HitchCount / FramesSeen).ToString("F2")).Append(" %)"); + s_sb.Append(" · 최악 ").Append(WorstMs.ToString("F1")).Append(" ms(frame ").Append(WorstFrame).Append(")\n"); + s_sb.Append("# 열: frame t(s) ms 등장 처치 풀증가 WL이펙트 GC0 힙KB 재무장 워밍 timeScale\n"); + s_sb.Append("# 🔴 「등장」이 12~15 인 줄 = 원본 PhaseAllKill 일괄 스폰(813e2) · 「풀증가」가 0 이 아닌 줄 = Addressables 로드+Instantiate\n"); + + int cap = s_ring.Length; + int start = s_count < cap ? 0 : s_next; + for (int k = 0; k < s_count; k++) + { + ref Sample s = ref s_ring[(start + k) % cap]; + s_sb.Append(s.frame).Append(" ") + .Append(s.realtime.ToString("F1")).Append(" ") + .Append(s.ms.ToString("F1")).Append(" ") + .Append(s.spawned).Append(' ') + .Append(s.killed).Append(' ') + .Append(s.poolGrew).Append(' ') + .Append(s.wlEffects).Append(' ') + .Append(s.gc0).Append(' ') + .Append(s.heapKb).Append(' ') + .Append(s.rearmed).Append(' ') + .Append(s.warmed).Append(' ') + .Append(s.timeScale.ToString("F2")).Append('\n'); + } + + // 요약 — 원인별로 몇 건이 걸렸나(QA 가 이 4줄만 봐도 원인 표와 대조된다) + int bySpawn = 0, byPool = 0, byGc = 0, none = 0; + for (int k = 0; k < s_count; k++) + { + ref Sample s = ref s_ring[(start + k) % cap]; + bool hit = false; + if (s.spawned > 0) { bySpawn++; hit = true; } + if (s.poolGrew > 0) { byPool++; hit = true; } + if (s.gc0 > 0) { byGc++; hit = true; } + if (!hit) none++; + } + s_sb.Append("\n[요약] 등장 동반 ").Append(bySpawn).Append("건 · 풀증가(Addressables) 동반 ").Append(byPool) + .Append("건 · GC 동반 ").Append(byGc).Append("건 · 셋 다 아님 ").Append(none).Append("건\n"); + s_sb.Append("[811i] 로드요청 ").Append(WL.Combat.Reaction.SkillSpectacle.LoadRequested) + .Append(" 완료 ").Append(WL.Combat.Reaction.SkillSpectacle.LoadCompleted) + .Append(" 인스턴스 ").Append(WL.Combat.Reaction.SkillSpectacle.Instantiated) + .Append(" · [워밍] 행 ").Append(EffectWarmup.RowsWarmed) + .Append(" 풀 ").Append(EffectWarmup.PoolWarmed) + .Append(" 상태 ").Append(EffectWarmup.LastState).Append('\n'); + s_sb.Append("[재무장] 존 ").Append(WL.Combat.Boss.BossArena.ZoneRearmCount) + .Append(" 마리 ").Append(WL.Combat.Boss.BossArena.ZoneRearmMobs) + .Append(" · 분산 대기 ").Append(WL.Combat.Boss.BossArena.RearmQueuePending).Append('\n'); + + string path = WriteFile(st, s_sb.ToString()); + if (!string.IsNullOrEmpty(path)) + { + FilesWritten++; + LastFile = path; + LastSummary = "[WL811q] hitch " + HitchCount + "건/" + FramesSeen + "프레임 · 최악 " + + WorstMs.ToString("F1") + " ms · 등장동반 " + bySpawn + " 풀증가 " + byPool + + " GC " + byGc + " · file=" + path; + // 🔴 Debug.Log 1줄만(Error/Exception 금지 = 모달 0). verboseLog 와 무관 — QA 가 이 줄을 읽는다. + if (st.consoleLineOnWrite) Debug.Log(LastSummary); + } + return path; + } + + static string WriteFile(WLHitchSettings st, string body) + { + try + { + string sub = !string.IsNullOrEmpty(st.hitchDir) ? st.hitchDir : "Screenshots_WL/WL811q"; + 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, "hitch_" + DateTime.Now.ToString("HHmmss") + ".txt"); + File.WriteAllText(file, body); + return file; + } + catch (Exception ex) + { + LastSummary = "[WL811q] 기록 실패: " + ex.Message; // 🔴 Debug.LogError 금지(813z) + return ""; + } + } + + // ───────────────────────────────────────────────────────────────────── + // 구독 (사망 · 런 종료 때 자동 기록) + // ───────────────────────────────────────────────────────────────────── + + static bool s_subscribed; + + internal static void Subscribe() + { + if (s_subscribed) return; + s_subscribed = true; + WL.Combat.Survival.DeathFlow.Died.Add(OnDied); + WL.Combat.Run.RunEvents.RunEnded.Add(OnRunEnded); + } + + internal static void Unsubscribe() + { + if (!s_subscribed) return; + s_subscribed = false; + WL.Combat.Survival.DeathFlow.Died.Remove(OnDied); + WL.Combat.Run.RunEvents.RunEnded.Remove(OnRunEnded); + } + + static void OnDied(in WL.Combat.Survival.DeathEvent e) + { + var st = St; + if (st != null && st.writeOnDeath) Write("사망 #" + e.deathCount); + } + + static void OnRunEnded(in WL.Combat.Run.RunResult e) + { + var st = St; + if (st != null && st.writeOnRunEnd) Write("런 종료"); + } + + // ───────────────────────────────────────────────────────────────────── + // 진단 · 초기화 + // ───────────────────────────────────────────────────────────────────── + + /// 프로브·재시작용. 버퍼와 카운터를 비운다. + public static void ResetAll() + { + s_ring = null; s_next = s_count = 0; s_frames = 0; s_primed = false; + s_prevSpawned = s_prevKilled = s_prevPool = s_prevWlEffects = s_prevGc0 = s_prevRearm = s_prevWarm = 0; + s_prevHeap = 0L; + FramesSeen = HitchCount = FilesWritten = 0; + WorstMs = 0f; WorstFrame = 0; ElapsedSeconds = 0f; + LastFile = LastSummary = ""; + } + + /// 프로브용: i 번째(오래된 것부터) 샘플. + public static Sample At(int i) + { + if (s_ring == null || i < 0 || i >= s_count) return default(Sample); + int cap = s_ring.Length; + int start = s_count < cap ? 0 : s_next; + return s_ring[(start + i) % cap]; + } + } + + /// HitchRecorder 전용 러너. SO 가 꺼져 있으면 오브젝트를 만들지 않는다(C8). + public sealed class HitchRecorderRunner : MonoBehaviour + { + static HitchRecorderRunner 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("__WLHitchRecorder"); + DontDestroyOnLoad(go); + s_runner = go.AddComponent(); + HitchRecorder.Subscribe(); + } + + void Update() + { + HitchRecorder.Tick(Time.unscaledDeltaTime); + EffectWarmup.Tick(Time.unscaledTime); + } + + void OnApplicationQuit() { HitchRecorder.Write("종료"); } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + if (WLHitchSettings.Enabled) Ensure(); // 꺼져 있으면 오브젝트 0(C8) + } + } +} diff --git a/Assets/WL/Tools/HitchRecorder.cs.meta b/Assets/WL/Tools/HitchRecorder.cs.meta new file mode 100644 index 000000000..b5d158e63 --- /dev/null +++ b/Assets/WL/Tools/HitchRecorder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 811c0a1b2c3d4e5f60718293a4b5c6d8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Tools/WLHitchSettings.cs b/Assets/WL/Tools/WLHitchSettings.cs new file mode 100644 index 000000000..5d5a3d278 --- /dev/null +++ b/Assets/WL/Tools/WLHitchSettings.cs @@ -0,0 +1,151 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLHitchSettings.cs — 히치(버벅임) 계측 · 1차 완화 값의 단일 출처 (SOT · C45) +// +// PD 지시 #813(「게임이 자꾸 버벅대는 듯 멈추는 렉」) · 발주서 WL-811q §1-2 · §1-3 (2026-09-10) +// +// ■ 왜 이 SO 하나에 「레코더 + 워밍」이 같이 있나 +// 둘 다 811q 히치 축이고, QA·PD 가 A/B 를 할 때 **한 에셋만 열면 되게** 하려는 것이다. +// 스폰 분산(`rearmPerFrame`)만은 재무장 코드가 있는 `WLBossArenaSettings` 에 뒀다(같은 기능 옆). +// +// ■ 근거(실측) +// · Q6 `qa/W2-6_재채점_v4.md` 7-3 = 히치 32프레임/3275 · 최악 **637.1 ms** · 최저 fps 1.6 (군중 구간) +// · Q8 `qa/W2-8_재채점_v6.md` 7-3 = 히치 6프레임/3520 · 최악 **1003.2 ms**(에디터 스톨 혼입 「가능성」) +// · Q12 `qa/W2-12_회귀.md` 7-3 = 히치 8프레임/3573 · 최악 **263.9 ms** +// · 원본 `Assets/ResWork/Table/Export/EffectList.json` = 90행 · **프리로드 인스턴스 합 514** +// (이펙트 312 · 투사체 202) — `InGameInfo.Start()` 가 맵 로드 때 한꺼번에 만든다. +// 🔴 그런데 WL 이 쓰는 원본 풀 이펙트 이름 **22종**(검기 21 + `BoomHit`)은 이 표에 **없다** +// → 전투 도중 첫 사용에서 Addressables 로드 + Instantiate 가 그대로 터진다. 그것이 warmPoolPrefabs 다. +// +// ■ C8 롤백 +// 에셋이 없거나 `enabled = 0` → 러너 0 · 레코더 0 · 워밍 0 = 100 % 기존 동작. +// `recorderEnabled = 0` → 계측만 끈다 / `warmOnMapEnter = 0` → 워밍만 끈다. +// +// 🔴 어셈블리 주의: Assets/WL/ 에 .asmdef 를 만들지 말 것(Actor/InGameInfo 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.Tools +{ + /// 히치 계측·완화 값. 에셋 = Assets/WL/Combat/Settings/Resources/WL/WLHitchSettings.asset + [CreateAssetMenu(fileName = "WLHitchSettings", menuName = "WL/Hitch Settings", order = 327)] + public sealed class WLHitchSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLHitchSettings"; + + static WLHitchSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null = 기능 전체 off. + public static WLHitchSettings Instance + { + get + { + if (!s_lookupDone) { s_cached = Resources.Load(ResourcesPath); s_lookupDone = true; } + return s_cached; + } + } + + /// 프로브·A/B 전용. 0 = 에셋의 enabled · 1 = 강제 on · -1 = 강제 off. + public static int RuntimeOverride; + + /// 축 전체가 살아 있는가. false 면 러너조차 만들지 않는다(C8). + public static bool Enabled + { + get + { + var i = Instance; + if (i == null || RuntimeOverride < 0) return false; + return RuntimeOverride > 0 || i.enabled; + } + } + + /// + /// 프로브가 임시 에셋을 끼워 넣을 때 쓴다(실에셋은 읽기만). + /// null 을 넣으면 캐시가 비워져 다음 접근에서 실에셋을 다시 로드한다. + /// 🔴 런타임 게임 경로에서는 절대 부르지 않는다. + /// + public static void OverrideInstanceForProbe(WLHitchSettings st) { s_cached = st; s_lookupDone = st != null; } + + // ═══════════════════════════════════════════ 공통 + [Header("공통 (C8)")] + [Tooltip("이 축 전체 on/off. 끄면 러너 오브젝트도 만들지 않는다 = 흔적 0.")] + public bool enabled = true; + + [Tooltip("콘솔 로그를 낸다. 🔴 문자열 결합은 이 값이 켜졌을 때만 일어난다(프레임당 할당 0 유지).")] + public bool verboseLog = false; + + // ═══════════════════════════════════════════ ① 히치 레코더 (발주서 §1-3) + [Header("① 히치 레코더 (Screenshots_WL/WL811q/hitch_*.txt · QA 가 읽는다)")] + [Tooltip("프레임 시간을 재서 링버퍼에 기록한다. 끄면 Update 가 즉시 반환한다.")] + public bool recorderEnabled = true; + + [Tooltip("🔴 이 시간(ms)을 넘는 프레임을 「히치」로 본다. 발주서 §1-3 기본 50 ms. " + + "60 fps = 16.7 ms 이므로 50 ms 는 약 3프레임분이다. QA 채점 기준과 맞추려면 여기만 바꾼다.")] + public float hitchMilliseconds = 50f; + + [Tooltip("링버퍼 칸 수(발주서 §1-3 = 64). 게임 수치가 아니라 버퍼 크기다. 넘치면 오래된 것부터 덮어쓴다.")] + public int ringCapacity = 64; + + [Tooltip("첫 N 프레임은 무시한다 — 맵 로드 직후 프레임은 항상 길다(로딩 화면 구간).")] + public int warmupFramesIgnored = 30; + + [Tooltip("GC.GetTotalMemory(false) 로 힙 델타(KB)를 같이 적는다. 끄면 GC 열이 0 으로 나온다.")] + public bool sampleManagedHeap = true; + + [Tooltip("히치가 이 개수만큼 쌓일 때마다 파일을 쓴다(0 = 자동 기록 없음 · 종료·사망·런종료에만).")] + public int autoWriteEveryHitches = 16; + + [Tooltip("PC 사망(DeathFlow.Died) 때 파일을 쓴다 — Q8 최악 831 ms 가 사망 구간이었다.")] + public bool writeOnDeath = true; + + [Tooltip("런 종료(RunEvents.RunEnded) 때 파일을 쓴다 — 한 판이 통째로 한 파일이 된다.")] + public bool writeOnRunEnd = true; + + [Tooltip("파일을 쓸 때 콘솔에 1줄 요약을 남긴다(verboseLog 와 무관 · QA 가 이 줄만 봐도 된다).")] + public bool consoleLineOnWrite = true; + + [Tooltip("기록 폴더(프로젝트 루트 기준 · 빌드는 persistentDataPath 기준). Assets 밖 = 임포트 0.")] + public string hitchDir = "Screenshots_WL/WL811q"; + + // ═══════════════════════════════════════════ ② 이펙트 사전 워밍 (발주서 §1-2 ⓑ) + [Header("② 이펙트 사전 워밍 (맵 진입 = 로딩 구간으로 비용을 앞당긴다)")] + [Tooltip("🔴 맵 진입(메인 PC 교체 또는 맵 ID 변경 · AutoCombatOnEnter 와 같은 경계)에서 워밍을 시작한다. " + + "끄면 811i 는 예전대로 **첫 AttackStarted** 에 프리로드한다(= 첫 스킬 직전에 로드가 몰린다).")] + public bool warmOnMapEnter = true; + + [Tooltip("맵 경계 감시 주기(초 · unscaled). 폴링 자체는 할당 0.")] + public float warmPollSeconds = 0.25f; + + [Tooltip("경계를 본 뒤 이만큼(초) 기다렸다가 워밍을 시작한다 — 맵 로드 직후의 원본 프리로드 514개와 겹치지 않게.")] + public float warmStartDelaySeconds = 1.5f; + + [Tooltip("🔴 한 프레임에 요청할 811i 행 수(3단 = 프리팹 최대 3개/행). 0 = 한 프레임에 전부(= 워밍이 스스로 히치가 된다).")] + public int warmRowsPerFrame = 1; + + [Tooltip("🔴 한 프레임에 워밍할 원본 풀 이펙트 이름 수. 0 = 한 프레임에 전부.")] + public int warmPoolPerFrame = 1; + + [Tooltip("원본 풀(InGameInfo.Show_Effect) 로 미리 1개씩 만들어 둘 이펙트 이름. " + + "🔴 기본값 22종 = WL 이 쓰는데 EffectList.json 프리로드 표에 **없는** 것 전부(실측 · 검기 21 + BoomHit). " + + "🔴 여기에 넣는 이름은 프리팹에 TurnOff_GO 가 있어야 한다(없으면 원본 Show_Effect 가 NRE) — " + + "기본 22종은 디스크에서 전수 확인했다.")] + public string[] warmPoolPrefabs = new string[] + { + "Effect_Slash_10101_1", "Effect_Slash_10101_2", "Effect_Slash_10101_3", + "Effect_Slash_10102_1", "Effect_Slash_10102_2", "Effect_Slash_10102_3", "Effect_Slash_10102_4", + "Effect_Slash_10201_1", "Effect_Slash_10201_2", "Effect_Slash_10201_3", + "Effect_Slash_10202_1", "Effect_Slash_10202_2", "Effect_Slash_10202_3", "Effect_Slash_10202_4", + "Effect_Slash_10501_1", "Effect_Slash_10501_2", "Effect_Slash_10501_3", + "Effect_Slash_10502_1", "Effect_Slash_10502_2", + "Effect_WLStab", "Effect_WLSwingArc", "BoomHit" + }; + + [Tooltip("워밍 인스턴스를 놓는 좌표(원본 InGameInfo.Start 의 사전 로드와 같은 곳 = Vector3.one * 이 값). " + + "TurnOff_GO 가 수명 뒤 스스로 꺼서 풀로 돌아간다.")] + public float warmFarDistance = 100000f; + + [Tooltip("🔴 811i 스킬 이펙트를 클래스 무관 **전 행** 워밍한다. 끄면 메인 PC 클래스 행만 워밍한다(로드량 1/3).")] + public bool warmAllClasses = false; + } +} diff --git a/Assets/WL/Tools/WLHitchSettings.cs.meta b/Assets/WL/Tools/WLHitchSettings.cs.meta new file mode 100644 index 000000000..febf8f9c8 --- /dev/null +++ b/Assets/WL/Tools/WLHitchSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 811c0a1b2c3d4e5f60718293a4b5c6d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: