Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CassoCore/Version.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 78 additions & 11 deletions CassoEmuCore/Audio/Disk2AudioSource.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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<float> * 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);
}
}
}
Expand Down
26 changes: 26 additions & 0 deletions CassoEmuCore/Audio/Disk2AudioSource.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<float> * buf,
bool previousStillPlaying);

// Pan (equal-power, precomputed by SetPan).
float m_panLeft = IDriveAudioSource::kCenterPan;
float m_panRight = IDriveAudioSource::kCenterPan;
Expand Down Expand Up @@ -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;

Expand Down
63 changes: 50 additions & 13 deletions CassoEmuCore/Devices/Disk/Disk2NibbleEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<uint8_t> ((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)
{
Expand Down
10 changes: 9 additions & 1 deletion CassoEmuCore/Devices/Disk/Disk2NibbleEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading