From 4fbda80927b16d139c94d48ac52b4f262109ab82 Mon Sep 17 00:00:00 2001 From: bubio Date: Thu, 24 Sep 2026 13:16:37 +0900 Subject: [PATCH 1/2] Align the createSaveState parameter continuation The extraSections line sat one column left of thumbnail, which scripts/format_all.sh --check reports. Whitespace only. Co-Authored-By: Claude Opus 5.5 --- Sources/Bubilator88Core/SaveStateSerialize.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 512c6861d889afd6d6627aa1f841ba0d59806174 Mon Sep 17 00:00:00 2001 From: bubio Date: Thu, 24 Sep 2026 13:35:18 +0900 Subject: [PATCH 2/2] Add BootSnapshot for disk thumbnails, and a switch to skip audio synthesis BootSnapshot boots a D88 headless (N88-BASIC V2, 4MHz, no Sound Board II, emulated-time RTC) and returns the frame to use as its thumbnail: it stops once the drives are quiet and the screen is stable, or animating without still filling up, and returns nil when the screen is nearly black or the machine is at the BASIC prompt. The Quick Look extension in the app uses it. PC88.audioOutputEnabled = false skips FM, SSG, rhythm and the mix while timers, busy status and ADPCM playback keep running, so the CPU sees no difference; a test checks the status ports, IRQ line and ADPCM EOS match. BootTester gains --snapshot to run the capture over disks and tune its thresholds, and BOOTTEST_AUDIO=0. Co-Authored-By: Claude Opus 5.5 --- Sources/BootTester/main.swift | 71 +++++- Sources/Bubilator88Core/BootSnapshot.swift | 205 ++++++++++++++++++ Sources/Bubilator88Core/PC88.swift | 9 + Sources/FMSynthesis/YM2608.swift | 9 + .../BootSnapshotTests.swift | 65 ++++++ .../YM2608AudioOutputTests.swift | 54 +++++ 6 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 Sources/Bubilator88Core/BootSnapshot.swift create mode 100644 Tests/Bubilator88CoreTests/BootSnapshotTests.swift create mode 100644 Tests/Bubilator88CoreTests/YM2608AudioOutputTests.swift 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/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) + } +}