From 1bcd6a377c7b1166cbe324ced244ab0988b36067 Mon Sep 17 00:00:00 2001 From: jenska Date: Wed, 9 Sep 2026 07:53:10 +0200 Subject: [PATCH] Adopt m68kemu v1.5.0 IRQ source and simplify device/config internals Finishes the v1.5.0 migration and the cleanup it opens up. m68kemu v1.5.0 adoption: - Build the CPU with WithDeferredReset and run Machine.Reset once during construction, making it the single reset path. This also fixes a latent bug: the ROM fast-memory window was only installed from Machine.Reset, which the desktop and headless runners never call, so the shipped binary had been running entirely on the bus path. - Route device interrupts through a single level-sensitive IRQSource (machineIRQ over GLUE/MFP/FDC) that the core samples each instruction, deleting dispatchInterrupts, maskedAutovectorPulse, the irqSources slice and the InterruptSource interface. GLUE now drives a held HBL/VBL autovector line instead of queuing pulses; a blank interrupt the CPU is masking is taken shortly after the mask clears rather than dropped. - EnableTrace disassembly comes from TraceInfo.Mnemonic; drop the direct m68kdasm dependency (m68kemu still pulls it in). Internal cleanup, no behaviour change: - PAL/NTSC raster constants and the FrameHz>=55 rule moved to config.Config.Video(); GLUE and MFP timing setup collapse to a few lines. - GLUE dropped its unused system-control register; $FF8006 is now a shared devices.ScratchRegion and GLUE is no longer bus-mapped. - DrainInterrupts removed from the devices in favour of PendingIRQ/AckIRQ plus a drainIRQ test helper. - The JSON config loader is table-driven (config.Config.jsonFields). - Removed the dead Machine.cartridge field. The CycleScheduler adoption (proposal 3.2) was re-attempted against v1.5.0 and reverted again: correct, but a 120-frame EmuTOS boot regressed ~20% because the devices advance per-quantum, not event-driven. Recorded in docs/m68kemu-api-proposal.md. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 23 ++++ docs/m68kemu-api-proposal.md | 63 ++++++---- go.mod | 2 +- internal/config/config.go | 155 ++++++++++++------------ internal/devices/acia.go | 4 - internal/devices/acia_test.go | 16 +-- internal/devices/device.go | 17 +-- internal/devices/fdc.go | 14 ++- internal/devices/fdc_test.go | 4 +- internal/devices/glue.go | 112 ++++++----------- internal/devices/glue_test.go | 78 +++++------- internal/devices/irq_test.go | 20 +++ internal/devices/mfp.go | 67 +++++----- internal/devices/mfp_test.go | 49 ++++---- internal/devices/scratch_region.go | 57 +++++++++ internal/devices/scratch_region_test.go | 43 +++++++ internal/emulator/debug_emutos_test.go | 9 +- internal/emulator/debug_panic_test.go | 20 +-- internal/emulator/machine_builder.go | 16 ++- internal/emulator/machine_io.go | 2 - internal/emulator/machine_runtime.go | 63 ++++++---- internal/emulator/machine_test.go | 113 +++++++++-------- internal/emulator/machine_trace.go | 10 +- 23 files changed, 528 insertions(+), 429 deletions(-) create mode 100644 internal/devices/irq_test.go create mode 100644 internal/devices/scratch_region.go create mode 100644 internal/devices/scratch_region_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd9c7a..8f62cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,27 @@ - The single-range peripherals (ACIA, GLUE, MFP, PSG, FDC, Blitter, STE sound, cartridge ROM) expose `AddressRange()` and drop their `Contains` boilerplate; the bus page-maps them instead of scanning. +- Machine construction now builds the CPU with `m68kemu` `WithDeferredReset` and + runs `Machine.Reset` once before returning, making it the single reset path. +- `EnableTrace` disassembly comes from `m68kemu`'s `TraceInfo.Mnemonic` instead + of a direct `m68kdasm` decode; output is unchanged. +- Device interrupts now reach the CPU as a single level-sensitive `m68kemu` + `IRQSource` (`machineIRQ` over GLUE/MFP/FDC) that the core samples each + instruction, replacing the machine's hand-rolled `dispatchInterrupts` drain + loop and `maskedAutovectorPulse` SR inspection. GLUE now drives a held HBL/VBL + autovector line instead of queuing pulses, so a blank interrupt the CPU is + masking is taken shortly after the mask clears rather than dropped outright. +- Internal cleanup, no behaviour change: PAL/NTSC raster constants moved to + `config.Config.Video()`; GLUE dropped its unused system-control register (now + a shared `devices.ScratchRegion` at `$FF8006`) and is no longer bus-mapped; + `DrainInterrupts` removed in favour of `PendingIRQ`/`AckIRQ`; the JSON config + loader is table-driven; the unused `Machine.cartridge` field is gone. + +### Fixed + +- The boot-ROM fast-memory window is now installed on the normal construction + path. It was only wired into `Machine.Reset`, which the desktop and headless + runners never call, so the shipped binary never actually used it. ### Dependencies @@ -24,6 +45,8 @@ `SetHooks`/`Hooks`, and `SetFastMemory` APIs, a polished public surface (plain-value `RequestInterrupt`, `*Bus` on `NewCPU`, fewer exported internals), and optional `Device.Contains`. +- `github.com/jenska/m68kdasm` is no longer a direct dependency; `m68kemu` + still pulls it in for disassembly. ## v0.4.0 - 2026-09-04 diff --git a/docs/m68kemu-api-proposal.md b/docs/m68kemu-api-proposal.md index bd86937..7d5c63b 100644 --- a/docs/m68kemu-api-proposal.md +++ b/docs/m68kemu-api-proposal.md @@ -9,9 +9,12 @@ Scope: additive where possible; one semantic change (item 2) behind an opt-in Landed in `m68kemu` (branch `api-additive-hooks-fastram`) and wired into `gost`: -- **3.6 `WithDeferredReset`** — implemented in `m68kemu`; not yet adopted by - `gost` (the current construction order already leaves devices in a valid - post-cold-reset state, so it buys little until the scheduler work). +- **3.6 `WithDeferredReset`** — implemented and adopted. `NewMachineWithCartridge` + builds the CPU with `WithDeferredReset` and calls `Machine.Reset` once before + returning, so there is a single reset path. This also fixed a latent bug: the + ROM fast-memory window (3.1) was only installed from `Machine.Reset`, which the + desktop and headless runners never call, so the shipped binary had been running + entirely on the bus path. - **3.5 `SetHooks`** — implemented and adopted; `EnableTrace` now installs the whole callback set in one call. - **3.1 fast memory** — implemented as `SetFastMemory(...FastRegion)` (multi @@ -30,21 +33,36 @@ constant instead of `*uint8` (the minimum of **3.3**); `NewCPU` takes `*Bus`; `InterruptController`, `MappedDevice`, `ScheduledEvent`, `WaitHook`, `Bus.SetWaitHook` unexported. -Primitives landed in `m68kemu` (branch `api-scheduler-irq`) but **not adopted by -`gost`**: - -- **3.2 `CycleScheduler.SetClockRatio`** and **3.3 `CPU.SetIRQSource`** — both - implemented, tested, opt-in (the queue and the 1:1 scheduler are unchanged - when unused). -- Wiring `gost` onto the scheduler was tried and reverted. It is behaviourally - correct — the debug-trace suite and the fast/bus cycle-parity tests were - unchanged — but ~2× slower: the current devices advance in an "advance me by - N cycles" style, so as `CycleListener`s they do their per-quantum work (GLUE - scanline loop, MFP GPIP-edge scan, interrupt drain) on *every instruction* - instead of every ~512-cycle quantum. The scheduler pays off only after the - device layer is rewritten event-driven (each device schedules its next state - change), which is its own project. Until then `gost` keeps the quantum loop - and `cpuCyclesForHardwareCycles`. +Also landed and adopted: + +- **3.3 `CPU.SetIRQSource`** — adopted. `machineIRQ` aggregates the GLUE, MFP and + FDC lines into one level-sensitive source the core samples per instruction. + `dispatchInterrupts`, `maskedAutovectorPulse`, the `irqSources` slice and the + `InterruptSource` interface are gone; `DrainInterrupts` survives only as a + PendingIRQ/AckIRQ wrapper for the device unit tests. GLUE now holds an HBL/VBL + autovector line (asserted at each edge, cleared on `AckIRQ`) rather than + queuing pulses — a blank interrupt the CPU is masking is taken shortly after + the mask clears instead of being dropped, which is the honest level-sensitive + behaviour and is invisible to the EmuTOS desktop. `Machine.RunUntil` still + runs its per-instruction loop for device advancement; deleting it waits on + 3.2. + +Primitives landed in `m68kemu` but **not adopted by `gost`**: + +- **3.2 `CycleScheduler.SetClockRatio`** — implemented, tested, opt-in. +- Wiring `gost` onto the scheduler was attempted a second time against v1.5.0 + (2026-09) and reverted again. It is behaviourally correct — the full test + suite, `-race`, and the fast/bus cycle-parity tests were unchanged — but a + 120-frame EmuTOS-boot benchmark went from ~382 ms to ~465 ms (**+22%**, fast + memory) / ~423 ms to ~507 ms (+20%, bus only) on an M1. The v1.5.0 scheduler + is leaner than the earlier attempt (the doc's "~2×" is now "~20%"), but it is + still a pure regression: the devices advance in an "advance me by N cycles" + style, so as `CycleListener`s they do their per-quantum work (GLUE scanline + loop, MFP timer division and GPIP scan, shifter line snapshots) on *every + instruction* instead of every ~512-cycle quantum. The scheduler pays off only + after the device layer is rewritten event-driven (each device schedules its + next state change), which is its own project. Until then `gost` keeps the + quantum loop, `cpuCyclesForHardwareCycles`, and `nextDeviceEventCycles`. - **3.4 optional `Contains`** — landed. `m68kemu`'s `Device` no longer requires `Contains`; a device is located by `AddressRangeDevice` (page-mapped) and/or @@ -54,8 +72,7 @@ Primitives landed in `m68kemu` (branch `api-scheduler-irq`) but **not adopted by check once, so the multi-device lookup is slightly faster. Debug-trace suite unchanged. -Not started: the device-layer event-driven rewrite that 3.2/3.3 adoption -depends on. +Not started: the device-layer event-driven rewrite that 3.2 adoption depends on. ## 1. Motivation @@ -66,9 +83,9 @@ versions do not fit the ST's timing and interrupt model, and a fourth | gost implements | m68kemu already has | why the library version is unused | |---|---|---| -| `clocked []Clocked`, `advanceDevices`, `EventPredictor`, `nextStepQuantum`, `cpuCyclesForHardwareCycles`, `cpuCycleCarry` | `CycleScheduler`, `CycleListener`, `Schedule`/`ScheduleAfter` | no CPU-clock ↔ device-clock ratio; no "advance to next scheduled event" from inside `RunCycles` | -| `irqSources`, `InterruptSource.DrainInterrupts`, `dispatchInterrupts`, `maskedAutovectorPulse` | `InterruptController`, `CPU.RequestInterrupt` | controller queues every request indefinitely and never coalesces; a masked autovector pulse is delivered late instead of dropped, so `gost` inspects `cpu.Registers().SR` by hand to discard it | -| `Machine.RunUntil` — a per-instruction loop that re-aggregates `RunResult` | `CPU.RunUntil` | cannot advance devices or sample interrupts between instructions, so `gost` forces `MaxInstructions = 1` and defeats the internal loop | +| `clocked []Clocked`, `advanceDevices`, `EventPredictor`, `nextStepQuantum`, `cpuCyclesForHardwareCycles`, `cpuCycleCarry` | `CycleScheduler`, `CycleListener`, `Schedule`/`ScheduleAfter` | per-instruction listener dispatch is ~20% slower than the 512-cycle quantum loop until the devices are event-driven (see 3.2 above) | +| ~~`irqSources`, `dispatchInterrupts`, `maskedAutovectorPulse`~~ — **done**, see 3.3 above | `CPU.SetIRQSource` | — | +| `Machine.RunUntil` — a per-instruction loop that re-aggregates `RunResult` | `CPU.RunUntil` | still needs to call `advanceDevices` between instructions; collapses to a one-line delegate once 3.2 lands | | — | `Bus.fastRAM`, `cpu.fastFetchMem` single-RAM fast path | only enabled when the bus holds exactly one device and it is a `*m68kemu.RAM` (`bus.go`, `refreshTopology`); `gost`'s bus has ~15 devices and its own `devices.RAM` | Relevant `gost` files: `internal/emulator/machine_runtime.go`, diff --git a/go.mod b/go.mod index 42e8033..48af366 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.27 require ( github.com/ebitenui/ebitenui v0.7.3 github.com/hajimehoshi/ebiten/v2 v2.9.11 - github.com/jenska/m68kdasm v1.1.0 github.com/jenska/m68kemu v1.5.0 github.com/jenska/ym2149 v1.1.0 golang.org/x/image v0.45.0 @@ -18,6 +17,7 @@ require ( github.com/ebitengine/purego v0.11.0 // indirect github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8 // indirect github.com/go-text/typesetting v0.3.4 // indirect + github.com/jenska/m68kdasm v1.1.0 // indirect github.com/jezek/xgb v1.3.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect diff --git a/internal/config/config.go b/internal/config/config.go index f71daec..b83dc69 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -196,6 +196,48 @@ func (cfg *Config) FrameCycles() uint64 { return cfg.ClockHz / cfg.FrameHz } +// ST raster line counts. FrameHz at or above 55 is treated as an NTSC machine. +const ( + VideoPALScanlines = 313 + VideoNTSCScanlines = 263 + // VideoActiveLines is the number of displayed scanlines per frame. + VideoActiveLines = 200 +) + +// VideoTiming is the ST raster timing a device derives from the machine clock +// and refresh rate. +type VideoTiming struct { + FrameCycles uint64 // machine cycles per display frame (>= 1) + Scanlines uint64 // total scanlines per frame + ActiveLines uint64 // displayed scanlines per frame (<= Scanlines) +} + +// Video returns the raster timing for this config, substituting the package +// defaults for a nil receiver or an unset ClockHz/FrameHz. +func (cfg *Config) Video() VideoTiming { + clockHz, frameHz := uint64(DefaultClockHz), uint64(DefaultFrameHz) + if cfg != nil { + if cfg.ClockHz != 0 { + clockHz = cfg.ClockHz + } + if cfg.FrameHz != 0 { + frameHz = cfg.FrameHz + } + } + + t := VideoTiming{FrameCycles: clockHz / frameHz} + if t.FrameCycles == 0 { + t.FrameCycles = 1 + } + if frameHz >= 55 { + t.Scanlines = VideoNTSCScanlines + } else { + t.Scanlines = VideoPALScanlines + } + t.ActiveLines = min(VideoActiveLines, t.Scanlines) + return t +} + func ConfigForPreset(preset Preset) (*Config, error) { normalized, err := normalizePreset(preset) if err != nil { @@ -337,59 +379,40 @@ func loadConfigPatch(path string) (configPatch, error) { return patch, nil } +// jsonFields maps each config key whose JSON value decodes straight into a +// Config field to that field's address. Keys needing custom parsing (preset +// selection, addresses, MHz) are handled explicitly in Apply. +func (cfg *Config) jsonFields() map[string]any { + return map[string]any{ + KeyROM: &cfg.ROMPath, + KeyCartridge: &cfg.CartridgePath, + KeyFloppyA: &cfg.FloppyA, + KeyFloppyB: &cfg.FloppyB, + KeyHardDiskSizeMB: &cfg.HardDiskSizeMB, + KeyHardDiskImage: &cfg.HardDiskImagePath, + KeyScale: &cfg.Scale, + KeyFullscreen: &cfg.Fullscreen, + KeyHeadless: &cfg.Headless, + KeyFrames: &cfg.Frames, + KeyDumpFrame: &cfg.DumpFramePath, + KeyTrace: &cfg.Trace, + KeyRAMSize: &cfg.RAMSize, + KeyClockHz: &cfg.ClockHz, + KeyCPUClockHz: &cfg.CPUClockHz, + KeyFrameHz: &cfg.FrameHz, + KeyColorMonitor: &cfg.ColorMonitor, + KeyRTC: &cfg.RTC, + KeyMidResYScale: &cfg.MidResYScale, + KeyModel: &cfg.Model, + } +} + func (p configPatch) Apply(cfg *Config) error { + fields := cfg.jsonFields() for key, raw := range p { switch key { case KeyPreset: // Preset is selected before defaults are built, so ignore it here. - case KeyROM: - if err := decodeJSON(raw, &cfg.ROMPath); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyCartridge: - if err := decodeJSON(raw, &cfg.CartridgePath); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyFloppyA: - if err := decodeJSON(raw, &cfg.FloppyA); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyFloppyB: - if err := decodeJSON(raw, &cfg.FloppyB); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyHardDiskSizeMB: - if err := decodeJSON(raw, &cfg.HardDiskSizeMB); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyHardDiskImage: - if err := decodeJSON(raw, &cfg.HardDiskImagePath); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyScale: - if err := decodeJSON(raw, &cfg.Scale); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyFullscreen: - if err := decodeJSON(raw, &cfg.Fullscreen); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyHeadless: - if err := decodeJSON(raw, &cfg.Headless); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyFrames: - if err := decodeJSON(raw, &cfg.Frames); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyDumpFrame: - if err := decodeJSON(raw, &cfg.DumpFramePath); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyTrace: - if err := decodeJSON(raw, &cfg.Trace); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } case KeyTraceStart: value, err := decodeAddressJSON(raw) if err != nil { @@ -402,46 +425,20 @@ func (p configPatch) Apply(cfg *Config) error { return fmt.Errorf("decode %q: %w", key, err) } cfg.TraceEnd = value - case KeyRAMSize: - if err := decodeJSON(raw, &cfg.RAMSize); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyClockHz: - if err := decodeJSON(raw, &cfg.ClockHz); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } case KeyCPUMHz: value, err := decodeMHzJSON(raw) if err != nil { return fmt.Errorf("decode %q: %w", key, err) } cfg.CPUClockHz = value - case KeyCPUClockHz: - if err := decodeJSON(raw, &cfg.CPUClockHz); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyFrameHz: - if err := decodeJSON(raw, &cfg.FrameHz); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyColorMonitor: - if err := decodeJSON(raw, &cfg.ColorMonitor); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyRTC: - if err := decodeJSON(raw, &cfg.RTC); err != nil { - return fmt.Errorf("decode %q: %w", key, err) - } - case KeyMidResYScale: - if err := decodeJSON(raw, &cfg.MidResYScale); err != nil { - return fmt.Errorf("decode %q: %w", key, err) + default: + target, ok := fields[key] + if !ok { + return fmt.Errorf("unsupported config key %q", key) } - case KeyModel: - if err := decodeJSON(raw, &cfg.Model); err != nil { + if err := decodeJSON(raw, target); err != nil { return fmt.Errorf("decode %q: %w", key, err) } - default: - return fmt.Errorf("unsupported config key %q", key) } } return nil diff --git a/internal/devices/acia.go b/internal/devices/acia.go index cccb438..ff3ab9d 100644 --- a/internal/devices/acia.go +++ b/internal/devices/acia.go @@ -115,10 +115,6 @@ func (a *ACIA) Advance(uint64) { } } -func (a *ACIA) DrainInterrupts() []Interrupt { - return nil -} - // pollReceiveChannel loads one pending endpoint byte when that channel's // receive register is empty. func (a *ACIA) pollReceiveChannel(channel uint32) { diff --git a/internal/devices/acia_test.go b/internal/devices/acia_test.go index ed99035..a02cc27 100644 --- a/internal/devices/acia_test.go +++ b/internal/devices/acia_test.go @@ -123,7 +123,7 @@ func TestACIAMIDIStaggersQueuedBytesAcrossAdvances(t *testing.T) { } } -func TestACIAKeyboardDoesNotQueueDirectCPUInterrupt(t *testing.T) { +func TestACIAKeyboardDoesNotDriveCPUInterruptLine(t *testing.T) { acia := NewACIA(nil) if err := acia.Write(1, aciaBase, 0x95); err != nil { @@ -133,8 +133,10 @@ func TestACIAKeyboardDoesNotQueueDirectCPUInterrupt(t *testing.T) { acia.PushKey(0x1E, true) acia.Advance(0) - if irqs := acia.DrainInterrupts(); len(irqs) != 0 { - t.Fatalf("expected no direct CPU interrupts from ACIA, got %d", len(irqs)) + // The ACIA has no interrupt line of its own; it signals the CPU only by + // driving MFP GPIP i4 through the SetACIAInterrupt callback. + if _, drivesLine := any(acia).(interface{ PendingIRQ() (uint8, uint8) }); drivesLine { + t.Fatal("ACIA must route interrupts through the MFP, not drive the CPU line directly") } } @@ -158,7 +160,7 @@ func TestACIAKeyboardSignalsMFPInterruptOnReceive(t *testing.T) { acia.PushKey(0x1E, true) acia.Advance(0) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected one MFP interrupt, got %d", len(irqs)) } @@ -170,7 +172,7 @@ func TestACIAKeyboardSignalsMFPInterruptOnReceive(t *testing.T) { t.Fatalf("read keyboard data: %v", err) } - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected interrupt to clear after data read, got %d", len(irqs)) } } @@ -194,7 +196,7 @@ func TestACIAMIDISignalsMFPInterruptOnReceive(t *testing.T) { acia.PushMIDIInput([]byte{0x90}) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected one MFP interrupt, got %d", len(irqs)) } @@ -205,7 +207,7 @@ func TestACIAMIDISignalsMFPInterruptOnReceive(t *testing.T) { if _, err := acia.Read(1, aciaBase+6); err != nil { t.Fatalf("read MIDI data: %v", err) } - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected interrupt to clear after MIDI data read, got %d", len(irqs)) } } diff --git a/internal/devices/device.go b/internal/devices/device.go index 36f4cc9..30883a8 100644 --- a/internal/devices/device.go +++ b/internal/devices/device.go @@ -10,11 +10,12 @@ import ( // request (vector 24+Level) rather than take a device-supplied vector. const AutoVector = cpu.AutoVector -// Interrupt models a pending CPU interrupt from a device to the CPU. +// Interrupt models a single interrupt a device drives onto the CPU: a priority +// Level (1-7) and an exception Vector (AutoVector to auto-vector). Devices +// expose their interrupt line through PendingIRQ/AckIRQ; the machine aggregates +// those lines into the CPU's IRQ source. type Interrupt struct { - // Level is the interrupt priority level (1-7). - Level uint8 - // Vector is the exception vector number, or AutoVector to auto-vector. + Level uint8 Vector uint8 } @@ -36,14 +37,6 @@ type EventPredictor interface { NextEventCycles() (uint64, bool) } -// InterruptSource exposes pending interrupts from a device to the CPU via -// the interrupt controller. -type InterruptSource interface { - // DrainInterrupts returns all pending interrupts and clears the device's - // interrupt queue. Interrupts are returned in priority order. - DrainInterrupts() []Interrupt -} - func readUint16BE(buf []byte, offset uint32) uint16 { return binary.BigEndian.Uint16(buf[offset:]) } diff --git a/internal/devices/fdc.go b/internal/devices/fdc.go index 2e2d53a..91f7e5f 100644 --- a/internal/devices/fdc.go +++ b/internal/devices/fdc.go @@ -543,13 +543,19 @@ func (f *FDC) Write(size cpu.Size, address uint32, value uint32) error { func (f *FDC) Advance(uint64) {} -func (f *FDC) DrainInterrupts() []Interrupt { +// PendingIRQ reports the FDC/DMA interrupt line: level 5 with the configured +// vector while an operation has completed and not yet been acknowledged. The +// same event is also delivered through MFP GPIP i5 (see queueInterrupt). +func (f *FDC) PendingIRQ() (level, vector uint8) { if len(f.pending) == 0 { - return nil + return 0, cpu.AutoVector } - out := append([]Interrupt(nil), f.pending...) + return f.pending[0].Level, f.pending[0].Vector +} + +// AckIRQ lowers the line once the CPU has taken the interrupt. +func (f *FDC) AckIRQ(uint8) { f.pending = f.pending[:0] - return out } func (f *FDC) readByte(offset uint32) byte { diff --git a/internal/devices/fdc_test.go b/internal/devices/fdc_test.go index 64dbcdb..e24421c 100644 --- a/internal/devices/fdc_test.go +++ b/internal/devices/fdc_test.go @@ -896,7 +896,7 @@ func TestFDCAcsiSingleByteTestUnitReadyCompletesOnDeselect(t *testing.T) { if fdc.acsiStatus != acsiStatusGood { t.Fatalf("single-byte TUR status = %02x, want %02x", fdc.acsiStatus, acsiStatusGood) } - if pending := fdc.DrainInterrupts(); len(pending) != 1 { + if pending := drainIRQ(fdc); len(pending) != 1 { t.Fatalf("single-byte TUR should queue one interrupt, got %d", len(pending)) } } @@ -945,7 +945,7 @@ func TestFDCAcsiPartialCommandBytesAssertHandshakeLine(t *testing.T) { if len(line) < 2 || line[len(line)-2] || !line[len(line)-1] { t.Fatalf("expected partial command byte to pulse handshake line low/high, got %v", line) } - if pending := fdc.DrainInterrupts(); len(pending) != 0 { + if pending := drainIRQ(fdc); len(pending) != 0 { t.Fatalf("partial command byte must not queue CPU interrupt, got %d", len(pending)) } } diff --git a/internal/devices/glue.go b/internal/devices/glue.go index ae744de..2636cc7 100644 --- a/internal/devices/glue.go +++ b/internal/devices/glue.go @@ -5,25 +5,21 @@ import ( cpu "github.com/jenska/m68kemu" ) -const ( - glueBase = 0xFF8006 - glueSize = 2 - gluePALScanlines = 313 - glueNTSCScanlines = 263 -) - -// GLUE models the ST's glue logic that is visible to the emulator core: -// address-decoded system-control probes plus horizontal/vertical blank -// autovector timing. Most GLUE behavior is expressed by other devices through -// the bus map, MFP lines, and shifter registers. +// GLUE models the part of the ST's glue logic the emulator core needs directly: +// the horizontal- and vertical-blank autovector timing. It is not memory-mapped +// (the system-control probe address it used to answer is a plain ScratchRegion +// now); the machine drives it through advanceDevices and the IRQ line. type GLUE struct { - configRegister uint16 - frameCycles uint64 - scanlines uint64 - cycleInFrame uint64 - nextLine uint64 - pending []Interrupt - draining []Interrupt + frameCycles uint64 + scanlines uint64 + cycleInFrame uint64 + nextLine uint64 + // lineLevel is the autovector interrupt level GLUE currently drives onto the + // CPU: 0 (idle), 2 (HBL) or 4 (VBL). Each scanline/frame edge (re)asserts it; + // AckIRQ lowers it once the CPU has taken it. A pulse the CPU never gets to + // (masked the whole time) is simply overwritten by the next edge, so it is + // taken at most about one scanline late rather than latched indefinitely. + lineLevel uint8 } func NewGLUE(cfg ...*config.Config) *GLUE { @@ -35,45 +31,10 @@ func NewGLUE(cfg ...*config.Config) *GLUE { return g } -func (g *GLUE) AddressRange() (uint32, uint32) { - return glueBase, glueBase + glueSize - 1 -} - -func (g *GLUE) Read(size cpu.Size, address uint32) (uint32, error) { - switch size { - case cpu.Byte: - if address&1 == 0 { - return uint32(g.configRegister >> 8), nil - } - return uint32(g.configRegister & 0xFF), nil - default: - return uint32(g.configRegister), nil - } -} - -func (g *GLUE) Peek(size cpu.Size, address uint32) (uint32, error) { - return g.Read(size, address) -} - -func (g *GLUE) Write(size cpu.Size, address uint32, value uint32) error { - switch size { - case cpu.Byte: - if address&1 == 0 { - g.configRegister = (g.configRegister & 0x00FF) | uint16(value&0xFF)<<8 - } else { - g.configRegister = (g.configRegister & 0xFF00) | uint16(value&0xFF) - } - default: - g.configRegister = uint16(value) - } - return nil -} - func (g *GLUE) Reset() { - g.configRegister = 0 g.cycleInFrame = 0 g.nextLine = 1 - g.pending = g.pending[:0] + g.lineLevel = 0 } func (g *GLUE) Advance(cycles uint64) { @@ -84,27 +45,29 @@ func (g *GLUE) Advance(cycles uint64) { g.cycleInFrame += cycles for g.cycleInFrame >= g.nextLineCycle() { if g.nextLine >= g.scanlines { - g.pending = append(g.pending, Interrupt{Level: 4}) + g.lineLevel = 4 g.cycleInFrame -= g.frameCycles g.nextLine = 1 continue } - g.pending = append(g.pending, Interrupt{Level: 2}) + if g.lineLevel < 2 { + g.lineLevel = 2 + } g.nextLine++ } } -// DrainInterrupts returns the interrupts queued since the last call. GLUE emits -// an HBL pulse every scanline and is drained twice per emulation quantum, so the -// result is handed back in a reused buffer that stays valid only until the next -// DrainInterrupts call. -func (g *GLUE) DrainInterrupts() []Interrupt { - if len(g.pending) == 0 { - return nil +// PendingIRQ reports the autovector line GLUE is currently driving (HBL level 2 +// or VBL level 4), or level 0 when idle. +func (g *GLUE) PendingIRQ() (level, vector uint8) { + return g.lineLevel, cpu.AutoVector +} + +// AckIRQ lowers the line once the CPU has accepted the pulse GLUE raised. +func (g *GLUE) AckIRQ(level uint8) { + if g.lineLevel == level { + g.lineLevel = 0 } - g.draining = append(g.draining[:0], g.pending...) - g.pending = g.pending[:0] - return g.draining } func (g *GLUE) NextEventCycles() (uint64, bool) { @@ -118,20 +81,13 @@ func (g *GLUE) NextEventCycles() (uint64, bool) { return next - g.cycleInFrame, true } -func (g *GLUE) WaitStates(cpu.Size, uint32) uint32 { - return 4 -} - func (g *GLUE) configureTiming(cfg *config.Config) { - g.frameCycles = cfg.FrameCycles() - if g.frameCycles == 0 { - return - } - if cfg.FrameHz >= 55 { - g.scanlines = glueNTSCScanlines - return + if cfg.FrameCycles() == 0 { + return // no usable clock/refresh; GLUE stays idle } - g.scanlines = gluePALScanlines + timing := cfg.Video() + g.frameCycles = timing.FrameCycles + g.scanlines = timing.Scanlines } func (g *GLUE) nextLineCycle() uint64 { diff --git a/internal/devices/glue_test.go b/internal/devices/glue_test.go index 0c796be..4d000b1 100644 --- a/internal/devices/glue_test.go +++ b/internal/devices/glue_test.go @@ -4,41 +4,9 @@ import ( "testing" "github.com/jenska/gost/internal/config" - "github.com/jenska/m68kemu" ) -func TestGLUEConfigRegisterDefaultsToZero(t *testing.T) { - glue := NewGLUE() - - value, err := glue.Read(m68kemu.Word, glueBase) - if err != nil { - t.Fatalf("read glue register: %v", err) - } - if got := uint16(value); got != 0 { - t.Fatalf("unexpected default glue value: got %04x want 0000", got) - } -} - -func TestGLUEByteWritesUpdateHighAndLowBytes(t *testing.T) { - glue := NewGLUE() - - if err := glue.Write(m68kemu.Byte, glueBase, 0x12); err != nil { - t.Fatalf("write glue high byte: %v", err) - } - if err := glue.Write(m68kemu.Byte, glueBase+1, 0x34); err != nil { - t.Fatalf("write glue low byte: %v", err) - } - - value, err := glue.Read(m68kemu.Word, glueBase) - if err != nil { - t.Fatalf("read glue word: %v", err) - } - if got := uint16(value); got != 0x1234 { - t.Fatalf("unexpected glue word: got %04x want 1234", got) - } -} - -func TestGLUEQueuesHBLAtScanlineBoundary(t *testing.T) { +func TestGLUEAssertsHBLLineAtScanlineBoundary(t *testing.T) { cfg := &config.Config{ClockHz: 8_000_000, FrameHz: 50} glue := NewGLUE(cfg) @@ -50,28 +18,48 @@ func TestGLUEQueuesHBLAtScanlineBoundary(t *testing.T) { t.Fatalf("unexpected first PAL scanline cycles: got %d want 511", cycles) } + if level, _ := glue.PendingIRQ(); level != 0 { + t.Fatalf("GLUE line asserted before any scanline edge: level %d", level) + } + glue.Advance(cycles) - irqs := glue.DrainInterrupts() - if len(irqs) != 1 { - t.Fatalf("expected one HBL interrupt, got %d", len(irqs)) + level, vector := glue.PendingIRQ() + if level != 2 || vector != AutoVector { + t.Fatalf("expected HBL autovector line, got level %d vector %d", level, vector) } - if irqs[0].Level != 2 || irqs[0].Vector != AutoVector { - t.Fatalf("unexpected HBL interrupt: %+v", irqs[0]) + + glue.AckIRQ(2) + if level, _ := glue.PendingIRQ(); level != 0 { + t.Fatalf("GLUE line still asserted after AckIRQ: level %d", level) + } +} + +func TestGLUEAssertsVBLLineAtFrameBoundary(t *testing.T) { + cfg := &config.Config{ClockHz: 8_000_000, FrameHz: 50} + glue := NewGLUE(cfg) + + glue.Advance(cfg.FrameCycles()) + if level, vector := glue.PendingIRQ(); level != 4 || vector != AutoVector { + t.Fatalf("expected VBL autovector line after a full frame, got level %d vector %d", level, vector) } } -func TestGLUEQueuesVBLAtFrameBoundary(t *testing.T) { +// TestGLUEVBLLineOutranksLaterHBLEdges guards the priority guard in Advance: an +// unacknowledged VBL (level 4) must not be downgraded to an HBL (level 2) by the +// scanline edges that follow it in the next frame. +func TestGLUEVBLLineOutranksLaterHBLEdges(t *testing.T) { cfg := &config.Config{ClockHz: 8_000_000, FrameHz: 50} glue := NewGLUE(cfg) glue.Advance(cfg.FrameCycles()) - irqs := glue.DrainInterrupts() - if len(irqs) == 0 { - t.Fatalf("expected GLUE frame interrupts") + if level, _ := glue.PendingIRQ(); level != 4 { + t.Fatalf("expected VBL line, got level %d", level) } - last := irqs[len(irqs)-1] - if last.Level != 4 || last.Vector != AutoVector { - t.Fatalf("expected final frame interrupt to be VBL autovector, got %+v", last) + + // Several more scanlines pass without the CPU taking the VBL. + glue.Advance(cfg.FrameCycles() / 100) + if level, _ := glue.PendingIRQ(); level != 4 { + t.Fatalf("VBL line was lowered by later HBL edges: level %d", level) } } diff --git a/internal/devices/irq_test.go b/internal/devices/irq_test.go new file mode 100644 index 0000000..49e51bc --- /dev/null +++ b/internal/devices/irq_test.go @@ -0,0 +1,20 @@ +package devices + +// irqLine is the level-sensitive interrupt line a peripheral drives onto the +// CPU. The machine aggregates these; the unit tests below use drainIRQ. +type irqLine interface { + PendingIRQ() (level, vector uint8) + AckIRQ(level uint8) +} + +// drainIRQ takes the interrupt a device's line is currently asserting and +// lowers it, the way the machine's old DrainInterrupts helper did. It returns +// nil when the line is idle. +func drainIRQ(line irqLine) []Interrupt { + level, vector := line.PendingIRQ() + if level == 0 { + return nil + } + line.AckIRQ(level) + return []Interrupt{{Level: level, Vector: vector}} +} diff --git a/internal/devices/mfp.go b/internal/devices/mfp.go index c20f3d8..3a9cec9 100644 --- a/internal/devices/mfp.go +++ b/internal/devices/mfp.go @@ -12,11 +12,7 @@ const ( // which implies a 2.4576 MHz MFP timer input clock. mfpTimerInputHz = 2_457_600 - mfpPALScanlines = 313 - mfpNTSCScanlines = 263 - mfpActiveVideoLines = 200 - mfpDefaultClockHz = config.DefaultClockHz - mfpDefaultFrameHz = config.DefaultFrameHz + mfpDefaultClockHz = config.DefaultClockHz mfpGPIP = 0x01 mfpAER = 0x03 @@ -67,11 +63,14 @@ type mfpTimer struct { // MFP models the STF's 68901 interrupt controller with timer-backed IRQs. type MFP struct { - cfg *config.Config - registers [mfpSize]byte - vectorBase uint8 - softwareEOI bool - inFlight [16]bool + cfg *config.Config + registers [mfpSize]byte + vectorBase uint8 + softwareEOI bool + inFlight [16]bool + // ackChannel is the channel PendingIRQ last reported, consumed by AckIRQ. + // -1 when there is nothing to acknowledge. + ackChannel int aciaIRQActive bool printerBusy bool fdcIRQActive bool @@ -113,6 +112,7 @@ func (m *MFP) WaitStates(cpu.Size, uint32) uint32 { func (m *MFP) Reset() { clear(m.registers[:]) clear(m.inFlight[:]) + m.ackChannel = -1 m.vectorBase = 0x40 m.softwareEOI = false // Unmodeled GPIP inputs idle high on a plain ST, which prevents EmuTOS @@ -198,22 +198,35 @@ func (m *MFP) Advance(cycles uint64) { } } -func (m *MFP) DrainInterrupts() []Interrupt { +// PendingIRQ reports the 68901 interrupt line: level 6 with the vector of the +// highest-priority channel that is pending, enabled, unmasked, and not blocked +// by an in-service interrupt. Level 0 means the line is idle. +func (m *MFP) PendingIRQ() (level, vector uint8) { m.updateGPIPEdges() channel, ok := m.nextPendingChannel() if !ok { - return nil + m.ackChannel = -1 + return 0, cpu.AutoVector } + m.ackChannel = channel + return 6, m.vectorBase + uint8(channel) +} + +// AckIRQ clears the pending latch for the channel PendingIRQ last reported and, +// under software end-of-interrupt, marks that channel in service. +func (m *MFP) AckIRQ(uint8) { + channel := m.ackChannel + if channel < 0 { + return + } + m.ackChannel = -1 m.clearRegisterBit(pendingRegisterForChannel(channel), channelBit(channel)) if m.softwareEOI { m.inFlight[channel] = true m.setRegisterBit(serviceRegisterForChannel(channel), channelBit(channel)) } - - vector := m.vectorBase + uint8(channel) - return []Interrupt{{Level: 6, Vector: vector}} } func (m *MFP) NextEventCycles() (uint64, bool) { @@ -430,26 +443,10 @@ func (m *MFP) timerCurrentValue(index int) byte { } func (m *MFP) configureEventCountTiming() { - clockHz := uint64(mfpDefaultClockHz) - frameHz := uint64(mfpDefaultFrameHz) - if m.cfg != nil { - if m.cfg.ClockHz != 0 { - clockHz = m.cfg.ClockHz - } - if m.cfg.FrameHz != 0 { - frameHz = m.cfg.FrameHz - } - } - m.eventCountFrameCycles = clockHz / frameHz - if m.eventCountFrameCycles == 0 { - m.eventCountFrameCycles = 1 - } - if frameHz >= 55 { - m.eventCountScanlines = mfpNTSCScanlines - } else { - m.eventCountScanlines = mfpPALScanlines - } - m.eventCountActiveLines = min(mfpActiveVideoLines, m.eventCountScanlines) + timing := m.cfg.Video() + m.eventCountFrameCycles = timing.FrameCycles + m.eventCountScanlines = timing.Scanlines + m.eventCountActiveLines = timing.ActiveLines } func (m *MFP) advanceEventCountTimers(cycles uint64) { diff --git a/internal/devices/mfp_test.go b/internal/devices/mfp_test.go index 98b64df..8926d0c 100644 --- a/internal/devices/mfp_test.go +++ b/internal/devices/mfp_test.go @@ -26,7 +26,7 @@ func TestMFPTimerQueuesInterrupt(t *testing.T) { } mfp.Advance(14) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected 1 interrupt, got %d", len(irqs)) } @@ -58,7 +58,7 @@ func TestMFPTimerCQueuesInterrupt(t *testing.T) { } mfp.Advance(14) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected 1 interrupt, got %d", len(irqs)) } @@ -107,7 +107,8 @@ func TestMFPTimerBEventCountModePausesDuringVerticalBlank(t *testing.T) { t.Fatalf("write timer b event-count control: %v", err) } - activeEnd := mfpActiveVideoLines * cfg.FrameCycles() / mfpPALScanlines + timing := cfg.Video() + activeEnd := timing.ActiveLines * timing.FrameCycles / timing.Scanlines mfp.Advance(activeEnd + 1_000) blankValue, err := mfp.Read(1, mfpBase+mfpTBDR) if err != nil { @@ -151,7 +152,7 @@ func TestMFPSoftwareEOIBlocksLowerPriorityInterrupts(t *testing.T) { mfp.Advance(14) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected 1 interrupt, got %d", len(irqs)) } @@ -167,7 +168,7 @@ func TestMFPSoftwareEOIBlocksLowerPriorityInterrupts(t *testing.T) { t.Fatalf("timer A should be in service, ISRA=%02x", isr) } - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected lower priority interrupt to be blocked, got %d", len(irqs)) } @@ -175,7 +176,7 @@ func TestMFPSoftwareEOIBlocksLowerPriorityInterrupts(t *testing.T) { t.Fatalf("clear in-service bit: %v", err) } - irqs = mfp.DrainInterrupts() + irqs = drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected 1 interrupt after software eoi, got %d", len(irqs)) } @@ -206,7 +207,7 @@ func TestMFPWritingPendingRegisterClearsPendingInterrupt(t *testing.T) { t.Fatalf("clear pending register: %v", err) } - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected pending interrupt to be cleared, got %d", len(irqs)) } } @@ -231,12 +232,12 @@ func TestMFPTimerAccumulatesFractionalCPUClock(t *testing.T) { } mfp.Advance(13) - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected no interrupt before 14 CPU cycles, got %d", len(irqs)) } mfp.Advance(1) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected 1 interrupt after 14 CPU cycles, got %d", len(irqs)) } @@ -265,13 +266,13 @@ func TestMFPAutoEOITimerCRepeats(t *testing.T) { } mfp.Advance(14) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected first timer c interrupt, got %d", len(irqs)) } mfp.Advance(14) - irqs = mfp.DrainInterrupts() + irqs = drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected recurring timer c interrupt under auto-EOI, got %d", len(irqs)) } @@ -328,13 +329,13 @@ func TestMFPSoftwareEOIPreventsDuplicateTimerDispatchBeforeServiceClear(t *testi } mfp.Advance(14) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected first timer c interrupt, got %d", len(irqs)) } mfp.Advance(14) - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected duplicate timer c interrupt to stay blocked until service clear, got %d", len(irqs)) } @@ -342,7 +343,7 @@ func TestMFPSoftwareEOIPreventsDuplicateTimerDispatchBeforeServiceClear(t *testi t.Fatalf("clear timer c in-service bit: %v", err) } - irqs = mfp.DrainInterrupts() + irqs = drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected pending timer c interrupt after service clear, got %d", len(irqs)) } @@ -516,7 +517,7 @@ func TestMFPGPIPAERDefaultDetectsFallingACIAEdge(t *testing.T) { } mfp.SetACIAInterrupt(true) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected falling ACIA edge interrupt, got %d", len(irqs)) } @@ -525,7 +526,7 @@ func TestMFPGPIPAERDefaultDetectsFallingACIAEdge(t *testing.T) { } mfp.SetACIAInterrupt(false) - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected rising ACIA edge to be ignored by default AER, got %d", len(irqs)) } } @@ -547,12 +548,12 @@ func TestMFPGPIPAERDetectsRisingACIAEdgeWhenConfigured(t *testing.T) { } mfp.SetACIAInterrupt(true) - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected falling ACIA edge to be ignored by rising AER, got %d", len(irqs)) } mfp.SetACIAInterrupt(false) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected rising ACIA edge interrupt, got %d", len(irqs)) } @@ -575,14 +576,14 @@ func TestMFPGPIPAERIgnoresEdgesOnDDRConfiguredOutputs(t *testing.T) { } mfp.SetACIAInterrupt(true) - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected DDR output ACIA edge to be ignored, got %d", len(irqs)) } if err := mfp.Write(1, mfpBase+mfpDDR, 0x00); err != nil { t.Fatalf("clear DDR: %v", err) } - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected no latent ACIA edge after returning bit to input, got %d", len(irqs)) } } @@ -603,7 +604,7 @@ func TestMFPGPIPAERDetectsFallingRTCEdgeOnGPIP5(t *testing.T) { } rtc.HandleCommand(icdRTCCmdBegin) - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected falling RTC edge interrupt, got %d", len(irqs)) } @@ -612,7 +613,7 @@ func TestMFPGPIPAERDetectsFallingRTCEdgeOnGPIP5(t *testing.T) { } rtc.HandleCommand(icdRTCCmdEnd) - if irqs := mfp.DrainInterrupts(); len(irqs) != 0 { + if irqs := drainIRQ(mfp); len(irqs) != 0 { t.Fatalf("expected rising RTC edge to be ignored by default AER, got %d", len(irqs)) } } @@ -715,7 +716,7 @@ func TestMFPRS232ReceiveByteSetsStatusAndInterrupt(t *testing.T) { t.Fatalf("expected RSR buffer-full bit, got %02x", byte(status)) } - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected receive interrupt, got %d", len(irqs)) } @@ -807,7 +808,7 @@ func TestMFPRS232TransmitCapturesOutputAndInterrupts(t *testing.T) { t.Fatalf("expected TSR buffer-empty bit, got %02x", byte(status)) } - irqs := mfp.DrainInterrupts() + irqs := drainIRQ(mfp) if len(irqs) != 1 { t.Fatalf("expected transmit interrupt, got %d", len(irqs)) } diff --git a/internal/devices/scratch_region.go b/internal/devices/scratch_region.go new file mode 100644 index 0000000..e770080 --- /dev/null +++ b/internal/devices/scratch_region.go @@ -0,0 +1,57 @@ +package devices + +import cpu "github.com/jenska/m68kemu" + +// ScratchRegion is a plain read/write word with no side effects. It satisfies +// guest probes of system-control addresses that GoST does not otherwise model +// (for example EmuTOS's startup poke at $FF8006), returning whatever was last +// written. +type ScratchRegion struct { + start uint32 + end uint32 // exclusive + value uint16 +} + +// NewScratchRegion covers the half-open range [start, end). +func NewScratchRegion(start, end uint32) *ScratchRegion { + return &ScratchRegion{start: start, end: end} +} + +func (r *ScratchRegion) AddressRange() (uint32, uint32) { + return r.start, r.end - 1 +} + +func (r *ScratchRegion) WaitStates(cpu.Size, uint32) uint32 { + return 4 +} + +func (r *ScratchRegion) Read(size cpu.Size, address uint32) (uint32, error) { + if size == cpu.Byte { + if address&1 == 0 { + return uint32(r.value >> 8), nil + } + return uint32(r.value & 0xFF), nil + } + return uint32(r.value), nil +} + +func (r *ScratchRegion) Peek(size cpu.Size, address uint32) (uint32, error) { + return r.Read(size, address) +} + +func (r *ScratchRegion) Write(size cpu.Size, address uint32, value uint32) error { + if size == cpu.Byte { + if address&1 == 0 { + r.value = (r.value & 0x00FF) | uint16(value&0xFF)<<8 + } else { + r.value = (r.value & 0xFF00) | uint16(value&0xFF) + } + return nil + } + r.value = uint16(value) + return nil +} + +func (r *ScratchRegion) Reset() { + r.value = 0 +} diff --git a/internal/devices/scratch_region_test.go b/internal/devices/scratch_region_test.go new file mode 100644 index 0000000..eeab557 --- /dev/null +++ b/internal/devices/scratch_region_test.go @@ -0,0 +1,43 @@ +package devices + +import ( + "testing" + + "github.com/jenska/m68kemu" +) + +func TestScratchRegionDefaultsToZero(t *testing.T) { + region := NewScratchRegion(0xFF8006, 0xFF8008) + + value, err := region.Read(m68kemu.Word, 0xFF8006) + if err != nil { + t.Fatalf("read scratch region: %v", err) + } + if got := uint16(value); got != 0 { + t.Fatalf("unexpected default value: got %04x want 0000", got) + } +} + +func TestScratchRegionByteWritesUpdateHighAndLowBytes(t *testing.T) { + region := NewScratchRegion(0xFF8006, 0xFF8008) + + if err := region.Write(m68kemu.Byte, 0xFF8006, 0x12); err != nil { + t.Fatalf("write high byte: %v", err) + } + if err := region.Write(m68kemu.Byte, 0xFF8007, 0x34); err != nil { + t.Fatalf("write low byte: %v", err) + } + + value, err := region.Read(m68kemu.Word, 0xFF8006) + if err != nil { + t.Fatalf("read word: %v", err) + } + if got := uint16(value); got != 0x1234 { + t.Fatalf("unexpected word: got %04x want 1234", got) + } + + region.Reset() + if value, _ := region.Read(m68kemu.Word, 0xFF8006); value != 0 { + t.Fatalf("Reset did not clear the register: %04x", value) + } +} diff --git a/internal/emulator/debug_emutos_test.go b/internal/emulator/debug_emutos_test.go index 0e70acb..7713df2 100644 --- a/internal/emulator/debug_emutos_test.go +++ b/internal/emulator/debug_emutos_test.go @@ -312,14 +312,7 @@ func TestBundledEmuTOSDoesNotPanicWithoutMFPDelivery(t *testing.T) { } machine.clocked = filteredClocked - filteredIRQs := make([]devices.InterruptSource, 0, len(machine.irqSources)) - for _, source := range machine.irqSources { - if source == machine.mfp { - continue - } - filteredIRQs = append(filteredIRQs, source) - } - machine.irqSources = filteredIRQs + keepDeviceIRQ(machine, true, false, true) stepFrames(t, machine, 120) diff --git a/internal/emulator/debug_panic_test.go b/internal/emulator/debug_panic_test.go index b3d3d8b..869754f 100644 --- a/internal/emulator/debug_panic_test.go +++ b/internal/emulator/debug_panic_test.go @@ -463,7 +463,7 @@ func TestDebugPanicWithoutIRQs(t *testing.T) { t.Fatalf("create machine: %v", err) } - machine.irqSources = nil + machine.cpu.SetIRQSource(nil) for frame := range 120 { if _, err := machine.StepFrame(); err != nil { @@ -496,14 +496,7 @@ func TestDebugPanicWithoutVBL(t *testing.T) { } machine.clocked = filteredClocked - filteredIRQs := make([]devices.InterruptSource, 0, len(machine.irqSources)) - for _, source := range machine.irqSources { - if source == machine.glue { - continue - } - filteredIRQs = append(filteredIRQs, source) - } - machine.irqSources = filteredIRQs + keepDeviceIRQ(machine, false, true, true) for frame := range 120 { if _, err := machine.StepFrame(); err != nil { @@ -533,14 +526,7 @@ func TestDebugPanicWithoutMFP(t *testing.T) { } machine.clocked = filteredClocked - filteredIRQs := make([]devices.InterruptSource, 0, len(machine.irqSources)) - for _, source := range machine.irqSources { - if source == machine.mfp { - continue - } - filteredIRQs = append(filteredIRQs, source) - } - machine.irqSources = filteredIRQs + keepDeviceIRQ(machine, true, false, true) for frame := range 120 { if _, err := machine.StepFrame(); err != nil { diff --git a/internal/emulator/machine_builder.go b/internal/emulator/machine_builder.go index 41be467..56d3e0f 100644 --- a/internal/emulator/machine_builder.go +++ b/internal/emulator/machine_builder.go @@ -72,7 +72,10 @@ func NewMachineWithCartridge(cfg *config.Config, romImage []byte, cartridgeImage overlayROM, ram, memoryConfig, - glue, + // $FF8006 is a system-control address EmuTOS pokes at startup; GoST does + // not model it, so a scratch word answers the probe. GLUE itself (below) + // is not memory-mapped. + devices.NewScratchRegion(0xFF8006, 0xFF8008), shifter, blitter, mfp, @@ -91,10 +94,14 @@ func NewMachineWithCartridge(cfg *config.Config, romImage []byte, cartridgeImage bus := cpu.NewBus(busDevices...) bus.SetWaitStates(4) - processor, err := cpu.NewCPU(bus) + // WithDeferredReset keeps construction from resetting the CPU against a bus + // that is not fully described yet; machine.Reset below is the single reset + // path and is also what installs the ROM fast-memory window. + processor, err := cpu.NewCPU(bus, cpu.WithDeferredReset()) if err != nil { return nil, err } + processor.SetIRQSource(machineIRQ{glue: glue, mfp: mfp, fdc: fdc}) machine := &Machine{ cfg: cfg, @@ -114,11 +121,14 @@ func NewMachineWithCartridge(cfg *config.Config, romImage []byte, cartridgeImage rs232: rs232, steSound: steSound, clocked: clockedDevices(glue, mfp, acia, fdc, psg, steSound), - irqSources: []devices.InterruptSource{glue, mfp, acia, fdc}, frameCycles: frameCycles, traceWriter: io.Discard, } + if err := machine.Reset(); err != nil { + return nil, err + } + if cfg.Trace == string(TraceModeSimple) { machine.EnableTrace(string(TraceModeSimple), os.Stdout) } diff --git a/internal/emulator/machine_io.go b/internal/emulator/machine_io.go index f20346b..1a090c8 100644 --- a/internal/emulator/machine_io.go +++ b/internal/emulator/machine_io.go @@ -42,7 +42,6 @@ type ( cpu cpu.CPU ram *devices.RAM rom *devices.ROM - cartridge *devices.CartridgeROM overlayROM *devices.OverlayROM memoryConfig *devices.MemoryConfig shifter *devices.Shifter @@ -55,7 +54,6 @@ type ( rs232 *devices.RS232 steSound *devices.STESound clocked []devices.Clocked - irqSources []devices.InterruptSource frameCycles uint64 cpuCycleCarry uint64 traceWriter io.Writer diff --git a/internal/emulator/machine_runtime.go b/internal/emulator/machine_runtime.go index eda9f2c..a5849cb 100644 --- a/internal/emulator/machine_runtime.go +++ b/internal/emulator/machine_runtime.go @@ -68,8 +68,6 @@ func (m *Machine) StepFrame() (bool, error) { m.shifter.BeginFrame() remainingHardwareCycles := m.frameCycles for remainingHardwareCycles > 0 { - m.dispatchInterrupts() - quantum := m.nextStepQuantum(remainingHardwareCycles) cpuQuantum := m.cpuCyclesForHardwareCycles(quantum) if cpuQuantum > 0 { @@ -79,7 +77,6 @@ func (m *Machine) StepFrame() (bool, error) { } m.advanceDevices(quantum) m.shifter.AdvanceFrame(quantum) - m.dispatchInterrupts() remainingHardwareCycles -= quantum } @@ -167,7 +164,6 @@ func (m *Machine) RunUntil(options cpu.RunUntilOptions) (cpu.RunResult, error) { advanced := m.cpu.Cycles() - before if advanced > 0 { m.advanceDevices(advanced) - m.dispatchInterrupts() } if err != nil { return total, err @@ -202,27 +198,50 @@ func (m *Machine) advanceDevices(cycles uint64) { } } -func (m *Machine) dispatchInterrupts() { - for _, source := range m.irqSources { - for _, irq := range source.DrainInterrupts() { - if m.maskedAutovectorPulse(irq) { - continue - } - _ = m.cpu.RequestInterrupt(irq.Level, irq.Vector) +// machineIRQ presents the ST's device interrupt lines to the CPU core as a +// single level-sensitive source. The core samples it at every instruction +// boundary and drops requests it is currently masking, which replaces the +// machine's old hand-rolled drain-and-mask loop. Levels are distinct across +// sources (GLUE 2/4 < FDC 5 < MFP 6), so the highest asserted line wins and +// AckIRQ routes the acknowledgement back to the source that raised it. +type machineIRQ struct { + glue *devices.GLUE + mfp *devices.MFP + fdc *devices.FDC +} + +func (s machineIRQ) PendingIRQ() (level, vector uint8) { + if s.mfp != nil { + if l, v := s.mfp.PendingIRQ(); l > level { + level, vector = l, v + } + } + if s.fdc != nil { + if l, v := s.fdc.PendingIRQ(); l > level { + level, vector = l, v } } + if s.glue != nil { + if l, v := s.glue.PendingIRQ(); l > level { + level, vector = l, v + } + } + return level, vector } -// maskedAutovectorPulse reports whether irq is an autovectored HBL/VBL pulse that -// the current interrupt mask would ignore. GLUE delivers these as edge pulses -// rather than a held line, so a pulse that arrives while masked is dropped here -// instead of latching until the mask drops. This matches how the ST's autovector -// interrupts behave for software that briefly raises IPL during critical -// sections; vectored (MFP) interrupts are never dropped this way. -func (m *Machine) maskedAutovectorPulse(irq devices.Interrupt) bool { - if irq.Vector != devices.AutoVector { - return false +func (s machineIRQ) AckIRQ(level uint8) { + switch { + case level >= 6: + if s.mfp != nil { + s.mfp.AckIRQ(level) + } + case level == 5: + if s.fdc != nil { + s.fdc.AckIRQ(level) + } + default: + if s.glue != nil { + s.glue.AckIRQ(level) + } } - mask := uint8((m.cpu.Registers().SR >> 8) & 0x7) - return irq.Level <= mask } diff --git a/internal/emulator/machine_test.go b/internal/emulator/machine_test.go index e9983dc..d793bce 100644 --- a/internal/emulator/machine_test.go +++ b/internal/emulator/machine_test.go @@ -130,7 +130,8 @@ func TestSTBusAlignmentAndMapping(t *testing.T) { blitter := devices.NewBlitter(ram) monsterProbe := devices.NewBusErrorRegion(devices.AddressRange{Start: 0xFFFE00, End: 0xFFFE10}) openBus := devices.NewOpenBus(devices.AddressRange{Start: 0xFF8000, End: 0x1000000}) - bus := cpu.NewBus(overlay, ram, memoryConfig, devices.NewGLUE(), blitter, monsterProbe, openBus, rom) + scratch := devices.NewScratchRegion(0xFF8006, 0xFF8008) + bus := cpu.NewBus(overlay, ram, memoryConfig, scratch, blitter, monsterProbe, openBus, rom) bus.SetWaitStates(4) if _, err := bus.Read(cpu.Word, 1); err == nil { @@ -345,7 +346,7 @@ func TestMachineInterruptHandling(t *testing.T) { machine := mustMachine(t, rom) // Isolate this test to explicit CPU interrupt requests and avoid // unrelated device IRQ traffic (for example frame-driven VBL interrupts). - machine.irqSources = nil + machine.cpu.SetIRQSource(nil) handlerAddress := uint32(0x00001000) vectorOffset := uint32((24 + 6) * 4) @@ -845,94 +846,76 @@ func TestVBLInterruptRunsHandler(t *testing.T) { } } +// testIRQSource is a synthetic CPU interrupt line for exercising the machine's +// interrupt wiring in isolation from real device timing. type testIRQSource struct { - pending []devices.Interrupt + level uint8 + vector uint8 + acks int } -func (s *testIRQSource) DrainInterrupts() []devices.Interrupt { - out := append([]devices.Interrupt(nil), s.pending...) - s.pending = nil - return out +func (s *testIRQSource) PendingIRQ() (uint8, uint8) { return s.level, s.vector } + +func (s *testIRQSource) AckIRQ(uint8) { + s.acks++ + s.level = 0 } -func TestMachineDropsMaskedAutovectorPulse(t *testing.T) { +// TestMachineDefersMaskedInterruptUntilUnmasked covers the IRQ-line contract the +// machine now delegates to the CPU core: a line asserted while the CPU masks its +// level is held, not dropped and not delivered, and is taken exactly once as +// soon as the mask clears. +func TestMachineDefersMaskedInterruptUntilUnmasked(t *testing.T) { rom := loopROM([]byte{ - 0x46, 0xFC, 0x27, 0x00, // move #$2700,sr - 0x46, 0xFC, 0x20, 0x00, // move #$2000,sr + 0x46, 0xFC, 0x27, 0x00, // move #$2700,sr ; mask all interrupts + 0x4E, 0x71, // nop + 0x4E, 0x71, // nop + 0x46, 0xFC, 0x20, 0x00, // move #$2000,sr ; unmask 0x4E, 0x71, // nop 0x60, 0xFE, // bra.s -2 }) machine := mustMachine(t, rom) - machine.irqSources = []devices.InterruptSource{ - &testIRQSource{pending: []devices.Interrupt{{Level: 4}}}, - } + + source := &testIRQSource{level: 6, vector: 64} + machine.cpu.SetIRQSource(source) handlerAddress := uint32(0x00002000) vectorBytes := make([]byte, 4) binary.BigEndian.PutUint32(vectorBytes, handlerAddress) - if err := machine.LoadIntoRAM(uint32((24+4)*4), vectorBytes); err != nil { - t.Fatalf("load vbl vector: %v", err) + if err := machine.LoadIntoRAM(uint32(source.vector)*4, vectorBytes); err != nil { + t.Fatalf("load interrupt vector: %v", err) } if err := machine.LoadIntoRAM(handlerAddress, []byte{ 0x72, 0x01, // moveq #1,d1 0x4E, 0x73, // rte }); err != nil { - t.Fatalf("load vbl handler: %v", err) + t.Fatalf("load interrupt handler: %v", err) } - if err := machine.cpu.Step(); err != nil { - t.Fatalf("set interrupt mask: %v", err) - } - machine.dispatchInterrupts() + // move #$2700,sr followed by two nops: the line is asserted but masked. for i := range 3 { if err := machine.cpu.Step(); err != nil { - t.Fatalf("step %d: %v", i, err) + t.Fatalf("masked step %d: %v", i, err) } } - if got := machine.Registers().D[1]; got != 0 { - t.Fatalf("masked autovector pulse should have been dropped, D1=%08x", uint32(got)) - } -} - -func TestMachineKeepsMaskedVectoredInterruptPending(t *testing.T) { - rom := loopROM([]byte{ - 0x46, 0xFC, 0x27, 0x00, // move #$2700,sr - 0x46, 0xFC, 0x20, 0x00, // move #$2000,sr - 0x4E, 0x71, // nop - 0x60, 0xFE, // bra.s -2 - }) - machine := mustMachine(t, rom) - vector := uint8(64) - machine.irqSources = []devices.InterruptSource{ - &testIRQSource{pending: []devices.Interrupt{{Level: 6, Vector: vector}}}, + t.Fatalf("masked interrupt taken early, D1=%08x", uint32(got)) } - - handlerAddress := uint32(0x00002000) - vectorBytes := make([]byte, 4) - binary.BigEndian.PutUint32(vectorBytes, handlerAddress) - if err := machine.LoadIntoRAM(uint32(vector)*4, vectorBytes); err != nil { - t.Fatalf("load interrupt vector: %v", err) - } - if err := machine.LoadIntoRAM(handlerAddress, []byte{ - 0x72, 0x01, // moveq #1,d1 - 0x4E, 0x73, // rte - }); err != nil { - t.Fatalf("load interrupt handler: %v", err) + if source.acks != 0 { + t.Fatalf("masked interrupt acknowledged: acks=%d", source.acks) } - if err := machine.cpu.Step(); err != nil { - t.Fatalf("set interrupt mask: %v", err) - } - machine.dispatchInterrupts() - for i := range 3 { + // move #$2000,sr: the still-asserted line is taken exactly once. + for i := range 4 { if err := machine.cpu.Step(); err != nil { - t.Fatalf("step %d: %v", i, err) + t.Fatalf("unmasked step %d: %v", i, err) } } - if got := machine.Registers().D[1]; got != 1 { - t.Fatalf("vectored interrupt should remain pending while masked, D1=%08x", uint32(got)) + t.Fatalf("interrupt not taken after mask cleared, D1=%08x", uint32(got)) + } + if source.acks != 1 { + t.Fatalf("expected exactly one acknowledgement, got %d", source.acks) } } @@ -1021,6 +1004,22 @@ func writeMachinePSGRegister(t *testing.T, machine *Machine, reg, value byte) { } } +// keepDeviceIRQ rewires the CPU interrupt source to only the devices passed as +// true, for tests that isolate a fault to a single interrupt path. +func keepDeviceIRQ(m *Machine, glue, mfp, fdc bool) { + var src machineIRQ + if glue { + src.glue = m.glue + } + if mfp { + src.mfp = m.mfp + } + if fdc { + src.fdc = m.fdc + } + m.cpu.SetIRQSource(src) +} + func loopROM(code []byte) []byte { if len(code) == 0 { code = []byte{0x4E, 0x71, 0x60, 0xFE} diff --git a/internal/emulator/machine_trace.go b/internal/emulator/machine_trace.go index 0f26cf1..abe408b 100644 --- a/internal/emulator/machine_trace.go +++ b/internal/emulator/machine_trace.go @@ -4,7 +4,6 @@ import ( "fmt" "io" - "github.com/jenska/m68kdasm" cpu "github.com/jenska/m68kemu" ) @@ -193,11 +192,10 @@ func traceValueString(size cpu.Size, value uint32) string { } func (m *Machine) decodeTraceInstruction(info cpu.TraceInfo) string { - if len(info.Bytes) >= 2 { - inst, err := m68kdasm.Decode(append([]byte(nil), info.Bytes...), info.PC) - if err == nil { - return inst.Assembly() - } + // The core fills Mnemonic from the same decoder path this used to call + // directly; DisassembleInstruction is its bus-backed fallback. + if info.Mnemonic != "" { + return info.Mnemonic } inst, err := cpu.DisassembleInstruction(m.bus, info.PC) if err != nil {