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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

Expand All @@ -33,12 +34,12 @@ 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 项并去重;若磁盘文件后来移动或删除,重新打开该项时会自动移除失效记录。

“软件”菜单将内置生产力软件按其所需的 IIe ROM 与磁盘布局启动。Apple Pascal 1.3 会将启动盘和工具盘分别装入两个驱动器;WordPerfect 1.1 使用独立的持久化工作盘,避免覆盖随 App 发布的原始映像。

## 下一阶段

当前版本以启动和执行为目标。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;尚未实现 PhasorSSI-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 移位寄存器;尚未实现 PhasorSSI-263 语音芯片。清理过的 WOZ 弱区按 MC3470 的四位延迟窗口处理,并在连续零单元后生成确定性的假脉冲;FLUX 使用以磁道为单位的数字锁相恢复,写入后会重新量化为 125 ns tick FLUX 间隔。仍未覆盖逐元件模拟的模拟读放大器,以及接入真实外置串口硬件的人工兼容性验证。因此它不是逐周期、全外设覆盖的 Apple IIc 保真模拟器。
52 changes: 43 additions & 9 deletions Sources/AppleIIEmulator/ACIA6551.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions Sources/AppleIIEmulator/AppleIICassette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ 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
private(set) var level = false

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
Expand Down
15 changes: 15 additions & 0 deletions Sources/AppleIIEmulator/AppleIIEmulatorApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "未插入")
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading