From b16f5c7176ac80b61a0962d71c37e2c42583d4ab Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Sat, 11 Jul 2026 09:23:11 -0700 Subject: [PATCH 1/7] =?UTF-8?q?feat(emu):=20Mockingboard=20A/C=20sound=20c?= =?UTF-8?q?ard=20=E2=80=94=206522=20VIA=20+=20AY-3-8910=20PSG=20(GH=20#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean-room from the datasheets (no GPL emulator code copied): - 6522 VIA: full register file, Timer 1 (one-shot + continuous) and Timer 2, IFR/IER with a level-sensitive IRQ line, ports A/B + DDRs. Generic and reusable — the card wires it, but it knows nothing about the Mockingboard. - AY-3-8910 PSG: three square-wave tone channels, a 17-bit-LFSR noise generator, a 16-level envelope generator, and a logarithmic DAC rendered to float32 PCM resampled to the host rate via a fractional accumulator. Frequencies match the datasheet formulas. - MockingboardCard: aggregates 2x VIA + 2x PSG in a slot's $Cn00 page (address bit 7 selects the VIA), translating each VIA's port A/B into the AY data bus and BC1/BDIR/RESET control lines. Integration: - VIA Timer 1 IRQs feed the existing InterruptController (the tempo source music players rely on). InterruptController::ResetSources reclaims tokens on machine teardown so switches don't leak the pool. - The two PSGs mix to stereo (PSG #1 hard-left, PSG #2 hard-right) through a dedicated DriveAudioMixer so the Options toggle is independent of Drive Audio; WasapiAudio::SubmitFrame sums it into the speaker bus with per-channel clamping. - Installed in slot 4 of the Apple ][+ and //e profiles. On the //e the CxxxRomRouter now delegates a slot's $Cn00 page to an active I/O card when INTCXROM=0 so the card is reachable behind the MMU. - Live "Mockingboard" mute toggle threaded through the settings / persistence stack, mirroring the Drive Audio toggle. Tests: 11 VIA + 12 AY + 10 card + 2 router-delegation; full suite 2081 passing, build clean, code analysis clean. Version 1.7.0. --- CHANGELOG.md | 24 + Casso/Config/UserConfigStore.cpp | 1 + Casso/EmulatorShell.cpp | 38 +- Casso/EmulatorShell.h | 7 + Casso/Shell/MachineManager.cpp | 54 ++ Casso/Ui/Settings/DiskPage.cpp | 11 + Casso/Ui/Settings/DiskPage.h | 2 + Casso/Ui/Settings/SettingsApplyController.cpp | 6 + Casso/Ui/Settings/SettingsPanelState.cpp | 22 +- Casso/Ui/Settings/SettingsPanelState.h | 3 + Casso/WasapiAudio.cpp | 14 +- Casso/WasapiAudio.h | 5 +- Casso/resource.h | 2 + CassoCore/Version.h | 4 +- CassoEmuCore/CassoEmuCore.vcxproj | 8 + CassoEmuCore/Core/ComponentRegistry.cpp | 2 + CassoEmuCore/Core/InterruptController.cpp | 21 + CassoEmuCore/Core/InterruptController.h | 8 + CassoEmuCore/Devices/CxxxRomRouter.cpp | 67 ++- CassoEmuCore/Devices/CxxxRomRouter.h | 10 +- CassoEmuCore/Devices/Mockingboard/Ay8910.cpp | 492 ++++++++++++++++++ CassoEmuCore/Devices/Mockingboard/Ay8910.h | 155 ++++++ .../Mockingboard/MockingboardAudioSource.cpp | 41 ++ .../Mockingboard/MockingboardAudioSource.h | 64 +++ .../Devices/Mockingboard/MockingboardCard.cpp | 261 ++++++++++ .../Devices/Mockingboard/MockingboardCard.h | 99 ++++ CassoEmuCore/Devices/Mockingboard/Via6522.cpp | 440 ++++++++++++++++ CassoEmuCore/Devices/Mockingboard/Via6522.h | 163 ++++++ README.md | 16 +- Resources/Machines/Apple2Plus/Apple2Plus.json | 3 +- Resources/Machines/Apple2e/Apple2e.json | 3 +- UnitTest/EmuTests/Ay8910Tests.cpp | 326 ++++++++++++ UnitTest/EmuTests/BackwardsCompatTests.cpp | 30 +- UnitTest/EmuTests/MmuTests.cpp | 55 ++ UnitTest/EmuTests/MockingboardCardTests.cpp | 293 +++++++++++ UnitTest/EmuTests/Via6522Tests.cpp | 296 +++++++++++ UnitTest/UiTests/SettingsPanelStateTests.cpp | 2 + UnitTest/UnitTest.vcxproj | 3 + 38 files changed, 3034 insertions(+), 17 deletions(-) create mode 100644 CassoEmuCore/Devices/Mockingboard/Ay8910.cpp create mode 100644 CassoEmuCore/Devices/Mockingboard/Ay8910.h create mode 100644 CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.cpp create mode 100644 CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.h create mode 100644 CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp create mode 100644 CassoEmuCore/Devices/Mockingboard/MockingboardCard.h create mode 100644 CassoEmuCore/Devices/Mockingboard/Via6522.cpp create mode 100644 CassoEmuCore/Devices/Mockingboard/Via6522.h create mode 100644 UnitTest/EmuTests/Ay8910Tests.cpp create mode 100644 UnitTest/EmuTests/MockingboardCardTests.cpp create mode 100644 UnitTest/EmuTests/Via6522Tests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index ad61c12b..691dd49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Versioned entries use `MAJOR.MINOR.PATCH` from [Version.h](CassoCore/Version.h). Entries before versioning was introduced use dates only. +## [1.7.0] — Mockingboard sound card (GH #66) + +### Added +- **feat(emu): Mockingboard A/C sound card emulation.** Adds the de-facto + Apple II audio standard: two clean-room chip cores written from the + datasheets — a generic **6522 VIA** (full register file, Timer 1 + one-shot/continuous, Timer 2, IFR/IER and a level-sensitive IRQ line, + ports A/B with data-direction registers) and an **AY-3-8910 PSG** (three + square-wave tone channels, a 17-bit-LFSR noise generator, a 16-level + envelope generator, and a logarithmic DAC rendered to float32 PCM + resampled to the host rate). A **MockingboardCard** aggregates two of + each in a slot's `$Cn00` I/O page, decoding address bit 7 to the two VIAs + and translating each VIA's port A/B into the AY data bus and BC1/BDIR/RESET + control lines. Timer 1 IRQs — the tempo source Mockingboard music players + rely on — feed the existing interrupt controller, and the two PSGs mix + into the stereo bus (PSG #1 hard-left, PSG #2 hard-right) through a + dedicated audio mixer so they sum cleanly with the speaker and Disk II + audio. The card is installed in slot 4 of the Apple ][+ and //e profiles + and can be muted live via **Settings → Disk → Mockingboard** (mirroring + the Drive Audio toggle) or removed entirely from **Settings → Machine**. + On the //e the `CxxxRomRouter` now delegates a slot's `$Cn00` page to an + active I/O card when `INTCXROM=0`, so the card is reachable behind the + MMU. Clean-room throughout: no GPL emulator code was copied. + ## [1.6.4] — Disk ][ Debug panel virtualization (GH #88) ### Fixed diff --git a/Casso/Config/UserConfigStore.cpp b/Casso/Config/UserConfigStore.cpp index 54d13e94..2b594089 100644 --- a/Casso/Config/UserConfigStore.cpp +++ b/Casso/Config/UserConfigStore.cpp @@ -395,6 +395,7 @@ namespace uiObj.emplace_back ("colorMode", JsonValue (std::string ("color"))); uiObj.emplace_back ("writeMode", JsonValue (std::string ("buffer-and-flush"))); uiObj.emplace_back ("floppySoundEnabled", JsonValue (true)); + uiObj.emplace_back ("mockingboardEnabled", JsonValue (true)); uiObj.emplace_back ("floppyMechanism", JsonValue (std::string ("shugart"))); wp.emplace_back (JsonValue (false)); wp.emplace_back (JsonValue (false)); diff --git a/Casso/EmulatorShell.cpp b/Casso/EmulatorShell.cpp index 19535f8a..60746cd5 100644 --- a/Casso/EmulatorShell.cpp +++ b/Casso/EmulatorShell.cpp @@ -15,6 +15,7 @@ #include "Devices/Apple2eSoftSwitchBank.h" #include "Devices/AppleSpeaker.h" #include "Devices/Disk2Controller.h" +#include "Devices/Mockingboard/MockingboardCard.h" #include "Devices/LanguageCard.h" #include "Devices/Apple2eMmu.h" #include "Core/Prng.h" @@ -991,6 +992,10 @@ HRESULT EmulatorShell::Initialize ( { m_driveAudioMixer.SetEnabled (enabled); } + if (SUCCEEDED (uiPrefs->GetBool ("mockingboardEnabled", enabled))) + { + m_mockingboardAudioMixer.SetEnabled (enabled); + } if (SUCCEEDED (uiPrefs->GetString ("floppyMechanism", mechNarrow)) && !mechNarrow.empty()) { // DriveAudioMixer matches mechanism names case- @@ -2887,6 +2892,14 @@ void EmulatorShell::OnCpuThreadStart() HRESULT hrLoad = m_driveAudioMixer.SetMechanism (m_driveAudioMixer.GetMechanism()); IGNORE_RETURN_VALUE (hrLoad, S_OK); } + + // The initial machine is built before WASAPI comes up, so its PSGs + // do not yet know the host sample rate. Seed it now (machine switches + // after this point pick it up at build time in MachineManager). + if (m_wasapiAudio.IsInitialized() && m_refs.mockingboard != nullptr) + { + m_refs.mockingboard->SetSampleRate (m_wasapiAudio.GetSampleRate()); + } } @@ -2972,6 +2985,11 @@ void EmulatorShell::DispatchCpuCommand (const EmulatorCommand & cmd) m_refs.diskController->Tick (m_cpu->GetLastInstructionCycles()); } + if (m_refs.mockingboard != nullptr) + { + m_refs.mockingboard->Tick (m_cpu->GetLastInstructionCycles()); + } + if (m_refs.keyboard != nullptr) { m_refs.keyboard->Tick (m_cpu->GetLastInstructionCycles()); @@ -3007,6 +3025,13 @@ void EmulatorShell::DispatchCpuCommand (const EmulatorCommand & cmd) break; } + case IDM_AUDIO_MOCKINGBOARD_ENABLE: + case IDM_AUDIO_MOCKINGBOARD_DISABLE: + { + m_mockingboardAudioMixer.SetEnabled (cmd.id == IDM_AUDIO_MOCKINGBOARD_ENABLE); + break; + } + case IDM_AUDIO_DRIVE_MECHANISM: { // Payload is "shugart" or "alps" (canonical lower-case from @@ -3220,6 +3245,11 @@ void EmulatorShell::StepInstructionWhilePaused() m_refs.diskController->Tick (m_cpu->GetLastInstructionCycles()); } + if (m_refs.mockingboard != nullptr) + { + m_refs.mockingboard->Tick (m_cpu->GetLastInstructionCycles()); + } + if (m_refs.keyboard != nullptr) { m_refs.keyboard->Tick (m_cpu->GetLastInstructionCycles()); @@ -3340,6 +3370,11 @@ void EmulatorShell::ExecuteCpuSlices() m_refs.diskController->Tick (cycles); } + if (m_refs.mockingboard != nullptr) + { + m_refs.mockingboard->Tick (cycles); + } + if (m_refs.keyboard != nullptr) { m_refs.keyboard->Tick (cycles); @@ -3360,7 +3395,8 @@ void EmulatorShell::ExecuteCpuSlices() m_refs.speaker->GetFrameInitialState(), numSamples, &m_driveAudioMixer, - m_cpu->GetTotalCycles()); + m_cpu->GetTotalCycles(), + &m_mockingboardAudioMixer); m_refs.speaker->ClearTimestamps(); m_refs.speaker->BeginFrame(); diff --git a/Casso/EmulatorShell.h b/Casso/EmulatorShell.h index 9bb92332..9cddb65d 100644 --- a/Casso/EmulatorShell.h +++ b/Casso/EmulatorShell.h @@ -656,6 +656,12 @@ class EmulatorShell : public IDxuiHostClient, public IDriveCommandSink, public I DriveAudioMixer m_driveAudioMixer; vector> m_diskAudioSources; + // Mockingboard audio. Its own mixer so the "Mockingboard" Options + // toggle is independent of the Drive Audio toggle. The PSG audio + // sources are owned by the MockingboardCard device; the mixer holds + // borrowed pointers, re-registered by MachineManager on every build. + DriveAudioMixer m_mockingboardAudioMixer; + // Live per-sound drive-audio gains (0..1), seeded from $cassoUiPrefs // at startup and updated via SetDriveAudioVolumes. Stored on the shell // so they survive machine resets (MachineManager re-seeds fresh @@ -704,6 +710,7 @@ class EmulatorShell : public IDxuiHostClient, public IDriveCommandSink, public I class AppleSpeaker * speaker = nullptr; class RamDevice * mainRamDev = nullptr; class Disk2Controller * diskController = nullptr; + class MockingboardCard * mockingboard = nullptr; class VideoOutput * activeVideoMode = nullptr; }; diff --git a/Casso/Shell/MachineManager.cpp b/Casso/Shell/MachineManager.cpp index 78d95f4a..a5ee265e 100644 --- a/Casso/Shell/MachineManager.cpp +++ b/Casso/Shell/MachineManager.cpp @@ -20,6 +20,7 @@ #include "Devices/Apple2eSoftSwitchBank.h" #include "Devices/AppleSpeaker.h" #include "Devices/Disk2Controller.h" +#include "Devices/Mockingboard/MockingboardCard.h" #include "Devices/LanguageCard.h" #include "Devices/Apple2eMmu.h" #include "Video/AppleTextMode.h" @@ -425,6 +426,49 @@ HRESULT MachineManager::CreateMemoryDevices (const MachineConfig & config) m_shell.m_refs.diskController->SetAudioSink (m_shell.m_diskAudioSources[0].get()); } + // Mockingboard wiring. Cache the card (if the active config installs + // one), attach both VIA timer-IRQ sources to the shared controller, + // and register the two PSG audio sources (PSG #1 hard-left, PSG #2 + // hard-right) with the dedicated Mockingboard mixer. The card owns + // the sources; the mixer holds borrowed pointers, dropped on the next + // teardown. + m_shell.m_refs.mockingboard = nullptr; + m_shell.m_mockingboardAudioMixer.UnregisterAllSources(); + + for (auto & dev : m_shell.m_ownedDevices) + { + MockingboardCard * mb = dynamic_cast (dev.get()); + + if (mb != nullptr) + { + m_shell.m_refs.mockingboard = mb; + break; + } + } + + if (m_shell.m_refs.mockingboard != nullptr) + { + hr = m_shell.m_refs.mockingboard->AttachInterruptController (&m_shell.m_interruptController); + CHR (hr); + + m_shell.m_mockingboardAudioMixer.RegisterSource (m_shell.m_refs.mockingboard->GetAudioSource (0)); + m_shell.m_mockingboardAudioMixer.RegisterSource (m_shell.m_refs.mockingboard->GetAudioSource (1)); + + m_shell.m_refs.mockingboard->SetSampleRate (m_shell.m_wasapiAudio.GetSampleRate()); + + // On the //e the Apple2eMmu's CxxxRomRouter owns $C100-$CFFF, so a + // bus device at $Cn00 would be shadowed. Register the card as the + // slot's active I/O device so the router delegates that page to it + // (INTCXROM=0). On ][/][+ there is no MMU and the card is + // bus-resident. + if (m_shell.m_mmu != nullptr) + { + m_shell.m_mmu->GetCxxxRouter()->SetSlotIoDevice ( + m_shell.m_refs.mockingboard->GetSlot(), + m_shell.m_refs.mockingboard); + } + } + Error: return hr; } @@ -1061,6 +1105,16 @@ HRESULT MachineManager::SwitchMachine (const std::wstring & machineName) // reassigned when the new config carries an apple2e-mmu device; // it must be explicitly reset here or it'll keep its stale // RamDevice pointer alive across a //e -> ][ switch. + // The Mockingboard's PSG audio sources are owned by the card device + // in m_ownedDevices, so the mixer's borrowed pointers must be dropped + // before that collection is cleared below (CreateMemoryDevices + // re-registers fresh ones for the new machine). + m_shell.m_mockingboardAudioMixer.UnregisterAllSources(); + + // Reclaim IRQ source tokens before the devices that hold them are + // destroyed; the rebuilt machine re-registers from a fresh pool. + m_shell.m_interruptController.ResetSources(); + m_shell.m_cpu.reset(); m_shell.m_ownedDevices.clear(); m_shell.m_videoModes.clear(); diff --git a/Casso/Ui/Settings/DiskPage.cpp b/Casso/Ui/Settings/DiskPage.cpp index b1d0fac3..67c7241d 100644 --- a/Casso/Ui/Settings/DiskPage.cpp +++ b/Casso/Ui/Settings/DiskPage.cpp @@ -56,6 +56,7 @@ DiskPage::DiskPage (std::wstring title) Adopt (m_wpLabel); Adopt (m_writeModeLabel); + Adopt (m_mockingboardLabel); Adopt (m_audioLabel); Adopt (m_mechLabel); Adopt (m_motorLabel); @@ -66,6 +67,7 @@ DiskPage::DiskPage (std::wstring title) Adopt (m_writeMode); Adopt (m_mechanism); + Adopt (m_mockingboard); Adopt (m_driveAudio); for (i = 0; i < m_writeProtect.size(); ++i) { @@ -148,6 +150,11 @@ void DiskPage::Layout (const RECT & rect, const DxuiDpiScaler & scaler) m_writeMode.SetItems ({ L"Buffer and flush", L"Copy on write" }); y += rowHeight + sectionGap; + m_mockingboardLabel.SetRect (MakeRect (x, y, labelWidth, rowHeight)); + m_mockingboardLabel.SetText (L"Mockingboard:"); + m_mockingboard.SetRect (MakeRect (controlsX, y, checkWidth, rowHeight)); + y += rowHeight + sectionGap; + m_audioLabel.SetRect (MakeRect (x, y, labelWidth, rowHeight)); m_audioLabel.SetText (L"Drive audio:"); m_driveAudio.SetRect (MakeRect (controlsX, y, checkWidth, rowHeight)); @@ -204,6 +211,8 @@ void DiskPage::Layout (const RECT & rect, const DxuiDpiScaler & scaler) m_wpLabel.SetDpi (dpi); m_writeModeLabel.SetDpi (dpi); + m_mockingboardLabel.SetDpi (dpi); + m_mockingboard.SetDpi (dpi); m_audioLabel.SetDpi (dpi); m_mechLabel.SetDpi (dpi); m_writeMode.SetDpi (dpi); @@ -254,6 +263,7 @@ void DiskPage::Rebuild () m_writeMode.SetSelected ((int) state->Prefs().writeMode); m_mechanism.SetSelected (state->Prefs().floppyMechanism == "alps" ? 1 : 0); m_driveAudio.SetChecked (state->Prefs().floppySoundEnabled); + m_mockingboard.SetChecked (state->Prefs().mockingboardEnabled); m_writeProtect[0].SetChecked (state->Prefs().writeProtect[0]); m_writeProtect[1].SetChecked (state->Prefs().writeProtect[1]); m_motorVol.SetValue (state->Prefs().driveMotorVolume * 100.0f); @@ -270,6 +280,7 @@ void DiskPage::Rebuild () state->SetFloppySound (checked); ApplyDriveAudioChildEnabled (checked); }); + m_mockingboard.SetOnChange ([state] (bool checked) { state->SetMockingboard (checked); }); m_writeProtect[0].SetOnChange ([state] (bool checked) { state->SetWriteProtect (0, checked); }); m_writeProtect[1].SetOnChange ([state] (bool checked) { state->SetWriteProtect (1, checked); }); diff --git a/Casso/Ui/Settings/DiskPage.h b/Casso/Ui/Settings/DiskPage.h index 38634981..d831be39 100644 --- a/Casso/Ui/Settings/DiskPage.h +++ b/Casso/Ui/Settings/DiskPage.h @@ -84,6 +84,7 @@ class DiskPage : public DxuiPropertyPage DxuiLabel m_wpLabel; DxuiLabel m_writeModeLabel; + DxuiLabel m_mockingboardLabel; DxuiLabel m_audioLabel; DxuiLabel m_mechLabel; DxuiLabel m_motorLabel; @@ -94,6 +95,7 @@ class DiskPage : public DxuiPropertyPage DxuiDropdown m_writeMode; DxuiDropdown m_mechanism; + DxuiToggle m_mockingboard; DxuiToggle m_driveAudio; std::array m_writeProtect; DxuiSlider m_motorVol; diff --git a/Casso/Ui/Settings/SettingsApplyController.cpp b/Casso/Ui/Settings/SettingsApplyController.cpp index 1c8476fe..98e9c078 100644 --- a/Casso/Ui/Settings/SettingsApplyController.cpp +++ b/Casso/Ui/Settings/SettingsApplyController.cpp @@ -67,6 +67,12 @@ namespace : IDM_AUDIO_DRIVE_DISABLE); } + void ApplyMockingboard (bool enabled) override + { + m_shell.PostCommand (enabled ? IDM_AUDIO_MOCKINGBOARD_ENABLE + : IDM_AUDIO_MOCKINGBOARD_DISABLE); + } + void ApplyMechanism (const std::string & mechanism) override { m_shell.PostCommand (IDM_AUDIO_DRIVE_MECHANISM, mechanism); diff --git a/Casso/Ui/Settings/SettingsPanelState.cpp b/Casso/Ui/Settings/SettingsPanelState.cpp index 3aaf8432..ed00dc96 100644 --- a/Casso/Ui/Settings/SettingsPanelState.cpp +++ b/Casso/Ui/Settings/SettingsPanelState.cpp @@ -416,6 +416,20 @@ void SettingsPanelState::SetFloppySound (bool enabled) +//////////////////////////////////////////////////////////////////////////////// +// +// SetMockingboard +// +//////////////////////////////////////////////////////////////////////////////// + +void SettingsPanelState::SetMockingboard (bool enabled) +{ + m_current.prefs.mockingboardEnabled = enabled; +} + + + + //////////////////////////////////////////////////////////////////////////////// // @@ -562,6 +576,7 @@ HRESULT SettingsPanelState::Apply ( sink.ApplySpeedMode (m_current.prefs.speedMode); sink.ApplyColorMode (m_current.prefs.colorMode); sink.ApplyFloppySound (m_current.prefs.floppySoundEnabled); + sink.ApplyMockingboard (m_current.prefs.mockingboardEnabled); sink.ApplyMechanism (m_current.prefs.floppyMechanism); sink.ApplyDriveVolumes (m_current.prefs.driveMotorVolume, m_current.prefs.driveHeadVolume, @@ -630,8 +645,9 @@ HRESULT SettingsPanelState::ExtractUiPrefs ( GetStringOpt (*uiObj, "writeMode", "buffer-and-flush"), SettingsWriteMode::BufferAndFlush); - outPrefs.floppySoundEnabled = GetBoolOpt (*uiObj, "floppySoundEnabled", true); - outPrefs.floppyMechanism = GetStringOpt (*uiObj, "floppyMechanism", "shugart"); + outPrefs.floppySoundEnabled = GetBoolOpt (*uiObj, "floppySoundEnabled", true); + outPrefs.mockingboardEnabled = GetBoolOpt (*uiObj, "mockingboardEnabled", true); + outPrefs.floppyMechanism = GetStringOpt (*uiObj, "floppyMechanism", "shugart"); outPrefs.driveMotorVolume = (float) GetNumberOpt (*uiObj, "driveMotorVolume", SettingsUiPrefs::kDefaultDriveMotorVolume); outPrefs.driveHeadVolume = (float) GetNumberOpt (*uiObj, "driveHeadVolume", SettingsUiPrefs::kDefaultDriveHeadVolume); @@ -1133,6 +1149,7 @@ JsonValue SettingsPanelState::BuildJson ( uiObj.emplace_back ("colorMode", JsonValue (std::string (ColorToString (prefs.colorMode)))); uiObj.emplace_back ("writeMode", JsonValue (std::string (WriteModeToString (prefs.writeMode)))); uiObj.emplace_back ("floppySoundEnabled", JsonValue (prefs.floppySoundEnabled)); + uiObj.emplace_back ("mockingboardEnabled", JsonValue (prefs.mockingboardEnabled)); uiObj.emplace_back ("floppyMechanism", JsonValue (prefs.floppyMechanism)); uiObj.emplace_back ("driveMotorVolume", JsonValue ((double) prefs.driveMotorVolume)); uiObj.emplace_back ("driveHeadVolume", JsonValue ((double) prefs.driveHeadVolume)); @@ -1169,6 +1186,7 @@ bool SettingsPanelState::PrefsEqual ( if (a.colorMode != b.colorMode) return false; if (a.writeMode != b.writeMode) return false; if (a.floppySoundEnabled != b.floppySoundEnabled) return false; + if (a.mockingboardEnabled != b.mockingboardEnabled) return false; if (a.floppyMechanism != b.floppyMechanism) return false; if (a.driveMotorVolume != b.driveMotorVolume) return false; if (a.driveHeadVolume != b.driveHeadVolume) return false; diff --git a/Casso/Ui/Settings/SettingsPanelState.h b/Casso/Ui/Settings/SettingsPanelState.h index bc19ee7e..063d5439 100644 --- a/Casso/Ui/Settings/SettingsPanelState.h +++ b/Casso/Ui/Settings/SettingsPanelState.h @@ -76,6 +76,7 @@ struct SettingsUiPrefs SettingsColorMode colorMode = SettingsColorMode::Color; SettingsWriteMode writeMode = SettingsWriteMode::BufferAndFlush; bool floppySoundEnabled = true; + bool mockingboardEnabled = true; std::string floppyMechanism = "shugart"; // "shugart" | "alps" bool writeProtect[2] = { false, false }; // Drive-audio component gains (0..1). Defaults mirror the @@ -158,6 +159,7 @@ class ISettingsApplySink virtual void ApplySpeedMode (SettingsSpeedMode mode) = 0; virtual void ApplyColorMode (SettingsColorMode mode) = 0; virtual void ApplyFloppySound (bool enabled) = 0; + virtual void ApplyMockingboard (bool enabled) = 0; virtual void ApplyMechanism (const std::string & mechanism) = 0; virtual void ApplyDriveVolumes (float motor, float head, float door) = 0; virtual void ApplyDrivePan (float driveOnePan, float driveTwoPan) = 0; @@ -210,6 +212,7 @@ class SettingsPanelState void SetColorMode (SettingsColorMode mode); void SetWriteMode (SettingsWriteMode mode); void SetFloppySound (bool enabled); + void SetMockingboard (bool enabled); void SetMechanism (const std::string & mechanism); void SetDriveMotorVolume (float gain); void SetDriveHeadVolume (float gain); diff --git a/Casso/WasapiAudio.cpp b/Casso/WasapiAudio.cpp index 82f002fe..f1effc60 100644 --- a/Casso/WasapiAudio.cpp +++ b/Casso/WasapiAudio.cpp @@ -215,7 +215,8 @@ HRESULT WasapiAudio::SubmitFrame ( float currentSpeakerState, uint32_t numSamplesToGenerate, DriveAudioMixer * driveMixer, - uint64_t currentCycleCount) + uint64_t currentCycleCount, + DriveAudioMixer * mockingboardMixer) { HRESULT hr = S_OK; size_t prevFrames = 0; @@ -282,6 +283,17 @@ HRESULT WasapiAudio::SubmitFrame ( DriveAudioMixer::MixDriveIntoSpeakerStereo ( stereoPtr, m_driveScratch.data(), numSamplesToGenerate); } + + // Mockingboard PSG audio shares the same additive stereo mix but + // runs through its own mixer so its Options toggle is independent + // of Drive Audio. Its sources ignore the cycle-based Tick. + if (mockingboardMixer != nullptr) + { + mockingboardMixer->GeneratePCM (m_driveScratch.data(), numSamplesToGenerate); + + DriveAudioMixer::MixDriveIntoSpeakerStereo ( + stereoPtr, m_driveScratch.data(), numSamplesToGenerate); + } } // Drain as many pending frames as WASAPI can accept. diff --git a/Casso/WasapiAudio.h b/Casso/WasapiAudio.h index a6be8a23..01171f9a 100644 --- a/Casso/WasapiAudio.h +++ b/Casso/WasapiAudio.h @@ -39,8 +39,9 @@ class WasapiAudio uint32_t totalCyclesThisSlice, float currentSpeakerState, uint32_t numSamplesToGenerate, - DriveAudioMixer * driveMixer = nullptr, - uint64_t currentCycleCount = 0); + DriveAudioMixer * driveMixer = nullptr, + uint64_t currentCycleCount = 0, + DriveAudioMixer * mockingboardMixer = nullptr); void RecordDriveDoorSyncEvent (int drive, int64_t timestampMs); int64_t GetLastDriveDoorSyncEventMs (int drive) const; diff --git a/Casso/resource.h b/Casso/resource.h index 10b507d5..3b8d9fee 100644 --- a/Casso/resource.h +++ b/Casso/resource.h @@ -48,6 +48,8 @@ #define IDM_AUDIO_DRIVE_VOLUMES 40053 #define IDM_AUDIO_DRIVE_PAN 40054 #define IDM_AUDIO_DRIVE_TEST 40055 +#define IDM_AUDIO_MOCKINGBOARD_ENABLE 40056 +#define IDM_AUDIO_MOCKINGBOARD_DISABLE 40057 #define IDM_HELP_KEYMAP 40040 #define IDM_HELP_ABOUT 40042 diff --git a/CassoCore/Version.h b/CassoCore/Version.h index 735092ad..074cd61e 100644 --- a/CassoCore/Version.h +++ b/CassoCore/Version.h @@ -8,8 +8,8 @@ // identifies an individual compile when that granularity is needed. #define VERSION_MAJOR 1 -#define VERSION_MINOR 6 -#define VERSION_PATCH 4 +#define VERSION_MINOR 7 +#define VERSION_PATCH 0 #define VERSION_YEAR 2026 // Helper macros for stringification diff --git a/CassoEmuCore/CassoEmuCore.vcxproj b/CassoEmuCore/CassoEmuCore.vcxproj index f62dbf33..96fd048b 100644 --- a/CassoEmuCore/CassoEmuCore.vcxproj +++ b/CassoEmuCore/CassoEmuCore.vcxproj @@ -210,6 +210,10 @@ + + + + @@ -278,6 +282,10 @@ + + + + diff --git a/CassoEmuCore/Core/ComponentRegistry.cpp b/CassoEmuCore/Core/ComponentRegistry.cpp index 39cd9c18..85ec2fa2 100644 --- a/CassoEmuCore/Core/ComponentRegistry.cpp +++ b/CassoEmuCore/Core/ComponentRegistry.cpp @@ -14,6 +14,7 @@ #include "../Devices/Apple2eMmu.h" #include "../Devices/Apple2eSoftSwitchBank.h" #include "../Devices/Acia6551.h" +#include "../Devices/Mockingboard/MockingboardCard.h" @@ -127,4 +128,5 @@ void ComponentRegistry::RegisterBuiltinDevices (ComponentRegistry & registry) registry.Register ("language-card", LanguageCard::Create); registry.Register ("disk-ii", Disk2Controller::Create); registry.Register ("acia-6551", Acia6551::Create); + registry.Register ("mockingboard", MockingboardCard::Create); } diff --git a/CassoEmuCore/Core/InterruptController.cpp b/CassoEmuCore/Core/InterruptController.cpp index 66466ca5..0cfa8108 100644 --- a/CassoEmuCore/Core/InterruptController.cpp +++ b/CassoEmuCore/Core/InterruptController.cpp @@ -177,3 +177,24 @@ void InterruptController::PowerCycle () { SoftReset (); } + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ResetSources +// +// Machine-teardown path: reclaim every source token and clear the +// aggregate so a rebuilt machine re-registers its IRQ sources from a +// fresh pool. +// +//////////////////////////////////////////////////////////////////////////////// + +void InterruptController::ResetSources () +{ + m_aggregate = 0; + m_nextSource = 0; + + UpdateLine (); +} diff --git a/CassoEmuCore/Core/InterruptController.h b/CassoEmuCore/Core/InterruptController.h index 130323b5..98b81e17 100644 --- a/CassoEmuCore/Core/InterruptController.h +++ b/CassoEmuCore/Core/InterruptController.h @@ -48,6 +48,14 @@ class InterruptController : public IInterruptController void SoftReset (); void PowerCycle (); + // Reclaim all source-ID allocations and clear the aggregate. Unlike + // SoftReset / PowerCycle (which preserve tokens so peripherals keep + // theirs across a reset), this is the machine-teardown path: every + // device that held a token is being destroyed and rebuilt, so the + // allocation pool starts over. Without it, each machine switch would + // leak tokens and eventually exhaust the 32-source pool. + void ResetSources (); + private: void UpdateLine (); diff --git a/CassoEmuCore/Devices/CxxxRomRouter.cpp b/CassoEmuCore/Devices/CxxxRomRouter.cpp index c5c6f09c..7a85fd02 100644 --- a/CassoEmuCore/Devices/CxxxRomRouter.cpp +++ b/CassoEmuCore/Devices/CxxxRomRouter.cpp @@ -113,6 +113,62 @@ bool CxxxRomRouter::HasSlotRom (int slot) const +//////////////////////////////////////////////////////////////////////////////// +// +// SetSlotIoDevice +// +//////////////////////////////////////////////////////////////////////////////// + +void CxxxRomRouter::SetSlotIoDevice (int slot, MemoryDevice * device) +{ + if (slot < kMinSlot || slot > kMaxSlot) + { + return; + } + + m_slotIoDevice[slot] = device; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SlotIoDeviceFor +// +// Returns the active I/O device owning `address`'s slot page, or nullptr +// if the address should resolve to ROM. Slot cards are only visible when +// INTCXROM=0; slot 3 additionally yields to the internal 80-column +// firmware unless SLOTC3ROM=1, and the $C800+ expansion window is never a +// slot I/O page. +// +//////////////////////////////////////////////////////////////////////////////// + +MemoryDevice * CxxxRomRouter::SlotIoDeviceFor (Word address) const +{ + int slot = static_cast ((address >> 8) & 0x0F); + + if (m_mmu.GetIntCxRom ()) + { + return nullptr; + } + + if (address >= kExpansionRomStart || slot < kMinSlot || slot > kMaxSlot) + { + return nullptr; + } + + if (slot == 3 && !m_mmu.GetSlotC3Rom ()) + { + return nullptr; + } + + return m_slotIoDevice[slot]; +} + + + + //////////////////////////////////////////////////////////////////////////////// // // Read @@ -124,7 +180,8 @@ bool CxxxRomRouter::HasSlotRom (int slot) const Byte CxxxRomRouter::Read (Word address) { - Byte value = ResolveByte (address); + MemoryDevice * io = SlotIoDeviceFor (address); + Byte value = (io != nullptr) ? io->Read (address) : ResolveByte (address); @@ -159,7 +216,13 @@ Byte CxxxRomRouter::Read (Word address) void CxxxRomRouter::Write (Word address, Byte value) { - UNREFERENCED_PARAMETER (value); + MemoryDevice * io = SlotIoDeviceFor (address); + + if (io != nullptr) + { + io->Write (address, value); + return; + } if (address == kIntC8RomClearAddr) { diff --git a/CassoEmuCore/Devices/CxxxRomRouter.h b/CassoEmuCore/Devices/CxxxRomRouter.h index 5a03514e..286bc7fd 100644 --- a/CassoEmuCore/Devices/CxxxRomRouter.h +++ b/CassoEmuCore/Devices/CxxxRomRouter.h @@ -49,10 +49,18 @@ class CxxxRomRouter : public MemoryDevice void SetSlotRom (int slot, vector data); bool HasSlotRom (int slot) const; + // Register an active I/O device (e.g. a Mockingboard) that owns a + // slot's $Cn00 page. When slot cards are visible (INTCXROM=0) reads + // and writes to that page are delegated to the device instead of + // resolving to slot ROM. Caller-owned; pass nullptr to detach. + void SetSlotIoDevice (int slot, MemoryDevice * device); + private: - Byte ResolveByte (Word address); + Byte ResolveByte (Word address); + MemoryDevice * SlotIoDeviceFor (Word address) const; Apple2eMmu & m_mmu; vector m_internal; vector m_slotRom[8]; + MemoryDevice * m_slotIoDevice[8] = {}; }; diff --git a/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp b/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp new file mode 100644 index 00000000..82020914 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp @@ -0,0 +1,492 @@ +#include "Pch.h" + +#include "Ay8910.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// File-local constants +// +//////////////////////////////////////////////////////////////////////////////// + +static constexpr Byte s_kToneCoarseMask = 0x0F; // coarse period is 4 bits +static constexpr Byte s_kNoisePeriodMask = 0x1F; // noise period is 5 bits +static constexpr int s_kByteShift = 8; +static constexpr int s_kLfsrFeedbackTap = 3; // AY noise taps bits 0 and 3 +static constexpr int s_kLfsrHighBit = 16; // 17-bit shift register + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Ay8910 +// +//////////////////////////////////////////////////////////////////////////////// + +Ay8910::Ay8910 (double clockHz) +{ + m_clockHz = clockHz; + + Reset (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetSampleRate +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::SetSampleRate (uint32_t sampleRate) +{ + m_sampleRate = sampleRate; + + if (m_sampleRate > 0) + { + m_ticksPerSample = (m_clockHz / kBaseTickDivisor) / static_cast (m_sampleRate); + } + else + { + m_ticksPerSample = 0.0; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetClock +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::SetClock (double clockHz) +{ + m_clockHz = clockHz; + + SetSampleRate (m_sampleRate); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WriteData +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::WriteData (Byte value) +{ + if (m_latched < kRegCount) + { + WriteRegister (m_latched, value); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ReadData +// +//////////////////////////////////////////////////////////////////////////////// + +Byte Ay8910::ReadData () const +{ + Byte result = 0xFF; + + if (m_latched < kRegCount) + { + result = m_regs[m_latched]; + } + + return result; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WriteRegister +// +// A write to the envelope-shape register always restarts the envelope, +// even when the value is unchanged. +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::WriteRegister (Byte reg, Byte value) +{ + if (reg >= kRegCount) + { + return; + } + + m_regs[reg] = value; + + if (reg == kRegEnvShape) + { + RestartEnvelope (value); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ReadRegister +// +//////////////////////////////////////////////////////////////////////////////// + +Byte Ay8910::ReadRegister (Byte reg) const +{ + Byte result = 0xFF; + + if (reg < kRegCount) + { + result = m_regs[reg]; + } + + return result; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Reset +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::Reset () +{ + int c = 0; + + for (c = 0; c < kRegCount; c++) + { + m_regs[c] = 0; + } + + m_latched = 0; + m_tickAccum = 0.0; + + for (c = 0; c < 3; c++) + { + m_toneCounter[c] = TonePeriod (c); + m_toneState[c] = 0; + } + + m_noiseCounter = 2 * NoisePeriod (); + m_lfsr = 1; + + m_envCounter = 2 * EnvPeriod (); + m_envLevel = 0; + m_envDirUp = false; + m_envHolding = false; + m_envCont = false; + m_envAttack = false; + m_envAlt = false; + m_envHold = false; + + SetSampleRate (m_sampleRate); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GenerateSample +// +// Advances the engine by one output sample worth of base ticks (a +// fractional accumulator carries the remainder between samples) and +// returns the resulting mono output. +// +//////////////////////////////////////////////////////////////////////////////// + +float Ay8910::GenerateSample () +{ + int ticks = 0; + int i = 0; + + if (m_sampleRate == 0) + { + return 0.0f; + } + + m_tickAccum += m_ticksPerSample; + ticks = static_cast (m_tickAccum); + m_tickAccum -= ticks; + + for (i = 0; i < ticks; i++) + { + AdvanceBaseTick (); + } + + return CurrentOutput (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// VolumeForLevel +// +//////////////////////////////////////////////////////////////////////////////// + +float Ay8910::VolumeForLevel (int level) +{ + if (level < 0) + { + level = 0; + } + else if (level > kMaxEnvLevel) + { + level = kMaxEnvLevel; + } + + return kVolTable[level]; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// AdvanceBaseTick +// +// One clock/8 step of every generator. Tone channels toggle every +// TonePeriod ticks; noise steps its LFSR every 2*NoisePeriod ticks; the +// envelope advances one level every 2*EnvPeriod ticks. +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::AdvanceBaseTick () +{ + int c = 0; + + for (c = 0; c < 3; c++) + { + if (--m_toneCounter[c] <= 0) + { + m_toneCounter[c] = TonePeriod (c); + m_toneState[c] ^= 1; + } + } + + if (--m_noiseCounter <= 0) + { + m_noiseCounter = 2 * NoisePeriod (); + StepLfsr (); + } + + if (--m_envCounter <= 0) + { + m_envCounter = 2 * EnvPeriod (); + EnvelopeStep (); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// StepLfsr +// +// 17-bit LFSR with feedback from bits 0 and 3; the low bit is the noise +// output. +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::StepLfsr () +{ + uint32_t feedback = (m_lfsr ^ (m_lfsr >> s_kLfsrFeedbackTap)) & 1u; + + m_lfsr = (m_lfsr >> 1) | (feedback << s_kLfsrHighBit); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// EnvelopeStep +// +// Advances the 4-bit envelope level one step and applies the HOLD / +// ALTERNATE / ATTACK / CONTINUE rules at the end of each ramp. +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::EnvelopeStep () +{ + if (m_envHolding) + { + return; + } + + // Still inside a ramp: move one level toward the far end. + if (m_envDirUp && m_envLevel < kMaxEnvLevel) + { + m_envLevel++; + return; + } + + if (!m_envDirUp && m_envLevel > 0) + { + m_envLevel--; + return; + } + + // Ramp complete (level sits at the end it reached). + if (!m_envCont) + { + m_envLevel = 0; + m_envHolding = true; + return; + } + + if (m_envHold) + { + m_envHolding = true; + + if (m_envAlt) + { + m_envLevel = m_envDirUp ? 0 : kMaxEnvLevel; + } + + return; + } + + if (m_envAlt) + { + m_envDirUp = !m_envDirUp; + } + else + { + m_envLevel = m_envDirUp ? 0 : kMaxEnvLevel; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// RestartEnvelope +// +// A write to R13 reloads the shape flags and restarts the ramp from the +// attack end. +// +//////////////////////////////////////////////////////////////////////////////// + +void Ay8910::RestartEnvelope (Byte shape) +{ + m_envCont = (shape & kEnvContinue) != 0; + m_envAttack = (shape & kEnvAttack) != 0; + m_envAlt = (shape & kEnvAlternate) != 0; + m_envHold = (shape & kEnvHold) != 0; + + m_envHolding = false; + m_envDirUp = m_envAttack; + m_envLevel = m_envDirUp ? 0 : kMaxEnvLevel; + m_envCounter = 2 * EnvPeriod (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// CurrentOutput +// +// Mixes the three channels. For each channel the tone and noise streams +// are gated by the mixer-enable bits (R7: a set bit disables that source), +// then scaled by the channel's fixed or envelope-driven amplitude. +// +//////////////////////////////////////////////////////////////////////////////// + +float Ay8910::CurrentOutput () const +{ + Byte mixer = m_regs[kRegMixer]; + float out = 0.0f; + int c = 0; + + for (c = 0; c < 3; c++) + { + int toneDisabled = (mixer >> c) & 1; + int noiseDisabled = (mixer >> (c + 3)) & 1; + int toneTerm = toneDisabled | m_toneState[c]; + int noiseTerm = noiseDisabled | static_cast (m_lfsr & 1u); + int active = toneTerm & noiseTerm; + Byte amp = m_regs[kRegAmpA + c]; + int level = (amp & kAmpUseEnvelope) ? m_envLevel : (amp & kAmpLevelMask); + + if (active != 0) + { + out += VolumeForLevel (level); + } + } + + return out; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TonePeriod +// +//////////////////////////////////////////////////////////////////////////////// + +int Ay8910::TonePeriod (int channel) const +{ + Byte fine = m_regs[kRegToneAFine + channel * 2]; + Byte coarse = m_regs[kRegToneACoarse + channel * 2]; + int period = ((coarse & s_kToneCoarseMask) << s_kByteShift) | fine; + + return (period == 0) ? 1 : period; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// NoisePeriod +// +//////////////////////////////////////////////////////////////////////////////// + +int Ay8910::NoisePeriod () const +{ + int period = m_regs[kRegNoisePeriod] & s_kNoisePeriodMask; + + return (period == 0) ? 1 : period; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// EnvPeriod +// +//////////////////////////////////////////////////////////////////////////////// + +int Ay8910::EnvPeriod () const +{ + int period = (m_regs[kRegEnvCoarse] << s_kByteShift) | m_regs[kRegEnvFine]; + + return (period == 0) ? 1 : period; +} diff --git a/CassoEmuCore/Devices/Mockingboard/Ay8910.h b/CassoEmuCore/Devices/Mockingboard/Ay8910.h new file mode 100644 index 00000000..6c27c8e9 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/Ay8910.h @@ -0,0 +1,155 @@ +#pragma once + +#include "Pch.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Ay8910 +// +// Clean-room General Instrument AY-3-8910 Programmable Sound Generator, +// implemented from the datasheet. Three square-wave tone channels, a +// 17-bit-LFSR noise generator, and a 16-level envelope generator feed a +// logarithmic DAC; the three channels are summed to one mono float32 +// stream. The chip is register-driven: the host latches a register +// address then writes or reads data, exactly as the Mockingboard's 6522 +// drives the AY bus. +// +// Synthesis is pull-based: GenerateSample() advances the chip by one +// host-audio-sample's worth of clocks (via a fractional accumulator) and +// returns the summed channel output. The internal engine is stepped in +// "base ticks" of clock/8, chosen so a tone channel that toggles every +// TP base ticks produces the datasheet output frequency clock/(16*TP); +// noise steps its LFSR every 2*NP base ticks -> clock/(16*NP), and the +// envelope advances one level every 2*EP base ticks -> a full 16-step +// ramp repeats at clock/(256*EP). +// +// The output is unipolar (0..sum-of-levels), matching the real DAC; the +// DC component is removed downstream by the audio source's DC blocker. +// Register-timed PCM tricks (rapid amplitude writes within one audio +// frame) are out of scope and not resolved sub-frame. +// +//////////////////////////////////////////////////////////////////////////////// + +class Ay8910 +{ +public: + static constexpr Byte kRegCount = 16; + + static constexpr Byte kRegToneAFine = 0; + static constexpr Byte kRegToneACoarse = 1; + static constexpr Byte kRegToneBFine = 2; + static constexpr Byte kRegToneBCoarse = 3; + static constexpr Byte kRegToneCFine = 4; + static constexpr Byte kRegToneCCoarse = 5; + static constexpr Byte kRegNoisePeriod = 6; + static constexpr Byte kRegMixer = 7; + static constexpr Byte kRegAmpA = 8; + static constexpr Byte kRegAmpB = 9; + static constexpr Byte kRegAmpC = 10; + static constexpr Byte kRegEnvFine = 11; + static constexpr Byte kRegEnvCoarse = 12; + static constexpr Byte kRegEnvShape = 13; + static constexpr Byte kRegIoPortA = 14; + static constexpr Byte kRegIoPortB = 15; + + // Amplitude register: bits 0..3 select a fixed level, bit 4 selects + // the envelope level instead. + static constexpr Byte kAmpLevelMask = 0x0F; + static constexpr Byte kAmpUseEnvelope = 0x10; + + // Envelope shape (R13) control bits. + static constexpr Byte kEnvHold = 0x01; + static constexpr Byte kEnvAlternate = 0x02; + static constexpr Byte kEnvAttack = 0x04; + static constexpr Byte kEnvContinue = 0x08; + + // Mockingboard clocks the AY from the Apple II system clock (~1.023 MHz). + static constexpr double kDefaultClockHz = 1022727.0; + + // Internal prescaler: the synthesis engine steps at clock / this. + static constexpr int kBaseTickDivisor = 8; + + static constexpr int kMaxEnvLevel = 15; + + explicit Ay8910 (double clockHz = kDefaultClockHz); + + void SetSampleRate (uint32_t sampleRate); + void SetClock (double clockHz); + + // Bus interface as driven by the 6522: latch an address, then write or + // read the data register at that address. + void LatchAddress (Byte reg) { m_latched = reg; } + void WriteData (Byte value); + Byte ReadData () const; + + // Direct register access (used by the latch/data path and by tests). + void WriteRegister (Byte reg, Byte value); + Byte ReadRegister (Byte reg) const; + + void Reset (); + + // Advance the chip by one output sample and return the summed mono + // output. Roughly [0, 3] before the caller's gain/DC-block stage. + float GenerateSample (); + + // Inspectors for tests. + Byte GetLatchedAddress () const { return m_latched; } + bool GetToneState (int channel) const { return m_toneState[channel] != 0; } + uint32_t GetNoiseLfsr () const { return m_lfsr; } + int GetEnvLevel () const { return m_envLevel; } + bool IsEnvHolding () const { return m_envHolding; } + + static float VolumeForLevel (int level); + +private: + void AdvanceBaseTick (); + void StepLfsr (); + void EnvelopeStep (); + void RestartEnvelope (Byte shape); + float CurrentOutput () const; + + int TonePeriod (int channel) const; + int NoisePeriod () const; + int EnvPeriod () const; + + // Logarithmic DAC: 16 levels, ~3 dB per step (sqrt(2) ratio), + // level 0 == silence, level 15 == full scale. Derived from the + // formula amp[L] = 2^((L-15)/2), a clean-room approximation of the + // AY's non-linear volume curve. + static constexpr float kVolTable[16] = + { + 0.00000000f, 0.00781250f, 0.01104854f, 0.01562500f, + 0.02209709f, 0.03125000f, 0.04419417f, 0.06250000f, + 0.08838835f, 0.12500000f, 0.17677670f, 0.25000000f, + 0.35355339f, 0.50000000f, 0.70710678f, 1.00000000f + }; + + Byte m_regs[kRegCount] = {}; + Byte m_latched = 0; + + double m_clockHz = kDefaultClockHz; + uint32_t m_sampleRate = 0; + double m_ticksPerSample = 0.0; + double m_tickAccum = 0.0; + + // Tone generators. + int m_toneCounter[3] = { 1, 1, 1 }; + int m_toneState[3] = { 0, 0, 0 }; + + // Noise generator (17-bit LFSR, seeded non-zero). + int m_noiseCounter = 1; + uint32_t m_lfsr = 1; + + // Envelope generator. + int m_envCounter = 1; + int m_envLevel = 0; + bool m_envDirUp = false; + bool m_envHolding = false; + bool m_envCont = false; + bool m_envAttack = false; + bool m_envAlt = false; + bool m_envHold = false; +}; diff --git a/CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.cpp b/CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.cpp new file mode 100644 index 00000000..fbff2d57 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.cpp @@ -0,0 +1,41 @@ +#include "Pch.h" + +#include "MockingboardAudioSource.h" +#include "Ay8910.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GeneratePCM +// +// Pulls one PSG sample per output frame, DC-blocks the unipolar DAC +// output into an AC-coupled signal centred on zero, and scales by the +// master gain. The mixer has already zeroed the buffer, so an unbound +// source contributes silence. +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardAudioSource::GeneratePCM (float * outMono, uint32_t numSamples) +{ + uint32_t i = 0; + float raw = 0.0f; + float filtered = 0.0f; + + if (m_psg == nullptr || outMono == nullptr) + { + return; + } + + for (i = 0; i < numSamples; i++) + { + raw = m_psg->GenerateSample (); + filtered = raw - m_dcPrevIn + kDcBlockPole * m_dcPrevOut; + + m_dcPrevIn = raw; + m_dcPrevOut = filtered; + + outMono[i] = filtered * kMasterGain; + } +} diff --git a/CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.h b/CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.h new file mode 100644 index 00000000..252c8a80 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/MockingboardAudioSource.h @@ -0,0 +1,64 @@ +#pragma once + +#include "Pch.h" +#include "Audio/IDriveAudioSource.h" + +class Ay8910; + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// MockingboardAudioSource +// +// Adapts one AY-3-8910 PSG to the DriveAudioMixer's IDriveAudioSource +// contract: it pulls mono samples from the chip, removes the DAC's DC +// offset with a one-pole blocker, applies a master gain, and reports its +// stereo pan. A Mockingboard is dual-mono -- PSG #1 is wired hard-left, +// PSG #2 hard-right -- so each source carries a fixed pan by default. +// +// The IDriveAudioSink notification methods are inherited from the disk +// audio abstraction and are no-ops here; a sound card has no motor, head, +// or door events. +// +//////////////////////////////////////////////////////////////////////////////// + +class MockingboardAudioSource : public IDriveAudioSource +{ +public: + // Headroom: two PSGs (three channels each) sum into the stereo bus + // alongside the speaker and Disk II audio, so each channel is + // attenuated to keep the pre-clamp sum civil. + static constexpr float kMasterGain = 0.28f; + + // One-pole DC-blocker pole. y[n] = x[n] - x[n-1] + R*y[n-1]. + static constexpr float kDcBlockPole = 0.995f; + + MockingboardAudioSource () = default; + + void SetPsg (Ay8910 * psg) { m_psg = psg; } + + // IDriveAudioSource + void GeneratePCM (float * outMono, uint32_t numSamples) override; + float PanLeft () const override { return m_panLeft; } + float PanRight () const override { return m_panRight; } + void SetPan (float panLeft, float panRight) override { m_panLeft = panLeft; m_panRight = panRight; } + + // IDriveAudioSink -- unused by a sound card. + void OnMotorEngaged () override {} + void OnMotorDisengaged () override {} + void OnHeadStep (int newQt) override { (void) newQt; } + void OnHeadBump () override {} + void OnDiskInserted () override {} + void OnDiskEjected () override {} + +private: + Ay8910 * m_psg = nullptr; + + float m_panLeft = IDriveAudioSource::kCenterPan; + float m_panRight = IDriveAudioSource::kCenterPan; + + float m_dcPrevIn = 0.0f; + float m_dcPrevOut = 0.0f; +}; diff --git a/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp new file mode 100644 index 00000000..a02a037e --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp @@ -0,0 +1,261 @@ +#include "Pch.h" + +#include "MockingboardCard.h" +#include "Core/MachineConfig.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// MockingboardCard +// +//////////////////////////////////////////////////////////////////////////////// + +MockingboardCard::MockingboardCard (int slot) +{ + m_slot = slot; + m_base = static_cast (kIoBase + slot * kSlotStride); + + m_audioSource[0].SetPsg (&m_psg[0]); + m_audioSource[1].SetPsg (&m_psg[1]); + + // Mockingboard is dual-mono: PSG #1 hard-left, PSG #2 hard-right. + m_audioSource[0].SetPan (1.0f, 0.0f); + m_audioSource[1].SetPan (0.0f, 1.0f); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Read +// +// Decodes the register access, refreshing the VIA's port-A input from the +// PSG first when the control lines currently select a PSG read so the CPU +// sees live AY data. +// +//////////////////////////////////////////////////////////////////////////////// + +Byte MockingboardCard::Read (Word address) +{ + Word offset = static_cast ((address - m_base) & (kPageSize - 1)); + int index = (offset & kVia2Select) ? 1 : 0; + Byte reg = static_cast (offset & Via6522::kRegisterMask); + Byte portB = m_via[index].GetPortB (); + + if ((reg == Via6522::kRegOra || reg == Via6522::kRegOraNh) && + (portB & kAyResetLow) != 0 && + (portB & kAyControlMask) == kAyBc1) + { + m_via[index].SetPortAInput (m_psg[index].ReadData ()); + } + + return m_via[index].ReadRegister (reg); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Write +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::Write (Word address, Byte value) +{ + Word offset = static_cast ((address - m_base) & (kPageSize - 1)); + int index = (offset & kVia2Select) ? 1 : 0; + Byte reg = static_cast (offset & Via6522::kRegisterMask); + + m_via[index].WriteRegister (reg, value); + + SyncPsg (index); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Reset +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::Reset () +{ + int i = 0; + + for (i = 0; i < kViaCount; i++) + { + m_via[i].Reset (); + m_psg[i].Reset (); + m_lastControl[i] = 0; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SoftReset +// +// The card's RESET line is tied to the Apple reset line, so Ctrl-Reset +// silences the PSGs and clears the VIAs. +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::SoftReset () +{ + Reset (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// PowerCycle +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::PowerCycle (Prng & prng) +{ + (void) prng; + + Reset (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Tick +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::Tick (uint32_t cycles) +{ + int i = 0; + + for (i = 0; i < kViaCount; i++) + { + m_via[i].Tick (cycles); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// AttachInterruptController +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT MockingboardCard::AttachInterruptController (IInterruptController * ic) +{ + HRESULT hr = S_OK; + int i = 0; + + for (i = 0; i < kViaCount; i++) + { + hr = m_via[i].AttachInterruptController (ic); + CHR (hr); + } + +Error: + return hr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetSampleRate +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::SetSampleRate (uint32_t sampleRate) +{ + int i = 0; + + for (i = 0; i < kViaCount; i++) + { + m_psg[i].SetSampleRate (sampleRate); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SyncPsg +// +// Translates the current VIA port state into a PSG bus operation. RESET +// (PB2 low) clears the chip; otherwise a change in the BDIR/BC1 lines into +// an active combination latches an address, writes data, or hands PSG data +// back to the port-A input latch. +// +//////////////////////////////////////////////////////////////////////////////// + +void MockingboardCard::SyncPsg (int index) +{ + Byte portB = m_via[index].GetPortB (); + Byte portA = m_via[index].GetPortA (); + Byte control = static_cast (portB & kAyControlMask); + + if ((portB & kAyResetLow) == 0) + { + m_psg[index].Reset (); + m_lastControl[index] = 0; + return; + } + + if (control == m_lastControl[index]) + { + return; + } + + switch (control) + { + case (kAyBdir | kAyBc1): // latch register address + m_psg[index].LatchAddress (portA); + break; + + case kAyBdir: // write data to the latched register + m_psg[index].WriteData (portA); + break; + + case kAyBc1: // read data from the latched register + m_via[index].SetPortAInput (m_psg[index].ReadData ()); + break; + + default: // inactive + break; + } + + m_lastControl[index] = control; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Create +// +//////////////////////////////////////////////////////////////////////////////// + +unique_ptr MockingboardCard::Create (const DeviceConfig & config, MemoryBus & bus) +{ + (void) bus; + + return make_unique (config.slot); +} diff --git a/CassoEmuCore/Devices/Mockingboard/MockingboardCard.h b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.h new file mode 100644 index 00000000..f1f44463 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.h @@ -0,0 +1,99 @@ +#pragma once + +#include "Pch.h" + +#include "Core/MemoryDevice.h" +#include "Core/IInterruptController.h" +#include "Via6522.h" +#include "Ay8910.h" +#include "MockingboardAudioSource.h" + +class MemoryBus; + +struct DeviceConfig; + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// MockingboardCard +// +// Sweet Micro Systems Mockingboard A/C: two 6522 VIAs, each wired to one +// AY-3-8910 PSG, occupying a slot's $Cn00 I/O-select page. Within the +// page, address bit 7 selects the VIA (0 -> VIA #1 / PSG #1 at $Cn00, +// 1 -> VIA #2 / PSG #2 at $Cn80); the low four address bits select the +// VIA register, so the register file mirrors every 16 bytes. +// +// Each VIA drives its PSG over the standard bus wiring: port A is the +// 8-bit AY data bus, and port B's low three bits are the control lines +// BC1 (PB0), BDIR (PB1), and active-low RESET (PB2). After every register +// write the card re-evaluates those lines and, on the edge into an active +// state, latches an address / writes data / reads data on the PSG. +// +// Timer 1 in continuous mode is the interrupt source Mockingboard music +// players use for tempo; both VIAs register their IRQ with the shared +// interrupt controller. Each PSG feeds a MockingboardAudioSource (PSG #1 +// hard-left, PSG #2 hard-right) that the host registers with its audio +// mixer. +// +//////////////////////////////////////////////////////////////////////////////// + +class MockingboardCard : public MemoryDevice +{ +public: + static constexpr int kViaCount = 2; + static constexpr Word kIoBase = 0xC000; + static constexpr Word kSlotStride = 0x100; + static constexpr Word kPageSize = 0x100; + + // Address bit 7 selects the second VIA/PSG within the slot page. + static constexpr Word kVia2Select = 0x80; + + // AY control lines on VIA port B. + static constexpr Byte kAyBc1 = 0x01; // PB0 + static constexpr Byte kAyBdir = 0x02; // PB1 + static constexpr Byte kAyResetLow = 0x04; // PB2, active low + static constexpr Byte kAyControlMask = kAyBdir | kAyBc1; + + explicit MockingboardCard (int slot); + + // MemoryDevice + Byte Read (Word address) override; + void Write (Word address, Byte value) override; + Word GetStart () const override { return m_base; } + Word GetEnd () const override { return static_cast (m_base + kPageSize - 1); } + void Reset () override; + void SoftReset () override; + void PowerCycle (Prng & prng) override; + + // Advance both VIA timers by `cycles` phi2 clocks (fires timer IRQs). + void Tick (uint32_t cycles); + + // Register both VIAs' IRQ sources with the shared controller. + HRESULT AttachInterruptController (IInterruptController * ic); + + // Set the host audio sample rate on both PSGs. + void SetSampleRate (uint32_t sampleRate); + + int GetSlot () const { return m_slot; } + Via6522 & GetVia (int index) { return m_via[index]; } + Ay8910 & GetPsg (int index) { return m_psg[index]; } + MockingboardAudioSource * GetAudioSource (int index) { return &m_audioSource[index]; } + + static unique_ptr Create (const DeviceConfig & config, MemoryBus & bus); + +private: + void SyncPsg (int index); + + int m_slot = 0; + Word m_base = 0; + + Via6522 m_via[kViaCount]; + Ay8910 m_psg[kViaCount]; + MockingboardAudioSource m_audioSource[kViaCount]; + + // Last control-line state (BDIR|BC1) seen on each VIA, for edge + // detection of PSG bus operations. + Byte m_lastControl[kViaCount] = { 0, 0 }; +}; diff --git a/CassoEmuCore/Devices/Mockingboard/Via6522.cpp b/CassoEmuCore/Devices/Mockingboard/Via6522.cpp new file mode 100644 index 00000000..2cf24da1 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/Via6522.cpp @@ -0,0 +1,440 @@ +#include "Pch.h" + +#include "Via6522.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ReadRegister +// +// Reads that touch a timer's low counter clear that timer's interrupt +// flag; every other read is side-effect free. Port reads combine the +// output register (for output pins) with the external input latch (for +// input pins). +// +//////////////////////////////////////////////////////////////////////////////// + +Byte Via6522::ReadRegister (Byte reg) +{ + Byte result = 0; + + switch (reg & kRegisterMask) + { + case kRegOrb: + result = GetPortB (); + break; + + case kRegOra: + case kRegOraNh: + result = GetPortA (); + break; + + case kRegDdrb: + result = m_ddrb; + break; + + case kRegDdra: + result = m_ddra; + break; + + case kRegT1CL: + ClearFlag (kIrqTimer1); + result = static_cast (m_t1Counter & 0xFF); + break; + + case kRegT1CH: + result = static_cast ((m_t1Counter >> 8) & 0xFF); + break; + + case kRegT1LL: + result = m_t1LatchLo; + break; + + case kRegT1LH: + result = m_t1LatchHi; + break; + + case kRegT2CL: + ClearFlag (kIrqTimer2); + result = static_cast (m_t2Counter & 0xFF); + break; + + case kRegT2CH: + result = static_cast ((m_t2Counter >> 8) & 0xFF); + break; + + case kRegSr: + result = m_sr; + break; + + case kRegAcr: + result = m_acr; + break; + + case kRegPcr: + result = m_pcr; + break; + + case kRegIfr: + result = GetIfr (); + break; + + case kRegIer: + result = GetIer (); + break; + + default: + break; + } + + return result; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WriteRegister +// +// Loading T1C-H transfers the latch to the counter, arms the timer, and +// clears the T1 flag; writing T1L-H or T2C-H clears the respective flag. +// IFR writes clear the flagged bits (write-1-to-clear); IER writes set or +// clear the flagged enables depending on bit 7. +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::WriteRegister (Byte reg, Byte value) +{ + switch (reg & kRegisterMask) + { + case kRegOrb: + m_orb = value; + break; + + case kRegOra: + case kRegOraNh: + m_ora = value; + break; + + case kRegDdrb: + m_ddrb = value; + break; + + case kRegDdra: + m_ddra = value; + break; + + case kRegT1CL: + m_t1LatchLo = value; + break; + + case kRegT1CH: + m_t1LatchHi = value; + m_t1Counter = (static_cast (m_t1LatchHi) << 8) | m_t1LatchLo; + m_t1Armed = true; + ClearFlag (kIrqTimer1); + break; + + case kRegT1LL: + m_t1LatchLo = value; + break; + + case kRegT1LH: + m_t1LatchHi = value; + ClearFlag (kIrqTimer1); + break; + + case kRegT2CL: + m_t2LatchLo = value; + break; + + case kRegT2CH: + m_t2Counter = (static_cast (value) << 8) | m_t2LatchLo; + m_t2Armed = true; + ClearFlag (kIrqTimer2); + break; + + case kRegSr: + m_sr = value; + break; + + case kRegAcr: + m_acr = value; + break; + + case kRegPcr: + m_pcr = value; + break; + + case kRegIfr: + // Write-1-to-clear the flag bits; bit 7 is not a real flag. + m_ifr &= static_cast (~(value & 0x7F)); + UpdateIrq (); + break; + + case kRegIer: + if (value & kIerSetClear) + { + m_ier |= static_cast (value & 0x7F); + } + else + { + m_ier &= static_cast (~(value & 0x7F)); + } + UpdateIrq (); + break; + + default: + break; + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Tick +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::Tick (uint32_t cycles) +{ + if (cycles == 0) + { + return; + } + + TickTimer1 (cycles); + TickTimer2 (cycles); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Reset +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::Reset () +{ + m_ora = 0; + m_orb = 0; + m_ddra = 0; + m_ddrb = 0; + m_portAIn = 0; + m_portBIn = 0; + + m_sr = 0; + m_acr = 0; + m_pcr = 0; + + m_ifr = 0; + m_ier = 0; + + m_t1LatchLo = 0; + m_t1LatchHi = 0; + m_t1Counter = 0; + m_t1Armed = false; + + m_t2LatchLo = 0; + m_t2Counter = 0; + m_t2Armed = false; + + UpdateIrq (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// AttachInterruptController +// +//////////////////////////////////////////////////////////////////////////////// + +HRESULT Via6522::AttachInterruptController (IInterruptController * ic) +{ + HRESULT hr = S_OK; + + CBRAEx (ic, E_INVALIDARG); + + hr = ic->RegisterSource (m_irqSource); + CHR (hr); + + m_ic = ic; + m_irqBound = true; + +Error: + return hr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// GetIfr +// +// IFR bit 7 is a computed summary: set whenever any enabled flag is +// pending. The stored m_ifr holds only bits 0..6. +// +//////////////////////////////////////////////////////////////////////////////// + +Byte Via6522::GetIfr () const +{ + Byte ifr = static_cast (m_ifr & 0x7F); + + if ((m_ifr & m_ier & 0x7F) != 0) + { + ifr |= kIrqAny; + } + + return ifr; +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TickTimer1 +// +// 16-bit down-counter. The counter reaches its first underflow counter+1 +// cycles after a load. In continuous mode it reloads from the latch and +// keeps firing every latch+1 cycles; in one-shot mode it fires once and +// then free-runs through 0xFFFF without setting the flag again. A single +// batched Tick may cross several periods, so the counter position is +// restored with modular arithmetic. +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::TickTimer1 (uint32_t cycles) +{ + bool freeRun = (m_acr & kAcrT1Continuous) != 0; + int64_t counter = m_t1Counter; + int64_t toUnderflow = counter + 1; + int64_t remaining = 0; + int64_t latch = (static_cast (m_t1LatchHi) << 8) | m_t1LatchLo; + int64_t period = latch + 1; + int64_t into = 0; + + if (static_cast (cycles) < toUnderflow) + { + m_t1Counter = static_cast (counter - cycles); + return; + } + + if (m_t1Armed) + { + SetFlag (kIrqTimer1); + + if (!freeRun) + { + m_t1Armed = false; + } + } + + remaining = static_cast (cycles) - toUnderflow; + + if (freeRun) + { + into = remaining % period; + m_t1Counter = static_cast (latch - into); + } + else + { + m_t1Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); + } +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// TickTimer2 +// +// One-shot timed mode only. Fires once on underflow, then free-runs +// through 0xFFFF. PB6 pulse counting (ACR bit 5) is not modelled. +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::TickTimer2 (uint32_t cycles) +{ + int64_t counter = m_t2Counter; + int64_t toUnderflow = counter + 1; + int64_t remaining = 0; + + if (static_cast (cycles) < toUnderflow) + { + m_t2Counter = static_cast (counter - cycles); + return; + } + + if (m_t2Armed) + { + SetFlag (kIrqTimer2); + m_t2Armed = false; + } + + remaining = static_cast (cycles) - toUnderflow; + m_t2Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetFlag / ClearFlag +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::SetFlag (Byte flag) +{ + m_ifr |= static_cast (flag & 0x7F); + + UpdateIrq (); +} + + +void Via6522::ClearFlag (Byte flag) +{ + m_ifr &= static_cast (~(flag & 0x7F)); + + UpdateIrq (); +} + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// UpdateIrq +// +// Level-sensitive: the line follows (IFR & IER) continuously. +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::UpdateIrq () +{ + if (!m_irqBound || (m_ic == nullptr)) + { + return; + } + + if ((m_ifr & m_ier & 0x7F) != 0) + { + m_ic->Assert (m_irqSource); + } + else + { + m_ic->Clear (m_irqSource); + } +} diff --git a/CassoEmuCore/Devices/Mockingboard/Via6522.h b/CassoEmuCore/Devices/Mockingboard/Via6522.h new file mode 100644 index 00000000..e1eeea66 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/Via6522.h @@ -0,0 +1,163 @@ +#pragma once + +#include "Pch.h" + +#include "Core/IInterruptController.h" + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// Via6522 +// +// Clean-room MOS/Rockwell 6522 Versatile Interface Adapter, implemented +// from the datasheet register map. Generic and reusable: the class knows +// nothing about the Mockingboard -- it exposes a 16-register file, two +// 8-bit ports with data-direction registers, Timer 1 / Timer 2, and the +// IFR/IER interrupt logic that drives a single IRQ line. The owning +// device (MockingboardCard) maps CPU-bus addresses onto ReadRegister / +// WriteRegister and advances the timers via Tick(). +// +// Register file (offset 0..15): +// +// $0 ORB/IRB $8 T2C-L (read clears T2 flag; write -> T2 low latch) +// $1 ORA/IRA $9 T2C-H (write loads counter, clears T2 flag, starts) +// $2 DDRB $A SR (shift register -- stored, not clocked) +// $3 DDRA $B ACR (T1/T2 mode select; SR/latch bits stored) +// $4 T1C-L $C PCR (handshake control -- stored, not modelled) +// $5 T1C-H $D IFR (bit7 = IRQ summary, bit6 = T1, bit5 = T2, ...) +// $6 T1L-L $E IER (bit7 = set/clear control, per-source enables) +// $7 T1L-H $F ORA/IRA (same as $1, no CA handshake) +// +// Timer 1 is a 16-bit down-counter clocked at the CPU phi2 rate. It is +// loaded from the latch on a T1C-H write and underflows (setting IFR +// bit 6) latch+1 cycles later. In one-shot mode (ACR bit 6 = 0) it fires +// once; in continuous mode (ACR bit 6 = 1) it reloads from the latch and +// re-fires every latch+1 cycles -- the periodic IRQ that Mockingboard +// music players use for timing. Timer 2 is one-shot timed only; PB6 +// pulse counting (ACR bit 5 = 1) is not modelled. +// +// Modelled: full register file, ports A/B + DDRs, Timer 1 (one-shot and +// continuous) and Timer 2 (one-shot), IFR/IER and the level-sensitive +// IRQ line. NOT modelled (registers stored, behaviour absent): the shift +// register clocking, CA1/CA2/CB1/CB2 handshaking, PB6 pulse counting, +// and PB7 timer output. The Mockingboard needs none of these. +// +//////////////////////////////////////////////////////////////////////////////// + +class Via6522 +{ +public: + static constexpr Byte kRegOrb = 0x0; + static constexpr Byte kRegOra = 0x1; + static constexpr Byte kRegDdrb = 0x2; + static constexpr Byte kRegDdra = 0x3; + static constexpr Byte kRegT1CL = 0x4; + static constexpr Byte kRegT1CH = 0x5; + static constexpr Byte kRegT1LL = 0x6; + static constexpr Byte kRegT1LH = 0x7; + static constexpr Byte kRegT2CL = 0x8; + static constexpr Byte kRegT2CH = 0x9; + static constexpr Byte kRegSr = 0xA; + static constexpr Byte kRegAcr = 0xB; + static constexpr Byte kRegPcr = 0xC; + static constexpr Byte kRegIfr = 0xD; + static constexpr Byte kRegIer = 0xE; + static constexpr Byte kRegOraNh = 0xF; + static constexpr Byte kRegisterCount = 16; + static constexpr Byte kRegisterMask = 0x0F; + + // IFR / IER bit assignments (datasheet Table). + static constexpr Byte kIrqCa2 = 0x01; + static constexpr Byte kIrqCa1 = 0x02; + static constexpr Byte kIrqShift = 0x04; + static constexpr Byte kIrqCb2 = 0x08; + static constexpr Byte kIrqCb1 = 0x10; + static constexpr Byte kIrqTimer2 = 0x20; + static constexpr Byte kIrqTimer1 = 0x40; + static constexpr Byte kIrqAny = 0x80; + + // ACR control bits. + static constexpr Byte kAcrT2PulseCount = 0x20; // 0 = timed one-shot + static constexpr Byte kAcrT1Continuous = 0x40; // 0 = one-shot + static constexpr Byte kAcrT1Pb7Output = 0x80; // PB7 output (stored) + + // IER write control bit: 1 = set the flagged enables, 0 = clear them. + static constexpr Byte kIerSetClear = 0x80; + + Via6522 () { Reset (); } + + // Register-file access (reg is masked to 0..15). Reads of the timer + // low-counter registers and writes of the flag registers have the + // documented interrupt-flag side effects. + Byte ReadRegister (Byte reg); + void WriteRegister (Byte reg, Byte value); + + // Advance both timers by `cycles` phi2 clocks, firing interrupts on + // underflow. + void Tick (uint32_t cycles); + + void Reset (); + + // Register the VIA's IRQ source with a caller-owned controller that + // outlives the VIA. The line is level-sensitive: it is re-driven from + // (IFR & IER) on every state change. + HRESULT AttachInterruptController (IInterruptController * ic); + + // Effective pin state on each port: output bits come from the output + // register, input bits from the external input latch. + Byte GetPortA () const { return static_cast ((m_ora & m_ddra) | (m_portAIn & ~m_ddra)); } + Byte GetPortB () const { return static_cast ((m_orb & m_ddrb) | (m_portBIn & ~m_ddrb)); } + + // Drive the external input pins (used by the card to hand the AY data + // bus back to the CPU during a PSG read). + void SetPortAInput (Byte value) { m_portAIn = value; } + void SetPortBInput (Byte value) { m_portBIn = value; } + + // Inspectors for tests. + Byte GetIfr () const; + Byte GetIer () const { return static_cast (m_ier | kIrqAny); } + bool IsIrqAsserted () const { return (m_ifr & m_ier & 0x7F) != 0; } + uint16_t GetTimer1 () const { return static_cast (m_t1Counter); } + uint16_t GetTimer2 () const { return static_cast (m_t2Counter); } + Byte GetOra () const { return m_ora; } + Byte GetOrb () const { return m_orb; } + Byte GetDdra () const { return m_ddra; } + Byte GetDdrb () const { return m_ddrb; } + +private: + void TickTimer1 (uint32_t cycles); + void TickTimer2 (uint32_t cycles); + void SetFlag (Byte flag); + void ClearFlag (Byte flag); + void UpdateIrq (); + + Byte m_ora = 0; + Byte m_orb = 0; + Byte m_ddra = 0; + Byte m_ddrb = 0; + Byte m_portAIn = 0; + Byte m_portBIn = 0; + + Byte m_sr = 0; + Byte m_acr = 0; + Byte m_pcr = 0; + + // IFR holds flag bits 0..6 only; bit 7 (IRQ summary) is computed on read. + Byte m_ifr = 0; + Byte m_ier = 0; + + Byte m_t1LatchLo = 0; + Byte m_t1LatchHi = 0; + int32_t m_t1Counter = 0; + bool m_t1Armed = false; + + Byte m_t2LatchLo = 0; + int32_t m_t2Counter = 0; + bool m_t2Armed = false; + + IInterruptController * m_ic = nullptr; + IrqSourceId m_irqSource = 0; + bool m_irqBound = false; +}; diff --git a/README.md b/README.md index eadce88e..6844d0d8 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,29 @@ Two of the three built-in themes booting the [casso-rocks demo disk](Apple2/Demo The project includes: -- **Apple II platform emulator** — GUI-based Apple II, II+, and //e emulator with D3D11 rendering, WASAPI audio, Disk II controller with realistic mechanical sounds, analog game I/O (joystick/paddle via the PREAD timer), data-driven machine configs, 80-column text + Double Hi-Res, auxiliary RAM, audit-correct Language Card state machine, and cycle-accurate IRQ/NMI infrastructure. +- **Apple II platform emulator** — GUI-based Apple II, II+, and //e emulator with D3D11 rendering, WASAPI audio, Disk II controller with realistic mechanical sounds, Mockingboard sound card (dual 6522 VIA + AY-3-8910 PSG), analog game I/O (joystick/paddle via the PREAD timer), data-driven machine configs, 80-column text + Double Hi-Res, auxiliary RAM, audit-correct Language Card state machine, and cycle-accurate IRQ/NMI infrastructure. - **6502 CPU emulator** — passes [Klaus Dormann's functional test suite](https://github.com/Klaus2m5/6502_65C02_functional_tests) and all 151 legal-opcode sets from [Tom Harte's SingleStepTests](https://github.com/SingleStepTests/ProcessorTests) (10,000 vectors each). - **AS65-compatible assembler** — a from-scratch reimplementation of Frank A. Kingswood's AS65, intended as a drop-in replacement. Supports the complete AS65 syntax: macros, conditional assembly (`if`/`ifdef`/`ifndef`/`else`/`endif`), the full expression evaluator (arithmetic, bitwise, logical, shift, `<`/`>` byte selectors, current-PC `*`), `equ`/`=` constants, `include`, three-segment model (`code`/`data`/`bss`), AS65-style listing output, and AS65 command-line flags (`-l`, `-t`, `-s`, `-s2`, `-z`, `-c`, `-w`, `-d`, `-g`, ...) including flag concatenation (`-tlfile`). - **CLI tool** — runs as an AS65-style assembler by default, or with the `run` subcommand to load and execute a binary or assembly source. - **First-run asset bootstrap** — Casso fetches the ROMs, sample disks, and Disk II audio samples it needs on first launch (with user consent), so a fresh `Casso.exe` boots to a usable //e BASIC prompt with no manual setup. - **Headless test harness** — `HeadlessHost` drives the emulator with no Win32 window, enabling deterministic integration tests for cold boot, disk boot, video framebuffer hashing, and reset semantics. -- **1900+ unit tests** — comprehensive coverage of CPU instruction encoding, addressing modes, arithmetic, branching, assembler features, audio pipeline (speaker + drive), //e MMU + Language Card, video timing, Disk II nibble engine, WOZ + nibblized image formats, 80-col + DHGR video, reset semantics, perf budget, and backwards-compat for ][ and ][ plus machines. +- **2000+ unit tests** — comprehensive coverage of CPU instruction encoding, addressing modes, arithmetic, branching, assembler features, audio pipeline (speaker + drive + Mockingboard), 6522 VIA timers/IRQ + AY-3-8910 synthesis, //e MMU + Language Card, video timing, Disk II nibble engine, WOZ + nibblized image formats, 80-col + DHGR video, reset semantics, perf budget, and backwards-compat for ][ and ][ plus machines. ## What's New See [CHANGELOG.md](CHANGELOG.md) for the granular history. +### Mockingboard sound card (v1.7.0) + +Casso now emulates the Sweet Micro Systems Mockingboard A/C — the de-facto +Apple II audio standard. Two clean-room chip cores written from the datasheets +(a reusable **6522 VIA** and the **AY-3-8910 PSG**: 3 tone voices + noise + +envelope) render to stereo float PCM, with VIA Timer 1 driving the periodic +IRQs music players use for tempo. The card ships in slot 4 of the ][+ and //e +profiles; it can be muted live from Settings → Disk → Mockingboard or removed +from Settings → Machine. Games like *Ultima IV*, *Skyfox*, and *Music +Construction Set* get their real soundtracks back. + ### Reliable disk writes (v1.6.2–v1.6.3) Fixed several bugs that corrupted or silently dropped guest writes to `.dsk`, @@ -272,6 +283,7 @@ All 56 standard 6502 mnemonics are implemented. Validated against [Klaus Dormann - [x] Apple //e fidelity — cold boot to BASIC, audit-correct Language Card, 64 KB aux RAM, 80-column text + Double Hi-Res, soft reset vs. power cycle, IRQ/NMI dispatch, RDVBLBAR - [x] Disk II controller — DOS 3.3 / ProDOS `.dsk` / `.do` / `.po` nibblization + WOZ v1 / v2 with auto-flush on eject - [x] Disk II mechanical audio — stereo motor hum, head-step clicks, track-0 bump, disk insert / eject sounds, with a runtime Settings → Machine → Drive audio toggle. Built on a generic `IDriveAudioSink` / `IDriveAudioSource` / `DriveAudioMixer` abstraction so future drive types (//c internal 5.25, DuoDisk, ProFile, …) plug in without touching the mixer +- [x] Mockingboard A/C sound card — clean-room 6522 VIA + AY-3-8910 PSG (3 tone voices + noise + envelope), stereo PCM, Timer 1 tempo IRQs, slot-4 install on ][+ / //e, live mute toggle ([#66](https://github.com/relmer/Casso/issues/66)) - [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)) diff --git a/Resources/Machines/Apple2Plus/Apple2Plus.json b/Resources/Machines/Apple2Plus/Apple2Plus.json index b10751c4..fc25bf76 100644 --- a/Resources/Machines/Apple2Plus/Apple2Plus.json +++ b/Resources/Machines/Apple2Plus/Apple2Plus.json @@ -3,7 +3,7 @@ // To customize, copy it to Machines//.json (any name // other than the three embedded defaults) and edit that copy. { - "$cassoMachineVersion": 7, + "$cassoMachineVersion": 8, "name": "Apple ][ plus", "releaseYear": 1979, "cpu": "6502", @@ -30,6 +30,7 @@ { "type": "apple2-gameport" } ], "slots": [ + { "slot": 4, "device": "mockingboard" }, { "slot": 6, "device": "disk-ii", "rom": "Disk2.rom" } ], "video": { diff --git a/Resources/Machines/Apple2e/Apple2e.json b/Resources/Machines/Apple2e/Apple2e.json index 23e24883..c0e3b832 100644 --- a/Resources/Machines/Apple2e/Apple2e.json +++ b/Resources/Machines/Apple2e/Apple2e.json @@ -3,7 +3,7 @@ // To customize, copy it to Machines//.json (any name // other than the three embedded defaults) and edit that copy. { - "$cassoMachineVersion": 6, + "$cassoMachineVersion": 7, "name": "Apple //e", "releaseYear": 1983, "cpu": "6502", @@ -32,6 +32,7 @@ { "type": "language-card" } ], "slots": [ + { "slot": 4, "device": "mockingboard" }, { "slot": 6, "device": "disk-ii", "rom": "Disk2.rom" } ], "video": { diff --git a/UnitTest/EmuTests/Ay8910Tests.cpp b/UnitTest/EmuTests/Ay8910Tests.cpp new file mode 100644 index 00000000..528582f8 --- /dev/null +++ b/UnitTest/EmuTests/Ay8910Tests.cpp @@ -0,0 +1,326 @@ +#include "Pch.h" + +#include "Devices/Mockingboard/Ay8910.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + + + + +namespace Ay8910TestNs +{ + static constexpr double kClockHz = 1022727.0; + static constexpr uint32_t kSampleRate = 44100; + + + + + //////////////////////////////////////////////////////////////////////////// + // + // Ay8910Tests + // + //////////////////////////////////////////////////////////////////////////// + + TEST_CLASS (Ay8910Tests) + { + public: + TEST_METHOD (ResetClearsRegisters) + { + Ay8910 ay (kClockHz); + + + + for (Byte r = 0; r < Ay8910::kRegCount; r++) + { + Assert::AreEqual (0, ay.ReadRegister (r)); + } + + Assert::AreEqual (0, ay.GetEnvLevel ()); + } + + + TEST_METHOD (LatchAndWriteDataRoutesToRegister) + { + Ay8910 ay (kClockHz); + + + + ay.LatchAddress (Ay8910::kRegAmpA); + ay.WriteData (0x0C); + + Assert::AreEqual (Ay8910::kRegAmpA, ay.GetLatchedAddress ()); + Assert::AreEqual (0x0C, ay.ReadRegister (Ay8910::kRegAmpA)); + + ay.LatchAddress (Ay8910::kRegAmpA); + Assert::AreEqual (0x0C, ay.ReadData ()); + } + + + TEST_METHOD (OutOfRangeLatchIsIgnored) + { + Ay8910 ay (kClockHz); + + + + // Only 16 registers exist; a latched address of 16+ is inert. + ay.LatchAddress (0x20); + ay.WriteData (0x55); + + Assert::AreEqual (0xFF, ay.ReadData ()); + } + + + TEST_METHOD (ToneFrequencyMatchesDatasheetFormula) + { + Ay8910 ay (kClockHz); + int period = 254; + int toggles = 0; + bool prevState = false; + uint32_t i = 0; + double expectedHz = kClockHz / (16.0 * period); + double measuredHz = 0.0; + + + + ay.SetSampleRate (kSampleRate); + + // Channel A tone period = 254 (fine=0xFE, coarse=0x00). + ay.WriteRegister (Ay8910::kRegToneAFine, static_cast (period & 0xFF)); + ay.WriteRegister (Ay8910::kRegToneACoarse, static_cast ((period >> 8) & 0x0F)); + + prevState = ay.GetToneState (0); + + // Generate exactly one second and count the square-wave toggles. + for (i = 0; i < kSampleRate; i++) + { + ay.GenerateSample (); + + if (ay.GetToneState (0) != prevState) + { + toggles++; + prevState = ay.GetToneState (0); + } + } + + // Two toggles per full cycle. + measuredHz = toggles / 2.0; + + Assert::IsTrue (std::abs (measuredHz - expectedHz) < expectedHz * 0.02, + L"Tone frequency must match clock/(16*period) within 2%"); + } + + + TEST_METHOD (SilentChipProducesNoOutput) + { + Ay8910 ay (kClockHz); + uint32_t i = 0; + float sum = 0.0f; + + + + ay.SetSampleRate (kSampleRate); + + // Mixer default 0 => all tone/noise enabled, but every channel + // amplitude is 0, so the DAC output is silence. + for (i = 0; i < 1000; i++) + { + sum += ay.GenerateSample (); + } + + Assert::AreEqual (0.0f, sum, L"Zero amplitude must yield pure silence"); + } + + + TEST_METHOD (AmplitudeScalesOutput) + { + Ay8910 ay (kClockHz); + uint32_t i = 0; + float peak = 0.0f; + + + + ay.SetSampleRate (kSampleRate); + + // Channel A: tone enabled, noise disabled, full amplitude. + ay.WriteRegister (Ay8910::kRegToneAFine, 0xFF); + ay.WriteRegister (Ay8910::kRegToneACoarse, 0x01); + ay.WriteRegister (Ay8910::kRegMixer, 0x3E); // tone A on, everything else off + ay.WriteRegister (Ay8910::kRegAmpA, 0x0F); + + for (i = 0; i < 5000; i++) + { + float s = ay.GenerateSample (); + + if (s > peak) + { + peak = s; + } + } + + Assert::IsTrue (peak > 0.9f, + L"Full-amplitude tone should peak near full scale"); + } + + + TEST_METHOD (NoiseOutputVaries) + { + Ay8910 ay (kClockHz); + uint32_t i = 0; + float minv = 1.0e9f; + float maxv = -1.0e9f; + + + + ay.SetSampleRate (kSampleRate); + + // Channel A: noise enabled, tone disabled, full amplitude. + ay.WriteRegister (Ay8910::kRegNoisePeriod, 0x01); + ay.WriteRegister (Ay8910::kRegMixer, 0x37); // noise A on, tone A off + ay.WriteRegister (Ay8910::kRegAmpA, 0x0F); + + for (i = 0; i < 5000; i++) + { + float s = ay.GenerateSample (); + + minv = (s < minv) ? s : minv; + maxv = (s > maxv) ? s : maxv; + } + + Assert::IsTrue (maxv > minv, + L"Noise must produce a varying output"); + Assert::AreNotEqual (1u, ay.GetNoiseLfsr (), + L"LFSR must have advanced from its seed"); + } + + + TEST_METHOD (EnvelopeSawtoothUpRampsAndRepeats) + { + Ay8910 ay (kClockHz); + uint32_t i = 0; + int maxLevel = 0; + bool dropped = false; + int lastPeak = 0; + + + + ay.SetSampleRate (kSampleRate); + + ay.WriteRegister (Ay8910::kRegEnvFine, 0x04); + ay.WriteRegister (Ay8910::kRegEnvCoarse, 0x00); + ay.WriteRegister (Ay8910::kRegAmpA, Ay8910::kAmpUseEnvelope); + ay.WriteRegister (Ay8910::kRegEnvShape, 0x0C); // /|/| sawtooth up, repeating + + for (i = 0; i < 4000; i++) + { + ay.GenerateSample (); + + int level = ay.GetEnvLevel (); + + maxLevel = (level > maxLevel) ? level : maxLevel; + + // Detect the reset back toward 0 after reaching the top. + if (lastPeak == Ay8910::kMaxEnvLevel && level < 4) + { + dropped = true; + } + + lastPeak = level; + } + + Assert::AreEqual (Ay8910::kMaxEnvLevel, maxLevel, + L"Sawtooth-up envelope must reach full level"); + Assert::IsTrue (dropped, L"Repeating sawtooth must restart from the bottom"); + Assert::IsFalse (ay.IsEnvHolding ()); + } + + + TEST_METHOD (EnvelopeOneShotDecayHoldsAtZero) + { + Ay8910 ay (kClockHz); + uint32_t i = 0; + + + + ay.SetSampleRate (kSampleRate); + + ay.WriteRegister (Ay8910::kRegEnvFine, 0x04); + ay.WriteRegister (Ay8910::kRegEnvCoarse, 0x00); + ay.WriteRegister (Ay8910::kRegAmpA, Ay8910::kAmpUseEnvelope); + ay.WriteRegister (Ay8910::kRegEnvShape, 0x00); // \___ decay then hold at 0 + + for (i = 0; i < 4000; i++) + { + ay.GenerateSample (); + } + + Assert::AreEqual (0, ay.GetEnvLevel (), + L"One-shot decay must settle at 0"); + Assert::IsTrue (ay.IsEnvHolding (), + L"One-shot envelope must hold when complete"); + } + + + TEST_METHOD (EnvelopeAttackHoldReachesAndHoldsTop) + { + Ay8910 ay (kClockHz); + uint32_t i = 0; + + + + ay.SetSampleRate (kSampleRate); + + ay.WriteRegister (Ay8910::kRegEnvFine, 0x04); + ay.WriteRegister (Ay8910::kRegEnvCoarse, 0x00); + ay.WriteRegister (Ay8910::kRegAmpA, Ay8910::kAmpUseEnvelope); + ay.WriteRegister (Ay8910::kRegEnvShape, 0x0D); // /--- attack then hold at 15 + + for (i = 0; i < 4000; i++) + { + ay.GenerateSample (); + } + + Assert::AreEqual (Ay8910::kMaxEnvLevel, ay.GetEnvLevel (), + L"Attack-hold envelope must hold at full level"); + Assert::IsTrue (ay.IsEnvHolding ()); + } + + + TEST_METHOD (OutputIsDeterministic) + { + Ay8910 a (kClockHz); + Ay8910 b (kClockHz); + uint32_t i = 0; + + + + a.SetSampleRate (kSampleRate); + b.SetSampleRate (kSampleRate); + + for (Byte r = 0; r < Ay8910::kRegCount; r++) + { + a.WriteRegister (r, static_cast (0x11 * (r + 1))); + b.WriteRegister (r, static_cast (0x11 * (r + 1))); + } + + // Two identically-programmed chips must render bit-identical + // streams -- the "reference render" reproducibility guarantee. + for (i = 0; i < 8000; i++) + { + Assert::AreEqual (a.GenerateSample (), b.GenerateSample ()); + } + } + + + TEST_METHOD (VolumeForLevelIsMonotonic) + { + for (int level = 1; level <= Ay8910::kMaxEnvLevel; level++) + { + Assert::IsTrue (Ay8910::VolumeForLevel (level) > Ay8910::VolumeForLevel (level - 1), + L"DAC table must increase monotonically"); + } + + Assert::AreEqual (0.0f, Ay8910::VolumeForLevel (0)); + Assert::AreEqual (1.0f, Ay8910::VolumeForLevel (Ay8910::kMaxEnvLevel)); + } + }; +} diff --git a/UnitTest/EmuTests/BackwardsCompatTests.cpp b/UnitTest/EmuTests/BackwardsCompatTests.cpp index 0b0279ce..6b49e652 100644 --- a/UnitTest/EmuTests/BackwardsCompatTests.cpp +++ b/UnitTest/EmuTests/BackwardsCompatTests.cpp @@ -200,6 +200,28 @@ namespace return false; } + + + //////////////////////////////////////////////////////////////////////////// + // + // HasSlotDevice — true if `cfg` installs `device` in `slot`. + // + //////////////////////////////////////////////////////////////////////////// + + bool HasSlotDevice (const MachineConfig & cfg, int slot, const std::string & device) + { + size_t i; + + for (i = 0; i < cfg.slots.size (); i++) + { + if (cfg.slots[i].slot == slot && cfg.slots[i].device == device) + { + return true; + } + } + + return false; + } } @@ -546,8 +568,12 @@ TEST_CLASS (BackwardsCompatTests) Assert::AreEqual (size_t (4), config.internalDevices.size (), L"Apple2Plus.json internalDevices count must remain exactly 4"); - Assert::AreEqual (size_t (1), config.slots.size (), - L"Apple2Plus.json must declare exactly one slot (Disk II in slot 6)"); + Assert::AreEqual (size_t (2), config.slots.size (), + L"Apple2Plus.json must declare two slots (Mockingboard in slot 4, Disk II in slot 6)"); + Assert::IsTrue (HasSlotDevice (config, 4, "mockingboard"), + L"Apple2Plus.json must install a Mockingboard in slot 4"); + Assert::IsTrue (HasSlotDevice (config, 6, "disk-ii"), + L"Apple2Plus.json must keep the Disk II controller in slot 6"); } diff --git a/UnitTest/EmuTests/MmuTests.cpp b/UnitTest/EmuTests/MmuTests.cpp index 6ab12c0a..37ac9a9a 100644 --- a/UnitTest/EmuTests/MmuTests.cpp +++ b/UnitTest/EmuTests/MmuTests.cpp @@ -66,6 +66,27 @@ namespace bus.AddDevice (&sw); } }; + + + // Stand-in for an active slot-I/O card (e.g. a Mockingboard) that owns + // a $Cn00 page. Records the last access so the router-delegation tests + // can assert whether the device was reached. + class RecordingIoDevice : public MemoryDevice + { + public: + explicit RecordingIoDevice (Word start) : m_start (start) {} + + Byte Read (Word address) override { m_lastReadAddr = address; return 0x5A; } + void Write (Word address, Byte value) override { m_lastWriteAddr = address; m_lastWriteValue = value; } + Word GetStart () const override { return m_start; } + Word GetEnd () const override { return static_cast (m_start + 0xFF); } + void Reset () override {} + + Word m_start = 0; + Word m_lastReadAddr = 0; + Word m_lastWriteAddr = 0; + Byte m_lastWriteValue = 0; + }; } @@ -397,6 +418,40 @@ TEST_CLASS (MmuTests) L"Audit C8: INTCXROM=1 must shadow slot 6 with internal ROM"); } + TEST_METHOD (SlotIoDeviceReachableWhenIntCxRomClear) + { + MmuFixture f; + RecordingIoDevice io (0xC400); + + f.mmu.GetCxxxRouter ()->SetSlotIoDevice (4, &io); + f.mmu.SetIntCxRom (false); + + Assert::AreEqual (static_cast (0x5A), f.bus.ReadByte (0xC404), + L"INTCXROM=0 must route slot-4 $Cn00 reads to the active I/O device"); + Assert::AreEqual (static_cast (0xC404), io.m_lastReadAddr); + + f.bus.WriteByte (0xC405, 0x33); + Assert::AreEqual (static_cast (0xC405), io.m_lastWriteAddr); + Assert::AreEqual (static_cast (0x33), io.m_lastWriteValue); + } + + TEST_METHOD (SlotIoDeviceShadowedWhenIntCxRomSet) + { + MmuFixture f; + RecordingIoDevice io (0xC400); + + vector internal (0x0F00, 0xEE); + + f.mmu.AttachInternalCxxxRom (move (internal)); + f.mmu.GetCxxxRouter ()->SetSlotIoDevice (4, &io); + f.mmu.SetIntCxRom (true); + + Assert::AreEqual (static_cast (0xEE), f.bus.ReadByte (0xC404), + L"INTCXROM=1 must shadow the slot-4 I/O device with internal ROM"); + Assert::AreEqual (static_cast (0), io.m_lastReadAddr, + L"A shadowed device must not observe the read"); + } + TEST_METHOD (SlotC3Rom_ClearMapsInternal80ColFirmware) { MmuFixture f; diff --git a/UnitTest/EmuTests/MockingboardCardTests.cpp b/UnitTest/EmuTests/MockingboardCardTests.cpp new file mode 100644 index 00000000..d6f9444e --- /dev/null +++ b/UnitTest/EmuTests/MockingboardCardTests.cpp @@ -0,0 +1,293 @@ +#include "Pch.h" + +#include "Devices/Mockingboard/MockingboardCard.h" +#include "Core/InterruptController.h" +#include "Core/ComponentRegistry.h" +#include "Core/MemoryBus.h" +#include "Core/MachineConfig.h" +#include "ICpu.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + + + + +namespace MockingboardCardTestNs +{ + // Slot 4 register bases. + static constexpr Word kVia1Base = 0xC400; + static constexpr Word kVia2Base = 0xC480; + + // AY control-line values written to ORB (RESET released, PB2 high). + static constexpr Byte kOrbInactive = 0x04; + static constexpr Byte kOrbLatch = 0x07; + static constexpr Byte kOrbWrite = 0x06; + + + + + //////////////////////////////////////////////////////////////////////////// + // + // MbTestCpu + // + //////////////////////////////////////////////////////////////////////////// + + class MbTestCpu : public ICpu + { + public: + HRESULT Reset () override { return S_OK; } + HRESULT Step (uint32_t & outCycles) override { outCycles = 0; return S_OK; } + uint64_t GetCycleCount () const override { return 0; } + + void SetInterruptLine (CpuInterruptKind kind, bool asserted) override + { + if (kind == CpuInterruptKind::kMaskable) + { + m_irqAsserted = asserted; + } + } + + bool IrqAsserted () const { return m_irqAsserted; } + + private: + bool m_irqAsserted = false; + }; + + + + + //////////////////////////////////////////////////////////////////////////// + // + // MockingboardCardTests + // + //////////////////////////////////////////////////////////////////////////// + + TEST_CLASS (MockingboardCardTests) + { + public: + TEST_METHOD (ClaimsSlotIoPage) + { + MockingboardCard card (4); + + + + Assert::AreEqual (0xC400, card.GetStart ()); + Assert::AreEqual (0xC4FF, card.GetEnd ()); + } + + + TEST_METHOD (Address7SelectsSecondVia) + { + MockingboardCard card (4); + + + + // Writing ORA on each VIA must land on independent chips. + card.Write (kVia1Base + Via6522::kRegDdra, 0xFF); + card.Write (kVia2Base + Via6522::kRegDdra, 0xFF); + card.Write (kVia1Base + Via6522::kRegOra, 0x11); + card.Write (kVia2Base + Via6522::kRegOra, 0x22); + + Assert::AreEqual (0x11, card.GetVia (0).GetOra ()); + Assert::AreEqual (0x22, card.GetVia (1).GetOra ()); + } + + + TEST_METHOD (RegisterFileMirrorsEverySixteenBytes) + { + MockingboardCard card (4); + + + + card.Write (kVia1Base + Via6522::kRegDdra, 0xFF); + + // $C401 and $C411 both address ORA on VIA #1. + card.Write (0xC411, 0x77); + Assert::AreEqual (0x77, card.GetVia (0).GetOra ()); + } + + + TEST_METHOD (BusProtocolWritesPsgRegister) + { + MockingboardCard card (4); + + + + InitAy (card, kVia1Base); + WriteAy (card, kVia1Base, Ay8910::kRegAmpA, 0x0C); + WriteAy (card, kVia1Base, Ay8910::kRegToneAFine, 0xAB); + + Assert::AreEqual (0x0C, card.GetPsg (0).ReadRegister (Ay8910::kRegAmpA)); + Assert::AreEqual (0xAB, card.GetPsg (0).ReadRegister (Ay8910::kRegToneAFine)); + } + + + TEST_METHOD (SecondViaDrivesSecondPsg) + { + MockingboardCard card (4); + + + + InitAy (card, kVia1Base); + InitAy (card, kVia2Base); + + WriteAy (card, kVia2Base, Ay8910::kRegAmpB, 0x09); + + Assert::AreEqual (0x00, card.GetPsg (0).ReadRegister (Ay8910::kRegAmpB), + L"PSG #1 must be untouched by a PSG #2 write"); + Assert::AreEqual (0x09, card.GetPsg (1).ReadRegister (Ay8910::kRegAmpB)); + } + + + TEST_METHOD (ResetLineClearsPsg) + { + MockingboardCard card (4); + + + + InitAy (card, kVia1Base); + WriteAy (card, kVia1Base, Ay8910::kRegAmpA, 0x0F); + Assert::AreEqual (0x0F, card.GetPsg (0).ReadRegister (Ay8910::kRegAmpA)); + + // Drive PB2 low (RESET active) via ORB. + card.Write (kVia1Base + Via6522::kRegOrb, 0x00); + + Assert::AreEqual (0x00, card.GetPsg (0).ReadRegister (Ay8910::kRegAmpA), + L"Active-low RESET must clear the PSG registers"); + } + + + TEST_METHOD (Timer1ContinuousDrivesSharedIrq) + { + MbTestCpu cpu; + InterruptController ic (&cpu); + MockingboardCard card (4); + HRESULT hr = S_OK; + + + + hr = card.AttachInterruptController (&ic); + Assert::AreEqual (S_OK, hr); + + // VIA #1: enable T1 IRQ, continuous mode, latch = 99. + card.Write (kVia1Base + Via6522::kRegIer, + static_cast (Via6522::kIerSetClear | Via6522::kIrqTimer1)); + card.Write (kVia1Base + Via6522::kRegAcr, Via6522::kAcrT1Continuous); + card.Write (kVia1Base + Via6522::kRegT1CL, 0x63); + card.Write (kVia1Base + Via6522::kRegT1CH, 0x00); + + card.Tick (100); + Assert::IsTrue (cpu.IrqAsserted (), + L"Continuous Timer 1 must drive the shared IRQ line"); + + // Reading T1C-L on VIA #1 clears the flag and de-asserts. + card.Read (kVia1Base + Via6522::kRegT1CL); + Assert::IsFalse (cpu.IrqAsserted ()); + } + + + TEST_METHOD (SecondViaTimerAlsoDrivesIrq) + { + MbTestCpu cpu; + InterruptController ic (&cpu); + MockingboardCard card (4); + HRESULT hr = S_OK; + + + + hr = card.AttachInterruptController (&ic); + Assert::AreEqual (S_OK, hr); + + card.Write (kVia2Base + Via6522::kRegIer, + static_cast (Via6522::kIerSetClear | Via6522::kIrqTimer1)); + card.Write (kVia2Base + Via6522::kRegAcr, Via6522::kAcrT1Continuous); + card.Write (kVia2Base + Via6522::kRegT1CL, 0x14); + card.Write (kVia2Base + Via6522::kRegT1CH, 0x00); + + card.Tick (21); + Assert::IsTrue (cpu.IrqAsserted (), + L"VIA #2's timer shares the same interrupt controller"); + } + + + TEST_METHOD (ProgrammedToneProducesAudio) + { + MockingboardCard card (4); + float buffer[2000] = {}; + uint32_t i = 0; + float peak = 0.0f; + + + + InitAy (card, kVia1Base); + + // Tone A period ~0x01FF, tone A enabled in the mixer, full amp. + WriteAy (card, kVia1Base, Ay8910::kRegToneAFine, 0xFF); + WriteAy (card, kVia1Base, Ay8910::kRegToneACoarse, 0x01); + WriteAy (card, kVia1Base, Ay8910::kRegMixer, 0x3E); + WriteAy (card, kVia1Base, Ay8910::kRegAmpA, 0x0F); + + card.SetSampleRate (44100); + card.GetAudioSource (0)->GeneratePCM (buffer, 2000); + + for (i = 0; i < 2000; i++) + { + float mag = std::abs (buffer[i]); + + if (mag > peak) + { + peak = mag; + } + } + + Assert::IsTrue (peak > 0.01f, + L"A programmed tone must reach the audio source output"); + } + + + TEST_METHOD (FactoryPlacesCardInSlotIoSpace) + { + ComponentRegistry registry; + MemoryBus bus; + DeviceConfig config; + unique_ptr device = nullptr; + + + + ComponentRegistry::RegisterBuiltinDevices (registry); + Assert::IsTrue (registry.IsRegistered ("mockingboard"), + L"mockingboard must be a registered device type"); + + config.type = "mockingboard"; + config.slot = 4; + config.hasSlot = true; + + device = registry.Create ("mockingboard", config, bus); + Assert::IsNotNull (device.get ()); + Assert::AreEqual (0xC400, device->GetStart ()); + Assert::AreEqual (0xC4FF, device->GetEnd ()); + } + + + private: + // Ports A and B to outputs, RESET released (PB2 high, lines idle). + static void InitAy (MockingboardCard & card, Word base) + { + card.Write (base + Via6522::kRegDdrb, 0xFF); + card.Write (base + Via6522::kRegDdra, 0xFF); + card.Write (base + Via6522::kRegOrb, kOrbInactive); + } + + // Standard Mockingboard latch-address-then-write-data sequence. + static void WriteAy (MockingboardCard & card, Word base, Byte reg, Byte value) + { + card.Write (base + Via6522::kRegOra, reg); + card.Write (base + Via6522::kRegOrb, kOrbLatch); + card.Write (base + Via6522::kRegOrb, kOrbInactive); + + card.Write (base + Via6522::kRegOra, value); + card.Write (base + Via6522::kRegOrb, kOrbWrite); + card.Write (base + Via6522::kRegOrb, kOrbInactive); + } + }; +} diff --git a/UnitTest/EmuTests/Via6522Tests.cpp b/UnitTest/EmuTests/Via6522Tests.cpp new file mode 100644 index 00000000..a0a1820a --- /dev/null +++ b/UnitTest/EmuTests/Via6522Tests.cpp @@ -0,0 +1,296 @@ +#include "Pch.h" + +#include "Devices/Mockingboard/Via6522.h" +#include "Core/InterruptController.h" +#include "ICpu.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + + + + +namespace Via6522TestNs +{ + //////////////////////////////////////////////////////////////////////////// + // + // ViaTestCpu + // + // Minimal ICpu double recording the maskable IRQ line state so a VIA + // wired through a real InterruptController can be observed end to end. + // + //////////////////////////////////////////////////////////////////////////// + + class ViaTestCpu : public ICpu + { + public: + HRESULT Reset () override { return S_OK; } + HRESULT Step (uint32_t & outCycles) override { outCycles = 0; return S_OK; } + uint64_t GetCycleCount () const override { return 0; } + + void SetInterruptLine (CpuInterruptKind kind, bool asserted) override + { + if (kind == CpuInterruptKind::kMaskable) + { + m_irqAsserted = asserted; + } + } + + bool IrqAsserted () const { return m_irqAsserted; } + + private: + bool m_irqAsserted = false; + }; + + + + + //////////////////////////////////////////////////////////////////////////// + // + // Via6522Tests + // + //////////////////////////////////////////////////////////////////////////// + + TEST_CLASS (Via6522Tests) + { + public: + TEST_METHOD (ResetClearsRegistersAndInterrupts) + { + Via6522 via; + + + + Assert::AreEqual (0, via.GetIfr ()); + Assert::AreEqual (Via6522::kIrqAny, via.GetIer ()); + Assert::IsFalse (via.IsIrqAsserted ()); + Assert::AreEqual (0, via.GetTimer1 ()); + Assert::AreEqual (0, via.GetTimer2 ()); + } + + + TEST_METHOD (PortAOutputReadsBackThroughDdr) + { + Via6522 via; + + + + // All outputs: the output register drives every pin. + via.WriteRegister (Via6522::kRegDdra, 0xFF); + via.WriteRegister (Via6522::kRegOra, 0x5A); + Assert::AreEqual (0x5A, via.GetPortA ()); + Assert::AreEqual (0x5A, via.ReadRegister (Via6522::kRegOra)); + + // All inputs: the external latch drives every pin. + via.WriteRegister (Via6522::kRegDdra, 0x00); + via.SetPortAInput (0x3C); + Assert::AreEqual (0x3C, via.GetPortA ()); + } + + + TEST_METHOD (PortBMixedDirectionMergesOutputAndInput) + { + Via6522 via; + + + + // Low nibble output, high nibble input. + via.WriteRegister (Via6522::kRegDdrb, 0x0F); + via.WriteRegister (Via6522::kRegOrb, 0xAA); // outputs -> low nibble 0xA + via.SetPortBInput (0x55); // inputs -> high nibble 0x5 + + Assert::AreEqual (0x5A, via.GetPortB ()); + } + + + TEST_METHOD (Timer1OneShotFiresExactlyOnce) + { + Via6522 via; + + + + EnableTimer1Irq (via); + + // Latch = 100 -> underflow 101 cycles after the T1C-H load. + LoadTimer1 (via, 100); + + via.Tick (100); + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer1) != 0, + L"Must not fire one cycle early"); + + via.Tick (1); + Assert::IsTrue ((via.GetIfr () & Via6522::kIrqTimer1) != 0, + L"Timer 1 must fire on underflow"); + Assert::IsTrue (via.IsIrqAsserted ()); + + // Clear the flag and confirm one-shot never re-fires. + via.ReadRegister (Via6522::kRegT1CL); + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer1) != 0); + + via.Tick (100000); + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer1) != 0, + L"One-shot must not re-arm on its own"); + } + + + TEST_METHOD (Timer1ContinuousReloadsAndRefires) + { + Via6522 via; + + + + EnableTimer1Irq (via); + via.WriteRegister (Via6522::kRegAcr, Via6522::kAcrT1Continuous); + LoadTimer1 (via, 100); + + via.Tick (101); + Assert::IsTrue ((via.GetIfr () & Via6522::kIrqTimer1) != 0, + L"First continuous underflow"); + Assert::AreEqual (100, via.GetTimer1 (), + L"Continuous mode reloads from the latch"); + + via.ReadRegister (Via6522::kRegT1CL); // clears the flag + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer1) != 0); + + via.Tick (101); + Assert::IsTrue ((via.GetIfr () & Via6522::kIrqTimer1) != 0, + L"Continuous mode re-fires every latch+1 cycles"); + } + + + TEST_METHOD (Timer1CounterCountsDownOnPartialTick) + { + Via6522 via; + + + + LoadTimer1 (via, 0x1000); + via.Tick (0x100); + + Assert::AreEqual (0x0F00, via.GetTimer1 ()); + Assert::AreEqual (0x00, via.ReadRegister (Via6522::kRegT1CL)); + Assert::AreEqual (0x0F, via.ReadRegister (Via6522::kRegT1CH)); + } + + + TEST_METHOD (Timer1FlagClearedByWritingLatchHigh) + { + Via6522 via; + + + + EnableTimer1Irq (via); + LoadTimer1 (via, 10); + via.Tick (11); + Assert::IsTrue ((via.GetIfr () & Via6522::kIrqTimer1) != 0); + + via.WriteRegister (Via6522::kRegT1LH, 0x00); + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer1) != 0, + L"Writing T1L-H clears the Timer 1 flag"); + } + + + TEST_METHOD (Timer2OneShotFiresOnce) + { + Via6522 via; + + + + via.WriteRegister (Via6522::kRegIer, + static_cast (Via6522::kIerSetClear | Via6522::kIrqTimer2)); + + // Latch low then load counter high: count = 0x0032 = 50. + via.WriteRegister (Via6522::kRegT2CL, 0x32); + via.WriteRegister (Via6522::kRegT2CH, 0x00); + + via.Tick (50); + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer2) != 0); + + via.Tick (1); + Assert::IsTrue ((via.GetIfr () & Via6522::kIrqTimer2) != 0, + L"Timer 2 fires on underflow"); + Assert::IsTrue (via.IsIrqAsserted ()); + + via.ReadRegister (Via6522::kRegT2CL); // clears the flag + via.Tick (100000); + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqTimer2) != 0, + L"Timer 2 is one-shot only"); + } + + + TEST_METHOD (IfrSummaryBitTracksEnabledFlags) + { + Via6522 via; + + + + LoadTimer1 (via, 5); + via.Tick (6); + + // Flag pending but not enabled: no summary bit, no line. + Assert::IsFalse ((via.GetIfr () & Via6522::kIrqAny) != 0); + Assert::IsFalse (via.IsIrqAsserted ()); + + // Enabling the source now surfaces the summary bit and the line. + via.WriteRegister (Via6522::kRegIer, + static_cast (Via6522::kIerSetClear | Via6522::kIrqTimer1)); + Assert::IsTrue ((via.GetIfr () & Via6522::kIrqAny) != 0); + Assert::IsTrue (via.IsIrqAsserted ()); + } + + + TEST_METHOD (IerSetAndClearControl) + { + Via6522 via; + + + + via.WriteRegister (Via6522::kRegIer, + static_cast (Via6522::kIerSetClear | Via6522::kIrqTimer1 | Via6522::kIrqTimer2)); + Assert::AreEqual (static_cast (Via6522::kIrqAny | Via6522::kIrqTimer1 | Via6522::kIrqTimer2), + via.GetIer ()); + + // Clearing (bit 7 = 0) disables only the flagged sources. + via.WriteRegister (Via6522::kRegIer, Via6522::kIrqTimer2); + Assert::AreEqual (static_cast (Via6522::kIrqAny | Via6522::kIrqTimer1), + via.GetIer ()); + } + + + TEST_METHOD (InterruptControllerSeesTimerIrq) + { + ViaTestCpu cpu; + InterruptController ic (&cpu); + Via6522 via; + HRESULT hr = S_OK; + + + + hr = via.AttachInterruptController (&ic); + Assert::AreEqual (S_OK, hr); + + EnableTimer1Irq (via); + LoadTimer1 (via, 20); + + via.Tick (21); + Assert::IsTrue (cpu.IrqAsserted (), + L"Timer 1 underflow must drive the shared IRQ line"); + + via.ReadRegister (Via6522::kRegT1CL); + Assert::IsFalse (cpu.IrqAsserted (), + L"Clearing the flag must de-assert the line"); + } + + + private: + static void EnableTimer1Irq (Via6522 & via) + { + via.WriteRegister (Via6522::kRegIer, + static_cast (Via6522::kIerSetClear | Via6522::kIrqTimer1)); + } + + static void LoadTimer1 (Via6522 & via, uint16_t latch) + { + via.WriteRegister (Via6522::kRegT1CL, static_cast (latch & 0xFF)); + via.WriteRegister (Via6522::kRegT1CH, static_cast ((latch >> 8) & 0xFF)); + } + }; +} diff --git a/UnitTest/UiTests/SettingsPanelStateTests.cpp b/UnitTest/UiTests/SettingsPanelStateTests.cpp index 0aa60f39..7ff7783a 100644 --- a/UnitTest/UiTests/SettingsPanelStateTests.cpp +++ b/UnitTest/UiTests/SettingsPanelStateTests.cpp @@ -28,6 +28,7 @@ namespace SettingsSpeedMode lastSpeed = SettingsSpeedMode::Authentic; SettingsColorMode lastColor = SettingsColorMode::Color; bool lastFloppySound = true; + bool lastMockingboard = true; std::string lastMechanism; bool lastWriteProtect[2] = { false, false }; float lastDriveMotor = -1.0f; @@ -41,6 +42,7 @@ namespace void ApplySpeedMode (SettingsSpeedMode mode) override { lastSpeed = mode; ++applyCount; } void ApplyColorMode (SettingsColorMode mode) override { lastColor = mode; ++applyCount; } void ApplyFloppySound (bool enabled) override { lastFloppySound = enabled; ++applyCount; } + void ApplyMockingboard (bool enabled) override { lastMockingboard = enabled; ++applyCount; } void ApplyMechanism (const std::string & m) override { lastMechanism = m; ++applyCount; } void ApplyDriveVolumes (float motor, float head, float door) override { diff --git a/UnitTest/UnitTest.vcxproj b/UnitTest/UnitTest.vcxproj index cf1264b8..025cd6f2 100644 --- a/UnitTest/UnitTest.vcxproj +++ b/UnitTest/UnitTest.vcxproj @@ -275,6 +275,9 @@ + + + From ab1192d92658e546ab8a1209ef1db3de263d052d Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Sat, 11 Jul 2026 13:47:22 -0700 Subject: [PATCH 2/7] fix(cpu): service maskable IRQ/NMI in the shell run loop The emulator run loop (ExecuteCpuSlices plus both single-step paths) drove the CPU exclusively through EmuCpu::StepOne(), which executes one opcode without checking the interrupt line -- the interrupt-aware Cpu6502::Step() was never called. So no device interrupt was ever serviced during emulation. This stayed latent because the Mockingboard is the first device in Casso to assert a maskable IRQ; the keyboard, Disk II and speaker never do. A Mockingboard music player would arm its Timer1 IRQ and hang waiting on a handler that never ran. Add Cpu6502::TryStepInterrupt(): dispatches a pending NMI edge or an unmasked IRQ via DispatchVector, records the 7-cycle cost in m_lastCycles, and (unlike TryDispatchInterrupt) leaves m_totalCycles to the caller's AddCycles -- so an interrupt step and an instruction step advance the counters through one identical path, with no double-count. The unit-tested Step()/TryDispatchInterrupt() path is untouched. Gate all three run-loop step sites on it: if TryStepInterrupt() dispatched, skip StepOne() for that step. Regression coverage (CpuIrqTests) -- the gap that let this ship: - three host-loop cases driving the exact StepOne pattern the shell uses (IRQ serviced, I-flag honored, NMI edge), and - an end-to-end Via6522 -> InterruptController -> 6502 test proving a Timer1 IRQ vectors the CPU through the StepOne loop. Full suite 2085/2085; clean build; validated audibly with the IRQ-driven Mockingboard test disk. --- Casso/EmulatorShell.cpp | 15 +++- CassoCore/Cpu6502.cpp | 42 +++++++++++ CassoCore/Cpu6502.h | 12 +++ CassoEmuCore/Core/EmuCpu.h | 7 ++ UnitTest/CpuIrqTests.cpp | 151 +++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 3 deletions(-) diff --git a/Casso/EmulatorShell.cpp b/Casso/EmulatorShell.cpp index 60746cd5..b49667ab 100644 --- a/Casso/EmulatorShell.cpp +++ b/Casso/EmulatorShell.cpp @@ -2978,7 +2978,10 @@ void EmulatorShell::DispatchCpuCommand (const EmulatorCommand & cmd) { if (m_cpu) { - m_cpu->StepOne(); + if (!m_cpu->TryStepInterrupt()) + { + m_cpu->StepOne(); + } if (m_refs.diskController != nullptr) { @@ -3238,7 +3241,10 @@ void EmulatorShell::StepInstructionWhilePaused() return; } - m_cpu->StepOne(); + if (!m_cpu->TryStepInterrupt()) + { + m_cpu->StepOne(); + } if (m_refs.diskController != nullptr) { @@ -3351,7 +3357,10 @@ void EmulatorShell::ExecuteCpuSlices() while (sliceActual < sliceTarget) { - m_cpu->StepOne(); + if (!m_cpu->TryStepInterrupt()) + { + m_cpu->StepOne(); + } cycles = m_cpu->GetLastInstructionCycles(); diff --git a/CassoCore/Cpu6502.cpp b/CassoCore/Cpu6502.cpp index ef04381c..6e062c24 100644 --- a/CassoCore/Cpu6502.cpp +++ b/CassoCore/Cpu6502.cpp @@ -237,6 +237,48 @@ bool Cpu6502::TryDispatchInterrupt (uint32_t & outCycles) +//////////////////////////////////////////////////////////////////////////////// +// +// TryStepInterrupt +// +// Host-loop companion to TryDispatchInterrupt. Same dispatch decision (NMI +// edge, or IRQ when I == 0) and the same 7-cycle cost, but the cost is +// recorded in m_lastCycles rather than rolled into m_totalCycles here: the +// caller's StepOne + AddCycles path owns the accumulator, so an interrupt step +// and a normal instruction step account identically. Returns true if an +// interrupt was taken (the caller then skips StepOne for this step). +// +//////////////////////////////////////////////////////////////////////////////// + +bool Cpu6502::TryStepInterrupt () +{ + bool dispatched = false; + + + + if (m_nmiPending) + { + m_nmiPending = false; + + DispatchVector (nmiVector, false); + + m_lastCycles = 7; + dispatched = true; + } + else if (m_irqLine && status.flags.interruptDisable == 0) + { + DispatchVector (irqVector, false); + + m_lastCycles = 7; + dispatched = true; + } + + return dispatched; +} + + + + //////////////////////////////////////////////////////////////////////////////// // diff --git a/CassoCore/Cpu6502.h b/CassoCore/Cpu6502.h index 2547687b..6c8ef0e3 100644 --- a/CassoCore/Cpu6502.h +++ b/CassoCore/Cpu6502.h @@ -59,6 +59,18 @@ class Cpu6502 : public Cpu, public ICpu, public I6502DebugInfo bool IsNmiLineAsserted () const { return m_nmiLine; } bool IsNmiPending () const { return m_nmiPending; } + // Host-driven interrupt dispatch for run loops that own their own cycle + // accounting (StepOne + AddCycles) rather than the self-accounting Step(). + // Dispatches a pending NMI edge or unmasked IRQ via DispatchVector, records + // the 7-cycle cost in m_lastCycles (so GetLastInstructionCycles reports it), + // and leaves m_totalCycles to the caller's AddCycles -- so an interrupt step + // and a normal instruction step advance the counters through one identical + // path. Returns true if an interrupt was taken, in which case the caller + // skips StepOne for this step. The shell run loop MUST call this each step + // or maskable IRQs are never serviced (the failure mode that hid until the + // Mockingboard became the first device to assert one). + bool TryStepInterrupt (); + protected: // Returns true if a pending NMI or unmasked IRQ was dispatched. On // dispatch, sets outCycles = 7 and accumulates m_totalCycles. diff --git a/CassoEmuCore/Core/EmuCpu.h b/CassoEmuCore/Core/EmuCpu.h index 5a1f9abd..64795b6f 100644 --- a/CassoEmuCore/Core/EmuCpu.h +++ b/CassoEmuCore/Core/EmuCpu.h @@ -75,6 +75,13 @@ class EmuCpu void StepOne () { m_cpu6502->StepOne (); } Byte GetLastInstructionCycles () const { return m_cpu6502->GetLastInstructionCycles (); } + // Interrupt-aware companion to StepOne for the shell run loop. Dispatches a + // pending NMI/IRQ (returns true) or does nothing (returns false); on a true + // return the caller skips StepOne and GetLastInstructionCycles() reports the + // 7-cycle interrupt cost. Callers that drive the CPU with raw StepOne MUST + // gate it on this or maskable IRQs are never serviced. + bool TryStepInterrupt () { return m_cpu6502->TryStepInterrupt (); } + // Execution trace (--trace switch). Forwarded to the underlying 6502. void EnableTrace (size_t capacity) { m_cpu6502->EnableTrace (capacity); } bool IsTraceEnabled () const { return m_cpu6502->IsTraceEnabled (); } diff --git a/UnitTest/CpuIrqTests.cpp b/UnitTest/CpuIrqTests.cpp index 216fe616..c55a91d7 100644 --- a/UnitTest/CpuIrqTests.cpp +++ b/UnitTest/CpuIrqTests.cpp @@ -3,6 +3,8 @@ #include "Cpu6502.h" #include "ICpu.h" #include "TestHelpers.h" +#include "Core/InterruptController.h" +#include "Devices/Mockingboard/Via6522.h" using namespace Microsoft::VisualStudio::CppUnitTestFramework; @@ -219,5 +221,154 @@ namespace Apple2eFidelity Assert::AreEqual (static_cast (7), cycles, L"NMI dispatch should consume 7 cycles"); } + + + // ------------------------------------------------------------------- + // Host-loop dispatch (TryStepInterrupt + StepOne). + // + // The shell run loop drives the CPU with StepOne -- which does NOT + // check interrupts -- gated on TryStepInterrupt. These tests exercise + // that gating directly. Without it the loop silently dropped every + // maskable IRQ, the regression that hid until the Mockingboard became + // the first device in Casso to assert one. + // ------------------------------------------------------------------- + + TEST_METHOD (HostStepLoopServicesAssertedIrq) + { + TestCpu cpu; + ICpu & iface = cpu; + bool tookIrq = false; + + + + cpu.InitForTest (kStartPc); + SeedVectors (cpu); + cpu.Poke (kStartPc, 0xEA); // NOP -- must NOT run; IRQ pre-empts it + cpu.Status ().flags.interruptDisable = 0; + + iface.SetInterruptLine (CpuInterruptKind::kMaskable, true); + + // One iteration of the shell's exact stepping pattern. + tookIrq = cpu.TryStepInterrupt (); + if (!tookIrq) + { + cpu.StepOne (); + } + + Assert::IsTrue (tookIrq, + L"Host step loop must dispatch the asserted IRQ"); + Assert::AreEqual (kIrqHandler, cpu.RegPC (), + L"PC must vector to the IRQ handler via the StepOne loop"); + Assert::AreEqual (static_cast (7), cpu.GetLastInstructionCycles (), + L"Interrupt step must report 7 cycles for host cycle accounting"); + } + + + TEST_METHOD (HostStepLoopHonorsIFlag) + { + TestCpu cpu; + ICpu & iface = cpu; + bool tookIrq = false; + + + + cpu.InitForTest (kStartPc); + SeedVectors (cpu); + cpu.Poke (kStartPc, 0xEA); // NOP -- must run because I=1 masks the IRQ + cpu.Status ().flags.interruptDisable = 1; + + iface.SetInterruptLine (CpuInterruptKind::kMaskable, true); + + tookIrq = cpu.TryStepInterrupt (); + if (!tookIrq) + { + cpu.StepOne (); + } + + Assert::IsFalse (tookIrq, L"IRQ must not dispatch while I=1"); + Assert::AreEqual (static_cast (kStartPc + 1), cpu.RegPC (), + L"With I=1 the NOP must run instead of vectoring"); + } + + + TEST_METHOD (HostStepLoopServicesNmiEdge) + { + TestCpu cpu; + ICpu & iface = cpu; + bool took = false; + + + + cpu.InitForTest (kStartPc); + SeedVectors (cpu); + cpu.Status ().flags.interruptDisable = 1; // NMI ignores I + + iface.SetInterruptLine (CpuInterruptKind::kNonMaskable, true); + + took = cpu.TryStepInterrupt (); + if (!took) + { + cpu.StepOne (); + } + + Assert::IsTrue (took, L"Host step loop must dispatch the NMI edge"); + Assert::AreEqual (kNmiHandler, cpu.RegPC (), + L"NMI must vector via $FFFA through the StepOne loop"); + } + + + TEST_METHOD (Via6522Timer1IrqReachesCpuThroughHostLoop) + { + // End-to-end reproduction of the Rescue Raiders "won't start" hang: + // a Mockingboard VIA Timer1 IRQ must actually vector the CPU when + // the shell drives it with the StepOne loop. Wires + // Via6522 -> InterruptController -> 6502 exactly as MachineManager + // does, then runs the host loop until (or fails to reach) the + // handler. + TestCpu cpu; + InterruptController ic (&cpu); + Via6522 via; + bool reachedHandler = false; + Word a = 0; + int i = 0; + + + + cpu.InitForTest (kStartPc); + SeedVectors (cpu); + for (a = kStartPc; a < kStartPc + 64; ++a) + { + cpu.Poke (a, 0xEA); // NOP field so StepOne makes progress + } + cpu.Status ().flags.interruptDisable = 0; + + Assert::AreEqual (S_OK, via.AttachInterruptController (&ic), + L"VIA must bind to the interrupt controller"); + + // Timer1 continuous mode, T1 IRQ enabled, short latch so it + // underflows within a handful of NOPs. + via.WriteRegister (Via6522::kRegAcr, Via6522::kAcrT1Continuous); + via.WriteRegister (Via6522::kRegIer, Via6522::kIerSetClear | Via6522::kIrqTimer1); + via.WriteRegister (Via6522::kRegT1LL, 0x10); + via.WriteRegister (Via6522::kRegT1CH, 0x00); // load+arm, count = 0x0010 + + for (i = 0; i < 200 && !reachedHandler; ++i) + { + if (!cpu.TryStepInterrupt ()) + { + cpu.StepOne (); + } + + via.Tick (cpu.GetLastInstructionCycles ()); + + if (cpu.RegPC () == kIrqHandler) + { + reachedHandler = true; + } + } + + Assert::IsTrue (reachedHandler, + L"Timer1 IRQ must vector the CPU to its handler via the host StepOne loop"); + } }; } From 8a3f94250b7d4e42922f49d67ac1ea2623673e80 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Sat, 11 Jul 2026 13:47:31 -0700 Subject: [PATCH 3/7] test(mockingboard): add deterministic tone smoke-test disks Two hand-crafted bootable //e disks that program the slot-4 Mockingboard directly, for isolating the host audio path from any game's own card detection: - mockingboard-test: programs AY channel A for a sustained tone and spins forever. Proves VIA -> AY -> audio source -> mixer -> WASAPI end to end with direct register writes. - mockingboard-irq-test: arms Timer1 in continuous mode, installs a handler at the Apple II user IRQ vector ($03FE) that sweeps tone-A each interrupt, and drops the drive motor. Reproduces the exact mechanism real music players use (IRQ-driven AY writes) and validates the interrupt fix audibly. Sources (.a65) assemble with CassoCli; the .dsk files are the laid-out 143,360-byte boot images (boot code at track 0, sector 0). Both appear in the disk picker via the bundled-demo scan. --- Apple2/Demos/mockingboard-irq-test.a65 | 149 +++++++++++++++++++++++++ Apple2/Demos/mockingboard-irq-test.dsk | Bin 0 -> 143360 bytes Apple2/Demos/mockingboard-test.a65 | 92 +++++++++++++++ Apple2/Demos/mockingboard-test.dsk | Bin 0 -> 143360 bytes 4 files changed, 241 insertions(+) create mode 100644 Apple2/Demos/mockingboard-irq-test.a65 create mode 100644 Apple2/Demos/mockingboard-irq-test.dsk create mode 100644 Apple2/Demos/mockingboard-test.a65 create mode 100644 Apple2/Demos/mockingboard-test.dsk diff --git a/Apple2/Demos/mockingboard-irq-test.a65 b/Apple2/Demos/mockingboard-irq-test.a65 new file mode 100644 index 00000000..9f741778 --- /dev/null +++ b/Apple2/Demos/mockingboard-irq-test.a65 @@ -0,0 +1,149 @@ +; ============================================================================ +; mockingboard-irq-test.a65 -- IRQ-driven Mockingboard smoke test +; +; Reproduces the mechanism real Mockingboard music players (e.g. Rescue +; Raiders) actually use: a 6522 Timer1 continuous interrupt whose handler +; writes the AY registers. This exercises the full chain that the plain +; tone test does NOT -- CPU interrupt dispatch + IRQ-handler AY writes + +; audio -- so a clean glide here means the only thing left that could +; keep a real game silent is that game's own card detection. +; +; What you should hear: a steady tone whose pitch glides downward ~once a +; second (the handler bumps tone-A's period each interrupt), with NO disk +; motor hum (we switch the drive off first). +; +; Boot: slot-6 Disk2.rom loads track 0 sector 0 to $0800 and JMPs $0801. +; +; Interrupt path: hardware IRQ -> ROM handler saves A at $45, sees the B +; flag clear, and JMP ($03FE) -- the Apple II user IRQ vector we install +; below. Our handler acknowledges Timer1, rewrites the AY, restores A and +; RTIs. +; ============================================================================ + + .org $0801 + +; -- Slot 4 Mockingboard VIA #1 (direct registers) -------------------------- +ORB = $C400 ; port B: AY control lines +ORA = $C401 ; port A: AY register number / data +DDRB = $C402 +DDRA = $C403 +T1CL = $C404 ; Timer1 counter low (read clears the T1 flag) +T1CH = $C405 ; Timer1 counter high (write loads + arms) +ACR = $C40B ; auxiliary control (Timer1 mode) +IER = $C40E ; interrupt enable + +; -- AY control-line states on port B --------------------------------------- +LATCH = $07 ; BDIR|BC1|/RESET -> latch register address +WRITE = $06 ; BDIR|/RESET -> write data byte +IDLE = $04 ; /RESET only -> bus idle, chip live + +; -- AY-3-8910 register numbers --------------------------------------------- +R_TONEA_LO = $00 +R_TONEA_HI = $01 +R_MIXER = $07 +R_AMP_A = $08 + +; -- VIA control values ----------------------------------------------------- +T1_CONT = $40 ; ACR bit 6: Timer1 continuous (periodic IRQ) +IER_T1 = $C0 ; IER: set-bit ($80) | Timer1 enable ($40) + +; -- Apple II system locations ---------------------------------------------- +IRQV = $03FE ; user IRQ vector the ROM JMPs through +MOTOROFF = $C0E8 ; slot 6 drive motor OFF +ACCSAVE = $45 ; ROM IRQ handler stashes A here + +; -- Zero-page scratch ------------------------------------------------------ +savex = $06 +savey = $07 +sweep = $08 + + +start + sei ; hold off interrupts while we wire things up + + lda MOTOROFF ; drop the drive motor so only the AY is audible + + ; VIA ports to outputs, AY control idle with /RESET released. + lda #$FF + sta DDRB + sta DDRA + lda #IDLE + sta ORB + + ; Initial voice: tone A enabled, period $0100, full fixed volume. + lda #R_MIXER + ldx #$3E + jsr writeay + lda #R_TONEA_HI + ldx #$01 + jsr writeay + lda #R_TONEA_LO + ldx #$00 + jsr writeay + lda #R_AMP_A + ldx #$0F + jsr writeay + + lda #$00 + sta sweep + + ; Install the interrupt handler at the Apple II user IRQ vector. + lda #irqhandler & $FF + sta IRQV + lda #irqhandler >> 8 + sta IRQV+1 + + ; Arm Timer1: continuous mode, T1 interrupt enabled, period $1000 + ; (~4 KHz phi2 / period -> roughly 250 interrupts per second). + lda #T1_CONT + sta ACR + lda #IER_T1 + sta IER + lda #$00 + sta T1CL ; latch low + lda #$10 + sta T1CH ; load counter = $1000, arm, start counting + + cli ; interrupts on -- Timer1 now drives the handler + +spin + jmp spin ; foreground idles; the ISR does all the work + + +; ---------------------------------------------------------------------------- +; writeay -- latch AY register (A) then write data (X) through the VIA bus. +; A = register number, X = value. Clobbers A; preserves X and Y. +; ---------------------------------------------------------------------------- +writeay + sta ORA + lda #LATCH + sta ORB + lda #IDLE + sta ORB + stx ORA + lda #WRITE + sta ORB + lda #IDLE + sta ORB + rts + + +; ---------------------------------------------------------------------------- +; irqhandler -- Timer1 continuous IRQ. The ROM handler already saved A at $45 +; and vectored here. Acknowledge the timer, glide the pitch by +; rewriting tone-A fine, then restore and RTI. +; ---------------------------------------------------------------------------- +irqhandler + stx savex + sty savey + lda T1CL ; read T1C-L clears the Timer1 flag, de-asserts IRQ + inc sweep + lda #R_TONEA_LO + ldx sweep + jsr writeay + ldy savey + ldx savex + lda ACCSAVE ; restore A saved by the ROM IRQ handler + rti + +; --- end --- diff --git a/Apple2/Demos/mockingboard-irq-test.dsk b/Apple2/Demos/mockingboard-irq-test.dsk new file mode 100644 index 0000000000000000000000000000000000000000..5d1caed17e689979313f2af6ce42303ad7dc8771 GIT binary patch literal 143360 zcmeIuu}#E45Qfn;Hug$LKnYZWE+Hd9A|Rv#QVIaFp$LmRX@cwFf~^Hn1&579P62O{ z(Vu+5E6@9{LmuO#Mc;CW(sG)wukLGhbGN=Sm-W^8;&;8)e2VWrYaILB#97NjoVP47 zw7iVd_HcVwqgx_tY0bSaWBePBZ{y3fAKK^A-PI-dG400f&89d41PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N t0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0tEgc@B<4bGnfDX literal 0 HcmV?d00001 diff --git a/Apple2/Demos/mockingboard-test.a65 b/Apple2/Demos/mockingboard-test.a65 new file mode 100644 index 00000000..98665f85 --- /dev/null +++ b/Apple2/Demos/mockingboard-test.a65 @@ -0,0 +1,92 @@ +; ============================================================================ +; mockingboard-test.a65 -- minimal Mockingboard tone smoke test +; +; A single boot-sector program that UNCONDITIONALLY programs the slot-4 +; Mockingboard's first AY-3-8910 (via VIA #1 at $C400) for a sustained +; square-wave tone on channel A, then spins forever. There is no card +; detection and no game logic: if you hear a steady tone, the host +; Mockingboard audio path (VIA -> AY -> audio source -> mixer -> WASAPI) +; works end-to-end. Silence means the fault is on the host side; a +; real game (e.g. Rescue Raiders) going silent while this tone plays +; isolates the problem to that title's own card detection. +; +; Boot: the slot-6 Disk2.rom firmware reads track 0 sector 0 into +; $0800-$08FF and JMPs $0801 -- same contract casso-rocks.a65 relies on. +; +; AY write protocol (mirrors MockingboardCard SyncPsg control lines): +; PB0 = BC1, PB1 = BDIR, PB2 = /RESET (high = released). +; latch address : BDIR|BC1|/RESET = $07 +; write data : BDIR|/RESET = $06 +; idle : /RESET = $04 +; Register number / data byte are driven on port A (DDRA = $FF). +; ============================================================================ + + .org $0801 + +; -- Slot 4 Mockingboard VIA #1 registers ----------------------------------- +ORB = $C400 ; port B: AY control lines (BC1/BDIR//RESET) +ORA = $C401 ; port A: AY register number / data byte +DDRB = $C402 +DDRA = $C403 + +; -- AY control-line states on port B --------------------------------------- +LATCH = $07 ; BDIR=1 BC1=1 /RESET=1 -> latch register address +WRITE = $06 ; BDIR=1 BC1=0 /RESET=1 -> write data byte +IDLE = $04 ; BDIR=0 BC1=0 /RESET=1 -> bus idle, chip live + +; -- AY-3-8910 register numbers --------------------------------------------- +R_TONEA_LO = $00 ; channel A tone period, fine +R_TONEA_HI = $01 ; channel A tone period, coarse (low 4 bits) +R_MIXER = $07 ; 0 = enabled; bit0 tone A, bit3 noise A ... +R_AMP_A = $08 ; bits 0-3 = level; bit4 = follow envelope + + +start + ; Both VIA ports to outputs, control lines idle with /RESET released + ; (releases the AY from power-on reset so it will accept writes). + lda #$FF + sta DDRB + sta DDRA + lda #IDLE + sta ORB + + ; Mixer: enable tone A only (bit0=0), everything else disabled ($3E). + lda #R_MIXER + ldx #$3E + jsr writeay + + ; Tone A period = $0100 -> a clear low-mid square wave. + lda #R_TONEA_LO + ldx #$00 + jsr writeay + lda #R_TONEA_HI + ldx #$01 + jsr writeay + + ; Channel A amplitude = full fixed volume (envelope bit clear). + lda #R_AMP_A + ldx #$0F + jsr writeay + +spin + jmp spin ; hold forever; the AY keeps sounding on its own + + +; ---------------------------------------------------------------------------- +; writeay -- latch AY register (A) then write data (X) through the VIA bus. +; A = AY register number on entry, X = value to write. Clobbers A. +; ---------------------------------------------------------------------------- +writeay + sta ORA ; register number onto port A + lda #LATCH + sta ORB ; BDIR|BC1 -> AY latches the address + lda #IDLE + sta ORB ; back to idle + stx ORA ; data byte onto port A + lda #WRITE + sta ORB ; BDIR -> AY writes the latched register + lda #IDLE + sta ORB ; back to idle + rts + +; --- end --- diff --git a/Apple2/Demos/mockingboard-test.dsk b/Apple2/Demos/mockingboard-test.dsk new file mode 100644 index 0000000000000000000000000000000000000000..c7d20cb7f4fcf1fd2dc99fe5d9388a11e40e913a GIT binary patch literal 143360 zcmeIuu?@m76a>(3h`=2yq>hvon1CTz$Qi<&_wZhcT`&UOyXJJ#RWI6EZoS-lTT1ly ztnYQtEo#h4HO Date: Sat, 11 Jul 2026 22:21:31 -0700 Subject: [PATCH 4/7] refactor(mmu): EHM single-exit + slot-index asserts in CxxxRomRouter Rework CxxxRomRouter to the project EHM conventions: - SetSlotRom / SetSlotIoDevice / HasSlotRom now assert an in-range slot index (CBRAEx E_INVALIDARG) instead of silently ignoring it. An out-of-range slot is a wiring/config bug, not a runtime value. - SlotIoDeviceFor and ResolveByte collapse their multiple returns into a single exit via BAIL_OUT_IF guards and an Error: label. - Write drops its early return for an if/else. Behaviour is identical: a delegated slot page and the $CFFF INTC8ROM clear are mutually exclusive by address. Also names the address-decode magic numbers and normalises spacing. No behaviour change; MMU/router tests unaffected. --- CassoEmuCore/Devices/CxxxRomRouter.cpp | 175 ++++++++++++++----------- 1 file changed, 100 insertions(+), 75 deletions(-) diff --git a/CassoEmuCore/Devices/CxxxRomRouter.cpp b/CassoEmuCore/Devices/CxxxRomRouter.cpp index 7a85fd02..8d61caf0 100644 --- a/CassoEmuCore/Devices/CxxxRomRouter.cpp +++ b/CassoEmuCore/Devices/CxxxRomRouter.cpp @@ -2,6 +2,7 @@ #include "CxxxRomRouter.h" #include "Apple2eMmu.h" +#include "Ehm.h" @@ -24,7 +25,11 @@ static constexpr Word kSlotRomPageSize = 0x0100; static constexpr Word kInternalRomSize = 0x0F00; static constexpr int kMinSlot = 1; static constexpr int kMaxSlot = 7; +static constexpr int kSlot3 = 3; static constexpr Byte kFloatingBusByte = 0xFF; +static constexpr int kAddressPageShift = 8; +static constexpr int kSlotNibbleMask = 0x0F; +static constexpr Word kPageOffsetMask = 0x00FF; @@ -58,7 +63,7 @@ void CxxxRomRouter::SetInternalRom (vector data) { m_internal = move (data); - if (m_internal.size () < kInternalRomSize) + if (m_internal.size() < kInternalRomSize) { m_internal.resize (kInternalRomSize, kFloatingBusByte); } @@ -72,21 +77,28 @@ void CxxxRomRouter::SetInternalRom (vector data) // // SetSlotRom // +// Installs a slot's 256-byte $Cn00 ROM page. A slot index outside 1..7 is +// a caller bug and asserts. +// //////////////////////////////////////////////////////////////////////////////// void CxxxRomRouter::SetSlotRom (int slot, vector data) { - if (slot < kMinSlot || slot > kMaxSlot) - { - return; - } + HRESULT hr = S_OK; + + + + CBRAEx (slot >= kMinSlot && slot <= kMaxSlot, E_INVALIDARG); m_slotRom[slot] = move (data); - if (m_slotRom[slot].size () < kSlotRomPageSize) + if (m_slotRom[slot].size() < kSlotRomPageSize) { m_slotRom[slot].resize (kSlotRomPageSize, kFloatingBusByte); } + +Error: + return; } @@ -97,16 +109,24 @@ void CxxxRomRouter::SetSlotRom (int slot, vector data) // // HasSlotRom // +// True if slot `slot` has a ROM page installed. A slot index outside 1..7 +// is a caller bug and asserts. +// //////////////////////////////////////////////////////////////////////////////// bool CxxxRomRouter::HasSlotRom (int slot) const { - if (slot < kMinSlot || slot > kMaxSlot) - { - return false; - } + HRESULT hr = S_OK; + bool result = false; + + + + CBRAEx (slot >= kMinSlot && slot <= kMaxSlot, E_INVALIDARG); - return !m_slotRom[slot].empty (); + result = !m_slotRom[slot].empty(); + +Error: + return result; } @@ -117,21 +137,30 @@ bool CxxxRomRouter::HasSlotRom (int slot) const // // SetSlotIoDevice // +// Registers (or clears, when `device` is nullptr) the active I/O device +// owning a slot's $Cn00 page. A slot index outside 1..7 is a caller bug +// and asserts. +// //////////////////////////////////////////////////////////////////////////////// void CxxxRomRouter::SetSlotIoDevice (int slot, MemoryDevice * device) { - if (slot < kMinSlot || slot > kMaxSlot) - { - return; - } + HRESULT hr = S_OK; + + + + CBRAEx (slot >= kMinSlot && slot <= kMaxSlot, E_INVALIDARG); m_slotIoDevice[slot] = device; + +Error: + return; } + //////////////////////////////////////////////////////////////////////////////// // // SlotIoDeviceFor @@ -146,29 +175,26 @@ void CxxxRomRouter::SetSlotIoDevice (int slot, MemoryDevice * device) MemoryDevice * CxxxRomRouter::SlotIoDeviceFor (Word address) const { - int slot = static_cast ((address >> 8) & 0x0F); + HRESULT hr = S_OK; + MemoryDevice * result = nullptr; + int slot = static_cast ((address >> kAddressPageShift) & kSlotNibbleMask); - if (m_mmu.GetIntCxRom ()) - { - return nullptr; - } - if (address >= kExpansionRomStart || slot < kMinSlot || slot > kMaxSlot) - { - return nullptr; - } - if (slot == 3 && !m_mmu.GetSlotC3Rom ()) - { - return nullptr; - } + BAIL_OUT_IF (m_mmu.GetIntCxRom(), S_OK); + BAIL_OUT_IF (address >= kExpansionRomStart || slot < kMinSlot || slot > kMaxSlot, S_OK); + BAIL_OUT_IF (slot == kSlot3 && !m_mmu.GetSlotC3Rom(), S_OK); - return m_slotIoDevice[slot]; + result = m_slotIoDevice[slot]; + +Error: + return result; } + //////////////////////////////////////////////////////////////////////////////// // // Read @@ -187,7 +213,7 @@ Byte CxxxRomRouter::Read (Word address) if (address >= kSlot3PageStart && address <= kSlot3PageEnd) { - if (!m_mmu.GetIntCxRom () && !m_mmu.GetSlotC3Rom ()) + if (!m_mmu.GetIntCxRom() && !m_mmu.GetSlotC3Rom()) { m_mmu.SetIntC8Rom (true); } @@ -195,7 +221,7 @@ Byte CxxxRomRouter::Read (Word address) if (address == kIntC8RomClearAddr) { - m_mmu.ResetIntC8Rom (); + m_mmu.ResetIntC8Rom(); } return value; @@ -209,8 +235,10 @@ Byte CxxxRomRouter::Read (Word address) // // Write // -// Writes are ignored (ROM); only the $CFFF side effect is preserved so -// software using STA $CFFF to deactivate expansion ROM still works. +// Writes are ignored (ROM); a slot I/O page is delegated to its device, +// and the $CFFF side effect (STA $CFFF to deactivate expansion ROM) is +// preserved. The two are mutually exclusive by address, so no page ever +// both delegates and clears INTC8ROM. // //////////////////////////////////////////////////////////////////////////////// @@ -218,15 +246,15 @@ void CxxxRomRouter::Write (Word address, Byte value) { MemoryDevice * io = SlotIoDeviceFor (address); + + if (io != nullptr) { io->Write (address, value); - return; } - - if (address == kIntC8RomClearAddr) + else if (address == kIntC8RomClearAddr) { - m_mmu.ResetIntC8Rom (); + m_mmu.ResetIntC8Rom(); } } @@ -240,7 +268,7 @@ void CxxxRomRouter::Write (Word address, Byte value) // //////////////////////////////////////////////////////////////////////////////// -void CxxxRomRouter::Reset () +void CxxxRomRouter::Reset() { } @@ -252,53 +280,50 @@ void CxxxRomRouter::Reset () // // ResolveByte // -// Maps an address to the active byte source per audit §8. +// Maps an address to the active byte source per audit §8. Out-of-range +// addresses and unmapped slots read as the floating bus. // //////////////////////////////////////////////////////////////////////////////// Byte CxxxRomRouter::ResolveByte (Word address) { - if (address < kCxxxRouterStart || address > kCxxxRouterEnd) - { - return kFloatingBusByte; - } - - bool intCx = m_mmu.GetIntCxRom (); - bool slotC3 = m_mmu.GetSlotC3Rom (); - bool intC8 = m_mmu.GetIntC8Rom (); - bool inSlot3 = (address >= kSlot3PageStart && address <= kSlot3PageEnd); - bool inExp = (address >= kExpansionRomStart && address <= kExpansionRomLast); - Word romOffset = static_cast (address - kCxxxRouterStart); - - - - if (intCx) - { - return romOffset < m_internal.size () ? m_internal[romOffset] : kFloatingBusByte; - } - - if (inSlot3 && !slotC3) + HRESULT hr = S_OK; + Byte result = kFloatingBusByte; + bool intCx = false; + bool slotC3 = false; + bool intC8 = false; + bool inSlot3 = false; + bool inExp = false; + Word romOffset = 0; + int slot = 0; + Word pageOff = 0; + + + + BAIL_OUT_IF (address < kCxxxRouterStart || address > kCxxxRouterEnd, S_OK); + + intCx = m_mmu.GetIntCxRom(); + slotC3 = m_mmu.GetSlotC3Rom(); + intC8 = m_mmu.GetIntC8Rom(); + inSlot3 = (address >= kSlot3PageStart && address <= kSlot3PageEnd); + inExp = (address >= kExpansionRomStart && address <= kExpansionRomLast); + romOffset = static_cast (address - kCxxxRouterStart); + slot = static_cast ((address >> kAddressPageShift) & kSlotNibbleMask); + pageOff = static_cast (address & kPageOffsetMask); + + if (intCx || (inSlot3 && !slotC3) || (inExp && intC8)) { - return romOffset < m_internal.size () ? m_internal[romOffset] : kFloatingBusByte; + result = (romOffset < m_internal.size()) ? m_internal[romOffset] : kFloatingBusByte; } - - if (inExp) + else if (inExp) { - if (intC8) - { - return romOffset < m_internal.size () ? m_internal[romOffset] : kFloatingBusByte; - } - - return kFloatingBusByte; + result = kFloatingBusByte; } - - int slot = static_cast ((address >> 8) & 0x0F); - Word pageOff = static_cast (address & 0xFF); - - if (slot < kMinSlot || slot > kMaxSlot || m_slotRom[slot].empty ()) + else if (slot >= kMinSlot && slot <= kMaxSlot && !m_slotRom[slot].empty()) { - return kFloatingBusByte; + result = (pageOff < m_slotRom[slot].size()) ? m_slotRom[slot][pageOff] : kFloatingBusByte; } - return pageOff < m_slotRom[slot].size () ? m_slotRom[slot][pageOff] : kFloatingBusByte; +Error: + return result; } From 15efe887e644c91d4db24099ee79a4584ef6f931 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Sat, 11 Jul 2026 22:21:31 -0700 Subject: [PATCH 5/7] refactor(mockingboard): single-exit, unmodelled-feature asserts, standards pass Coding-standard and robustness pass over the Mockingboard device layer (Ay8910, Via6522, MockingboardCard): - Single-exit: EnvelopeStep, SyncPsg, Tick / TickTimer1 / TickTimer2 and UpdateIrq lose their guard/early returns. - Assert on genuinely-unsupported conditions instead of ignoring them. Ay8910: GenerateSample requires a configured sample rate (E_UNEXPECTED), WriteRegister/ReadRegister require an in-range register (E_INVALIDARG). Via6522 WriteRegister flags unmodelled ACR features (T2 pulse counting, shift register, PB7 output, port latching) and any PCR handshake config (E_INVALIDARG). The register value is still stored first, so release builds stay graceful while debug builds surface a title that depends on something we do not model. - WriteData/ReadData keep their out-of-range-latch guards: a program latching an AY address >= 16 is inert on real hardware, so faithfully ignoring it is correct, not a bug to assert on. - PowerCycle / Create use UNREFERENCED_PARAMETER for their interface-mandated but unused Prng / MemoryBus arguments. Plus blank-line and empty-paren spacing fixes. No behaviour change under test; full suite green. --- CassoEmuCore/Devices/Mockingboard/Ay8910.cpp | 157 ++++++++++++------ .../Devices/Mockingboard/MockingboardCard.cpp | 91 ++++++---- CassoEmuCore/Devices/Mockingboard/Via6522.cpp | 155 +++++++++++------ 3 files changed, 260 insertions(+), 143 deletions(-) diff --git a/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp b/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp index 82020914..0aa9d072 100644 --- a/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp +++ b/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp @@ -1,6 +1,8 @@ #include "Pch.h" #include "Ay8910.h" +#include "Ehm.h" + @@ -16,6 +18,8 @@ static constexpr Byte s_kNoisePeriodMask = 0x1F; // noise period is 5 bits static constexpr int s_kByteShift = 8; static constexpr int s_kLfsrFeedbackTap = 3; // AY noise taps bits 0 and 3 static constexpr int s_kLfsrHighBit = 16; // 17-bit shift register +static constexpr int s_kChannelCount = 3; + @@ -30,12 +34,13 @@ Ay8910::Ay8910 (double clockHz) { m_clockHz = clockHz; - Reset (); + Reset(); } + //////////////////////////////////////////////////////////////////////////////// // // SetSampleRate @@ -59,6 +64,7 @@ void Ay8910::SetSampleRate (uint32_t sampleRate) + //////////////////////////////////////////////////////////////////////////////// // // SetClock @@ -75,10 +81,15 @@ void Ay8910::SetClock (double clockHz) + //////////////////////////////////////////////////////////////////////////////// // // WriteData // +// A data write reaches the register selected by the last latched address. +// Addresses outside 0..15 select no register, so the write is inert -- the +// documented AY behaviour, not a bug. +// //////////////////////////////////////////////////////////////////////////////// void Ay8910::WriteData (Byte value) @@ -92,16 +103,22 @@ void Ay8910::WriteData (Byte value) + //////////////////////////////////////////////////////////////////////////////// // // ReadData // +// Reads the register selected by the last latched address; an out-of-range +// address reads as the floating data bus (0xFF). +// //////////////////////////////////////////////////////////////////////////////// Byte Ay8910::ReadData () const { Byte result = 0xFF; + + if (m_latched < kRegCount) { result = m_regs[m_latched]; @@ -113,21 +130,25 @@ Byte Ay8910::ReadData () const + //////////////////////////////////////////////////////////////////////////////// // // WriteRegister // -// A write to the envelope-shape register always restarts the envelope, -// even when the value is unchanged. +// Direct register write. `reg` must name one of the 16 registers -- the bus +// path (WriteData) filters out-of-range addresses, so reaching here with +// reg >= 16 is a caller bug and asserts. A write to the envelope-shape +// register always restarts the envelope, even when the value is unchanged. // //////////////////////////////////////////////////////////////////////////////// void Ay8910::WriteRegister (Byte reg, Byte value) { - if (reg >= kRegCount) - { - return; - } + HRESULT hr = S_OK; + + + + CBRAEx (reg < kRegCount, E_INVALIDARG); m_regs[reg] = value; @@ -135,42 +156,55 @@ void Ay8910::WriteRegister (Byte reg, Byte value) { RestartEnvelope (value); } + +Error: + return; } + //////////////////////////////////////////////////////////////////////////////// // // ReadRegister // +// Direct register read. `reg` must name one of the 16 registers; reaching +// here with reg >= 16 is a caller bug and asserts. +// //////////////////////////////////////////////////////////////////////////////// Byte Ay8910::ReadRegister (Byte reg) const { - Byte result = 0xFF; + HRESULT hr = S_OK; + Byte result = 0xFF; - if (reg < kRegCount) - { - result = m_regs[reg]; - } + + CBRAEx (reg < kRegCount, E_INVALIDARG); + + result = m_regs[reg]; + +Error: return result; } + //////////////////////////////////////////////////////////////////////////////// // // Reset // //////////////////////////////////////////////////////////////////////////////// -void Ay8910::Reset () +void Ay8910::Reset() { int c = 0; + + for (c = 0; c < kRegCount; c++) { m_regs[c] = 0; @@ -179,16 +213,16 @@ void Ay8910::Reset () m_latched = 0; m_tickAccum = 0.0; - for (c = 0; c < 3; c++) + for (c = 0; c < s_kChannelCount; c++) { m_toneCounter[c] = TonePeriod (c); m_toneState[c] = 0; } - m_noiseCounter = 2 * NoisePeriod (); + m_noiseCounter = 2 * NoisePeriod(); m_lfsr = 1; - m_envCounter = 2 * EnvPeriod (); + m_envCounter = 2 * EnvPeriod(); m_envLevel = 0; m_envDirUp = false; m_envHolding = false; @@ -203,25 +237,28 @@ void Ay8910::Reset () + //////////////////////////////////////////////////////////////////////////////// // // GenerateSample // // Advances the engine by one output sample worth of base ticks (a // fractional accumulator carries the remainder between samples) and -// returns the resulting mono output. +// returns the resulting mono output. Requires a configured sample rate -- +// generating audio before SetSampleRate is a code-ordering bug and asserts. // //////////////////////////////////////////////////////////////////////////////// -float Ay8910::GenerateSample () +float Ay8910::GenerateSample() { - int ticks = 0; - int i = 0; + HRESULT hr = S_OK; + float result = 0.0f; + int ticks = 0; + int i = 0; - if (m_sampleRate == 0) - { - return 0.0f; - } + + + CBRAEx (m_sampleRate != 0, E_UNEXPECTED); m_tickAccum += m_ticksPerSample; ticks = static_cast (m_tickAccum); @@ -229,15 +266,19 @@ float Ay8910::GenerateSample () for (i = 0; i < ticks; i++) { - AdvanceBaseTick (); + AdvanceBaseTick(); } - return CurrentOutput (); + result = CurrentOutput(); + +Error: + return result; } + //////////////////////////////////////////////////////////////////////////////// // // VolumeForLevel @@ -261,6 +302,7 @@ float Ay8910::VolumeForLevel (int level) + //////////////////////////////////////////////////////////////////////////////// // // AdvanceBaseTick @@ -271,11 +313,13 @@ float Ay8910::VolumeForLevel (int level) // //////////////////////////////////////////////////////////////////////////////// -void Ay8910::AdvanceBaseTick () +void Ay8910::AdvanceBaseTick() { int c = 0; - for (c = 0; c < 3; c++) + + + for (c = 0; c < s_kChannelCount; c++) { if (--m_toneCounter[c] <= 0) { @@ -286,20 +330,21 @@ void Ay8910::AdvanceBaseTick () if (--m_noiseCounter <= 0) { - m_noiseCounter = 2 * NoisePeriod (); - StepLfsr (); + m_noiseCounter = 2 * NoisePeriod(); + StepLfsr(); } if (--m_envCounter <= 0) { - m_envCounter = 2 * EnvPeriod (); - EnvelopeStep (); + m_envCounter = 2 * EnvPeriod(); + EnvelopeStep(); } } + //////////////////////////////////////////////////////////////////////////////// // // StepLfsr @@ -309,7 +354,7 @@ void Ay8910::AdvanceBaseTick () // //////////////////////////////////////////////////////////////////////////////// -void Ay8910::StepLfsr () +void Ay8910::StepLfsr() { uint32_t feedback = (m_lfsr ^ (m_lfsr >> s_kLfsrFeedbackTap)) & 1u; @@ -319,6 +364,7 @@ void Ay8910::StepLfsr () + //////////////////////////////////////////////////////////////////////////////// // // EnvelopeStep @@ -328,52 +374,46 @@ void Ay8910::StepLfsr () // //////////////////////////////////////////////////////////////////////////////// -void Ay8910::EnvelopeStep () +void Ay8910::EnvelopeStep() { if (m_envHolding) { - return; + // Held at the last level; nothing to advance. } - - // Still inside a ramp: move one level toward the far end. - if (m_envDirUp && m_envLevel < kMaxEnvLevel) + else if (m_envDirUp && m_envLevel < kMaxEnvLevel) { + // Still ramping up toward the top. m_envLevel++; - return; } - - if (!m_envDirUp && m_envLevel > 0) + else if (!m_envDirUp && m_envLevel > 0) { + // Still ramping down toward zero. m_envLevel--; - return; } - - // Ramp complete (level sits at the end it reached). - if (!m_envCont) + else if (!m_envCont) { + // Ramp complete, one-shot: park at 0 and hold. m_envLevel = 0; m_envHolding = true; - return; } - - if (m_envHold) + else if (m_envHold) { + // Ramp complete, hold at the end reached (ALTERNATE flips it once). m_envHolding = true; if (m_envAlt) { m_envLevel = m_envDirUp ? 0 : kMaxEnvLevel; } - - return; } - - if (m_envAlt) + else if (m_envAlt) { + // ALTERNATE: reverse direction for the next ramp. m_envDirUp = !m_envDirUp; } else { + // Repeat: jump back to the start of the ramp. m_envLevel = m_envDirUp ? 0 : kMaxEnvLevel; } } @@ -381,6 +421,7 @@ void Ay8910::EnvelopeStep () + //////////////////////////////////////////////////////////////////////////////// // // RestartEnvelope @@ -400,12 +441,13 @@ void Ay8910::RestartEnvelope (Byte shape) m_envHolding = false; m_envDirUp = m_envAttack; m_envLevel = m_envDirUp ? 0 : kMaxEnvLevel; - m_envCounter = 2 * EnvPeriod (); + m_envCounter = 2 * EnvPeriod(); } + //////////////////////////////////////////////////////////////////////////////// // // CurrentOutput @@ -422,10 +464,12 @@ float Ay8910::CurrentOutput () const float out = 0.0f; int c = 0; - for (c = 0; c < 3; c++) + + + for (c = 0; c < s_kChannelCount; c++) { int toneDisabled = (mixer >> c) & 1; - int noiseDisabled = (mixer >> (c + 3)) & 1; + int noiseDisabled = (mixer >> (c + s_kChannelCount)) & 1; int toneTerm = toneDisabled | m_toneState[c]; int noiseTerm = noiseDisabled | static_cast (m_lfsr & 1u); int active = toneTerm & noiseTerm; @@ -444,6 +488,7 @@ float Ay8910::CurrentOutput () const + //////////////////////////////////////////////////////////////////////////////// // // TonePeriod @@ -462,6 +507,7 @@ int Ay8910::TonePeriod (int channel) const + //////////////////////////////////////////////////////////////////////////////// // // NoisePeriod @@ -478,6 +524,7 @@ int Ay8910::NoisePeriod () const + //////////////////////////////////////////////////////////////////////////////// // // EnvPeriod diff --git a/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp index a02a037e..009046bb 100644 --- a/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp +++ b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp @@ -6,6 +6,7 @@ + //////////////////////////////////////////////////////////////////////////////// // // MockingboardCard @@ -28,6 +29,7 @@ MockingboardCard::MockingboardCard (int slot) + //////////////////////////////////////////////////////////////////////////////// // // Read @@ -43,13 +45,15 @@ Byte MockingboardCard::Read (Word address) Word offset = static_cast ((address - m_base) & (kPageSize - 1)); int index = (offset & kVia2Select) ? 1 : 0; Byte reg = static_cast (offset & Via6522::kRegisterMask); - Byte portB = m_via[index].GetPortB (); + Byte portB = m_via[index].GetPortB(); + + if ((reg == Via6522::kRegOra || reg == Via6522::kRegOraNh) && (portB & kAyResetLow) != 0 && (portB & kAyControlMask) == kAyBc1) { - m_via[index].SetPortAInput (m_psg[index].ReadData ()); + m_via[index].SetPortAInput (m_psg[index].ReadData()); } return m_via[index].ReadRegister (reg); @@ -58,6 +62,7 @@ Byte MockingboardCard::Read (Word address) + //////////////////////////////////////////////////////////////////////////////// // // Write @@ -70,6 +75,8 @@ void MockingboardCard::Write (Word address, Byte value) int index = (offset & kVia2Select) ? 1 : 0; Byte reg = static_cast (offset & Via6522::kRegisterMask); + + m_via[index].WriteRegister (reg, value); SyncPsg (index); @@ -78,20 +85,23 @@ void MockingboardCard::Write (Word address, Byte value) + //////////////////////////////////////////////////////////////////////////////// // // Reset // //////////////////////////////////////////////////////////////////////////////// -void MockingboardCard::Reset () +void MockingboardCard::Reset() { int i = 0; + + for (i = 0; i < kViaCount; i++) { - m_via[i].Reset (); - m_psg[i].Reset (); + m_via[i].Reset(); + m_psg[i].Reset(); m_lastControl[i] = 0; } } @@ -99,6 +109,7 @@ void MockingboardCard::Reset () + //////////////////////////////////////////////////////////////////////////////// // // SoftReset @@ -108,30 +119,36 @@ void MockingboardCard::Reset () // //////////////////////////////////////////////////////////////////////////////// -void MockingboardCard::SoftReset () +void MockingboardCard::SoftReset() { - Reset (); + Reset(); } + //////////////////////////////////////////////////////////////////////////////// // // PowerCycle // +// The Mockingboard has no DRAM-shaped state to randomise, so a cold start +// is identical to a reset; the Prng required by the MemoryDevice contract +// is unused. +// //////////////////////////////////////////////////////////////////////////////// void MockingboardCard::PowerCycle (Prng & prng) { - (void) prng; + UNREFERENCED_PARAMETER (prng); - Reset (); + Reset(); } + //////////////////////////////////////////////////////////////////////////////// // // Tick @@ -142,6 +159,8 @@ void MockingboardCard::Tick (uint32_t cycles) { int i = 0; + + for (i = 0; i < kViaCount; i++) { m_via[i].Tick (cycles); @@ -151,6 +170,7 @@ void MockingboardCard::Tick (uint32_t cycles) + //////////////////////////////////////////////////////////////////////////////// // // AttachInterruptController @@ -162,6 +182,8 @@ HRESULT MockingboardCard::AttachInterruptController (IInterruptController * ic) HRESULT hr = S_OK; int i = 0; + + for (i = 0; i < kViaCount; i++) { hr = m_via[i].AttachInterruptController (ic); @@ -175,6 +197,7 @@ HRESULT MockingboardCard::AttachInterruptController (IInterruptController * ic) + //////////////////////////////////////////////////////////////////////////////// // // SetSampleRate @@ -185,6 +208,8 @@ void MockingboardCard::SetSampleRate (uint32_t sampleRate) { int i = 0; + + for (i = 0; i < kViaCount; i++) { m_psg[i].SetSampleRate (sampleRate); @@ -194,6 +219,7 @@ void MockingboardCard::SetSampleRate (uint32_t sampleRate) + //////////////////////////////////////////////////////////////////////////////// // // SyncPsg @@ -207,46 +233,45 @@ void MockingboardCard::SetSampleRate (uint32_t sampleRate) void MockingboardCard::SyncPsg (int index) { - Byte portB = m_via[index].GetPortB (); - Byte portA = m_via[index].GetPortA (); + Byte portB = m_via[index].GetPortB(); + Byte portA = m_via[index].GetPortA(); Byte control = static_cast (portB & kAyControlMask); + + if ((portB & kAyResetLow) == 0) { - m_psg[index].Reset (); + m_psg[index].Reset(); m_lastControl[index] = 0; - return; } - - if (control == m_lastControl[index]) + else if (control != m_lastControl[index]) { - return; - } + switch (control) + { + case (kAyBdir | kAyBc1): // latch register address + m_psg[index].LatchAddress (portA); + break; - switch (control) - { - case (kAyBdir | kAyBc1): // latch register address - m_psg[index].LatchAddress (portA); - break; + case kAyBdir: // write data to the latched register + m_psg[index].WriteData (portA); + break; - case kAyBdir: // write data to the latched register - m_psg[index].WriteData (portA); - break; + case kAyBc1: // read data from the latched register + m_via[index].SetPortAInput (m_psg[index].ReadData()); + break; - case kAyBc1: // read data from the latched register - m_via[index].SetPortAInput (m_psg[index].ReadData ()); - break; + default: // inactive + break; + } - default: // inactive - break; + m_lastControl[index] = control; } - - m_lastControl[index] = control; } + //////////////////////////////////////////////////////////////////////////////// // // Create @@ -255,7 +280,7 @@ void MockingboardCard::SyncPsg (int index) unique_ptr MockingboardCard::Create (const DeviceConfig & config, MemoryBus & bus) { - (void) bus; + UNREFERENCED_PARAMETER (bus); return make_unique (config.slot); } diff --git a/CassoEmuCore/Devices/Mockingboard/Via6522.cpp b/CassoEmuCore/Devices/Mockingboard/Via6522.cpp index 2cf24da1..38ab3b4d 100644 --- a/CassoEmuCore/Devices/Mockingboard/Via6522.cpp +++ b/CassoEmuCore/Devices/Mockingboard/Via6522.cpp @@ -5,6 +5,7 @@ + //////////////////////////////////////////////////////////////////////////////// // // ReadRegister @@ -20,15 +21,17 @@ Byte Via6522::ReadRegister (Byte reg) { Byte result = 0; + + switch (reg & kRegisterMask) { case kRegOrb: - result = GetPortB (); + result = GetPortB(); break; case kRegOra: case kRegOraNh: - result = GetPortA (); + result = GetPortA(); break; case kRegDdrb: @@ -78,11 +81,11 @@ Byte Via6522::ReadRegister (Byte reg) break; case kRegIfr: - result = GetIfr (); + result = GetIfr(); break; case kRegIer: - result = GetIer (); + result = GetIer(); break; default: @@ -95,6 +98,7 @@ Byte Via6522::ReadRegister (Byte reg) + //////////////////////////////////////////////////////////////////////////////// // // WriteRegister @@ -108,6 +112,10 @@ Byte Via6522::ReadRegister (Byte reg) void Via6522::WriteRegister (Byte reg, Byte value) { + HRESULT hr = S_OK; + + + switch (reg & kRegisterMask) { case kRegOrb: @@ -162,17 +170,27 @@ void Via6522::WriteRegister (Byte reg, Byte value) break; case kRegAcr: + // Only the Timer1 mode bit is modelled. Any other ACR bit selects an + // unmodelled feature (T2 pulse counting, shift register, PB7 output, + // port input latching). Store the value for read-back, then assert so + // a debug build surfaces any title that actually configures one -- + // in release the write is simply inert for the unmodelled bits. m_acr = value; + CBRAEx ((value & ~kAcrT1Continuous) == 0, E_INVALIDARG); break; case kRegPcr: + // CA1/CA2/CB1/CB2 handshaking is unmodelled. The Mockingboard drives + // the AY from the ports, not the handshake lines, so a non-zero PCR + // means a title depends on something we do not implement. m_pcr = value; + CBRAEx (value == 0, E_INVALIDARG); break; case kRegIfr: // Write-1-to-clear the flag bits; bit 7 is not a real flag. m_ifr &= static_cast (~(value & 0x7F)); - UpdateIrq (); + UpdateIrq(); break; case kRegIer: @@ -184,30 +202,32 @@ void Via6522::WriteRegister (Byte reg, Byte value) { m_ier &= static_cast (~(value & 0x7F)); } - UpdateIrq (); + UpdateIrq(); break; default: break; } + +Error: + return; } + //////////////////////////////////////////////////////////////////////////////// // // Tick // +// Advance both timers by `cycles` phi2 clocks. A zero count is a harmless +// no-op -- each timer step is a no-op when it cannot underflow. +// //////////////////////////////////////////////////////////////////////////////// void Via6522::Tick (uint32_t cycles) { - if (cycles == 0) - { - return; - } - TickTimer1 (cycles); TickTimer2 (cycles); } @@ -215,13 +235,14 @@ void Via6522::Tick (uint32_t cycles) + //////////////////////////////////////////////////////////////////////////////// // // Reset // //////////////////////////////////////////////////////////////////////////////// -void Via6522::Reset () +void Via6522::Reset() { m_ora = 0; m_orb = 0; @@ -246,12 +267,13 @@ void Via6522::Reset () m_t2Counter = 0; m_t2Armed = false; - UpdateIrq (); + UpdateIrq(); } + //////////////////////////////////////////////////////////////////////////////// // // AttachInterruptController @@ -262,6 +284,8 @@ HRESULT Via6522::AttachInterruptController (IInterruptController * ic) { HRESULT hr = S_OK; + + CBRAEx (ic, E_INVALIDARG); hr = ic->RegisterSource (m_irqSource); @@ -277,6 +301,7 @@ HRESULT Via6522::AttachInterruptController (IInterruptController * ic) + //////////////////////////////////////////////////////////////////////////////// // // GetIfr @@ -286,10 +311,12 @@ HRESULT Via6522::AttachInterruptController (IInterruptController * ic) // //////////////////////////////////////////////////////////////////////////////// -Byte Via6522::GetIfr () const +Byte Via6522::GetIfr() const { Byte ifr = static_cast (m_ifr & 0x7F); + + if ((m_ifr & m_ier & 0x7F) != 0) { ifr |= kIrqAny; @@ -301,6 +328,7 @@ Byte Via6522::GetIfr () const + //////////////////////////////////////////////////////////////////////////////// // // TickTimer1 @@ -324,38 +352,42 @@ void Via6522::TickTimer1 (uint32_t cycles) int64_t period = latch + 1; int64_t into = 0; + + if (static_cast (cycles) < toUnderflow) { m_t1Counter = static_cast (counter - cycles); - return; } - - if (m_t1Armed) + else { - SetFlag (kIrqTimer1); - - if (!freeRun) + if (m_t1Armed) { - m_t1Armed = false; + SetFlag (kIrqTimer1); + + if (!freeRun) + { + m_t1Armed = false; + } } - } - remaining = static_cast (cycles) - toUnderflow; + remaining = static_cast (cycles) - toUnderflow; - if (freeRun) - { - into = remaining % period; - m_t1Counter = static_cast (latch - into); - } - else - { - m_t1Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); + if (freeRun) + { + into = remaining % period; + m_t1Counter = static_cast (latch - into); + } + else + { + m_t1Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); + } } } + //////////////////////////////////////////////////////////////////////////////// // // TickTimer2 @@ -371,28 +403,32 @@ void Via6522::TickTimer2 (uint32_t cycles) int64_t toUnderflow = counter + 1; int64_t remaining = 0; + + if (static_cast (cycles) < toUnderflow) { m_t2Counter = static_cast (counter - cycles); - return; } - - if (m_t2Armed) + else { - SetFlag (kIrqTimer2); - m_t2Armed = false; - } + if (m_t2Armed) + { + SetFlag (kIrqTimer2); + m_t2Armed = false; + } - remaining = static_cast (cycles) - toUnderflow; - m_t2Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); + remaining = static_cast (cycles) - toUnderflow; + m_t2Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); + } } + //////////////////////////////////////////////////////////////////////////////// // -// SetFlag / ClearFlag +// SetFlag // //////////////////////////////////////////////////////////////////////////////// @@ -400,41 +436,50 @@ void Via6522::SetFlag (Byte flag) { m_ifr |= static_cast (flag & 0x7F); - UpdateIrq (); + UpdateIrq(); } + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ClearFlag +// +//////////////////////////////////////////////////////////////////////////////// + void Via6522::ClearFlag (Byte flag) { m_ifr &= static_cast (~(flag & 0x7F)); - UpdateIrq (); + UpdateIrq(); } + //////////////////////////////////////////////////////////////////////////////// // // UpdateIrq // -// Level-sensitive: the line follows (IFR & IER) continuously. +// Level-sensitive: the line follows (IFR & IER) continuously. A VIA that +// has not been bound to an interrupt controller simply drives nothing. // //////////////////////////////////////////////////////////////////////////////// -void Via6522::UpdateIrq () +void Via6522::UpdateIrq() { - if (!m_irqBound || (m_ic == nullptr)) - { - return; - } - - if ((m_ifr & m_ier & 0x7F) != 0) + if (m_irqBound && m_ic != nullptr) { - m_ic->Assert (m_irqSource); - } - else - { - m_ic->Clear (m_irqSource); + if ((m_ifr & m_ier & 0x7F) != 0) + { + m_ic->Assert (m_irqSource); + } + else + { + m_ic->Clear (m_irqSource); + } } } From 098f96e59f0dd1e4f0342fb57141907c587b6835 Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Sat, 11 Jul 2026 22:21:32 -0700 Subject: [PATCH 6/7] fix(settings): move Mockingboard control to the Hardware tab The Mockingboard audio-enable toggle was misplaced on the Disk settings page. Remove it and its entire pref/command plumbing; the card's slot entry in the Hardware tab's device tree is now the single control, and audio follows card presence (the mixer goes silent with no registered sources). Drops the mockingboardEnabled pref, SetMockingboard, the ApplyMockingboard sink method, the IDM_AUDIO_MOCKINGBOARD_ENABLE/DISABLE command ids and their shell handler, and the DiskPage toggle. Also, in the same file: wrap the audio SubmitFrame's discarded HRESULT in IGNORE_RETURN_VALUE. --- Casso/Config/UserConfigStore.cpp | 1 - Casso/EmulatorShell.cpp | 27 +++++++------------ Casso/Ui/Settings/DiskPage.cpp | 11 -------- Casso/Ui/Settings/DiskPage.h | 2 -- Casso/Ui/Settings/SettingsApplyController.cpp | 6 ----- Casso/Ui/Settings/SettingsPanelState.cpp | 18 ------------- Casso/Ui/Settings/SettingsPanelState.h | 3 --- Casso/resource.h | 2 -- UnitTest/UiTests/SettingsPanelStateTests.cpp | 2 -- 9 files changed, 9 insertions(+), 63 deletions(-) diff --git a/Casso/Config/UserConfigStore.cpp b/Casso/Config/UserConfigStore.cpp index 2b594089..54d13e94 100644 --- a/Casso/Config/UserConfigStore.cpp +++ b/Casso/Config/UserConfigStore.cpp @@ -395,7 +395,6 @@ namespace uiObj.emplace_back ("colorMode", JsonValue (std::string ("color"))); uiObj.emplace_back ("writeMode", JsonValue (std::string ("buffer-and-flush"))); uiObj.emplace_back ("floppySoundEnabled", JsonValue (true)); - uiObj.emplace_back ("mockingboardEnabled", JsonValue (true)); uiObj.emplace_back ("floppyMechanism", JsonValue (std::string ("shugart"))); wp.emplace_back (JsonValue (false)); wp.emplace_back (JsonValue (false)); diff --git a/Casso/EmulatorShell.cpp b/Casso/EmulatorShell.cpp index b49667ab..dd41c137 100644 --- a/Casso/EmulatorShell.cpp +++ b/Casso/EmulatorShell.cpp @@ -992,10 +992,6 @@ HRESULT EmulatorShell::Initialize ( { m_driveAudioMixer.SetEnabled (enabled); } - if (SUCCEEDED (uiPrefs->GetBool ("mockingboardEnabled", enabled))) - { - m_mockingboardAudioMixer.SetEnabled (enabled); - } if (SUCCEEDED (uiPrefs->GetString ("floppyMechanism", mechNarrow)) && !mechNarrow.empty()) { // DriveAudioMixer matches mechanism names case- @@ -3028,13 +3024,6 @@ void EmulatorShell::DispatchCpuCommand (const EmulatorCommand & cmd) break; } - case IDM_AUDIO_MOCKINGBOARD_ENABLE: - case IDM_AUDIO_MOCKINGBOARD_DISABLE: - { - m_mockingboardAudioMixer.SetEnabled (cmd.id == IDM_AUDIO_MOCKINGBOARD_ENABLE); - break; - } - case IDM_AUDIO_DRIVE_MECHANISM: { // Payload is "shugart" or "alps" (canonical lower-case from @@ -3317,6 +3306,7 @@ void EmulatorShell::ExecuteCpuSlices() { static constexpr uint32_t kSliceCycles = 1023; + HRESULT hr = S_OK; uint32_t targetCycles = m_cyclesPerFrame; SpeedMode speed = m_cpuManager.GetSpeedMode(); bool audioActive = (m_refs.speaker != nullptr && m_wasapiAudio.IsInitialized()); @@ -3399,13 +3389,14 @@ void EmulatorShell::ExecuteCpuSlices() m_sampleRemainder = exactSamples - static_cast (numSamples); - m_wasapiAudio.SubmitFrame (m_refs.speaker->GetToggleTimestamps(), - sliceActual, - m_refs.speaker->GetFrameInitialState(), - numSamples, - &m_driveAudioMixer, - m_cpu->GetTotalCycles(), - &m_mockingboardAudioMixer); + hr = m_wasapiAudio.SubmitFrame (m_refs.speaker->GetToggleTimestamps(), + sliceActual, + m_refs.speaker->GetFrameInitialState(), + numSamples, + &m_driveAudioMixer, + m_cpu->GetTotalCycles(), + &m_mockingboardAudioMixer); + IGNORE_RETURN_VALUE (hr, S_OK); m_refs.speaker->ClearTimestamps(); m_refs.speaker->BeginFrame(); diff --git a/Casso/Ui/Settings/DiskPage.cpp b/Casso/Ui/Settings/DiskPage.cpp index 67c7241d..b1d0fac3 100644 --- a/Casso/Ui/Settings/DiskPage.cpp +++ b/Casso/Ui/Settings/DiskPage.cpp @@ -56,7 +56,6 @@ DiskPage::DiskPage (std::wstring title) Adopt (m_wpLabel); Adopt (m_writeModeLabel); - Adopt (m_mockingboardLabel); Adopt (m_audioLabel); Adopt (m_mechLabel); Adopt (m_motorLabel); @@ -67,7 +66,6 @@ DiskPage::DiskPage (std::wstring title) Adopt (m_writeMode); Adopt (m_mechanism); - Adopt (m_mockingboard); Adopt (m_driveAudio); for (i = 0; i < m_writeProtect.size(); ++i) { @@ -150,11 +148,6 @@ void DiskPage::Layout (const RECT & rect, const DxuiDpiScaler & scaler) m_writeMode.SetItems ({ L"Buffer and flush", L"Copy on write" }); y += rowHeight + sectionGap; - m_mockingboardLabel.SetRect (MakeRect (x, y, labelWidth, rowHeight)); - m_mockingboardLabel.SetText (L"Mockingboard:"); - m_mockingboard.SetRect (MakeRect (controlsX, y, checkWidth, rowHeight)); - y += rowHeight + sectionGap; - m_audioLabel.SetRect (MakeRect (x, y, labelWidth, rowHeight)); m_audioLabel.SetText (L"Drive audio:"); m_driveAudio.SetRect (MakeRect (controlsX, y, checkWidth, rowHeight)); @@ -211,8 +204,6 @@ void DiskPage::Layout (const RECT & rect, const DxuiDpiScaler & scaler) m_wpLabel.SetDpi (dpi); m_writeModeLabel.SetDpi (dpi); - m_mockingboardLabel.SetDpi (dpi); - m_mockingboard.SetDpi (dpi); m_audioLabel.SetDpi (dpi); m_mechLabel.SetDpi (dpi); m_writeMode.SetDpi (dpi); @@ -263,7 +254,6 @@ void DiskPage::Rebuild () m_writeMode.SetSelected ((int) state->Prefs().writeMode); m_mechanism.SetSelected (state->Prefs().floppyMechanism == "alps" ? 1 : 0); m_driveAudio.SetChecked (state->Prefs().floppySoundEnabled); - m_mockingboard.SetChecked (state->Prefs().mockingboardEnabled); m_writeProtect[0].SetChecked (state->Prefs().writeProtect[0]); m_writeProtect[1].SetChecked (state->Prefs().writeProtect[1]); m_motorVol.SetValue (state->Prefs().driveMotorVolume * 100.0f); @@ -280,7 +270,6 @@ void DiskPage::Rebuild () state->SetFloppySound (checked); ApplyDriveAudioChildEnabled (checked); }); - m_mockingboard.SetOnChange ([state] (bool checked) { state->SetMockingboard (checked); }); m_writeProtect[0].SetOnChange ([state] (bool checked) { state->SetWriteProtect (0, checked); }); m_writeProtect[1].SetOnChange ([state] (bool checked) { state->SetWriteProtect (1, checked); }); diff --git a/Casso/Ui/Settings/DiskPage.h b/Casso/Ui/Settings/DiskPage.h index d831be39..38634981 100644 --- a/Casso/Ui/Settings/DiskPage.h +++ b/Casso/Ui/Settings/DiskPage.h @@ -84,7 +84,6 @@ class DiskPage : public DxuiPropertyPage DxuiLabel m_wpLabel; DxuiLabel m_writeModeLabel; - DxuiLabel m_mockingboardLabel; DxuiLabel m_audioLabel; DxuiLabel m_mechLabel; DxuiLabel m_motorLabel; @@ -95,7 +94,6 @@ class DiskPage : public DxuiPropertyPage DxuiDropdown m_writeMode; DxuiDropdown m_mechanism; - DxuiToggle m_mockingboard; DxuiToggle m_driveAudio; std::array m_writeProtect; DxuiSlider m_motorVol; diff --git a/Casso/Ui/Settings/SettingsApplyController.cpp b/Casso/Ui/Settings/SettingsApplyController.cpp index 98e9c078..1c8476fe 100644 --- a/Casso/Ui/Settings/SettingsApplyController.cpp +++ b/Casso/Ui/Settings/SettingsApplyController.cpp @@ -67,12 +67,6 @@ namespace : IDM_AUDIO_DRIVE_DISABLE); } - void ApplyMockingboard (bool enabled) override - { - m_shell.PostCommand (enabled ? IDM_AUDIO_MOCKINGBOARD_ENABLE - : IDM_AUDIO_MOCKINGBOARD_DISABLE); - } - void ApplyMechanism (const std::string & mechanism) override { m_shell.PostCommand (IDM_AUDIO_DRIVE_MECHANISM, mechanism); diff --git a/Casso/Ui/Settings/SettingsPanelState.cpp b/Casso/Ui/Settings/SettingsPanelState.cpp index ed00dc96..59a560a2 100644 --- a/Casso/Ui/Settings/SettingsPanelState.cpp +++ b/Casso/Ui/Settings/SettingsPanelState.cpp @@ -416,20 +416,6 @@ void SettingsPanelState::SetFloppySound (bool enabled) -//////////////////////////////////////////////////////////////////////////////// -// -// SetMockingboard -// -//////////////////////////////////////////////////////////////////////////////// - -void SettingsPanelState::SetMockingboard (bool enabled) -{ - m_current.prefs.mockingboardEnabled = enabled; -} - - - - //////////////////////////////////////////////////////////////////////////////// // @@ -576,7 +562,6 @@ HRESULT SettingsPanelState::Apply ( sink.ApplySpeedMode (m_current.prefs.speedMode); sink.ApplyColorMode (m_current.prefs.colorMode); sink.ApplyFloppySound (m_current.prefs.floppySoundEnabled); - sink.ApplyMockingboard (m_current.prefs.mockingboardEnabled); sink.ApplyMechanism (m_current.prefs.floppyMechanism); sink.ApplyDriveVolumes (m_current.prefs.driveMotorVolume, m_current.prefs.driveHeadVolume, @@ -646,7 +631,6 @@ HRESULT SettingsPanelState::ExtractUiPrefs ( SettingsWriteMode::BufferAndFlush); outPrefs.floppySoundEnabled = GetBoolOpt (*uiObj, "floppySoundEnabled", true); - outPrefs.mockingboardEnabled = GetBoolOpt (*uiObj, "mockingboardEnabled", true); outPrefs.floppyMechanism = GetStringOpt (*uiObj, "floppyMechanism", "shugart"); outPrefs.driveMotorVolume = (float) GetNumberOpt (*uiObj, "driveMotorVolume", SettingsUiPrefs::kDefaultDriveMotorVolume); @@ -1149,7 +1133,6 @@ JsonValue SettingsPanelState::BuildJson ( uiObj.emplace_back ("colorMode", JsonValue (std::string (ColorToString (prefs.colorMode)))); uiObj.emplace_back ("writeMode", JsonValue (std::string (WriteModeToString (prefs.writeMode)))); uiObj.emplace_back ("floppySoundEnabled", JsonValue (prefs.floppySoundEnabled)); - uiObj.emplace_back ("mockingboardEnabled", JsonValue (prefs.mockingboardEnabled)); uiObj.emplace_back ("floppyMechanism", JsonValue (prefs.floppyMechanism)); uiObj.emplace_back ("driveMotorVolume", JsonValue ((double) prefs.driveMotorVolume)); uiObj.emplace_back ("driveHeadVolume", JsonValue ((double) prefs.driveHeadVolume)); @@ -1186,7 +1169,6 @@ bool SettingsPanelState::PrefsEqual ( if (a.colorMode != b.colorMode) return false; if (a.writeMode != b.writeMode) return false; if (a.floppySoundEnabled != b.floppySoundEnabled) return false; - if (a.mockingboardEnabled != b.mockingboardEnabled) return false; if (a.floppyMechanism != b.floppyMechanism) return false; if (a.driveMotorVolume != b.driveMotorVolume) return false; if (a.driveHeadVolume != b.driveHeadVolume) return false; diff --git a/Casso/Ui/Settings/SettingsPanelState.h b/Casso/Ui/Settings/SettingsPanelState.h index 063d5439..bc19ee7e 100644 --- a/Casso/Ui/Settings/SettingsPanelState.h +++ b/Casso/Ui/Settings/SettingsPanelState.h @@ -76,7 +76,6 @@ struct SettingsUiPrefs SettingsColorMode colorMode = SettingsColorMode::Color; SettingsWriteMode writeMode = SettingsWriteMode::BufferAndFlush; bool floppySoundEnabled = true; - bool mockingboardEnabled = true; std::string floppyMechanism = "shugart"; // "shugart" | "alps" bool writeProtect[2] = { false, false }; // Drive-audio component gains (0..1). Defaults mirror the @@ -159,7 +158,6 @@ class ISettingsApplySink virtual void ApplySpeedMode (SettingsSpeedMode mode) = 0; virtual void ApplyColorMode (SettingsColorMode mode) = 0; virtual void ApplyFloppySound (bool enabled) = 0; - virtual void ApplyMockingboard (bool enabled) = 0; virtual void ApplyMechanism (const std::string & mechanism) = 0; virtual void ApplyDriveVolumes (float motor, float head, float door) = 0; virtual void ApplyDrivePan (float driveOnePan, float driveTwoPan) = 0; @@ -212,7 +210,6 @@ class SettingsPanelState void SetColorMode (SettingsColorMode mode); void SetWriteMode (SettingsWriteMode mode); void SetFloppySound (bool enabled); - void SetMockingboard (bool enabled); void SetMechanism (const std::string & mechanism); void SetDriveMotorVolume (float gain); void SetDriveHeadVolume (float gain); diff --git a/Casso/resource.h b/Casso/resource.h index 3b8d9fee..10b507d5 100644 --- a/Casso/resource.h +++ b/Casso/resource.h @@ -48,8 +48,6 @@ #define IDM_AUDIO_DRIVE_VOLUMES 40053 #define IDM_AUDIO_DRIVE_PAN 40054 #define IDM_AUDIO_DRIVE_TEST 40055 -#define IDM_AUDIO_MOCKINGBOARD_ENABLE 40056 -#define IDM_AUDIO_MOCKINGBOARD_DISABLE 40057 #define IDM_HELP_KEYMAP 40040 #define IDM_HELP_ABOUT 40042 diff --git a/UnitTest/UiTests/SettingsPanelStateTests.cpp b/UnitTest/UiTests/SettingsPanelStateTests.cpp index 7ff7783a..0aa60f39 100644 --- a/UnitTest/UiTests/SettingsPanelStateTests.cpp +++ b/UnitTest/UiTests/SettingsPanelStateTests.cpp @@ -28,7 +28,6 @@ namespace SettingsSpeedMode lastSpeed = SettingsSpeedMode::Authentic; SettingsColorMode lastColor = SettingsColorMode::Color; bool lastFloppySound = true; - bool lastMockingboard = true; std::string lastMechanism; bool lastWriteProtect[2] = { false, false }; float lastDriveMotor = -1.0f; @@ -42,7 +41,6 @@ namespace void ApplySpeedMode (SettingsSpeedMode mode) override { lastSpeed = mode; ++applyCount; } void ApplyColorMode (SettingsColorMode mode) override { lastColor = mode; ++applyCount; } void ApplyFloppySound (bool enabled) override { lastFloppySound = enabled; ++applyCount; } - void ApplyMockingboard (bool enabled) override { lastMockingboard = enabled; ++applyCount; } void ApplyMechanism (const std::string & m) override { lastMechanism = m; ++applyCount; } void ApplyDriveVolumes (float motor, float head, float door) override { From 8f8e161b8715951438fa3f95555dd7b2545764ae Mon Sep 17 00:00:00 2001 From: Robert Elmer Date: Sat, 11 Jul 2026 22:21:32 -0700 Subject: [PATCH 7/7] style(cpu): coding-standard cleanup of the interrupt-servicing change Follow-up to the maskable-IRQ fix (ab1192d9): - No space before empty parens on the authored lines. - Trim the over-long TryStepInterrupt doc comment on the .h declaration (the full block lives in the .cpp). - Name the end-to-end test's loop bounds (kNopFieldBytes / kMaxHostSteps). - SetInterruptLine drops its early return for a single-exit if/else. --- CassoCore/Cpu6502.cpp | 15 +++++++------ CassoCore/Cpu6502.h | 14 +++--------- CassoEmuCore/Core/EmuCpu.h | 8 ++----- UnitTest/CpuIrqTests.cpp | 44 +++++++++++++++++++++----------------- 4 files changed, 37 insertions(+), 44 deletions(-) diff --git a/CassoCore/Cpu6502.cpp b/CassoCore/Cpu6502.cpp index 6e062c24..01d895a1 100644 --- a/CassoCore/Cpu6502.cpp +++ b/CassoCore/Cpu6502.cpp @@ -135,15 +135,16 @@ void Cpu6502::SetInterruptLine (CpuInterruptKind kind, bool asserted) if (kind == CpuInterruptKind::kMaskable) { m_irqLine = asserted; - return; } - - if (asserted && !m_nmiLine) + else { - m_nmiPending = true; - } + if (asserted && !m_nmiLine) + { + m_nmiPending = true; + } - m_nmiLine = asserted; + m_nmiLine = asserted; + } } @@ -250,7 +251,7 @@ bool Cpu6502::TryDispatchInterrupt (uint32_t & outCycles) // //////////////////////////////////////////////////////////////////////////////// -bool Cpu6502::TryStepInterrupt () +bool Cpu6502::TryStepInterrupt() { bool dispatched = false; diff --git a/CassoCore/Cpu6502.h b/CassoCore/Cpu6502.h index 6c8ef0e3..b3168bc1 100644 --- a/CassoCore/Cpu6502.h +++ b/CassoCore/Cpu6502.h @@ -59,17 +59,9 @@ class Cpu6502 : public Cpu, public ICpu, public I6502DebugInfo bool IsNmiLineAsserted () const { return m_nmiLine; } bool IsNmiPending () const { return m_nmiPending; } - // Host-driven interrupt dispatch for run loops that own their own cycle - // accounting (StepOne + AddCycles) rather than the self-accounting Step(). - // Dispatches a pending NMI edge or unmasked IRQ via DispatchVector, records - // the 7-cycle cost in m_lastCycles (so GetLastInstructionCycles reports it), - // and leaves m_totalCycles to the caller's AddCycles -- so an interrupt step - // and a normal instruction step advance the counters through one identical - // path. Returns true if an interrupt was taken, in which case the caller - // skips StepOne for this step. The shell run loop MUST call this each step - // or maskable IRQs are never serviced (the failure mode that hid until the - // Mockingboard became the first device to assert one). - bool TryStepInterrupt (); + // Dispatches a pending NMI/IRQ for host loops that drive the CPU with raw + // StepOne rather than the self-accounting Step(). Returns true if taken. + bool TryStepInterrupt(); protected: // Returns true if a pending NMI or unmasked IRQ was dispatched. On diff --git a/CassoEmuCore/Core/EmuCpu.h b/CassoEmuCore/Core/EmuCpu.h index 64795b6f..4da434c5 100644 --- a/CassoEmuCore/Core/EmuCpu.h +++ b/CassoEmuCore/Core/EmuCpu.h @@ -75,12 +75,8 @@ class EmuCpu void StepOne () { m_cpu6502->StepOne (); } Byte GetLastInstructionCycles () const { return m_cpu6502->GetLastInstructionCycles (); } - // Interrupt-aware companion to StepOne for the shell run loop. Dispatches a - // pending NMI/IRQ (returns true) or does nothing (returns false); on a true - // return the caller skips StepOne and GetLastInstructionCycles() reports the - // 7-cycle interrupt cost. Callers that drive the CPU with raw StepOne MUST - // gate it on this or maskable IRQs are never serviced. - bool TryStepInterrupt () { return m_cpu6502->TryStepInterrupt (); } + // Interrupt-aware companion to StepOne; see Cpu6502::TryStepInterrupt. + bool TryStepInterrupt() { return m_cpu6502->TryStepInterrupt(); } // Execution trace (--trace switch). Forwarded to the underlying 6502. void EnableTrace (size_t capacity) { m_cpu6502->EnableTrace (capacity); } diff --git a/UnitTest/CpuIrqTests.cpp b/UnitTest/CpuIrqTests.cpp index c55a91d7..a479b867 100644 --- a/UnitTest/CpuIrqTests.cpp +++ b/UnitTest/CpuIrqTests.cpp @@ -34,6 +34,10 @@ namespace Apple2eFidelity static constexpr Word kNmiHandler = 0xA000; static constexpr Word kStartPc = 0x8000; + // Via6522Timer1IrqReachesCpuThroughHostLoop bounds. + static constexpr Word kNopFieldBytes = 64; // NOP scratch the CPU runs before the IRQ fires + static constexpr int kMaxHostSteps = 200; // host-loop iterations before we give up + static void SeedVectors (TestCpu & cpu) { @@ -244,22 +248,22 @@ namespace Apple2eFidelity cpu.InitForTest (kStartPc); SeedVectors (cpu); cpu.Poke (kStartPc, 0xEA); // NOP -- must NOT run; IRQ pre-empts it - cpu.Status ().flags.interruptDisable = 0; + cpu.Status().flags.interruptDisable = 0; iface.SetInterruptLine (CpuInterruptKind::kMaskable, true); // One iteration of the shell's exact stepping pattern. - tookIrq = cpu.TryStepInterrupt (); + tookIrq = cpu.TryStepInterrupt(); if (!tookIrq) { - cpu.StepOne (); + cpu.StepOne(); } Assert::IsTrue (tookIrq, L"Host step loop must dispatch the asserted IRQ"); - Assert::AreEqual (kIrqHandler, cpu.RegPC (), + Assert::AreEqual (kIrqHandler, cpu.RegPC(), L"PC must vector to the IRQ handler via the StepOne loop"); - Assert::AreEqual (static_cast (7), cpu.GetLastInstructionCycles (), + Assert::AreEqual (static_cast (7), cpu.GetLastInstructionCycles(), L"Interrupt step must report 7 cycles for host cycle accounting"); } @@ -275,18 +279,18 @@ namespace Apple2eFidelity cpu.InitForTest (kStartPc); SeedVectors (cpu); cpu.Poke (kStartPc, 0xEA); // NOP -- must run because I=1 masks the IRQ - cpu.Status ().flags.interruptDisable = 1; + cpu.Status().flags.interruptDisable = 1; iface.SetInterruptLine (CpuInterruptKind::kMaskable, true); - tookIrq = cpu.TryStepInterrupt (); + tookIrq = cpu.TryStepInterrupt(); if (!tookIrq) { - cpu.StepOne (); + cpu.StepOne(); } Assert::IsFalse (tookIrq, L"IRQ must not dispatch while I=1"); - Assert::AreEqual (static_cast (kStartPc + 1), cpu.RegPC (), + Assert::AreEqual (static_cast (kStartPc + 1), cpu.RegPC(), L"With I=1 the NOP must run instead of vectoring"); } @@ -301,18 +305,18 @@ namespace Apple2eFidelity cpu.InitForTest (kStartPc); SeedVectors (cpu); - cpu.Status ().flags.interruptDisable = 1; // NMI ignores I + cpu.Status().flags.interruptDisable = 1; // NMI ignores I iface.SetInterruptLine (CpuInterruptKind::kNonMaskable, true); - took = cpu.TryStepInterrupt (); + took = cpu.TryStepInterrupt(); if (!took) { - cpu.StepOne (); + cpu.StepOne(); } Assert::IsTrue (took, L"Host step loop must dispatch the NMI edge"); - Assert::AreEqual (kNmiHandler, cpu.RegPC (), + Assert::AreEqual (kNmiHandler, cpu.RegPC(), L"NMI must vector via $FFFA through the StepOne loop"); } @@ -336,11 +340,11 @@ namespace Apple2eFidelity cpu.InitForTest (kStartPc); SeedVectors (cpu); - for (a = kStartPc; a < kStartPc + 64; ++a) + for (a = kStartPc; a < kStartPc + kNopFieldBytes; ++a) { cpu.Poke (a, 0xEA); // NOP field so StepOne makes progress } - cpu.Status ().flags.interruptDisable = 0; + cpu.Status().flags.interruptDisable = 0; Assert::AreEqual (S_OK, via.AttachInterruptController (&ic), L"VIA must bind to the interrupt controller"); @@ -352,16 +356,16 @@ namespace Apple2eFidelity via.WriteRegister (Via6522::kRegT1LL, 0x10); via.WriteRegister (Via6522::kRegT1CH, 0x00); // load+arm, count = 0x0010 - for (i = 0; i < 200 && !reachedHandler; ++i) + for (i = 0; i < kMaxHostSteps && !reachedHandler; ++i) { - if (!cpu.TryStepInterrupt ()) + if (!cpu.TryStepInterrupt()) { - cpu.StepOne (); + cpu.StepOne(); } - via.Tick (cpu.GetLastInstructionCycles ()); + via.Tick (cpu.GetLastInstructionCycles()); - if (cpu.RegPC () == kIrqHandler) + if (cpu.RegPC() == kIrqHandler) { reachedHandler = true; }