Mockinboard card

This commit is contained in:
Iván Izaguirre committed 2026-07-10 13:47:31 +02:00
1 parent 4a608231dd
commit a7782ad49b
28 files changed
+1525 -252

No files matched your search

+25 -6
View File
@@ -17,8 +17,10 @@ type Apple2 struct {
io *ioC0Page
video screen.VideoSource
cg *CharacterGenerator
cards [8]Card
tracers []executionTracer
cards [8]Card
tracers []executionTracer
tickerCards []cardTicker
irqRequests uint8 // Bitmask of the slots asserting the IRQ line
softVideoSwitch softVideoSwitchProvider
board string
@@ -50,15 +52,32 @@ func (a *Apple2) GetCards() [8]Card {
return a.cards
}
// A cardTicker is a card that needs to run logic on every instruction
type cardTicker interface {
tick()
}
func (a *Apple2) registerTickerCard(c cardTicker) {
a.tickerCards = append(a.tickerCards, c)
}
// requestIRQ sets or clears the IRQ request of a slot. The CPU IRQ line
// is asserted while any slot requests it. To be called only from the
// emulation goroutine.
func (a *Apple2) requestIRQ(slot int, asserted bool) {
if asserted {
a.irqRequests |= 1 << slot
} else {
a.irqRequests &^= 1 << slot
}
a.cpu.SetIRQ(a.irqRequests != 0)
}
// SetKeyboardProvider attaches an external keyboard provider
func (a *Apple2) SetKeyboardProvider(kb KeyboardProvider) {
a.io.setKeyboardProvider(kb)
}
// SetSpeakerProvider attaches an external speaker provider
func (a *Apple2) SetSpeakerProvider(s SpeakerProvider) {
a.io.setSpeakerProvider(s)
}
// SetJoysticksProvider attaches an external joysticks provider
func (a *Apple2) SetJoysticksProvider(j JoysticksProvider) {
+8
View File
@@ -48,6 +48,7 @@ func (a *Apple2) Start(paused bool) {
a.cpu.ExecuteInstruction()
a.cycles += a.cpu.GetCycles() - startCycles
a.tickCards()
a.executionTrace()
}
} else {
@@ -57,6 +58,7 @@ func (a *Apple2) Start(paused bool) {
card.runDMACycle()
a.cycles++
a.tickCards()
a.executionTrace()
}
}
@@ -137,6 +139,12 @@ func (a *Apple2) reset() {
}
}
func (a *Apple2) tickCards() {
for _, c := range a.tickerCards {
c.tick()
}
}
func (a *Apple2) executionTrace() {
for _, v := range a.tracers {
v.inspect()
+196
View File
@@ -0,0 +1,196 @@
/*
Package audio synthesizes the sound of the Apple II.
The emulation core reports the sound activity as events with the CPU
cycle when they happen: speaker clicks and output level changes of the
sound cards. This package reconstructs a mixed 48 kHz float32 mono
stream from those events. It is shared by the frontends, that only need
to send the samples to their audio device.
The synthesis works as follows:
- The Mixer owns a render clock, measured in CPU cycles, that advances
exactly one sample period per sample generated, whether there is
sound or not.
- Each Source holds the timestamped level changes of one sound
generator. For every output sample, the level of each source is
averaged over the sample window of ~21.31 CPU cycles, with the
changes applied at their exact fractional position (box filter).
This avoids most of the aliasing of naive point sampling.
- The render clock lags the newest received event by a target latency,
building a cushion against jitter between the emulation and audio
clocks. If the events get too far ahead (fast forward) or fall
behind (pause, hiccup) the render clock resynchronizes.
- The sources are summed and a DC-blocking high-pass filter models the
AC coupling of a real speaker: constant levels decay to silence.
*/
package audio
const (
// SampleRate is the sample rate of the generated audio stream
SampleRate = 48000
// The render clock stays this far behind the newest event, as a
// cushion against jitter of the emulation clock
targetLatencyMs = 30
// Resynchronize if the newest event is further ahead than this
maxLatencyMs = 200
// Pole of the DC-blocking filter, ~30 Hz cutoff at 48 kHz
dcFilterPole = 0.996
eventChannelSize = 10_000
)
type levelEvent struct {
cycle uint64
level float32
}
// Source receives the output level changes of one sound generator. It
// implements the izapple2.AudioSink interface.
type Source struct {
events chan levelEvent
pending []levelEvent
consumed int
level float64 // Current output level of the generator
}
// PushLevel reports that the generator output changed to level at the
// given CPU cycle. It can be called from any goroutine.
func (s *Source) PushLevel(cycle uint64, level float32) {
select {
case s.events <- levelEvent{cycle, level}:
default:
// The channel is full, the event is lost
}
}
// renderWindow returns the average level over the sample window,
// applying the pending events that fall inside it
func (s *Source) renderWindow(start, end float64) float64 {
acc := 0.0
pos := start
for s.consumed < len(s.pending) {
event := s.pending[s.consumed]
cycle := float64(event.cycle)
if cycle >= end {
break
}
if cycle > pos {
acc += s.level * (cycle - pos)
pos = cycle
}
s.level = float64(event.level)
s.consumed++
}
acc += s.level * (end - pos)
return acc / (end - start)
}
// Mixer generates the audio stream of the machine, summing the streams
// of its sources.
type Mixer struct {
sources []*Source
// Conversions for the configured CPU clock
cyclesPerSample float64 // ~21.31 cycles at the standard clock
targetLatencyCycles float64
maxLatencyCycles float64
synced bool // False until the first event arrives
renderCycle float64 // Render clock, the CPU cycle of the next sample
// DC-blocking filter state
dcPrevIn float64
dcPrevOut float64
}
// NewMixer creates a Mixer for a CPU running at clockMhz
func NewMixer(clockMhz float64) *Mixer {
var m Mixer
m.cyclesPerSample = 1000 * clockMhz * 1000 / SampleRate
m.targetLatencyCycles = 1000 * clockMhz * targetLatencyMs
m.maxLatencyCycles = 1000 * clockMhz * maxLatencyMs
return &m
}
// NewSource adds a sound source to the mix. All the sources must be
// created before the audio device starts calling ReadSamples.
func (m *Mixer) NewSource() *Source {
s := &Source{
events: make(chan levelEvent, eventChannelSize),
pending: make([]levelEvent, 0, eventChannelSize),
}
m.sources = append(m.sources, s)
return s
}
// ReadSamples fills buf with the next samples of the audio stream. Mono,
// values in [-1.0, 1.0]. It must always be called from the same goroutine
// or audio callback thread.
func (m *Mixer) ReadSamples(buf []float32) {
// Take the queued events of each source
someEvents := false
var oldest, newest float64
for _, s := range m.sources {
for done := false; !done; {
select {
case event := <-s.events:
s.pending = append(s.pending, event)
default:
done = true
}
}
if len(s.pending) > 0 {
first := float64(s.pending[0].cycle)
last := float64(s.pending[len(s.pending)-1].cycle)
if !someEvents || first < oldest {
oldest = first
}
if !someEvents || last > newest {
newest = last
}
someEvents = true
}
}
// Synchronize the render clock with the event timestamps
if someEvents {
if !m.synced || oldest < m.renderCycle || newest > m.renderCycle+m.maxLatencyCycles {
m.renderCycle = newest - m.targetLatencyCycles
m.synced = true
}
}
if !m.synced {
// No events received yet, keep silent
for i := range buf {
buf[i] = 0
}
return
}
for i := range buf {
windowEnd := m.renderCycle + m.cyclesPerSample
sample := 0.0
for _, s := range m.sources {
sample += s.renderWindow(m.renderCycle, windowEnd)
}
// DC-blocking filter
out := sample - m.dcPrevIn + dcFilterPole*m.dcPrevOut
m.dcPrevIn = sample
m.dcPrevOut = out
buf[i] = float32(out)
m.renderCycle = windowEnd
}
// Keep the events not rendered yet for the next call
for _, s := range m.sources {
s.pending = s.pending[:copy(s.pending, s.pending[s.consumed:])]
s.consumed = 0
}
}
+62
View File
@@ -0,0 +1,62 @@
package audio
import (
"testing"
)
func waveAmplitude(buf []float32) float32 {
peak := float32(0)
for _, v := range buf {
if v > peak {
peak = v
}
}
return peak
}
func TestMixerSumsSources(t *testing.T) {
// The same square wave on one speaker and on two in-phase speakers
single := newWaveDriver(1_000_000, 1)
double := newWaveDriver(1_000_000, 2)
singlePeak := waveAmplitude(single.run(512, 20))
doublePeak := waveAmplitude(double.run(512, 20))
ratio := doublePeak / singlePeak
if ratio < 1.8 || ratio > 2.2 {
t.Errorf("expected two in-phase sources to double the amplitude, got a ratio of %v", ratio)
}
}
func TestMixerSilentSourceDoesNotBlock(t *testing.T) {
// An extra source with no events must not prevent the others from
// playing
d := newWaveDriver(1_000_000, 1)
d.m.NewSource()
out := d.run(512, 20)
if waveAmplitude(out) < 0.1 {
t.Error("expected the speaker to play with a silent source attached")
}
}
func TestMixerSourceHoldsLevel(t *testing.T) {
// A source that reports one level change holds it, and the DC filter
// slowly brings the output back to zero
m := NewMixer(testClockMhz)
s := m.NewSource()
s.PushLevel(1_000_000, 0.5)
buf := make([]float32, SampleRate) // One second
m.ReadSamples(buf)
if waveAmplitude(buf) < 0.4 {
t.Error("expected the level step to reach the output")
}
tail := buf[len(buf)-100:]
for _, v := range tail {
if v > 0.01 || v < -0.01 {
t.Fatalf("expected the DC filter to decay a constant level, got %v", v)
}
}
}
-156
View File
@@ -1,156 +0,0 @@
/*
Package audio synthesizes the sound of the Apple II speaker.
The Apple II speaker is a 1-bit device: each access to $C030 toggles the
speaker cone position. The emulator core reports those accesses as clicks
with the CPU cycle when they happen. This package reconstructs a 48 kHz
float32 audio stream from that stream of click timestamps. It is shared by
the frontends, that only need to send the samples to their audio device.
The synthesis works as follows:
- A render clock, measured in CPU cycles, advances exactly one sample
period per sample generated, whether there is sound or not.
- Each output sample is the average of the speaker position over its
window of ~21.31 CPU cycles, with the toggles placed at their exact
fractional position (box filter). This avoids most of the aliasing of
naive point sampling.
- The render clock lags the newest received click by a target latency,
building a cushion against jitter between the emulation and audio
clocks. If the clicks get too far ahead (fast forward) or fall behind
(pause, hiccup) the render clock resynchronizes.
- A DC-blocking high-pass filter models the AC coupling of a real
speaker: constant levels decay to silence and no artificial decay or
resting level is needed.
*/
package audio
const (
// SampleRate is the sample rate of the generated audio stream
SampleRate = 48000
// The render clock stays this far behind the newest click, as a
// cushion against jitter of the emulation clock
targetLatencyMs = 30
// Resynchronize if the newest click is further ahead than this
maxLatencyMs = 200
// Speaker cone positions, giving half of the full scale peak to peak
speakerLevel = 0.25
// Pole of the DC-blocking filter, ~30 Hz cutoff at 48 kHz
dcFilterPole = 0.996
clickChannelSize = 10_000
)
// Speaker generates the audio stream of the Apple II speaker. It
// implements the izapple2.SpeakerProvider interface.
type Speaker struct {
clickChannel chan uint64
pendingClicks []uint64
// Conversions for the configured CPU clock
cyclesPerSample float64 // ~21.31 cycles at the standard clock
targetLatencyCycles float64
maxLatencyCycles float64
synced bool // False until the first click arrives
renderCycle float64 // Render clock, the CPU cycle of the next sample
level float64 // Current speaker position
// DC-blocking filter state
dcPrevIn float64
dcPrevOut float64
}
// NewSpeaker creates a Speaker for a CPU running at clockMhz
func NewSpeaker(clockMhz float64) *Speaker {
var s Speaker
s.clickChannel = make(chan uint64, clickChannelSize)
s.pendingClicks = make([]uint64, 0, clickChannelSize)
s.cyclesPerSample = 1000 * clockMhz * 1000 / SampleRate
s.targetLatencyCycles = 1000 * clockMhz * targetLatencyMs
s.maxLatencyCycles = 1000 * clockMhz * maxLatencyMs
s.level = speakerLevel
return &s
}
// Click receives a speaker click. The argument is the CPU cycle when it is
// generated. It can be called from any goroutine.
func (s *Speaker) Click(cycle uint64) {
select {
case s.clickChannel <- cycle:
default:
// The channel is full, the click is lost
}
}
// ReadSamples fills buf with the next samples of the audio stream. Mono,
// values in [-1.0, 1.0]. It must always be called from the same goroutine
// or audio callback thread.
func (s *Speaker) ReadSamples(buf []float32) {
// Take the queued clicks
for done := false; !done; {
select {
case cycle := <-s.clickChannel:
s.pendingClicks = append(s.pendingClicks, cycle)
default:
done = true
}
}
// Synchronize the render clock with the click timestamps
if len(s.pendingClicks) > 0 {
oldest := float64(s.pendingClicks[0])
newest := float64(s.pendingClicks[len(s.pendingClicks)-1])
if !s.synced || oldest < s.renderCycle || newest > s.renderCycle+s.maxLatencyCycles {
s.renderCycle = newest - s.targetLatencyCycles
s.synced = true
}
}
if !s.synced {
// No clicks received yet, keep silent
for i := range buf {
buf[i] = 0
}
return
}
consumed := 0
for i := range buf {
windowEnd := s.renderCycle + s.cyclesPerSample
// Average the speaker position over the sample window, toggling
// at the exact click positions
acc := 0.0
pos := s.renderCycle
for consumed < len(s.pendingClicks) {
click := float64(s.pendingClicks[consumed])
if click >= windowEnd {
break
}
if click > pos {
acc += s.level * (click - pos)
pos = click
}
s.level = -s.level
consumed++
}
acc += s.level * (windowEnd - pos)
sample := acc / s.cyclesPerSample
// DC-blocking filter
out := sample - s.dcPrevIn + dcFilterPole*s.dcPrevOut
s.dcPrevIn = sample
s.dcPrevOut = out
buf[i] = float32(out)
s.renderCycle = windowEnd
}
// Keep the clicks not rendered yet for the next call
s.pendingClicks = s.pendingClicks[:copy(s.pendingClicks, s.pendingClicks[consumed:])]
}
+42 -17
View File
@@ -8,14 +8,32 @@ import (
// The standard NTSC Apple II clock
const testClockMhz = 14.318 / 14
func TestSilenceBeforeFirstClick(t *testing.T) {
s := NewSpeaker(testClockMhz)
// testToggler pushes speaker-like level toggles to a source
type testToggler struct {
source *Source
high bool
}
const testSpeakerLevel = 0.25
func (tg *testToggler) click(cycle uint64) {
tg.high = !tg.high
if tg.high {
tg.source.PushLevel(cycle, testSpeakerLevel)
} else {
tg.source.PushLevel(cycle, -testSpeakerLevel)
}
}
func TestSilenceBeforeFirstEvent(t *testing.T) {
m := NewMixer(testClockMhz)
m.NewSource()
buf := make([]float32, 1024)
buf[10] = 42.0 // Ensure the buffer is overwritten
s.ReadSamples(buf)
m.ReadSamples(buf)
for i, v := range buf {
if v != 0 {
t.Fatalf("expected silence before the first click, got %v at sample %v", v, i)
t.Fatalf("expected silence before the first event, got %v at sample %v", v, i)
}
}
}
@@ -24,35 +42,42 @@ func TestSilenceBeforeFirstClick(t *testing.T) {
// parallel: on each step it queues the clicks of a square wave for the
// next chunk of emulated time and reads the equivalent audio samples.
type waveDriver struct {
s *Speaker
m *Mixer
togglers []*testToggler
nextClick uint64
cycle uint64
}
const driverChunkSamples = 480 // 10 ms
func newWaveDriver(startCycle uint64) *waveDriver {
return &waveDriver{
s: NewSpeaker(testClockMhz),
func newWaveDriver(startCycle uint64, togglers int) *waveDriver {
d := &waveDriver{
m: NewMixer(testClockMhz),
nextClick: startCycle,
cycle: startCycle,
}
for range togglers {
d.togglers = append(d.togglers, &testToggler{source: d.m.NewSource()})
}
return d
}
// step advances 10 ms, toggling every halfPeriodCycles, 0 for silence
func (d *waveDriver) step(halfPeriodCycles uint64) []float32 {
chunkCycles := float64(driverChunkSamples) * d.s.cyclesPerSample
chunkCycles := float64(driverChunkSamples) * d.m.cyclesPerSample
d.cycle += uint64(chunkCycles)
if halfPeriodCycles != 0 {
for d.nextClick < d.cycle {
d.s.Click(d.nextClick)
for _, tg := range d.togglers {
tg.click(d.nextClick)
}
d.nextClick += halfPeriodCycles
}
} else {
d.nextClick = d.cycle
}
buf := make([]float32, driverChunkSamples)
d.s.ReadSamples(buf)
d.m.ReadSamples(buf)
return buf
}
@@ -66,7 +91,7 @@ func (d *waveDriver) run(halfPeriodCycles uint64, steps int) []float32 {
func TestSquareWaveFrequency(t *testing.T) {
// A square wave toggling every 512 cycles, ~977 Hz, for 400 ms
d := newWaveDriver(1_000_000)
d := newWaveDriver(1_000_000, 1)
out := d.run(512, 40)
// Check the average period on the second half, once in steady state
@@ -82,14 +107,14 @@ func TestSquareWaveFrequency(t *testing.T) {
}
gotPeriod := float64(risings[len(risings)-1]-risings[0]) / float64(len(risings)-1)
wantPeriod := 2 * 512 / d.s.cyclesPerSample // ~48 samples
wantPeriod := 2 * 512 / d.m.cyclesPerSample // ~48 samples
if math.Abs(gotPeriod-wantPeriod) > 0.1 {
t.Errorf("expected a period of %.2f samples, got %.2f", wantPeriod, gotPeriod)
}
}
func TestAmplitudeAndDCRemoval(t *testing.T) {
d := newWaveDriver(1_000_000)
d := newWaveDriver(1_000_000, 1)
out := d.run(512, 40)
sum := 0.0
@@ -106,7 +131,7 @@ func TestAmplitudeAndDCRemoval(t *testing.T) {
}
func TestSilenceDecaysAfterSound(t *testing.T) {
d := newWaveDriver(1_000_000)
d := newWaveDriver(1_000_000, 1)
d.run(512, 20)
// A second of silence
@@ -121,7 +146,7 @@ func TestSilenceDecaysAfterSound(t *testing.T) {
}
func TestSoundResumesAfterPause(t *testing.T) {
d := newWaveDriver(1_000_000)
d := newWaveDriver(1_000_000, 1)
d.run(512, 20)
d.run(0, 100) // A second of silence
@@ -140,7 +165,7 @@ func TestSoundResumesAfterPause(t *testing.T) {
}
func TestResyncAfterFastForward(t *testing.T) {
d := newWaveDriver(1_000_000)
d := newWaveDriver(1_000_000, 1)
d.run(512, 20)
// The emulation fast-forwards ten seconds ahead of the audio clock
+64
View File
@@ -0,0 +1,64 @@
package izapple2
/*
Audio interface between the sound generators and the frontends.
The built-in speaker and the cards that generate sound implement
AudioSource. The frontend attaches an AudioSink to each source and
renders the reported level changes, typically with the audio package
Mixer.
*/
// AudioSink receives the output level changes of a sound source with the
// CPU cycle when they happen. Implemented by the frontends.
type AudioSink interface {
PushLevel(cycle uint64, level float32)
}
// AudioSource is implemented by the cards that generate sound.
type AudioSource interface {
GetAudioSourceName() string
SetAudioSink(sink AudioSink)
}
// GetAudioSources returns the sound generators of the machine: the
// built-in speaker and the sound cards
func (a *Apple2) GetAudioSources() []AudioSource {
sources := []AudioSource{&a.io.speaker}
for _, card := range a.cards {
if source, ok := card.(AudioSource); ok {
sources = append(sources, source)
}
}
return sources
}
// Position of the speaker cone, giving half of the full scale peak to peak
const speakerLevel = 0.25
// speakerAudioSource is the AudioSource of the built-in speaker. Each
// access to $C030 toggles the position of the speaker cone.
type speakerAudioSource struct {
sink AudioSink
high bool // Position of the speaker cone
}
func (s *speakerAudioSource) GetAudioSourceName() string {
return "speaker"
}
func (s *speakerAudioSource) SetAudioSink(sink AudioSink) {
s.sink = sink
}
func (s *speakerAudioSource) click(cycle uint64) {
if s.sink == nil {
return
}
s.high = !s.high
if s.high {
s.sink.PushLevel(cycle, speakerLevel)
} else {
s.sink.PushLevel(cycle, -speakerLevel)
}
}
+1 -3
View File
@@ -123,9 +123,7 @@ func getSoftSwitch(io *ioC0Page, ioFlag uint8, isSet bool) softSwitchR {
func buildSpeakerSoftSwitch(io *ioC0Page) softSwitchR {
return func() uint8 {
if io.speaker != nil {
io.speaker.Click(io.apple2.GetCycles())
}
io.speaker.click(io.apple2.GetCycles())
return 0
}
}
+1
View File
@@ -51,6 +51,7 @@ func getCardFactory() map[string]*cardBuilder {
cardFactory["language"] = newCardLanguageBuilder()
cardFactory["softswitchlogger"] = newCardLoggerBuilder()
cardFactory["memexp"] = newCardMemoryExpansionBuilder()
cardFactory["mockingboard"] = newCardMockingboardBuilder()
cardFactory["mouse"] = newCardMouseBuilder()
cardFactory["multirom"] = newMultiRomCardBuilder()
cardFactory["parallel"] = newCardParallelPrinterBuilder()
+4
View File
@@ -80,6 +80,10 @@ func TestCardsDetected(t *testing.T) {
testCardDetectedInternal(t, "2enh", "4096", "ramworks", 55_000_000, "RAMWorks 4096K Card in Aux Slot")
})
t.Run("test Mockingboard card", func(t *testing.T) {
testCardDetectedInternal(t, "2enh", "mockingboard", "s4", 55_000_000, "4 MockingBoard Card")
})
// Swyftcard not compatible with Card Cat
// Unknonw cards: prodosromcard3
}
+185
View File
@@ -0,0 +1,185 @@
package izapple2
import (
"github.com/ivanizag/izapple2/component"
)
/*
Mockingboard sound card. Two MOS 6522 VIAs driving two AY-3-8913
programmable sound generators.
See:
https://en.wikipedia.org/wiki/Mockingboard
https://github.com/AppleWin/AppleWin/blob/master/source/Mockingboard.cpp
The card is addressed on its slot ROM page, there is no ROM:
$Cn00-$Cn0F: VIA 1 registers, mirrored up to $Cn7F
$Cn80-$Cn8F: VIA 2 registers, mirrored up to $CnFF
Each VIA drives one AY-3-8913:
Port A: data bus
Port B bit 0: BC1
Port B bit 1: BDIR
Port B bit 2: /RESET, active low
The IRQ outputs of both VIAs are wired to the 6502 IRQ line. The VIA
timers are caught up on every access and on every instruction, so the
timer reads used by the Mockingboard detection routines are cycle exact.
The mixed output level of both PSGs is reported to the frontend as an
AudioSource, stepping the chips every 8 CPU cycles.
*/
type CardMockingboard struct {
cardBase
via [2]component.MOS6522
psg [2]component.AY38913
lastBusOp [2]uint8
sink AudioSink
lastCycle uint64 // The chips are caught up to this cycle
psgCycle uint64 // Start of the next PSG synthesis step
lastLevel float32
}
const (
mockingboardBusRead uint8 = 1
mockingboardBusWrite uint8 = 2
mockingboardBusLatch uint8 = 3
mockingboardPsgStepCycles = 8
)
func newCardMockingboardBuilder() *cardBuilder {
return &cardBuilder{
name: "Mockingboard",
description: "Mockingboard sound card with two AY-3-8913 sound generators",
buildFunc: func(params map[string]string) (Card, error) {
var c CardMockingboard
return &c, nil
},
}
}
func (c *CardMockingboard) assign(a *Apple2, slot int) {
c.cardBase.assign(a, slot)
if slot != 0 {
a.mmu.setCardROM(slot, traceMemory(c, c.name, c.traceMemory))
}
a.registerTickerCard(c)
c.lastCycle = a.GetCycles()
c.psgCycle = c.lastCycle
c.psg[0].Reset()
c.psg[1].Reset()
}
func (c *CardMockingboard) reset() {
for i := range 2 {
c.via[i].Reset()
c.psg[i].Reset()
c.lastBusOp[i] = 0
}
if c.a != nil {
c.a.requestIRQ(c.slot, false)
}
}
// peek and poke serve the $Cn00-$CnFF page, implementing memoryHandler
func (c *CardMockingboard) peek(address uint16) uint8 {
c.catchUp()
n := int(address>>7) & 1
value := c.via[n].Read(uint8(address & 0x0f))
c.updateIRQ() // Some register reads clear interrupt flags
return value
}
func (c *CardMockingboard) poke(address uint16, value uint8) {
c.catchUp()
n := int(address>>7) & 1
reg := uint8(address & 0x0f)
c.via[n].Write(reg, value)
if reg == 0 || reg == 2 {
// ORB or DDRB writes can change the PSG bus control lines
c.updatePsgBus(n)
}
c.updateIRQ()
}
// updatePsgBus runs the AY-3-8913 bus protocol wired to the VIA port B
func (c *CardMockingboard) updatePsgBus(n int) {
portB := c.via[n].GetPortB()
if portB&0x04 == 0 {
// /RESET is active low
c.psg[n].Reset()
c.lastBusOp[n] = 0
return
}
// The operations execute when BDIR-BC1 change
busOp := portB & 0x03
if busOp != c.lastBusOp[n] {
switch busOp {
case mockingboardBusRead:
c.via[n].SetInputA(c.psg[n].ReadData())
case mockingboardBusWrite:
c.psg[n].WriteData(c.via[n].GetPortA())
case mockingboardBusLatch:
c.psg[n].LatchAddress(c.via[n].GetPortA())
}
c.lastBusOp[n] = busOp
}
}
// tick is called on every instruction to keep the timers, the interrupt
// line and the sound synthesis up to date
func (c *CardMockingboard) tick() {
c.catchUp()
}
func (c *CardMockingboard) catchUp() {
current := c.a.GetCycles()
if current <= c.lastCycle {
return
}
elapsed := current - c.lastCycle
c.lastCycle = current
c.via[0].Tick(elapsed)
c.via[1].Tick(elapsed)
c.updateIRQ()
c.synthesize(current)
}
func (c *CardMockingboard) updateIRQ() {
c.a.requestIRQ(c.slot, c.via[0].InterruptAsserted() || c.via[1].InterruptAsserted())
}
// synthesize advances both PSGs up to the given cycle in steps of 8 CPU
// cycles, reporting the changes of the mixed level to the audio sink
func (c *CardMockingboard) synthesize(toCycle uint64) {
if c.sink == nil {
c.psgCycle = toCycle
return
}
for ; c.psgCycle+mockingboardPsgStepCycles <= toCycle; c.psgCycle += mockingboardPsgStepCycles {
// Each PSG ranges from 0.0 to 3.0, scale the sum of both to a
// range comparable to the speaker
level := (c.psg[0].Step() + c.psg[1].Step()) * 0.25
if level != c.lastLevel {
c.sink.PushLevel(c.psgCycle, level)
c.lastLevel = level
}
}
}
// GetAudioSourceName implements the AudioSource interface
func (c *CardMockingboard) GetAudioSourceName() string {
return "mockingboard"
}
// SetAudioSink implements the AudioSource interface
func (c *CardMockingboard) SetAudioSink(sink AudioSink) {
c.sink = sink
}
+150
View File
@@ -0,0 +1,150 @@
package izapple2
import (
"testing"
)
func makeMockingboardTester(t *testing.T) (*Apple2, *CardMockingboard) {
overrides := newConfiguration()
overrides.set(confS4, "mockingboard")
at, err := makeApple2Tester("2plus", overrides)
if err != nil {
t.Fatal(err)
}
a := at.a
card, ok := a.cards[4].(*CardMockingboard)
if !ok {
t.Fatal("The Mockingboard should be in slot 4")
}
return a, card
}
func TestCardMockingboardTimerReadIsCycleExact(t *testing.T) {
a, _ := makeMockingboardTester(t)
// Start VIA 1 T1 with 0x2000 cycles
a.mmu.Poke(0xc404, 0x00)
a.mmu.Poke(0xc405, 0x20)
a.cycles += 100
counter := uint16(a.mmu.Peek(0xc404)) | uint16(a.mmu.Peek(0xc405))<<8
if counter != 0x2000-100 {
t.Errorf("The timer should decrement with the CPU cycles, got 0x%04x", counter)
}
}
func TestCardMockingboardTimerIRQ(t *testing.T) {
a, card := makeMockingboardTester(t)
// Program VIA 1 T1 free running with interrupts every 0x1000 cycles
a.mmu.Poke(0xc40b, 0x40) // ACR: T1 free running
a.mmu.Poke(0xc40e, 0xc0) // IER: enable T1
a.mmu.Poke(0xc404, 0x00)
a.mmu.Poke(0xc405, 0x10)
a.cycles += 0x800
card.tick()
if a.irqRequests != 0 {
t.Error("The IRQ should not be requested before the timer underflows")
}
a.cycles += 0x900
card.tick()
if a.irqRequests == 0 {
t.Error("The IRQ should be requested when the timer underflows")
}
// Reading T1C-L clears the interrupt
a.mmu.Peek(0xc404)
if a.irqRequests != 0 {
t.Error("The IRQ should be released after reading T1C-L")
}
}
func TestCardMockingboardSecondVIA(t *testing.T) {
a, card := makeMockingboardTester(t)
// The second VIA is at $Cn80, enable its T2
a.mmu.Poke(0xc48e, 0xa0) // IER: enable T2
a.mmu.Poke(0xc488, 0x50)
a.mmu.Poke(0xc489, 0x00)
a.cycles += 0x100
card.tick()
if a.irqRequests == 0 {
t.Error("The IRQ should be requested by the second VIA")
}
a.mmu.Poke(0xc48d, 0x20) // Clear the T2 flag on IFR
if a.irqRequests != 0 {
t.Error("The IRQ should be released after clearing IFR")
}
}
// writePsg drives the AY bus protocol like the Mockingboard drivers do
func writePsg(a *Apple2, base uint16, reg uint8, value uint8) {
a.mmu.Poke(base+1, reg) // ORA: register number
a.mmu.Poke(base, 4|3) // Latch address
a.mmu.Poke(base, 4) // Inactive
a.mmu.Poke(base+1, value)
a.mmu.Poke(base, 4|2) // Write data
a.mmu.Poke(base, 4) // Inactive
}
func TestCardMockingboardPsgBus(t *testing.T) {
a, _ := makeMockingboardTester(t)
// Set the VIA 1 ports as the drivers do
a.mmu.Poke(0xc403, 0xff) // DDRA: all output
a.mmu.Poke(0xc402, 0x07) // DDRB: control lines output
writePsg(a, 0xc400, 7, 0x3e) // Mixer: only tone A
// Read the register back through the bus
a.mmu.Poke(0xc401, 7)
a.mmu.Poke(0xc400, 4|3) // Latch address
a.mmu.Poke(0xc400, 4)
a.mmu.Poke(0xc403, 0x00) // DDRA: all input to read
a.mmu.Poke(0xc400, 4|1) // Read data
if v := a.mmu.Peek(0xc401); v != 0x3e {
t.Errorf("The PSG register should read back 0x3e, got 0x%02x", v)
}
}
type testAudioSink struct {
events int
}
func (s *testAudioSink) PushLevel(cycle uint64, level float32) {
s.events++
}
func TestCardMockingboardGeneratesSound(t *testing.T) {
a, card := makeMockingboardTester(t)
var card2 AudioSource
for _, source := range a.GetAudioSources() {
if source.GetAudioSourceName() == "mockingboard" {
card2 = source
}
}
if card2 == nil {
t.Fatal("The card should be published as an audio source")
}
var sink testAudioSink
card2.SetAudioSink(&sink)
a.mmu.Poke(0xc403, 0xff)
a.mmu.Poke(0xc402, 0x07)
writePsg(a, 0xc400, 0, 100) // Tone A period
writePsg(a, 0xc400, 7, 0x3e) // Mixer: only tone A
writePsg(a, 0xc400, 8, 15) // Full volume
// Run 10000 cycles, the tone toggles every 800 cycles
a.cycles += 10_000
card.tick()
if sink.events < 10 {
t.Errorf("The sink should receive the tone level changes, got %v", sink.events)
}
}
+214
View File
@@ -0,0 +1,214 @@
package component
/*
General Instrument AY-3-8913 Programmable Sound Generator
See:
https://map.grauw.nl/resources/sound/generalinstrument_ay-3-8910.pdf
https://en.wikipedia.org/wiki/General_Instrument_AY-3-8910
The 8913 is the variant of the AY-3-8910 without I/O ports. Two of them
are used by the Mockingboard card, driven by the 6522 VIAs.
Registers:
R0-R1: channel A tone period, 12 bits
R2-R3: channel B tone period, 12 bits
R4-R5: channel C tone period, 12 bits
R6: noise period, 5 bits
R7: mixer, bits 0-2 disable tone A-C, bits 3-5 disable noise A-C
R8-R10: channel A-C amplitude, bit 4 selects the envelope
R11-R12: envelope period, 16 bits
R13: envelope shape: CONTINUE, ATTACK, ALTERNATE, HOLD
The bus operations selected by the BDIR and BC1 pins are exposed as the
LatchAddress, WriteData and ReadData methods.
Synthesis is done in steps of 8 chip clocks with Step(), the granularity
of the tone generators. A tone with period TP completes a full cycle
every 2*TP steps (16*TP clocks as per the datasheet).
*/
type AY38913 struct {
regs [16]uint8
address uint8
toneCounter [3]uint16
toneOut [3]bool
noiseCounter uint16
noiseShift uint32 // 17 bit LFSR
envCounter uint32
envPosition uint8 // 0 to 15 within a ramp
envAttack bool // Direction of the current ramp
envHolding bool
envHeldLevel uint8
}
var ay38913RegisterMasks = [16]uint8{
0xff, 0x0f, 0xff, 0x0f, 0xff, 0x0f, 0x1f, 0xff,
0x1f, 0x1f, 0x1f, 0xff, 0xff, 0x0f, 0xff, 0xff,
}
/*
Volume of each of the 16 amplitude levels. The DAC is logarithmic with
steps of ~3dB per level. Normalized so that one channel at full volume
gives 1.0.
*/
var ay38913Volumes = [16]float32{
0.0000, 0.0106, 0.0150, 0.0222, 0.0320, 0.0466, 0.0665, 0.1039,
0.1237, 0.1986, 0.2803, 0.3548, 0.4702, 0.6030, 0.7530, 1.0000,
}
// LatchAddress selects the register for the next data operations
func (ay *AY38913) LatchAddress(value uint8) {
// The chip compares the high nibble with its hardwired mask
if value&0xf0 == 0 {
ay.address = value & 0x0f
}
}
// WriteData writes to the selected register
func (ay *AY38913) WriteData(value uint8) {
ay.regs[ay.address] = value & ay38913RegisterMasks[ay.address]
if ay.address == 13 {
// Writing the envelope shape restarts the envelope
ay.envCounter = 0
ay.envPosition = 0
ay.envAttack = value&0x04 != 0
ay.envHolding = false
}
}
// ReadData reads the selected register
func (ay *AY38913) ReadData() uint8 {
return ay.regs[ay.address]
}
// Reset clears the registers and the generators
func (ay *AY38913) Reset() {
for i := range ay.regs {
ay.regs[i] = 0
}
ay.address = 0
for i := range 3 {
ay.toneCounter[i] = 0
ay.toneOut[i] = false
}
ay.noiseCounter = 0
ay.noiseShift = 1
ay.envCounter = 0
ay.envPosition = 0
ay.envAttack = false
ay.envHolding = false
}
// Step advances the generators 8 chip clocks and returns the mixed level
// of the three channels, from 0.0 to 3.0
func (ay *AY38913) Step() float32 {
// Tone generators, a half period is TP steps
for i := range 3 {
period := uint16(ay.regs[2*i]) | uint16(ay.regs[2*i+1])<<8
if period == 0 {
period = 1
}
ay.toneCounter[i]++
if ay.toneCounter[i] >= period {
ay.toneCounter[i] = 0
ay.toneOut[i] = !ay.toneOut[i]
}
}
// Noise generator, the LFSR shifts every 2*NP steps
noisePeriod := 2 * uint16(ay.regs[6])
if noisePeriod == 0 {
noisePeriod = 2
}
if ay.noiseShift == 0 {
ay.noiseShift = 1 // Make the zero value usable
}
ay.noiseCounter++
if ay.noiseCounter >= noisePeriod {
ay.noiseCounter = 0
// 17 bit LFSR with taps on bits 0 and 3
bit := (ay.noiseShift ^ (ay.noiseShift >> 3)) & 1
ay.noiseShift = (ay.noiseShift >> 1) | (bit << 16)
}
noiseOut := ay.noiseShift&1 != 0
// Envelope generator, one of the 16 levels every 2*EP steps
if !ay.envHolding {
envPeriod := 2 * (uint32(ay.regs[11]) | uint32(ay.regs[12])<<8)
if envPeriod == 0 {
envPeriod = 2
}
ay.envCounter++
if ay.envCounter >= envPeriod {
ay.envCounter = 0
if ay.envPosition < 15 {
ay.envPosition++
} else {
ay.endOfEnvelopeRamp()
}
}
}
// Mix the channels
level := float32(0)
mixer := ay.regs[7]
for i := range 3 {
toneEnabled := mixer&(1<<i) == 0
noiseEnabled := mixer&(1<<(3+i)) == 0
// The channel is high unless an enabled generator drives it low
high := (ay.toneOut[i] || !toneEnabled) && (noiseOut || !noiseEnabled)
if high {
amplitude := ay.regs[8+i]
var volume uint8
if amplitude&0x10 != 0 {
volume = ay.envelopeLevel()
} else {
volume = amplitude & 0x0f
}
level += ay38913Volumes[volume]
}
}
return level
}
func (ay *AY38913) envelopeLevel() uint8 {
if ay.envHolding {
return ay.envHeldLevel
}
if ay.envAttack {
return ay.envPosition
}
return 15 - ay.envPosition
}
// endOfEnvelopeRamp applies the shape bits when a 16 level ramp completes
func (ay *AY38913) endOfEnvelopeRamp() {
shape := ay.regs[13]
continues := shape&0x08 != 0
attack := shape&0x04 != 0
alternate := shape&0x02 != 0
hold := shape&0x01 != 0
if !continues {
ay.envHolding = true
ay.envHeldLevel = 0
} else if hold {
ay.envHolding = true
if attack != alternate {
ay.envHeldLevel = 15
} else {
ay.envHeldLevel = 0
}
} else {
// Restart the ramp, reversing the direction when alternating
ay.envPosition = 0
if alternate {
ay.envAttack = !ay.envAttack
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package component
import (
"testing"
)
func ay38913SetRegister(ay *AY38913, reg uint8, value uint8) {
ay.LatchAddress(reg)
ay.WriteData(value)
}
func TestAY38913RegisterMasks(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 1, 0xff)
if v := ay.ReadData(); v != 0x0f {
t.Errorf("The coarse tone period should be masked to 4 bits, got 0x%02x", v)
}
ay38913SetRegister(&ay, 6, 0xff)
if v := ay.ReadData(); v != 0x1f {
t.Errorf("The noise period should be masked to 5 bits, got 0x%02x", v)
}
ay38913SetRegister(&ay, 8, 0xff)
if v := ay.ReadData(); v != 0x1f {
t.Errorf("The amplitude should be masked to 5 bits, got 0x%02x", v)
}
}
func TestAY38913AddressLatch(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 2, 0x12)
ay.LatchAddress(0x1f) // Invalid, high nibble not zero
ay.LatchAddress(0x02)
if v := ay.ReadData(); v != 0x12 {
t.Errorf("The invalid address should be ignored, got 0x%02x", v)
}
}
func TestAY38913Tone(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 0, 5) // Channel A tone period 5
ay38913SetRegister(&ay, 7, 0x3e) // Only tone A enabled
ay38913SetRegister(&ay, 8, 15) // Channel A at full fixed volume
// The output must toggle between 0 and 1 every 5 steps
last := ay.Step()
changes := []int{}
for i := range 50 {
level := ay.Step()
if level != last {
if level != 0.0 && level != 1.0 {
t.Fatalf("The level should toggle between 0 and 1, got %v", level)
}
changes = append(changes, i)
last = level
}
}
if len(changes) < 8 {
t.Fatalf("The tone should toggle, got %v changes", len(changes))
}
for i := 1; i < len(changes); i++ {
if changes[i]-changes[i-1] != 5 {
t.Errorf("The half period should be 5 steps, got %v", changes[i]-changes[i-1])
}
}
}
func TestAY38913ConstantLevel(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 7, 0x3f) // Everything disabled
ay38913SetRegister(&ay, 8, 15) // Channel A at full fixed volume
// With tone and noise disabled the channel stays high, this is how
// the volume register is used to play digitized samples
for range 20 {
if level := ay.Step(); level != 1.0 {
t.Fatalf("The channel should stay at full level, got %v", level)
}
}
}
func TestAY38913EnvelopeDecay(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 7, 0x3f) // Everything disabled, channels high
ay38913SetRegister(&ay, 8, 0x10) // Channel A on envelope
ay38913SetRegister(&ay, 11, 1) // Envelope period 1
ay38913SetRegister(&ay, 13, 0x00) // Decay then off
first := ay.Step()
if first != 1.0 {
t.Errorf("The envelope should start at full level, got %v", first)
}
last := first
for range 100 {
level := ay.Step()
if level > last {
t.Fatalf("The envelope should never raise on decay, got %v after %v", level, last)
}
last = level
}
if last != 0.0 {
t.Errorf("The envelope should end at zero, got %v", last)
}
}
func TestAY38913EnvelopeAttackHold(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 7, 0x3f)
ay38913SetRegister(&ay, 8, 0x10)
ay38913SetRegister(&ay, 11, 1)
ay38913SetRegister(&ay, 13, 0x0d) // Attack then hold at full level
var last float32
for range 100 {
last = ay.Step()
}
if last != 1.0 {
t.Errorf("The envelope should hold at full level, got %v", last)
}
}
func TestAY38913Noise(t *testing.T) {
var ay AY38913
ay38913SetRegister(&ay, 6, 1) // Fastest noise
ay38913SetRegister(&ay, 7, 0x37) // Only noise A enabled
ay38913SetRegister(&ay, 8, 15)
changes := 0
last := ay.Step()
for range 1000 {
level := ay.Step()
if level != last {
changes++
last = level
}
}
if changes < 100 {
t.Errorf("The noise should toggle the output often, got %v changes", changes)
}
}
+202
View File
@@ -0,0 +1,202 @@
package component
/*
MOS 6522 Versatile Interface Adapter (VIA)
See:
http://archive.6502.org/datasheets/mos_6522_preliminary_nov_1977.pdf
http://archive.6502.org/datasheets/rockwell_r6522_via.pdf
Used by the Mockingboard card to drive the AY-3-8913 sound generators
and to raise interrupts with the timers.
Implemented: ports A and B, timers T1 and T2, the IFR/IER interrupt logic.
Not implemented: the shift register, the CA/CB control lines, handshaking
and the T2 pulse counting mode.
Registers:
0: ORB/IRB 1: ORA/IRA 2: DDRB 3: DDRA
4: T1C-L 5: T1C-H 6: T1L-L 7: T1L-H
8: T2C-L 9: T2C-H 10: SR 11: ACR
12: PCR 13: IFR 14: IER 15: ORA no handshake
The timers advance with Tick(elapsedCycles). Catching up with several
cycles at a time is supported, including multiple T1 underflows in free
running mode.
*/
type MOS6522 struct {
ora, orb uint8 // Output registers
ira, irb uint8 // Input registers, the values on the input pins
ddra, ddrb uint8 // Data direction registers, 1 is output
t1counter int64 // Signed to detect underflows when catching up
t1latch uint16
t1fired bool
t2counter int64
t2latchL uint8
t2fired bool
sr, acr, pcr uint8
ifr, ier uint8
}
const (
mos6522IntT1 uint8 = 1 << 6
mos6522IntT2 uint8 = 1 << 5
mos6522AcrT1FreeRunning uint8 = 1 << 6
)
// Read returns the value of a register
func (v *MOS6522) Read(reg uint8) uint8 {
switch reg & 0x0f {
case 0:
return (v.irb &^ v.ddrb) | (v.orb & v.ddrb)
case 1, 15:
return (v.ira &^ v.ddra) | (v.ora & v.ddra)
case 2:
return v.ddrb
case 3:
return v.ddra
case 4:
v.ifr &^= mos6522IntT1
return uint8(uint16(v.t1counter))
case 5:
return uint8(uint16(v.t1counter) >> 8)
case 6:
return uint8(v.t1latch)
case 7:
return uint8(v.t1latch >> 8)
case 8:
v.ifr &^= mos6522IntT2
return uint8(uint16(v.t2counter))
case 9:
return uint8(uint16(v.t2counter) >> 8)
case 10:
return v.sr
case 11:
return v.acr
case 12:
return v.pcr
case 13:
ifr := v.ifr & 0x7f
if ifr&v.ier != 0 {
ifr |= 0x80
}
return ifr
case 14:
return v.ier | 0x80
}
return 0
}
// Write sets the value of a register
func (v *MOS6522) Write(reg uint8, value uint8) {
switch reg & 0x0f {
case 0:
v.orb = value
case 1, 15:
v.ora = value
case 2:
v.ddrb = value
case 3:
v.ddra = value
case 4, 6:
v.t1latch = (v.t1latch & 0xff00) | uint16(value)
case 5:
// Load the counter from the latch and start counting
v.t1latch = (v.t1latch & 0x00ff) | uint16(value)<<8
v.t1counter = int64(v.t1latch)
v.t1fired = false
v.ifr &^= mos6522IntT1
case 7:
v.t1latch = (v.t1latch & 0x00ff) | uint16(value)<<8
v.ifr &^= mos6522IntT1
case 8:
v.t2latchL = value
case 9:
v.t2counter = int64(value)<<8 | int64(v.t2latchL)
v.t2fired = false
v.ifr &^= mos6522IntT2
case 10:
v.sr = value
case 11:
v.acr = value
case 12:
v.pcr = value
case 13:
// Writing ones clears the flags
v.ifr &^= value & 0x7f
case 14:
if value&0x80 != 0 {
v.ier |= value & 0x7f
} else {
v.ier &^= value & 0x7f
}
}
}
// Tick advances the timers by the elapsed CPU cycles
func (v *MOS6522) Tick(elapsedCycles uint64) {
v.t1counter -= int64(elapsedCycles)
if v.t1counter < 0 {
if v.acr&mos6522AcrT1FreeRunning != 0 {
// Reload from the latch and interrupt on each underflow
period := int64(v.t1latch) + 2
v.t1counter += period * (1 + (-v.t1counter-1)/period)
v.ifr |= mos6522IntT1
} else {
// One shot: interrupt once, the counter rolls over
if !v.t1fired {
v.ifr |= mos6522IntT1
v.t1fired = true
}
v.t1counter = int64(uint16(v.t1counter))
}
}
v.t2counter -= int64(elapsedCycles)
if v.t2counter < 0 {
// One shot: interrupt once, the counter rolls over
if !v.t2fired {
v.ifr |= mos6522IntT2
v.t2fired = true
}
v.t2counter = int64(uint16(v.t2counter))
}
}
// InterruptAsserted returns the state of the IRQ output line
func (v *MOS6522) InterruptAsserted() bool {
return v.ifr&v.ier&0x7f != 0
}
// Reset clears the registers as the RES pin. The timers and the shift
// register are not affected
func (v *MOS6522) Reset() {
v.ora, v.orb = 0, 0
v.ddra, v.ddrb = 0, 0
v.acr, v.pcr = 0, 0
v.ifr, v.ier = 0, 0
}
// GetPortA returns the values on the port A pins
func (v *MOS6522) GetPortA() uint8 {
return (v.ora & v.ddra) | (v.ira &^ v.ddra)
}
// GetPortB returns the values on the port B pins
func (v *MOS6522) GetPortB() uint8 {
return (v.orb & v.ddrb) | (v.irb &^ v.ddrb)
}
// SetInputA sets the values on the port A input pins
func (v *MOS6522) SetInputA(value uint8) {
v.ira = value
}
// SetInputB sets the values on the port B input pins
func (v *MOS6522) SetInputB(value uint8) {
v.irb = value
}
+167
View File
@@ -0,0 +1,167 @@
package component
import (
"testing"
)
func TestMOS6522Timer1OneShot(t *testing.T) {
var v MOS6522
v.Write(14, 0x80|0x40) // Enable the T1 interrupt
v.Write(4, 100) // T1 latch low
v.Write(5, 0) // T1 counter high, starts the timer
v.Tick(100)
if v.InterruptAsserted() {
t.Error("The interrupt should not fire before the timer underflows")
}
v.Tick(2)
if !v.InterruptAsserted() {
t.Error("The interrupt should fire when the timer underflows")
}
// In one shot mode it must fire only once
v.Write(13, 0x40) // Clear the T1 flag
v.Tick(1000)
if v.InterruptAsserted() {
t.Error("The one shot timer should not fire again")
}
}
func TestMOS6522Timer1FreeRunning(t *testing.T) {
var v MOS6522
v.Write(11, 0x40) // ACR: T1 free running
v.Write(14, 0x80|0x40) // Enable the T1 interrupt
v.Write(4, 100)
v.Write(5, 0)
v.Tick(102)
if !v.InterruptAsserted() {
t.Error("The interrupt should fire on the first underflow")
}
v.Write(13, 0x40) // Clear the T1 flag
v.Tick(102) // A full period without reprogramming the timer
if !v.InterruptAsserted() {
t.Error("The interrupt should fire again in free running mode")
}
// Catching up with many periods at once must not hang and must
// leave the counter in range
v.Write(13, 0x40)
v.Tick(1_000_000)
if !v.InterruptAsserted() {
t.Error("The interrupt should fire after catching up many periods")
}
counter := uint16(v.Read(4)) | uint16(v.Read(5))<<8
if counter > 101 {
t.Errorf("The counter should stay in the period range, got %v", counter)
}
}
func TestMOS6522Timer1ReadClearsFlag(t *testing.T) {
var v MOS6522
v.Write(14, 0x80|0x40)
v.Write(4, 10)
v.Write(5, 0)
v.Tick(50)
if !v.InterruptAsserted() {
t.Error("The interrupt should be asserted")
}
v.Read(4) // Reading T1C-L clears the flag
if v.InterruptAsserted() {
t.Error("Reading T1C-L should clear the T1 flag")
}
}
func TestMOS6522Timer2(t *testing.T) {
var v MOS6522
v.Write(14, 0x80|0x20) // Enable the T2 interrupt
v.Write(8, 50) // T2 latch low
v.Write(9, 0) // T2 counter high, starts the timer
v.Tick(50)
if v.InterruptAsserted() {
t.Error("The interrupt should not fire before the timer underflows")
}
v.Tick(2)
if !v.InterruptAsserted() {
t.Error("The interrupt should fire when the timer underflows")
}
v.Write(13, 0x20)
v.Tick(1000)
if v.InterruptAsserted() {
t.Error("T2 should not fire again")
}
}
func TestMOS6522InterruptEnable(t *testing.T) {
var v MOS6522
v.Write(4, 10)
v.Write(5, 0)
v.Tick(50) // T1 underflows but the interrupt is not enabled
if v.InterruptAsserted() {
t.Error("The interrupt should be masked by IER")
}
if v.Read(13)&0x40 == 0 {
t.Error("The T1 flag should be set on IFR even when masked")
}
if v.Read(13)&0x80 != 0 {
t.Error("The IFR bit 7 should be clear when masked")
}
v.Write(14, 0x80|0x40) // Enable the T1 interrupt afterwards
if !v.InterruptAsserted() {
t.Error("The interrupt should be asserted once enabled")
}
if v.Read(13)&0x80 == 0 {
t.Error("The IFR bit 7 should be set when interrupting")
}
v.Write(14, 0x40) // Disable the T1 interrupt
if v.InterruptAsserted() {
t.Error("The interrupt should be masked again")
}
if v.Read(14) != 0x80 {
t.Errorf("IER should read as 0x80, got 0x%02x", v.Read(14))
}
}
func TestMOS6522Ports(t *testing.T) {
var v MOS6522
v.Write(3, 0x0f) // DDRA: half output, half input
v.Write(1, 0xa5) // ORA
v.SetInputA(0x5a)
if port := v.GetPortA(); port != 0x55 {
t.Errorf("Port A should mix output and input bits, got 0x%02x", port)
}
if value := v.Read(1); value != 0x55 {
t.Errorf("Reading port A should mix output and input bits, got 0x%02x", value)
}
v.Write(2, 0xff) // DDRB: all output
v.Write(0, 0x07)
if port := v.GetPortB(); port != 0x07 {
t.Errorf("Port B should return the output register, got 0x%02x", port)
}
}
func TestMOS6522Reset(t *testing.T) {
var v MOS6522
v.Write(14, 0x80|0x40)
v.Write(4, 10)
v.Write(5, 0)
v.Tick(50)
v.Reset()
if v.InterruptAsserted() {
t.Error("Reset should clear the interrupt state")
}
if v.Read(2) != 0 || v.Read(3) != 0 {
t.Error("Reset should clear the data direction registers")
}
}
+1
View File
@@ -9,4 +9,5 @@ nsc: main
s0: language
s2: vidhd
s3: fastchip
s4: mockingboard
s6: diskii,disk1=<internal>/dos33.dsk
+2 -1
View File
@@ -240,7 +240,7 @@ Usage: izapple2 [file]
-s3 string
slot 3 configuration. (default "fastchip")
-s4 string
slot 4 configuration. (default "empty")
slot 4 configuration. (default "mockingboard")
-s5 string
slot 5 configuration. (default "empty")
-s6 string
@@ -282,6 +282,7 @@ The available cards are:
fujinet: SmartPort interface card hosting the Fujinet
language: Language card with 16 extra KB for the Apple ][ and ][+
memexp: Memory expansion card
mockingboard: Mockingboard sound card with two AY-3-8913 sound generators
mouse: Mouse card implementation, does not emulate a real card, only the firmware behaviour
multirom: Multiple Image ROM card
parallel: Card to dump to a file what would be printed to a parallel printer
+2 -1
View File
@@ -32,7 +32,7 @@ Usage: izapple2 [file]
-s3 string
slot 3 configuration. (default "fastchip")
-s4 string
slot 4 configuration. (default "empty")
slot 4 configuration. (default "mockingboard")
-s5 string
slot 5 configuration. (default "empty")
-s6 string
@@ -74,6 +74,7 @@ The available cards are:
fujinet: SmartPort interface card hosting the Fujinet
language: Language card with 16 extra KB for the Apple ][ and ][+
memexp: Memory expansion card
mockingboard: Mockingboard sound card with two AY-3-8913 sound generators
mouse: Mouse card implementation, does not emulate a real card, only the firmware behaviour
multirom: Multiple Image ROM card
parallel: Card to dump to a file what would be printed to a parallel printer
@@ -9,27 +9,24 @@ import (
a2audio "github.com/ivanizag/izapple2/audio"
)
// ebitenSpeaker sends the audio from the shared synthesizer to the ebiten
// audio player. It implements izapple2.SpeakerProvider.
type ebitenSpeaker struct {
speaker *a2audio.Speaker
// ebitenAudio sends the mixed audio of the machine to the ebiten audio
// player. The audio sources of the machine attach to the mixer.
type ebitenAudio struct {
mixer *a2audio.Mixer
audioContext *audio.Context
audioPlayer *audio.Player
samples []float32
}
func newEbitenSpeaker(clockMhz float64) *ebitenSpeaker {
return &ebitenSpeaker{speaker: a2audio.NewSpeaker(clockMhz)}
}
// Click receives a speaker click. The argument is the CPU cycle when it is generated
func (s *ebitenSpeaker) Click(cycle uint64) {
s.speaker.Click(cycle)
func newEbitenAudio(clockMhz float64) *ebitenAudio {
return &ebitenAudio{
mixer: a2audio.NewMixer(clockMhz),
}
}
// Read is io.Reader's Read, it fills the buffer with audio samples
func (s *ebitenSpeaker) Read(buf []byte) (n int, err error) {
func (s *ebitenAudio) Read(buf []byte) (n int, err error) {
const bytesPerSample = 8 // Two float32, one for each channel
samples := len(buf) / bytesPerSample
@@ -37,7 +34,7 @@ func (s *ebitenSpeaker) Read(buf []byte) (n int, err error) {
s.samples = make([]float32, samples)
}
s.samples = s.samples[:samples]
s.speaker.ReadSamples(s.samples)
s.mixer.ReadSamples(s.samples)
for i, v := range s.samples {
putFloat32InBuffer(buf, i, v)
@@ -57,7 +54,7 @@ func putFloat32InBuffer(buf []byte, i int, f float32) {
buf[i*8+7] = byte(v >> 24)
}
func (s *ebitenSpeaker) update() error {
func (s *ebitenAudio) update() error {
if s.audioContext == nil {
s.audioContext = audio.NewContext(a2audio.SampleRate)
}
+5 -3
View File
@@ -19,7 +19,7 @@ type Game struct {
a *izapple2.Apple2
image *ebiten.Image
keyboard *ebitenKeyboard
speaker *ebitenSpeaker
speaker *ebitenAudio
fontSource *text.GoTextFaceSource
paused bool
@@ -132,9 +132,11 @@ func ebitenRun(a *izapple2.Apple2) {
game := &Game{
a: a,
keyboard: newEbitenKeyBoard(a),
speaker: newEbitenSpeaker(a.GetClockMhz()),
speaker: newEbitenAudio(a.GetClockMhz()),
}
for _, source := range a.GetAudioSources() {
source.SetAudioSink(game.speaker.mixer.NewSource())
}
a.SetSpeakerProvider(game.speaker)
var err error
game.fontSource, err = text.NewGoTextFaceSource(bytes.NewReader(fonts.MPlus1pRegular_ttf))
+4 -2
View File
@@ -49,9 +49,11 @@ func sdlRun(a *izapple2.Apple2) {
kp := newSDLKeyBoard(a)
s := newSDLSpeaker(a.GetClockMhz())
s := newSDLAudio(a.GetClockMhz())
for _, source := range a.GetAudioSources() {
source.SetAudioSink(s.mixer.NewSource())
}
s.start()
a.SetSpeakerProvider(s)
j := newSDLJoysticks(!a.UsesMouse())
a.SetJoysticksProvider(j)
@@ -17,26 +17,23 @@ import (
// Samples per SDL audio buffer, ~21 ms
const bufferSamples = 1024
// sdlSpeaker sends the audio from the shared synthesizer to the SDL audio
// device. It implements izapple2.SpeakerProvider.
type sdlSpeaker struct {
speaker *audio.Speaker
// sdlAudio sends the mixed audio of the machine to the SDL audio device.
// The audio sources of the machine attach to the mixer.
type sdlAudio struct {
mixer *audio.Mixer
}
/*
I have not found a way to encode the pointer to sdlSpeaker on the userdata of
I have not found a way to encode the pointer to sdlAudio on the userdata of
the call to SpeakerCallback(). I use a global as workaround... It is atomic
because the callback runs on the SDL audio thread.
*/
var theSDLSpeaker atomic.Pointer[sdlSpeaker]
var theSDLAudio atomic.Pointer[sdlAudio]
func newSDLSpeaker(clockMhz float64) *sdlSpeaker {
return &sdlSpeaker{speaker: audio.NewSpeaker(clockMhz)}
}
// Click receives a speaker click. The argument is the CPU cycle when it is generated
func (s *sdlSpeaker) Click(cycle uint64) {
s.speaker.Click(cycle)
func newSDLAudio(clockMhz float64) *sdlAudio {
return &sdlAudio{
mixer: audio.NewMixer(clockMhz),
}
}
// SpeakerCallback is called to get more sound buffer data
@@ -46,7 +43,7 @@ func SpeakerCallback(userdata unsafe.Pointer, stream *C.Uint8, length C.int) {
// Adapt the C buffer to a slice of float32 samples
buf := unsafe.Slice((*float32)(unsafe.Pointer(stream)), int(length)/4)
s := theSDLSpeaker.Load()
s := theSDLAudio.Load()
if s == nil {
// SDL does not guarantee the buffer to be initialized
for i := range buf {
@@ -55,10 +52,10 @@ func SpeakerCallback(userdata unsafe.Pointer, stream *C.Uint8, length C.int) {
return
}
s.speaker.ReadSamples(buf)
s.mixer.ReadSamples(buf)
}
func (s *sdlSpeaker) start() {
func (s *sdlAudio) start() {
err := sdl.Init(sdl.INIT_AUDIO)
if err != nil {
fmt.Printf("Error starting SDL audio: %v.\n", err)
@@ -77,11 +74,11 @@ func (s *sdlSpeaker) start() {
fmt.Printf("Error opening the SDL audio channel: %v.\n", err)
return
}
theSDLSpeaker.Store(s)
theSDLAudio.Store(s)
sdl.PauseAudio(false)
}
func (s *sdlSpeaker) close() {
func (s *sdlAudio) close() {
sdl.CloseAudio()
sdl.Quit()
}
+5 -3
View File
@@ -16,7 +16,7 @@ type Game struct {
a *izapple2.Apple2
image *ebiten.Image
keyboard *wasmKeyboard
speaker *wasmSpeaker
speaker *wasmAudio
updates uint64
screenMode int
@@ -107,13 +107,15 @@ func ebitenRun(a *izapple2.Apple2) {
game := &Game{
a: a,
speaker: newWasmSpeaker(a.GetClockMhz()),
speaker: newWasmAudio(a.GetClockMhz()),
keyboard: newWasmKeyBoard(a),
screenMode: a_screen.ScreenModeNTSC,
}
// Set up providers
a.SetSpeakerProvider(game.speaker)
for _, source := range a.GetAudioSources() {
source.SetAudioSink(game.speaker.mixer.NewSource())
}
// Setup API exports for React
setupAPI(a, game)
@@ -11,27 +11,24 @@ import (
a2audio "github.com/ivanizag/izapple2/audio"
)
// wasmSpeaker sends the audio from the shared synthesizer to the ebiten
// audio player. It implements izapple2.SpeakerProvider.
type wasmSpeaker struct {
speaker *a2audio.Speaker
// wasmAudio sends the mixed audio of the machine to the ebiten audio
// player. The audio sources of the machine attach to the mixer.
type wasmAudio struct {
mixer *a2audio.Mixer
audioContext *audio.Context
audioPlayer *audio.Player
samples []float32
}
func newWasmSpeaker(clockMhz float64) *wasmSpeaker {
return &wasmSpeaker{speaker: a2audio.NewSpeaker(clockMhz)}
}
// Click receives a speaker click. The argument is the CPU cycle when it is generated
func (s *wasmSpeaker) Click(cycle uint64) {
s.speaker.Click(cycle)
func newWasmAudio(clockMhz float64) *wasmAudio {
return &wasmAudio{
mixer: a2audio.NewMixer(clockMhz),
}
}
// Read is io.Reader's Read, it fills the buffer with audio samples
func (s *wasmSpeaker) Read(buf []byte) (n int, err error) {
func (s *wasmAudio) Read(buf []byte) (n int, err error) {
const bytesPerSample = 8 // Two float32, one for each channel
samples := len(buf) / bytesPerSample
@@ -39,7 +36,7 @@ func (s *wasmSpeaker) Read(buf []byte) (n int, err error) {
s.samples = make([]float32, samples)
}
s.samples = s.samples[:samples]
s.speaker.ReadSamples(s.samples)
s.mixer.ReadSamples(s.samples)
for i, v := range s.samples {
putFloat32InBuffer(buf, i, v)
@@ -59,7 +56,7 @@ func putFloat32InBuffer(buf []byte, i int, f float32) {
buf[i*8+7] = byte(v >> 24)
}
func (s *wasmSpeaker) update() error {
func (s *wasmAudio) update() error {
if s.audioContext == nil {
s.audioContext = audio.NewContext(a2audio.SampleRate)
}
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.26.0
require (
fyne.io/fyne/v2 v2.6.1
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728
github.com/ivanizag/iz6502 v1.4.0
github.com/ivanizag/iz6502 v1.5.0
github.com/koron-go/z80 v0.10.1
github.com/pkg/profile v1.7.0
github.com/veandco/go-sdl2 v0.4.40
+2 -2
View File
@@ -72,8 +72,8 @@ github.com/hajimehoshi/ebiten/v2 v2.8.8 h1:xyMxOAn52T1tQ+j3vdieZ7auDBOXmvjUprSrx
github.com/hajimehoshi/ebiten/v2 v2.8.8/go.mod h1:durJ05+OYnio9b8q0sEtOgaNeBEQG7Yr7lRviAciYbs=
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/ivanizag/iz6502 v1.4.0 h1:7eYygUkCPwFRH0tf2JSg1k+Sy27wwPi3ActuVNVv1Uc=
github.com/ivanizag/iz6502 v1.4.0/go.mod h1:h4gbw3IK6WCYawi00kBhQ4ACeQkGWgqbUeAgDaQpy6s=
github.com/ivanizag/iz6502 v1.5.0 h1:hWNfjuZHbSjBZr1lzlFNgF6O1gCI/1NIHaQwa+W2+cQ=
github.com/ivanizag/iz6502 v1.5.0/go.mod h1:h4gbw3IK6WCYawi00kBhQ4ACeQkGWgqbUeAgDaQpy6s=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
+1 -11
View File
@@ -11,7 +11,7 @@ type ioC0Page struct {
softSwitchesWName [256]string
softSwitchesData [128]uint8
keyboard KeyboardProvider
speaker SpeakerProvider
speaker speakerAudioSource
paddlesStrobeCycle uint64
joysticks JoysticksProvider
mouse MouseProvider
@@ -24,12 +24,6 @@ type ioC0Page struct {
type softSwitchR func() uint8
type softSwitchW func(value uint8)
// SpeakerProvider provides a speaker implementation
type SpeakerProvider interface {
// Click receives a speaker click. The argument is the CPU cycle when it is generated
Click(cycle uint64)
}
// JoysticksProvider abstracts the joysticks
type JoysticksProvider interface {
ReadButton(i int) bool
@@ -114,10 +108,6 @@ func (p *ioC0Page) setKeyboardProvider(kb KeyboardProvider) {
p.keyboard = kb
}
func (p *ioC0Page) setSpeakerProvider(s SpeakerProvider) {
p.speaker = s
}
func (p *ioC0Page) setJoysticksProvider(j JoysticksProvider) {
p.joysticks = j
}