From f1b550466e6a0a3c56aefcf9aa8db35d27aab955 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 14:42:18 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813l2]=20=EC=A0=84=ED=88=AC=EB=A0=A5=20?= =?UTF-8?q?=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=88=98=EB=A6=AC:=20LevelUp=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=B0=98=20(#8?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AgentScripts/WL813l2_PROBE.txt | 67 +++ AgentScripts/WL813l2_Probe.cs | 426 ++++++++++++++++++ Assets/WL/UI/Scripts/PowerDeltaToast.cs | 254 ++++++++++- .../Resources/WL/WLPowerDeltaSettings.asset | 8 + Assets/WL/UI/Settings/WLPowerDeltaSettings.cs | 34 ++ 5 files changed, 768 insertions(+), 21 deletions(-) create mode 100644 AgentScripts/WL813l2_PROBE.txt create mode 100644 AgentScripts/WL813l2_Probe.cs diff --git a/AgentScripts/WL813l2_PROBE.txt b/AgentScripts/WL813l2_PROBE.txt new file mode 100644 index 000000000..25ad788bf --- /dev/null +++ b/AgentScripts/WL813l2_PROBE.txt @@ -0,0 +1,67 @@ +# WL813l2 Probe 2026-09-09 14:41:18 (edit mode · Play 0 · 로그인 0) +playMode=False + +── 준비 + [OK] 구독 ON · MyValue.bMyPC=False(월드 PC 없음 = 로비·에디트 경로) + +── ⓪ 설정 에셋 · 성장 입력 값 전수 (C45 · 코드 상수 0) + [OK] WLPowerDeltaSettings 로드 + [OK] Enabled · enabled_=True + 성장 입력: useLevelUpPayload=True readGrowthFromServerData=True statPointWeight=1 atkGain=1 hpGain=22 perLevel=5 levelWeight=0 levelBase=1 + 기존 산식: atk=BaseDmg(폴백 ATK) hp=MaxHP hpWeight=0.1 powerScale=1 최소 표시 0.5% suppressToastOnLevelUp=True + [OK] statPointsPerLevel=5 (원본 실측 ServerClass.cs:2770 Get_Point() = (Lv−1)*5 − 사용분) + [OK] 포인트 환산 ATK +1 / MaxHP +22 (원본 실측 MyValue.cs:335-336 dic_MainStatMultiplier[STR] = ATK 1 · MaxHP 22) + +── ① 🔴 D-28 원인 실증 — 원본 스탯은 레벨업으로 안 움직인다 · 움직이는 것은 미사용 포인트뿐 + Lv 1 → 16 · 스탯 포인트 사용 0 · 장비 변화 0 일 때 원본이 주는 것: + [OK] Get_Point() 0 → 75 (= (Lv−1)*5 · 미사용 포인트만 늘어난다) + [OK] SD_PC.Get_Stat(STR) 0 → 0 (= **사용한** 포인트만 셈 · table_classconfig.Get_PureStat:246 이 읽는 값) → ActorStatInfo 는 그대로 + ⇒ `ActorStatInfo.Reset`(MyValue.cs:286-453)은 SD_PC.Lv 를 한 번도 안 읽는다 = 813l 이 폴링해도 값이 상수 + (Q6 D-28 `PollCount 1573` 인데 `LastPower 52.000000` 불변 · 레벨 1→16) + [OK] ApplyGrowth ↔ 원본 Get_Point() 대조 — 내 값 70 · 원본 70 · level=16 (사용 5점 반영) + [OK] 813l 산식 회귀 4건(성장 입력 0) — 200.00 / 210.00 / 230.00 / 486.00 (기대 200 / 210 / 230 / 486) + [OK] 포인트 5점 → 216.00 (기대 216.00 = 105 + 111.0) + [OK] 포인트 10점 → 232.00 (기대 232.00 · 레벨당 +16.0) + [OK] 레벨업 1회 증가율 8.00% (최소 표시 0.5% 를 넘는다) + [OK] 포인트 사용 전/후 연속 — 미사용 216.00 ↔ 사용 후 216.00 (statPointWeight=1 의 뜻) + +── ② 813k GrowthEvents.LevelUp 실 Dispatch — prev ATK 100 → new 112 (발주서 §1-3) + dispatch GrowthEvents.LevelUp 6→7 pt=30 · 구독자=1 + [OK] 수신 1회 · 소스 "levelUp(813k 페이로드)" + [OK] 페이로드 전투력 prev=280.00 new=308.00 (기대 280.00 / 308.00 · 손계산) + [OK] 🔴 LastPower 0 → 308.00 (D-28 = 여기가 52 로 고정이었다) + [OK] 레벨업 프레임 토스트 생략(Suppressed=1 Toast=0) — 같은 값을 팝업 줄이 보여준다 + [OK] 팝업 줄 "전투력 280 → 308 (+10.0%)" (기대 280 → 308 · +10.0%) + [OK] 토스트 1회 · 문구 "전투력 +10.0%" (기대 "전투력 +10.0%") + [OK] useLevelUpPayload=0 → 소스 "levelUp(스탯 읽기 실패)" · 토스트 0회 · LastPower 없음(= 813l 원래 경로) + +── ③ 🔴 D-28 재현 · 수리 — 스탯이 **하나도 안 바뀌는** 원본 기본 동작(813k 보고 ①)에서 + dispatch GrowthEvents.LevelUp 7→8 pt=35 · 구독자=1 · 스탯 3종 prev == new (원본 기본값) + [OK] 전투력 296.00 → 312.00 (기대 296.00 → 312.00) + [OK] ✅ 수리 후 — 토스트 1회 "전투력 +5.4%" + [OK] ❌ 재현(statPointWeight=0) — 레벨 7→8→9 인데 전투력 200.00 불변 · 토스트 0회 = Q6 D-28 그대로 + [OK] 다단(5→8 · 3레벨) prev 포인트 역산 → prev 전투력 264.00 (기대 264.00 = 포인트 20점) + +── ④ 레벨업 팝업 전투력 줄 — 813y StatPopup 왕복(구독 → Dispatch → 줄) + init: 구독 ON · box pos=(0.00, 180.00) size=(1080.00, 296.00) rows=3 power=on unitsPerPx=1.0000 titleFont=72.0 edge[LiberationSans SDF Material (WL813y Edge) shader=TextMeshPro/Mobile/Distance Field outlineW=0.260 outlineC=0D0805FF underlay=000000BF off(0.45,-0.45) soft=0.20 UNDERLAY_ON=True] + [OK] StatPopup.PowerLineOn + [OK] StatPopup 구독=True · GrowthEvents.LevelUp 구독자=2(StatPopup + PowerDeltaToast) + 팝업 표시: rows=1 level=10 text="LEVEL 10 / +5 포인트 (보유 45) / 전투력 328 → 344 (+4.9%)" + [OK] 🔴 팝업 전투력 줄 "전투력 328 → 344 (+4.9%)" (기대 328 → 344 · +4.9%) + [OK] 중복 표시 0 — Suppressed=1 Toast=0 + [OK] 프레임 스탬프 prev=328.00 new=344.00 → 팝업/토스트가 같은 값을 본다(구독 순서 무관) + +── ⑤ GC 0 (폴링 · 페이로드 · 포인트 산식) + [OK] Tick × 1000 → GetTotalMemory 0 B · MonoUsedSize 0 B (폴링 2회) + [OK] ApplyGrowth × 1000 = 0 B ↔ 원본 Get_Point() × 1000 = 0 B (원본은 LINQ Select+Sum · sink=138000) + 레벨업 Dispatch × 1000 — 헬퍼만 126976 B · 구독 포함 176128 B → 내 경로 몫 49152 B / 1000회 (813l 때부터 있던 진단 문자열 · **프레임 경로 아님**) + 대조군 new object() × 1000 = 24576 B (0 이 아니어야 측정이 살아 있다) · keep=1000 + +── ⑥ C8 롤백 + [OK] Enabled=false + [OK] 레벨업 무시(LevelUpSeen=0 Toast=0) + [OK] BuildStatPopupLine()=null + [OK] 해제 후 Enabled=true + [OK] statPointWeight=0 · levelWeight=0 → 레벨 16 · 포인트 75 여도 200.00 (813l 원래 산식과 동일 = 값 하나로 되돌린다) + +RESULT PASS diff --git a/AgentScripts/WL813l2_Probe.cs b/AgentScripts/WL813l2_Probe.cs new file mode 100644 index 000000000..a345e8b6c --- /dev/null +++ b/AgentScripts/WL813l2_Probe.cs @@ -0,0 +1,426 @@ +// WL813l2_Probe.cs — #813l2(Q6 D-28 「전투력 토스트가 한 번도 안 뜸 · LastPower 52 불변」 수리) 에디트 모드 프로브 +// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬 무수정(임시 GameObject 는 즉시 파괴) · 에셋 쓰기 0 +// +// 상주 에디터: +// unity command run_script --project-path --file AgentScripts/WL813l2_Probe.cs --entry WL813l2_Probe.RunAll --timeout 600 +// 결과: Console + AgentScripts/WL813l2_PROBE.txt (RESULT PASS / RESULT FAIL n) +// +// 검사 (발주서 WL-813l2 §1) +// ⓪ 설정 에셋 · 새 성장 입력 값 전수 +// ① 원인 실증 — 원본 스탯은 레벨업으로 안 움직인다 / 미사용 포인트만 움직인다(원본 Get_Point() 와 대조) +// ② 813k 실 Dispatch — LevelUp(prev ATK 100 → new 112) → 토스트 "+N %" · LastPower 변화 +// ③ 🔴 D-28 재현·수리 — 스탯 prev == new(원본 기본 동작)인데도 표시된다 / statPointWeight=0 이면 다시 0회 +// ④ 팝업 전투력 줄 — 813y StatPopup 에 「전투력 A → B (+N %)」 실제로 붙는가(레벨업 프레임 스탬프) +// ⑤ GC 0 — Tick × 1000 · 페이로드 Dispatch × 1000 · ApplyGrowth × 1000(원본 LINQ Get_Point 대조군) +// ⑥ C8 — enabled_=0 → 표시 0 / statPointWeight=0·levelWeight=0 → 813l 원래 산식 그대로 +// +// 에디트 모드 한계(보고서 「미확인」) +// · 진짜 PC 는 ServerData 없이 m_Stat 이 안 만들어진다(813l 보고 §5-3) → 실전 전투력 자릿수는 여전히 미확인. +// · Canvas 가 없어 UnitsPerPx = 1(px == 유닛). + +using System; +using System.IO; +using System.Text; +using UnityEngine; +using UnityEngine.UI; +using TMPro; +using WL.Combat.Growth; +using WL.UI; + +public static class WL813l2_Probe +{ + const string OutCommit = "AgentScripts/WL813l2_PROBE.txt"; + + static StringBuilder sb; + static int s_fail; + + static GameObject s_canvasGo; + static StatPopup s_popup; + static PCActor s_prevMyPc; + static bool s_prevBMyPc; + + // 에셋 값 임시 변경분 원복용(에셋 저장 0) + static bool s_prevSuppress; + static float s_prevPointWeight, s_prevLevelWeight; + static bool s_prevUsePayload; + + public static object RunAll() + { + sb = new StringBuilder(); + s_fail = 0; + sb.AppendLine("# WL813l2 Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)"); + sb.AppendLine("playMode=" + Application.isPlaying); + + try + { + Setup(); + Sec0_Asset(); + Sec1_Cause(); + Sec2_Payload(); + Sec3_D28(); + Sec4_PopupLine(); + Sec5_Gc(); + Sec6_Rollback(); + } + catch (Exception ex) + { + s_fail++; + L("EXCEPTION " + ex); + } + finally { Teardown(); } + + sb.AppendLine(); + sb.AppendLine(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail); + string text = sb.ToString(); + try { File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), OutCommit), text, new UTF8Encoding(true)); } catch { } + Debug.Log(text); + return text; + } + + static void L(string s) { sb.AppendLine(s); } + static string P(bool ok) { if (!ok) s_fail++; return ok ? "[OK]" : "[FAIL]"; } + static bool Near(double a, double b) { return Math.Abs(a - b) < 0.01d; } + + // ───────────────────────────────────────── 준비 + static void Setup() + { + s_prevMyPc = MyValue.MyPC; + s_prevBMyPc = MyValue.bMyPC; + MyValue.MyPC = null; // 에디트 모드 = 월드에 PC 없음(페이로드만으로 도는 경로) + + WLPowerDeltaSettings.RuntimeDisabled = false; + WLPowerDeltaSettings.ClearCache(); + var s = WLPowerDeltaSettings.Instance; + if (s != null) + { + s_prevSuppress = s.suppressToastOnLevelUp; + s_prevPointWeight = s.statPointWeight; + s_prevLevelWeight = s.levelWeight; + s_prevUsePayload = s.useLevelUpPayload; + } + + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.Subscribe(false); + PowerDeltaToast.Subscribe(true); + + L(""); + L("── 준비"); + L(" " + P(PowerDeltaToast.Subscribed) + " 구독 ON · MyValue.bMyPC=" + MyValue.bMyPC + "(월드 PC 없음 = 로비·에디트 경로)"); + } + + static void Teardown() + { + try + { + var s = WLPowerDeltaSettings.Instance; + if (s != null) + { + s.suppressToastOnLevelUp = s_prevSuppress; + s.statPointWeight = s_prevPointWeight; + s.levelWeight = s_prevLevelWeight; + s.useLevelUpPayload = s_prevUsePayload; + } + if (s_popup != null) s_popup.Subscribe(false); + PowerDeltaToast.Subscribe(false); + PowerDeltaToast.ResetDiagnostics(); + WLPowerDeltaSettings.RuntimeDisabled = false; + MyValue.MyPC = s_prevMyPc; + MyValue.bMyPC = s_prevBMyPc; + if (s_canvasGo != null) UnityEngine.Object.DestroyImmediate(s_canvasGo); + } + catch { } + } + + // ───────────────────────────────────────── ⓪ 에셋 + static void Sec0_Asset() + { + L(""); + L("── ⓪ 설정 에셋 · 성장 입력 값 전수 (C45 · 코드 상수 0)"); + var s = WLPowerDeltaSettings.Instance; + L(" " + P(s != null) + " WLPowerDeltaSettings 로드"); + if (s == null) return; + L(" " + P(WLPowerDeltaSettings.Enabled) + " Enabled · enabled_=" + s.enabled_); + L(" 성장 입력: useLevelUpPayload=" + s.useLevelUpPayload + " readGrowthFromServerData=" + s.readGrowthFromServerData + + " statPointWeight=" + s.statPointWeight + " atkGain=" + s.statPointAtkGain + " hpGain=" + s.statPointHpGain + + " perLevel=" + s.statPointsPerLevel + " levelWeight=" + s.levelWeight + " levelBase=" + s.levelBase); + L(" 기존 산식: atk=" + s.atkStat + "(폴백 " + s.atkFallbackStat + ") hp=" + s.hpStat + + " hpWeight=" + s.hpWeight + " powerScale=" + s.powerScale + " 최소 표시 " + s.minPercentToShow + "%" + + " suppressToastOnLevelUp=" + s.suppressToastOnLevelUp); + L(" " + P(s.statPointsPerLevel == 5) + " statPointsPerLevel=5 (원본 실측 ServerClass.cs:2770 Get_Point() = (Lv−1)*5 − 사용분)"); + L(" " + P(Near(s.statPointAtkGain, 1d) && Near(s.statPointHpGain, 22d)) + + " 포인트 환산 ATK +" + s.statPointAtkGain + " / MaxHP +" + s.statPointHpGain + + " (원본 실측 MyValue.cs:335-336 dic_MainStatMultiplier[STR] = ATK 1 · MaxHP 22)"); + } + + // ───────────────────────────────────────── ① 원인 실증 + static void Sec1_Cause() + { + L(""); + L("── ① 🔴 D-28 원인 실증 — 원본 스탯은 레벨업으로 안 움직인다 · 움직이는 것은 미사용 포인트뿐"); + var s = WLPowerDeltaSettings.Instance; + + // (a) 원본 규약 대조 — SD_PC 를 직접 만들어 레벨만 올려 본다(원본 API 만 · 쓰기 0) + var pc1 = new SD_PC(); // Lv=1 · Stat={0,0,0,0} + var pc16 = new SD_PC(); pc16.Lv = 16; // 레벨만 1 → 16 + L(" Lv 1 → 16 · 스탯 포인트 사용 0 · 장비 변화 0 일 때 원본이 주는 것:"); + L(" " + P(pc1.Get_Point() == 0 && pc16.Get_Point() == 75) + + " Get_Point() " + pc1.Get_Point() + " → " + pc16.Get_Point() + " (= (Lv−1)*5 · 미사용 포인트만 늘어난다)"); + L(" " + P(pc1.Get_Stat(eStat.STR) == 0 && pc16.Get_Stat(eStat.STR) == 0) + + " SD_PC.Get_Stat(STR) " + pc1.Get_Stat(eStat.STR) + " → " + pc16.Get_Stat(eStat.STR) + + " (= **사용한** 포인트만 셈 · table_classconfig.Get_PureStat:246 이 읽는 값) → ActorStatInfo 는 그대로"); + L(" ⇒ `ActorStatInfo.Reset`(MyValue.cs:286-453)은 SD_PC.Lv 를 한 번도 안 읽는다 = 813l 이 폴링해도 값이 상수"); + L(" (Q6 D-28 `PollCount 1573` 인데 `LastPower 52.000000` 불변 · 레벨 1→16)"); + + // (b) 내 포인트 산식이 원본 Get_Point() 와 값이 같은가(GC 0 판 · LINQ 없음) + var pcx = new SD_PC(); pcx.Lv = 16; pcx.Stat[0] = 3; pcx.Stat[1] = 2; // 5점 사용 + WLPowerMeter.Sample g = default(WLPowerMeter.Sample); + g.aspd = s.attackSpeedBase; + WLPowerMeter.ApplyGrowth(pcx, s, ref g); + L(" " + P((int)g.statPoints == pcx.Get_Point() && (int)g.level == 16) + + " ApplyGrowth ↔ 원본 Get_Point() 대조 — 내 값 " + g.statPoints.ToString("F0") + + " · 원본 " + pcx.Get_Point() + " · level=" + g.level.ToString("F0") + " (사용 5점 반영)"); + + // (c) 813l 원래 산식 회귀 — 성장 입력 0 이면 값이 그대로여야 한다 + double p1 = WLPowerMeter.Compute(100d, 0d, 0d, 1d, 1000d, s); + double p2 = WLPowerMeter.Compute(100d, 0.2d, 0.5d, 1d, 1000d, s); + double p3 = WLPowerMeter.Compute(100d, 0d, 0d, 1.3d, 1000d, s); + double p4 = WLPowerMeter.Compute(200d, 0.2d, 0.5d, 1.3d, 2000d, s); + L(" " + P(Near(p1, 200d) && Near(p2, 210d) && Near(p3, 230d) && Near(p4, 486d)) + + " 813l 산식 회귀 4건(성장 입력 0) — " + p1.ToString("F2") + " / " + p2.ToString("F2") + " / " + + p3.ToString("F2") + " / " + p4.ToString("F2") + " (기대 200 / 210 / 230 / 486)"); + + // (d) 성장 입력이 붙으면 값이 움직인다 — 손계산 대조 + // (100 + 5×1×1) × 1 × 1 + (1000 + 5×1×22) × 0.1 = 105 + 111.0 = 216.0 + double g5 = WLPowerMeter.Compute(100d, 0d, 0d, 1d, 1000d, 2d, 5d, s); + L(" " + P(Near(g5, 216d)) + " 포인트 5점 → " + g5.ToString("F2") + " (기대 216.00 = 105 + 111.0)"); + double g10 = WLPowerMeter.Compute(100d, 0d, 0d, 1d, 1000d, 3d, 10d, s); + L(" " + P(Near(g10, 232d)) + " 포인트 10점 → " + g10.ToString("F2") + " (기대 232.00 · 레벨당 +16.0)"); + L(" " + P(WLPowerMeter.PercentDelta(p1, g5) > 7.9d && WLPowerMeter.PercentDelta(p1, g5) < 8.1d) + + " 레벨업 1회 증가율 " + WLPowerMeter.PercentDelta(p1, g5).ToString("F2") + "% (최소 표시 " + s.minPercentToShow + "% 를 넘는다)"); + + // (e) 포인트를 실제로 찍어도 전투력이 튀지 않는다(잠재 → 실현 연속) + // 5점 미사용: (100+5)×1 + (1000+110)×0.1 = 216.0 + // 5점 사용 후: atk 100+5 · hp 1000+110 · 포인트 0 → 216.0 + double spent = WLPowerMeter.Compute(105d, 0d, 0d, 1d, 1110d, 2d, 0d, s); + L(" " + P(Near(spent, g5)) + " 포인트 사용 전/후 연속 — 미사용 " + g5.ToString("F2") + + " ↔ 사용 후 " + spent.ToString("F2") + " (statPointWeight=1 의 뜻)"); + } + + // ───────────────────────────────────────── ② 813k 실 Dispatch (스탯이 실제로 바뀐 경우) + static void Sec2_Payload() + { + L(""); + L("── ② 813k GrowthEvents.LevelUp 실 Dispatch — prev ATK 100 → new 112 (발주서 §1-3)"); + var s = WLPowerDeltaSettings.Instance; + PowerDeltaToast.ResetDiagnostics(); + + // (a) 에셋 기본(suppressToastOnLevelUp=1) — 팝업 줄이 표시하므로 토스트는 생략된다 + s.suppressToastOnLevelUp = true; + string d1 = PowerDeltaToast.RaiseFakeLevelUpForProbe(6, 7, 30, + eStat.ATK, 100d, 112d, eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d); + L(" " + d1); + // 기대: new = (112+30)×1×1 + (1000+30×22)×0.1 = 142 + 166 = 308 + // prev= (100+25)×1×1 + (1000+25×22)×0.1 = 125 + 155 = 280 (prev 포인트 = 30 − 5) + L(" " + P(PowerDeltaToast.LevelUpSeen == 1 && PowerDeltaToast.PayloadUsed == 1) + + " 수신 " + PowerDeltaToast.LevelUpSeen + "회 · 소스 \"" + PowerDeltaToast.LastPowerSource + "\""); + L(" " + P(Near(PowerDeltaToast.LastPayloadPrev, 280d) && Near(PowerDeltaToast.LastPayloadNew, 308d)) + + " 페이로드 전투력 prev=" + PowerDeltaToast.LastPayloadPrev.ToString("F2") + + " new=" + PowerDeltaToast.LastPayloadNew.ToString("F2") + " (기대 280.00 / 308.00 · 손계산)"); + L(" " + P(PowerDeltaToast.HasPower && Near(PowerDeltaToast.LastPower, 308d)) + + " 🔴 LastPower 0 → " + PowerDeltaToast.LastPower.ToString("F2") + " (D-28 = 여기가 52 로 고정이었다)"); + L(" " + P(PowerDeltaToast.SuppressedByPopup == 1 && PowerDeltaToast.PowerToastCount == 0) + + " 레벨업 프레임 토스트 생략(Suppressed=" + PowerDeltaToast.SuppressedByPopup + + " Toast=" + PowerDeltaToast.PowerToastCount + ") — 같은 값을 팝업 줄이 보여준다"); + string line = PowerDeltaToast.BuildStatPopupLine(); + L(" " + P(line != null && line.Contains("280") && line.Contains("308") && line.Contains("10.0")) + + " 팝업 줄 \"" + line + "\" (기대 280 → 308 · +10.0%)"); + + // (b) 토스트 자체 — 생략 스위치를 끄고 같은 페이로드를 다시 흘린다 + PowerDeltaToast.ResetDiagnostics(); + s.suppressToastOnLevelUp = false; + PowerDeltaToast.RaiseFakeLevelUpForProbe(6, 7, 30, + eStat.ATK, 100d, 112d, eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d); + L(" " + P(PowerDeltaToast.PowerToastCount == 1 && PowerDeltaToast.LastText.Contains("10.0")) + + " 토스트 " + PowerDeltaToast.PowerToastCount + "회 · 문구 \"" + PowerDeltaToast.LastText + "\" (기대 \"전투력 +10.0%\")"); + s.suppressToastOnLevelUp = s_prevSuppress; + + // (c) 페이로드를 끄면(=813l 원래 동작) 에디트 모드에서는 읽을 스탯이 없어 표시 0 — D-28 의 모양 + PowerDeltaToast.ResetDiagnostics(); + s.useLevelUpPayload = false; + PowerDeltaToast.RaiseFakeLevelUpForProbe(6, 7, 30, + eStat.ATK, 100d, 112d, eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d); + L(" " + P(PowerDeltaToast.PayloadUsed == 0 && PowerDeltaToast.PowerToastCount == 0 && !PowerDeltaToast.HasPower) + + " useLevelUpPayload=0 → 소스 \"" + PowerDeltaToast.LastPowerSource + "\" · 토스트 " + + PowerDeltaToast.PowerToastCount + "회 · LastPower 없음(= 813l 원래 경로)"); + s.useLevelUpPayload = s_prevUsePayload; + } + + // ───────────────────────────────────────── ③ D-28 재현 · 수리 + static void Sec3_D28() + { + L(""); + L("── ③ 🔴 D-28 재현 · 수리 — 스탯이 **하나도 안 바뀌는** 원본 기본 동작(813k 보고 ①)에서"); + var s = WLPowerDeltaSettings.Instance; + + // (a) 수리 후 — 스탯 prev == new 인데도 표시된다(포인트가 늘었으니까) + PowerDeltaToast.ResetDiagnostics(); + s.suppressToastOnLevelUp = false; + string d = PowerDeltaToast.RaiseFakeLevelUpForProbe(7, 8, 35, + eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + L(" " + d + " · 스탯 3종 prev == new (원본 기본값)"); + // 기대: new = (100+35)×1 + (1000+35×22)×0.1 = 135 + 177 = 312 + // prev= (100+30)×1 + (1000+30×22)×0.1 = 130 + 166 = 296 → +5.41% + L(" " + P(Near(PowerDeltaToast.LastPayloadPrev, 296d) && Near(PowerDeltaToast.LastPayloadNew, 312d)) + + " 전투력 " + PowerDeltaToast.LastPayloadPrev.ToString("F2") + " → " + + PowerDeltaToast.LastPayloadNew.ToString("F2") + " (기대 296.00 → 312.00)"); + L(" " + P(PowerDeltaToast.PowerToastCount == 1 && PowerDeltaToast.LastText.Contains("5.4")) + + " ✅ 수리 후 — 토스트 " + PowerDeltaToast.PowerToastCount + "회 \"" + PowerDeltaToast.LastText + "\""); + + // (b) 수리 전 모양 — 성장 입력을 0 으로 두면 그대로 D-28(값 불변 · 표시 0) + PowerDeltaToast.ResetDiagnostics(); + s.statPointWeight = 0f; s.levelWeight = 0f; + PowerDeltaToast.RaiseFakeLevelUpForProbe(7, 8, 35, + eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + double p0 = PowerDeltaToast.LastPayloadPrev, n0 = PowerDeltaToast.LastPayloadNew; + PowerDeltaToast.RaiseFakeLevelUpForProbe(8, 9, 40, + eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + L(" " + P(Near(p0, n0) && Near(PowerDeltaToast.LastPayloadNew, n0) && PowerDeltaToast.PowerToastCount == 0) + + " ❌ 재현(statPointWeight=0) — 레벨 7→8→9 인데 전투력 " + p0.ToString("F2") + " 불변 · 토스트 " + + PowerDeltaToast.PowerToastCount + "회 = Q6 D-28 그대로"); + s.statPointWeight = s_prevPointWeight; s.levelWeight = s_prevLevelWeight; + s.suppressToastOnLevelUp = s_prevSuppress; + + // (c) 다단 레벨업(levelsGained > 1) 도 prev 포인트를 제대로 역산하는가 + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.RaiseFakeLevelUpForProbe(5, 8, 35, + eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + // prev 포인트 = 35 − 3×5 = 20 → prev = (100+20)×1 + (1000+440)×0.1 = 120 + 144 = 264 + L(" " + P(Near(PowerDeltaToast.LastPayloadPrev, 264d)) + + " 다단(5→8 · 3레벨) prev 포인트 역산 → prev 전투력 " + PowerDeltaToast.LastPayloadPrev.ToString("F2") + + " (기대 264.00 = 포인트 20점)"); + } + + // ───────────────────────────────────────── ④ 팝업 줄 (813y StatPopup 왕복) + static void Sec4_PopupLine() + { + L(""); + L("── ④ 레벨업 팝업 전투력 줄 — 813y StatPopup 왕복(구독 → Dispatch → 줄)"); + var s = WLPowerDeltaSettings.Instance; + + s_canvasGo = new GameObject("__WL813l2_Canvas", typeof(Canvas), typeof(CanvasScaler)); + s_canvasGo.layer = 5; + var croot = (RectTransform)s_canvasGo.transform; + croot.sizeDelta = new Vector2(1080f, 1920f); + + var prt = new GameObject("WL_StatPopup", typeof(RectTransform)); + prt.layer = 5; + prt.transform.SetParent(croot, false); + s_popup = prt.AddComponent(); + L(" init: " + s_popup.Initialize()); + L(" " + P(StatPopup.PowerLineOn) + " StatPopup.PowerLineOn"); + L(" " + P(s_popup.Subscribed) + " StatPopup 구독=" + s_popup.Subscribed + + " · GrowthEvents.LevelUp 구독자=" + GrowthEvents.LevelUp.Count + "(StatPopup + PowerDeltaToast)"); + + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.RaiseFakeLevelUpForProbe(9, 10, 45, + eStat.MaxHP, 1000d, 1000d, eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + // new = (100+45) + (1000+990)×0.1 = 145 + 199 = 344 · prev = (100+40) + (1000+880)×0.1 = 140 + 188 = 328 + L(" 팝업 표시: rows=" + s_popup.LastRowCount + " level=" + s_popup.LastLevel + + " text=\"" + s_popup.LastText + "\""); + L(" " + P(!string.IsNullOrEmpty(s_popup.LastPowerLine) && + s_popup.LastPowerLine.Contains("328") && s_popup.LastPowerLine.Contains("344")) + + " 🔴 팝업 전투력 줄 \"" + s_popup.LastPowerLine + "\" (기대 328 → 344 · +4.9%)"); + L(" " + P(PowerDeltaToast.SuppressedByPopup + PowerDeltaToast.PowerToastCount == 1) + + " 중복 표시 0 — Suppressed=" + PowerDeltaToast.SuppressedByPopup + " Toast=" + PowerDeltaToast.PowerToastCount); + + // 구독 순서 무관 — StatPopup 이 먼저 구독돼 있어도 같은 값이 나온다(프레임 스탬프) + L(" " + P(PowerDeltaToast.LastPayloadPrev > 0d) + " 프레임 스탬프 prev=" + + PowerDeltaToast.LastPayloadPrev.ToString("F2") + " new=" + PowerDeltaToast.LastPayloadNew.ToString("F2") + + " → 팝업/토스트가 같은 값을 본다(구독 순서 무관)"); + } + + // ───────────────────────────────────────── ⑤ GC + static void Sec5_Gc() + { + L(""); + L("── ⑤ GC 0 (폴링 · 페이로드 · 포인트 산식)"); + var s = WLPowerDeltaSettings.Instance; + PowerDeltaToast.ResetDiagnostics(); + PowerDeltaToast.Observe(1000d, 0f, "base"); + + long a0 = Gc(), b0 = Prof(); + for (int i = 0; i < 1000; i++) PowerDeltaToast.Tick(3000f + i * 0.001f, 0.001f); + long a1 = Gc(), b1 = Prof(); + L(" " + P(a1 - a0 <= 0 && b1 - b0 <= 0) + " Tick × 1000 → GetTotalMemory " + (a1 - a0) + + " B · MonoUsedSize " + (b1 - b0) + " B (폴링 " + PowerDeltaToast.PollCount + "회)"); + + // 내 포인트 산식(for 루프 · 할당 0) vs 원본 Get_Point()(LINQ Select+Sum · 할당 있음) + var pcx = new SD_PC(); pcx.Lv = 30; pcx.Stat[0] = 7; + WLPowerMeter.Sample g = default(WLPowerMeter.Sample); + g.aspd = s.attackSpeedBase; + WLPowerMeter.ApplyGrowth(pcx, s, ref g); // 워밍업 + long c0 = Gc(); + for (int i = 0; i < 1000; i++) WLPowerMeter.ApplyGrowth(pcx, s, ref g); + long c1 = Gc(); + int sink = 0; + long d0 = Gc(); + for (int i = 0; i < 1000; i++) sink += pcx.Get_Point(); + long d1 = Gc(); + L(" " + P(c1 - c0 <= 0) + " ApplyGrowth × 1000 = " + (c1 - c0) + " B ↔ 원본 Get_Point() × 1000 = " + + (d1 - d0) + " B (원본은 LINQ Select+Sum · sink=" + sink + ")"); + + // 레벨업 1회당 비용 — 프레임 경로가 아니라 **이벤트 1회** 경로다. + // 기준선(A) = 프로브 헬퍼의 반환 문자열만(구독 off) · 측정(B) = 거기에 내 페이로드 경로가 얹힌 것. + if (s_popup != null) s_popup.Subscribe(false); // 813y 팝업 비용은 이 측정에서 뺀다 + PowerDeltaToast.Subscribe(false); + long e0 = Gc(); + for (int i = 0; i < 1000; i++) + PowerDeltaToast.RaiseFakeLevelUpForProbe(7, 8, 35, eStat.MaxHP, 1000d, 1000d, + eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + long e1 = Gc(); + PowerDeltaToast.Subscribe(true); + PowerDeltaToast.ResetDiagnostics(); + long f0 = Gc(); + for (int i = 0; i < 1000; i++) + PowerDeltaToast.RaiseFakeLevelUpForProbe(7, 8, 35, eStat.MaxHP, 1000d, 1000d, + eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + long f1 = Gc(); + L(" 레벨업 Dispatch × 1000 — 헬퍼만 " + (e0 == e1 ? "0" : (e1 - e0).ToString()) + " B · 구독 포함 " + (f1 - f0) + + " B → 내 경로 몫 " + ((f1 - f0) - (e1 - e0)) + " B / 1000회 (813l 때부터 있던 진단 문자열 · **프레임 경로 아님**)"); + + long h0 = Gc(); + var keep = new object[1000]; + for (int i = 0; i < 1000; i++) keep[i] = new object(); + long h1 = Gc(); + L(" 대조군 new object() × 1000 = " + (h1 - h0) + " B (0 이 아니어야 측정이 살아 있다) · keep=" + keep.Length); + PowerDeltaToast.ResetDiagnostics(); + } + + static long Gc() { return GC.GetTotalMemory(false); } + static long Prof() { return UnityEngine.Profiling.Profiler.GetMonoUsedSizeLong(); } + + // ───────────────────────────────────────── ⑥ C8 + static void Sec6_Rollback() + { + L(""); + L("── ⑥ C8 롤백"); + var s = WLPowerDeltaSettings.Instance; + PowerDeltaToast.ResetDiagnostics(); + WLPowerDeltaSettings.RuntimeDisabled = true; + L(" " + P(!WLPowerDeltaSettings.Enabled) + " Enabled=false"); + PowerDeltaToast.RaiseFakeLevelUpForProbe(7, 8, 35, eStat.MaxHP, 1000d, 1000d, + eStat.MaxMP, 200d, 200d, eStat.ATK, 100d, 100d); + L(" " + P(PowerDeltaToast.LevelUpSeen == 0 && PowerDeltaToast.PowerToastCount == 0) + + " 레벨업 무시(LevelUpSeen=" + PowerDeltaToast.LevelUpSeen + " Toast=" + PowerDeltaToast.PowerToastCount + ")"); + L(" " + P(PowerDeltaToast.BuildStatPopupLine() == null) + " BuildStatPopupLine()=null"); + WLPowerDeltaSettings.RuntimeDisabled = false; + L(" " + P(WLPowerDeltaSettings.Enabled) + " 해제 후 Enabled=true"); + + // 성장 입력만 끄면 813l 원래 산식 그대로(= 이번 변경 전 동작) + s.statPointWeight = 0f; s.levelWeight = 0f; + double r1 = WLPowerMeter.Compute(100d, 0d, 0d, 1d, 1000d, 16d, 75d, s); + s.statPointWeight = s_prevPointWeight; s.levelWeight = s_prevLevelWeight; + L(" " + P(Near(r1, 200d)) + " statPointWeight=0 · levelWeight=0 → 레벨 16 · 포인트 75 여도 " + + r1.ToString("F2") + " (813l 원래 산식과 동일 = 값 하나로 되돌린다)"); + } +} diff --git a/Assets/WL/UI/Scripts/PowerDeltaToast.cs b/Assets/WL/UI/Scripts/PowerDeltaToast.cs index 8315b0070..8c9799512 100644 --- a/Assets/WL/UI/Scripts/PowerDeltaToast.cs +++ b/Assets/WL/UI/Scripts/PowerDeltaToast.cs @@ -14,10 +14,33 @@ // · Gameplay(`Assets/WL/Combat/**`) 수정 0 — 811b `CombatEvents` · 813k `GrowthEvents` · 813p `RunEvents` **구독만**. // · 프리팹 diff 0 — 토스트 노드는 런타임 생성(`HideFlags.DontSave`) · 813q `RunHud` 선례. // +// ■ 🔴 WL-813l2 — Q6 D-28(레벨 1→16 인데 `LastPower 52.000000` 불변 · 토스트 0회) 원인과 수리 +// 원인(실측 · 「추정」 아님): +// · `ActorStatInfo.Reset`(`Assets/Script/Util/MyValue.cs:286-453`)이 **`SD_PC.Lv` 를 한 번도 안 읽는다.** +// PC 스탯은 `table_classconfig.Get_PureStat`(`Table/PC/table_classconfig.cs:232-248` +// = 보유 직업 기본치 + **사용한** 포인트 `SD_PC.Get_Stat`) + 장비·각성·초월·유물에서만 만들어진다. +// · 레벨업(`Server/ServerClass.cs:2725-2739 Add_Exp`)은 `++Lv` 뿐 — 스탯은 안 건드리고 +// `Get_Point()`(`:2770` = `(Lv−1)*5 − 사용분`)의 **미사용 포인트만** 늘린다. +// 포인트를 실제로 찍는 곳은 수동 UI `PCInfoStatMgrUI.cs:81` 하나뿐이다(자동 배분 0). +// → 813l 이 레벨업 때도 `ActorStatInfo` 를 **다시 읽기만** 해서(`Observe(sm.power)`) 값이 상수 = 표시 0회. +// 폴링은 살아 있었다(Q6 `PollCount 1573`) — **읽는 값이 안 움직인 것**이다. +// 수리(발주서 WL-813l2 §1-2): +// ① 레벨업 경로는 813k `GrowthEvents.LevelUp` **페이로드**(prev/new 스탯 3종 · prevLevel/newLevel · +// statPointTotal)로 성장 전/후 전투력을 만든다 — 813y `StatPopup` 이 읽는 것과 **같은 값**이다. +// ② 산식 입력에 **미사용 스탯 포인트**를 넣는다. 원본 실측 환산(주스탯 1점 → `ATK +1`·`MaxHP +22` · +// `MyValue.cs:335-336`)을 그대로 써서, 포인트를 찍어도 전투력이 튀지 않는다(잠재→실현 연속). +// 가중치·환산·레벨 기여는 전부 SO(`statPointWeight`·`statPointAtkGain`·`statPointHpGain`·`levelWeight`). +// ③ 장비/강화 경로는 그대로 폴링이다 — 실측으로 **폴링이 옳다**는 것을 확인했다(아래 항). +// // ■ 🔴 장비/강화 변경 이벤트는 원본에 없다(실측) // `ServerClass.Set_Equip`(`Assets/Script/Server/ServerClass.cs:2329`)·`ActorStatInfo.Reset` 어디에도 // 구독 가능한 신호가 없고, 원본 파일 수정은 금지다. → 발주서 §1-2 대안대로 **폴링**한다 // (`pollIntervalSeconds` · 기본 0.5 s). 폴링 경로는 값 비교뿐이라 **할당 0**(GC 0) — 변화가 있을 때만 문자열이 난다. +// 🔴 813l2 재실측 — **폴링이 실제로 잡는다**: 장비 착용·강화·각성·초월·유물·스탯 포인트 사용이 전부 +// `MyValue.Get_ActorStatInfoorNull(eRole.PC)`(예: `UI/Eqiupment/EquipmentPopup_Obtain.cs:85` · +// `EquipmentItemPopup.cs:77` · `EquipmentUI.cs:96` · `PCInfoStatMgrUI.cs:85`)를 부르고, 그게 +// `MyValue.cs:122` 에서 **공유 캐시 인스턴스**(`dic_ActorStatInfo[(PC,"0",0)]`)에 `Reset()` 을 건다. +// 인게임 PC 도 `Actor.cs:322` 로 **같은 인스턴스**를 물고 있다 → 다음 폴링(≤0.5 s)에 값이 바뀐다. // // ■ C8 롤백 — `WLPowerDeltaSettings.enabled_ = 0`(또는 `RuntimeDisabled`) → 구독 0 · 폴링 0 · 노드 0 · 표시 0. // @@ -50,17 +73,38 @@ namespace WL.UI public struct Sample { public double atk, critRate, critDamage, aspd, hp, power; + /// WL-813l2 성장 입력 — 레벨(`SD_PC.Lv`)과 미사용 스탯 포인트(`SD_PC.Get_Point()`). + public double level, statPoints; + /// 공격력이 1순위 스탯(BaseDmg)이 아니라 폴백(ATK)에서 왔는가 — 페이로드 환산 방식이 갈린다. + public bool atkFromFallback; public bool valid; public override string ToString() { return "atk=" + atk.ToString("F1") + " cri=" + critRate.ToString("F3") + " criDmg=" + critDamage.ToString("F3") + " aspd=" + aspd.ToString("F3") + - " hp=" + hp.ToString("F1") + " → power=" + power.ToString("F1"); + " hp=" + hp.ToString("F1") + " lv=" + level.ToString("F0") + + " pt=" + statPoints.ToString("F0") + " → power=" + power.ToString("F1"); } } - /// 순수 함수 — 스탯 5개와 계수로 전투력을 만든다(Unity 객체 0 · 프로브가 그대로 검증한다). + /// 순수 함수(성장 입력 없음) — 813l 원래 산식. 아래 8인자 판에 0/0 을 넘긴다. public static double Compute(double atk, double critRate, double critDamage, double aspd, double hp, WLPowerDeltaSettings s) + { + return Compute(atk, critRate, critDamage, aspd, hp, 0d, 0d, s); + } + + /// + /// 순수 함수 — 스탯 5개 + 성장 입력 2개(레벨 · 미사용 스탯 포인트)와 계수로 전투력을 만든다 + /// (Unity 객체 0 · 프로브가 그대로 검증한다). + /// + /// 🔴 WL-813l2 — 미사용 스탯 포인트를 넣는 이유는 **원본이 레벨업으로 스탯을 안 올리기 때문**이다 + /// (실측 근거는 파일 머리 주석). 포인트를 「아직 안 쓴 힘」으로 보고 원본 실측 환산 + /// (주스탯 1점 → `ATK +1` · `MaxHP +22` · `MyValue.cs:335-336`)으로 산식에 그대로 넣으면, + /// 포인트를 실제로 찍는 순간(포인트 −1 · 스탯 +1/+22) 전투력이 **튀지 않는다**(잠재→실현 연속). + /// `statPointWeight = 0` 이면 813l 원래 산식과 완전히 같다(C8). + /// + public static double Compute(double atk, double critRate, double critDamage, double aspd, double hp, + double level, double statPoints, WLPowerDeltaSettings s) { if (s == null) return 0d; double crit = s.useCritDamage ? critRate * critDamage : critRate; @@ -68,27 +112,55 @@ namespace WL.UI double aspdTerm = 1d + s.attackSpeedWeight * (aspd - s.attackSpeedBase); if (critTerm < 0d) critTerm = 0d; if (aspdTerm < 0d) aspdTerm = 0d; - double p = atk * critTerm * aspdTerm + hp * s.hpWeight; + double pts = statPoints > 0d ? statPoints * s.statPointWeight : 0d; + if (pts < 0d) pts = 0d; + double p = (atk + pts * s.statPointAtkGain) * critTerm * aspdTerm + + (hp + pts * s.statPointHpGain) * s.hpWeight; + double lv = level - s.levelBase; + if (lv > 0d) p += lv * s.levelWeight; return p * s.powerScale; } - /// 원본 스탯 묶음에서 전투력을 읽는다(쓰기 0). 스탯이 없으면 false. + /// 원본 스탯 묶음에서 전투력을 읽는다(쓰기 0). 스탯이 없으면 false. 성장 입력은 0 = 스탯만. public static bool TryRead(ActorStatInfo stat, WLPowerDeltaSettings s, out Sample o) { o = default(Sample); if (stat == null || s == null) return false; double atk = stat.Get_Stat(s.atkStat); - if (atk <= 0d) atk = stat.Get_Stat(s.atkFallbackStat); + if (atk <= 0d) { atk = stat.Get_Stat(s.atkFallbackStat); o.atkFromFallback = true; } o.atk = atk; o.critRate = stat.Get_Stat(s.critRateStat); o.critDamage = s.useCritDamage ? stat.Get_Stat(s.critDamageStat) : 0d; o.aspd = stat.Get_Stat(s.attackSpeedStat); o.hp = stat.Get_Stat(s.hpStat); - o.power = Compute(o.atk, o.critRate, o.critDamage, o.aspd, o.hp, s); + o.power = Compute(o.atk, o.critRate, o.critDamage, o.aspd, o.hp, o.level, o.statPoints, s); o.valid = true; return true; } + /// + /// WL-813l2 — 서버 데이터에서 성장 입력(레벨 · 미사용 스탯 포인트)을 채우고 전투력을 다시 만든다. + /// 🔴 **할당 0**: 원본 `SD_PC.Get_Point()`(`ServerClass.cs:2769-2770`)는 LINQ(`Select`+`Sum`)라 + /// 폴링마다 가비지가 난다 → **같은 식을 for 루프로 다시 센다**(값 동일 · GC 0). + /// 「레벨당 5포인트」는 코드 상수가 아니라 SO(`statPointsPerLevel` · 원본 실측 5)다. + /// + public static void ApplyGrowth(SD_PC pc, WLPowerDeltaSettings s, ref Sample o) + { + if (s == null) return; + if (pc != null && s.readGrowthFromServerData) + { + int lv = (int)pc.Lv; + o.level = lv; + int used = 0; + var lst = pc.Stat; + // ObscuredInt 는 int 와 양방향 암시 변환이 있어 연산자가 모호해진다 → 명시 캐스트로 고정. + if (lst != null) for (int i = 0; i < lst.Count; i++) used += (int)lst[i]; + int pts = (lv - 1) * s.statPointsPerLevel - used; + o.statPoints = pts > 0 ? pts : 0; + } + o.power = Compute(o.atk, o.critRate, o.critDamage, o.aspd, o.hp, o.level, o.statPoints, s); + } + /// 메인 PC 의 현재 전투력. PC 나 스탯이 없으면 false(로비·에디트 모드). public static bool TryReadMainPc(out Sample o) { @@ -98,7 +170,12 @@ namespace WL.UI if (!MyValue.bMyPC) return false; var pc = MyValue.MyPC; if (pc == null) return false; - return TryRead(pc.Get_StatInfo(), s, out o); + if (!TryRead(pc.Get_StatInfo(), s, out o)) return false; + // 🔴 `ServerInfo.Ins` 는 게터가 GameObject 를 새로 만든다(부작용 · `Server/ServerInfo.cs:19`) → + // 쓰지 않는다. PC 가 이미 물고 있는 `ServerData` 만 읽는다(`Actor.cs:987` · 읽기 전용). + var sd = pc.Get_ServerData(); + ApplyGrowth(sd != null ? sd.PC : null, s, ref o); + return true; } /// 증가율(%) — prev 가 0 이하면 0(나눗셈 방지). @@ -154,8 +231,10 @@ namespace WL.UI private static float s_lastAnnounceAt = -999f; private static int s_suppressToastFrame = -1; // 레벨업 프레임 — 팝업이 전투력 줄을 이미 보여 준다 - // 레벨업 프레임의 「성장 직전 전투력」 — 813y StatPopup 이 어떤 순서로 구독됐든 같은 값을 보게 한다. + // 레벨업 프레임의 「성장 직전/직후 전투력」 — 813y StatPopup 이 어떤 순서로 구독됐든 같은 값을 보게 한다. + // 🔴 813l2: 직후 값도 못박는다 — 팝업이 그때 `ActorStatInfo` 를 다시 읽으면 레벨업 분이 안 보인다(D-28). private static double s_growthPrevPower; + private static double s_growthNewPower; private static int s_growthPrevFrame = -1; // ── 처치 타수 계측 ─────────────────────────────────────────────────── @@ -173,6 +252,11 @@ namespace WL.UI public static int PollCount, ObserveCount, SkippedSmall, SkippedDown, MergedByCooldown, SuppressedByPopup; public static string LastText = ""; public static string LastCompareText = ""; + // WL-813l2 진단 — 레벨업 전투력이 어느 소스에서 왔는가(페이로드/스탯 재읽기/실패) · 그때의 prev·new. + public static string LastPowerSource = ""; + public static double LastPayloadPrev, LastPayloadNew; + public static double LastLevelSeen, LastStatPointsSeen; + public static int PayloadUsed, PayloadMissed; public static double LastPower { get { return s_lastPower; } } public static bool HasPower { get { return s_hasLastPower; } } public static bool Subscribed { get { return s_subscribed; } } @@ -384,7 +468,12 @@ namespace WL.UI s_nextPoll = now + Mathf.Max(0.05f, s.pollIntervalSeconds); PollCount++; WLPowerMeter.Sample sm; - if (WLPowerMeter.TryReadMainPc(out sm)) Observe(sm.power, now, "poll"); + if (WLPowerMeter.TryReadMainPc(out sm)) + { + // 진단용 대입뿐 — 문자열·박싱 0(GC 0 유지). + LastLevelSeen = sm.level; LastStatPointsSeen = sm.statPoints; + Observe(sm.power, now, "poll"); + } } // ② 하한·쿨다운으로 미뤄 둔 변화 마감(기준값과 마지막 관측값이 다르면 다시 본다) @@ -472,19 +561,107 @@ namespace WL.UI return r; } - // ── 813k 레벨업 ────────────────────────────────────────────────────── + // ── 813k 레벨업 (🔴 WL-813l2 = D-28 수리 지점) ─────────────────────── private static void OnLevelUp(in PlayerLevelUpEvent e) { if (!WLPowerDeltaSettings.Enabled) return; LevelUpSeen++; + var s = St; float now = Time.unscaledTime; - // 팝업 줄이 읽을 「성장 직전 전투력」을 이 프레임에 못박는다(구독 순서 무관 · 아래 BuildStatPopupLine). - if (s_hasLastPower) { s_growthPrevPower = s_baseline; s_growthPrevFrame = Time.frameCount; } s_suppressToastFrame = Time.frameCount; // 이 프레임의 전투력 토스트는 팝업 줄에 양보한다 // 레벨업은 「성장 이벤트」다 — 스탯이 그대로여도(원본 기본 동작 · 813k 보고 ①) 타수 창은 새로 연다. MarkGrowth(now, "levelUp " + e.prevLevel + "→" + e.newLevel); - WLPowerMeter.Sample sm; - if (WLPowerMeter.TryReadMainPc(out sm)) Observe(sm.power, now, "levelUp"); + + double prevP, newP; + string src; + if (s != null && s.useLevelUpPayload && TryPayloadPower(s, in e, out prevP, out newP)) + { + PayloadUsed++; + src = "levelUp(813k 페이로드)"; + } + else + { + // 폴백 = 813l 원래 동작. 🔴 원본은 레벨업으로 스탯을 안 올리므로 여기로 떨어지면 값이 안 움직인다(D-28). + WLPowerMeter.Sample sm; + if (!WLPowerMeter.TryReadMainPc(out sm)) + { + PayloadMissed++; LastPowerSource = "levelUp(스탯 읽기 실패)"; + return; + } + PayloadMissed++; + newP = sm.power; + prevP = s_hasLastPower ? s_baseline : newP; + src = "levelUp(스탯 재읽기)"; + } + LastPowerSource = src; LastPayloadPrev = prevP; LastPayloadNew = newP; + LastLevelSeen = e.newLevel; LastStatPointsSeen = e.statPointTotal; + + // 기준값이 아직 없으면 페이로드의 prev 로 연다 — 첫 레벨업이 「기준값 세우기」로 삼켜지지 않게. + if (!s_hasLastPower) { s_lastPower = prevP; s_baseline = prevP; s_hasLastPower = true; } + + // 팝업 줄이 읽을 값을 이 프레임에 못박는다(813y StatPopup 과 구독 순서 무관 · 아래 BuildStatPopupLine). + // prev 는 「마지막으로 **표시한** 값」(s_baseline)이라 토스트와 이중 계상이 나지 않는다. + s_growthPrevPower = s_baseline; + s_growthNewPower = newP; + s_growthPrevFrame = Time.frameCount; + + Observe(newP, now, src); + } + + /// + /// 🔴 WL-813l2 — 813k `LevelUp` 페이로드로 성장 전/후 전투력을 만든다(D-28 수리의 핵심). + /// 레벨업 순간 `ActorStatInfo` 를 다시 읽으면 **prev == new** 다(원본이 레벨업으로 스탯을 안 올린다). + /// 그래서 ① 스탯 3종은 페이로드의 `prevStatX`/`newStatX`(= 813y `StatPopup` 이 읽는 바로 그 값) + /// ② 레벨은 `prevLevel`/`newLevel` ③ 미사용 포인트는 `statPointTotal` 과 그 역산값을 쓴다. + /// + private static bool TryPayloadPower(WLPowerDeltaSettings s, in PlayerLevelUpEvent e, out double prevP, out double newP) + { + prevP = 0d; newP = 0d; + WLPowerMeter.Sample cur; + if (!WLPowerMeter.TryReadMainPc(out cur)) + { + // 월드에 PC 가 없다(로비·오프라인 보상·에디트 모드) — 페이로드만으로 만든다. + cur = default(WLPowerMeter.Sample); + cur.aspd = s.attackSpeedBase; // 기준 배율 = 공속 기여 0 + } + // 산식이 BaseDmg(1순위)를 쓰는 중인가 — 페이로드가 ATK 를 줄 때 환산 방식이 갈린다. + bool atkPrimary = cur.valid && !cur.atkFromFallback && cur.atk > 0d; + + var nw = cur; var pv = cur; + ApplyPayloadStat(s, ref nw, e.statAKind, e.newStatA, e.newStatA, atkPrimary); + ApplyPayloadStat(s, ref nw, e.statBKind, e.newStatB, e.newStatB, atkPrimary); + ApplyPayloadStat(s, ref nw, e.statCKind, e.newStatC, e.newStatC, atkPrimary); + ApplyPayloadStat(s, ref pv, e.statAKind, e.prevStatA, e.newStatA, atkPrimary); + ApplyPayloadStat(s, ref pv, e.statBKind, e.prevStatB, e.newStatB, atkPrimary); + ApplyPayloadStat(s, ref pv, e.statCKind, e.prevStatC, e.newStatC, atkPrimary); + + // 「레벨업 직전 미사용 포인트」 역산 — 원본 Get_Point() = (Lv−1)*5 − 사용분 이므로 + // 사용분이 그대로면 오른 레벨 수 × 레벨당 포인트만큼 적었다. + int gained = e.newLevel - e.prevLevel; if (gained < 0) gained = 0; + int per = s.statPointsPerLevel > 0 ? s.statPointsPerLevel : 0; + int prevPts = e.statPointTotal - gained * per; if (prevPts < 0) prevPts = 0; + + newP = WLPowerMeter.Compute(nw.atk, nw.critRate, nw.critDamage, nw.aspd, nw.hp, e.newLevel, e.statPointTotal, s); + prevP = WLPowerMeter.Compute(pv.atk, pv.critRate, pv.critDamage, pv.aspd, pv.hp, e.prevLevel, prevPts, s); + return newP > 0d || prevP > 0d; + } + + /// 페이로드 스탯 1개를 산식 입력에 얹는다. 산식에 없는 스탯(예 MaxMP)은 그냥 버린다. + private static void ApplyPayloadStat(WLPowerDeltaSettings s, ref WLPowerMeter.Sample o, + eStat kind, double v, double refNew, bool atkPrimary) + { + if (kind == s.hpStat) { o.hp = v; return; } + if (kind == s.critRateStat) { o.critRate = v; return; } + if (kind == s.critDamageStat) { if (s.useCritDamage) o.critDamage = v; return; } + if (kind == s.attackSpeedStat) { o.aspd = v; return; } + if (kind == s.atkStat) { o.atk = v; return; } + if (kind == s.atkFallbackStat) + { + // 산식이 BaseDmg(= ATK × ATK_Multiplier × DMG_Multiplier · 실측 MyValue.cs:384-386)를 쓰는 중이면 + // 배율을 모르는 채 ATK 로 덮어쓸 수 없다 → 같은 비율로 환산한다. + if (!atkPrimary) o.atk = v; + else if (refNew > 0d) o.atk = o.atk * (v / refNew); + } } // ── 처치 타수 계측 ─────────────────────────────────────────────────── @@ -595,13 +772,20 @@ namespace WL.UI { var s = St; if (s == null || !WLPowerDeltaSettings.Enabled || !s.statPopupLineEnabled) return null; - WLPowerMeter.Sample sm; - if (!WLPowerMeter.TryReadMainPc(out sm)) return null; - double now = sm.power; + // 이 프레임에 레벨업 스냅샷이 찍혔으면 **그 값만** 쓴다(PowerDeltaToast 가 먼저 구독됐든 StatPopup 이 먼저든 같다). + // 🔴 813l2: 여기서 `ActorStatInfo` 를 다시 읽으면 안 된다 — 원본은 레벨업으로 스탯을 안 올려서 + // prev == new 가 되고 팝업 줄이 「변화 없음」으로 굳는다(D-28 과 같은 뿌리). + bool stamped = s_growthPrevFrame == Time.frameCount; + double now; + if (stamped) now = s_growthNewPower; + else + { + WLPowerMeter.Sample sm; + if (!WLPowerMeter.TryReadMainPc(out sm)) return null; + now = sm.power; + } if (now <= 0d) return null; - // 이 프레임에 레벨업 스냅샷이 찍혔으면 그것을 쓴다(PowerDeltaToast 가 먼저 구독됐든 StatPopup 이 먼저든 같은 값). - double prev = s_growthPrevFrame == Time.frameCount ? s_growthPrevPower - : (s_hasLastPower ? s_baseline : now); + double prev = stamped ? s_growthPrevPower : (s_hasLastPower ? s_baseline : now); double pct = WLPowerMeter.PercentDelta(prev, now); if (prev <= 0d || System.Math.Abs(pct) < 0.005d) return DSUtil.Format(s.statPopupLineFormatNoChange, WLPowerMeter.Format(now)); @@ -617,8 +801,10 @@ namespace WL.UI LevelUpSeen = PowerToastCount = CompareToastCount = ZoneClearedSeen = 0; PollCount = ObserveCount = SkippedSmall = SkippedDown = MergedByCooldown = SuppressedByPopup = 0; LastText = ""; LastCompareText = ""; + LastPowerSource = ""; LastPayloadPrev = LastPayloadNew = 0d; + LastLevelSeen = LastStatPointsSeen = 0d; PayloadUsed = PayloadMissed = 0; s_lastPower = 0d; s_baseline = 0d; s_hasLastPower = false; - s_growthPrevPower = 0d; s_growthPrevFrame = -1; s_suppressToastFrame = -1; + s_growthPrevPower = 0d; s_growthNewPower = 0d; s_growthPrevFrame = -1; s_suppressToastFrame = -1; s_lastAnnounceAt = -999f; s_nextPoll = 0f; s_curHits = s_curAttacks = s_curKills = 0; s_windowStart = 0f; s_beforeRatio = 0f; s_hasBefore = false; s_armed = false; s_armFrom = s_armTo = 0; s_armedAt = 0f; @@ -647,6 +833,32 @@ namespace WL.UI return "hits=" + s_curHits + " attacks=" + s_curAttacks + " kills=" + s_curKills; } + /// + /// 프로브 전용(WL-813l2) — 813k `GrowthEvents.LevelUp` 을 **실제로 Dispatch** 한다(구독 경로 그대로). + /// 813y `StatPopup.RaiseFakeGrowthLevelUp` 은 스탯 3종이 고정이라 「prev ATK 100 → new 112」를 못 만든다. + /// + public static string RaiseFakeLevelUpForProbe(int prevLevel, int newLevel, int statPointTotal, + eStat kindA, double prevA, double newA, + eStat kindB, double prevB, double newB, + eStat kindC, double prevC, double newC) + { + var e = new PlayerLevelUpEvent + { + data = null, pc = null, + prevLevel = prevLevel, newLevel = newLevel, + levelsGained = Mathf.Max(1, newLevel - prevLevel), + statPointTotal = statPointTotal, + statAKind = kindA, prevStatA = prevA, newStatA = newA, + statBKind = kindB, prevStatB = prevB, newStatB = newB, + statCKind = kindC, prevStatC = prevC, newStatC = newC, + hpBefore = 0d, maxHp = 0d, mpBefore = 0d, maxMp = 0d, + position = Vector3.zero, time = Time.unscaledTime, frame = Time.frameCount, + }; + GrowthEvents.LevelUp.Dispatch(in e); + return "dispatch GrowthEvents.LevelUp " + prevLevel + "→" + newLevel + + " pt=" + statPointTotal + " · 구독자=" + GrowthEvents.LevelUp.Count; + } + /// 프로브 전용 — 813p `RunEvents.ZoneCleared` 를 가짜로 흘린다. public static string RaiseFakeZoneClearedForProbe(int zoneIndex) { diff --git a/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset b/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset index 498b75809..5ec9e27db 100644 --- a/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset +++ b/Assets/WL/UI/Settings/Resources/WL/WLPowerDeltaSettings.asset @@ -26,6 +26,14 @@ MonoBehaviour: hpStat: 10 hpWeight: 0.1 powerScale: 1 + useLevelUpPayload: 1 + readGrowthFromServerData: 1 + statPointWeight: 1 + statPointAtkGain: 1 + statPointHpGain: 22 + statPointsPerLevel: 5 + levelWeight: 0 + levelBase: 1 toastEnabled: 1 hudParentPath: IngameUIs/WL_HUD toastCenterOffsetPx: 430 diff --git a/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs b/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs index 761819f5e..330235dc3 100644 --- a/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs +++ b/Assets/WL/UI/Settings/WLPowerDeltaSettings.cs @@ -128,6 +128,40 @@ namespace WL.UI [Tooltip("최종 전투력에 곱하는 표시 배율(자릿수 조정용). 1 = 산식 그대로.")] public float powerScale = 1f; + // ── 성장 입력 (WL-813l2 · Q6 D-28) ──────────────────────────────────── + // 🔴 실측: 원본은 **레벨업으로 스탯을 1도 올리지 않는다**. + // · `ActorStatInfo.Reset`(`Assets/Script/Util/MyValue.cs:286-453`)은 `SD_PC.Lv` 를 한 번도 읽지 않는다. + // · PC 스탯은 `table_classconfig.Get_PureStat`(`Table/PC/table_classconfig.cs:232-248` + // = 보유 직업 기본치 + **사용한** 스탯 포인트 `SD_PC.Get_Stat`) + 장비·각성·초월·유물에서만 온다. + // · 레벨업은 `SD_PC.Get_Point()`(`Server/ServerClass.cs:2770` = `(Lv−1)*5 − 사용분`)로 + // **미사용 스탯 포인트만** 늘린다. 포인트를 찍는 곳은 `PCInfoStatMgrUI.cs:81` 수동 UI 하나뿐이다. + // → 그래서 813l 이 `ActorStatInfo` 만 보면 레벨 1→16 에도 전투력이 상수다(Q6 D-28 `LastPower 52` 불변). + // 발주서 WL-813l2 §1-2 대로 **813k `LevelUp` 페이로드(prev/new 스탯 · statPointTotal)** 를 입력으로 쓴다. + [Header("성장 입력 (WL-813l2 · D-28) — 🔴 원본은 레벨업으로 스탯을 안 올린다(위 주석 = 실측)")] + [Tooltip("레벨업 경로에서 813k GrowthEvents.LevelUp 페이로드(prev/new 스탯 · prevLevel/newLevel · statPointTotal)로 전투력을 만든다. 끄면 813l 원래 동작(레벨업 때 ActorStatInfo 재읽기 = D-28 재발).")] + public bool useLevelUpPayload = true; + + [Tooltip("폴링 경로에서 SD_PC 의 레벨·미사용 스탯 포인트를 읽는다(장비/강화 갱신과 같은 주기). 끄면 성장 입력 0.")] + public bool readGrowthFromServerData = true; + + [Tooltip("미사용 스탯 포인트를 얼마나 「이미 얻은 힘」으로 셀지(0~1). 1 = 찍은 것과 똑같이 센다 → 포인트를 실제로 찍어도 전투력이 튀지 않는다(잠재→실현 연속). 0 = 813l 원래 산식 그대로(= D-28 재발).")] + public float statPointWeight = 1f; + + [Tooltip("스탯 포인트 1점의 공격력 환산. 원본 실측 = 1 (MyValue.cs:335/343/351 · 주스탯 1점 → ATK += 1).")] + public float statPointAtkGain = 1f; + + [Tooltip("스탯 포인트 1점의 체력 환산. 원본 실측 = 22 (MyValue.cs:336 · STR 1점 → MaxHP += 22). DEX/INT 주스탯 직업은 이 몫이 없다 = 「추정」.")] + public float statPointHpGain = 22f; + + [Tooltip("레벨 1당 주는 스탯 포인트. 원본 실측 = 5 (ServerClass.cs:2770 Get_Point() = (Lv−1)*5 − 사용분). 페이로드에서 「레벨업 직전 포인트」를 역산할 때도 쓴다.")] + public int statPointsPerLevel = 5; + + [Tooltip("레벨 1당 전투력 기여(스탯·포인트와 별개의 보정). 기본 0 = 끔 — 켜면 포인트를 다 쓴 뒤에도 레벨만으로 전투력이 오른다.")] + public float levelWeight = 0f; + + [Tooltip("레벨 기여의 기준 레벨(이 레벨을 빼서 증가분을 만든다). 원본 시작 레벨 1.")] + public int levelBase = 1; + // ── 전투력 % 토스트 ─────────────────────────────────────────────────── [Header("전투력 % 토스트")] [Tooltip("끄면 토스트만 표시 안 함(팝업 줄·타수 비교는 그대로).")]