When Claude and I started the macXserver adventure, a key thing I wanted was essentially unlimited scaling. By design the server would be rootless and I wanted to decide how the size of X windows would appear alongside Mac windows. It seemed reasonable, given the raw graphics horsepower of the Mac compared to the MIT server running on any of my Sun boxes. In my mind, antialiasing all the graphics rendering coupled with Retina display support, this would be a slam dunk. And it was at first.

For early development we decided on using 3× as the magnification factor. That resulted in X windows being logically just a bit bigger than their Mac counterparts (about 1.x).

The first apps we concentrated on were xclock and xterm. Xclock was working within the first three days, and we used 3× magnification for nearly the entire 30 days. When xclock launched and worked pretty much right out of the gate, I thought, “wow, that was easy.” We had antialiased drawing enabled and it looked amazing. At any one time I’d only run xclock for a few seconds, kill it, and move on to the next issue.

Moving on to xterm, the big task was getting font rendering working. We’d made the decision early on that we would only support scalable native Mac fonts and map them to X font names. So for fonts we used antialiasing as well. Of course xterm uses very little rendering other than fonts and CopyAreas, so success came pretty quickly. In general xterm exercises only a small subset of the X protocol’s 127 opcodes.

But as time went on, I would leave xclock running on the screen while we worked on xterm. xclock on OG X11R6 doesn’t show a second hand by default, so it took a bit of time to notice some rendering artifacts materialize. As the minute hand moved around, I noticed it was leaving faint shadows near the pivot point at the center of the clock face. So I asked Claude what was happening.

claude what are the visual artifacts im seeing around the clock center, it appears almost subpixel in nature

Yeah, “subpixel” is exactly the right word for it. But the artifact isn’t coming from the rendering pipeline. It’s coming from xclock itself.

xclock from X11R6 doesn’t redraw the clock face every tick. That would have been expensive on 1985 hardware, and “expensive” meant flicker. So it does something cleverer: when the minute hand moves, xclock erases the old position by drawing the same line again in the background color, then draws the new line in the foreground color. Two line-draws per tick. The clock face underneath stays put.

This pattern only works under one condition. The second line-draw has to land on exactly the same device pixels as the first one. If the erase line is off by even half a pixel, the original’s edge pixels aren’t fully covered. They survive into the next frame. After a few ticks of accumulated leakage you get a faint ghost of every position the hand has visited, radiating out from the pivot. That’s what you’re seeing.

Two things in our pipeline are causing the erase draws to miss.

The first is antialiasing. We have AA on by default because we wanted everything to look modern. But AA softens stroke edges across multiple device pixels with sub-100% opacity. When xclock first draws its minute hand at angle θ₁, AA blends the stroke into about 2 device pixels on each side with whatever fractional coverage the geometry works out to. When xclock then draws the same line in the background color to erase it, AA blends the erase line into 2 device pixels on each side too, but the blending isn’t a perfect inverse. The foreground-color residue at the original AA edges doesn’t get fully neutralized by the erase pass. Multiply that residue across a hundred sweeps of the minute hand and you get the haze you’re seeing.

The second is the scale factor, indirectly. We’re at 3× right now, which is an integer scale, so this isn’t biting us today. But if we ever offered 1.5× or 2.5× as an option, the same problem would get much worse. A 1-logical-pixel stroke at 2.5× takes 2.5 device pixels. There’s no integer way to draw 2.5 device pixels, so Core Graphics distributes the half-pixel across adjacent rows with subpixel positioning. Each consecutive draw lands in a slightly different rasterized footprint. Even with AA off, the erase pass wouldn’t cover the original.

The fix is two constraints I think we should bake into the server as load-bearing rules.

Antialiasing off on shape fills. Specifically, every withClip we use for PolyFillRectangle, PolyLine, PolySegment, PolyArc, FillPoly, anything that draws a geometric shape. CGContext.setShouldAntialias(false) plus setInterpolationQuality(.none) at the top of every shape-drawing path. Text rendering stays on AA because it goes through CTFontDrawGlyphs, which rasterizes glyphs from outlines with its own anti-aliasing that’s idempotent when redrawn at the same integer pointSize at the same coordinates.

Integer scale factors only. No 1.5×, no 2.5×. The user-facing Preferences radio (Auto / Comfortable / Compact) maps to 2× or 3× under the hood, and the --scale CLI flag only accepts 2 or 3. We already enforce this in the scale picker’s logic, but it’s worth calling out as a hard constraint rather than a default. There’s no version of this server that renders fractional scales correctly, and there shouldn’t be one.

The deeper point: xclock isn’t asking the server for anything weird. It’s assuming what every X server before 1995 implemented as a matter of course. Pixel-exact drawing, pixel-exact erasing, pixel-exact re-drawing. Modern rendering pipelines default to “make things look smooth,” which means antialiasing and subpixel positioning and interpolation. Those defaults are exactly wrong for clients that own their own pixels and expect them to stay put. We’re choosing to render less smoothly on shape draws so that 30-year-old clients render correctly.

xeyes will have the same issue, by the way, the moment we start watching it for more than a few seconds. So will oclock. So will quickplot’s plot redraws and xterm’s cursor blink. Any X client written before about 1995 assumes pixel-exact draw-to-erase semantics, because every X server before then implemented exactly that. Worth fixing this once, in withClip, where it covers all of them.

And Claude was correct. I launched xeyes and the same halo effect happened there too. A few things were crystal clear to me at this point: we could only support 1×, 2×, or 3× scaling, and antialiasing on graphics was not going to work. Fortunately, Retina displays render graphics so cleanly that even without antialiasing on shapes it didn’t make a big difference. But Retina would come back to haunt us later in development, with clipmasks.