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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,36 @@
- 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

- `github.com/jenska/m68kemu` updated to v1.5.0 for the `WithDeferredReset`,
`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

Expand Down
63 changes: 40 additions & 23 deletions docs/m68kemu-api-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
155 changes: 76 additions & 79 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 0 additions & 4 deletions internal/devices/acia.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading