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 00000000..5d1caed1 Binary files /dev/null and b/Apple2/Demos/mockingboard-irq-test.dsk differ 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 00000000..c7d20cb7 Binary files /dev/null and b/Apple2/Demos/mockingboard-test.dsk differ 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/EmulatorShell.cpp b/Casso/EmulatorShell.cpp index 19535f8a..dd41c137 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" @@ -2887,6 +2888,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()); + } } @@ -2965,13 +2974,21 @@ void EmulatorShell::DispatchCpuCommand (const EmulatorCommand & cmd) { if (m_cpu) { - m_cpu->StepOne(); + if (!m_cpu->TryStepInterrupt()) + { + m_cpu->StepOne(); + } if (m_refs.diskController != nullptr) { 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()); @@ -3213,13 +3230,21 @@ void EmulatorShell::StepInstructionWhilePaused() return; } - m_cpu->StepOne(); + if (!m_cpu->TryStepInterrupt()) + { + m_cpu->StepOne(); + } if (m_refs.diskController != nullptr) { 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()); @@ -3281,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()); @@ -3321,7 +3347,10 @@ void EmulatorShell::ExecuteCpuSlices() while (sliceActual < sliceTarget) { - m_cpu->StepOne(); + if (!m_cpu->TryStepInterrupt()) + { + m_cpu->StepOne(); + } cycles = m_cpu->GetLastInstructionCycles(); @@ -3340,6 +3369,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); @@ -3355,12 +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()); + 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/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/SettingsPanelState.cpp b/Casso/Ui/Settings/SettingsPanelState.cpp index 3aaf8432..59a560a2 100644 --- a/Casso/Ui/Settings/SettingsPanelState.cpp +++ b/Casso/Ui/Settings/SettingsPanelState.cpp @@ -630,8 +630,8 @@ 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.floppyMechanism = GetStringOpt (*uiObj, "floppyMechanism", "shugart"); outPrefs.driveMotorVolume = (float) GetNumberOpt (*uiObj, "driveMotorVolume", SettingsUiPrefs::kDefaultDriveMotorVolume); outPrefs.driveHeadVolume = (float) GetNumberOpt (*uiObj, "driveHeadVolume", SettingsUiPrefs::kDefaultDriveHeadVolume); 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/CassoCore/Cpu6502.cpp b/CassoCore/Cpu6502.cpp index ef04381c..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; + } } @@ -237,6 +238,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..b3168bc1 100644 --- a/CassoCore/Cpu6502.h +++ b/CassoCore/Cpu6502.h @@ -59,6 +59,10 @@ class Cpu6502 : public Cpu, public ICpu, public I6502DebugInfo bool IsNmiLineAsserted () const { return m_nmiLine; } bool IsNmiPending () const { return m_nmiPending; } + // 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 // dispatch, sets outCycles = 7 and accumulates m_totalCycles. 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/EmuCpu.h b/CassoEmuCore/Core/EmuCpu.h index 5a1f9abd..4da434c5 100644 --- a/CassoEmuCore/Core/EmuCpu.h +++ b/CassoEmuCore/Core/EmuCpu.h @@ -75,6 +75,9 @@ class EmuCpu void StepOne () { m_cpu6502->StepOne (); } Byte GetLastInstructionCycles () const { return m_cpu6502->GetLastInstructionCycles (); } + // 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); } bool IsTraceEnabled () const { return m_cpu6502->IsTraceEnabled (); } 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..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,86 @@ 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); + + result = !m_slotRom[slot].empty(); + +Error: + return result; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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) +{ + HRESULT hr = S_OK; + + + + CBRAEx (slot >= kMinSlot && slot <= kMaxSlot, E_INVALIDARG); + + m_slotIoDevice[slot] = device; + +Error: + return; +} + - return !m_slotRom[slot].empty (); + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 +{ + HRESULT hr = S_OK; + MemoryDevice * result = nullptr; + int slot = static_cast ((address >> kAddressPageShift) & kSlotNibbleMask); + + + + 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); + + result = m_slotIoDevice[slot]; + +Error: + return result; } @@ -124,13 +206,14 @@ 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); if (address >= kSlot3PageStart && address <= kSlot3PageEnd) { - if (!m_mmu.GetIntCxRom () && !m_mmu.GetSlotC3Rom ()) + if (!m_mmu.GetIntCxRom() && !m_mmu.GetSlotC3Rom()) { m_mmu.SetIntC8Rom (true); } @@ -138,7 +221,7 @@ Byte CxxxRomRouter::Read (Word address) if (address == kIntC8RomClearAddr) { - m_mmu.ResetIntC8Rom (); + m_mmu.ResetIntC8Rom(); } return value; @@ -152,18 +235,26 @@ 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. // //////////////////////////////////////////////////////////////////////////////// void CxxxRomRouter::Write (Word address, Byte value) { - UNREFERENCED_PARAMETER (value); + MemoryDevice * io = SlotIoDeviceFor (address); - if (address == kIntC8RomClearAddr) + + + if (io != nullptr) { - m_mmu.ResetIntC8Rom (); + io->Write (address, value); + } + else if (address == kIntC8RomClearAddr) + { + m_mmu.ResetIntC8Rom(); } } @@ -177,7 +268,7 @@ void CxxxRomRouter::Write (Word address, Byte value) // //////////////////////////////////////////////////////////////////////////////// -void CxxxRomRouter::Reset () +void CxxxRomRouter::Reset() { } @@ -189,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) + 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 kFloatingBusByte; + result = (romOffset < m_internal.size()) ? m_internal[romOffset] : 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) + else if (inExp) { - return romOffset < m_internal.size () ? m_internal[romOffset] : kFloatingBusByte; + result = kFloatingBusByte; } - - if (inExp) - { - if (intC8) - { - return romOffset < m_internal.size () ? m_internal[romOffset] : kFloatingBusByte; - } - - return 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; } 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..0aa9d072 --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/Ay8910.cpp @@ -0,0 +1,539 @@ +#include "Pch.h" + +#include "Ay8910.h" +#include "Ehm.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 +static constexpr int s_kChannelCount = 3; + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 +// +// 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) +{ + if (m_latched < kRegCount) + { + WriteRegister (m_latched, 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]; + } + + return result; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// WriteRegister +// +// 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) +{ + HRESULT hr = S_OK; + + + + CBRAEx (reg < kRegCount, E_INVALIDARG); + + m_regs[reg] = value; + + if (reg == kRegEnvShape) + { + 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 +{ + HRESULT hr = S_OK; + Byte result = 0xFF; + + + + CBRAEx (reg < kRegCount, E_INVALIDARG); + + result = m_regs[reg]; + +Error: + 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 < s_kChannelCount; 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. Requires a configured sample rate -- +// generating audio before SetSampleRate is a code-ordering bug and asserts. +// +//////////////////////////////////////////////////////////////////////////////// + +float Ay8910::GenerateSample() +{ + HRESULT hr = S_OK; + float result = 0.0f; + int ticks = 0; + int i = 0; + + + + CBRAEx (m_sampleRate != 0, E_UNEXPECTED); + + m_tickAccum += m_ticksPerSample; + ticks = static_cast (m_tickAccum); + m_tickAccum -= ticks; + + for (i = 0; i < ticks; i++) + { + AdvanceBaseTick(); + } + + result = CurrentOutput(); + +Error: + return result; +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 < s_kChannelCount; 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) + { + // Held at the last level; nothing to advance. + } + else if (m_envDirUp && m_envLevel < kMaxEnvLevel) + { + // Still ramping up toward the top. + m_envLevel++; + } + else if (!m_envDirUp && m_envLevel > 0) + { + // Still ramping down toward zero. + m_envLevel--; + } + else if (!m_envCont) + { + // Ramp complete, one-shot: park at 0 and hold. + m_envLevel = 0; + m_envHolding = true; + } + 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; + } + } + 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; + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// 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 < s_kChannelCount; c++) + { + int toneDisabled = (mixer >> c) & 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; + 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..009046bb --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/MockingboardCard.cpp @@ -0,0 +1,286 @@ +#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 +// +// 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) +{ + UNREFERENCED_PARAMETER (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; + } + else if (control != m_lastControl[index]) + { + 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) +{ + UNREFERENCED_PARAMETER (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..38ab3b4d --- /dev/null +++ b/CassoEmuCore/Devices/Mockingboard/Via6522.cpp @@ -0,0 +1,485 @@ +#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) +{ + HRESULT hr = S_OK; + + + + 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: + // 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(); + break; + + case kRegIer: + if (value & kIerSetClear) + { + m_ier |= static_cast (value & 0x7F); + } + else + { + m_ier &= static_cast (~(value & 0x7F)); + } + 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) +{ + 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); + } + else + { + 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); + } + else + { + if (m_t2Armed) + { + SetFlag (kIrqTimer2); + m_t2Armed = false; + } + + remaining = static_cast (cycles) - toUnderflow; + m_t2Counter = static_cast (0xFFFF - (remaining & 0xFFFF)); + } +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// SetFlag +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::SetFlag (Byte flag) +{ + m_ifr |= static_cast (flag & 0x7F); + + UpdateIrq(); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// ClearFlag +// +//////////////////////////////////////////////////////////////////////////////// + +void Via6522::ClearFlag (Byte flag) +{ + m_ifr &= static_cast (~(flag & 0x7F)); + + UpdateIrq(); +} + + + + + +//////////////////////////////////////////////////////////////////////////////// +// +// UpdateIrq +// +// 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() +{ + if (m_irqBound && m_ic != nullptr) + { + 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/CpuIrqTests.cpp b/UnitTest/CpuIrqTests.cpp index 216fe616..a479b867 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; @@ -32,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) { @@ -219,5 +225,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 + kNopFieldBytes; ++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 < kMaxHostSteps && !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"); + } }; } 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/UnitTest.vcxproj b/UnitTest/UnitTest.vcxproj index cf1264b8..025cd6f2 100644 --- a/UnitTest/UnitTest.vcxproj +++ b/UnitTest/UnitTest.vcxproj @@ -275,6 +275,9 @@ + + +