diff --git a/README.md b/README.md index 2ed0320..8eb79da 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Apple II Emulator 是一个为 macOS 打造的原生 Apple II 系列模拟器。 - 主板磁带输入/输出(`$C060` / `$C020`)与 Apple II+ 四路 annunciator(`$C058-$C05F`)软开关; - 内置游戏库按标题首字母分组,并提供 Apple Writer、WordPerfect、VisiCalc、Copy II Plus 与 Apple Pascal 等独立“软件”启动项; - 菜单栏提供“ROM”“磁盘”“游戏”“软件”和“串口”:可选择内置 ROM、按需打开已验证的用户 ROM 映像,或独立装入磁盘映像。 +- “调试”菜单可显示实时 CPU 寄存器、周期和 Disk II 状态;暂停后“单步执行”仍走完整的 CPU 总线周期,不会跳过 I/O 软开关。该菜单也提供会话内的“快速保存状态”(Command-Shift-K)和“快速恢复状态”(Command-Shift-L):快照包含 CPU、主/辅 RAM、软开关、Disk II、SmartPort、Mockingboard、串口、鼠标和磁带的模拟硬件状态;恢复时会丢弃旧执行批次并按快照中的 ACIA 线路格式重新配置已连接的 macOS 串口。快速状态仅驻留内存,关闭应用后不会保留。 - ROM 菜单可额外打开用户有权使用的 ROM 映像:Apple II/II+ 12 KB,或 Apple IIc 16 KB / 32 KB;加载后会按映像类型切换机器并重置。 - 不依赖第三方库,可直接用 Xcode 打开 `Package.swift`。 @@ -33,7 +34,7 @@ swift run 若要构建带图标的本地 App,请运行 `make package-app`,然后打开 `build/AppleIIEmulator.app`。首次启动默认使用内置 Apple II+ Autostart ROM,显示经典 `APPLE ][` 开机画面;默认不插入磁盘。通过“游戏”菜单选择游戏会自动装入驱动器 1 并重新启动。测试启动盘仅保留在“磁盘”菜单中,用于验证 ROM、IWM、GCR 解码和 `$0801` 引导链。点一下屏幕即可输入;若要验证键盘回显,可在“ROM”菜单选择“内置诊断 ROM”。 -第三方 `.dsk` / `.do`(DOS 顺序)、`.d13`(13 扇区)、`.po`(ProDOS 顺序)、`.nib`、5¼ 英寸 `.2mg/.2img` 或 WOZ 1.x/2.1 位流映像可通过“磁盘”菜单独立装入驱动器 1 或 2;之后按 Command-R 重置即可由该 ROM 启动。SmartPort 硬盘是独立的 slot 7 设备,可从“磁盘”菜单装入 512 字节块的 `.po/.hdv/.img/.2mg/.2img` 映像。WOZ 会遵从其 `INFO` 写保护位;对可写 BITS 映像,IWM 写入保留在当前磁表面中。WOZ 2.1 的 FLUX 间隔以 125 ns tick 读取,并由驱动器上的数字锁相环恢复为控制器位单元;首次写入 FLUX 磁道会将该磁道恢复为 BITS 后继续写入,未修改的 FLUX 磁道仍按原始间隔保留。“磁盘 → 将驱动器 N 另存为 .woz…”会导出带 CRC、保留有效 BITS/FLUX 轨道与 quarter-track 映射的 WOZ 2 文件,且始终要求选择新路径,不会改写原始镜像;未修改磁表面时会保留 `META`、`WRIT` 和兼容的非磁表面扩展块,修改后会丢弃描述旧 BITS 校验的 `WRIT`。`.nib` 导出仍适用于标准 35 磁道 NIB 流。请只使用你有权使用的磁盘映像。 +第三方 `.dsk` / `.do`(DOS 顺序)、`.d13`(13 扇区)、`.po`(ProDOS 顺序)、`.nib`、5¼ 英寸 `.2mg/.2img` 或 WOZ 1.x/2.1 位流映像可通过“磁盘”菜单独立装入驱动器 1 或 2;之后按 Command-R 重置即可由该 ROM 启动。SmartPort 硬盘是独立的 slot 7 设备,可从“磁盘”菜单装入 512 字节块的 `.po/.hdv/.img/.2mg/.2img` 映像;在 Apple II+/IIe 上,“从 SmartPort 硬盘启动”会进入 `$C700` 卡 ROM,由该 ROM 将 ProDOS block 0 读入 `$0800` 后执行。WOZ 会遵从其 `INFO` 写保护位;对可写 BITS 映像,IWM 写入保留在当前磁表面中。WOZ 2.1 的 FLUX 间隔以 125 ns tick 读取,并由驱动器上的数字锁相环恢复为控制器位单元;首次写入 FLUX 磁道会将该磁道恢复为 BITS 工作表面,导出时再将修改的位单元重新量化为 FLUX,未修改的 FLUX 磁道仍按原始间隔保留。“磁盘 → 将驱动器 N 另存为 .woz…”会导出带 CRC、保留有效 BITS/FLUX 轨道与 quarter-track 映射的 WOZ 2 文件,且始终要求选择新路径,不会改写原始镜像;未修改磁表面时会保留 `META`、`WRIT` 和兼容的非磁表面扩展块,修改后会丢弃描述旧 BITS 校验的 `WRIT`。`.nib` 导出仍适用于标准 35 磁道 NIB 流。请只使用你有权使用的磁盘映像。 随 App 发布的游戏映像位于 `Sources/AppleIIEmulator/Resources/Games/`,构建时会打包进应用资源。可在窗口的 `GAME` 菜单或菜单栏“游戏”中选择“已下载游戏”,再按首字母选择游戏;它会装入驱动器 1,并以 Apple IIe 游戏兼容模式启动。发布后的 App 只枚举自身资源包,不依赖工作区的 `Downloads` 目录;“从已下载游戏库打开…”仍保留,供选择后来新增但尚未打包的本地映像。“最近玩过”会同时记录内置游戏和用户装入的一到两张启动磁盘,按最近顺序保留 8 项并去重;若磁盘文件后来移动或删除,重新打开该项时会自动移除失效记录。 @@ -41,4 +42,4 @@ swift run ## 下一阶段 -当前版本以启动和执行为目标。Apple IIc 的两个 6551 ACIA 可从“串口”菜单连接到用户选择的 macOS `/dev/cu.*` 设备;主机 I/O 在独立队列上运行,收发字节仍经 ACIA 寄存器和 6502 周期时序进入模拟器,并以 macOS PTY 端到端回归覆盖主机收发、设备 EOF/错误断开与重连安全。标准 macOS termios 未提供 3600-baud 档,选择该 ACIA 档位时会明确报错,不会以错误速率通信。Mockingboard 目前覆盖双 VIA/双 PSG 的常用音乐路径和 Timer 1/2 IRQ;尚未实现 Phasor、SSI-263 语音芯片及完整 VIA 移位寄存器。清理过的 WOZ 弱区按 MC3470 的四位延迟窗口处理,并在连续零单元后生成确定性的假脉冲;FLUX 使用以磁道为单位的数字锁相恢复,并会在写入前转换目标磁道为 BITS。仍未覆盖逐元件模拟的模拟读放大器、原生 FLUX 脉冲重新量化,以及接入真实外置串口硬件的人工兼容性验证。因此它不是逐周期、全外设覆盖的 Apple IIc 保真模拟器。 +当前版本以启动和执行为目标。Apple IIc 的两个 6551 ACIA 可从“串口”菜单连接到用户选择的 macOS `/dev/cu.*` 设备;主机 I/O 在独立队列上运行,收发字节仍经 ACIA 寄存器和 6502 周期时序进入模拟器,字长、奇偶校验、停止位和波特率也会同步到 macOS termios,并以 macOS PTY 端到端回归覆盖主机收发、设备 EOF/错误断开与重连安全。标准 macOS termios 未提供 3600-baud 档,选择该 ACIA 档位时会明确报错,不会以错误速率通信。Mockingboard 目前覆盖双 VIA/双 PSG 的常用音乐路径、Timer 1/2 IRQ 与 CPU/T2 时钟的 VIA 移位寄存器;尚未实现 Phasor 和 SSI-263 语音芯片。清理过的 WOZ 弱区按 MC3470 的四位延迟窗口处理,并在连续零单元后生成确定性的假脉冲;FLUX 使用以磁道为单位的数字锁相恢复,写入后会重新量化为 125 ns tick FLUX 间隔。仍未覆盖逐元件模拟的模拟读放大器,以及接入真实外置串口硬件的人工兼容性验证。因此它不是逐周期、全外设覆盖的 Apple IIc 保真模拟器。 diff --git a/Sources/AppleIIEmulator/ACIA6551.swift b/Sources/AppleIIEmulator/ACIA6551.swift index f81e121..4fbb5c5 100644 --- a/Sources/AppleIIEmulator/ACIA6551.swift +++ b/Sources/AppleIIEmulator/ACIA6551.swift @@ -1,10 +1,23 @@ import Foundation +struct SerialLineConfiguration: Equatable { + enum Parity: Equatable { case none, odd, even } + + let baudRate: Int + let dataBits: Int + let stopBits: Int + let parity: Parity +} + /// Synertek/MOS 6551 ACIA as used by the Apple IIc's built-in printer and /// modem ports. It models register-visible state and shifts one framed byte /// from the transmitter using the emulator's 6502 cycle clock; it deliberately /// has no AppKit or host-serial dependency. struct ACIA6551 { + struct State { + fileprivate let acia: ACIA6551 + fileprivate init(acia: ACIA6551) { self.acia = acia } + } private static let receiverDataFull: UInt8 = 0x08 private static let transmitterDataEmpty: UInt8 = 0x10 private static let interruptRequest: UInt8 = 0x80 @@ -31,6 +44,17 @@ struct ACIA6551 { private var transmitterBreakActive: Bool { command & 0x0C == 0x0C } var irqPending: Bool { receiveData != nil && receiverInterruptEnabled } var baudRate: Int { selectedBaudRate } + var lineConfiguration: SerialLineConfiguration { + SerialLineConfiguration( + baudRate: selectedBaudRate, + dataBits: selectedDataBits, + stopBits: control & 0x80 == 0 ? 1 : 2, + parity: selectedParity + ) + } + + func snapshot() -> State { State(acia: self) } + mutating func restore(_ snapshot: State) { self = snapshot.acia } mutating func reset() { command = 0 @@ -141,17 +165,27 @@ struct ACIA6551 { // duration reflects its visible word-length, parity and stop-bit // controls so software polling TDRE remains tied to 6502 cycles. let baud = selectedBaudRate - let dataBits: Int + let frameBits = 1 + selectedDataBits + (command & 0x03 == 0 ? 0 : 1) + (control & 0x80 == 0 ? 1 : 2) + return max(1, (1_021_800 * frameBits + baud - 1) / baud) + } + + private var selectedDataBits: Int { switch (control >> 5) & 0x03 { - case 0x01: dataBits = 7 - case 0x02: dataBits = 6 - case 0x03: dataBits = 5 - default: dataBits = 8 + case 0x01: return 7 + case 0x02: return 6 + case 0x03: return 5 + default: return 8 + } + } + + private var selectedParity: SerialLineConfiguration.Parity { + // Mark/space parity has no portable macOS termios representation. + // Keep their emulated framing distinct from a silently wrong host mode. + switch command & 0x03 { + case 0x01: return .odd + case 0x02: return .even + default: return .none } - let parityBits = command & 0x03 == 0 ? 0 : 1 - let stopBits = control & 0x80 == 0 ? 1 : 2 - let frameBits = 1 + dataBits + parityBits + stopBits - return max(1, (1_021_800 * frameBits + baud - 1) / baud) } private var selectedBaudRate: Int { diff --git a/Sources/AppleIIEmulator/AppleIICassette.swift b/Sources/AppleIIEmulator/AppleIICassette.swift index 890a573..be963bd 100644 --- a/Sources/AppleIIEmulator/AppleIICassette.swift +++ b/Sources/AppleIIEmulator/AppleIICassette.swift @@ -4,6 +4,10 @@ import Foundation /// durations before mounting it here; the memory bus then sees only the /// cassette comparator's level at `$C060`. struct AppleIICassetteInput { + struct State { + fileprivate let cassette: AppleIICassetteInput + fileprivate init(cassette: AppleIICassetteInput) { self.cassette = cassette } + } private var pulseDurations = [Int]() private var pulseIndex = 0 private var cyclesUntilEdge = 0 @@ -11,6 +15,9 @@ struct AppleIICassetteInput { var isMounted: Bool { !pulseDurations.isEmpty } + func snapshot() -> State { State(cassette: self) } + mutating func restore(_ snapshot: State) { self = snapshot.cassette } + mutating func mount(pulseDurations: [Int], initialLevel: Bool = false) { self.pulseDurations = pulseDurations.filter { $0 > 0 } pulseIndex = 0 diff --git a/Sources/AppleIIEmulator/AppleIIEmulatorApp.swift b/Sources/AppleIIEmulator/AppleIIEmulatorApp.swift index d6bb011..8123940 100644 --- a/Sources/AppleIIEmulator/AppleIIEmulatorApp.swift +++ b/Sources/AppleIIEmulator/AppleIIEmulatorApp.swift @@ -92,6 +92,8 @@ struct AppleIIEmulatorApp: App { Button("弹出磁带") { machine.ejectCassette() } Divider() Button("装入 SmartPort 硬盘映像…") { machine.chooseHardDiskImage() } + Button("从 SmartPort 硬盘启动") { machine.bootFromHardDisk() } + .disabled(!machine.canBootHardDisk) Button("推出 SmartPort 硬盘") { machine.ejectHardDisk() } .disabled(machine.hardDiskDescription == "未插入") } @@ -133,6 +135,19 @@ struct AppleIIEmulatorApp: App { serialPortMenu(title: "端口 1(打印机)", port: 1, connectedDevice: machine.serialPort1Device) serialPortMenu(title: "端口 2(调制解调器)", port: 2, connectedDevice: machine.serialPort2Device) } + CommandMenu("调试") { + Button(machine.isDebuggerVisible ? "隐藏调试监视器" : "显示调试监视器") { + machine.isDebuggerVisible.toggle() + } + Button("单步执行") { machine.stepInstruction() } + .disabled(machine.isRunning) + Divider() + Button("快速保存状态") { machine.saveQuickState() } + .keyboardShortcut("k", modifiers: [.command, .shift]) + Button("快速恢复状态") { machine.restoreQuickState() } + .keyboardShortcut("l", modifiers: [.command, .shift]) + .disabled(!machine.hasQuickState) + } CommandGroup(after: .newItem) { Button("重置") { machine.reset() } .keyboardShortcut("r", modifiers: .command) diff --git a/Sources/AppleIIEmulator/AppleIIMachine.swift b/Sources/AppleIIEmulator/AppleIIMachine.swift index 5ba8306..f9ca452 100644 --- a/Sources/AppleIIEmulator/AppleIIMachine.swift +++ b/Sources/AppleIIEmulator/AppleIIMachine.swift @@ -252,6 +252,25 @@ final class AppleIIMachine: ObservableObject { case software } + /// An in-memory, session-only machine image. This deliberately captures + /// emulated hardware state rather than host objects such as serial file + /// descriptors, Core Audio callbacks, or security-scoped URLs. + private struct QuickState { + let cpu: MOS6502.State + let memory: AppleIIMemory.State + let wasRunning: Bool + let wasAccelerated: Bool + let selectedBootROM: BootROM + let hasExternalROM: Bool + let externalROMName: String? + let diskDescription: String + let externalDiskDescription: String + let hardDiskDescription: String + let activeMediaKind: ActiveMediaKind + let wizardryScenarioDiskData: Data? + let wizardryScenarioPromptHandled: Bool + } + /// A playable item in the recent-games menu. Disk games retain every /// image selected for their initial boot, so a two-disk game reopens with /// the same drive layout instead of silently losing its second disk. @@ -314,6 +333,11 @@ final class AppleIIMachine: ObservableObject { /// without synchronously querying the emulation queue from the UI. @Published private(set) var presentedCPUCycles = 0 @Published private(set) var presentedProgramCounter: UInt16 = 0 + @Published private(set) var debuggerSnapshot = MOS6502DebugSnapshot( + a: 0, x: 0, y: 0, sp: 0xFD, pc: 0, p: 0x24, cycles: 0 + ) + @Published var isDebuggerVisible = false + @Published private(set) var hasQuickState = false @Published private(set) var presentedDiskState = DiskIIDebugSnapshot( motorOn: false, selectedDrive: 0, @@ -379,6 +403,7 @@ final class AppleIIMachine: ObservableObject { /// A diskless Apple II+ reaches ROM Applesoft through the Autostart ROM's /// warm-reset path after it has shown the power-on banner. private var applesoftWarmStartDeadline: TimeInterval? + private var quickState: QuickState? var currentROMTitle: String { externalROMName ?? selectedBootROM.title } var supportsIIcSerial: Bool { @@ -472,7 +497,10 @@ final class AppleIIMachine: ObservableObject { } if startsRuntimeTimer ?? !Self.isAutomatedRun { timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - Task { @MainActor in self?.tick() } + // Capture the weak reference in the task itself. Swift 6 + // rejects forwarding the outer closure's mutable weak + // capture into a concurrently-executing task. + Task { @MainActor [weak self] in self?.tick() } } } } @@ -525,15 +553,16 @@ final class AppleIIMachine: ObservableObject { ) let serialPort1Bytes = memory.drainTransmittedSerialBytes(port: 1) let serialPort2Bytes = memory.drainTransmittedSerialBytes(port: 2) - let serialPort1Baud = memory.serialBaudRate(port: 1) - let serialPort2Baud = memory.serialBaudRate(port: 2) + let serialPort1Configuration = memory.serialLineConfiguration(port: 1) + let serialPort2Configuration = memory.serialLineConfiguration(port: 2) let totalCycles = cpu.totalCycles let programCounter = cpu.pc + let debuggerSnapshot = cpu.debugSnapshot let diskState = memory.diskDebugSnapshot let snapshot = memory.makeVideoSnapshot() lock.unlock() - serialBridge.setBaudRate(serialPort1Baud, port: 1) - serialBridge.setBaudRate(serialPort2Baud, port: 2) + serialBridge.setConfiguration(serialPort1Configuration, port: 1) + serialBridge.setConfiguration(serialPort2Configuration, port: 2) serialBridge.send(serialPort1Bytes, port: 1) serialBridge.send(serialPort2Bytes, port: 2) DispatchQueue.main.async { [weak self] in @@ -547,6 +576,7 @@ final class AppleIIMachine: ObservableObject { self.videoSnapshot = snapshot self.presentedCPUCycles = totalCycles self.presentedProgramCounter = programCounter + self.debuggerSnapshot = debuggerSnapshot self.presentedDiskState = diskState self.advanceWizardryScenarioPromptIfNeeded(snapshot) self.refreshToken &+= 1 @@ -583,16 +613,17 @@ final class AppleIIMachine: ObservableObject { /// Narrow diagnostic seam for regression tests: it runs the same CPU and /// bus path as the display timer without needing an AppKit run loop. func runForVerification(cycles: Int) { - let snapshot = withEmulationLock { + let result = withEmulationLock { cpu.run(cycles: cycles) speaker.advance( toEmulatedCycle: cpu.totalCycles, auxiliarySamples: memory.renderMockingboardAudio(toEmulatedCycle: cpu.totalCycles) ) - return memory.makeVideoSnapshot() + return (memory.makeVideoSnapshot(), cpu.debugSnapshot) } - videoSnapshot = snapshot - advanceWizardryScenarioPromptIfNeeded(snapshot) + videoSnapshot = result.0 + debuggerSnapshot = result.1 + advanceWizardryScenarioPromptIfNeeded(result.0) refreshToken &+= 1 } @@ -673,6 +704,101 @@ final class AppleIIMachine: ObservableObject { lastEmulationTick = ProcessInfo.processInfo.systemUptime } + func stepInstruction() { + guard !isRunning, !cpuSliceQueued else { + if isRunning { status = "请先暂停后再单步执行" } + return + } + let result = withEmulationLock { () -> (AppleIIVideoSnapshot, MOS6502DebugSnapshot, DiskIIDebugSnapshot, [UInt8], [UInt8], SerialLineConfiguration, SerialLineConfiguration) in + _ = cpu.runOneInstruction() + speaker.advance( + toEmulatedCycle: cpu.totalCycles, + auxiliarySamples: memory.renderMockingboardAudio(toEmulatedCycle: cpu.totalCycles) + ) + return ( + memory.makeVideoSnapshot(), cpu.debugSnapshot, memory.diskDebugSnapshot, + memory.drainTransmittedSerialBytes(port: 1), memory.drainTransmittedSerialBytes(port: 2), + memory.serialLineConfiguration(port: 1), memory.serialLineConfiguration(port: 2) + ) + } + serialBridge.setConfiguration(result.5, port: 1) + serialBridge.setConfiguration(result.6, port: 2) + serialBridge.send(result.3, port: 1) + serialBridge.send(result.4, port: 2) + videoSnapshot = result.0 + debuggerSnapshot = result.1 + presentedCPUCycles = result.1.cycles + presentedProgramCounter = result.1.pc + presentedDiskState = result.2 + status = "单步执行 · PC=$\(String(result.1.pc, radix: 16).uppercased())" + refreshToken &+= 1 + } + + func saveQuickState() { + let state = withEmulationLock { () -> QuickState in + QuickState( + cpu: cpu.snapshot(), memory: memory.snapshot(), + wasRunning: isRunning, wasAccelerated: isCPUAccelerated, + selectedBootROM: selectedBootROM, hasExternalROM: hasExternalROM, + externalROMName: externalROMName, diskDescription: diskDescription, + externalDiskDescription: externalDiskDescription, + hardDiskDescription: hardDiskDescription, activeMediaKind: activeMediaKind, + wizardryScenarioDiskData: wizardryScenarioDiskData, + wizardryScenarioPromptHandled: wizardryScenarioPromptHandled + ) + } + quickState = state + hasQuickState = true + status = "已快速保存状态 · PC=$\(String(debuggerSnapshot.pc, radix: 16).uppercased())" + } + + func restoreQuickState() { + guard let quickState else { + status = "尚未保存快速状态" + return + } + + // A completed older slice is allowed to publish only if it belongs to + // this generation. Restoring before it gets back to the main actor + // prevents stale video, CPU and disk snapshots overwriting this one. + executionGeneration &+= 1 + isRunning = false + applesoftWarmStartDeadline = nil + let result = withEmulationLock { () -> (AppleIIVideoSnapshot, MOS6502DebugSnapshot, DiskIIDebugSnapshot, SerialLineConfiguration, SerialLineConfiguration, Int) in + memory.restore(quickState.memory) + cpu.restore(quickState.cpu) + speaker.resetWaveform(toEmulatedCycle: cpu.totalCycles, flipCount: memory.speakerFlipCount) + return ( + memory.makeVideoSnapshot(), cpu.debugSnapshot, memory.diskDebugSnapshot, + memory.serialLineConfiguration(port: 1), memory.serialLineConfiguration(port: 2), + cpu.totalCycles + ) + } + serialBridge.setConfiguration(result.3, port: 1) + serialBridge.setConfiguration(result.4, port: 2) + selectedBootROM = quickState.selectedBootROM + hasExternalROM = quickState.hasExternalROM + externalROMName = quickState.externalROMName + diskDescription = quickState.diskDescription + externalDiskDescription = quickState.externalDiskDescription + hardDiskDescription = quickState.hardDiskDescription + activeMediaKind = quickState.activeMediaKind + wizardryScenarioDiskData = quickState.wizardryScenarioDiskData + wizardryScenarioPromptHandled = quickState.wizardryScenarioPromptHandled + isCPUAccelerated = quickState.wasAccelerated + cpuSliceQueued = false + fractionalCPUCycles = 0 + lastEmulationTick = ProcessInfo.processInfo.systemUptime + videoSnapshot = result.0 + debuggerSnapshot = result.1 + presentedCPUCycles = result.5 + presentedProgramCounter = result.1.pc + presentedDiskState = result.2 + isRunning = quickState.wasRunning + status = "已恢复快速状态 · PC=$\(String(result.1.pc, radix: 16).uppercased())" + refreshToken &+= 1 + } + func refreshSerialDevices() { serialBridge.refreshDevices() } func connectSerialDevice(_ path: String, port: Int) { @@ -680,9 +806,9 @@ final class AppleIIMachine: ObservableObject { status = "串口仅适用于 Apple IIc ROM" return } - // The next CPU slice applies the ACIA's current control-register - // rate. Start at the reset-default 9600 to avoid waiting on the UI. - serialBridge.connect(path: path, port: port, baudRate: 9_600) + serialBridge.connect(path: path, port: port, configuration: withEmulationLock { + memory.serialLineConfiguration(port: port) + }) } func disconnectSerialDevice(port: Int) { serialBridge.disconnect(port: port) } @@ -1065,16 +1191,20 @@ final class AppleIIMachine: ObservableObject { return (drive: drive, disk: disk, data: try Data(contentsOf: url)) } let rom = try AppleIIROMImages.iiE(software.bootROM) - let wordPerfectWorkDisk: (url: URL, data: Data)? + // The persistent work disk is a NIB export, but a first + // launch seeds it from the bundled DSK. Keep the source + // format with the bytes; parsing new DSK bytes as the future + // `.nib` destination made Drive 2 fail only on clean hosts. + let wordPerfectWorkDisk: (url: URL, data: Data, sourceExtension: String)? if software == .wordPerfect11 { let persistentURL = try Self.wordPerfectWorkDiskStorageURL() if FileManager.default.fileExists(atPath: persistentURL.path) { - wordPerfectWorkDisk = (persistentURL, try Data(contentsOf: persistentURL)) + wordPerfectWorkDisk = (persistentURL, try Data(contentsOf: persistentURL), persistentURL.pathExtension) } else { guard let bundledURL = AppResources.bundle.url(forResource: "WordPerfect 1.1 Work Disk", withExtension: "dsk") else { throw CocoaError(.fileNoSuchFile) } - wordPerfectWorkDisk = (persistentURL, try Data(contentsOf: bundledURL)) + wordPerfectWorkDisk = (persistentURL, try Data(contentsOf: bundledURL), bundledURL.pathExtension) } } else { wordPerfectWorkDisk = nil @@ -1092,7 +1222,7 @@ final class AppleIIMachine: ObservableObject { ) } if let workDisk = wordPerfectWorkDisk { - try memory.mountDiskImageData(workDisk.data, fileExtension: workDisk.url.pathExtension, drive: 1) + try memory.mountDiskImageData(workDisk.data, fileExtension: workDisk.sourceExtension, drive: 1) } let bootsDriveOne = memory.hasDisk(in: 0) memory.coldBootSystemState() @@ -1416,6 +1546,32 @@ final class AppleIIMachine: ObservableObject { status = "\(currentROMTitle) · SmartPort 硬盘未插入" } + /// Enters the slot-seven card ROM just as `PR#7` does on an Apple II+ or + /// IIe. The ROM itself performs the block transfer; the UI never copies + /// disk data into RAM or jumps directly to the payload. + func bootFromHardDisk() { + guard hardDiskDescription != "未插入", canBootHardDisk else { + status = "SmartPort 启动需要已插入硬盘及 Apple II+/IIe ROM" + return + } + let result = withEmulationLock { () -> AppleIIVideoSnapshot in + memory.coldBootSystemState() + cpu.reset() + cpu.start(at: 0xC700) + return memory.makeVideoSnapshot() + } + isRunning = true + fractionalCPUCycles = 0 + lastEmulationTick = ProcessInfo.processInfo.systemUptime + videoSnapshot = result + status = "\(currentROMTitle) · 正在经 slot 7 启动 SmartPort 硬盘" + refreshToken &+= 1 + } + + var canBootHardDisk: Bool { + hardDiskDescription != "未插入" && withEmulationLock { memory.supportsSmartPortSlotROM } + } + func ejectDisk(drive: Int = 0) { if drive == 1 { persistWordPerfectWorkDisk() } withEmulationLock { memory.ejectDisk(drive: drive) } @@ -1512,6 +1668,42 @@ final class AppleIIMachine: ObservableObject { } final class AppleIIMemory: AppleIIBus, @unchecked Sendable { + struct State { + fileprivate let bytes: [UInt8], auxiliaryBytes: [UInt8] + fileprivate let languageCardBank1: [UInt8], languageCardBank2: [UInt8], languageCardHigh: [UInt8] + fileprivate let auxiliaryLanguageCardBank1: [UInt8], auxiliaryLanguageCardBank2: [UInt8], auxiliaryLanguageCardHigh: [UInt8] + fileprivate let languageCardRAMRead: Bool, languageCardBank2Selected: Bool, languageCardWriteArmed: Bool, languageCardWriteEnabled: Bool + fileprivate let iicROM: [UInt8], iicROMBank: Int, iieROM: [UInt8], plusSlot6ROM: [UInt8], plusDiskFirmware: DiskIIFirmware? + fileprivate let model: Model, supportsMouseText: Bool, keyLatch: UInt8 + fileprivate let textMode: Bool, mixedMode: Bool, page2: Bool, hires: Bool, column80: Bool, store80: Bool + fileprivate let ramReadAuxiliary: Bool, ramWriteAuxiliary: Bool, internalCXROM: Bool, slot3ROM: Bool + fileprivate let doubleHires: Bool, alternateZeroPage: Bool, alternateCharset: Bool + fileprivate let disk: IWMController.State, smartPort: SmartPortController.State, mockingboard: MockingboardController.State + fileprivate let serialPort1: ACIA6551.State, serialPort2: ACIA6551.State, mouse: AppleIIMouseInterface.State + fileprivate let speakerFlips: Int, speakerCycle: Int, cassetteInput: Bool, cassetteTransport: AppleIICassetteInput.State + fileprivate let cassetteOutput: Bool, cassetteOutputEdges: [Int], annunciators: [Bool] + fileprivate let videoClock: Int, iiCVBLFlag: Bool, iicIOUDisabled: Bool, iicVBLEnabled: Bool + fileprivate let paddleElapsedCycles: Int, paddles: [UInt8], openAppleDown: Bool, closedAppleDown: Bool + + fileprivate init(memory: AppleIIMemory) { + bytes = memory.bytes; auxiliaryBytes = memory.auxiliaryBytes + languageCardBank1 = memory.languageCardBank1; languageCardBank2 = memory.languageCardBank2; languageCardHigh = memory.languageCardHigh + auxiliaryLanguageCardBank1 = memory.auxiliaryLanguageCardBank1; auxiliaryLanguageCardBank2 = memory.auxiliaryLanguageCardBank2; auxiliaryLanguageCardHigh = memory.auxiliaryLanguageCardHigh + languageCardRAMRead = memory.languageCardRAMRead; languageCardBank2Selected = memory.languageCardBank2Selected + languageCardWriteArmed = memory.languageCardWriteArmed; languageCardWriteEnabled = memory.languageCardWriteEnabled + iicROM = memory.iicROM; iicROMBank = memory.iicROMBank; iieROM = memory.iieROM; plusSlot6ROM = memory.plusSlot6ROM; plusDiskFirmware = memory.plusDiskFirmware + model = memory.model; supportsMouseText = memory.supportsMouseText; keyLatch = memory.keyLatch + textMode = memory.textMode; mixedMode = memory.mixedMode; page2 = memory.page2; hires = memory.hires; column80 = memory.column80; store80 = memory.store80 + ramReadAuxiliary = memory.ramReadAuxiliary; ramWriteAuxiliary = memory.ramWriteAuxiliary; internalCXROM = memory.internalCXROM; slot3ROM = memory.slot3ROM + doubleHires = memory.doubleHires; alternateZeroPage = memory.alternateZeroPage; alternateCharset = memory.alternateCharset + disk = memory.diskController.snapshot(); smartPort = memory.smartPortController.snapshot(); mockingboard = memory.mockingboardController.snapshot() + serialPort1 = memory.serialPort1.snapshot(); serialPort2 = memory.serialPort2.snapshot(); mouse = memory.mouseController.snapshot() + speakerFlips = memory.speakerFlips; speakerCycle = memory.speakerCycle; cassetteInput = memory.cassetteInput; cassetteTransport = memory.cassetteTransport.snapshot() + cassetteOutput = memory.cassetteOutput; cassetteOutputEdges = memory.cassetteOutputEdges; annunciators = memory.annunciators + videoClock = memory.videoClock; iiCVBLFlag = memory.iiCVBLFlag; iicIOUDisabled = memory.iicIOUDisabled; iicVBLEnabled = memory.iicVBLEnabled + paddleElapsedCycles = memory.paddleElapsedCycles; paddles = memory.paddles; openAppleDown = memory.openAppleDown; closedAppleDown = memory.closedAppleDown + } + } enum Model: Equatable { case appleIIPlus, appleIIc, appleIIe } enum DiskIIFirmware: Equatable { case thirteenSector, sixteenSector @@ -1558,6 +1750,27 @@ final class AppleIIMemory: AppleIIBus, @unchecked Sendable { case .appleIIe: return "Apple IIe" } } + + func snapshot() -> State { State(memory: self) } + + func restore(_ state: State) { + bytes = state.bytes; auxiliaryBytes = state.auxiliaryBytes + languageCardBank1 = state.languageCardBank1; languageCardBank2 = state.languageCardBank2; languageCardHigh = state.languageCardHigh + auxiliaryLanguageCardBank1 = state.auxiliaryLanguageCardBank1; auxiliaryLanguageCardBank2 = state.auxiliaryLanguageCardBank2; auxiliaryLanguageCardHigh = state.auxiliaryLanguageCardHigh + languageCardRAMRead = state.languageCardRAMRead; languageCardBank2Selected = state.languageCardBank2Selected + languageCardWriteArmed = state.languageCardWriteArmed; languageCardWriteEnabled = state.languageCardWriteEnabled + iicROM = state.iicROM; iicROMBank = state.iicROMBank; iieROM = state.iieROM; plusSlot6ROM = state.plusSlot6ROM; plusDiskFirmware = state.plusDiskFirmware + model = state.model; supportsMouseText = state.supportsMouseText; keyLatch = state.keyLatch + textMode = state.textMode; mixedMode = state.mixedMode; page2 = state.page2; hires = state.hires; column80 = state.column80; store80 = state.store80 + ramReadAuxiliary = state.ramReadAuxiliary; ramWriteAuxiliary = state.ramWriteAuxiliary; internalCXROM = state.internalCXROM; slot3ROM = state.slot3ROM + doubleHires = state.doubleHires; alternateZeroPage = state.alternateZeroPage; alternateCharset = state.alternateCharset + diskController.restore(state.disk); smartPortController.restore(state.smartPort); mockingboardController.restore(state.mockingboard) + serialPort1.restore(state.serialPort1); serialPort2.restore(state.serialPort2); mouseController.restore(state.mouse) + speakerFlips = state.speakerFlips; speakerCycle = state.speakerCycle; cassetteInput = state.cassetteInput; cassetteTransport.restore(state.cassetteTransport) + cassetteOutput = state.cassetteOutput; cassetteOutputEdges = state.cassetteOutputEdges; annunciators = state.annunciators + videoClock = state.videoClock; iiCVBLFlag = state.iiCVBLFlag; iicIOUDisabled = state.iicIOUDisabled; iicVBLEnabled = state.iicVBLEnabled + paddleElapsedCycles = state.paddleElapsedCycles; paddles = state.paddles; openAppleDown = state.openAppleDown; closedAppleDown = state.closedAppleDown + } private var keyLatch: UInt8 = 0 private(set) var textMode = true private(set) var mixedMode = false @@ -1581,6 +1794,7 @@ final class AppleIIMemory: AppleIIBus, @unchecked Sendable { private var serialPort2 = ACIA6551() private var mouseController = AppleIIMouseInterface() private(set) var speakerFlips = 0 + var speakerFlipCount: Int { speakerFlips } var speakerDidToggle: (() -> Void)? var speakerDidToggleAtCycle: ((Int) -> Void)? private var speakerCycle = 0 @@ -1738,6 +1952,9 @@ final class AppleIIMemory: AppleIIBus, @unchecked Sendable { if (model == .appleIIPlus || model == .appleIIe), (0xC600...0xC6FF).contains(a), !internalCXROM, plusSlot6ROM.count == 0x100 { return plusSlot6ROM[a - 0xC600] } + if (model == .appleIIPlus || model == .appleIIe), (0xC700...0xC7FF).contains(a), !internalCXROM { + return smartPortController.romByte(at: a - 0xC700) + } if model != .appleIIc, a >= 0xD000, languageCardRAMRead { return languageCardByte(at: a) } @@ -1814,6 +2031,7 @@ final class AppleIIMemory: AppleIIBus, @unchecked Sendable { default: if model == .appleIIc, a >= 0xC100 { return } if (model == .appleIIPlus || model == .appleIIe), (0xC600...0xC6FF).contains(a), !internalCXROM { return } + if (model == .appleIIPlus || model == .appleIIe), (0xC700...0xC7FF).contains(a), !internalCXROM { return } if model != .appleIIc, a >= 0xD000 { if languageCardWriteEnabled { setLanguageCardByte(value, at: a) } return @@ -2138,6 +2356,7 @@ final class AppleIIMemory: AppleIIBus, @unchecked Sendable { } func ejectHardDisk(drive: Int = 0) { smartPortController.eject(drive: drive) } func hasHardDisk(in drive: Int) -> Bool { smartPortController.hasDisk(in: drive) } + var supportsSmartPortSlotROM: Bool { model != .appleIIc } func hardDiskBlockCount(in drive: Int) -> Int { smartPortController.blockCount(in: drive) } func hardDiskImage(drive: Int = 0) -> Data? { smartPortController.imageData(drive: drive) } @@ -2160,6 +2379,12 @@ final class AppleIIMemory: AppleIIBus, @unchecked Sendable { return 9_600 } + func serialLineConfiguration(port: Int) -> SerialLineConfiguration { + if port == 1 { return serialPort1.lineConfiguration } + if port == 2 { return serialPort2.lineConfiguration } + return SerialLineConfiguration(baudRate: 9_600, dataBits: 8, stopBits: 1, parity: .none) + } + func renderMockingboardAudio(toEmulatedCycle cycle: Int) -> [Float] { mockingboardController.renderAudio(toEmulatedCycle: cycle) } diff --git a/Sources/AppleIIEmulator/AppleIIMockingboard.swift b/Sources/AppleIIEmulator/AppleIIMockingboard.swift index 0db73fc..ba36810 100644 --- a/Sources/AppleIIEmulator/AppleIIMockingboard.swift +++ b/Sources/AppleIIEmulator/AppleIIMockingboard.swift @@ -4,11 +4,26 @@ import Foundation /// each driving an AY-3-8913. This deliberately keeps the card at its real /// I/O addresses instead of treating music as a UI-side audio effect. final class MockingboardController { + struct State { + fileprivate let vias: [VIA6522] + fileprivate let chips: [AYChip] + fileprivate let events: [AudioEvent] + fileprivate let nextEvent: Int + fileprivate let renderCycle: Double + fileprivate let audioChips: [AudioChip] + fileprivate init( + vias: [VIA6522], chips: [AYChip], events: [AudioEvent], nextEvent: Int, + renderCycle: Double, audioChips: [AudioChip] + ) { + self.vias = vias; self.chips = chips; self.events = events + self.nextEvent = nextEvent; self.renderCycle = renderCycle; self.audioChips = audioChips + } + } private static let sampleRate = 44_100.0 private static let cyclesPerSecond = 1_021_800.0 private static let ayClock = 1_021_800.0 - private struct VIA6522 { + fileprivate struct VIA6522 { var ora: UInt8 = 0 var orb: UInt8 = 0 var ddra: UInt8 = 0 @@ -21,6 +36,9 @@ final class MockingboardController { var timer1Counter: Int? var timer2Latch: UInt16 = 0 var timer2Counter: Int? + var shiftRegister: UInt8 = 0 + var shiftBitsRemaining = 0 + var shiftCycles = 0 var irqPending: Bool { ifr & ier & 0x7F != 0 } @@ -37,6 +55,7 @@ final class MockingboardController { var timer2Counter = timer2Counter advanceTimer(&timer2Counter, latch: timer2Latch, flag: 0x20, continuous: false, cycles: cycles) self.timer2Counter = timer2Counter + advanceShiftRegister(by: cycles) } mutating func read(_ register: Int, portAInput: UInt8) -> UInt8 { @@ -55,6 +74,9 @@ final class MockingboardController { ifr &= ~0x20 return UInt8(truncatingIfNeeded: timer2Counter ?? 0) case 9: return UInt8(truncatingIfNeeded: (timer2Counter ?? 0) >> 8) + case 10: + ifr &= ~0x04 + return shiftRegister case 11: return acr case 12: return pcr case 13: return ifr | (irqPending ? 0x80 : 0) @@ -81,6 +103,11 @@ final class MockingboardController { timer2Latch = (timer2Latch & 0x00FF) | UInt16(value) << 8 timer2Counter = Int(timer2Latch) + 1 ifr &= ~0x20 + case 10: + shiftRegister = value + shiftBitsRemaining = 8 + shiftCycles = 0 + ifr &= ~0x04 case 11: acr = value case 12: pcr = value case 13: ifr &= ~(value & 0x7F) @@ -107,9 +134,30 @@ final class MockingboardController { } counter = remaining } + + private mutating func advanceShiftRegister(by cycles: Int) { + // ACR bits 4...2 select the 6522 shift mode. External-clock + // modes have no synthetic edge source; phi2 modes clock once per + // CPU cycle and T2 modes clock from their programmed latch. + let mode = (acr >> 2) & 0x07 + guard mode != 0, shiftBitsRemaining > 0 else { return } + guard mode != 3, mode != 7 else { return } + let period = (mode == 2 || mode == 6) ? 1 : max(1, Int(timer2Latch) + 1) + shiftCycles += cycles + while shiftCycles >= period, shiftBitsRemaining > 0 { + shiftCycles -= period + if mode >= 4 { + shiftRegister <<= 1 // shift-out serial data on CB2 + } else { + shiftRegister = (shiftRegister << 1) | 1 // idle-high CB2 input + } + shiftBitsRemaining -= 1 + } + if shiftBitsRemaining == 0 { ifr |= 0x04 } + } } - private struct AYChip { + fileprivate struct AYChip { var registers = [UInt8](repeating: 0, count: 16) var selectedRegister: Int? var busState: UInt8 = 0 @@ -150,7 +198,7 @@ final class MockingboardController { } } - private struct AudioChip { + fileprivate struct AudioChip { var registers = [UInt8](repeating: 0, count: 16) var tonePhase = [Double](repeating: 0, count: 3) var noisePhase = 0.0 @@ -214,7 +262,7 @@ final class MockingboardController { } } - private struct AudioEvent { + fileprivate struct AudioEvent { let cycle: Int let registers: [[UInt8]] } @@ -228,6 +276,15 @@ final class MockingboardController { var irqPending: Bool { vias.contains { $0.irqPending } } + func snapshot() -> State { + State(vias: vias, chips: chips, events: events, nextEvent: nextEvent, renderCycle: renderCycle, audioChips: audioChips) + } + + func restore(_ state: State) { + vias = state.vias; chips = state.chips; events = state.events + nextEvent = state.nextEvent; renderCycle = state.renderCycle; audioChips = state.audioChips + } + func reset() { vias.indices.forEach { vias[$0].reset() } chips.indices.forEach { chips[$0].reset() } diff --git a/Sources/AppleIIEmulator/AppleIIMouse.swift b/Sources/AppleIIEmulator/AppleIIMouse.swift index 5e3059b..c3e94e3 100644 --- a/Sources/AppleIIEmulator/AppleIIMouse.swift +++ b/Sources/AppleIIEmulator/AppleIIMouse.swift @@ -4,6 +4,10 @@ import Foundation /// its built-in mouse port; its ROM communicates through an MC6821 PIA rather /// than reading host coordinates directly. struct AppleIIMouseInterface { + struct State { + fileprivate let mouse: AppleIIMouseInterface + fileprivate init(mouse: AppleIIMouseInterface) { self.mouse = mouse } + } private struct PIA6821 { var portAOutput: UInt8 = 0 var portBOutput: UInt8 = 0 @@ -66,6 +70,9 @@ struct AppleIIMouseInterface { var irqPending: Bool { state & Self.interruptMask != 0 } + func snapshot() -> State { State(mouse: self) } + mutating func restore(_ snapshot: State) { self = snapshot.mouse } + mutating func reset() { pia = PIA6821() latchedPortB = 0x40 diff --git a/Sources/AppleIIEmulator/AppleIISpeaker.swift b/Sources/AppleIIEmulator/AppleIISpeaker.swift index 0d9b670..9680daf 100644 --- a/Sources/AppleIIEmulator/AppleIISpeaker.swift +++ b/Sources/AppleIIEmulator/AppleIISpeaker.swift @@ -23,6 +23,14 @@ struct AppleIISpeakerWaveform { edgeCycles.append(cycle) } + mutating func reset(toEmulatedCycle cycle: Int, flipCount: Int) { + edgeCycles.removeAll(keepingCapacity: true) + nextEdge = 0 + renderedLevel = flipCount & 1 == 0 ? -1 : 1 + renderCycle = Double(cycle) + dcEstimate = 0 + } + /// Convert every whole output sample ending at or before `cycle`. mutating func render(toEmulatedCycle cycle: Int) -> [Float] { guard cycle > 0 else { return [] } @@ -140,6 +148,14 @@ final class AppleIISpeaker: @unchecked Sendable { waveform.toggle(atEmulatedCycle: cycle) } + /// A machine-state restore changes the emulated timeline discontinuously. + /// Discard the producer-side edge history so PCM is never synthesized + /// across two unrelated moments in Apple II time. The bounded FIFO may + /// still drain at most its existing short host-audio lead. + func resetWaveform(toEmulatedCycle cycle: Int, flipCount: Int) { + waveform.reset(toEmulatedCycle: cycle, flipCount: flipCount) + } + /// Convert all fully elapsed Apple II cycles to PCM after each CPU slice. func advance(toEmulatedCycle cycle: Int, auxiliarySamples: [Float] = []) { var samples = waveform.render(toEmulatedCycle: cycle) diff --git a/Sources/AppleIIEmulator/DiskDrive.swift b/Sources/AppleIIEmulator/DiskDrive.swift index b7ec7c7..898ba2e 100644 --- a/Sources/AppleIIEmulator/DiskDrive.swift +++ b/Sources/AppleIIEmulator/DiskDrive.swift @@ -49,6 +49,11 @@ struct DiskDrive { /// deliberately blank or overlapping tracks used by protected disks. var quarterTrackMap = [Int]() var fluxQuarterTrackMap = [Int]() + /// Runtime writes use a recovered BITS working surface, while WOZ Save As + /// must retain the original FLUX addressing and re-quantize that changed + /// surface. This map is immutable media geometry, not a live head map. + var originalFluxQuarterTrackMap = [Int]() + var modifiedFluxTracks = [Bool]() var wozContainer: WOZContainerMetadata? var isThirteenSector = false var isWriteProtected = false @@ -80,6 +85,8 @@ struct DiskDrive { fluxTracks.removeAll() quarterTrackMap.removeAll() fluxQuarterTrackMap.removeAll() + originalFluxQuarterTrackMap.removeAll() + modifiedFluxTracks.removeAll() wozContainer = nil isThirteenSector = false isWriteProtected = false @@ -111,6 +118,8 @@ struct DiskDrive { self.fluxTracks = fluxTracks self.quarterTrackMap = quarterTrackMap self.fluxQuarterTrackMap = fluxQuarterTrackMap + self.originalFluxQuarterTrackMap = fluxQuarterTrackMap + self.modifiedFluxTracks = Array(repeating: false, count: fluxTracks.count) self.wozContainer = wozContainer self.isThirteenSector = thirteenSector self.isWriteProtected = writeProtected @@ -160,8 +169,8 @@ struct DiskDrive { /// A flux capture has no intrinsic writable bit cells. Before the IWM /// changes one, recover a complete revolution into a BITS track and move /// each FLUX-mapped quarter track onto that new surface. This is a - /// deliberate one-way conversion: a later WOZ export retains untouched - /// FLUX tracks but writes the altered track as conventional BITS data. + /// The working copy is BITS so the IWM can address individual cells. A + /// later WOZ export re-quantizes only this changed track back to FLUX. mutating func materializeFluxTrack(_ track: Int) -> Bool { guard fluxTracks.indices.contains(track), let stream = fluxTracks[track], !stream.bytes.isEmpty else { return false @@ -192,7 +201,7 @@ struct DiskDrive { bitCount: cells.count, nibbleBitOffsets: Array(repeating: -1, count: cells.count) ) - fluxTracks[track] = nil + if modifiedFluxTracks.indices.contains(track) { modifiedFluxTracks[track] = true } for quarterTrack in fluxQuarterTrackMap.indices where fluxQuarterTrackMap[quarterTrack] == track { fluxQuarterTrackMap[quarterTrack] = -1 if quarterTrackMap.indices.contains(quarterTrack), quarterTrackMap[quarterTrack] < 0 { @@ -207,6 +216,42 @@ struct DiskDrive { return true } + /// FLUX records transition intervals in 125 ns ticks. A recovered BITS + /// track has an explicit transition at every `1` cell, so its modified + /// surface can be deterministically re-quantized for WOZ 2.1 Save As. + func fluxSurfaceForExport() -> (tracks: [DiskFluxTrack?], map: [Int]) { + guard modifiedFluxTracks.contains(true) else { + return (fluxTracks, fluxQuarterTrackMap) + } + var tracks = fluxTracks + for index in modifiedFluxTracks.indices where modifiedFluxTracks[index] { + guard bitTracks.indices.contains(index) else { continue } + let timing = fluxTracks.indices.contains(index) ? fluxTracks[index]?.optimalBitTiming ?? 32 : 32 + tracks[index] = Self.quantize(bitTrack: bitTracks[index], timing: timing) + } + return (tracks, originalFluxQuarterTrackMap) + } + + private static func quantize(bitTrack: DiskBitTrack, timing: Int) -> DiskFluxTrack { + guard bitTrack.bitCount > 0 else { return DiskFluxTrack(bytes: [1], optimalBitTiming: timing) } + var intervals = [UInt8]() + var cellsSinceTransition = 0 + for position in 0..> UInt8(7 - (position & 7))) & 1 + guard bit != 0 else { continue } + var ticks = max(1, cellsSinceTransition * max(16, timing)) + while ticks >= 255 { + intervals.append(0xFF) + ticks -= 255 + } + intervals.append(UInt8(ticks)) + cellsSinceTransition = 0 + } + if intervals.isEmpty { intervals = [UInt8(min(254, max(1, bitTrack.bitCount * max(16, timing))))] } + return DiskFluxTrack(bytes: intervals, optimalBitTiming: timing) + } + private mutating func nextFluxInterval(from stream: DiskFluxTrack) -> Int { var ticks = 0 repeat { diff --git a/Sources/AppleIIEmulator/DiskII.swift b/Sources/AppleIIEmulator/DiskII.swift index ba985d7..cf67bf9 100644 --- a/Sources/AppleIIEmulator/DiskII.swift +++ b/Sources/AppleIIEmulator/DiskII.swift @@ -18,6 +18,24 @@ struct DiskIIDebugSnapshot { /// IWM/P6 soft-switch state machine. Media container decoding is delegated to /// `DiskImageCodec`; callers may keep using the historical `DiskII` alias. final class IWMController { + struct State { + fileprivate let drives: [DiskDrive] + fileprivate let q6: Bool + fileprivate let q7: Bool + fileprivate let motorOn: Bool + fileprivate let motorOffDelay: Int + fileprivate let selectedDrive: Int + fileprivate let dataLatch: UInt8 + fileprivate let busData: UInt8 + fileprivate let sequencerState: UInt8 + fileprivate let sequencerPhase: UInt8 + fileprivate let writeLevel: UInt8 + fileprivate let nibbleReads: Int + fileprivate let nibbleWrites: Int + fileprivate let weakBitsGenerated: Int + fileprivate let fluxBitReads: Int + fileprivate let readBitsByDrive: [Int] + } static let imageSize = 35 * 16 * 256 static let thirteenSectorImageSize = 35 * 13 * 256 static let nibImageSize = 35 * 6_656 @@ -92,6 +110,26 @@ final class IWMController { var hasDisk: Bool { drives.contains { $0.hasDisk } } func hasDisk(in drive: Int) -> Bool { drives.indices.contains(drive) && drives[drive].hasDisk } + func snapshot() -> State { + State( + drives: drives, q6: q6, q7: q7, motorOn: motorOn, motorOffDelay: motorOffDelay, + selectedDrive: selectedDrive, dataLatch: dataLatch, busData: busData, + sequencerState: sequencerState, sequencerPhase: sequencerPhase, writeLevel: writeLevel, + nibbleReads: nibbleReads, nibbleWrites: nibbleWrites, + weakBitsGenerated: weakBitsGenerated, fluxBitReads: fluxBitReads, readBitsByDrive: readBitsByDrive + ) + } + + func restore(_ state: State) { + drives = state.drives; q6 = state.q6; q7 = state.q7; motorOn = state.motorOn + motorOffDelay = state.motorOffDelay; selectedDrive = state.selectedDrive + dataLatch = state.dataLatch; busData = state.busData; sequencerState = state.sequencerState + sequencerPhase = state.sequencerPhase; writeLevel = state.writeLevel + nibbleReads = state.nibbleReads; nibbleWrites = state.nibbleWrites + weakBitsGenerated = state.weakBitsGenerated; fluxBitReads = state.fluxBitReads + readBitsByDrive = state.readBitsByDrive + } + func reset() { for index in drives.indices { drives[index].phaseStates = 0 @@ -629,14 +667,15 @@ final class IWMController { /// Save As action; mounting a disk never changes its original file. func wozImage(drive: Int = 0) -> Data? { guard drives.indices.contains(drive), drives[drive].quarterTrackMap.count == 160 else { return nil } + let fluxSurface = drives[drive].fluxSurfaceForExport() return try? DiskImageCodec.encodeWOZ2( tracks: drives[drive].bitTracks, quarterTrackMap: drives[drive].quarterTrackMap, thirteenSector: drives[drive].isThirteenSector, writeProtected: drives[drive].isWriteProtected, container: drives[drive].wozContainer, - fluxTracks: drives[drive].fluxTracks, - fluxQuarterTrackMap: drives[drive].fluxQuarterTrackMap, + fluxTracks: fluxSurface.tracks, + fluxQuarterTrackMap: fluxSurface.map, preserveWriteHints: !drives[drive].surfaceModified ) } diff --git a/Sources/AppleIIEmulator/EmulatorDebuggerHUD.swift b/Sources/AppleIIEmulator/EmulatorDebuggerHUD.swift new file mode 100644 index 0000000..b70de9c --- /dev/null +++ b/Sources/AppleIIEmulator/EmulatorDebuggerHUD.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// A read-only live monitor plus one-instruction control. It deliberately +/// displays snapshots published by the emulation queue; the view never reads +/// mutable CPU or disk state directly from SwiftUI. +struct EmulatorDebuggerHUD: View { + @ObservedObject var machine: AppleIIMachine + + var body: some View { + let cpu = machine.debuggerSnapshot + let disk = machine.presentedDiskState + VStack(alignment: .leading, spacing: 5) { + HStack { + Text("调试监视器") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + Spacer() + Button("隐藏") { machine.isDebuggerVisible = false } + .buttonStyle(.plain) + } + Text("PC $\(hex(cpu.pc, width: 4)) A $\(hex(cpu.a)) X $\(hex(cpu.x)) Y $\(hex(cpu.y))") + Text("SP $\(hex(cpu.sp)) P $\(hex(cpu.p)) CYC \(cpu.cycles)") + Text("DISK D\(disk.selectedDrive + 1) T\(disk.tracks[disk.selectedDrive]) M:\(disk.motorOn ? "ON" : "OFF") Q6:\(disk.q6 ? 1 : 0) Q7:\(disk.q7 ? 1 : 0)") + HStack(spacing: 8) { + Button("单步") { machine.stepInstruction() } + .disabled(machine.isRunning) + Text(machine.isRunning ? "先暂停" : "已暂停") + } + HStack(spacing: 8) { + Button("快速保存") { machine.saveQuickState() } + Button("快速恢复") { machine.restoreQuickState() } + .disabled(!machine.hasQuickState) + } + } + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color(red: 0.76, green: 1.0, blue: 0.70)) + .padding(10) + .frame(width: 315, alignment: .leading) + .background(.black.opacity(0.82), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 7, style: .continuous).stroke(.green.opacity(0.35), lineWidth: 1)) + .padding(16) + } + + private func hex(_ value: UInt8) -> String { String(format: "%02X", value) } + private func hex(_ value: UInt16, width: Int) -> String { String(format: "%0*X", width, value) } +} diff --git a/Sources/AppleIIEmulator/EmulatorView.swift b/Sources/AppleIIEmulator/EmulatorView.swift index 3aef9df..422c2e6 100644 --- a/Sources/AppleIIEmulator/EmulatorView.swift +++ b/Sources/AppleIIEmulator/EmulatorView.swift @@ -21,6 +21,11 @@ struct EmulatorView: View { idealHeight: ChassisLayout.idealHeight ) .preferredColorScheme(.dark) + .overlay(alignment: .topLeading) { + if machine.isDebuggerVisible { + EmulatorDebuggerHUD(machine: machine) + } + } } private var chassis: some View { diff --git a/Sources/AppleIIEmulator/MOS6502.swift b/Sources/AppleIIEmulator/MOS6502.swift index f313205..a74695f 100644 --- a/Sources/AppleIIEmulator/MOS6502.swift +++ b/Sources/AppleIIEmulator/MOS6502.swift @@ -1,9 +1,33 @@ import Foundation +struct MOS6502DebugSnapshot: Equatable { + let a: UInt8 + let x: UInt8 + let y: UInt8 + let sp: UInt8 + let pc: UInt16 + let p: UInt8 + let cycles: Int +} + /// 65C02 instruction-level core. It deliberately models the processor /// separately from Apple II hardware, allowing the same core to be exercised /// with conformance ROMs and a simple test bus. final class MOS6502: @unchecked Sendable { + struct State { + fileprivate let a: UInt8, x: UInt8, y: UInt8, sp: UInt8, pc: UInt16, p: UInt8 + fileprivate let totalCycles: Int, lastOpcode: UInt8, lastInstructionAddress: UInt16 + fileprivate let unsupportedOpcodes: Set, lastUnsupportedInstructionAddress: UInt16 + fileprivate let recentInstructions: [(UInt16, UInt8)], firstUnsupportedTrace: [(UInt16, UInt8)] + fileprivate let hasExecutedRAMInstruction: Bool, waitingForInterrupt: Bool, stopped: Bool + fileprivate init(cpu: MOS6502) { + a = cpu.a; x = cpu.x; y = cpu.y; sp = cpu.sp; pc = cpu.pc; p = cpu.p + totalCycles = cpu.totalCycles; lastOpcode = cpu.lastOpcode; lastInstructionAddress = cpu.lastInstructionAddress + unsupportedOpcodes = cpu.unsupportedOpcodes; lastUnsupportedInstructionAddress = cpu.lastUnsupportedInstructionAddress + recentInstructions = cpu.recentInstructions; firstUnsupportedTrace = cpu.firstUnsupportedTrace + hasExecutedRAMInstruction = cpu.hasExecutedRAMInstruction; waitingForInterrupt = cpu.waitingForInterrupt; stopped = cpu.stopped + } + } private let bus: AppleIIBus private(set) var a: UInt8 = 0 private(set) var x: UInt8 = 0 @@ -53,6 +77,34 @@ final class MOS6502: @unchecked Sendable { if let registerX { x = registerX } } + var debugSnapshot: MOS6502DebugSnapshot { + MOS6502DebugSnapshot(a: a, x: x, y: y, sp: sp, pc: pc, p: p, cycles: totalCycles) + } + + func snapshot() -> State { State(cpu: self) } + + func restore(_ state: State) { + a = state.a; x = state.x; y = state.y; sp = state.sp; pc = state.pc; p = state.p + totalCycles = state.totalCycles; lastOpcode = state.lastOpcode; lastInstructionAddress = state.lastInstructionAddress + unsupportedOpcodes = state.unsupportedOpcodes; lastUnsupportedInstructionAddress = state.lastUnsupportedInstructionAddress + recentInstructions = state.recentInstructions; firstUnsupportedTrace = state.firstUnsupportedTrace + hasExecutedRAMInstruction = state.hasExecutedRAMInstruction; waitingForInterrupt = state.waitingForInterrupt; stopped = state.stopped + cyclePenalty = 0; instructionBusCycle = 0; advancedBusCycles = 0 + } + + /// Executes exactly one instruction using the same bus-cycle protocol as + /// normal execution. Debug stepping therefore cannot skip a disk, sound + /// or soft-switch side effect merely because the UI is paused. + @discardableResult + func runOneInstruction() -> Int { + let cyclesBefore = totalCycles + let cost = step() + totalCycles += cost + finishInstructionBusCycles(total: cost) + if bus.irqPending { irq() } + return totalCycles - cyclesBefore + } + func run(cycles budget: Int) { var elapsed = 0 diff --git a/Sources/AppleIIEmulator/MacSerialBridge.swift b/Sources/AppleIIEmulator/MacSerialBridge.swift index 3867b80..5d01b24 100644 --- a/Sources/AppleIIEmulator/MacSerialBridge.swift +++ b/Sources/AppleIIEmulator/MacSerialBridge.swift @@ -17,12 +17,12 @@ final class MacSerialBridge: @unchecked Sendable { private final class Connection { let descriptor: Int32 let source: DispatchSourceRead - var baudRate: Int + var configuration: SerialLineConfiguration - init(descriptor: Int32, source: DispatchSourceRead, baudRate: Int) { + init(descriptor: Int32, source: DispatchSourceRead, configuration: SerialLineConfiguration) { self.descriptor = descriptor self.source = source - self.baudRate = baudRate + self.configuration = configuration } } @@ -53,17 +53,17 @@ final class MacSerialBridge: @unchecked Sendable { } } - func connect(path: String, port: Int, baudRate: Int) { + func connect(path: String, port: Int, configuration: SerialLineConfiguration) { queue.async { [weak self] in guard let self else { return } self.disconnectLocked(port: port, notify: false) do { - let descriptor = try self.open(path: path, baudRate: baudRate) + let descriptor = try self.open(path: path, configuration: configuration) let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: self.queue) source.setEventHandler { [weak self] in self?.readAvailable(from: descriptor, port: port) } - self.connections[port] = Connection(descriptor: descriptor, source: source, baudRate: baudRate) + self.connections[port] = Connection(descriptor: descriptor, source: source, configuration: configuration) source.resume() self.didChangeConnection?(port, path) } catch { @@ -72,22 +72,30 @@ final class MacSerialBridge: @unchecked Sendable { } } + func connect(path: String, port: Int, baudRate: Int) { + connect(path: path, port: port, configuration: SerialLineConfiguration(baudRate: baudRate, dataBits: 8, stopBits: 1, parity: .none)) + } + func disconnect(port: Int) { queue.async { [weak self] in self?.disconnectLocked(port: port, notify: true) } } - func setBaudRate(_ baudRate: Int, port: Int) { + func setConfiguration(_ configuration: SerialLineConfiguration, port: Int) { queue.async { [weak self] in - guard let self, let connection = self.connections[port], connection.baudRate != baudRate else { return } + guard let self, let connection = self.connections[port], connection.configuration != configuration else { return } do { - try self.configure(descriptor: connection.descriptor, baudRate: baudRate) - connection.baudRate = baudRate + try self.configure(descriptor: connection.descriptor, configuration: configuration) + connection.configuration = configuration } catch { self.didFail?(port, error) } } } + func setBaudRate(_ baudRate: Int, port: Int) { + setConfiguration(SerialLineConfiguration(baudRate: baudRate, dataBits: 8, stopBits: 1, parity: .none), port: port) + } + func send(_ bytes: [UInt8], port: Int) { guard !bytes.isEmpty else { return } queue.async { [weak self] in @@ -155,11 +163,11 @@ final class MacSerialBridge: @unchecked Sendable { } } - private func open(path: String, baudRate: Int) throws -> Int32 { + private func open(path: String, configuration: SerialLineConfiguration) throws -> Int32 { let descriptor = Darwin.open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) guard descriptor >= 0 else { throw posixError() } do { - try configure(descriptor: descriptor, baudRate: baudRate) + try configure(descriptor: descriptor, configuration: configuration) return descriptor } catch { Darwin.close(descriptor) @@ -167,19 +175,30 @@ final class MacSerialBridge: @unchecked Sendable { } } - private func configure(descriptor: Int32, baudRate: Int) throws { + private func configure(descriptor: Int32, configuration: SerialLineConfiguration) throws { var attributes = termios() guard tcgetattr(descriptor, &attributes) == 0 else { throw posixError() } cfmakeraw(&attributes) attributes.c_cflag |= tcflag_t(CLOCAL | CREAD) - attributes.c_cflag &= ~tcflag_t(CSIZE | PARENB | CSTOPB) - attributes.c_cflag |= tcflag_t(CS8) + attributes.c_cflag &= ~tcflag_t(CSIZE | PARENB | PARODD | CSTOPB) + switch configuration.dataBits { + case 5: attributes.c_cflag |= tcflag_t(CS5) + case 6: attributes.c_cflag |= tcflag_t(CS6) + case 7: attributes.c_cflag |= tcflag_t(CS7) + default: attributes.c_cflag |= tcflag_t(CS8) + } + if configuration.stopBits == 2 { attributes.c_cflag |= tcflag_t(CSTOPB) } + switch configuration.parity { + case .none: break + case .odd: attributes.c_cflag |= tcflag_t(PARENB | PARODD) + case .even: attributes.c_cflag |= tcflag_t(PARENB) + } withUnsafeMutableBytes(of: &attributes.c_cc) { values in values[Int(VMIN)] = 1 values[Int(VTIME)] = 0 } - guard let speed = serialSpeed(for: baudRate) else { - throw BridgeError.unsupportedBaudRate(baudRate) + guard let speed = serialSpeed(for: configuration.baudRate) else { + throw BridgeError.unsupportedBaudRate(configuration.baudRate) } guard cfsetspeed(&attributes, speed) == 0, tcsetattr(descriptor, TCSANOW, &attributes) == 0 else { throw posixError() diff --git a/Sources/AppleIIEmulator/SmartPort.swift b/Sources/AppleIIEmulator/SmartPort.swift index 339c45d..9d2b076 100644 --- a/Sources/AppleIIEmulator/SmartPort.swift +++ b/Sources/AppleIIEmulator/SmartPort.swift @@ -8,13 +8,25 @@ final class SmartPortController { static let blockSize = 512 static let maximumBlocks = 65_535 - private struct Drive { + fileprivate struct Drive { var blocks: [UInt8] var writeProtected: Bool var blockCount: Int { blocks.count / SmartPortController.blockSize } } + struct State { + fileprivate let drives: [Drive?] + fileprivate let command: UInt8 + fileprivate let unit: UInt8 + fileprivate let bufferAddress: UInt16 + fileprivate let blockNumber: UInt32 + fileprivate let statusCode: UInt8 + fileprivate let error: UInt8 + fileprivate let emulatedCycle: Int + fileprivate let busyUntilCycle: Int + } + private var drives: [Drive?] = [nil, nil] private var command: UInt8 = 0 private var unit: UInt8 = 0 @@ -25,10 +37,54 @@ final class SmartPortController { private var emulatedCycle = 0 private var busyUntilCycle = 0 + /// A minimal slot-seven boot ROM. It uses this card's real C0F0 register + /// window to load ProDOS block zero into $0800, then transfers control to + /// that block. Apple II+/IIe software can therefore enter via `PR#7` / + /// `$C700` instead of a UI-side program-counter shortcut. + private static let bootROM: [UInt8] = { + var rom = [UInt8](repeating: 0x60, count: 256) // RTS for unused probes + let code: [UInt8] = [ + 0xA9, 0x01, // LDA #$01: SmartPort block read + 0x8D, 0xF2, 0xC0, // STA $C0F2 + 0xA9, 0x70, // LDA #$70: slot 7, drive 1 + 0x8D, 0xF3, 0xC0, // STA $C0F3 + 0xA9, 0x00, // LDA #$00: buffer low + 0x8D, 0xF4, 0xC0, // STA $C0F4 + 0xA9, 0x08, // LDA #$08: buffer high ($0800) + 0x8D, 0xF5, 0xC0, // STA $C0F5 + 0xA9, 0x00, // LDA #$00: ProDOS block zero + 0x8D, 0xF6, 0xC0, // STA $C0F6: block 0 low + 0x8D, 0xF7, 0xC0, // STA $C0F7: block 0 high + 0x8D, 0xF8, 0xC0, // STA $C0F8: block 0 bank + 0xAD, 0xF0, 0xC0, // LDA $C0F0: execute command + 0x29, 0x01, // AND #$01: SmartPort error bit + 0xD0, 0x03, // BNE failure + 0x4C, 0x00, 0x08, // JMP $0800 + 0x60 // failure: RTS + ] + rom.replaceSubrange(0.. Bool { drives.indices.contains(drive) && drives[drive] != nil } func blockCount(in drive: Int) -> Int { drives.indices.contains(drive) ? drives[drive]?.blockCount ?? 0 : 0 } + func snapshot() -> State { + State( + drives: drives, command: command, unit: unit, bufferAddress: bufferAddress, + blockNumber: blockNumber, statusCode: statusCode, error: error, + emulatedCycle: emulatedCycle, busyUntilCycle: busyUntilCycle + ) + } + + func restore(_ state: State) { + drives = state.drives; command = state.command; unit = state.unit + bufferAddress = state.bufferAddress; blockNumber = state.blockNumber + statusCode = state.statusCode; error = state.error + emulatedCycle = state.emulatedCycle; busyUntilCycle = state.busyUntilCycle + } + func reset() { command = 0 unit = 0 @@ -65,6 +121,10 @@ final class SmartPortController { return Data(drive.blocks) } + func romByte(at offset: Int) -> UInt8 { + Self.bootROM[offset & 0xFF] + } + /// C0F0-C0FF for slot 7. The CPU has already performed the bus access at /// the exact emulated cycle, so DMA and busy signalling share that clock. func access(_ address: Int, write value: UInt8?, bus: AppleIIBus) -> UInt8 { diff --git a/Tests/AppleIIEmulatorTests/ACIA6551Tests.swift b/Tests/AppleIIEmulatorTests/ACIA6551Tests.swift index dcc5682..edfa576 100644 --- a/Tests/AppleIIEmulatorTests/ACIA6551Tests.swift +++ b/Tests/AppleIIEmulatorTests/ACIA6551Tests.swift @@ -72,6 +72,19 @@ final class ACIA6551Tests: XCTestCase { XCTAssertEqual(memory.serialBaudRate(port: 2), 19_200) } + func testIIcSerialLineFormatIsExposedToTheHostBridge() { + let memory = AppleIIMemory() + memory.loadROM(Data(repeating: 0, count: 0x8000)) + memory.write(0xC09B, 0xAF) // 7 data bits, 2 stops, 19,200 baud + memory.write(0xC09A, 0x05) // transmitter enabled, odd parity + XCTAssertEqual( + memory.serialLineConfiguration(port: 1), + SerialLineConfiguration(baudRate: 19_200, dataBits: 7, stopBits: 2, parity: .odd) + ) + memory.write(0xC09A, 0x06) + XCTAssertEqual(memory.serialLineConfiguration(port: 1).parity, .even) + } + func testAppleIIPlusDoesNotExposeIIcACIARegisters() { let memory = AppleIIMemory() memory.loadROM(Data(repeating: 0, count: 0x3000)) diff --git a/Tests/AppleIIEmulatorTests/DebuggerTests.swift b/Tests/AppleIIEmulatorTests/DebuggerTests.swift new file mode 100644 index 0000000..f7e9f5e --- /dev/null +++ b/Tests/AppleIIEmulatorTests/DebuggerTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import AppleIIEmulator + +final class DebuggerTests: XCTestCase { + func testSingleInstructionUsesTheNormalBusCyclePath() { + let memory = AppleIIMemory() + memory.write(0x0200, 0xA9) // LDA #$42 + memory.write(0x0201, 0x42) + memory.write(0x0202, 0x8D) // STA $C030 + memory.write(0x0203, 0x30) + memory.write(0x0204, 0xC0) + let cpu = MOS6502(bus: memory) + cpu.start(at: 0x0200) + + XCTAssertEqual(cpu.runOneInstruction(), 2) + XCTAssertEqual(cpu.debugSnapshot.a, 0x42) + XCTAssertEqual(cpu.debugSnapshot.pc, 0x0202) + XCTAssertEqual(cpu.runOneInstruction(), 4) + XCTAssertEqual(memory.speakerFlips, 1) + XCTAssertEqual(cpu.debugSnapshot.pc, 0x0205) + } + + @MainActor + func testQuickStateRestoresMemory() { + let suiteName = "AppleIIEmulator.DebuggerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let machine = AppleIIMachine(defaults: defaults, startsRuntimeTimer: false) + machine.memory.write(0x0400, 0x11) + machine.saveQuickState() + machine.memory.write(0x0400, 0x22) + + machine.restoreQuickState() + + XCTAssertTrue(machine.hasQuickState) + XCTAssertEqual(machine.memory.read(0x0400), 0x11) + XCTAssertTrue(machine.status.hasPrefix("已恢复快速状态")) + } + + func testCoreStateSnapshotRestoresCPUAndBusSideEffects() { + let memory = AppleIIMemory() + memory.write(0x0200, 0xA9) // LDA #$7E + memory.write(0x0201, 0x7E) + memory.write(0x0202, 0x8D) // STA $C030 + memory.write(0x0203, 0x30) + memory.write(0x0204, 0xC0) + let cpu = MOS6502(bus: memory) + cpu.start(at: 0x0200) + let cpuState = cpu.snapshot() + let memoryState = memory.snapshot() + + _ = cpu.runOneInstruction() + _ = cpu.runOneInstruction() + memory.write(0x0400, 0x55) + XCTAssertEqual(memory.speakerFlips, 1) + + memory.restore(memoryState) + cpu.restore(cpuState) + XCTAssertEqual(cpu.debugSnapshot.pc, 0x0200) + XCTAssertEqual(memory.read(0x0400), 0) + XCTAssertEqual(memory.speakerFlips, 0) + _ = cpu.runOneInstruction() + _ = cpu.runOneInstruction() + XCTAssertEqual(memory.speakerFlips, 1) + } +} diff --git a/Tests/AppleIIEmulatorTests/IWMControllerTests.swift b/Tests/AppleIIEmulatorTests/IWMControllerTests.swift index 067edc9..8ad6e65 100644 --- a/Tests/AppleIIEmulatorTests/IWMControllerTests.swift +++ b/Tests/AppleIIEmulatorTests/IWMControllerTests.swift @@ -241,7 +241,7 @@ final class IWMControllerTests: XCTestCase { XCTAssertGreaterThan(restored.fluxBitReads, 0) } - func testWOZ21FluxWriteMaterializesChangedTrackAsBITS() throws { + func testWOZ21FluxWriteRequantizesChangedTrackAsFLUX() throws { let disk = IWMController() try disk.mountImage(woz21FluxImage(), fileExtension: "woz") _ = disk.access(0x09, write: nil) // motor on @@ -251,14 +251,14 @@ final class IWMControllerTests: XCTestCase { XCTAssertGreaterThan(disk.nibbleWrites, 0) let saved = try XCTUnwrap(disk.wozImage()) - XCTAssertNil(saved.range(of: Data("FLUX".utf8)), "the modified flux track must export as BITS") + XCTAssertNotNil(saved.range(of: Data("FLUX".utf8)), "the modified flux track must remain a FLUX surface") let restored = IWMController() try restored.mountImage(saved, fileExtension: "woz") _ = restored.access(0x09, write: nil) _ = restored.access(0x0C, write: nil) restored.advance(by: 64) XCTAssertGreaterThan(restored.nibbleReads, 0) - XCTAssertEqual(restored.fluxBitReads, 0) + XCTAssertGreaterThan(restored.fluxBitReads, 0) } func testWOZSaveAsRetainsExtensionsUntilSurfaceChanges() throws { diff --git a/Tests/AppleIIEmulatorTests/MockingboardTests.swift b/Tests/AppleIIEmulatorTests/MockingboardTests.swift index 8617b94..10cbaff 100644 --- a/Tests/AppleIIEmulatorTests/MockingboardTests.swift +++ b/Tests/AppleIIEmulatorTests/MockingboardTests.swift @@ -30,6 +30,17 @@ final class MockingboardTests: XCTestCase { XCTAssertEqual(memory.read(0xC0CD) & 0x40, 0) } + func testVIAShiftRegisterRaisesIRQAfterPhi2Transfer() { + let memory = AppleIIMemory() + memory.write(0xC0CB, 0x18) // ACR: shift out under phi2 + memory.write(0xC0CE, 0x84) // IER: enable shift-register interrupt + memory.write(0xC0CA, 0xA5) + memory.advanceVideoClock(by: 8) + XCTAssertEqual(memory.read(0xC0CD) & 0x84, 0x84) + _ = memory.read(0xC0CA) + XCTAssertEqual(memory.read(0xC0CD) & 0x04, 0) + } + func testAYAudioIsSynthesizedFromTheCycleClock() { let memory = AppleIIMemory() writeAY(memory, base: 0xC0C0, register: 0, value: 0x08) diff --git a/Tests/AppleIIEmulatorTests/ROMLoadingTests.swift b/Tests/AppleIIEmulatorTests/ROMLoadingTests.swift index e89a215..3cb488d 100644 --- a/Tests/AppleIIEmulatorTests/ROMLoadingTests.swift +++ b/Tests/AppleIIEmulatorTests/ROMLoadingTests.swift @@ -217,8 +217,15 @@ final class ROMLoadingTests: XCTestCase { func testWordPerfectMountsItsWorkDiskInDriveTwo() { let machine = AppleIIMachine() machine.loadBundledSoftware(.wordPerfect11) - XCTAssertTrue(machine.memory.hasDisk(in: 0)) - XCTAssertTrue(machine.memory.hasDisk(in: 1)) + // Production mounting reads the bundled media away from the main + // actor. Give the execution queue a run-loop turn rather than + // racing the two drive assertions against that legitimate I/O. + let deadline = Date().addingTimeInterval(5) + while Date() < deadline, !(machine.hasDisk(in: 0) && machine.hasDisk(in: 1)) { + RunLoop.current.run(until: Date().addingTimeInterval(0.01)) + } + XCTAssertTrue(machine.hasDisk(in: 0)) + XCTAssertTrue(machine.hasDisk(in: 1)) } } diff --git a/Tests/AppleIIEmulatorTests/SmartPortTests.swift b/Tests/AppleIIEmulatorTests/SmartPortTests.swift index 9163d1a..5b7d3bd 100644 --- a/Tests/AppleIIEmulatorTests/SmartPortTests.swift +++ b/Tests/AppleIIEmulatorTests/SmartPortTests.swift @@ -37,4 +37,19 @@ final class SmartPortTests: XCTestCase { _ = memory.read(0xC0F0) XCTAssertEqual(memory.read(0x0900), 1) } + + func testSlotSevenROMLoadsBlockZeroBeforeEnteringIt() throws { + let memory = AppleIIMemory() + memory.loadROM(Data(repeating: 0, count: 0x3000)) // Apple II+ + var image = [UInt8](repeating: 0xEA, count: SmartPortController.blockSize) + image[0] = 0xA9 // LDA #$42, proves execution transferred into RAM + image[1] = 0x42 + try memory.mountHardDiskImageData(Data(image), fileExtension: "hdv") + let cpu = MOS6502(bus: memory) + cpu.start(at: 0xC700) + cpu.run(cycles: 80) + XCTAssertEqual(memory.read(0x0800), 0xA9) + XCTAssertEqual(cpu.a, 0x42) + XCTAssertGreaterThanOrEqual(cpu.pc, 0x0802) + } }