This document captures everything learned while getting the 1996 Microsoft Comic Chat MFC client to render correctly on a modern high-DPI Windows display. It is written for the next person who touches the rendering code, so it explains why the bugs happened, not just what changed.
TL;DR: Comic Chat draws each comic panel into an off-screen DIB and blits it to the window. On a high-DPI display the off-screen bitmap was sized from the reported DPI (
GetDeviceCaps) while the on-screen device context actually used a larger logical→device mapping.BitBltdoes not scale, so the right ~12% of every panel was silently clipped. The fix is to size the bitmap from the DC's realLPtoDPextent, rebuild it when that extent changes, and blit withStretchBltas a safety net.A second, app-wide issue: only the comic page is drawn in device-independent TWIPs. The other surfaces (member-list face icons, the bodycam emotion wheel) use hardcoded 96-DPI pixel sizes, so once the app became DPI-aware they rendered ~33% too small. Those are scaled through a single
DpiScale()helper (see §10).
- All drawing coordinates are in TWIPs (1440 per inch), defined as
UNITSPERINCHincommon.h. The view runs inMM_TWIPSmap mode. - A comic page is a grid of fixed-size panels (
CUnitPanelPage). The panel size isunitWidth × unitHeight(TWIPs), recomputed from the client area inCPageView::GetProspectivePanelWidth/SetPanelsWide. - Each panel is rendered off-screen first:
CreateRetainedPanel(inpageview.cpp) allocates a DIB section (m_retSec/m_retDib) sized to one panel.CUnitPanelPage::Draw(inpanel.cpp) selects that DIB into a memory DC, draws the panel (backdrop, avatars, balloons, title) into it, thenBitBlts it to the destination DC at the panel's on-screen position.
- Text/title layout is measured with a long-lived cached
CClientDC(GetClientDC()), created once inCPageView::OnCreate.
The title bar of a comic ("MEET MARKET", "VIRTUALLY VACUOUS", …) is a random
pun pulled from titles.txt, rendered with the large m_fontTitle. Long puns
are the most visible victims of any horizontal clipping.
Windows exposes display scaling in two ways, and Comic Chat's code mixes them up:
| Concept | API | What it returns |
|---|---|---|
| Reported logical DPI | GetDeviceCaps(hdc, LOGPIXELSX/Y) |
A nominal number (e.g. 96 or 144). |
| Actual mapping scale | LPtoDP on the DC |
The real logical→device conversion the DC will use when it draws/blits. |
For a pure MM_TWIPS DC these agree: LPtoDP(1440 twips) == LOGPIXELSX.
But a CScrollView (or anything that sets viewport/window extents) can give a
DC an effective scale that differs from the reported DPI. In that case
GetDeviceCaps lies relative to what the DC actually draws.
Comic Chat sized its bitmaps from the reported DPI but drew into them with the actual mapping scale — and those two disagreed on this machine.
On a 144-DPI (150% scaling) laptop display:
- Initial run (no DPI awareness): the whole app was bitmap-stretched by Windows' DPI virtualization. Panels were huge and blurry and the comic content was clipped.
- After
SetProcessDPIAware(): UI chrome (menus, dialogs) rendered crisp and correctly sized, and most of the comic rendered correctly — but the right edge of every panel was still clipped. The clearest tell was the random title pun:YOU SHOULDA BEEN THERErendered asYOU SHOULD‑(the "A" sliced off), while shorter lines likeBEEN THEREwere fine.
The clip was always on the right/bottom and was roughly a fixed percentage of the panel — a classic "destination bigger than source" blit symptom, not a font or word-wrap problem.
The app already emits TRACE(...) via the MFC/ATL debug channel, which is
visible in DebugView (DbgView). Temporary traces were added at the exact
draw sites and read back live while connected to irc.libera.chat:
-
Title fit —
CUnitPanelPage::AddTitle:TITLEFIT 'YOU SHOULDA BEEN THERE' unitWidth=7365 measuredWidth=6511 ... right=6938The wrapped title measured 6938 < unitWidth 7365, i.e. layout said it fit. So it was not a measurement/word-wrap bug.
-
DC agreement —
CLabel::Draw: the drawing DC and the cached client DC returned identical text extents (12266 == 12266). So it was not a measure-DC vs draw-DC mismatch either. -
Bitmap vs panel —
CUnitPanelPage::Draw:PANELBLIT bmpW=737 bmpH=737 unitWidth=7365 dcDpiX=144The bitmap was 737px (=
7365 / 1440 × 144). -
The smoking gun — per-line device position in
CLabel::Draw:TITLELINE line=0 width=6511 rightTwip=6938 rightDev=788 bmpW=737 unitWidth=7365The title's right edge maps to 788px, but the bitmap is only 737px wide.
788 / 6938 × 1440 ≈ 164→ the DC was effectively drawing at ~164 DPI while the bitmap was sized for 144 DPI. The last 51px (the "A") fell off the edge of the DIB. -
Blit positions confirmed the same scale on the destination side: panel #2's logical x
7509mapped to device853px(pure 144-DPIMM_TWIPSwould give751px).
So: bitmap sized at the reported DPI (144 → 737px); content drawn and panels
positioned at the DC's real scale (~164 → 788px). BitBlt copies 1:1, so the
extra ~12% is clipped.
All changes are surgical and live in three files.
CChatApp::InitInstance calls SetProcessDPIAware() (resolved dynamically
from user32.dll, since the original 1996 target predates it). This stops
Windows from bitmap-stretching the whole window and lets the app render at
native resolution.
HMODULE hUser32 = LoadLibrary("user32.dll");
if (hUser32) {
typedef BOOL (WINAPI *SetProcessDPIAwareFunc)();
SetProcessDPIAwareFunc setDPIAware =
(SetProcessDPIAwareFunc)GetProcAddress(hUser32, "SetProcessDPIAware");
if (setDPIAware) setDPIAware();
FreeLibrary(hUser32);
}CreateRetainedPanel now derives the DIB pixel size from the DC's actual
LPtoDP extent instead of GetDeviceCaps(LOGPIXELSX/Y):
POINT pOrg = { 0, 0 };
POINT pExt = { CUnitPanelPage::unitWidth, CUnitPanelPage::unitHeight };
pDC->LPtoDP(&pOrg);
pDC->LPtoDP(&pExt);
infoHdr->biWidth = abs(pExt.x - pOrg.x);
infoHdr->biHeight = abs(pExt.y - pOrg.y);This guarantees the bitmap is exactly the device size the same DC will draw and blit a panel at, regardless of whether the reported DPI matches the mapping. (Two points are transformed and subtracted so any viewport-origin offset cancels out.)
CPageView::OnDraw used to create the retained section once and keep it
forever. Now it checks the existing bitmap's width against what the current
drawing DC needs and rebuilds if they differ (panel size changes, view-mode
switches, DPI/monitor changes, etc.):
if (!pDC->IsPrinting()) {
POINT po = { 0, 0 };
POINT pe = { CUnitPanelPage::unitWidth, CUnitPanelPage::unitHeight };
pDC->LPtoDP(&po);
pDC->LPtoDP(&pe);
int needW = abs(pe.x - po.x);
BOOL needNew = (GetRetSec(pDC) == NULL);
if (!needNew) {
BITMAP bm;
::GetObject(GetRetSec(pDC), sizeof(bm), &bm);
if (bm.bmWidth != needW) needNew = TRUE;
}
if (needNew) {
FreeRetainedPanelS();
CreateRetainedPanel(pDC, &m_retSec, &m_retDib);
}
}The panel blit was changed from BitBlt to StretchBlt so that even if a
residual scale mismatch ever remains, the whole panel bitmap is scaled to
exactly fill the panel slot rather than being clipped:
dc->SetStretchBltMode(COLORONCOLOR);
dc->StretchBlt(loc2.x, loc2.y, unitWidth, -unitHeight,
&memDC, 0, 0, unitWidth, -unitHeight, SRCCOPY);With 5b/5c in place the source and destination sizes match, so StretchBlt
behaves like a 1:1 copy in the common case; it only kicks in if the scales ever
diverge again.
- "Just shrink the title font." The title already measured as fitting
(
6938 < 7365). The font was correctly sized; the bitmap was too small. - "It's a measurement DC vs drawing DC mismatch." Traced and ruled out — both DCs returned identical text extents.
- "Force the app DPI-unaware and let Windows stretch it." That brought back uniform blurriness and still clipped in the original (virtualized) run, because the clip is inherent to the bitmap-sizing logic, not to the scaling mode.
The only correct lever is to make the off-screen bitmap size and the
on-screen mapping scale agree, which is exactly what LPtoDP-based sizing
does.
GetDeviceCaps(LOGPIXELSX)is not the same as the DC's drawing scale. When a view uses scroll/extent-based mapping, preferLPtoDPto learn the real conversion.BitBltnever scales. If the source DIB and the destination logical rect imply different device sizes, content is clipped (or garbage is read past the end of the source). UseStretchBltwhen you cannot guarantee equal scales.- Long-lived cached DCs (
GetClientDC()created inOnCreate) can capture a stale mapping. If display DPI/monitor changes at runtime, anything sized from that cached DC may be wrong. TheOnDrawsize-check (5c) mitigates this for the panel bitmap. - This is system-DPI awareness, not per-monitor.
SetProcessDPIAware()fixes the single-display case. Moving the window between monitors of different DPI is out of scope; per-monitor v2 awareness would require a manifest andWM_DPICHANGEDhandling. - TWIPs everywhere. Keep new drawing code in
MM_TWIPSand reason about device pixels only viaLPtoDP/DPtoLP.
- Build:
nmake /f chat.mak CFG="chat - Win32 Debug"(see the repo README / build notes; ATLMFC spectre libs anduuid.libare required). - Run
chat.exeon a high-DPI display (e.g. 150% scaling). - Connect to
irc.libera.chat(plaintext port 6667 — see note below) and join a busy channel so a title/cast panel is generated. - Confirm the random title pun renders in full (no clipped final letter) and that balloons/avatars reach the panel's right edge without being cut.
- Confirm the pixel-based surfaces are sized proportionally to the comic (see §10): the member-list face icons (upper right), the avatar preview, and the bodycam emotion wheel with its 8 face icons (lower right).
- Optional: attach DebugView and confirm there are no panel clip anomalies.
Verified: built clean and run end-to-end on a 144-DPI (150%) display —
connected to Libera (###bots), comic view rendered with the title
"YOU SHOULDA BEEN THERE" uncut, correctly-sized member-list faces, a crisp
avatar preview, and a properly-scaled emotion wheel. Standard-DPI (96/100%) is
unaffected because DpiScale(n) == n there.
- No TLS. Comic Chat speaks plaintext IRC over
CSocket; it cannot connect to TLS-only ports such as6697. Use a localstunnel/ZNC bridge if a network requires TLS, and point Comic Chat at the local plaintext endpoint. - Room-join assertion. Joining a channel called
SetPathName(channel), which modern MFC pushes onto the MRU file list, asserting on the non-file name. PassingFALSE(SetPathName(channel, FALSE)) skips the MRU add. - Missing TTS wrapper.
chat.exeimportedinitproc/ttsproc/exitprocfromlhwrap.dll, a wrapper around the Lernout & Hauspie text-to-speech engine (1996). That DLL is absent, so these are stubbed; speech is a no-op.
Fixing the comic page (§5) is necessary but not sufficient. The comic page is the only surface drawn in TWIPs; the rest of the UI uses hardcoded device pixels tuned for 96 DPI. Once the process became DPI-aware, those surfaces stopped being bitmap-stretched by Windows and rendered at their literal pixel sizes — i.e. ~33% too small on a 144-DPI (150%) display. The visible symptoms were the member-list face icons (upper right) and the bodycam emotion wheel (lower right) looking tiny next to a correctly-sized comic.
We tried the obvious "uniform" answers and rejected them:
- DPI virtualization (unaware): bitmap-stretches everything uniformly, but blurry, and it still hit the comic-bitmap clipping bug.
- GDI Scaling (
gdiScaling=true+dpiUnaware): re-renders GDI crisply, but Comic Chat persists its window geometry in raw pixels. Geometry saved during a DPI-aware session got re-scaled by the OS, so the main window opened gigantic. The OS-scaling model fights the app's custom pixel layout.
So we keep true DPI awareness (comic + window geometry are correct) and scale the pixel surfaces ourselves.
common.h exposes one tiny helper, initialized once in
CChatApp::InitInstance:
extern int g_screenDpi; // = GetDeviceCaps(LOGPIXELSX)
inline int DpiScale(int n96) { return ::MulDiv(n96, g_screenDpi, 96); }At 96 DPI DpiScale(n) == n, so standard-DPI users see no change — this is
purely additive for high-DPI displays.
- Member list (
memblst.cpp,irc.cpp): the list-view image list is created atDpiScale(40)cells, andAddToImageListStretchBlts each face bitmap up by the same factor (the source art is fixed ~96-DPI pixel art, so it must be enlarged or it renders tiny in a big cell). - Bodycam emotion wheel (
bodycam.cpp):CacheBullSidecaps the wheel atDpiScale(MAXBULL)/DpiScale(MINBULL); the shared icon metrics (m_iconWidth,m_iconHeight,m_cursorRadius) are scaled once in the constructor; and the emotion face icons are drawn with the stretchingCDIB::Draw(dc,x,y,w,h)overload to fill the scaled slots. - Say box + action buttons (
chat.cpp,saywnd.cpp,spltchat.cpp): the input-edit font (m_fontText,nFontHeight) is created at-DpiScale(14); the Say-window layout (edit height, bar position) inCSayWnd::OnSizeusesDpiScale(cxSayBar/cyToolBar); the Say pane's minimum height (nPixelsSayMin) isDpiScale(23)so the bigger text isn't clipped; and the four action-balloon toolbar buttons are enlarged by stretchingIDB_BALLOONSinto aDpiScale-sized image list assigned to the toolbar control (high-DPI only — a no-op at 96).
- Stretching small pixel art with
COLORONCOLORis slightly blocky but matches the in-panel comic avatars (which stretch the same art) and is far better than tiny.HALFTONEwould be smoother at a small perf cost. - The bodycam wheel is still capped by its splitter pane's pixel width; it's wide enough at normal window sizes, but a very narrow right column will clamp it.
Two non-DPI usability fixes shipped alongside the scaling work:
-
Mouse-wheel scrolling (
pageview.cpp).CPageView(aCScrollView) had no wheel handler, so the comic area never scrolled by wheel. AddedOnMouseWheel, which scrolls viaOnScrollBy(CSize(0, -notches*DpiScale(48))). Relies on the cursor's window receivingWM_MOUSEWHEEL(focused window, or the hovered one with Windows' "scroll inactive windows" setting, which is on by default). -
Panels-per-row auto-fit (
pageview.cpp). The column count was a fixed, registry-persisted value (usually 2), so wide windows wasted space. AddedFitPanelsWide()— the largest column count (1..5) whose panels stay a comfortable width (COMFORTABLEPANELWIDTHtwips) — and apply it when the view activates and on resize.Important implementation detail:
SetPanelsWide()reloads the comic history and updates scroll info, which re-entersWM_SIZE. Calling it directly fromOnSize/OnActivateViewcrashed (re-entrancy). The fix is to defer the reflow withPostMessage(WM_AUTOFITPANELS)and run it from the posted handler in a stable state, guarded bym_bAutoFittingand an idempotent "only if the count actually changed" check. -
Word wrap inside balloons (
balloon.cpp).BreakIntoLinesused toForceLineBreak(split mid-character) when a single word was wider than the balloon's estimated text area — so in a narrow balloon a short word like "Test" became "Tes" / "t". Since the balloon spline is built from the actual line widths, the right behavior is to keep the word whole and let the balloon grow;BreakIntoLinesnow does that instead of splitting the word. (This is a bubble-internal layout fix, independent of panel width — narrow panels just made it surface more often.)