Skip to content

fix(disk): Denibblize silently zero-fills a track's remaining sectors on any decode failure, on the flush path #115

Description

@relmer

Summary

NibblizationLayer::Denibblize abandons a track on the first sector it cannot
decode, leaves every remaining sector of that track as zeros, and returns
S_OK. It sits on the emulator's flush path for every sector-format image, so a
guest that leaves a track partially written loses the rest of that track on
eject — silently, with success reported all the way up.

This is independent of any spec in flight. Found while surveying the disk
substrate for specs/020-disk-file-access.

Mechanism

CassoEmuCore/Devices/Disk/DiskImage.cpp:429-435Serialize routes every
sector format straight through Denibblize:

case DiskFormat::Dsk:
case DiskFormat::Do:
case DiskFormat::Po:
    hr = NibblizationLayer::Denibblize (*this, m_format, outBytes);
    break;

CassoEmuCore/Devices/Disk/NibblizationLayer.cpp:741 — the output buffer starts
zeroed:

out.assign (kImageByteSize, 0);

CassoEmuCore/Devices/Disk/NibblizationLayer.cpp:762-769 — the per-sector loop:

for (sec = 0; sec < kSectorsPerTrack; sec++)
{
    HRESULT hrSector = DecodeOneSector (img, track, bitPos, outSector, data);

    if (FAILED (hrSector))
    {
        break;          // <-- abandons the whole track
    }
    ...
}

Denibblize then returns S_OK regardless.

Two distinct problems compound here:

  1. Failure is not reported. A caller cannot distinguish "decoded cleanly"
    from "decoded nothing," because both return S_OK and a full-size buffer.
  2. break rather than continue makes the blast radius the whole rest of the
    track.
    Sectors decoded before the bad one survive; every sector after it
    in scan order is zeroed, whether or not it was individually decodable.
    Because the loop walks the bit stream sequentially from bitPos, "after in
    scan order" is rotational position, not logical sector number — so which
    sectors are lost depends on where the head happened to be.

Reproduction path

Any guest that leaves a track in a state DecodeOneSector rejects:

  • power-off or reset mid-sector-write, leaving a torn data field
  • a program writing its own nibble format to part of a track
  • a write that corrupts an address or data prologue

On the next flush — eject, motor spin-down, machine switch, or exit — the image
written back to disk has that track truncated from the failure point onward. The
user is told nothing.

Why it has not been noticed

Sector-format images are nibblized from valid sectors at load, so they decode by
construction. The failure needs a guest to write something malformed first, and
WOZ images take the WozLoader::Serialize path instead and are unaffected.

Suggested fix

  • continue rather than break, so one unreadable sector costs one sector
    rather than the tail of a track. Advancing bitPos past the failed field is
    the fiddly part — resynchronizing on the next address prologue is the natural
    approach, and DecodeOneSector's existing prologue search already does that
    work.
  • Report per-track decode results out of Denibblize instead of swallowing
    them, so callers can act. spec 020-disk-file-access needs exactly this
    report (its FR-017) to refuse writing to tracks that do not round-trip, so the
    two efforts should share one mechanism rather than each growing their own.
  • Decide what a flush should DO on partial decode. Silently writing a damaged
    image is the current behavior and the worst option. Refusing the flush and
    telling the user preserves the file on disk; DiskImageStore::FlushEntry
    already routes genuine persist failures through the shared EHM notifier
    (CHRN/CBRNEhmNotifyUser) for precisely this reason, so the plumbing
    exists.

Not in scope

Making Denibblize decode non-standard or copy-protected tracks. The goal is to
stop it destroying data it cannot read, and to say so when it cannot.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingimpact: userEmulator end user: running Apple II softwarepriority: highMarquee end-user impact (end-user-value triage)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions