diff --git a/Sources/BootTester/main.swift b/Sources/BootTester/main.swift index 744e988..ed16483 100644 --- a/Sources/BootTester/main.swift +++ b/Sources/BootTester/main.swift @@ -25,12 +25,13 @@ let requestedDiskPath: String? = { if first == "--help" || first == "-h" { print("Usage: swift run BootTester [disk.d88]") print(" swift run BootTester --script # タイムラインスクリプト再生") + print(" swift run BootTester --snapshot ... # Quick Look thumbnail capture") exit(0) } if first.hasPrefix("--") { // Flags are not disk paths. Unknown flags other than --script are warned // about rather than silently ignored. - if first != "--script" { + if first != "--script" && first != "--snapshot" { FileHandle.standardError.write( Data("warning: 未知のオプション \(first) を無視します\n".utf8)) } @@ -484,6 +485,10 @@ func setupMachine(dipSw1: UInt8 = 0xC3, dipSw2: UInt8 = 0x79) -> Machine { if ProcessInfo.processInfo.environment["BOOTTEST_FORCE_OPN"] != nil { machine.sound.forceOPNMode = true } + // BOOTTEST_AUDIO=0 skips synthesis, as a headless capture does. + if ProcessInfo.processInfo.environment["BOOTTEST_AUDIO"] == "0" { + machine.sound.audioOutputEnabled = false + } if let tapePath = ProcessInfo.processInfo.environment["BOOTTEST_TAPE_PATH"], !tapePath.isEmpty, let tapeData = try? Data(contentsOf: URL(fileURLWithPath: tapePath)) { @@ -616,6 +621,70 @@ if let sp = scriptPath { runScriptMode(scriptPath: sp) } +// ============================================================ +// Snapshot mode: run BootSnapshot, the Quick Look thumbnail capture, on each +// disk and report how long it took and why it stopped. +// BOOTTEST_SNAPSHOT_DIR directory for .ppm (optional) +// BOOTTEST_SNAPSHOT_DEADLINE wall-clock budget per disk in seconds (default 3) +// ============================================================ +func runSnapshotMode(diskPaths: [String]) -> Never { + let env = ProcessInfo.processInfo.environment + var roms: [(PC88.ROM, [UInt8])] = [(.n88Basic, Array(romData))] + let optional: [(PC88.ROM, String)] = [ + (.nBasic, "N80.ROM"), (.font, "FONT.ROM"), (.disk, "DISK.ROM"), + (.kanji1, "KANJI1.ROM"), (.kanji2, "KANJI2.ROM"), + ] + (0..<4).map { (.n88Ext(bank: $0), "N88_\($0).ROM") } + for (rom, name) in optional { + if let data = try? Data(contentsOf: appSupport.appendingPathComponent(name)) { + roms.append((rom, Array(data))) + } + } + let budget = Double(env["BOOTTEST_SNAPSHOT_DEADLINE"] ?? "") ?? 3 + // BOOTTEST_SNAPSHOT_PARAMS="settledIdle=4,stable=4,idle=10,minContent=0.02,max=90,opn=1" + var parameters = BootSnapshot.Parameters() + for pair in (env["BOOTTEST_SNAPSHOT_PARAMS"] ?? "").split(separator: ",") { + let kv = pair.split(separator: "=") + guard kv.count == 2, let v = Double(kv[1]) else { continue } + switch kv[0] { + case "settledIdle": parameters.settledIdleSeconds = v + case "stable": parameters.stableSeconds = v + case "idle": parameters.idleSeconds = v + case "minContent": parameters.minContent = v + case "max": parameters.maxEmulatedSeconds = v + case "opn": parameters.hideSoundBoard2 = v != 0 + default: print("unknown parameter \(kv[0])") + } + } + let outDir = env["BOOTTEST_SNAPSHOT_DIR"] + for path in diskPaths { + let name = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { + print("\(name): unreadable") + continue + } + let disks = D88Disk.parseAll(data: Array(data)) + let start = ContinuousClock.now + let frame = BootSnapshot.capture(roms: roms, disks: disks, parameters: parameters, + deadline: start + .milliseconds(Int(budget * 1000))) + let wall = (ContinuousClock.now - start).components + let wallSeconds = Double(wall.seconds) + Double(wall.attoseconds) / 1e18 + let wallText = String(format: "%.2f", wallSeconds) + guard let frame else { + print("\(name): no frame wall=\(wallText)s") + continue + } + print("\(name): \(frame.reason.rawValue) emulated=\(String(format: "%.1f", frame.emulatedSeconds))s wall=\(wallText)s content=\(String(format: "%.4f", frame.content))") + if let outDir { + try? writePPMScreenshot(path: "\(outDir)/\(name).ppm", pixels: frame.pixels) + } + } + exit(0) +} + +if let i = bootArgs.firstIndex(of: "--snapshot") { + runSnapshotMode(diskPaths: Array(bootArgs[(i + 1)...])) +} + // ============================================================ // Cold boot with "0" + Return keyboard input // ============================================================ diff --git a/Sources/Bubilator88Core/BootSnapshot.swift b/Sources/Bubilator88Core/BootSnapshot.swift new file mode 100644 index 0000000..8d5842d --- /dev/null +++ b/Sources/Bubilator88Core/BootSnapshot.swift @@ -0,0 +1,205 @@ +import PC88Types + +/// Boots a disk with no host attached and picks one frame to stand for it — +/// what a file browser shows as the disk's thumbnail. +/// +/// PC-8801 software loads from disk for seconds before its title appears, so a +/// fixed frame count would catch loading screens on some disks and waste time +/// on others. Instead the run stops once the machine has settled: the drives +/// have gone quiet and the screen shows something. Audio synthesis is off +/// throughout, since nothing would hear it. +/// +/// The machine is always N88-BASIC V2 at 4MHz. A disk that needs another mode +/// often ends at the BASIC prompt, which counts as not booting. +public enum BootSnapshot { + + /// Why the run stopped. + public enum StopReason: String, Sendable { + /// Drives idle and the screen unchanged for a while. + case settled + /// Drives idle for longer still, with the screen still moving but no + /// longer filling up — an animated title. + case idle + /// `Parameters.maxEmulatedSeconds` ran out with something on screen. + case emulatedLimit + /// The wall-clock deadline passed with something on screen. + case deadline + } + + /// The chosen frame. + public struct Frame: Sendable { + /// `PC88.frameWidth` × `PC88.frameHeight` RGBA, as `PC88.render` writes it. + public let pixels: [UInt8] + /// Emulated time at which the frame was taken. + public let emulatedSeconds: Double + /// Fraction of sampled pixels that are not black. + public let content: Double + public let reason: StopReason + } + + /// The thresholds that decide when the machine has settled. All times are + /// emulated seconds. + public struct Parameters: Sendable { + /// Frames between two looks at the drives and the screen. + public var sampleInterval = 15 + /// Drive idle time after which an unchanged screen is taken. + public var settledIdleSeconds = 4.0 + /// How long the screen must stay unchanged for `settled`. + public var stableSeconds = 4.0 + /// Drive idle time after which a moving screen is taken anyway, as long + /// as it is not still filling up. + public var idleSeconds = 10.0 + /// How much the lit fraction may grow over `stableSeconds` for a moving + /// screen to count as animating rather than still being drawn + /// (ハイドライド3 paints its title for seconds after the drive stops). + public var maxGrowth = 0.05 + /// Fraction of sampled pixels that must be non-black for the screen to + /// count as showing something. 2% rejects a few lines of text on black + /// (LION's menu lights 0.65%), which makes a poor thumbnail; the + /// sparsest real title in the test set lights 3.4%. + public var minContent = 0.02 + /// Give up after this much emulated time. + public var maxEmulatedSeconds = 90.0 + /// Present the machine without a Sound Board II. Programs that find only + /// an OPN skip loading their ADPCM samples, which can take seconds of + /// disk time. + public var hideSoundBoard2 = true + + public init() {} + } + + /// Boot `disks` (image 0 in drive 0, image 1, if any, in drive 1) and return + /// the frame that stands for them, or nil if nothing bootable showed up: + /// the screen is (nearly) black when the run stops, or the machine ended up + /// at the BASIC prompt. + /// + /// When a limit cuts the run short, the screen at that moment is used only + /// if it shows something. An earlier frame is not substituted: it tends to + /// be the BASIC sign-on or a loading screen, not the software. + /// + /// - Parameters: + /// - roms: BIOS images. N88-BASIC and DISK.ROM are the ones that matter. + /// - deadline: wall-clock limit on the run. + public static func capture(roms: [(PC88.ROM, [UInt8])], disks: [D88Disk], + parameters: Parameters = Parameters(), + deadline: ContinuousClock.Instant) -> Frame? { + guard let first = disks.first else { return nil } + let pc88 = PC88() + for (rom, data) in roms { + pc88.loadROM(rom, data: data) + } + pc88.installExtRAM() + pc88.setBootMode(.n88v2) + pc88.mountDisk(drive: 0, disk: first) + if disks.count > 1 { + pc88.mountDisk(drive: 1, disk: disks[1]) + } + pc88.applyBootStrap() + pc88.reset() + pc88.clock8MHz = false + pc88.audioOutputEnabled = false + pc88.forceOPNMode = parameters.hideSoundBoard2 + installEmulatedClock(pc88.machine) + + var pixels = [UInt8](repeating: 0, count: PC88.frameBufferSize) + var lastSignature: UInt64 = 0 + var lastChange = 0.0 + var lastActivity = 0.0 + var contentHistory: [(time: Double, content: Double)] = [] + var now = 0.0 + + while true { + // Read the rate each time: a program can switch the monitor mode, and + // with it the frame rate, part way through. + let frameRate = pc88.frameRate + for _ in 0.. $0.content * (1 + parameters.maxGrowth) } ?? true + + let hasContent = content >= parameters.minContent + let idle = now - lastActivity + var reason: StopReason? + if hasContent { + if idle >= parameters.settledIdleSeconds && now - lastChange >= parameters.stableSeconds { + reason = .settled + } else if idle >= parameters.idleSeconds && !stillFilling { + reason = .idle + } + } + if reason == nil { + if now >= parameters.maxEmulatedSeconds { + reason = .emulatedLimit + } else if ContinuousClock.now >= deadline { + reason = .deadline + } + } + guard let reason else { continue } + guard hasContent, !isAtBASICPrompt(pc88.copyTextAsUnicode()) else { return nil } + return Frame(pixels: pixels, emulatedSeconds: now, content: content, reason: reason) + } + } + + /// Whether the text screen shows N88-BASIC waiting for the user: the file + /// count question still unanswered, the sign-on banner, or a bare `Ok`. + /// That is where a disk with nothing to autorun ends up. Only meaningful + /// once the machine has settled — disk BASIC answers the question and + /// prints the banner on its way to running a game. + /// + /// The function key legends are no use as a sign: text VRAM keeps them + /// after games stop displaying the text layer. + static func isAtBASICPrompt(_ text: String) -> Bool { + text.split(whereSeparator: \.isNewline).contains { line in + let row = line.drop { $0.isWhitespace }.reversed().drop { $0.isWhitespace }.reversed() + .map(String.init).joined() + return row == "Ok" || row.hasSuffix("How many files(0-15)?") || row.hasSuffix("Bytes free") + } + } + + /// Every 2nd pixel of every 4th row: an FNV-1a hash to notice changes, and + /// the fraction of samples that are not black. Dense enough that thin text + /// strokes are not stepped over. + static func sample(_ pixels: [UInt8]) -> (signature: UInt64, content: Double) { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + var lit = 0 + var count = 0 + pixels.withUnsafeBufferPointer { buffer in + for y in Swift.stride(from: 0, to: PC88.frameHeight, by: 4) { + for x in Swift.stride(from: 0, to: PC88.frameWidth, by: 2) { + let i = (y * PC88.frameWidth + x) * 4 + let rgb = UInt32(buffer[i]) << 16 | UInt32(buffer[i + 1]) << 8 | UInt32(buffer[i + 2]) + hash = (hash ^ UInt64(rgb)) &* 0x0000_0100_0000_01B3 + if rgb != 0 { lit += 1 } + count += 1 + } + } + } + return (hash, Double(lit) / Double(count)) + } + + /// Drive the calendar from emulated time. The host clock would barely move + /// while the machine runs many times faster, and programs that wait on the + /// RTC would stall. The date itself (2025-01-01, a Wednesday) is arbitrary. + private static func installEmulatedClock(_ machine: Machine) { + machine.calendar.timeProvider = { [unowned machine] in + let elapsed = Int(Double(machine.totalTStates) / machine.cpuClock) + let minutes = elapsed / 60 + let hours = minutes / 60 + return (sec: elapsed % 60, min: minutes % 60, hour: hours % 24, + day: 1 + hours / 24, wday: 3, mon: 1, year: 25) + } + } +} diff --git a/Sources/Bubilator88Core/PC88.swift b/Sources/Bubilator88Core/PC88.swift index 29537c0..2b098fc 100644 --- a/Sources/Bubilator88Core/PC88.swift +++ b/Sources/Bubilator88Core/PC88.swift @@ -513,6 +513,15 @@ public final class PC88: @unchecked Sendable { set { machine.sound.immersiveOutputEnabled = newValue } } + /// Synthesise audio at all. Turning it off skips FM, SSG, rhythm and the + /// mix, and `takeAudioSamples()` then returns nothing; timers and ADPCM + /// status keep running, so programs behave the same. For headless runs + /// such as thumbnail capture, where synthesis is most of the cost. + public var audioOutputEnabled: Bool { + get { machine.sound.audioOutputEnabled } + set { machine.sound.audioOutputEnabled = newValue } + } + /// Report the sound board as YM2203 (OPN) so programs skip OPNA features. public var forceOPNMode: Bool { get { machine.sound.forceOPNMode } diff --git a/Sources/Bubilator88Core/SaveStateSerialize.swift b/Sources/Bubilator88Core/SaveStateSerialize.swift index 56bc1f7..5a59134 100644 --- a/Sources/Bubilator88Core/SaveStateSerialize.swift +++ b/Sources/Bubilator88Core/SaveStateSerialize.swift @@ -707,7 +707,7 @@ extension Machine { /// Unknown tags are ignored by `loadSaveState`, so adding one does not /// require a format version bump. package func createSaveState(thumbnail: [UInt8]? = nil, - extraSections: [(tag: UInt32, data: [UInt8])] = []) -> [UInt8] { + extraSections: [(tag: UInt32, data: [UInt8])] = []) -> [UInt8] { var w = SaveStateWriter() writeSaveState(to: &w) let mainSection = (tag: SaveStateFile.fourCC("MAIN"), data: w.data) diff --git a/Sources/FMSynthesis/YM2608.swift b/Sources/FMSynthesis/YM2608.swift index eb8898a..23c6c4d 100644 --- a/Sources/FMSynthesis/YM2608.swift +++ b/Sources/FMSynthesis/YM2608.swift @@ -321,6 +321,12 @@ package final class YM2608 { /// Format: [L, R, L, R, ...]. Accumulated during tick(), consumed by audio output. package var audioBuffer: [Float] = [] + /// When false, `tick` skips synthesis and mixing and appends nothing to + /// the output buffers. Timers, busy status and ADPCM playback (whose EOS + /// flag programs poll) keep running, so the CPU sees no difference. For + /// headless runs, where the samples would only be thrown away. + package var audioOutputEnabled: Bool = true + // MARK: - Immersive Audio Output /// When true, per-channel stereo buffers are populated alongside audioBuffer. @@ -610,6 +616,9 @@ package final class YM2608 { // ADPCM-B decode/interpolate at audio output rate (fmgen // ADPCMBMix runs at output `r`, with adplbase scaled to `r`). advanceADPCM() + // Everything below only produces sound. ADPCM above stays: programs + // poll its EOS flag. + guard audioOutputEnabled else { continue } generateFMSamples() advanceRhythm() let ssg = generateSSGSample() diff --git a/Tests/Bubilator88CoreTests/BootSnapshotTests.swift b/Tests/Bubilator88CoreTests/BootSnapshotTests.swift new file mode 100644 index 0000000..a5c72aa --- /dev/null +++ b/Tests/Bubilator88CoreTests/BootSnapshotTests.swift @@ -0,0 +1,65 @@ +import Testing +@testable import Bubilator88Core + +/// The pieces of `BootSnapshot` that decide when to stop. The capture itself +/// needs the BIOS ROMs, which tests do not have; `BootTester --snapshot` +/// covers it against real disks. +@Suite("BootSnapshot") +struct BootSnapshotTests { + + @Test("A black frame has no content") + func blackFrame() { + let pixels = [UInt8](repeating: 0, count: PC88.frameBufferSize) + #expect(BootSnapshot.sample(pixels).content == 0) + } + + /// Fill `rows` 16-pixel-high bands across the full width, lighting every + /// third pixel: roughly what a line of text gives off. + private func textRows(_ rows: Int) -> [UInt8] { + var pixels = [UInt8](repeating: 0, count: PC88.frameBufferSize) + for y in 0..<(rows * 16) { + for x in 0..= BootSnapshot.Parameters().minContent) + } + + @Test("The signature changes when a sampled pixel does") + func signatureTracksChanges() { + var pixels = [UInt8](repeating: 0, count: PC88.frameBufferSize) + let before = BootSnapshot.sample(pixels).signature + pixels[0] = 0x80 + #expect(BootSnapshot.sample(pixels).signature != before) + } + + @Test("BASIC waiting for input is recognised", arguments: [ + "How many files(0-15)?", + "NEC N-88 BASIC Version 2.3\n 45589 Bytes free\n", + "\nOk\n", + ]) + func basicPrompt(_ text: String) { + #expect(BootSnapshot.isAtBASICPrompt(text)) + } + + @Test("Disk BASIC on its way to a game is not", arguments: [ + "How many files(0-15)? 0", + "LION\n 1. ゲームを始める\n", + "Hit Any Key", + ]) + func notBasicPrompt(_ text: String) { + #expect(!BootSnapshot.isAtBASICPrompt(text)) + } +} diff --git a/Tests/Bubilator88CoreTests/YM2608AudioOutputTests.swift b/Tests/Bubilator88CoreTests/YM2608AudioOutputTests.swift new file mode 100644 index 0000000..844ee0c --- /dev/null +++ b/Tests/Bubilator88CoreTests/YM2608AudioOutputTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import FMSynthesis + +/// `audioOutputEnabled = false` skips synthesis for headless runs. It must not +/// change anything the CPU can observe: the status ports, the timer IRQ line +/// and ADPCM end-of-sample. +@Suite("YM2608 audio output switch") +struct YM2608AudioOutputTests { + + private func configure(_ ym: YM2608) { + ym.reset() + // Timer A, period 1024 - 0x3F0, with its flag and IRQ enabled. + ym.writeAddr(0x24); ym.writeData(0xFC) + ym.writeAddr(0x25); ym.writeData(0x00) + ym.writeAddr(0x27); ym.writeData(0x15) + // An FM note and an SSG tone, so synthesis has work to skip. + ym.writeAddr(0xA4); ym.writeData(0x22) + ym.writeAddr(0xA0); ym.writeData(0x69) + ym.writeAddr(0x28); ym.writeData(0xF0) + ym.writeAddr(0x07); ym.writeData(0x3E) + ym.writeAddr(0x08); ym.writeData(0x0F) + // A short ADPCM sample from RAM, played to its end. + let ext: [(UInt8, UInt8)] = [ + (0x01, 0xC0), (0x02, 0x00), (0x03, 0x00), (0x04, 0x01), (0x05, 0x00), + (0x09, 0xFF), (0x0A, 0xFF), (0x0B, 0xFF), (0x00, 0xA0), + ] + for (addr, value) in ext { + ym.writeExtAddr(addr) + ym.writeExtData(value) + } + } + + @Test("Status, IRQ and ADPCM EOS match with synthesis off") + func cpuVisibleStateMatches() { + let on = YM2608() + let off = YM2608() + configure(on) + configure(off) + off.audioOutputEnabled = false + + var sawEOS = false + for _ in 0..<4000 { + on.tick(tStates: 97) + off.tick(tStates: 97) + #expect(on.readStatus() == off.readStatus()) + #expect(on.readExtStatus() == off.readExtStatus()) + #expect(on.irqLineActive == off.irqLineActive) + if on.adpcmStatusFlags & 0x04 != 0 { sawEOS = true } + } + #expect(sawEOS, "the ADPCM sample should have reached its end") + #expect(!on.audioBuffer.isEmpty) + #expect(off.audioBuffer.isEmpty) + } +}