All notes

· 5 min read

Testing against the thing that actually loads your file

A round-trip test proves an encoder agrees with its own parser. Asking Win32 to load the output proves something else entirely, and it turned an undocumented format limit into a measured number.

Test with the actual loader

A round-trip test proves one thing, which is that an encoder and its parser agree with each other. It's a really useful test, but the question it answers only sits next to the one I care about, which is whether the program that'll actually open the file accepts it.

Those two questions can have different answers, and a round-trip test can't see it when they do, because Windows was never asked.

So now, if something outside my code is going to load the output, the test has to be that thing loading the output. A parser I wrote to the same spec doesn't count, and neither does a structural comparison against a reference file. It has to be the real loader, called on the real bytes.

Where it came from

I built a cursor scheme for Windows, with seventeen roles drawn as SVG, rasterised at five sizes, and packed into .cur and .ani files by an encoder I wrote from the format documentation. Windows has no supported way to install a cursor scheme other than writing the files and pointing the registry at them, so the encoder is the whole product.

The suite around it was in decent condition. It round-tripped every file the encoder produced, and (the part I was a bit pleased with) it parsed Microsoft's own C:\Windows\Cursors\aero_arrow.cur and aero_busy.ani and checked that my reading of the format matched theirs. That's a real external reference, and it caught real mistakes while I was writing the encoder.

The two animated cursors still didn't load. Windows substituted its own busy cursor with no error anywhere, and otherwise the scheme looked installed and worked fine. The tests were green the entire time, and they were right to be. The encoder and the parser agreed, and both agreed with Microsoft's files on structure.

The constraint that isn't in the documentation

An .ani is a RIFF file of form type ACON, and the thing worth knowing about it is what a frame actually is.

RIFF <size> ACON
  LIST <size> INFO        optional: INAM (title), IART (artist)
  anih <36>               ANIHEADER
  LIST <size> fram
    icon <size>           a complete .cur file, one per frame
    icon <size>
    ...

Each frame is a whole .cur file, with its own directory of images inside it, and not a bitmap. So when you pack five resolutions into an animated cursor, you're not storing five images once, you're storing all five in every single frame. My static cursors carried 32, 48, 64, 96 and 128px and came out at a sensible size. The same five sizes in an animated cursor produced frames of 136,606 bytes each, and Windows won't load a frame that large.

Total file size isn't the limit. A 1,988 KB file made of small frames loads without complaint, while a 534 KB file of oversized ones doesn't. Anything reasoning about the file as a whole, which is what both my tests and my intuition were doing, is looking at the wrong number. Specs describe layout, but in my experience loaders have limits the spec doesn't mention.

Measuring instead of guessing

Once you suspect a limit, the obvious move is to pick a safe-looking number and stay under it. That's still a guess, and it goes stale the moment someone adds a resolution.

The better move costs about twenty minutes. Generate variants at a range of frame sizes and ask Win32 to load each one, and you get real numbers.

Per-frame bytes   Contents               Result
         67,646   one 128px image        loads
         68,966   96 + 64 + 48 + 32      loads
         71,926   128 + 32               fails
         98,534   128 + 64 + 48 + 32     fails
        136,606   all five sizes         fails

The real ceiling is somewhere between 68,966 and 71,926. I took the low end as the constant, because that's what I measured, and the midpoint would have been a guess. Animated roles now carry 32/48/64, which gives 30,894-byte frames, comfortably clear, and happens to be the same three sizes Microsoft ships in aero_busy.ani. That coincidence is the strongest hint that the ceiling is real and long-standing.

Asking the loader directly

The check that now runs after every install doesn't look at my files at all. It reads the paths currently in HKCU\Control Panel\Cursors, hands each one to LoadCursorFromFile, and then calls GetIconInfo to read back the hotspot Windows resolved.

$handle = [PandaCursors.Verify]::LoadCursorFromFile($path)
if ($handle -eq [IntPtr]::Zero) {
    $err = [System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
    Write-Host "  $label LOAD FAILED (win32 error $err)  $path" -ForegroundColor Red
    $failed++
    continue
}

Either a handle comes back or it doesn't. There's no room for my interpretation of the format in that answer. It also verifies something the unit tests structurally can't, which is the hotspot Windows chose, at the resolution Windows picked out of the multi-image file according to the user's cursor-size setting.

Turning the measurement into a guard

A number established by experiment is worth very little if it lives in a commit message. It goes into the encoder as a refusal.

export const MAX_ANI_FRAME_BYTES = 68_966;

const oversized = frames.findIndex((f) => f.length > MAX_ANI_FRAME_BYTES);
if (oversized !== -1) {
  throw new Error(
    `frame ${oversized} is ${frames[oversized]!.length} bytes, over the ` +
      `${MAX_ANI_FRAME_BYTES}-byte limit Windows will load. Pack fewer resolutions ` +
      `into animated cursors (see ANIMATED_SIZES in roles.ts).`,
  );
}

I think a warning would be the wrong call here. Windows swaps in its own cursor and everything looks approximately fine, so a build that emits a warning nobody reads leaves me where I started. So the build stops, and the message says what to do about it.

Generating variants and loading each one turned a vague "keep the files small" into a number I could put in the code, and the comment above that constant lists all five measurements, so whoever next wants to add a resolution can see where the ceiling sits and doesn't have to take my word for it.

Published testing · windows · file formats