// PD #747 2단계 — Play 검증 프로브 (상태 덤프 · 공격 · CSV+캡처 · GC 측정 · 셰이더 감사) // unity command run_script --file AgentScripts/WL747_Probe.cs --entry WL747_Probe.Status // unity command run_script --file AgentScripts/WL747_Probe.cs --entry WL747_Probe.ShaderAudit // unity command run_script --file AgentScripts/WL747_Probe.cs --entry WL747_Probe.Run --args '[\"10501\", 6.0]' // unity command run_script --file AgentScripts/WL747_Probe.cs --entry WL747_Probe.Measure --args '[\"on\", 6.0, true]' // unity command run_script --file AgentScripts/WL747_Probe.cs --entry WL747_Probe.SetEnable --args '[false]' // // 🔴 run_script 는 호출마다 **새 임시 어셈블리**로 컴파일된다 → 호출 간 static 상태가 이어지지 않는다. // 그래서 프레임에 걸친 작업(샘플링·캡처)은 전부 **한 번의 호출 안에서** 자기 종료하도록 짰다. // 이전 호출이 남긴 EditorApplication.update 구독은 CleanupTicks 가 리플렉션으로 떼어 낸다 // (안 떼면 죽은 어셈블리의 틱이 매 프레임 돌면서 GC 측정을 오염시킨다). // 산출물은 전부 Screenshots_WL/trail/ (Assets 밖). using System; using System.Collections.Generic; using System.Text; using UnityEditor; using UnityEngine; public static class WL747_Probe { const string OutDir = "Screenshots_WL/trail"; static PCActor MainPC() { foreach (var a in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None)) if (a.IsMainPC()) return a; return null; } static string V(Vector3 v) { return v.x.ToString("F3") + "," + v.y.ToString("F3") + "," + v.z.ToString("F3"); } /// 이전 run_script 어셈블리가 남긴 이 프로브의 update 구독을 전부 떼어 낸다. public static object CleanupTicks() { int n = 0; var d = EditorApplication.update; if (d != null) foreach (var sub in d.GetInvocationList()) { var dt = sub.Method.DeclaringType; if (dt != null && dt.Name == "WL747_Probe") { EditorApplication.update -= (EditorApplication.CallbackFunction)sub; n++; } } return "removed stale ticks = " + n; } // ── 상태 덤프 ──────────────────────────────────────────────────────────── public static object Status() { var sb = new StringBuilder(); var set = WL.Combat.SlashTrailSettings.Instance; sb.Append("settings=").Append(set != null ? set.name : "NULL") .Append(" enable=").Append(set != null && set.enableWeaponTrail) .Append(" verbose=").Append(set != null && set.verboseLog) .Append(" matAdd=").Append(set != null && set.materialAdditive != null ? set.materialAdditive.shader.name : "-") .Append(" rules=").Append(set != null ? set.crescentRules.Count : -1) .Append(" burstTiming=").Append(set != null ? set.burstTiming.ToString() : "-").Append('\n'); var pc = MainPC(); if (pc == null) { sb.Append("MainPC = null\n"); return sb.ToString(); } sb.Append("PC=").Append(pc.name).Append(" classId=").Append(pc.Get_ID()) .Append(" animSpeed=").Append(pc.m_animation != null ? pc.m_animation.speed.ToString("F2") : "-").Append('\n'); var cfg = table_classconfig.Ins != null ? table_classconfig.Ins.Get_Data_orNull(pc.Get_ID()) : null; if (cfg != null) sb.Append("classcfg battleType=").Append(cfg.e_BattleType).Append(" range=").Append(cfg.f_AttackRange) .Append(" LH=").Append(cfg.n_WeaponLHIndex).Append(" RH=").Append(cfg.n_WeaponRHIndex).Append('\n'); var socks = pc.tfs_weapon; if (socks != null) for (int i = 0; i < socks.Length; i++) if (socks[i] != null && socks[i].childCount > 0) sb.Append(" socket[").Append(i).Append("] ").Append(socks[i].name).Append(" -> ").Append(socks[i].GetChild(0).name).Append('\n'); var drv = pc.GetComponent(); sb.Append("driver=").Append(drv != null).Append('\n'); if (drv != null) { sb.Append(" swinging=").Append(drv.IsSwinging).Append(" swingCount=").Append(drv.SwingCount) .Append(" slots=").Append(drv.ActiveSlotCount).Append('\n'); for (int i = 0; i < drv.ActiveSlotCount; i++) { var t = drv.GetTrail(i); sb.Append(" slot").Append(i).Append(" bladeLen=").Append(drv.GetBladeLength(i).ToString("F3")) .Append(" tip=").Append(V(drv.GetTipWorld(i))).Append(" hilt=").Append(V(drv.GetHiltWorld(i))) .Append(" trail=").Append(t != null ? ("samples=" + t.SampleCount + " visible=" + t.IsVisible + " hasMat=" + t.HasMaterial) : "null") .Append('\n'); } } sb.Append("externalSpawner=").Append(WL.Combat.WeaponTrailDriver.ExternalSpawner != null).Append('\n'); return sb.ToString(); } // ── 셰이더 감사 ────────────────────────────────────────────────────────── public static object ShaderAudit() { var sb = new StringBuilder(); var guids = AssetDatabase.FindAssets("Effect_Slash_ t:Prefab"); var seen = new SortedDictionary(); int prefabs = 0; foreach (var g in guids) { var path = AssetDatabase.GUIDToAssetPath(g); if (!System.IO.Path.GetFileName(path).StartsWith("Effect_Slash_")) continue; var go = AssetDatabase.LoadAssetAtPath(path); if (go == null) continue; prefabs++; foreach (var r in go.GetComponentsInChildren(true)) foreach (var m in r.sharedMaterials) { if (m == null || m.shader == null) continue; string tex = m.HasProperty("_MainTex") && m.GetTexture("_MainTex") != null ? m.GetTexture("_MainTex").name : (m.HasProperty("_BaseMap") && m.GetTexture("_BaseMap") != null ? m.GetTexture("_BaseMap").name : "(no tex)"); seen[m.name + " | " + m.shader.name] = "supported=" + m.shader.isSupported + " passes=" + m.passCount + " tex=" + tex; } } sb.Append("Effect_Slash prefabs=").Append(prefabs).Append(" distinct=").Append(seen.Count).Append('\n'); foreach (var kv in seen) sb.Append(" ").Append(kv.Key).Append(" -> ").Append(kv.Value).Append('\n'); return sb.ToString(); } // ── 공격 · 설정 토글 ───────────────────────────────────────────────────── public static object Attack() { var pc = MainPC(); if (pc == null) return "MainPC null"; pc.Play_Attack(0, 1f, null); return "attack0 class=" + pc.Get_ID(); } public static object AttackN(int n) { var pc = MainPC(); if (pc == null) return "MainPC null"; pc.Play_Attack(n, 1f, null); return "attack" + n; } public static object SetEnable(bool v) { var set = WL.Combat.SlashTrailSettings.Instance; if (set == null) return "settings null"; set.enableWeaponTrail = v; return "enableWeaponTrail=" + set.enableWeaponTrail; } public static object SetVerbose(bool v) { var set = WL.Combat.SlashTrailSettings.Instance; if (set == null) return "settings null"; set.verboseLog = v; return "verboseLog=" + set.verboseLog; } /// /// 클라이언트 전용 클래스 전환. PCActor.Change_Class(cfg, _ui:true) 는 m_ID·m_ClassData·Set_Obj() /// (모델 스케일 · 애니메이터 컨트롤러 · Change_Weapon) 만 건드린다 — **서버 쓰기 호출이 없다**(실측). /// Play 를 멈추면 전부 사라진다. 드라이버는 classId 변경을 감지해 무기 슬롯을 다시 잰다. /// public static object SwitchClass(int classId) { var pc = MainPC(); if (pc == null) return "MainPC null"; if (table_classconfig.Ins == null) return "table null"; var cfg = table_classconfig.Ins.Get_Data_orNull(classId); if (cfg == null) return "no classconfig " + classId; pc.Change_Class(cfg, true); return "Change_Class -> " + classId + " (LH=" + cfg.n_WeaponLHIndex + " RH=" + cfg.n_WeaponRHIndex + " lh=" + cfg.s_WeaponLHPrefab + " rh=" + cfg.s_WeaponRHPrefab + ")"; } /// /// 모든 무기 소켓의 메시 형상을 **필터와 무관하게** 실측한다. /// WeaponTrailDriver.MeasureSlot 과 같은 계산(월드 크기 · 최장축 · 최장/차축 비율)이라 /// minBladeLength / minBladeAspect 임계값을 데이터로 정할 수 있다(C39·C45). /// public static object MeasureWeapons() { var pc = MainPC(); if (pc == null) return "MainPC null"; var sb = new StringBuilder(); var set = WL.Combat.SlashTrailSettings.Instance; sb.Append("class=").Append(pc.Get_ID()) .Append(" minLen=").Append(set != null ? set.minBladeLength : -1) .Append(" minAspect=").Append(set != null ? set.minBladeAspect : -1).Append('\n'); var socks = pc.tfs_weapon; for (int i = 0; i < socks.Length; i++) { if (socks[i] == null || socks[i].childCount == 0) continue; foreach (var r in socks[i].GetComponentsInChildren(true)) { Mesh mesh = null; var smr = r as SkinnedMeshRenderer; if (smr != null) mesh = smr.sharedMesh; else { var mf = r.GetComponent(); if (mf != null) mesh = mf.sharedMesh; } if (mesh == null) continue; var ls = r.transform.lossyScale; var b = mesh.bounds; var ws = new Vector3(b.size.x * Mathf.Abs(ls.x), b.size.y * Mathf.Abs(ls.y), b.size.z * Mathf.Abs(ls.z)); int axis = (ws.x >= ws.y && ws.x >= ws.z) ? 0 : (ws.y >= ws.z ? 1 : 2); float longest = ws[axis]; float second = 0f; for (int k = 0; k < 3; k++) if (k != axis && ws[k] > second) second = ws[k]; float aspect = second > 1e-5f ? longest / second : 999f; sb.Append(" socket[").Append(i).Append("] ").Append(socks[i].name).Append(" / ").Append(r.name) .Append(" worldSize=").Append(ws.ToString("F3")) .Append(" longest=").Append(longest.ToString("F3")).Append("m second=").Append(second.ToString("F3")) .Append("m aspect=").Append(aspect.ToString("F2")) .Append(set != null ? ((longest >= set.minBladeLength && aspect >= set.minBladeAspect) ? " PASS" : " REJECT") : "") .Append('\n'); } } return sb.ToString(); } /// 살아 있는 트레일의 색·폭을 즉시 바꾼다 — 정점색이 실제로 먹는지 확인용(PLAN 미확인 ②). public static object TintTest(double r, double g, double b, double w) { var pc = MainPC(); if (pc == null) return "MainPC null"; var drv = pc.GetComponent(); if (drv == null) return "driver null"; var c = new Color((float)r, (float)g, (float)b, 1f); int n = 0; for (int i = 0; i < drv.ActiveSlotCount; i++) { var t = drv.GetTrail(i); if (t != null) { t.SetLook(c, c, (float)w); n++; } } return "SetLook applied to " + n + " trails color=" + c + " width=" + w; } // ── 한 호출 = CSV 샘플링 + 스윙 동기 캡처 (자기 종료) ──────────────────── static readonly List s_rows = new List(8192); static string s_tag; static float s_t0, s_dur; static double s_maxTipErr; static int s_shots, s_maxShots, s_lastShotSwing; static WL.Combat.WeaponTrailDriver s_drv; public static object Run(string tag, double seconds) { return RunEx(tag, seconds, true); } public static object RunEx(string tag, double seconds, bool close) { CleanupTicks(); s_close = close; var pc = MainPC(); if (pc == null) return "MainPC null"; s_drv = pc.GetComponent(); s_rows.Clear(); s_rows.Add("time,swingActive,swingCount,slotIndex,tipX,tipY,tipZ,hiltX,hiltY,hiltZ,sampleCount,meshTipX,meshTipY,meshTipZ,tipErr,visible"); s_tag = string.IsNullOrEmpty(tag) ? "sample" : tag; s_dur = (float)seconds; s_t0 = Time.realtimeSinceStartup; s_maxTipErr = 0; s_shots = 0; s_maxShots = 4; s_lastShotSwing = -1; EditorApplication.update += RunTick; return "Run started tag=" + s_tag + " seconds=" + s_dur + " (결과는 콘솔 [WL747 Run] 로그로)"; } /// 드라이버와 무관하게 **살아 있는 Effect_Slash_* 인스턴스**가 보일 때 찍는다. /// enableWeaponTrail=false 원복 확인(ⓕ)과 몬스터·원거리 회귀 캡처(ⓓ)에 쓴다. public static bool s_baseMode; public static object RunBase(string tag, double seconds) { CleanupTicks(); s_close = true; s_baseMode = true; var pc = MainPC(); if (pc == null) return "MainPC null"; s_drv = pc.GetComponent(); s_rows.Clear(); s_rows.Add("time,swingActive,swingCount,slotIndex,tipX,tipY,tipZ,hiltX,hiltY,hiltZ,sampleCount,meshTipX,meshTipY,meshTipZ,tipErr,visible"); s_tag = tag; s_dur = (float)seconds; s_t0 = Time.realtimeSinceStartup; s_maxTipErr = 0; s_shots = 0; s_maxShots = 4; s_lastShotSwing = -1; EditorApplication.update += RunTick; return "RunBase started tag=" + tag; } static float s_lastBaseShot; static void RunTick() { if (!Application.isPlaying) { Finish(); return; } var pc = MainPC(); if (pc == null) return; if (s_baseMode) { float tb = Time.realtimeSinceStartup - s_t0; if (s_shots < s_maxShots && tb - s_lastBaseShot > 0.6f && AnySlashActive()) { s_lastBaseShot = tb; Shoot(s_tag + "_" + s_shots); s_shots++; } if (tb >= s_dur) { s_baseMode = false; Finish(); } return; } if (s_drv == null) s_drv = pc.GetComponent(); var drv = s_drv; if (drv == null) return; float t = Time.realtimeSinceStartup - s_t0; for (int i = 0; i < drv.ActiveSlotCount; i++) { var tr = drv.GetTrail(i); Vector3 tip = drv.GetTipWorld(i); Vector3 hilt = drv.GetHiltWorld(i); int n = tr != null ? tr.SampleCount : 0; Vector3 meshTip = n > 0 ? tr.GetTip(n - 1) : Vector3.zero; double err = n > 0 ? Vector3.Distance(tip, meshTip) : -1; if (drv.IsSwinging && n > 0 && err > s_maxTipErr) s_maxTipErr = err; s_rows.Add(t.ToString("F3") + "," + (drv.IsSwinging ? "1" : "0") + "," + drv.SwingCount + "," + i + "," + tip.x.ToString("F4") + "," + tip.y.ToString("F4") + "," + tip.z.ToString("F4") + "," + hilt.x.ToString("F4") + "," + hilt.y.ToString("F4") + "," + hilt.z.ToString("F4") + "," + n + "," + meshTip.x.ToString("F4") + "," + meshTip.y.ToString("F4") + "," + meshTip.z.ToString("F4") + "," + err.ToString("F5") + "," + ((tr != null && tr.IsVisible) ? "1" : "0")); // 스윙 1회당 최대 1장 — 리본이 실제로 화면에 있는 프레임에서만 찍는다. if (s_shots < s_maxShots && drv.IsSwinging && tr != null && tr.IsVisible && n >= 6 && drv.SwingCount != s_lastShotSwing) { s_lastShotSwing = drv.SwingCount; Shoot(s_tag + "_swing" + drv.SwingCount + "_n" + n); s_shots++; } } if (t >= s_dur) Finish(); } static bool AnySlashActive() { foreach (var ps in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None)) { var r = ps.transform; while (r.parent != null && !r.name.StartsWith("Effect_Slash_")) r = r.parent; if (r.name.StartsWith("Effect_Slash_") && r.gameObject.activeInHierarchy) return true; } return false; } static void Finish() { EditorApplication.update -= RunTick; System.IO.Directory.CreateDirectory(OutDir); var path = System.IO.Path.Combine(OutDir, "trail_" + s_tag + ".csv"); System.IO.File.WriteAllLines(path, s_rows.ToArray(), new UTF8Encoding(true)); Debug.Log("[WL747 Run] csv=" + path + " rows=" + (s_rows.Count - 1) + " maxTipErr=" + s_maxTipErr.ToString("F5") + "m shots=" + s_shots); } // ── GC 측정 (틱 자체가 할당하지 않는다 · 판정 기준 ⓔ) ──────────────────── static long[] s_mono = new long[20000]; static int s_mi; static float s_mt0, s_mdur; static string s_mtag; public static object Measure(string tag, double seconds, bool trailOn) { CleanupTicks(); var set = WL.Combat.SlashTrailSettings.Instance; if (set != null) { set.enableWeaponTrail = trailOn; set.verboseLog = false; } s_mtag = tag; s_mi = 0; s_mdur = (float)seconds; s_mt0 = Time.realtimeSinceStartup; GC.Collect(); EditorApplication.update += MeasureTick; return "Measure started tag=" + tag + " trailOn=" + trailOn + " (결과는 콘솔 [WL747 Measure])"; } static void MeasureTick() // 할당 없음 — 배열은 미리 잡아 두었다 { if (s_mi < s_mono.Length) s_mono[s_mi++] = UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); if (Time.realtimeSinceStartup - s_mt0 < s_mdur && s_mi < s_mono.Length) return; EditorApplication.update -= MeasureTick; long grow = 0; int growFrames = 0; long peak = 0; for (int i = 1; i < s_mi; i++) { long d = s_mono[i] - s_mono[i - 1]; if (d > 0) { grow += d; growFrames++; if (d > peak) peak = d; } } Debug.Log("[WL747 Measure] tag=" + s_mtag + " frames=" + s_mi + " growFrames=" + growFrames + " totalGrowth=" + grow + "B" + " perFrame=" + (s_mi > 1 ? (grow / (double)(s_mi - 1)).ToString("F0") : "0") + "B" + " peakFrame=" + peak + "B"); } // ── 캡처 ──────────────────────────────────────────────────────────────── public static object Capture(string tag) { return Shoot(tag); } /// true 면 PC 를 가까이서 잡는다(리본 형상 판독용). Run 이 켠다. public static bool s_close; static string Shoot(string tag) { var cam = Camera.main; if (cam == null) return "no Camera.main"; System.IO.Directory.CreateDirectory(OutDir); int w = 1280, h = 720; var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32); var go = new GameObject("__wl747cam"); var c = go.AddComponent(); c.CopyFrom(cam); if (s_close) { var pc = MainPC(); if (pc != null) { // 캐릭터 가슴 높이를 정면 살짝 위에서 근접으로 — 리본이 검날을 덮는지 보려면 이 거리라야 한다. Vector3 center = pc.Get_Center_position(); Vector3 dir = (cam.transform.position - center).normalized; c.transform.position = center + dir * 3.2f + Vector3.up * 0.5f; c.transform.LookAt(center); c.fieldOfView = 45f; c.nearClipPlane = 0.05f; } } c.targetTexture = rt; c.enabled = false; c.Render(); var prev = RenderTexture.active; RenderTexture.active = rt; var tex = new Texture2D(w, h, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(); RenderTexture.active = prev; var px = tex.GetPixels32(); int magenta = 0; foreach (var p in px) if (p.r > 200 && p.b > 200 && p.g < 80) magenta++; var path = System.IO.Path.Combine(OutDir, tag + ".png"); System.IO.File.WriteAllBytes(path, tex.EncodeToPNG()); UnityEngine.Object.DestroyImmediate(tex); c.targetTexture = null; UnityEngine.Object.DestroyImmediate(go); rt.Release(); UnityEngine.Object.DestroyImmediate(rt); return path + " magentaRatio=" + ((float)magenta / px.Length).ToString("F5"); } // ── 살아 있는 슬래시 인스턴스의 이미터·머티리얼 실측 ──────────────────── public static object LiveEffects() { var sb = new StringBuilder(); var roots = new HashSet(); foreach (var ps in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) { var root = ps.transform; while (root.parent != null && !root.name.StartsWith("Effect_Slash_")) root = root.parent; if (!root.name.StartsWith("Effect_Slash_")) continue; var psr = ps.GetComponent(); if (psr == null) continue; roots.Add(root); sb.Append(root.name).Append(" / ").Append(ps.gameObject.name) .Append(" rendererEnabled=").Append(psr.enabled) .Append(" mat=").Append(psr.sharedMaterial != null ? psr.sharedMaterial.name : "null") .Append(" supported=").Append(psr.sharedMaterial != null && psr.sharedMaterial.shader != null && psr.sharedMaterial.shader.isSupported) .Append('\n'); } sb.Insert(0, "slash instances=" + roots.Count + "\n"); return sb.ToString(); } }