-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAngiris.cpp
More file actions
3964 lines (3620 loc) · 192 KB
/
Copy pathAngiris.cpp
File metadata and controls
3964 lines (3620 loc) · 192 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
// ═══════════════════════════════════════════════════════════════════════
// Angiris — D2R Mod Launcher
// ═══════════════════════════════════════════════════════════════════════
//
// A standalone launcher for Diablo II: Resurrected mods.
// Design reference: Angiris_Design_Specification.txt
//
// All UI is custom-painted via GDI+. Win32 children are used only for
// the EDIT/BUTTON controls that need keyboard/clipboard support.
//
// ═══════════════════════════════════════════════════════════════════════
//
// NAMING CONVENTIONS (Win32 + GDI+ idiom)
// ────────────────────────────────────────────────────────────────────
// These short names appear constantly throughout the file. They follow
// the conventions you'll see in MSDN documentation and most Win32 code,
// so once you know them they read naturally.
//
// ── Win32 message-procedure parameters ──
// hw, hwnd Window handle (HWND). The "h" stands for "handle".
// msg Message ID (UINT) — e.g. WM_PAINT, WM_COMMAND.
// wp, wParam WPARAM — message-specific data, usually a flag/ID.
// lp, lParam LPARAM — message-specific data, usually a pointer
// or packed coords (use LOWORD/HIWORD or
// GET_X_LPARAM/GET_Y_LPARAM to unpack).
//
// ── Drawing / paint primitives ──
// hdc Device Context handle (HDC) — the "canvas" you draw
// onto. Returned by BeginPaint, GetDC, etc.
// ps PAINTSTRUCT — info about the current WM_PAINT call
// (ps.rcPaint is the dirty rectangle to repaint).
// rc RECT — a {left, top, right, bottom} rectangle.
// g GDI+ Graphics object — wraps an HDC and provides
// the modern drawing API (antialiasing, gradients).
// sf StringFormat — text alignment / wrapping options.
//
// ── Type-prefix conventions ──
// g_ Global variable. Anything mutable that lives
// outside a function uses this prefix.
// g_hw… Global HWND. e.g. g_hwMain, g_hwHero.
// g_ff… Global FontFamily pointer. e.g. g_ffCinzel.
// g_f… Global Font pointer. e.g. g_fHeroName.
// IDC_… Control ID constant (sent in WM_COMMAND).
// WM_… Standard Windows messages (defined by Windows).
// MSG_… Our custom messages, all WM_USER + N values.
// IDT_… Timer IDs we pass to SetTimer.
// LO:: Layout constants (window dimensions, panel sizes).
// Tok:: Design tokens (colors, mainly).
// OP / ML / MD… Per-section namespaces (Options panel, Mod List,
// Modding column) — both for constants and helpers.
//
// ── State pointers in window procedures ──
// st Pointer to that window's state struct. Each custom
// window (Options panel, Mod List, etc.) has its own
// state map keyed by HWND.
//
// ── Misc shorthand ──
// W, H Width / height of the current paint region.
// cx, cy Center coordinates of something (e.g. a button).
// REAL GDI+ float type (alias for float).
// GP(r,g,b) Make an opaque GDI+ Color.
// GPA(a,r,g,b) Make a GDI+ Color with explicit alpha.
//
// ═══════════════════════════════════════════════════════════════════════
// Common Win32/GDI+/std includes live in angiris_common.h so every
// translation unit sees the same surface. Module headers follow.
#include "angiris_common.h"
#include "core.h"
#include "version.h"
#include "ini_editor.h"
#include "http.h"
#include "config.h"
#include "update_cache.h"
#include "playtime.h"
#include "seeds.h"
#include "mod_types.h"
#include "mod_scan.h"
#include "launch_flags.h"
#include "mod_config.h"
#include "tool_resolver.h"
#include "fs_utils.h"
#include "mod_updates.h"
#include "save_backup.h"
#include "zip_install.h"
#include "launcher_self_update.h"
#include "assets.h"
#include "fonts.h"
#include "layout.h"
#include "scaling.h"
#include "colors.h"
#include "hover_tip.h"
#include "mod_list.h"
#include "plugin_manager.h"
#include "control_ids.h"
#include "dialogs.h"
#include "paint_helpers.h"
#include "paint_main.h"
#include "ui_state.h"
#pragma comment(lib, "gdiplus.lib")
#pragma comment(lib, "comctl32.lib")
#pragma comment(lib, "shell32.lib")
#pragma comment(lib, "comdlg32.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "winhttp.lib")
// ═══════════════════════════════════════════════════════════════════════
// DESIGN TOKENS
// All colors and spacing live here — extend this block, never inline.
// ═══════════════════════════════════════════════════════════════════════
// (extracted to module — was GP/GPA color helpers)
// (extracted to module — was Tok:: + Sp:: namespaces)
// ═══════════════════════════════════════════════════════════════════════
// DPI / USER-SCALE PLUMBING
// ═══════════════════════════════════════════════════════════════════════
//
// Two scale factors compose into a single multiplier that converts logical
// pixels (what every LO:: constant and Layout coordinate is written in) to
// physical pixels (what Win32 and the framebuffer actually use):
//
// g_userScale from launcher_config.json. Default 0.85; "small" = 0.70.
// g_dpiScale system DPI / 96.0 (e.g. 4K @ 150% Windows scaling = 1.5).
// g_scale = g_userScale * g_dpiScale.
//
// logical → physical via S(int) / SF(REAL)
// physical → logical via U(int)
//
// Mouse input arrives in physical pixels and must be unscaled before being
// tested against logical rects. Paint code stays in logical pixels: WM_PAINT
// can apply a single g.ScaleTransform at the start of the paint and every
// DrawString / DrawImage / FillRectangle inherits the scale automatically.
// This stage only adds the plumbing — nothing reads g_scale yet, so the
// visible UI is unchanged until later stages wire CreateWindow, fonts, and
// Layout() through S() / SF().
// (extracted to module — was g_userScale + g_scale globals)
// Logical → physical.
// (extracted to module — was S/SF/U inline functions)
// SetWindowPos that takes LOGICAL coords/sizes and applies S() at the
// Win32 boundary. Use this everywhere in Layout() so the layout math
// stays readable (one unit system: logical pixels) and the scaling is
// applied uniformly. Mouse handlers and Layout never have to know
// about g_scale individually.
// SPosL extracted to scaling.h so layout.cpp + other TUs can use it.
// (extracted to module — was InvalidateRectL + GetClientRectL helpers)
// Per-monitor V2 DPI awareness if available, falling back to per-monitor V1,
// then system-aware. Must be called before the first window is created.
void InitDpiAwareness() {
HMODULE hUser32 = GetModuleHandleW(L"user32.dll");
if (hUser32) {
// Win10 v1703+: per-monitor V2 (best non-client scaling)
typedef BOOL (WINAPI *PFN_SetProcessDpiAwarenessContext)(HANDLE);
auto pSetCtx = (PFN_SetProcessDpiAwarenessContext)GetProcAddress(
hUser32, "SetProcessDpiAwarenessContext");
if (pSetCtx) {
// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = (HANDLE)-4
if (pSetCtx((HANDLE)-4)) return;
}
}
// Win8.1+ fallback: per-monitor V1
HMODULE hShcore = LoadLibraryW(L"Shcore.dll");
if (hShcore) {
typedef HRESULT (WINAPI *PFN_SetProcessDpiAwareness)(int);
auto pSetAware = (PFN_SetProcessDpiAwareness)GetProcAddress(
hShcore, "SetProcessDpiAwareness");
if (pSetAware) {
// PROCESS_PER_MONITOR_DPI_AWARE = 2
HRESULT hr = pSetAware(2);
FreeLibrary(hShcore);
if (SUCCEEDED(hr)) return;
} else {
FreeLibrary(hShcore);
}
}
// Vista+ fallback: system-DPI-aware
SetProcessDPIAware();
}
// Returns DPI / 96.0 for the primary monitor (e.g. 1.0 at 100%, 1.5 at 150%).
// Uses GetDpiForSystem on Win10+ and falls back to GetDeviceCaps.
double QuerySystemDpiScale() {
HMODULE hUser32 = GetModuleHandleW(L"user32.dll");
if (hUser32) {
typedef UINT (WINAPI *PFN_GetDpiForSystem)();
auto pGetDpi = (PFN_GetDpiForSystem)GetProcAddress(
hUser32, "GetDpiForSystem");
if (pGetDpi) {
UINT dpi = pGetDpi();
if (dpi > 0) return (double)dpi / 96.0;
}
}
HDC hdc = GetDC(nullptr);
if (hdc) {
int dpi = GetDeviceCaps(hdc, LOGPIXELSX);
ReleaseDC(nullptr, hdc);
if (dpi > 0) return (double)dpi / 96.0;
}
return 1.0;
}
// (TBL namespace extracted to layout.h)
// ═══════════════════════════════════════════════════════════════════════
// CONTROL IDs
// ═══════════════════════════════════════════════════════════════════════
//
// Extracted to control_ids.h so modules other than Angiris.cpp
// (mod_list, plugin_manager, future dialog modules) can see the same
// numeric values without redeclaring them. Header-only.
// (control IDs enum + OPT_NOTIFY_CHANGED extracted to control_ids.h)
// ═══════════════════════════════════════════════════════════════════════
// DATA TYPES
// ═══════════════════════════════════════════════════════════════════════
// ModInfo struct now lives in mod_types.h.
// Per-mod cached result of the latest update check. Persisted to
// <appdir>\assets\update_cache.json keyed by mod folder name.
// UpdateInfo struct now lives in update_cache.h. See update_cache.h/cpp.
// LaunchBehavior enum and LauncherCfg struct now live in config.h.
// See config.h/config.cpp.
// ── Launcher self-update wiring ────────────────────────────────────────
// The launcher checks the GitHub repository for a newer tagged release
// at startup. When a newer release is found and the user hasn't
// "skipped" that exact tag, a themed dialog offers Update / Skip /
// Ignore. The Update path downloads the release zip, renames the
// running .exe to .old, extracts the new files in place, and spawns
// the new process — see the long comment near LauncherUpdateInstallWorker.
// (extracted to module — was LAUNCHER_VERSION constant)
// (extracted to module — was LAUNCHER_GITHUB_OWNER/REPO)
// (extracted to module — was launcher self-update state globals)
// ── Version label hit-rect (paint state, NOT launcher-update state) ─────
// Filled by PaintBody when it lays out the version label under the
// logo; read by MainProc's WM_LBUTTONDOWN / WM_SETCURSOR to hit-test
// the clickable area. Stays in Angiris.cpp because it's a paint
// concern, not part of the self-update module's state. Was
// accidentally swept up in Phase 3b's anchor-pair removal of the
// launcher_self_update globals — restored here.
RECT g_versionLabelRect = {0, 0, 0, 0};
// ModSettings struct and g_modSettings now live in launch_flags.h/cpp.
// Update-check cache, keyed by mod folder. Persisted to
// <appdir>\assets\update_cache.json. Refetches happen at startup and
// on Refresh Mod List click; within the TTL we serve cached results.
// g_updateInfo now lives in update_cache.cpp. See update_cache.h.
// Per-mod playtime tracking. Persisted to <appdir>\assets\playtime.json
// (centralized, not per-mod) so that re-installing a mod doesn't wipe
// its history — the cache survives any change to the mod's own files.
// `seconds` is the running total of D2R-up-and-running time across all
// launches; `lastPlayed` is the unix epoch of the most recent exit.
// Display surface: the hover tooltip on each mod row.
// PlaytimeRec struct and g_playtimes now live in playtime.h/cpp.
// Tracking state for the current launch. Snapshot at click time so the
// poll handler can attribute play time correctly even if the user
// switches the selected mod mid-game. g_d2rGameStartTick is the
// GetTickCount() value captured the first poll where D2R was seen
// running — that's the proper "game actually playing" start (vs. the
// click tick, which includes loader/spawn overhead).
DWORD g_d2rGameStartTick = 0;
wstring g_d2rGameModFolder;
// (extracted to module — was UPDATE_* constants + MSG_UPDATE_CHECK_DONE)
// ── Drag-and-drop zip install (V1.1) ─────────────────────────────────────
// User drops one or more .zip files onto the launcher window. A worker
// thread extracts each (via Windows-bundled tar.exe), locates modinfo.json
// inside, derives the mod folder name, and installs into <D2R>\mods\.
// On collision a themed modal dialog asks the user how to resolve.
//
// MSG_ZIP_CONFLICT_DIALOG: worker→main, SendMessage (blocks worker). LPARAM
// points to a ConflictDialogParam; main thread shows the modal dialog and
// writes the user's choice back to param->choice before returning. The
// modal MUST run on the main UI thread (Win32 modals + window-class
// restrictions), hence the SendMessage round-trip.
// MSG_ZIP_QUEUE_DONE: worker→main, PostMessage. Triggers a final
// RefreshMods + repaint after all queued zips have been processed.
// (extracted to module — was MSG_ZIP_* and MSG_LAUNCHER/LUPOPUP constants)
// (extracted to module — was ConflictDialogParam + ProgressUpdate)
// D2R process tracking for the post-launch Minimize/Close behaviors.
// The process handle is kept alive for polling when minimize is chosen.
// ── D2R process tracking ─────────────────────────────────────────────────
// We don't track D2RLoader's handle — it's a shim that injects mod hooks
// into D2R.exe and then exits, often before D2R has finished loading.
// Polling the loader's handle would make us think D2R exited when only
// the loader did, and the launcher would un-minimize back on top of a
// still-loading game. Instead we poll the process table by name; the
// launcher restores only when D2R.exe itself disappears (or never shows
// up at all, after a fail-safe timeout).
//
// State machine:
// g_d2rTracking == false → not tracking, no timer running
// true + g_d2rEverSeen == false → launch sent, waiting for D2R.exe
// to appear in the process table
// true + g_d2rEverSeen == true → D2R.exe is running; waiting for it
// to exit
//
// g_d2rLaunchTick anchors a 60 s fail-safe — if D2R.exe never shows up
// (loader crashed, wrong path, etc.) we give up and restore the launcher
// rather than polling forever.
bool g_d2rTracking = false;
bool g_d2rEverSeen = false;
DWORD g_d2rLaunchTick = 0;
// True between the post-launch SetTimer and the FIRST IDT_D2R_POLL fire.
// While set, the timer is on its "wait 10 s before first poll" interval;
// the first fire resets the timer to the normal 1 s cadence.
bool g_pollFirstShot = false;
// Returns true if any process whose image name matches one of `names`
// (case-insensitive) is present in the current process snapshot. The
// snapshot is built once and walked once regardless of how many names
// we're checking, which keeps the polling cost flat.
bool AnyProcessExistsByName(const wchar_t* const* names, size_t count) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return false;
PROCESSENTRY32W pe = { sizeof(pe) };
bool found = false;
if (Process32FirstW(snap, &pe)) {
do {
for (size_t i = 0; i < count; ++i) {
if (_wcsicmp(pe.szExeFile, names[i]) == 0) {
found = true;
break;
}
}
if (found) break;
} while (Process32NextW(snap, &pe));
}
CloseHandle(snap);
return found;
}
// The two process names we treat as "the game is alive". D2RLoader is
// the bootstrap shim that may exit shortly after spawning D2R.exe — but
// some setups keep it alive throughout play, and some leave it active
// briefly between the loader doing its work and D2R.exe being visible.
// Counting either name as "running" handles all of those.
const wchar_t* const k_d2rProcessNames[] = {
L"D2R.exe",
L"D2RLoader.exe",
};
const size_t k_d2rProcessNameCount =
sizeof(k_d2rProcessNames) / sizeof(k_d2rProcessNames[0]);
constexpr UINT IDT_D2R_POLL = 9101; // 1s timer fired on the main HWND
// (extracted to module — was IDT_HOVER_TIP constant) // 2s timer on the mod list HWND;
// fires the playtime tooltip
// if the cursor's been still
// over a row long enough.
// (extracted to module — was IDT_CLEANUP_OLD_EXE)
// (extracted to module — was .old cleanup state globals)
// Discord Rich Presence integration is held back pending the Discord
// application review process — the IPC code lives in a separate module
// (discord_rpc.cpp / discord_rpc.h) which is currently not included or
// built. See discord_rpc.h for re-enable instructions when the time
// comes.
// Decoded banner image cache, keyed by file path. Reloaded only when
// the path changes — repeated paints of the same banner are free.
// (Now used by the mod list rows, which render banners as backgrounds.)
namespace Gdiplus { class Bitmap; }
// (extracted to module — was g_bannerCache + g_bannerCacheKey)
// ═══════════════════════════════════════════════════════════════════════
// GLOBALS
// ═══════════════════════════════════════════════════════════════════════
// `g_hInst` is declared extern in core.h so other modules (plugin_manager,
// future dialog modules) can pass it to CreateWindowEx without re-querying.
// The value is set once in wWinMain.
HINSTANCE g_hInst = nullptr;
// g_hwMain now lives in core.cpp (extern via core.h) so background
// threads in mod_scan / mod_updates / etc. can PostMessage to it.
HWND g_hwList = nullptr; // mod list (custom-painted)
HWND g_hwLaunch = nullptr; // PLAY button (in right column)
// Mod Description link buttons (per-mod, shown/hidden based on modinfo.json)
HWND g_hwModDiscord = nullptr;
HWND g_hwModDocs = nullptr;
HWND g_hwModWebsite = nullptr;
// Left rail navigation buttons (open external paths/files)
HWND g_hwNavMods = nullptr;
HWND g_hwNavOptions = nullptr;
HWND g_hwNavLogs = nullptr;
HWND g_hwNavHelp = nullptr;
HWND g_hwNavAbout = nullptr;
HWND g_hwNavExit = nullptr;
// Loader Directory row (read-only path + ... browse button)
RECT g_loaderDirRect = {}; // paint+hit-test rect for the path bar
RECT g_stashDropdownRect = {}; // Stash Tabs row rect (Layout populates)
// g_dmgDropdownRect removed — the Dmg Display dropdown was replaced by
// the Plugins button (g_hwLoaderPlugins) in the Loader Options layout.
// Center-column toolbar dropdowns (sit between the Nexus/Update button
// row and the expand arrow). All three are painted programmatically and
// hit-tested in WM_LBUTTONDOWN. Layout() populates their rects.
RECT g_scaleDropdownRect = {}; // top-row Scale label+value (display only, not clickable)
RECT g_scaleSliderRect = {}; // 3-state toggle slider below Scale — the click target
RECT g_onLaunchHeaderRect= {}; // top tier — "ON LAUNCH" header label
RECT g_onLaunchRect = {}; // middle tier — value-only textbox (Min/Close/Stay)
RECT g_onLaunchSliderRect= {}; // bottom tier — 3-state toggle slider
// Measured rendered width of the "Seed" label (in LOGICAL units, not
// scaled). Recomputed every time CreateGdipFonts runs so a font swap
// via the toolbar Font dropdown immediately reflows the combo. Default
// covers the case where the global is read before the first measure.
int g_seedLabelLogicalW = 44;
RECT g_fontDropdownRect = {};
RECT g_colorDropdownRect = {};
HWND g_hwLoaderDirBtn = nullptr; // "..." button
HWND g_hwLoaderPlugins = nullptr; // Plugins button — opens plugin manager
// Mod list adjacent buttons
HWND g_hwRefresh = nullptr; // top-right "Refresh"
HWND g_hwBrowseMods = nullptr; // bottom-left
HWND g_hwUpdateMod = nullptr; // bottom-right
// Bottom expansion panel
HWND g_hwExpandToggle = nullptr; // arrow button
// `g_bottomExpanded` is exposed via core.h so buttons.cpp's Arrow paint
// can flip the chevron art and paint_main / layout (Phase 7c+) can read
// the same flag without a re-entry into MainProc.
bool g_bottomExpanded = false;
// Custom title-bar button state (rendered as image assets in PaintBody;
// hit-tested in WM_NCHITTEST and WM_LBUTTONDOWN). -1 = no hover, 0 = min,
// 1 = close. Pressed is true while the mouse is held over a button.
static int g_tbHover = -1;
int g_tbPressed = -1;
// Owner-drawn buttons. Every push-button created via MkStdBtn is registered
// here with a "kind" that tells WM_DRAWITEM which asset and state transform
// to use. Until the styled button assets land, every kind falls back to
// the programmatic OPDrawBtnFrame so the launcher looks identical to today.
//
// One asset per kind (no _idle/_hover/_click variants). The grow-on-hover,
// shrink-and-offset-on-click effect is produced by applying a GDI+ transform
// at paint time. Both the asset and its label text scale together so they
// read as a single unified element.
//
// Asset filenames:
// Nav → btn_nav.png (left rail + link buttons)
// NavSm → btn_nav.png (unused — kept for future use; bottom
// expansion panel now uses NexusUpdate)
// Refresh → btn_refresh.png (text baked into art)
// NexusUpdate → btn_nexus_update.png (Nexus + Update Selected; shared)
// Play → btn_play.png (large)
// Ellipse → btn_ellipse.png ("..." button; glyph baked in)
// Arrow → btn_expand_arrow.png (chevron; rotated 180° when expanded)
// (ButtonKind enum extracted to buttons.h)
#include "buttons.h"
// (extracted to module — was ButtonStateTransform + StateTransformFor + AssetNameFor)
// (extracted to module — was AssetNameFor body)
// (extracted to module — was ButtonState struct + g_btnStates)
// (extracted to module — was BtnHoverSubclass)
// (extracted to module — was RegisterButton)
HWND g_hwBottomTools[6] = {}; // 6 tool launchers
HWND g_hwBottomRefs[3] = {}; // 3 references
HWND g_hwBottomDls[3] = {}; // 3 download links
static bool g_modsDirty = false; // watcher saw changes; manual refresh pending
static ULONG_PTR g_gdipToken = 0;
// g_cfg now lives in config.cpp. See config.h.
// g_mods and g_selMod now live in mod_scan.cpp. See mod_scan.h.
// Bundled fonts — loaded from assets/fonts/ at startup with FR_PRIVATE
// so they're visible to GDI+ but not added to the system font list.
// (extracted to module — was g_loadedFonts)
// Persistent PrivateFontCollection holding every bundled .ttf for the
// app's lifetime. Originally LoadFonts used a throwaway local PFC per
// file just to extract family names, relying on AddFontResourceEx
// (FR_PRIVATE) to make later FontFamily(name) lookups resolve. That
// silently failed for the user-font override path on at least some
// systems — FontFamily(name)->GetLastStatus came back non-Ok, the
// override stayed null, and CreateGdipFonts fell back to Exocet for
// every user pick. Keeping the PFC alive lets us pass &g_pfc as the
// FontCollection arg to FontFamily, which guarantees the lookup
// resolves the font we just added.
// (extracted to module — was g_pfc + g_ff* + g_userFont*)
// Cached GDI+ Font instances at the design sizes. Exocet (D2 menu font)
// carries the launcher's identity; Georgia is used where dense legibility
// matters (cmd preview, mod description body, hero meta italics).
// (extracted to module — was g_f* font globals) // Georgia, 11px
// ═══════════════════════════════════════════════════════════════════════
// UTILITIES (carried from D2R_ModLauncher.cpp — proven working)
// ═══════════════════════════════════════════════════════════════════════
// AppDir() now lives in core.cpp. See core.h.
// ═══════════════════════════════════════════════════════════════════════
// ASSET CACHE
// ═══════════════════════════════════════════════════════════════════════
//
// Lazy-loaded GDI+ Bitmap cache for image assets in <exe-dir>\assets\images\.
// First call to AssetImage(L"name.png") loads + caches; later calls are
// just a map lookup. Missing or unreadable assets return nullptr; callers
// are responsible for handling that case (typically by falling back to a
// programmatic paint). The cache is freed in DestroyAssetCache() at exit.
//
// Cached images are owned by the cache (do not delete the returned ptr).
// (extracted to module — was Asset cache + DrawAssetAt/Stretched/9Slice)
// (DrawAssetAt + DrawButton9Slice extracted to assets.cpp — leftover removed)
// Measures the safe-interior inset of a frame asset by scanning each edge
// inward until it finds a row/column whose opacity drops below the "edge
// filigree" density. The result tells layout how far inset from the window
// edges the content area starts.
//
// Cached per-asset by name. Once computed it's reused for the life of the
// process (no need to re-scan a 1536×1024 bitmap on every paint).
// (extracted to module — was FrameInset + MeasureFrameInset)
// Measures the three internal region boundaries of frame_panel_right.png
// (the right-column panel asset). The asset has horizontal dividers at
// specific Y coordinates that split it into MOD DESCRIPTION (top),
// LAUNCH OPTIONS (middle), and PLAY (bottom) regions. We detect dividers
// by scanning row density; rows that are mostly-opaque across the full
// width are dividers.
//
// All values are in the asset's native pixel space (not stretched), since
// the asset itself is drawn at 1:1 in the launcher.
// (extracted to module — was PanelRegions + MeasurePanelRegions)
// Mod-link resolver. modinfo.json's documents/website/discord fields
// can hold a URL, an absolute path, or a path relative to the mod folder.
// This normalizes whichever was given into something ShellExecute can open.
static wstring ResolveModLink(const wstring& field, const wstring& modDir) {
if (field.empty()) return field;
// URL scheme detection: anything with "://" near the start, or a
// bare "mailto:". Generous — modders may use schemes we don't list.
size_t colon = field.find(L':');
if (colon != wstring::npos && colon < 12) {
// "http:" "https:" "ftp:" "file:" "mailto:" "steam:" etc.
return field;
}
// Absolute Windows path: "C:\..." or "\\server\share"
if (field.size() >= 2 && field[1] == L':') return field;
if (field.size() >= 2 && field[0] == L'\\' && field[1] == L'\\') return field;
// Otherwise: relative to the mod's folder. Strip any leading "./"
wstring rel = field;
if (rel.size() >= 2 && rel[0] == L'.' && (rel[1] == L'/' || rel[1] == L'\\')) {
rel = rel.substr(2);
}
// Normalize forward slashes to Windows backslashes
for (auto& c : rel) if (c == L'/') c = L'\\';
return modDir + L"\\" + rel;
}
// ReadTextFile, WriteTextFile, EscapeJson, JsonStr, JsonBool, JsonInt,
// JsonDouble now live in core.cpp. See core.h.
// ═══════════════════════════════════════════════════════════════════════
// INI LINE-EDITOR
// ═══════════════════════════════════════════════════════════════════════
//
// D2RLoader.ini contains user-managed config that we should NOT trash
// when we touch one of its values. These helpers do line-by-line in-place
// edits: read preserves the full file as-is, write replaces ONLY the
// line(s) for the key(s) we care about while keeping every comment,
// blank line, and unknown key untouched.
// TrimWs, ParseIniLine, IniGetInt, IniSetInt now live in ini_editor.cpp.
// See ini_editor.h.
// ═══════════════════════════════════════════════════════════════════════
// CONFIG I/O
// ═══════════════════════════════════════════════════════════════════════
// CfgPath, LoadCfg, SaveCfg now live in config.cpp. See config.h.
// ══════════════════════════════════════════════════════════════════════
// D2RLOADER.INI INTEGRATION
// ══════════════════════════════════════════════════════════════════════
//
// We expose two D2RLoader.ini settings via dropdowns in a "LOADER OPTIONS"
// section at the bottom of the Modding column:
// [Stash] extra_shared_tabs (0..16)
// [Advanced.Logging] damage_indicator (0..2)
//
// State lives in g_loaderOpts (mirror of what's on disk). LoadLoaderOpts
// reads from D2RLoader.ini at startup; the dropdowns write through
// SaveLoaderOpts which does an in-place line edit (preserves the rest
// of the file). These settings are global (not per-mod) because the
// loader's INI is shared across all mods.
// (LoaderOpts struct moved to ui_state.h)
LoaderOpts g_loaderOpts;
static wstring LoaderIniPath() {
return g_cfg.d2rPath + L"\\D2RLoader.ini";
}
static void LoadLoaderOpts() {
wstring p = LoaderIniPath();
g_loaderOpts.extraSharedTabs =
IniGetInt(p, L"Stash", L"extra_shared_tabs", 0);
g_loaderOpts.damageIndicator =
IniGetInt(p, L"Advanced.Logging", L"damage_indicator", 2);
// Clamp to sane ranges in case the file holds something weird
if (g_loaderOpts.extraSharedTabs < 0) g_loaderOpts.extraSharedTabs = 0;
if (g_loaderOpts.extraSharedTabs > 16) g_loaderOpts.extraSharedTabs = 16;
if (g_loaderOpts.damageIndicator < 0) g_loaderOpts.damageIndicator = 0;
if (g_loaderOpts.damageIndicator > 2) g_loaderOpts.damageIndicator = 2;
}
static void SaveLoaderOptStashTabs(int v) {
IniSetInt(LoaderIniPath(), L"Stash", L"extra_shared_tabs", v);
}
// SaveLoaderOptDamageIndicator removed — the Dmg Display UI was
// replaced by the Plugins button, so there's no longer a UI path
// that writes [Advanced.Logging] damage_indicator. The value is
// still read in LoadLoaderOpts so any pre-existing setting from
// a prior launcher version is preserved silently on disk.
// ══════════════════════════════════════════════════════════════════════
// MOD UPDATE CHECKER
// ══════════════════════════════════════════════════════════════════════
//
// Opt-in: a mod participates by adding "update_github" or
// "update_manifest" to its modinfo.json. The launcher fetches a small
// JSON document, compares versions, and exposes the result via
// g_updateInfo[modFolder]. UI elements consult that map.
//
// Checks happen on launcher startup AND when the user clicks "Refresh
// Mod List". Within UPDATE_CACHE_TTL_SECONDS we serve cached results;
// the refresh click bypasses the TTL for a force-refetch.
//
// Network I/O runs in background threads (one per mod, capped via a
// simple atomic counter). When a fetch completes, the thread posts
// MSG_UPDATE_CHECK_DONE to g_hwMain which triggers a UI repaint.
// UpdateCachePath, LoadUpdateCache, SaveUpdateCache now live in
// update_cache.cpp. See update_cache.h.
// Playtime cache I/O (PlaytimeCachePath, LoadPlaytimes, SavePlaytimes,
// RecordPlaytime, FormatPlaytime, FormatLastPlayed) now lives in
// playtime.cpp. See playtime.h.
// ── Hover-tip state ──────────────────────────────────────────────────────
// Shown over a mod row after the cursor has rested on it for ~2 s. Hidden
// on row change, mouse leave, click, or scroll. The display surface is
// the only consumer of g_playtimes outside the recording path.
// (extracted to module — was hover tip globals)
// Defined further down the file (after the existing modal dialogs) so
// these can be called from ModListProc.
// (extracted to module — was hover tip forward decls)
// ── Version comparison ────────────────────────────────────────────────
//
// "2.5.0" > "2.4.1", "1.10" > "1.9", "0.9.5b" > "0.9.5a", etc.
// NormalizeVersion, CompareVersions now live in version.cpp. See version.h.
// HttpResult, ParseUrl, HttpGet, HttpDownloadFile now live in http.cpp.
// Utf8ToWide moved to core.cpp. See http.h / core.h.
//
// GitHub Releases API response: parse `tag_name`, `body`, `published_at`,
// first .zip in `assets`, `html_url`.
// Generic manifest: parse `latest_version`, `changelog`, `download_url`,
// `source_url`, `release_date`, optional `sha256`.
// (extracted to module — was mod_updates block (Parse* + Fetch* + UpdateFetchWorker + KickUpdateChecks + GetUpdateInfo))
//
// Persisted to <mod>\Launcher Files\launcher_mod_cfg.json. Each mod has
// its own independent set of flags; switching mods loads that mod's saved
// state, switching back restores it.
// (block extracted to module — was mod_config block)
// ═══════════════════════════════════════════════════════════════════════
// D2R DETECTION + MOD SCANNING
// ═══════════════════════════════════════════════════════════════════════
static wstring FindD2RInstall() {
struct { const wchar_t* key; const wchar_t* val; } tries[] = {
{L"SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Diablo II Resurrected", L"InstallLocation"},
{L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Diablo II Resurrected", L"InstallLocation"},
{L"SOFTWARE\\WOW6432Node\\Blizzard Entertainment\\Diablo II Resurrected", L"Path"},
};
for (auto& t : tries) {
HKEY hk;
if (RegOpenKeyEx(HKEY_LOCAL_MACHINE, t.key, 0, KEY_READ, &hk) == ERROR_SUCCESS) {
wchar_t buf[MAX_PATH] = {};
DWORD sz = sizeof(buf), type = REG_SZ;
if (RegQueryValueEx(hk, t.val, nullptr, &type, (BYTE*)buf, &sz) == ERROR_SUCCESS
&& GetFileAttributes(buf) != INVALID_FILE_ATTRIBUTES) {
RegCloseKey(hk); return buf;
}
RegCloseKey(hk);
}
}
const wchar_t* defs[] = {
L"C:\\Program Files (x86)\\Diablo II Resurrected",
L"C:\\Program Files\\Diablo II Resurrected",
L"D:\\Diablo II Resurrected", L"E:\\Diablo II Resurrected",
L"F:\\Diablo II Resurrected", L"G:\\Diablo II Resurrected",
};
for (auto* p : defs) if (GetFileAttributes(p) != INVALID_FILE_ATTRIBUTES) return p;
return L"";
}
// (block extracted to module — was ModInfoFromJsonPath + FindMods)
// ═══════════════════════════════════════════════════════════════════════
// FONT LOADING
// ═══════════════════════════════════════════════════════════════════════
//
// Bundled fonts live in assets/fonts/. We load them via FR_PRIVATE so
// they're visible to this app only — no system font list pollution.
// Required files: Cinzel-Regular/Bold/Black.ttf, IMFellEnglishSC-Regular.ttf.
// Missing fonts fall back to Georgia.
// ─────────────────────────────────────────────────────────────────────
// Available fonts discovered in assets/fonts/ at startup. Four parallel
// vectors keyed by index i:
// g_availableFonts[i] — filename without extension (display key,
// persisted in cfg.fontName)
// g_availableFamilies[i] — actual Gdiplus family name resolved via
// PrivateFontCollection. Currently unused by
// the live UI (kept for a potential future
// "actually apply this font" pass).
// g_availableStyles[i] — FontStyle flags derived from filename. Same
// future-use note as above.
// g_availableAbbrevs[i] — short label shown in the Font dropdown and
// idle textbox. The full filenames don't fit
// in the toolbar's value box (rendering them
// in each face had its own ambiguity issues
// with Cinzel-Regular vs Cinzel-Bold both
// reporting family "Cinzel"), so we ship a
// single-font abbreviated label instead.
std::vector<std::wstring> g_availableFonts;
static std::vector<std::wstring> g_availableFamilies;
static std::vector<INT> g_availableStyles;
std::vector<std::wstring> g_availableAbbrevs;
// Abbreviate a font filename stem to a compact label. The pattern is:
// first three chars of the first segment, "-", first three chars of the
// LAST segment. Middle segments are dropped (Exocet-Blizzard-Medium →
// "Exo-Med"). Files with no "-" separator just get the first 4 chars.
//
// "Cinzel-Bold" → "Cin-Bol"
// "Cinzel-Regular" → "Cin-Reg"
// "Cinzel-Black" → "Cin-Bla"
// "Exocet-Blizzard-Medium" → "Exo-Med"
// "IMFellEnglishSC-Regular" → "IMF-Reg"
static wstring AbbreviateFontName(const wstring& name) {
if (name.empty()) return L"";
// Split on "-"
std::vector<wstring> parts;
wstring cur;
for (wchar_t c : name) {
if (c == L'-') {
if (!cur.empty()) parts.push_back(cur);
cur.clear();
} else cur.push_back(c);
}
if (!cur.empty()) parts.push_back(cur);
if (parts.empty()) return name.substr(0, 4);
wstring out = parts[0].substr(0, 3);
if (parts.size() >= 2) out += L"-" + parts.back().substr(0, 3);
return out;
}
// ─────────────────────────────────────────────────────────────────────
// Owner-drawn popup-menu rendering context. Set by the caller right
// before TrackPopupMenu, consulted by WM_MEASUREITEM and WM_DRAWITEM
// ODT_MENU. Three modes:
// IntValue — each item shows its integer value (used by the
// existing Stash Tabs / DMG Display dropdowns).
// StringList — each item shows g_menuCtx.labels[id-1] (Font dropdown).
// ColorSwatch — each item shows a color swatch followed by its label
// (Color dropdown — uses both labels[] and colors[]).
enum class MenuKind { IntValue, StringList, ColorSwatch, FontPreview };
struct MenuRenderCtx {
MenuKind kind = MenuKind::IntValue;
int itemWidth = 80; // logical px
int itemHeight = 28;
std::vector<std::wstring> labels;
std::vector<COLORREF> colors;
};
static MenuRenderCtx g_menuCtx;
// Color presets for the toolbar's Colour dropdown. Index order is what
// the popup shows and what we persist in LauncherCfg::fontColorIdx
// (with -1 meaning "use the default Gold"). The display label is the
// menu text; the swatch shows the actual hue.
//
// Curation rules:
// * Luminance ≥ ~0.30 against the dark stone background so text
// stays readable. Dark Red and Dark Green sit near that floor —
// they're kept for the people who want a heavy, brooding tone.
// * No pure white / pure black — both wash out against the gold
// accent frames and produce poor contrast at the engraved chrome.
// * Hues span the wheel: warm reds/golds/ambers, cool blues/teals,
// a purple accent, and a single cool neutral (Silver).
//
// Note: existing cfgs may have fontColorIdx pointing at the old Black
// (was idx 0) or Bright Green (was idx 4). Both are removed here, so
// those cfgs now resolve to the colour that sits at the same numeric
// index (Dark Red for old-Black, Bright Gold for old-Bright Green) on
// next load. ApplyColorChange's range check still protects against
// out-of-bound indices (falls back to default Gold), so deleting at the
// end of the list later wouldn't break anything either.
// (extracted to module — was ColorPreset + g_colorPresets)
// ─────────────────────────────────────────────────────────────────────
// UI scale presets for the toolbar Scale cycling button. The percentage
// label is what the on-screen button shows; the multiplier is what gets
// stored in LauncherCfg::uiScale. Final g_scale = multiplier * g_dpiScale.
// The active preset SET is DPI-dependent (see ActiveScalePresets below):
// at 150% Windows scaling only the smaller three make sense (anything
// above 100% would push the launcher past most monitors); at 100% the
// larger three give the user room to scale up.
// (extracted to module — was ScalePreset + g_scalePresets)
// Return the indices into g_scalePresets[] that are active under the
// current g_dpiScale. The boundary is 1.25 — anything at-or-above
// returns the {75/85/100} subset (typical "150%" Windows scaling),
// anything below returns the {100/115/127} subset (typical "100%"
// scaling on a high-pixel-density display).
// (extracted to module — was ActiveScalePresets)
// Return the slider state (0/1/2) for the current cfg.uiScale. Used
// both to pick which btn_toggle*.png to render and as the starting
// index for the cycle-on-click action.
// (extracted to module — was ScaleToggleState)
// ── On Launch toggle ─────────────────────────────────────────────────────
// Mirrors the Scale toggle's three-state pattern, but the states map to
// post-PLAY behaviors instead of UI sizes. The slider order is
// intentional — Minimize is the default (slider far left), Close is
// the destructive option, Stay Open is the "do nothing" no-op. Map
// to/from the existing LB_* enum so the launch-completion handler at
// the bottom of WM_LBUTTONUP doesn't need to change.
//
// slider state 0 → Minimize (LB_MINIMIZE)
// slider state 1 → Close (LB_CLOSE)
// slider state 2 → Stay Open (LB_STAY)
int OnLaunchSliderState() {
switch (g_cfg.launchBehavior) {
case LB_MINIMIZE: return 0;
case LB_CLOSE: return 1;
case LB_STAY: return 2;
}
return 0;
}
static int OnLaunchSliderStateToBehavior(int state) {
switch (state) {
case 0: return LB_MINIMIZE;
case 1: return LB_CLOSE;
case 2: return LB_STAY;
}
return LB_MINIMIZE;
}
const wchar_t* OnLaunchStateLabel() {
switch (g_cfg.launchBehavior) {
case LB_MINIMIZE: return L"Min";
case LB_CLOSE: return L"Close";
case LB_STAY: return L"Stay";
}
return L"Min";
}
// (extracted to module — was TryLoadFont + LoadFonts)
// (extracted to module — was UnloadFonts)
// (extracted to module — was MakeFamily)
// Resolve the user's chosen face (g_cfg.fontName) into a fresh
// FontFamily + style bits. Called before CreateGdipFonts so the
// override is in place when the fonts are built. The FontFamily is
// looked up against g_pfc (which owns every bundled .ttf) so the
// lookup is guaranteed to find the file we registered — passing no
// collection to FontFamily(name) silently failed on some systems
// and left the override null on every pick.
static void UpdateUserFontFromCfg() {
delete g_userFontFamilyOverride;
g_userFontFamilyOverride = nullptr;
g_userFontStyleOverride = FontStyleRegular;
if (g_cfg.fontName.empty()) return;
for (size_t i = 0; i < g_availableFonts.size(); ++i) {
if (g_availableFonts[i] != g_cfg.fontName) continue;
if (i < g_availableFamilies.size() && !g_availableFamilies[i].empty()) {
FontFamily* ff = new FontFamily(g_availableFamilies[i].c_str(), g_pfc);
if (ff->GetLastStatus() == Ok) {
g_userFontFamilyOverride = ff;
} else {
// PFC lookup failed — try the collection-less form as
// a fallback (the system might have the font registered
// even when our PFC pointer is missing it).
delete ff;
ff = new FontFamily(g_availableFamilies[i].c_str());
if (ff->GetLastStatus() == Ok) g_userFontFamilyOverride = ff;
else delete ff;
}
}
if (i < g_availableStyles.size()) {
g_userFontStyleOverride = g_availableStyles[i];
}
return;
}
}
static void CreateGdipFonts() {
// IMPORTANT: the family name passed to MakeFamily must match the font's
// nameID 1 (family name) as Windows sees it, NOT nameID 16 (typographic
// family). For Exocet Blizzard the nameID 1 includes "Medium" because
// Emigre's TTF fuses the weight into the family name. Passing the wrong
// name silently falls back to Georgia (see MakeFamily) with no visible
// error, which is how this bug hid for a while. Inspect a TTF with
// fonttools or the Windows Font Viewer if a font isn't appearing.
g_ffCinzel = MakeFamily(L"Cinzel");
g_ffCinzelBold = MakeFamily(L"Cinzel"); // same family, weighted via FontStyle
g_ffFell = MakeFamily(L"IM Fell English SC");
g_ffExocet = MakeFamily(L"Exocet Blizzard OT Medium");
g_ffGeorgia = MakeFamily(L"Georgia");
// When the user has picked a Font in the toolbar, swap that family
// in for every Exocet-based UI font (titles, headers, buttons, mod
// row names, PLAY). dispFam and dispStyle below collapse the
// override-or-default decision into one place so each new Font()
// call below stays a one-liner.
FontFamily* dispFam = g_userFontFamilyOverride ? g_userFontFamilyOverride : g_ffExocet;
FontStyle dispStyle = g_userFontFamilyOverride
? (FontStyle)g_userFontStyleOverride
: FontStyleRegular;
// Per-family cell-height normalization. Different families have wildly
// different cell-to-em ratios — Cinzel Black's rendered cell at em=38
// is ~30% taller than Exocet's at the same em, which clips mod row
// banners and overflows the Loader Dir textbox at high DPI. The
// launcher's design sizes (38, 26, 18, ...) are calibrated for
// Exocet's metrics, so we treat Exocet's rendered cell as the target
// and scale the user font's em DOWN so its cell matches.
//
// cellPx(font, em) = em * (ascent + descent) / emHeight
// want cellPx(user, em_u) = cellPx(Exocet, em)
// → em_u = em * (cellE * emU) / (emE * cellU)
//
// The factor is only applied when it would SHRINK the user font
// (factor < 1). When the user font has a smaller native cell ratio
// than Exocet, we leave emSize alone rather than stretch it up —
// the user asked for a max height per character, not a fixed one.
REAL emScale = 1.0f;
if (g_userFontFamilyOverride && g_ffExocet) {
UINT16 emE = g_ffExocet->GetEmHeight (FontStyleRegular);
UINT16 cellE = g_ffExocet->GetCellAscent (FontStyleRegular)
+ g_ffExocet->GetCellDescent(FontStyleRegular);
UINT16 emU = dispFam->GetEmHeight (dispStyle);
UINT16 cellU = dispFam->GetCellAscent (dispStyle)
+ dispFam->GetCellDescent(dispStyle);
if (emE > 0 && cellE > 0 && emU > 0 && cellU > 0) {
REAL f = ((REAL)cellE * (REAL)emU) / ((REAL)emE * (REAL)cellU);
if (f < 1.0f) emScale = f;
}
}
// SFE = "scaled font em" — SF() with the per-family cap applied.
// Use for every dispFam-based font; Georgia/raw-Exocet fonts keep
// plain SF() since they don't switch with the user pick.
auto SFE = [emScale](float em) { return SF(em) * emScale; };