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-435 — Serialize 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:
- Failure is not reported. A caller cannot distinguish "decoded cleanly"
from "decoded nothing," because both return S_OK and a full-size buffer.
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/CBRN → EhmNotifyUser) 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.
Summary
NibblizationLayer::Denibblizeabandons a track on the first sector it cannotdecode, 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 aguest 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-435—Serializeroutes everysector format straight through
Denibblize:CassoEmuCore/Devices/Disk/NibblizationLayer.cpp:741— the output buffer startszeroed:
CassoEmuCore/Devices/Disk/NibblizationLayer.cpp:762-769— the per-sector loop:Denibblizethen returnsS_OKregardless.Two distinct problems compound here:
from "decoded nothing," because both return
S_OKand a full-size buffer.breakrather thancontinuemakes the blast radius the whole rest of thetrack. 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 inscan 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
DecodeOneSectorrejects: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::Serializepath instead and are unaffected.Suggested fix
continuerather thanbreak, so one unreadable sector costs one sectorrather than the tail of a track. Advancing
bitPospast the failed field isthe fiddly part — resynchronizing on the next address prologue is the natural
approach, and
DecodeOneSector's existing prologue search already does thatwork.
Denibblizeinstead of swallowingthem, so callers can act.
spec 020-disk-file-accessneeds exactly thisreport (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.
image is the current behavior and the worst option. Refusing the flush and
telling the user preserves the file on disk;
DiskImageStore::FlushEntryalready routes genuine persist failures through the shared EHM notifier
(
CHRN/CBRN→EhmNotifyUser) for precisely this reason, so the plumbingexists.
Not in scope
Making
Denibblizedecode non-standard or copy-protected tracks. The goal is tostop it destroying data it cannot read, and to say so when it cannot.