diff --git a/CHANGELOG.md b/CHANGELOG.md index ac37c611..863fa4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Versioned entries use `MAJOR.MINOR.BUILD` from [Version.h](CassoCore/Version.h). Entries before versioning was introduced use dates only. +## [1.4.1288] — Protected games boot: quarter-track disk pipeline + +### Added +- **feat(disk2): quarter-track read pipeline for half-track copy + protection.** The disk pipeline now resolves reads at quarter-track + (0-159) resolution via a TMAP-derived slot map, and the head stepper + uses the apple2js PHASE_DELTA model so it always rests on a valid + detent. Disks formatted on half-track boundaries — *Choplifter*, + *Karateka*, and *Lode Runner* — now boot from their original WOZ + images (previously stalled in the protected region around track 12). + +### Fixed +- **fix(disk2): boot recalibrate audio is a machine-gun ratchet, not a + buzz.** During a DOS boot recalibrate the head pins at the track-0 stop + and emits a steady ~52 Hz stream of identical thunks; restarting the + same sample every 19 ms smeared into a continuous buzz. The audio layer + now cycles rapid consecutive bumps through a 4-slot ratchet pattern, + restoring the realistic slow "machine-gun" ratchet a real Disk II makes. + Isolated bumps stay firm thunks; a genuine step re-arms the pattern. + + ## [1.4.1279] — Disk II copy-protection fidelity foundations ### Added diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 4b1b81e1..08f02d8f 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -5,7 +5,7 @@ #define VERSION_MAJOR 1 #define VERSION_MINOR 4 -#define VERSION_BUILD 1279 +#define VERSION_BUILD 1288 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/CassoEmuCore/Audio/Disk2AudioSource.cpp b/CassoEmuCore/Audio/Disk2AudioSource.cpp index 3eaf5c38..a9e049b4 100644 --- a/CassoEmuCore/Audio/Disk2AudioSource.cpp +++ b/CassoEmuCore/Audio/Disk2AudioSource.cpp @@ -440,6 +440,11 @@ void Disk2AudioSource::OnHeadStep (int newQt) m_lastStepCycle = m_currentCycle; + // A real step means the head left the track-0 wall, so the bump + // ratchet is no longer in progress -- re-arm it from the top. + m_lastEventWasBump = false; + m_ratchetSlot = 0; + // Spec-006 audio-decision sink (FR-022 / FR-025). Mapping: // * wasInSeekMode == true at entry -> AudioContinued // * empty step buffer -> AudioSilent / BufferMissing @@ -475,15 +480,22 @@ void Disk2AudioSource::OnHeadStep (int newQt) // // OnHeadBump // -// Track-0 / max-track wall-bang. Always restarts the stop one-shot -// (a bump is a discrete event, never collapsed into the seek-burst -// state). Clears seek mode so a subsequent step starts fresh. +// Track-0 / max-track wall-bang. An ISOLATED bump is a firm thunk +// (the HeadStop one-shot, restarted). A rapid run of consecutive bumps +// -- as the controller emits while the head is pinned against the +// track-0 stop during a boot recalibrate -- is instead rendered through +// a 4-slot ratchet pattern [thunk, pause, click, click] so it sounds +// like a slow machine gun rather than a continuous buzz. Clears seek +// mode either way. // //////////////////////////////////////////////////////////////////////////////// void Disk2AudioSource::OnHeadBump() { bool previousStillPlaying = false; + bool ratchet = false; + uint64_t gap = 0; + uint32_t slot = 0; uint32_t headLen = 0; if (m_headBuf != nullptr) @@ -492,25 +504,80 @@ void Disk2AudioSource::OnHeadBump() previousStillPlaying = (headLen > 0 && m_headPos < headLen); } - m_seekMode = false; - m_headBuf = &m_stopBuf; - m_headPos = 0; - m_lastStepCycle = m_currentCycle; + if (m_lastEventWasBump && m_lastStepCycle != 0 && m_currentCycle >= m_lastStepCycle) + { + gap = m_currentCycle - m_lastStepCycle; + ratchet = (gap < kHeadIdleCycles); + } + + m_seekMode = false; + m_lastEventWasBump = true; + m_lastStepCycle = m_currentCycle; + + if (!ratchet) + { + // Isolated wall-bang: firm thunk. Re-arm the ratchet so a + // following rapid burst renders [silent, click, click, thunk...]. + m_ratchetSlot = kRatchetSlotSilent; + TriggerHeadShot (SoundKind::HeadStop, &m_stopBuf, previousStillPlaying); + return; + } + + slot = m_ratchetSlot; + m_ratchetSlot = (m_ratchetSlot + 1) % kRatchetPeriod; + + if (slot == kRatchetSlotThunk) + { + TriggerHeadShot (SoundKind::HeadStop, &m_stopBuf, previousStillPlaying); + } + else if (slot == kRatchetSlotSilent) + { + // Rhythmic pause: hold the decaying tail and emit nothing. This + // silent slot is what breaks a steady 52 Hz buzz into the grouped + // "slow machine gun" cadence of a real recalibrate. + } + else + { + // The two remaining slots are step clicks -> 2:1 click-to-thunk. + TriggerHeadShot (SoundKind::HeadStep, &m_stepBuf, previousStillPlaying); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TriggerHeadShot +// +// Starts a head one-shot on `buf` from sample 0 and fires the matching +// audio-decision event. Shared by the isolated-bump, ratchet-thunk and +// ratchet-click paths in OnHeadBump. +// +//////////////////////////////////////////////////////////////////////////////// + +void Disk2AudioSource::TriggerHeadShot ( + SoundKind kind, + const vector * buf, + bool previousStillPlaying) +{ + m_headBuf = buf; + m_headPos = 0; if (m_audioEventSink != nullptr) { - if (m_stopBuf.empty()) + if (buf->empty()) { - m_audioEventSink->OnAudioSilent (SoundKind::HeadStop, m_driveIndex, + m_audioEventSink->OnAudioSilent (kind, m_driveIndex, SilentReason::BufferMissing); } else if (previousStillPlaying) { - m_audioEventSink->OnAudioRestarted (SoundKind::HeadStop, m_driveIndex); + m_audioEventSink->OnAudioRestarted (kind, m_driveIndex); } else { - m_audioEventSink->OnAudioStarted (SoundKind::HeadStop, m_driveIndex); + m_audioEventSink->OnAudioStarted (kind, m_driveIndex); } } } diff --git a/CassoEmuCore/Audio/Disk2AudioSource.h b/CassoEmuCore/Audio/Disk2AudioSource.h index e7ba8714..2517ca1b 100644 --- a/CassoEmuCore/Audio/Disk2AudioSource.h +++ b/CassoEmuCore/Audio/Disk2AudioSource.h @@ -112,11 +112,21 @@ class Disk2AudioSource : public IDriveAudioSource bool IsSeekMode() const { return m_seekMode; } uint64_t GetLastStepCycle() const { return m_lastStepCycle; } + // Test introspection for the boot-recalibrate ratchet (see OnHeadBump). + uint32_t GetRatchetSlot() const { return m_ratchetSlot; } + private: void MixMotor (float * out, uint32_t n); void MixHead (float * out, uint32_t n); void MixDoor (float * out, uint32_t n); + // Starts a head one-shot on `buf` and fires the matching audio-event + // (Started / Restarted / Silent). Shared by the bump and ratchet paths. + void TriggerHeadShot ( + SoundKind kind, + const vector * buf, + bool previousStillPlaying); + // Pan (equal-power, precomputed by SetPan). float m_panLeft = IDriveAudioSource::kCenterPan; float m_panRight = IDriveAudioSource::kCenterPan; @@ -154,6 +164,22 @@ class Disk2AudioSource : public IDriveAudioSource uint64_t m_currentCycle = 0; bool m_seekMode = false; + // Boot-recalibrate ratchet. When the head is pinned against the + // track-0 stop, the controller fires a steady ~52 Hz stream of bumps + // (one per phase-on, ~19,690 cycles apart). Rendering every one as a + // HeadStop thunk smears into a continuous "fast buzz". A real Disk II + // recalibrate instead sounds like a slow machine gun: the ratcheting + // mechanism produces a grouped [thunk, pause, click, click] cadence. + // We reproduce that by cycling rapid consecutive bumps through a + // 4-slot pattern -- a thunk, a silent rhythmic pause, then two step + // clicks -- yielding a 2:1 click-to-thunk ratio at ~38 Hz effective. + static constexpr uint32_t kRatchetPeriod = 4; + static constexpr uint32_t kRatchetSlotThunk = 0; + static constexpr uint32_t kRatchetSlotSilent = 1; + + uint32_t m_ratchetSlot = 0; + bool m_lastEventWasBump = false; + // Spec-006 audio-decision sink (FR-022 / FR-025). Optional. IDriveAudioEventSink * m_audioEventSink = nullptr; diff --git a/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.cpp b/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.cpp index 6412909e..08a932d1 100644 --- a/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.cpp +++ b/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.cpp @@ -154,15 +154,18 @@ void Disk2NibbleEngine::SetShiftLoadMode (bool q6) // // SetCurrentTrack // -// Clamps to [0, kMaxTrack]. Track is full-track index (controller maps -// half-tracks -> full tracks). Switching tracks preserves rotational -// position by carrying the bit cursor modulo the new track's bit length. +// Clamps to [kMinTrack, kMaxTrack]. Track is a quarter-track index +// (0..159); the controller passes the head's physical quarter-track +// position. ResolveQuarterTrack maps it to a backing storage slot (-1 == +// unformatted). Switching tracks preserves rotational position by +// carrying the bit cursor modulo the new track's bit length. // //////////////////////////////////////////////////////////////////////////////// void Disk2NibbleEngine::SetCurrentTrack (int track) { - int clamped = track; + int clamped = track; + size_t newBits = 0; if (clamped < kMinTrack) { @@ -187,11 +190,8 @@ void Disk2NibbleEngine::SetCurrentTrack (int track) // RWTS read loop frequently times out and reports a checksum // error. Cap to the new track's bit length so we don't end // up past the wrap. - size_t newBits = (m_disk != nullptr) - ? m_disk->GetTrackBitCount (clamped) - : 0; - m_currentTrack = clamped; + newBits = CurrentTrackBits (); if (newBits > 0) { @@ -207,6 +207,37 @@ void Disk2NibbleEngine::SetCurrentTrack (int track) +//////////////////////////////////////////////////////////////////////////////// +// +// CurrentTrackBits +// +// Bit length of the stream under the head. A resolved slot reports its +// real length; an unformatted position (slot -1) reports the nominal +// blank-track length so the disk keeps spinning and the weak-bit model +// keeps producing noise. +// +//////////////////////////////////////////////////////////////////////////////// + +size_t Disk2NibbleEngine::CurrentTrackBits () const +{ + int slot = (m_disk != nullptr) ? m_disk->ResolveQuarterTrack (m_currentTrack) : -1; + + if (m_disk == nullptr) + { + return 0; + } + + if (slot < 0) + { + return kUnformattedTrackBits; + } + + return m_disk->GetTrackBitCount (slot); +} + + + + //////////////////////////////////////////////////////////////////////////////// // // Reset @@ -283,11 +314,12 @@ void Disk2NibbleEngine::StepLss() uint8_t command = 0; bool prevMsbSet = false; size_t trackBits = 0; + int slot = (m_disk != nullptr) ? m_disk->ResolveQuarterTrack (m_currentTrack) : -1; if (m_lssClock == kLssReadClock) { - uint8_t rawBit = (m_disk != nullptr) - ? m_disk->ReadBit (m_currentTrack, m_bitPos) + uint8_t rawBit = (slot >= 0) + ? m_disk->ReadBit (slot, m_bitPos) : 0; pulse = ApplyHeadWindow (rawBit); @@ -345,14 +377,19 @@ void Disk2NibbleEngine::StepLss() if (m_lssClock == kLssReadClock) { - if (m_writeMode && m_disk != nullptr && !m_disk->IsWriteProtected()) + if (m_writeMode && slot >= 0 && !m_disk->IsWriteProtected()) { uint8_t outBit = static_cast ((m_lssState & kWriteBitMask) ? 1 : 0); - m_disk->WriteBit (m_currentTrack, m_bitPos, outBit); + m_disk->WriteBit (slot, m_bitPos, outBit); } - trackBits = (m_disk != nullptr) ? m_disk->GetTrackBitCount (m_currentTrack) : 0; + trackBits = (slot >= 0) ? m_disk->GetTrackBitCount (slot) : kUnformattedTrackBits; + + if (m_disk == nullptr) + { + trackBits = 0; + } if (trackBits > 0) { diff --git a/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.h b/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.h index 924b27ed..0c12dcc5 100644 --- a/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.h +++ b/CassoEmuCore/Devices/Disk/Disk2NibbleEngine.h @@ -45,7 +45,14 @@ class Disk2NibbleEngine public: static constexpr int kCyclesPerBit = 4; static constexpr int kMinTrack = 0; - static constexpr int kMaxTrack = 39; + static constexpr int kMaxTrack = 159; + + // Nominal bit length of an unformatted quarter-track. When the head + // sits over a position the image holds no data for, the disk still + // spins: the bit cursor advances over this many cells of "no flux" + // (rawBit 0), which the head-window weak-bit model turns into the + // ~30% random stream a real drive reads off blank surface. + static constexpr size_t kUnformattedTrackBits = 51200; Disk2NibbleEngine(); @@ -94,6 +101,7 @@ class Disk2NibbleEngine void StepLss(); uint8_t ApplyHeadWindow (uint8_t inBit); uint8_t NextWeakBit(); + size_t CurrentTrackBits() const; DiskImage * m_disk = nullptr; int m_currentTrack = 0; diff --git a/CassoEmuCore/Devices/Disk/DiskImage.cpp b/CassoEmuCore/Devices/Disk/DiskImage.cpp index 6377725c..249a19c9 100644 --- a/CassoEmuCore/Devices/Disk/DiskImage.cpp +++ b/CassoEmuCore/Devices/Disk/DiskImage.cpp @@ -22,6 +22,113 @@ DiskImage::DiskImage () m_trackBits.resize (kDefaultTrackCount); m_trackBitCounts.resize (kDefaultTrackCount, 0); m_trackDirty.resize (kDefaultTrackCount, false); + InitWholeTrackMap (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// InitWholeTrackMap +// +// Default quarter-track map for sector images: every quarter-track +// resolves to its whole track (qt / 4). WOZ loads overwrite this with the +// image's TMAP so half/quarter-track-formatted tracks resolve to distinct +// storage slots. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskImage::InitWholeTrackMap () +{ + int qt = 0; + + m_quarterTrackMap.assign (kQuarterTrackCount, -1); + + for (qt = 0; qt < kQuarterTrackCount; qt++) + { + m_quarterTrackMap[qt] = qt / kQuarterTracksPerWholeTrack; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ResolveQuarterTrack +// +// Maps a head quarter-track position to its backing storage slot, or -1 +// when the position holds no data (unformatted: an out-of-range slot or a +// zero-length stream). Callers treat -1 as no flux under the head. +// +//////////////////////////////////////////////////////////////////////////////// + +int DiskImage::ResolveQuarterTrack (int quarterTrack) const +{ + int slot = 0; + + if (quarterTrack < 0 || quarterTrack >= static_cast (m_quarterTrackMap.size ())) + { + return -1; + } + + slot = m_quarterTrackMap[quarterTrack]; + + if (slot < 0 || slot >= static_cast (m_trackBitCounts.size ())) + { + return -1; + } + + if (m_trackBitCounts[slot] == 0) + { + return -1; + } + + return slot; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ClearQuarterTrackMap / SetQuarterTrackSlot / EnsureTrackSlots +// +// Bulk-loader surface (WozLoader). ClearQuarterTrackMap marks every +// quarter-track unformatted; SetQuarterTrackSlot points one quarter-track +// at a storage slot; EnsureTrackSlots grows the slot storage to hold at +// least slotCount distinct bit streams. +// +//////////////////////////////////////////////////////////////////////////////// + +void DiskImage::ClearQuarterTrackMap () +{ + m_quarterTrackMap.assign (kQuarterTrackCount, -1); +} + + +void DiskImage::SetQuarterTrackSlot (int quarterTrack, int slot) +{ + if (quarterTrack < 0 || quarterTrack >= static_cast (m_quarterTrackMap.size ())) + { + return; + } + + m_quarterTrackMap[quarterTrack] = slot; +} + + +void DiskImage::EnsureTrackSlots (int slotCount) +{ + if (slotCount <= static_cast (m_trackBits.size ())) + { + return; + } + + m_trackBits.resize (slotCount); + m_trackBitCounts.resize (slotCount, 0); + m_trackDirty.resize (slotCount, false); } @@ -331,6 +438,7 @@ void DiskImage::LoadFromBytes (DiskFormat fmt, const vector & raw, const s m_loaded = false; m_dirty = false; m_rawSourceBytes = raw; + InitWholeTrackMap (); switch (fmt) { @@ -397,6 +505,7 @@ HRESULT DiskImage::Load (const string & filePath) m_dirty = false; m_format = DiskFormat::Dsk; m_rawSourceBytes = move (raw); + InitWholeTrackMap (); Error: return hr; @@ -444,6 +553,7 @@ void DiskImage::Eject () m_trackBits.assign (kDefaultTrackCount, vector ()); m_trackBitCounts.assign (kDefaultTrackCount, 0); m_trackDirty.assign (kDefaultTrackCount, false); + InitWholeTrackMap (); m_loaded = false; m_dirty = false; } diff --git a/CassoEmuCore/Devices/Disk/DiskImage.h b/CassoEmuCore/Devices/Disk/DiskImage.h index 16175593..91f389ff 100644 --- a/CassoEmuCore/Devices/Disk/DiskImage.h +++ b/CassoEmuCore/Devices/Disk/DiskImage.h @@ -30,6 +30,8 @@ class DiskImage : public IDiskImage static constexpr int kDefaultTrackCount = 35; static constexpr size_t kDefaultTrackByteSize = 6400; static constexpr size_t kDos33ImageSize = 143360; + static constexpr int kQuarterTrackCount = 160; + static constexpr int kQuarterTracksPerWholeTrack = 4; DiskImage (); @@ -54,6 +56,17 @@ class DiskImage : public IDiskImage void ClearDirty (); void ResizeTrack (int track, size_t bitCount); + // Quarter-track addressing. The head physically steps in quarter-track + // increments (0..159); ResolveQuarterTrack maps a head position to the + // backing storage slot that holds its bit stream (-1 == unformatted / + // no data). Standard sector images map every quarter-track to its whole + // track (qt / 4); WOZ images install an explicit map from the TMAP so + // half/quarter-track-formatted protections resolve to distinct streams. + int ResolveQuarterTrack (int quarterTrack) const; + void ClearQuarterTrackMap (); + void SetQuarterTrackSlot (int quarterTrack, int slot); + void EnsureTrackSlots (int slotCount); + // Direct bit-buffer access for bulk writers (NibblizationLayer, WozLoader). // ResizeTrack must be called first; the returned buffer length matches the // packed-byte size for the track. Bypasses write-protect. @@ -68,11 +81,13 @@ class DiskImage : public IDiskImage private: HRESULT LoadDsk (const vector & raw); + void InitWholeTrackMap (); string m_filePath; vector> m_trackBits; vector m_trackBitCounts; vector m_trackDirty; + vector m_quarterTrackMap; DiskFormat m_format = DiskFormat::Dsk; bool m_loaded = false; bool m_dirty = false; diff --git a/CassoEmuCore/Devices/Disk/WozLoader.cpp b/CassoEmuCore/Devices/Disk/WozLoader.cpp index e938fa4a..da8f07ce 100644 --- a/CassoEmuCore/Devices/Disk/WozLoader.cpp +++ b/CassoEmuCore/Devices/Disk/WozLoader.cpp @@ -231,7 +231,6 @@ HRESULT WozLoader::Load (const vector & raw, DiskImage & out) const Byte * trksData = nullptr; size_t trksSize = 0; int qt = 0; - int destTrack = 0; Byte trackIndex = 0; int trackI = 0; @@ -326,64 +325,68 @@ HRESULT WozLoader::Load (const vector & raw, DiskImage & out) out.SetWriteProtected (writeProtected); out.SetSourceFormat (DiskFormat::Woz); + out.ClearQuarterTrackMap (); + + { + int maxSlot = -1; + + for (qt = 0; qt < static_cast (kTmapChunkSize); qt++) + { + if (tmap[qt] != kTmapEmptyTrack && tmap[qt] > maxSlot) + { + maxSlot = tmap[qt]; + } + } + + out.EnsureTrackSlots (maxSlot + 1); + } if (isV2) { + vector parsed (kV2TrkRecordCount, false); + if (trksSize < kV2TrkRecordCount * kV2TrkRecordSize) { hr = E_FAIL; goto Error; } - for (qt = 0; qt < kTmapChunkSize; qt++) + for (qt = 0; qt < static_cast (kTmapChunkSize); qt++) { trackIndex = tmap[qt]; - if (trackIndex == kTmapEmptyTrack) - { - continue; - } - - destTrack = qt / kQuarterTracksPerTrack; - if (destTrack >= kMaxTracks) + if (trackIndex == kTmapEmptyTrack || trackIndex >= kV2TrkRecordCount) { continue; } - if ((qt % kQuarterTracksPerTrack) != 0) + if (!parsed[trackIndex]) { - continue; - } + HRESULT hrTrack = ParseV2Track ( + raw, + trksData + static_cast (trackIndex) * kV2TrkRecordSize, + trackIndex, + out); - HRESULT hrTrack = ParseV2Track ( - raw, - trksData + static_cast (trackIndex) * kV2TrkRecordSize, - destTrack, - out); + if (FAILED (hrTrack)) + { + hr = hrTrack; + goto Error; + } - if (FAILED (hrTrack) && destTrack < out.GetTrackCount ()) - { - hr = hrTrack; - goto Error; + parsed[trackIndex] = true; } + + out.SetQuarterTrackSlot (qt, trackIndex); } } else { - for (qt = 0; qt < kTmapChunkSize; qt++) + vector parsed (kV2TrkRecordCount, false); + + for (qt = 0; qt < static_cast (kTmapChunkSize); qt++) { trackIndex = tmap[qt]; - if (trackIndex == kTmapEmptyTrack) - { - continue; - } - - destTrack = qt / kQuarterTracksPerTrack; - if (destTrack >= kMaxTracks) - { - continue; - } - - if ((qt % kQuarterTracksPerTrack) != 0) + if (trackIndex == kTmapEmptyTrack || trackIndex >= kV2TrkRecordCount) { continue; } @@ -397,8 +400,14 @@ HRESULT WozLoader::Load (const vector & raw, DiskImage & out) goto Error; } - ParseV1Track (trksData + recOffset, destTrack, out); + if (!parsed[trackIndex]) + { + ParseV1Track (trksData + recOffset, trackIndex, out); + parsed[trackIndex] = true; + } } + + out.SetQuarterTrackSlot (qt, trackIndex); } } diff --git a/CassoEmuCore/Devices/Disk2Controller.cpp b/CassoEmuCore/Devices/Disk2Controller.cpp index 7d6ab4f6..12e7da17 100644 --- a/CassoEmuCore/Devices/Disk2Controller.cpp +++ b/CassoEmuCore/Devices/Disk2Controller.cpp @@ -325,14 +325,42 @@ Byte Disk2Controller::HandleReadDispatch() // // HandlePhase // -// Update the phase mask, walk the head one quarter-track toward the -// newly-energized phase, clamp to legal range, then push the resulting -// full-track index into the active drive's engine. +// Update the phase mask, walk the head toward the newly-energized phase, +// clamp to legal range, then push the resulting quarter-track index into +// the active drive's engine. // //////////////////////////////////////////////////////////////////////////////// void Disk2Controller::HandlePhase (int phase, bool on) { + // Stepper model ported clean-room from apple2js (MIT), disk2.ts + // setPhase + PHASE_DELTA. UTAIIe ch. 9 / Sather p. 9-12. + // + // The cog tracks the last-energized phase magnet (m_phase), NOT a + // position-derived magnet index. On each phase-ON event the head + // moves PHASE_DELTA[m_phase][phase] half-tracks (= *2 quarter-tracks) + // toward the newly-energized magnet, then remembers the new phase. + // Phase-OFF events do not move the head -- the cog holds its detent. + // + // Because every delta is an even number of quarter-tracks, a normal + // two-phase DOS step lands the head on a whole- or half-track detent + // (quarter-track index even); it can never get marooned on an odd + // quarter-track between detents. The previous position-derived model + // (`(m_quarterTrack / 2) & 3`) could not distinguish qt N from qt N+1 + // and would leave the head stuck one quarter-track off a real track, + // serving unformatted noise on narrow-band protected disks. + static constexpr int kPhaseDelta[kPhaseCount][kPhaseCount] = + { + { 0, 1, 2, -1 }, + { -1, 0, 1, 2 }, + { -2, -1, 0, 1 }, + { 1, -2, -1, 0 }, + }; + + int prevQt = m_quarterTrack; + int qtDelta = 0; + int postRaw = prevQt; + if (on) { m_phases = static_cast (m_phases | (1 << phase)); @@ -342,50 +370,13 @@ void Disk2Controller::HandlePhase (int phase, bool on) m_phases = static_cast (m_phases & ~(1 << phase)); } - // Disk II stepper model (UTAIIe ch. 9; AppleWin ControlStepperDeferred): - // - 4 phase magnets arranged 90 degrees apart around the cog. - // - The head's rotational position (which magnet it's nearest) - // cycles every full track. Casso represents position as a - // quarter-track count (0..kMaxQuarterTrack); two consecutive - // quarter-tracks lie under different magnet positions, so - // `(m_quarterTrack / 2) & 3` is the current "phase index" the - // head is nearest. - // - Movement is determined by which adjacent magnets (rot+/-1) - // are currently energized. A single adjacent magnet pulls the - // head one half-track toward it (= 2 quarter-tracks). Two - // adjacent magnets ($3=0+1, $6=1+2, $C=2+3, $9=3+0) pull the - // head only halfway, i.e. one quarter-track (the cog rests - // between the two magnet positions). - // - Opposing-only magnet pairs ($5=0+2, $A=1+3) cancel out and - // leave the head where it is. - // - // The previous "highest set bit" model only stepped by one quarter- - // track per phase event regardless of the magnet topology, which - // walked the head ~1.5x too fast on standard DOS step sequences and - // dropped multi-track seeks intermittently. - int rot = (m_quarterTrack / 2) & 3; - int direction = 0; - - if (m_phases & (1 << ((rot + 1) & 3))) - { - direction += 1; - } - if (m_phases & (1 << ((rot + 3) & 3))) - { - direction -= 1; - } - - int qtDelta = direction * 2; - - if (m_phases == 0x3 || m_phases == 0x6 || - m_phases == 0xC || m_phases == 0x9) + if (on) { - qtDelta = direction; + qtDelta = kPhaseDelta[m_phase][phase] * 2; + m_phase = phase; } - int prevQt = m_quarterTrack; - int postRaw = prevQt + qtDelta; - + postRaw = prevQt + qtDelta; m_quarterTrack += qtDelta; if (m_quarterTrack < 0) @@ -398,7 +389,7 @@ void Disk2Controller::HandlePhase (int phase, bool on) m_quarterTrack = kMaxQuarterTrack; } - m_engine[m_activeDrive].SetCurrentTrack (m_quarterTrack / 4); + m_engine[m_activeDrive].SetCurrentTrack (m_quarterTrack); // Audio sink (FR-003 / FR-004). Fire only when the head actually // moved (qtDelta != 0). Distinguish a normal step from a track-0 / @@ -460,7 +451,7 @@ void Disk2Controller::UpdateEngineSelection() m_engine[other].SetMotorOn (false); m_engine[m_activeDrive].SetMotorOn (m_motorOn); - m_engine[m_activeDrive].SetCurrentTrack (m_quarterTrack / 4); + m_engine[m_activeDrive].SetCurrentTrack (m_quarterTrack); m_engine[m_activeDrive].SetShiftLoadMode (m_q6); m_engine[m_activeDrive].SetWriteMode (m_q7); } @@ -746,6 +737,7 @@ void Disk2Controller::Reset() int i = 0; m_phases = 0; + m_phase = 0; m_quarterTrack = 0; m_motorOn = false; m_motorSpindownCycles = 0; diff --git a/CassoEmuCore/Devices/Disk2Controller.h b/CassoEmuCore/Devices/Disk2Controller.h index 58f04697..599fc636 100644 --- a/CassoEmuCore/Devices/Disk2Controller.h +++ b/CassoEmuCore/Devices/Disk2Controller.h @@ -144,6 +144,7 @@ class Disk2Controller : public MemoryDevice Word m_ioEnd; uint8_t m_phases = 0; + int m_phase = 0; int m_quarterTrack = 0; bool m_motorOn = false; diff --git a/README.md b/README.md index fc08506e..2476cc7f 100644 --- a/README.md +++ b/README.md @@ -207,16 +207,16 @@ All 56 standard 6502 mnemonics are implemented. Validated against [Klaus Dormann - [x] Headless test harness for deterministic integration tests (`HeadlessHost`, framebuffer scraper, keyboard injector) - [x] Performance gate — emulator throughput budget enforced in CI (Release-only) - [x] Cycle-accurate execution and profiling ([#57](https://github.com/relmer/Casso/issues/57)) +- [x] Disk II copy-protection fidelity — motor spin-up delay, MC3470 weak-bit emulation, real 16-state LSS, quarter-track read pipeline, and bit-level write path ([#67](https://github.com/relmer/Casso/issues/67)) +- [x] Boot *Karateka* from its WOZ image (RWTS18 copy protection) ([#68](https://github.com/relmer/Casso/issues/68)) +- [x] Boot *Lode Runner* from its WOZ image (copy protection) ([#70](https://github.com/relmer/Casso/issues/70)) ### Medium Priority - [ ] 65C02 extended instruction support, with assembler `--cpu` flag ([#9](https://github.com/relmer/Casso/issues/9)) - [ ] Undocumented / illegal opcode support ([#52](https://github.com/relmer/Casso/issues/52)) - [ ] Rockwell / WDC 65C02 variants ([#49](https://github.com/relmer/Casso/issues/49), [#50](https://github.com/relmer/Casso/issues/50)) -- [ ] Disk II copy-protection fidelity — spin-up delay, cycle-accurate rotational position, bit-level write path ([#67](https://github.com/relmer/Casso/issues/67)) -- [ ] Boot *Karateka* from its WOZ image (RWTS18 copy protection) ([#68](https://github.com/relmer/Casso/issues/68)) -- [ ] *Choplifter* gameplay starts after the title screen (WOZ copy protection) ([#69](https://github.com/relmer/Casso/issues/69)) -- [ ] Boot *Lode Runner* from its WOZ image (copy protection) ([#70](https://github.com/relmer/Casso/issues/70)) +- [ ] *Choplifter* gameplay starts after the title screen (WOZ copy protection) — boots; needs joystick/button input ([#69](https://github.com/relmer/Casso/issues/69), [#72](https://github.com/relmer/Casso/issues/72)) ### Low Priority diff --git a/UnitTest/Audio/Disk2AudioSourceEventSinkTests.cpp b/UnitTest/Audio/Disk2AudioSourceEventSinkTests.cpp index 17d0afdd..f37af0e4 100644 --- a/UnitTest/Audio/Disk2AudioSourceEventSinkTests.cpp +++ b/UnitTest/Audio/Disk2AudioSourceEventSinkTests.cpp @@ -414,4 +414,105 @@ TEST_CLASS (Disk2AudioSourceEventSinkTests) Assert::IsTrue (out[0] > 0.0f, L"Motor audible again after re-insert"); } + + + //////////////////////////////////////////////////////////////////// + // + // Boot-recalibrate ratchet -- a rapid run of consecutive head bumps + // (head pinned at the track-0 stop) renders as a grouped + // [thunk, pause, click, click] cadence rather than a steady stream + // of identical thunks (the "fast buzz" regression). Models the real + // controller stream: an isolated first bump, then bumps every + // ~19,690 cycles -- comfortably inside kHeadIdleCycles (51,150). + // + //////////////////////////////////////////////////////////////////// + + TEST_METHOD (RapidBumpBurst_rendersMachineGunRatchetNotSteadyThunks) + { + Disk2AudioSource src; + RecordingAudioEventSink sink; + uint64_t cycle = 236309; + + src.SetSampleBufferForTest (L"HeadStep", vector (1024, 0.5f)); + src.SetSampleBufferForTest (L"HeadStop", vector (1024, 0.9f)); + src.SetAudioEventSink (&sink); + + // Nine consecutive bumps, each 19,690 cycles after the previous. + for (int n = 0; n < 9; n++) + { + src.Tick (cycle); + src.OnHeadBump(); + cycle += 19690; + } + + // Two of the nine bumps land on the silent ratchet slot, so only + // seven produce an audio onset. The expected sound sequence is + // [thunk, click, click, thunk, click, click, thunk]. + SoundKind expected[7] = + { + SoundKind::HeadStop, + SoundKind::HeadStep, + SoundKind::HeadStep, + SoundKind::HeadStop, + SoundKind::HeadStep, + SoundKind::HeadStep, + SoundKind::HeadStop, + }; + + Assert::AreEqual (size_t (7), sink.log.size()); + + for (int i = 0; i < 7; i++) + { + Assert::IsTrue (sink.log[i].sound == expected[i]); + } + } + + TEST_METHOD (IsolatedBumpsFarApart_eachRenderAsFirmThunk) + { + Disk2AudioSource src; + RecordingAudioEventSink sink; + + src.SetSampleBufferForTest (L"HeadStep", vector (16, 0.5f)); + src.SetSampleBufferForTest (L"HeadStop", vector (16, 0.9f)); + src.SetAudioEventSink (&sink); + + // Two bumps separated by more than kHeadIdleCycles (51,150) are + // not a ratchet -- each is a discrete wall-bang thunk. + src.Tick (100000); + src.OnHeadBump(); + src.Tick (100000 + 60000); + src.OnHeadBump(); + + Assert::AreEqual (size_t (2), sink.log.size()); + Assert::IsTrue (sink.log[0].sound == SoundKind::HeadStop); + Assert::IsTrue (sink.log[1].sound == SoundKind::HeadStop); + } + + TEST_METHOD (StepBetweenBumps_reArmsRatchetSoNextBumpIsThunk) + { + Disk2AudioSource src; + RecordingAudioEventSink sink; + + src.SetSampleBufferForTest (L"HeadStep", vector (16, 0.5f)); + src.SetSampleBufferForTest (L"HeadStop", vector (16, 0.9f)); + + // Drive a short ratchet so the slot counter advances off zero. + src.Tick (100000); + src.OnHeadBump(); + src.Tick (119690); + src.OnHeadBump(); + + // A real step (head left the wall) must re-arm the pattern. + src.Tick (139380); + src.OnHeadStep (1); + Assert::AreEqual (uint32_t (0), src.GetRatchetSlot()); + + // The next bump after the step is therefore an isolated thunk. + src.SetAudioEventSink (&sink); + src.Tick (159070); + src.OnHeadBump(); + + Assert::AreEqual (size_t (1), sink.log.size()); + Assert::IsTrue (sink.log[0].sound == SoundKind::HeadStop); + } }; diff --git a/UnitTest/EmuTests/Disk2NibbleEngineTests.cpp b/UnitTest/EmuTests/Disk2NibbleEngineTests.cpp index 48769972..7283248f 100644 --- a/UnitTest/EmuTests/Disk2NibbleEngineTests.cpp +++ b/UnitTest/EmuTests/Disk2NibbleEngineTests.cpp @@ -410,4 +410,189 @@ TEST_CLASS (Disk2NibbleEngineTests) L"Formatted-track latch sequence must be deterministic across engines"); } } + + + //////////////////////////////////////////////////////////////////////////// + // + // Self-sync framing helpers + // + // Lay down genuine 10-bit self-sync FF bytes (1111111100), the gap + // pattern DOS 3.3 / ProDOS write between fields. Unlike an all-ones + // stream, the two trailing zeros force the LSS through its re-align + // path every byte -- the mechanism that makes every reader converge + // on the same byte framing regardless of where it started reading. + // + //////////////////////////////////////////////////////////////////////////// + + static void WriteSelfSyncByte (DiskImage & img, size_t & bitPos) + { + int i = 0; + + for (i = 0; i < 8; i++) + { + img.WriteBit (0, bitPos++, 1); + } + + img.WriteBit (0, bitPos++, 0); + img.WriteBit (0, bitPos++, 0); + } + + + TEST_METHOD (SelfSyncStreamFramesAsFFWithoutDrift) + { + // Port of apple2js disk2.spec.ts "reads an FF sync byte" / + // "reads several FF sync bytes". A run of 10-bit self-sync FF + // bytes must decode as a steady stream of 0xFF nibbles. Any + // dropped or doubled bit cell would show up as a non-FF nibble + // once the framing slipped -- the exact failure signature seen + // when a protected loader stalls hunting for a prologue. + DiskImage img; + Disk2NibbleEngine eng; + const int kSyncBytes = 64; + size_t bitPos = 0; + int b = 0; + int t = 0; + int freshReads = 0; + int nonFF = 0; + uint8_t nib = 0; + + img.ResizeTrack (0, (size_t) kSyncBytes * 10); + + for (b = 0; b < kSyncBytes; b++) + { + WriteSelfSyncByte (img, bitPos); + } + + eng.SetDiskImage (&img); + eng.SetMotorOn (true); + + + + // Tick one bit cell at a time across several full revolutions, + // harvesting every freshly assembled nibble. Skip the first few + // assemblies while the sequencer locks onto self-sync. + for (t = 0; t < kSyncBytes * 10 * 4; t++) + { + eng.Tick (Disk2NibbleEngine::kCyclesPerBit); + + if (eng.ConsumeFreshNibble (nib)) + { + freshReads++; + + if (freshReads > 4 && nib != 0xFF) + { + nonFF++; + } + } + } + + Assert::IsTrue (freshReads > 16, + L"Self-sync stream must assemble a steady run of nibbles"); + Assert::AreEqual (0, nonFF, + L"Every framed self-sync nibble must be 0xFF -- no bit-slip drift"); + } + + + static void WriteDataByte (DiskImage & img, size_t & bitPos, uint8_t value) + { + int i = 0; + + for (i = 0; i < 8; i++) + { + img.WriteBit (0, bitPos++, (uint8_t) ((value >> (7 - i)) & 1)); + } + } + + + static void WriteZeroRun (DiskImage & img, size_t & bitPos, int count) + { + int i = 0; + + for (i = 0; i < count; i++) + { + img.WriteBit (0, bitPos++, 0); + } + } + + + TEST_METHOD (LssReSyncsAfterLongZeroGap) + { + // The boundary case protected loaders depend on: a long zero + // run (an intentional weak-bit / "fake bit" region, exactly the + // 234 runs of 4+ zeros measured on Choplifter track 0) is + // immediately followed by self-sync and a fresh prologue. The + // weak region randomizes, but self-sync is self-correcting: + // once enough FF sync bytes pass, framing MUST re-lock so the + // post-gap D5 AA 96 prologue decodes cleanly. If the LSS could + // not re-lock after a gap, the prologue after every weak region + // would be unreadable -- which is what a stalled loader looks + // like. + DiskImage img; + Disk2NibbleEngine eng; + const int kLeadSync = 24; + const int kGapZeros = 200; + const int kTailSync = 32; + size_t bitPos = 0; + int b = 0; + int t = 0; + int prologues = 0; + uint8_t nib = 0; + std::vector harvested; + + img.ResizeTrack (0, 4096); + + for (b = 0; b < kLeadSync; b++) + { + WriteSelfSyncByte (img, bitPos); + } + + WriteDataByte (img, bitPos, 0xD5); + WriteDataByte (img, bitPos, 0xAA); + WriteDataByte (img, bitPos, 0x96); + + WriteZeroRun (img, bitPos, kGapZeros); + + for (b = 0; b < kTailSync; b++) + { + WriteSelfSyncByte (img, bitPos); + } + + WriteDataByte (img, bitPos, 0xD5); + WriteDataByte (img, bitPos, 0xAA); + WriteDataByte (img, bitPos, 0x96); + + img.SetTrackBitCount (0, bitPos); + + eng.SetDiskImage (&img); + eng.SetMotorOn (true); + + + + harvested.reserve (1024); + + for (t = 0; t < (int) bitPos * 3; t++) + { + eng.Tick (Disk2NibbleEngine::kCyclesPerBit); + + if (eng.ConsumeFreshNibble (nib)) + { + harvested.push_back (nib); + } + } + + for (b = 0; b + 2 < (int) harvested.size (); b++) + { + if (harvested[b] == 0xD5 && harvested[b + 1] == 0xAA && harvested[b + 2] == 0x96) + { + prologues++; + } + } + + // Both prologues -- the one before the gap and the one after -- + // must frame. Across ~3 revolutions each is seen multiple times; + // the floor of 2 proves the post-gap prologue re-locked at least + // once rather than being lost to permanent bit-slip. + Assert::IsTrue (prologues >= 2, + L"LSS must re-lock self-sync framing after a long zero/weak gap"); + } }; diff --git a/UnitTest/EmuTests/Disk2Tests.cpp b/UnitTest/EmuTests/Disk2Tests.cpp index e099c28c..c982582a 100644 --- a/UnitTest/EmuTests/Disk2Tests.cpp +++ b/UnitTest/EmuTests/Disk2Tests.cpp @@ -130,6 +130,50 @@ TEST_CLASS (Disk2Tests) L"Phase magnet stepping must move the head"); } + //////////////////////////////////////////////////////////////////////// + // Stepper guard: a realistic single-phase-at-a-time DOS seek must + // advance exactly one half-track (2 quarter-tracks) per phase + // energize and leave the head on an EVEN quarter-track detent after + // every step. + // + // Regression: the prior position-derived stepper could land the + // head on an ODD quarter-track between detents (e.g. qt25 for a + // track-6 seek) where narrow-band protected disks store no flux, + // serving unformatted noise and stalling the loader. The apple2js + // PHASE_DELTA model moves an even number of quarter-tracks per + // energize, so an odd resting position is unreachable. + //////////////////////////////////////////////////////////////////////// + + TEST_METHOD (PhaseSeekAlwaysRestsOnEvenQuarterTrack) + { + unique_ptr disk = make_unique (6); + int step = 0; + + disk->Read (kMotorOn); + + // From the track-0 detent (phase 0 energized last) an outward + // seek walks phases 1,2,3,0,1,2,3,0 -- eight half-steps == four + // whole tracks. + for (step = 0; step < 8; step++) + { + int phase = (step + 1) & 3; + Word onAddr = static_cast (kSlot6Base + 1 + phase * 2); + Word offAddr = static_cast (kSlot6Base + phase * 2); + int beforeQt = disk->GetQuarterTrack (); + + disk->Read (onAddr); + disk->Read (offAddr); + + Assert::AreEqual (beforeQt + 2, disk->GetQuarterTrack (), + L"Each phase energize must advance exactly one half-track"); + Assert::AreEqual (0, disk->GetQuarterTrack () & 1, + L"Head must rest on an even quarter-track after every step"); + } + + Assert::AreEqual (16, disk->GetQuarterTrack (), + L"Eight half-steps must land the head at quarter-track 16 (track 4)"); + } + TEST_METHOD (MotorOnOffViaC0E8C0E9) { unique_ptr disk = make_unique (6); diff --git a/UnitTest/EmuTests/GameBootTests.cpp b/UnitTest/EmuTests/GameBootTests.cpp index 6f610fff..0bcb3046 100644 --- a/UnitTest/EmuTests/GameBootTests.cpp +++ b/UnitTest/EmuTests/GameBootTests.cpp @@ -211,44 +211,47 @@ TEST_CLASS (GameBootTests) //////////////////////////////////////////////////////////////////////// // Choplifter (1982, Broderbund). Standard DOS 3.3 boot with light - // copy-protection. Loader sweeps tracks 0-22 to stage the game. - // Strong "actually loading content" signal: >= 10 distinct tracks. + // copy-protection that formats outer tracks (12+) on half-track + // boundaries. The loader sweeps the disk to stage the game; with the + // quarter-track pipeline and apple2js stepper model it reaches the + // protected half-track region well past track 12. Strong "actually + // loading content" signal: >= 20 distinct tracks. //////////////////////////////////////////////////////////////////////// TEST_METHOD (Choplifter_WozBoot_HeadVisitsContentTracks) { - AssertGameBoots ("Apple2/Demos/Choplifter.woz", L"Choplifter", 10); + AssertGameBoots ("Apple2/Demos/Choplifter.woz", L"Choplifter", 20); } //////////////////////////////////////////////////////////////////////// // Karateka (1984, Broderbund). Aggressive copy-protection: nibble // counts, non-standard sync patterns, and the Jordan-Mechner-era - // RWTS18 trickery. Full boot is a future goal -- this test - // currently only proves Disk II plumbing reaches the loader far - // enough to attempt protection checks (motor, bit cursor, head - // movement off track 0). Empirically the head walks 3 tracks - // within the cycle budget before the protection check stalls - // the loader; weak-bit emulation alone is not sufficient to - // push past the check. Tracked for a follow-up issue. + // RWTS18 trickery. With the quarter-track pipeline and apple2js + // stepper model the loader now clears the protection checks, sweeps + // the disk to stage the game, and turns the motor off once the load + // completes -- the same boot signature as Choplifter. The head walks + // well past the old 3-track protection stall (empirically ~14 distinct + // tracks up to track 32), so require >= 10 as a "really loading" signal. //////////////////////////////////////////////////////////////////////// - TEST_METHOD (Karateka_WozBoot_LoaderReachesProtectionChecks) + TEST_METHOD (Karateka_WozBoot_HeadVisitsContentTracks) { - AssertGameBoots ("Apple2/Demos/Karateka.woz", L"Karateka", 3); + AssertGameBoots ("Apple2/Demos/Karateka.woz", L"Karateka", 10); } //////////////////////////////////////////////////////////////////////// - // Lode Runner (1983, Broderbund). Also stalls in protection like - // Karateka; head reaches 4 distinct tracks before the loader's - // check fails. Strong evidence the two games depend on the same - // missing fidelity piece (track-seam jitter? big-skip jitter? - // some other LSS detail). Follow-up issue tracks the dig. + // Lode Runner (1983, Broderbund). Same copy-protection family as + // Karateka. Previously stalled at 4 tracks; with the quarter-track + // pipeline + apple2js stepper it now boots, sweeping the disk and + // reaching the high outer tracks (empirically ~21 distinct tracks up + // to track 33) before handing off with the motor off. Require >= 15 + // distinct tracks as the "really loading" signal. //////////////////////////////////////////////////////////////////////// - TEST_METHOD (LodeRunner_WozBoot_LoaderReachesProtectionChecks) + TEST_METHOD (LodeRunner_WozBoot_HeadVisitsContentTracks) { - AssertGameBoots ("Apple2/Demos/LodeRunner.woz", L"Lode Runner", 4); + AssertGameBoots ("Apple2/Demos/LodeRunner.woz", L"Lode Runner", 15); } }; diff --git a/specs/012-disk2-debug-telemetry/spec.md b/specs/012-disk2-debug-telemetry/spec.md new file mode 100644 index 00000000..d63c8ce9 --- /dev/null +++ b/specs/012-disk2-debug-telemetry/spec.md @@ -0,0 +1,367 @@ +# Feature Specification: Disk II Debug Telemetry — Head-Resolution & Read-Stall Diagnostics + +**Feature Branch**: `012-disk2-debug-telemetry` +**Created**: 2026-05-30 +**Status**: Draft (deferred — do not implement until coordinated with spec 011) +**Input**: User description: "What data should we have added to the Disk II debug window to have caught the half-track truncation bug earlier? Capture four enhancements — (1) flag commanded-vs-resolved head-position discrepancies as errors with a new severity column, (2) surface the TMAP/resolved-track lookup and actively diagnose dropped half-track data rather than leaving it to the user, (3) a read-stall event when the loader spins with no address mark, (4) a raw nibble peek under the head — and defer them to a spec to avoid colliding with the other CLI instance currently editing the debug dialog." + +## Background & Motivation *(non-normative)* + +Choplifter (#69) hangs on "track 12". A multi-session investigation eventually +traced this to a **whole-track-only disk pipeline** colliding with Choplifter's +**half-track-formatted outer tracks** (its WOZ TMAP routes the real outer-track +data — TRKS 13, 15, 17, … — to the `.5` quarter-track positions, e.g. qt50 = +track 12.50 → TRKS 13). Two truncation points silently discarded that data: +`WozLoader` dropped every non-whole-track TMAP entry, and `Disk2Controller` +quantized the head with `m_quarterTrack / 4`. + +Crucially, the Disk II Debug window (spec 006) **already recorded the raw +signal** — `HeadStep` events carry quarter-track `prevQt`/`newQt`, so the loader +stepping to qt50 was right there in the log. What the window never showed was +the **resolution result**: that qt48 and qt50 both served the *same* bitstream +(TRKS 12), when the TMAP says qt50 should serve TRKS 13. Nothing contrasted the +*commanded* head position against the *resolved* track. This spec adds the +telemetry that would have turned a multi-session hunt into a glance. + +This feature is a **diagnostic-fidelity** add-on to spec 006; it does not change +emulation behavior. It is most useful *after* the quarter-track resolution fix +(tracked separately under #67); several requirements below describe behavior in +both the pre-fix (truncating) and post-fix (quarter-track-aware) worlds. + +## Coordination constraint *(normative)* + +The Disk II Debug dialog surface (`Casso/Disk2DebugDialog.*`, +`Disk2DebugDialogState.*`, `Disk2EventDisplay.*`) is being actively modified by a +separate effort (spec 011, native-dialogs-completion). To avoid merge conflicts: + +- **FR-C1**: This feature MUST NOT be implemented until spec 011's dialog edits + have landed on `master`. Until then this document is a captured backlog item. +- **FR-C2**: New event types and payloads (the emulation-core side: + `Disk2Event.h`, `Disk2Controller`, `Disk2NibbleEngine`, `WozLoader`, + `DiskImage`, the `IDisk2EventSink` interface) MAY be designed and even + implemented independently of the dialog, since they live in CassoEmuCore and + do not touch the Win32 surface. The dialog-side rendering (new column, icons, + formatters, filters) MUST be sequenced after spec 011. + +## Clarifications + +### Session 2026-05-30 + +- Q: Should a commanded-vs-resolved head-position discrepancy be surfaced as an + error? → A: Yes. Introduce a new **Severity** column (Info / Warning / Error) + with a matching status icon. A discrepancy between the commanded quarter-track + and the track the engine actually serves is an **Error**-severity row. +- Q: For the "unformatted / dropped half-track data" case, should the window just + show an FF/unformatted flag, or actually diagnose the data loss? → A: Actively + diagnose it. The tooling MUST detect — without relying on the user to + eyeball — when a mounted WOZ carries distinct data at quarter-track positions + the current pipeline cannot address, and emit an explicit diagnostic naming the + affected tracks. Bonus over a bare FF indicator. +- Q: Read-stall event and raw nibble peek? → A: Both approved as specified. +- Q: The boot-recalibrate audio ratchet (Disk2AudioSource) deliberately + suppresses the audio onset on some head bumps (the silent slot of its + `[thunk, pause, click, click]` pattern), so the window shows a bare + `Head bump` row with no accompanying audio-decision event. Should the + window distinguish a bump that voiced a sound from one whose audio was + intentionally suppressed? → A: Yes. A bump-without-audio currently reads + like a dropped/missing event. The window MUST identify suppressed audio + events explicitly (Info severity) so an intentionally silent ratchet slot + is visibly distinct from an audio failure. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Resolved-track telemetry exposes a head-resolution bug at a glance (Priority: P1) + +A developer is investigating why a WOZ image hangs partway through loading. They +open **View → Disk II Debug…**, mount the image, and cold-boot. As the loader +steps the head, each `HeadStep` row now shows not just the commanded quarter- +track but the **resolved** mapping the engine used: `qt → TMAP[qt] → TRKS idx +(bitCount)`. When the loader steps to a half-track whose data the pipeline cannot +reach, the row resolves to the *wrong* TRKS record (or an unformatted slot), and +the contrast between commanded and resolved position is immediately visible. With +the discrepancy-detection of User Story 2, that row is additionally flagged +Error-severity. + +**Why this priority**: This is the single datum whose absence cost multiple +sessions of investigation. It is the feature's core reason to exist. + +**Independent Test**: Mount a half-track-formatted WOZ (e.g. Choplifter), boot, +and confirm that as the head reaches the half-track region the debug log shows +distinct commanded and resolved track values for the `.5` positions. Confirm a +standard DOS 3.3 disk shows commanded == resolved for every step. + +**Acceptance Scenarios**: + +1. **Given** a mounted WOZ and the debug window open, **When** the head steps to + quarter-track `qt`, **Then** the resulting `HeadStep` (or a new + `HeadResolve`) row MUST display the commanded position (`qtN`, rendered as a + fractional track e.g. `12.50`), the TMAP entry for `qt` (TRKS index or + `FF`/unformatted), and the bit length of the served bitstream. +2. **Given** a standard whole-track disk, **When** the head settles on any track, + **Then** the commanded and resolved track MUST be equal for every step (no + spurious discrepancy rows). +3. **Given** a half-track-formatted disk on the pre-fix (truncating) pipeline, + **When** the head steps to a `.5` position carrying distinct data, **Then** + the resolved TRKS index MUST differ from what the TMAP assigns to that + quarter-track (exposing the truncation). + +--- + +### User Story 2 — Severity column flags commanded-vs-resolved discrepancies as errors (Priority: P1) + +The developer wants problems to *announce themselves*, not require reading every +row. A new **Severity** column (leftmost or adjacent to Event) classifies each +row Info / Warning / Error with a matching icon. Routine events are Info. A +head-resolution discrepancy — commanded quarter-track resolves to a track the +TMAP did not assign to it, or to an unformatted slot when the loader expected +data — is an **Error**. The developer can filter to Error+Warning only and +instantly see the failing seek. + +**Why this priority**: Turns the resolved-track datum (US1) from "available if +you look" into "impossible to miss." Pairs with US1 as the MVP. + +**Independent Test**: Boot a half-track WOZ on the truncating pipeline and verify +an Error-severity row appears at the first `.5`-position seek; filter to +Error-only and confirm the failing seeks are isolated. Boot a standard disk and +verify zero Error/Warning rows. + +**Acceptance Scenarios**: + +1. **Given** any controller/audio event with no anomaly, **When** it is logged, + **Then** its Severity MUST be Info with the Info icon. +2. **Given** a head step whose commanded quarter-track resolves to a TRKS record + the TMAP did not assign to that quarter-track, **When** it is logged, **Then** + its Severity MUST be Error with the Error icon and a Detail string naming both + the commanded and resolved positions. +3. **Given** the Severity filter set to "Error and Warning only", **When** the + projection rebuilds, **Then** only rows at those severities are shown + (consistent with spec 006's projection-not-drop filter model). +4. **Given** the Severity column, **When** the user sorts/auto-sizes columns, + **Then** it behaves like other spec-006 columns (FR-026/FR-027 semantics). + +--- + +### User Story 3 — Half-track data-loss is diagnosed at mount, not left to the user (Priority: P2) + +When a WOZ is mounted, the tooling scans the TMAP and determines whether the +image carries **distinct** data at quarter-track positions the current pipeline +cannot address (i.e. distinct TRKS records at `qt % 4 != 0` while head resolution +is whole-track-only). If so, it emits a single explicit diagnostic at mount time +naming the affected fractional tracks (e.g. "WOZ uses half-track formatting; +data at tracks 12.50, 13.50, 14.50, … is not addressable by the current +whole-track head pipeline — N quarter-tracks affected"). The developer learns the +*cause* without manually decoding the TMAP. After the quarter-track resolution +fix lands, the same scan instead emits an Info row confirming half-track data is +present and addressable. + +**Why this priority**: Converts a latent silent data-loss condition into an +explicit, named diagnostic. High value, but depends on US1's TMAP plumbing. + +**Independent Test**: Mount Choplifter on the truncating pipeline and verify a +single Warning/Error mount-time diagnostic listing the affected half-tracks. +Mount a standard DOS 3.3 disk and verify no such diagnostic. After the +quarter-track fix, mount Choplifter and verify the diagnostic downgrades to Info +("half-track data present and addressable"). + +**Acceptance Scenarios**: + +1. **Given** a WOZ whose TMAP assigns distinct TRKS records to `qt % 4 != 0` + positions, **When** it is mounted on a whole-track-only pipeline, **Then** a + single mount-time diagnostic row MUST be emitted at Warning or Error severity, + listing the affected fractional tracks and the count of unreachable + quarter-tracks. +2. **Given** a WOZ with no distinct fractional-track data (standard format), + **When** it is mounted, **Then** NO half-track diagnostic MUST be emitted. +3. **Given** the quarter-track resolution fix is in place, **When** a half-track + WOZ is mounted, **Then** the diagnostic MUST be Info severity and state the + data is addressable. +4. **Given** the diagnostic is emitted, **When** the user reads it, **Then** it + MUST be a single consolidated row (not one row per affected quarter-track). + +--- + +### User Story 4 — Read-stall event makes an invisible spin visible (Priority: P2) + +The hang's actual signature is a read loop spinning at a head position finding no +matching address mark. Today `AddrMark` fires only on **success**, so the spin is +invisible — the log just shows address marks tapering off. This feature adds a +`ReadStall` event emitted when the passive nibble watcher observes the head dwell +at a position for ≥ K disk revolutions with zero address marks decoded while the +read latch is being actively polled. The row names the position and the +revolution count, pointing straight at the failing seek. + +**Why this priority**: Directly surfaces the failure mode. Independent of the +resolution telemetry, but lower priority than the resolved-track contrast that +explains *why* the stall happens. + +**Independent Test**: Boot a disk that stalls (half-track WOZ on the truncating +pipeline) and verify a `ReadStall` row appears at the stalling track within a +bounded number of revolutions. Boot a healthy disk and verify no `ReadStall` +rows fire during a normal boot. + +**Acceptance Scenarios**: + +1. **Given** the read latch is being polled (`$C08C` reads) at a fixed head + position, **When** ≥ K full revolutions elapse with zero address marks + decoded, **Then** exactly one `ReadStall` row MUST be emitted (Warning + severity) naming the position and revolution count. +2. **Given** a `ReadStall` was emitted, **When** the loader subsequently decodes + an address mark at that position or steps the head, **Then** the stall state + MUST reset so a later stall at the same position can re-fire. +3. **Given** a normal boot with steady address-mark cadence, **When** the boot + completes, **Then** NO `ReadStall` rows MUST be emitted. + +--- + +### User Story 5 — Raw nibble peek under the head (Priority: P3) + +The developer wants to see the actual nibbles currently flowing under the head at +a chosen position, to compare against an expected prologue. The window offers a +read-only "nibble peek" that samples a short window of nibbles the engine is +currently returning at the head's position (non-perturbing — it does not consume +or advance the real read cursor). Comparing the peeked nibbles after a `.5`-seek +against the expected outer-track prologue shows a wrong-track read directly. The +existing `trackFilterRawQt` flag in `FilterState` suggests this raw view was +already anticipated. + +**Why this priority**: Powerful but the most niche and the most implementation- +heavy (non-perturbing sampling). Lowest priority of the five. + +**Independent Test**: Seek to a known track on a standard disk and verify the +nibble peek shows the expected `D5 AA 96` prologue cadence; verify peeking does +not alter the live read stream (boot continues unaffected). + +**Acceptance Scenarios**: + +1. **Given** a mounted disk and a settled head, **When** the user requests a + nibble peek, **Then** the window MUST display a short sample of the nibbles + currently under the head at the current position. +2. **Given** a peek is taken, **When** the live emulation continues, **Then** the + peek MUST NOT consume, advance, or otherwise perturb the real read cursor or + the LSS state (non-perturbing sampling). + +--- + +### Edge Cases + +- WOZ with a TMAP that maps a quarter-track to a TRKS record whose `bitCount` is + zero or out of range → resolved-track display MUST show the slot as + unformatted/empty rather than crash, and (if the loader expected data) flag a + discrepancy. +- Multiple distinct fractional-track regions on one disk (e.g. tracks 12.5–34.5) + → the mount-time half-track diagnostic MUST consolidate into one row with a + compact range/list, not flood the log. +- `.dsk` / `.nib` images (no TMAP) → resolved-track display MUST degrade + gracefully (commanded == resolved, no TMAP column content), and the half-track + diagnostic MUST NOT fire. +- Quarter-track fix landed but a WOZ legitimately leaves a quarter-track + unformatted by design (protection expects no data there) → resolving to an + unformatted slot MUST be Info/expected, not an Error, when the loader is not + actively waiting for a prologue there. (Discrepancy = commanded resolves to a + *different assigned* track, not merely an unformatted one.) +- Read-stall K threshold tuning so brief inter-sector gaps and self-sync regions + do not trip a false stall. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The emulation core MUST expose, per head-position change, the + commanded quarter-track, the TMAP entry for that quarter-track (TRKS index or + unformatted marker), and the bit length of the served bitstream, via the + `IDisk2EventSink` surface (new payload or new event type). +- **FR-002**: The debug window MUST render, for each head-resolution event, the + commanded fractional track, the resolved TRKS index, and the served bit length. +- **FR-003**: The system MUST classify every logged row with a Severity of Info, + Warning, or Error, and the window MUST render a new Severity column with a + matching status icon per level. +- **FR-004**: The system MUST classify a head-resolution event as Error severity + when the commanded quarter-track resolves to a TRKS record the TMAP did not + assign to that quarter-track (or to an unformatted slot while the loader is + awaiting a prologue there). +- **FR-005**: The Severity column MUST participate in the spec-006 filter model + (projection, not drop), with at least an "Error/Warning only" filter option. +- **FR-006**: On disk mount, the system MUST scan the TMAP and detect distinct + data at quarter-track positions the current head pipeline cannot address; when + found, it MUST emit exactly one consolidated diagnostic row naming the affected + fractional tracks and the count of unreachable quarter-tracks. +- **FR-007**: The mount-time half-track diagnostic MUST be Warning/Error severity + while the pipeline is whole-track-only, and Info severity once quarter-track + resolution is in place and the data is addressable. +- **FR-008**: The system MUST emit a `ReadStall` event (Warning severity) when + the read latch is actively polled at a fixed head position for ≥ K disk + revolutions with zero address marks decoded; the stall state MUST reset on a + successful address-mark decode or a head step so it can re-fire later. +- **FR-009**: The `ReadStall` revolution threshold K MUST be a named constant + tuned to avoid false positives across self-sync gaps and normal inter-sector + spacing. [NEEDS CLARIFICATION: exact K — proposed default 2–3 revolutions.] +- **FR-010**: The window MUST provide a non-perturbing "nibble peek" that samples + a short window of the nibbles currently under the head without consuming or + advancing the real read cursor or mutating LSS state. +- **FR-011**: All new event types MUST keep `Disk2Event` within its documented + size bound (currently ≤ 32 bytes; see `Disk2Event.h` static_assert) or the + bound MUST be explicitly and documented-ly relaxed with a ring-footprint + benchmark. +- **FR-012**: For non-WOZ images (no TMAP), resolved-track display MUST degrade + to commanded == resolved with empty TMAP content, and the half-track diagnostic + MUST NOT fire. +- **FR-013**: The system MUST distinguish a head-movement event that voiced a + sound from one whose audio was intentionally suppressed. The boot-recalibrate + ratchet in `Disk2AudioSource::OnHeadBump` cycles rapid consecutive bumps + through a `[thunk, silent, click, click]` pattern, deliberately emitting no + audio-decision event on the silent slot. Today such a bump renders as a bare + `Head bump` row with no adjacent `Audio …` row, which is indistinguishable + from a missing/dropped audio event. The window MUST surface an explicit + Info-severity indicator (e.g. an `Audio suppressed (ratchet)` annotation or a + voiced/silent marker on the bump row) so an intentionally silent ratchet slot + is visibly distinct from an audio failure. This requires the audio source to + report the suppression decision (a new audio-event-sink signal or equivalent + payload), not just omit the event. + +### Key Entities + +- **Head-resolution record**: commanded quarter-track, fractional track label, + TMAP entry (TRKS index or unformatted), served bit length, discrepancy flag. +- **Severity**: enum { Info, Warning, Error } with associated icon; a property of + every display row. +- **Half-track diagnostic**: one-shot, per-mount consolidated record listing the + fractional tracks carrying unreachable distinct data and the affected count. +- **ReadStall record**: head position, revolution count at stall detection. +- **Nibble peek**: ephemeral, non-perturbing sample of nibbles at the current + head position (not a ring event; a pull-style query). +- **Suppressed-audio marker**: per-bump indication that the audio source + intentionally voiced no sound on a ratchet silent slot, distinguishing a + deliberately silent bump from an audio failure (FR-013). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Given a half-track-formatted WOZ on the truncating pipeline, a + developer can identify the failing fractional-track seek from the debug window + in under 60 seconds, without external tools or TMAP decoding. +- **SC-002**: Booting a standard DOS 3.3 disk produces zero Error- and + zero Warning-severity rows for an entire successful boot (no false positives). +- **SC-003**: The mount-time half-track diagnostic correctly fires for Choplifter + and stays silent for at least one standard-format reference disk. +- **SC-004**: A `ReadStall` row appears within K+1 revolutions of a genuine read + stall and never appears during a clean boot. +- **SC-005**: Enabling the nibble peek during an active boot does not alter the + boot outcome or timing (non-perturbing, verified by identical boot behavior + with peek on vs off). + +## Assumptions + +- This feature is a diagnostic add-on to spec 006 and changes no emulation + behavior; it only observes and reports. +- The quarter-track resolution fix (under #67) is a separate, prerequisite-ish + work item; this spec describes telemetry behavior in both the pre-fix + (truncating) and post-fix (quarter-track-aware) worlds and is most valuable + once that fix lands. +- The Disk II Debug dialog surface is owned by spec 011 in the near term; the + dialog-side portions of this feature are sequenced after spec 011 reaches + `master` (see Coordination constraint). The CassoEmuCore-side event plumbing + may proceed independently. +- Single Disk II controller, consistent with spec 006's v1 scope. +- WOZ images are not committed to the repo; tests use synthetic mini-WOZ fixtures + or on-demand downloads, per repository security rules.