-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgui.cpp
More file actions
2150 lines (1988 loc) · 107 KB
/
Copy pathgui.cpp
File metadata and controls
2150 lines (1988 loc) · 107 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// ============================================================
// ZZZ Gacha Visualizer - Win32 + GDI+ + PMR / 预分桶 / AoS
// ------------------------------------------------------------
// 自 Endfield Gacha Visualizer (v0.1.3.3 加固版) 迁移到绝区零:
// 三个分析池: 独家频段(代理人 UP, "2") / 音擎频段(武器 UP, "3") /
// 常驻频段(热门卡司, "1"); 邦布频段("5") 不分析。
// 保底模型: 代理人/常驻 0.6%/74软/90硬, 音擎 1.0%/65软/80硬;
// UP 大保底真实存在 ("歪了下次必中"), 水位与大保底状态跨期【永续】
// (区别于终末地的每期重置) → 无卡池边界探测, 无 pool_map, 无 is_free。
// 数组容量 200: 代理人 UP 大保底间隔上限 180 (90 歪 + 90 保底) + 富余。
// ============================================================
#include <windows.h>
#include <commctrl.h>
#include <richedit.h>
#include <gdiplus.h>
#include <string>
#include <vector>
#include <unordered_set>
#include <unordered_map>
#include <numeric>
#include <cmath>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <string_view>
#include <charconv>
#include <ranges>
#include <memory_resource>
#include <array>
#include <span> // v0.1.3.3: 理论 CDF 表改用 std::span 传参
#include <cstdint>
#include <memory> // std::make_unique_for_overwrite (C++20) —— worker 的 2MB PMR arena 用它在堆上不清零分配
#include <process.h> // _beginthreadex / _endthreadex(调用 CRT 的线程应走这个而非裸 CreateThread)
#pragma comment(lib, "gdiplus.lib")
#pragma comment(lib, "comctl32.lib")
#pragma comment(lib, "user32.lib") // CreateWindowExW / SendMessageW / MessageBoxW / GetMessageW ...
#pragma comment(lib, "gdi32.lib") // BitBlt / CreateCompatibleDC / SelectObject / CreateFontW ...
#pragma comment(lib, "shell32.lib") // DragAcceptFiles / DragQueryFileW / DragFinish
// ---------------------------------------------------------
// [枚举降维]
// 注: 分析核心不消费 item_type 字段 —— 分桶按 gacha_type, 稀有度按
// rank_type, UP 判定按 name (item_type 仅存在于拉取/导出链路 main_zzz.cpp)。
// ---------------------------------------------------------
// 稀有度: 2=B, 3=A, 4=S
enum class RankType : uint8_t { Unknown = 0, RankB = 2, RankA = 3, RankS = 4 };
// gacha_type 数字字符串: "1"/"2"/"3"/"5" (UIGF v4.2 nap.gacha_type enum)
enum class GachaType : uint8_t {
Unknown = 0,
Standard = 1, // "1" 常驻频段(热门卡司) — 分析对象 (仅综合, 无 UP)
AgentUP = 2, // "2" 独家频段(代理人 UP) — 分析对象
WEngineUP = 3, // "3" 音擎频段(武器 UP) — 分析对象
Bangboo = 5 // "5" 邦布频段 — 不分析
};
inline RankType ParseRankType(std::string_view sv) {
if (sv == "4") return RankType::RankS;
if (sv == "3") return RankType::RankA;
if (sv == "2") return RankType::RankB;
return RankType::Unknown;
}
inline GachaType ParseGachaType(std::string_view sv) {
// ZZZ 的 gacha_type 是数字字符串, 严格相等匹配即可;
// 不做 Contains 匹配以免 "12" 之类的值误配 (API 实际不返回这种值, 纯防御)
if (sv == "1") return GachaType::Standard;
if (sv == "2") return GachaType::AgentUP;
if (sv == "3") return GachaType::WEngineUP;
if (sv == "5") return GachaType::Bangboo;
return GachaType::Unknown;
}
// ---------------------------------------------------------
// [极简 JSON 模块 - 修复转义边界]
// ---------------------------------------------------------
inline size_t FindJsonKey(std::string_view source, std::string_view key, size_t startPos = 0) {
while (true) {
size_t pos = source.find(key, startPos);
if (pos == std::string_view::npos) return std::string_view::npos;
if (pos > 0 && source[pos - 1] == '"' &&
(pos + key.length() < source.length()) &&
source[pos + key.length()] == '"') return pos - 1;
startPos = pos + key.length();
}
}
inline std::string_view ExtractJsonValue(std::string_view source, std::string_view key, bool isString) {
size_t pos = FindJsonKey(source, key);
if (pos == std::string_view::npos) return {};
pos = source.find(':', pos + key.length() + 2);
if (pos == std::string_view::npos) return {};
++pos;
while (pos < source.length() &&
(source[pos] == ' ' || source[pos] == '\t' ||
source[pos] == '\n' || source[pos] == '\r')) ++pos;
if (isString) {
if (pos >= source.length() || source[pos] != '"') return {};
++pos;
size_t endPos = pos;
while (endPos < source.length() && source[endPos] != '"') {
if (source[endPos] == '\\' && endPos + 1 < source.length()) endPos += 2;
else ++endPos;
}
return (endPos < source.length()) ? source.substr(pos, endPos - pos) : std::string_view{};
} else {
size_t endPos = pos;
// 注意:原版 gui.cpp 少了 ']' 判断(main.cpp 有),这里补齐以保证解析嵌套数组值时不出错
while (endPos < source.length() &&
source[endPos] != ',' && source[endPos] != '}' &&
source[endPos] != ']' && source[endPos] != ' ' &&
source[endPos] != '\n' && source[endPos] != '\r') ++endPos;
return source.substr(pos, endPos - pos);
}
}
template<typename Callback>
void ForEachJsonObject(std::string_view source, std::string_view arrayKey, Callback&& cb) {
size_t pos = FindJsonKey(source, arrayKey);
if (pos == std::string_view::npos) return;
pos = source.find(':', pos + arrayKey.length() + 2);
if (pos == std::string_view::npos) return;
pos = source.find('[', pos);
if (pos == std::string_view::npos) return;
int depth = 0;
size_t objStart = 0;
const size_t len = source.length();
for (size_t i = pos; i < len; ++i) {
char c = source[i];
if (c == '"') {
for (++i; i < len; ++i) {
if (source[i] == '\\' && i + 1 < len) { ++i; continue; }
if (source[i] == '"') break;
}
continue;
}
if (c == '{') {
if (depth == 0) objStart = i;
++depth;
} else if (c == '}') {
--depth;
if (depth == 0) cb(source.substr(objStart, i - objStart + 1));
} else if (c == ']' && depth == 0) {
break;
}
}
}
struct StringHash {
using is_transparent = void;
size_t operator()(std::string_view sv) const { return std::hash<std::string_view>{}(sv); }
};
inline std::string WideToUtf8(std::wstring_view wstr) {
if (wstr.empty()) return {};
int size = WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), nullptr, 0, nullptr, nullptr);
std::string result(size, 0);
WideCharToMultiByte(CP_UTF8, 0, wstr.data(), (int)wstr.size(), result.data(), size, nullptr, nullptr);
return result;
}
// 注意:常驻名单文本中故意只识别 ASCII ',' 作为分隔符。
// 全角逗号 ','(U+FF0C) 不视为分隔符 —— 因为合法的物品名本身可能含全角符号
// (ZZZ 里「11号」用了全角直角引号「」, 未来也可能出现含全角逗号的名字)。
// 把全角逗号当分隔符会导致这些条目被切碎, UP 识别失效。
//
// ZZZ 的 UP 判定是二元化的: "S 级不在常驻名单 = 当期 UP", 不需要终末地那种
// "池名→UP角色" 映射 (ParsePoolMapUtf8* 已随之移除); 宽字符版
// ParseCommaSeparatedUtf8 在 Endfield 版中已是死代码 (主线程只做
// GetWindowText→WideToUtf8, 解析统一在 worker 用 FromUtf8 版), 一并移除。
inline std::string TrimUtf8(std::string_view sv) {
size_t b = sv.find_first_not_of(" \t\r\n");
if (b == std::string_view::npos) return {};
size_t e = sv.find_last_not_of(" \t\r\n");
return std::string(sv.substr(b, e - b + 1));
}
std::unordered_set<std::string, StringHash, std::equal_to<>> ParseCommaSeparatedUtf8FromUtf8(std::string_view text) {
// 与 wchar_t 版同步: 仅识别 ASCII ',' 作为分隔符,不识别全角逗号。
std::unordered_set<std::string, StringHash, std::equal_to<>> result;
std::string cur;
for (size_t i = 0; i < text.size(); ++i) {
if (text[i] == ',') {
std::string trimmed = TrimUtf8(cur);
if (!trimmed.empty()) result.insert(std::move(trimmed));
cur.clear();
} else {
cur += text[i];
}
}
std::string trimmed = TrimUtf8(cur);
if (!trimmed.empty()) result.insert(std::move(trimmed));
return result;
}
// ---------------------------------------------------------
// [SoA 分桶 - 独家/音擎/常驻 独立桶,Calculate 不再 filter 全量]
// 统计热路径 (非 S 级的 [[likely]] 分支) 只访问紧凑的 rank_types 标量数组;
// names 仅在少量 S 级记录做 UP 判定时才访问 (会触达 mmap 字节)。
// ZZZ 不存在终末地的 poolNames(期边界重置)/is_free(赠送十连)/starts_new_banner:
// 水位与大保底状态跨期永续, 全部记录按 id 升序连续推进。
// ---------------------------------------------------------
struct PullBucket {
std::pmr::vector<RankType> rank_types;
std::pmr::vector<std::string_view> names;
explicit PullBucket(std::pmr::polymorphic_allocator<std::byte> alloc)
: rank_types(alloc), names(alloc) {}
void reserve(size_t cap) {
rank_types.reserve(cap); names.reserve(cap);
}
void push_back(RankType rt, std::string_view name) {
rank_types.push_back(rt); names.push_back(name);
}
size_t size() const { return rank_types.size(); }
};
// StatsAccumulator: Calculate() 内的单线程累加器 (局部变量 acc), 不存在多核并发写,
// 不需要 cache-line 对齐 —— 故不加 alignas (旧版的 alignas(128) 在单线程下是无操作,
// 留着只会误导维护者以为这里有并发)。将来若改成多线程分片归约、每线程持有相邻
// accumulator, 再按实际 cache-line 布局补 padding 防 false sharing 即可。
struct StatsAccumulator {
std::array<int, 200> freq_all{}; // 容量 200: 代理人 UP 间隔上限 180 + 富余
std::array<int, 200> freq_up{};
long long sum_all = 0, sum_sq_all = 0, sum_up = 0, sum_sq_up = 0, sum_win = 0;
int count_all = 0, count_up = 0, count_win = 0;
int max_pity_all = 0, max_pity_up = 0;
int win_5050 = 0, lose_5050 = 0;
// 右删失:循环结束时仍在累积、尚未结算的当前保底计数
// 生存分析里这些样本应参与分母(risk set),但不参与分子(event)
int censored_pity_all = 0;
int censored_pity_up = 0;
};
struct StatsResult {
std::array<int, 200> freq_all{};
std::array<int, 200> freq_up{};
int count_all = 0, count_up = 0;
double avg_all = 0.0, avg_up = 0.0, avg_win = -1.0;
double cv_all = 0.0, ci_all_err = 0.0, ci_up_err = 0.0;
int win_5050 = 0, lose_5050 = 0;
double win_rate_5050 = -1.0;
std::array<double, 200> hazard_all{}, hazard_up{};
double ks_d_all = 0.0, ks_d_up = 0.0;
bool ks_is_normal = true, ks_is_normal_up = true;
// 右删失(用于显示"当前已垫 N 抽")
int censored_pity_all = 0;
int censored_pity_up = 0;
};
StatsResult statsAgent, statsWEngine, statsStandard;
HWND hOutEdit, hAgentEdit, hWEngineEdit;
static HBITMAP g_hChartBmp = NULL;
int g_dpi = 96;
int DPIScale (int value) { return MulDiv(value, g_dpi, 96); }
float DPIScaleF(float value) { return value * (g_dpi / 96.0f); }
// -------------------------------------------------------
// CDF 表 & KS 检验
// -------------------------------------------------------
// 米哈游模型 (官方概率公示 + 社区实测软保底拐点):
//
// 代理人池(独家频段) / 常驻频段(热门卡司) — 同一 S 级出货分布:
// 基础概率 0.6%, 90 抽硬保底, 综合概率 1.6%
// 软保底拐点: 第74抽起, 每抽 +6%
// k=1..73: hazard = 0.006
// k=74..89: hazard = 0.006 + (k-73) * 0.06 = 0.066, 0.126, ..., 0.966
// k=90: hazard = 1.0 (硬保底)
// 验证: 综合期望 ≈ 62.30 抽, 综合概率 = 1/62.30 ≈ 1.605% ✓
//
// 音擎池(音擎频段):
// 基础概率 1.0%, 80 抽硬保底, 综合概率 2.0%
// 软保底拐点: 第65抽起, 每抽 +7%
// k=1..64: hazard = 0.01
// k=65..79: hazard = 0.01 + (k-64) * 0.07 = 0.08, 0.15, ..., 0.99
// k=80: hazard = 1.0 (硬保底)
// 验证: 综合期望 ≈ 49.71 抽, 综合概率 = 1/49.71 ≈ 2.012% ✓
//
// UP CDF: 双状态前向迭代 (Dn 没歪 / Dl 已歪):
// "歪了下次必中"的大保底真实存在 (区别于终末地), 且状态跨期永续。
// 转移: 没歪状态出货 → winRate 毕业, (1-winRate) 转入 Dl[0];
// 已歪状态出货 → 100% 毕业 (大保底)。
// 代理人: 50% 不歪, 间隔上限 180 (89 垫 + 1 歪 + 89 垫 + 1 保底), E[首UP] ≈ 93.45
// 音擎: 75% 不歪, 间隔上限 160, E[首UP] ≈ 62.13
// 常驻频段没有 UP, 综合 K-S 对照复用 g_cdf_agent (同分布)。
static double g_cdf_agent[92] = {}; // x=0..90, 代理人/常驻池综合 (末位哨兵 91)
static double g_cdf_wengine[82] = {}; // x=0..80, 音擎池综合
static double g_cdf_agent_up[182] = {}; // x=0..180, 代理人池 UP
static double g_cdf_wengine_up[162] = {}; // x=0..160, 音擎池 UP
static bool g_cdf_init = false;
static double HazardAgent(int k) {
if (k <= 73) return 0.006;
else if (k <= 89) return (std::min)(1.0, 0.006 + (k - 73) * 0.06);
else return 1.0;
}
static double HazardWEngine(int k) {
if (k <= 64) return 0.01;
else if (k <= 79) return (std::min)(1.0, 0.01 + (k - 64) * 0.07);
else return 1.0;
}
void InitCDFTables() {
// 幂等保护: 仅 WinMain 启动时 (任何 worker 创建之前) 单线程调用一次,
// bool 标志即可, 不需要 call_once
if (g_cdf_init) return;
// ---- 代理人/常驻池综合 CDF ----
{
double surv = 1.0;
for (int i = 1; i <= 90; ++i) {
double p = HazardAgent(i);
g_cdf_agent[i] = g_cdf_agent[i - 1] + surv * p;
surv *= (1.0 - p);
}
g_cdf_agent[91] = 1.0;
}
// ---- 音擎池综合 CDF ----
{
double surv = 1.0;
for (int i = 1; i <= 80; ++i) {
double p = HazardWEngine(i);
g_cdf_wengine[i] = g_cdf_wengine[i - 1] + surv * p;
surv *= (1.0 - p);
}
g_cdf_wengine[81] = 1.0;
}
// ---- UP CDF 双状态前向迭代 (代理人 50/50 上限 180; 音擎 75/25 上限 160) ----
// 状态空间: Dn[s] (没歪过, 水位 s) + Dl[s] (已歪, 水位 s)
auto buildUp = [](double* cdf, int hard_cap, int full_cap, double win_rate,
double (*hazard)(int)) {
std::vector<double> Dn(hard_cap + 1, 0.0); Dn[0] = 1.0;
std::vector<double> Dl(hard_cap + 1, 0.0);
double cum = 0.0;
for (int n = 1; n <= full_cap; ++n) {
std::vector<double> newDn(hard_cap + 1, 0.0), newDl(hard_cap + 1, 0.0);
for (int sidx = 0; sidx < hard_cap; ++sidx) {
double h = hazard(sidx + 1);
if (Dn[sidx] > 0) {
double hit = Dn[sidx] * h;
cum += hit * win_rate; // 不歪, 毕业
newDl[0] += hit * (1.0 - win_rate); // 歪 → 已歪状态, 水位归 0
newDn[sidx + 1] += Dn[sidx] * (1.0 - h); // 不出货, 水位 +1
}
if (Dl[sidx] > 0) {
double hit = Dl[sidx] * h;
cum += hit; // 大保底, 必毕业
newDl[sidx + 1] += Dl[sidx] * (1.0 - h);
}
}
cdf[n] = (std::min)(1.0, cum);
Dn = std::move(newDn); Dl = std::move(newDl);
}
cdf[full_cap + 1] = 1.0; // 哨兵
};
buildUp(g_cdf_agent_up, 90, 180, 0.50, &HazardAgent);
buildUp(g_cdf_wengine_up, 80, 160, 0.75, &HazardWEngine);
g_cdf_init = true; // 末尾置位,确保所有读者看到完整表
}
// 修复:离散阶梯 CDF 的 K-S 统计量需严格对齐两条阶梯// 修复:离散阶梯 CDF 的 K-S 统计量需严格对齐两条阶梯
// 在 x 处,两条阶梯的"底":F_n(cum before x),F_theory(x-1)
// 在 x 处,两条阶梯的"顶":F_n(cum after x),F_theory(x)
// 原版用 fn_before 减 cdf_table[x] —— 拿经验阶梯底对理论阶梯顶,
// 人为引入 h_x 的单点跳跃(软保底段可高达 5%+),造成巨大伪偏差
double ComputeKS(const std::array<int, 200>& freq, int max_pity, int n,
std::span<const double> cdf_table) {
// v0.1.3.3: "裸指针 + 长度"两个散参 → std::span (C++20)。长度随表走,
// 调用方不可能再把表和长度传错配对; 函数体保留局部 cdf_len, 下方逻辑零改动。
const int cdf_len = (int)cdf_table.size();
if (n == 0) return 0.0;
// 防御性 clamp: freq 数组容量 200,max_pity 必须 < 200 否则越界读
if (max_pity > 199) max_pity = 199;
// 找到 CDF 表的"有效末端" last_valid (饱和到 1 或单调性破坏前的最后一格).
// 越过 last_valid 后, 用 cdf[last_valid] 而非 1.0 作 fallback。ZZZ 各池
// CDF 都自然饱和到 1.0 (大保底封顶), 这里主要是定位饱和点, 避免在硬保底
// 之后的哨兵区做无意义比较; 单调性分支是纯防御。
constexpr double EPS_SAT = 1e-6;
int last_valid = cdf_len - 1;
for (int k = 1; k < cdf_len; ++k) {
if (cdf_table[k] >= 1.0 - EPS_SAT) { last_valid = k; break; }
if (k > 0 && cdf_table[k] + EPS_SAT < cdf_table[k - 1]) { last_valid = k - 1; break; }
}
auto lookup_cdf = [&](int idx) -> double {
if (idx < 0) return 0.0;
if (idx >= cdf_len) return cdf_table[last_valid];
return cdf_table[idx];
};
double max_d = 0.0;
int cum_count = 0;
for (int x = 1; x <= max_pity; ++x) {
double fn_before = (double)cum_count / n;
double cdf_before_x = lookup_cdf(x - 1);
cum_count += freq[x];
double fn_after = (double)cum_count / n;
double cdf_after_x = lookup_cdf(x);
double d1 = std::abs(fn_before - cdf_before_x);
double d2 = std::abs(fn_after - cdf_after_x);
if (d1 > max_d) max_d = d1;
if (d2 > max_d) max_d = d2;
}
return max_d;
}
// -------------------------------------------------------
// 统计工具:t 分布 95% 双侧临界值 (α/2 = 0.025)
// -------------------------------------------------------
// 当样本量较小时(N < 30),标准正态 z=1.96 的 CI 会严重低估真实不确定性
// (因为 t 分布尾部更厚)。严格的样本 CI 应该用 t_{α/2, N-1}
//
// 实现策略:
// df = 1, 2, 3, 4:查表(Hill 近似在低 df 误差较大,最高 0.75%)
// df ≥ 5:用 Hill(1970) 四阶渐近展开(误差 < 0.02%)
// df → ∞ 时收敛到 z = 1.959964
inline double TCritical95(int df) {
// α=0.025 双侧 95% CI
if (df <= 0) return 1.959963984540054; // 保护
// 低自由度查表(值来自 scipy.stats.t.ppf(0.975, df))
static constexpr double kTable[] = {
0.0, // df=0 占位
12.706205, // df=1
4.302653, // df=2
3.182446, // df=3
2.776445, // df=4
};
if (df <= 4) return kTable[df];
// Hill 1970 四阶展开
constexpr double z = 1.959963984540054;
constexpr double z2 = z * z;
constexpr double z3 = z2 * z;
constexpr double z5 = z3 * z2;
constexpr double z7 = z5 * z2;
constexpr double z9 = z7 * z2;
constexpr double g1 = (z3 + z) / 4.0;
constexpr double g2 = (5.0*z5 + 16.0*z3 + 3.0*z) / 96.0;
constexpr double g3 = (3.0*z7 + 19.0*z5 + 17.0*z3 - 15.0*z) / 384.0;
constexpr double g4 = (79.0*z9 + 776.0*z7 + 1482.0*z5 - 1920.0*z3 - 945.0*z) / 92160.0;
double d = (double)df;
double inv_d = 1.0 / d;
return z + g1 * inv_d
+ g2 * inv_d * inv_d
+ g3 * inv_d * inv_d * inv_d
+ g4 * inv_d * inv_d * inv_d * inv_d;
}
// -------------------------------------------------------
// 无偏样本方差(贝塞尔校正):s² = [Σx² - (Σx)²/N] / (N-1)
// 注意 N=1 时样本方差未定义(除零),返回 0
// -------------------------------------------------------
inline double SampleVariance(long long sum, long long sum_sq, int n) {
if (n <= 1) return 0.0;
// 数值稳定式:避免先算 mean 再做 E[X²]-E[X]² 的灾难性消去
double numerator = (double)sum_sq - (double)sum * sum / (double)n;
if (numerator < 0.0) numerator = 0.0; // 浮点误差保护
return numerator / (double)(n - 1);
}
// -------------------------------------------------------
// 统计核心 - bucket 已只含目标池子,无需 filter
//
// 三个池子的语义 (与 macOS/iOS AnalyzerWrapper.mm 1:1 对齐):
// - 独家频段 (Agent): 50% 不歪, 90 硬保底 / 74 起软保底, 有大保底
// - 音擎频段 (WEngine): 75% 不歪, 80 硬保底 / 65 起软保底, 有大保底
// - 常驻频段 (Standard): 无 UP 概念, 90 硬保底 / 74 起软保底, 仅综合统计
//
// 关键机制 (区别于终末地):
// - 大保底真实存在: 歪了之后下一个 S 级【必定】是当期 UP
// → had_non_up 状态机跟踪小/大保底状态
// - 水位与大保底状态【永续】: 跨卡池期数保留, 全部记录按 id 升序连续推进,
// 不做任何期边界重置。删失只发生在记录末尾 (当前在途水位)。
// - UP 判定二元化: 出的 S 级不在 standard_names 常驻名单里就是当期 UP
// (UP 池里的 S 级只有"当期 UP + 常驻 S"两种来源)
//
// win_5050 / lose_5050 / avg_win:
// - had_non_up=false (小保底/50-50 阶段) 时出 UP 计入 "win";
// had_non_up=true (大保底) 时出 UP 是必然事件, 不算"赢"。
// - 出非 UP S 级: 仅当上一个 S 是 UP (新一轮判定失败) 才计入 lose;
// had_non_up=true 时不重复计数 (违反规则的异常数据防御)。
// - avg_win (count_win/sum_win): "小保底阶段成功毕业"的样本期望,
// 代理人/音擎两池都有大保底, 语义一致, 都累计。
// - 常驻频段无 UP: 全部 UP 相关字段保持 0 / -1 sentinel。
// -------------------------------------------------------
enum class PoolKind : uint8_t { Agent, WEngine, Standard };
StatsResult Calculate(const PullBucket& bucket, PoolKind kind,
const std::unordered_set<std::string, StringHash, std::equal_to<>>& standard_names) {
StatsAccumulator acc;
const bool hasUp = (kind != PoolKind::Standard);
int current_pity = 0, pity_since_last_up = 0;
bool had_non_up = false; // 上一个 S 是否歪了 (小/大保底状态; 跨期永续)
const size_t total = bucket.size();
for (size_t i = 0; i < total; ++i) {
++current_pity;
++pity_since_last_up;
// 非 S 级:likely 分支 (UP 池里也会出 A 级音擎/邦布, 照常推进保底进度)
if (bucket.rank_types[i] != RankType::RankS) [[likely]] {
continue;
}
// 出 S 级
const int slot_all = current_pity;
if (slot_all < 200) acc.freq_all[slot_all]++;
if (slot_all > acc.max_pity_all) acc.max_pity_all = slot_all;
acc.count_all++;
acc.sum_all += slot_all;
acc.sum_sq_all += (long long)slot_all * slot_all;
if (hasUp) {
// UP 判定: 不在常驻名单里就是当期 UP (二元化)
bool isUP = !standard_names.contains(bucket.names[i]);
if (isUP) {
const int slot_up = pity_since_last_up;
if (slot_up < 200) acc.freq_up[slot_up]++;
if (slot_up > acc.max_pity_up) acc.max_pity_up = slot_up;
acc.count_up++;
acc.sum_up += slot_up;
acc.sum_sq_up += (long long)slot_up * slot_up;
if (!had_non_up) {
// 小保底阶段掷中 UP = 真实的"赢" (大保底必中不计入)
acc.win_5050++;
acc.count_win++;
acc.sum_win += slot_all;
}
had_non_up = false;
pity_since_last_up = 0;
} else {
// 歪了: 仅小保底阶段计 lose (大保底阶段出非 UP 违反规则, 防御性不计)
if (!had_non_up) acc.lose_5050++;
had_non_up = true;
}
}
current_pity = 0;
}
// 右删失:遍历结束时仍有未结算的水位 → 删失样本
// (生存分析: 进分母 risk set, 不进分子 event)。
// ZZZ 水位永续 → 全部历史只有这【一个】末尾在途水位是删失样本。
acc.censored_pity_all = current_pity;
acc.censored_pity_up = hasUp ? pity_since_last_up : 0;
// 防御性 clamp:即使数据异常导致 max_pity / censored_pity > 199,
// 后续 ComputeKS 与 hazard 循环的索引访问也必须安全
if (acc.max_pity_all > 199) acc.max_pity_all = 199;
if (acc.max_pity_up > 199) acc.max_pity_up = 199;
if (acc.censored_pity_all > 199) acc.censored_pity_all = 199;
if (acc.censored_pity_up > 199) acc.censored_pity_up = 199;
StatsResult s;
s.freq_all = acc.freq_all;
s.freq_up = acc.freq_up;
s.count_all = acc.count_all;
s.count_up = acc.count_up;
s.win_5050 = acc.win_5050;
s.lose_5050 = acc.lose_5050;
s.censored_pity_all = acc.censored_pity_all;
s.censored_pity_up = acc.censored_pity_up;
if (acc.count_all > 0) {
s.avg_all = (double)acc.sum_all / acc.count_all;
// 贝塞尔校正的无偏样本方差; N=1 时 SampleVariance 返回 0(CI 也自然为 0)
double var = SampleVariance(acc.sum_all, acc.sum_sq_all, acc.count_all);
double std_all = std::sqrt(var);
s.cv_all = (s.avg_all > 0) ? std_all / s.avg_all : 0;
// CI 使用 t 分布临界值(自由度 N-1),小样本下比 z=1.96 更保守正确
double t_crit = TCritical95(acc.count_all - 1);
s.ci_all_err = t_crit * std_all / std::sqrt((double)acc.count_all);
// K-S 检验:代理人/常驻池用 g_cdf_agent (同分布),音擎池用 g_cdf_wengine
const std::span<const double> cdf_tbl = (kind == PoolKind::WEngine)
? std::span<const double>(g_cdf_wengine) // 82
: std::span<const double>(g_cdf_agent); // 92
s.ks_d_all = ComputeKS(acc.freq_all, acc.max_pity_all, acc.count_all,
cdf_tbl);
s.ks_is_normal = (s.ks_d_all <= (1.36 / std::sqrt((double)acc.count_all)));
}
// Kaplan-Meier 式经验风险函数 - 综合 S 级:
// risk set 初值 = 全部观测样本(已毕业 + 删失)
// 到 x 抽时 hazard[x] = freq[x] / survivors
// survivors 每步先减去事件(freq[x]),再减去在 x 发生的删失
// 即使 count_all=0 也要处理:用户可能从未出 S 级但已垫 N 抽(极少见但有效)
if (acc.count_all > 0 || acc.censored_pity_all > 0) {
int survivors = acc.count_all + (acc.censored_pity_all > 0 ? 1 : 0);
int max_reach_all = (std::max)(acc.max_pity_all, acc.censored_pity_all);
if (max_reach_all > 199) max_reach_all = 199; // 防御性 clamp
for (int x = 1; x <= max_reach_all; ++x) {
if (survivors > 0) {
s.hazard_all[x] = (double)acc.freq_all[x] / survivors;
survivors -= acc.freq_all[x];
if (x == acc.censored_pity_all) survivors -= 1;
}
}
}
if (acc.count_up > 0) {
s.avg_up = (double)acc.sum_up / acc.count_up;
double var = SampleVariance(acc.sum_up, acc.sum_sq_up, acc.count_up);
double std_up = std::sqrt(var);
double t_crit = TCritical95(acc.count_up - 1);
s.ci_up_err = t_crit * std_up / std::sqrt((double)acc.count_up);
// UP K-S 检验: 用双状态前向迭代得到的 g_cdf_*_up。
// ZZZ 全部是单抽粒度判定, 无终末地武器池那种"申领聚合"需求,
// 经验 freq_up 直接与理论 CDF 逐抽比较。
const std::span<const double> cdf_up_tbl = (kind == PoolKind::WEngine)
? std::span<const double>(g_cdf_wengine_up) // 162
: std::span<const double>(g_cdf_agent_up); // 182
s.ks_d_up = ComputeKS(acc.freq_up, acc.max_pity_up, acc.count_up,
cdf_up_tbl);
s.ks_is_normal_up = (s.ks_d_up <= (1.36 / std::sqrt((double)acc.count_up)));
}
// UP hazard 同理
if (acc.count_up > 0 || acc.censored_pity_up > 0) {
int survivors = acc.count_up + (acc.censored_pity_up > 0 ? 1 : 0);
int max_reach_up = (std::max)(acc.max_pity_up, acc.censored_pity_up);
if (max_reach_up > 199) max_reach_up = 199; // 防御性 clamp
for (int x = 1; x <= max_reach_up; ++x) {
if (survivors > 0) {
s.hazard_up[x] = (double)acc.freq_up[x] / survivors;
survivors -= acc.freq_up[x];
if (x == acc.censored_pity_up) survivors -= 1;
}
}
}
if (acc.count_win > 0) s.avg_win = (double)acc.sum_win / acc.count_win;
if (acc.win_5050 + acc.lose_5050 > 0)
s.win_rate_5050 = (double)acc.win_5050 / (acc.win_5050 + acc.lose_5050);
return s;
}
// ---------------------------------------------------------
// [RAII 句柄]
// ---------------------------------------------------------// ---------------------------------------------------------
// [RAII 句柄]
// ---------------------------------------------------------
struct FileGuard {
HANDLE h = INVALID_HANDLE_VALUE;
~FileGuard() { if (h != INVALID_HANDLE_VALUE) CloseHandle(h); }
operator HANDLE() const { return h; }
};
struct MapGuard {
HANDLE h = NULL;
~MapGuard() { if (h) CloseHandle(h); }
operator HANDLE() const { return h; }
};
struct ViewGuard {
const void* p = nullptr;
~ViewGuard() { if (p) UnmapViewOfFile(p); }
};
// ---------------------------------------------------------
// 文件处理
// ---------------------------------------------------------
// 安全读取动态长度的 Edit 控件文本
// 原版固定 wchar_t[4096] 在用户粘贴超长 UP 映射文本时会被 GetWindowTextW 截断,
// 下游解析看到的是不完整数据 → 丢失记录。
// 先 GetWindowTextLengthW 查长度再按需分配,彻底消除截断风险
inline std::wstring GetDynamicWindowText(HWND hwnd) {
int len = GetWindowTextLengthW(hwnd);
if (len <= 0) return L"";
// v0.1.3.3: GetWindowTextLengthW 对 RichEdit 文档明示"可能大于实际长度"(估计值)。
// 旧写法忽略 GetWindowTextW 的实际拷贝数, 高估时 wstring 尾部残留 L'\0' 填充;
// 这些 NUL 经 WideToUtf8 原样转换, 粘在名单最后一个 token 上 (TrimUtf8 只剥空白
// 不剥 '\0'), 导致最后一个常驻名 / 最后一条 UP 映射永远匹配不上 JSON 里的名字,
// 统计被静默污染。修复: 按实际拷贝数 resize。
std::wstring buf((size_t)len + 1, L'\0');
int copied = GetWindowTextW(hwnd, buf.data(), len + 1);
buf.resize(copied > 0 ? (size_t)copied : 0);
return buf;
}
// ---------------------------------------------------------
// [文件处理 - 工作线程化]
//
// 原版 WM_DROPFILES 同步调 ProcessFile + RebuildChartCache,期间窗口消息
// 循环阻塞,用户无法移动窗口/输入/最小化。重构后:
// 1) 主线程做 I/O 准备(读 GUI 文本框 + 把文件内容拷到 std::string)
// 2) Worker 线程做纯 CPU 计算(JSON 解析 + Calculate),结果写入 heap 上
// 的 ProcessOutput 对象
// 3) Worker 用 PostMessage(WM_APP_PROCESS_DONE) 把结果指针回投到主线程
// 4) 主线程在该消息处理中更新 statsAgent/statsWEngine/statsStandard + UI,然后 delete output
//
// 注意:
// - GDI / SetWindowTextW 都不是 thread-safe,只能在主线程调
// - statsAgent/statsWEngine/statsStandard 是全局,WM_PAINT 通过 g_hChartBmp 间接读它们,
// 但 g_hChartBmp 由 RebuildChartCache 重建,所以只要 RebuildChartCache
// 和 stats 写入都在主线程串行,就不需要锁
// - g_processing 标志防止 worker 跑时重复触发(双开 worker)
// - 线程句柄保留在 g_hWorker (主线程独占): 下次 Submit 开头与 WM_DESTROY 时
// join + CloseHandle, 保证进程退出前 worker 已完整走完 CRT 尾声 (见 WM_DESTROY)
// ---------------------------------------------------------
#define WM_APP_PROCESS_DONE (WM_APP + 1)
// 前向声明:RebuildChartCache 定义在 DrawECDF/DrawMRL 之后,但 ProcessFile_Consume
// 需要在文件中段调用它。
void RebuildChartCache(HWND hwnd);
// 跨线程载荷:主线程构造,worker 填充结果,主线程消费后 delete
struct ProcessOutput {
HWND hwnd_main = NULL; // 主窗口,worker 用 PostMessage 回投到这里
// === 主线程预填(由 ProcessFile_Submit 设置) ===
// 文件 buffer 用 mmap 直读,零拷贝(与原版 ProcessFile + macOS Analyzer 对齐)。
// 三个 handle 必须存活到 Consume 阶段才能 unmap/close,因为 ExportRecord
// 内的 string_view 都指向 mmap 区域。
HANDLE hFile = INVALID_HANDLE_VALUE;
HANDLE hMap = NULL;
const void* viewPtr = nullptr;
size_t fileSize = 0; // v0.1.3.2: DWORD → size_t, 配合 GetFileSizeEx (不再 32 位截断)
std::string utf8_agents; // 来自 hAgentEdit (GUI 控件文本必须主线程 GetWindowText)
std::string utf8_wengines; // 来自 hWEngineEdit
// === worker 填充 ===
bool ok = false;
StatsResult statsAgent;
StatsResult statsWEngine;
StatsResult statsStandard;
std::wstring outMsg;
std::wstring errMsg;
~ProcessOutput() {
// 主线程消费后调 delete 时统一清理 mmap 资源
if (viewPtr) UnmapViewOfFile(viewPtr);
if (hMap) CloseHandle(hMap);
if (hFile != INVALID_HANDLE_VALUE) CloseHandle(hFile);
}
};
// 用全局原子防双开;Win32 上 LONG volatile + InterlockedExchange 等价于 atomic_flag
static volatile LONG g_processing = 0;
// worker 线程句柄, 仅主线程读写。生命周期: Submit 创建 → (a) 下次 Submit 开头
// join+close (此刻上一个 worker 早已投递结果/自清理, 最多剩微秒级 CRT 尾声), 或
// (b) WM_DESTROY join+close (退出前兜底)。不在 Consume 里关闭: 投递结果 ≠ 线程已
// 退出, 提前关闭会失去 join 能力, 留下 "ExitProcess 终止恰好持有 CRT 堆锁的尾声
// 线程 → 退出挂死" 的风险窗口。
static HANDLE g_hWorker = NULL;
// Worker 线程入口:纯 CPU 工作,不碰任何 GUI。
// 用 _beginthreadex 启动 (见 ProcessFile_Submit), 故签名是 unsigned __stdcall(void*) ——
// 这样 worker 里用到的 CRT (std::string/unordered_map/swprintf/std::ranges::sort/异常等)
// 的 per-thread 状态能被正确初始化与清理; 裸 CreateThread 跑 CRT 在极端低内存下有终止
// 进程的风险 (见 ProcessFile_Submit 的说明)。
unsigned __stdcall ProcessFile_Worker(void* arg) {
auto* out = (ProcessOutput*)arg;
// 解析输入(WideToUtf8 已经在主线程做完,这里直接用 utf8 视图)
auto stdAgents = ParseCommaSeparatedUtf8FromUtf8(out->utf8_agents);
auto stdWEngines = ParseCommaSeparatedUtf8FromUtf8(out->utf8_wengines);
std::string_view bufferView((const char*)out->viewPtr, out->fileSize);
if (bufferView.size() >= 3 &&
(unsigned char)bufferView[0] == 0xEF &&
(unsigned char)bufferView[1] == 0xBB &&
(unsigned char)bufferView[2] == 0xBF) {
bufferView.remove_prefix(3);
}
// PMR:2MB 单调缓冲池 (monotonic_buffer_resource)。v0.1.3.2 起【改放堆上】(此前在栈上)。
//
// 为什么从栈改到堆 (用 make_unique_for_overwrite, 而不是 std::vector<std::byte>(2MB)):
// - 修正 v0.1.3.1 的一处错误结论: 当时注释说"栈版只有真正写入的 ~600KB 才落到物理页",
// 这不对。固定 2MB 栈帧 >= 1 页时 MSVC 序言会调 __chkstk 逐页探测【整块 2MB】以保证栈
// 能安全扩展 —— 进入 worker 时这 2MB 就被全部触达/提交, 与 PMR 实际用多少无关。
// - 反而堆写法更省: make_unique_for_overwrite 不清零 (区别于 std::vector(2MB) / 带括号的
// new[]() / calloc 那种值初始化), 内存按需触页 —— 只有 PMR 真正写到的 ~600KB 才 fault-in
// 成物理页, 不像栈版被 __chkstk 强行摸满 2MB。
// - 顺带: 不再需要给 worker 配 4MB 栈 (见 ProcessFile_Submit), 线程栈回默认即可,
// 去掉了那处 STACK_SIZE_PARAM_IS_A_RESERVATION 特殊化。
// - (先前感到"堆版更卡"是因为当时用了会清零的写法; 本写法无清零, 不复现该开销。)
// 关于缓存: 别再写"L1/L2 热 / TLB 不 miss"。2MB = 512 页, 远超 L1(~48KB) 和 L1 DTLB(~64 项);
// 能保证的只是减少分配器调用 + 让 temps/bucket 集中在一段连续内存 (利于顺序访问的局部性)。
// 真实命中率要用 PMU 实测。
// 关于 fallback: pool 没显式指定 upstream, 默认 = get_default_resource() (= new_delete_resource)。
// 故【不是】严格只用这 2MB: 超大导入耗尽后会 fallback 到堆而非崩溃 (有意为之, 比抛 bad_alloc
// 退出更实用)。另注 monotonic_buffer_resource 不回收 vector 扩容前的旧块, 直到整个 pool 析构
// —— 一旦 reserve() 预估被大幅突破, arena 占用会比普通 allocator 涨得快。
//
// 生命周期: 声明顺序 arena → pool → alloc, 析构逆序 (alloc/pool 先, arena 后), 故 pool 引用
// 的 arena 内存在 pool 存活期间始终有效; 各 pmr 容器声明在 alloc 之后, 会更早析构。
constexpr size_t kArenaSize = 2 * 1024 * 1024;
auto arena = std::make_unique_for_overwrite<std::byte[]>(kArenaSize); // 堆, 不清零 (C++20)
std::pmr::monotonic_buffer_resource pool(arena.get(), kArenaSize);
std::pmr::polymorphic_allocator<std::byte> alloc(&pool);
struct Temp {
long long id;
GachaType gt;
RankType rt;
std::string_view name;
};
std::pmr::vector<Temp> temps(alloc);
temps.reserve(10000);
// UIGF v4.2 文件可能同时含多个游戏段 (hk4e/hkrpg/nap/...), 每段内层都有
// "list" —— 直接找全文件第一个 "list" 可能命中别的游戏。先定位 "nap" key,
// 在其后子串内找 "list" (nap[0] 结构: { uid, timezone, lang, list }, info 块
// 没有 list, 所以 nap 子串内第一个 "list" 即正确记录数组); 找不到 "nap" 才
// 回退全文件搜索 (兼容只含 ZZZ 数据的宽松第三方文件)。
std::string_view napScope = bufferView;
{
size_t napPos = FindJsonKey(bufferView, "nap");
if (napPos != std::string_view::npos) napScope = bufferView.substr(napPos);
}
ForEachJsonObject(napScope, "list", [&](std::string_view itemStr) {
// UIGF v4.2 nap 字段:
// - "gacha_type" 数字字符串 ("1"/"2"/"3"/"5")
// - "rank_type" "2"/"3"/"4" (B/A/S)
// - "name" 物品名
RankType rt = ParseRankType (ExtractJsonValue(itemStr, "rank_type", true));
GachaType gt = ParseGachaType(ExtractJsonValue(itemStr, "gacha_type", true));
// 分析对象: 独家("2") + 音擎("3") + 常驻("1"); 邦布("5") 跳过。
// 注: UP 池里也会出 A 级音擎/邦布, 这些非 S 级抽数照常推进保底进度,
// 按 gacha_type 分桶 (而非 item_type), 语义才正确。
if (gt != GachaType::AgentUP && gt != GachaType::WEngineUP &&
gt != GachaType::Standard) return;
std::string_view name = ExtractJsonValue(itemStr, "name", true);
std::string_view idStr = ExtractJsonValue(itemStr, "id", true);
if (idStr.empty()) idStr = ExtractJsonValue(itemStr, "id", false);
long long parsed_id = 0;
if (!idStr.empty()) {
std::from_chars(idStr.data(), idStr.data() + idStr.size(), parsed_id);
}
temps.push_back(Temp{parsed_id, gt, rt, name});
});
if (temps.empty()) {
out->ok = false;
out->errMsg = L"JSON 解析失败或无数据 (未找到 nap.list 记录)。";
// 防御分支: WM_DESTROY 会先 join 本线程再销毁窗口, "关窗导致 HWND 失效"
// 已不会发生; PostMessageW 仍可能因极端情况失败 (如线程消息队列满 10000 条)。
// 失败则没人消费 out → worker 自己清理, 否则泄漏 ProcessOutput + mmap 句柄,
// 且 g_processing 卡在 1。out 在 Submit 里已 release 给 worker, 此处归 worker 所有。
if (!PostMessageW(out->hwnd_main, WM_APP_PROCESS_DONE, 0, (LPARAM)out)) {
delete out;
InterlockedExchange(&g_processing, 0);
}
return 0;
}
// 排序: ZZZ 的 id 是 19 位全局递增正整数, 单关键字升序 = 时间升序
// (不需要终末地的"武器取负分区 + |id|"三级排序)
auto less = [](const Temp& a, const Temp& b) { return a.id < b.id; };
bool sorted = true;
for (size_t i = 1; i < temps.size(); ++i) {
if (less(temps[i], temps[i - 1])) { sorted = false; break; }
}
if (!sorted) std::ranges::sort(temps, less);
// 分桶: 按 gacha_type 分到 独家 / 音擎 / 常驻 三个桶
PullBucket bucketAgent (alloc); bucketAgent.reserve(6000);
PullBucket bucketWEngine (alloc); bucketWEngine.reserve(4000);
PullBucket bucketStandard(alloc); bucketStandard.reserve(4000);
for (const auto& t : temps) {
if (t.gt == GachaType::AgentUP) bucketAgent .push_back(t.rt, t.name);
else if (t.gt == GachaType::WEngineUP) bucketWEngine.push_back(t.rt, t.name);
else bucketStandard.push_back(t.rt, t.name);
}
out->statsAgent = Calculate(bucketAgent, PoolKind::Agent, stdAgents);
out->statsWEngine = Calculate(bucketWEngine, PoolKind::WEngine, stdWEngines);
out->statsStandard = Calculate(bucketStandard, PoolKind::Standard, stdAgents);
// 在 worker 渲染输出文本(swprintf 是 thread-safe;只有 SetWindowTextW 不是)
wchar_t winAgentStr[64] = L"[无数据]";
if (out->statsAgent.avg_win >= 0)
swprintf(winAgentStr, 64, L"%.2f 抽", out->statsAgent.avg_win);
wchar_t winWEngineStr[64] = L"[无数据]";
if (out->statsWEngine.avg_win >= 0)
swprintf(winWEngineStr, 64, L"%.2f 抽", out->statsWEngine.avg_win);
wchar_t pendAgentStr[96] = L"";
if (out->statsAgent.censored_pity_all > 0 || out->statsAgent.censored_pity_up > 0) {
swprintf(pendAgentStr, 96, L" [当前水位: 距上次S级 %d 抽 / 距上次 UP %d 抽]",
out->statsAgent.censored_pity_all, out->statsAgent.censored_pity_up);
}
wchar_t pendWEngineStr[96] = L"";
if (out->statsWEngine.censored_pity_all > 0 || out->statsWEngine.censored_pity_up > 0) {
swprintf(pendWEngineStr, 96, L" [当前水位: 距上次S级 %d 抽 / 距上次 UP %d 抽]",
out->statsWEngine.censored_pity_all, out->statsWEngine.censored_pity_up);
}
wchar_t pendStandardStr[96] = L"";
if (out->statsStandard.censored_pity_all > 0) {
swprintf(pendStandardStr, 96, L" [当前水位: 距上次S级 %d 抽]",
out->statsStandard.censored_pity_all);
}
auto ksLabel = [](const StatsResult& r) -> const wchar_t* {
if (r.count_all == 0) return L"-";
return r.ks_is_normal ? L"符合理论模型" : L"偏离过大";
};
auto ksUpLabel = [](const StatsResult& r) -> const wchar_t* {
if (r.count_up == 0) return L"-";
return r.ks_is_normal_up ? L"符合理论模型" : L"偏离过大";
};
const wchar_t* ksAgentLabel = ksLabel (out->statsAgent);
const wchar_t* ksWEngineLabel = ksLabel (out->statsWEngine);
const wchar_t* ksStandardLabel = ksLabel (out->statsStandard);
const wchar_t* ksAgentUpLabel = ksUpLabel(out->statsAgent);
const wchar_t* ksWEngineUpLabel = ksUpLabel(out->statsWEngine);
// 4096 字符缓冲: 三段池块 (独家/音擎各 4 行 + 常驻 3 行) 约 2200 字符,
// 留足余量避免 swprintf 截断 (截断会让 SetWindowTextW 显示残缺尾巴)。
wchar_t outMsg[4096];
swprintf(outMsg, 4096,
L"【独家频段 (代理人 UP 池)】 总计 S 级: %d | 出当期 UP: %d%ls\r\n"
L" ▶ 综合 S 级 (含歪) 出货平均期望: %5.2f 抽 (理论 ≈ 62.30) [95%% CI: %5.1f ~ %5.1f] | 波动率 (CV): %5.1f%%\t[K-S 检验偏离度 D值: %.3f (%ls)]\r\n"
L" ▶ 抽到当期限定 UP 的综合平均期望: %5.2f 抽 (理论 ≈ 93.45) [95%% CI: %5.1f ~ %5.1f] | 真实不歪率: %5.1f%% (理论 50%%) (%d胜%d负)\t[K-S 检验偏离度 D值: %.3f (%ls)]\r\n"
L" ▶ 赢下小保底 (不歪) 的出货期望: %ls\r\n\r\n"
L"【音擎频段 (武器 UP 池)】 总计 S 级: %d | 出当期 UP: %d%ls\r\n"
L" ▶ 综合 S 级 (含歪) 出货平均期望: %5.2f 抽 (理论 ≈ 49.71) [95%% CI: %5.1f ~ %5.1f] | 波动率 (CV): %5.1f%%\t[K-S 检验偏离度 D值: %.3f (%ls)]\r\n"
L" ▶ 抽到当期限定 UP 的综合平均期望: %5.2f 抽 (理论 ≈ 62.13) [95%% CI: %5.1f ~ %5.1f] | 真实不歪率: %5.1f%% (理论 75%%) (%d胜%d负)\t[K-S 检验偏离度 D值: %.3f (%ls)]\r\n"
L" ▶ 赢下小保底 (不歪) 的出货期望: %ls\r\n\r\n"
L"【常驻频段 (热门卡司)】 总计 S 级: %d%ls\r\n"
L" ▶ 综合 S 级出货平均期望: %5.2f 抽 (理论 ≈ 62.30) [95%% CI: %5.1f ~ %5.1f] | 波动率 (CV): %5.1f%%\t[K-S 检验偏离度 D值: %.3f (%ls)]\r\n"
L" ▶ (常驻频段无 UP 概念; 新人保底/300抽自选不在抽卡记录内, 早期样本可能轻微偏离理论)",
out->statsAgent.count_all, out->statsAgent.count_up, pendAgentStr,
out->statsAgent.avg_all, (std::max)(1.0, out->statsAgent.avg_all - out->statsAgent.ci_all_err),
out->statsAgent.avg_all + out->statsAgent.ci_all_err, out->statsAgent.cv_all * 100.0,
out->statsAgent.ks_d_all, ksAgentLabel,
out->statsAgent.avg_up, (std::max)(1.0, out->statsAgent.avg_up - out->statsAgent.ci_up_err),
out->statsAgent.avg_up + out->statsAgent.ci_up_err,
out->statsAgent.win_rate_5050 >= 0 ? out->statsAgent.win_rate_5050 * 100.0 : 0.0,
out->statsAgent.win_5050, out->statsAgent.lose_5050,
out->statsAgent.ks_d_up, ksAgentUpLabel,
winAgentStr,
out->statsWEngine.count_all, out->statsWEngine.count_up, pendWEngineStr,
out->statsWEngine.avg_all, (std::max)(1.0, out->statsWEngine.avg_all - out->statsWEngine.ci_all_err),
out->statsWEngine.avg_all + out->statsWEngine.ci_all_err, out->statsWEngine.cv_all * 100.0,
out->statsWEngine.ks_d_all, ksWEngineLabel,
out->statsWEngine.avg_up, (std::max)(1.0, out->statsWEngine.avg_up - out->statsWEngine.ci_up_err),
out->statsWEngine.avg_up + out->statsWEngine.ci_up_err,
out->statsWEngine.win_rate_5050 >= 0 ? out->statsWEngine.win_rate_5050 * 100.0 : 0.0,
out->statsWEngine.win_5050, out->statsWEngine.lose_5050,
out->statsWEngine.ks_d_up, ksWEngineUpLabel,
winWEngineStr,
out->statsStandard.count_all, pendStandardStr,
out->statsStandard.avg_all, (std::max)(1.0, out->statsStandard.avg_all - out->statsStandard.ci_all_err),
out->statsStandard.avg_all + out->statsStandard.ci_all_err, out->statsStandard.cv_all * 100.0,
out->statsStandard.ks_d_all, ksStandardLabel
);
out->outMsg = outMsg;
out->ok = true; out->outMsg = outMsg;
out->ok = true;
// 同上的防御分支 (消息队列满等极端失败)。窗口关闭路径已由 WM_DESTROY 收口:
// 先 join 本线程 (彼时 hwnd 仍有效, 本行 PostMessageW 必然成功入队), 再 reap 队列里
// 这条永远不会被派发的结果消息并释放载荷 —— 见 WndProc 的 WM_DESTROY。
// (v0.1.3.3 修正: 旧注释称"由 ExitProcess 统一回收, 无实际危害, 不加 join" —— 结论
// 不成立: ExitProcess 会直接终止其它线程, worker 若恰好在 CRT 堆锁内被终止, 退出
// 流程可能挂死。现已改为退出前必 join, 该风险窗口不复存在。)
if (!PostMessageW(out->hwnd_main, WM_APP_PROCESS_DONE, 0, (LPARAM)out)) {
delete out;
InterlockedExchange(&g_processing, 0);
}
return 0;
}
// 主线程入口:做 I/O 准备 + 启动 worker。
// 返回 false 表示提交失败(应立即清理),true 表示 worker 已启动(WM_APP_PROCESS_DONE
// 会在完成时投递)。
bool ProcessFile_Submit(HWND hwnd, const std::wstring& path) {
// 双开保护:用 InterlockedCompareExchange 原子地把 0->1
if (InterlockedCompareExchange(&g_processing, 1, 0) != 0) {
return false; // 已有 worker 在跑,忽略本次拖入
}
auto out = std::make_unique<ProcessOutput>();
out->hwnd_main = hwnd;
// 主线程读 GUI 控件文本(子控件的 GetWindowTextW 不允许从 worker 调)
out->utf8_agents = WideToUtf8(GetDynamicWindowText(hAgentEdit));
out->utf8_wengines = WideToUtf8(GetDynamicWindowText(hWEngineEdit));
// 主线程做文件 mmap,所有权直接交给 ProcessOutput(零拷贝)。
// mmap view 在 worker 持有期间一直有效,Consume 阶段 ProcessOutput 析构统一 unmap。