diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4e272aef..0200dd58 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -88,6 +88,7 @@ set(COMMON_SOURCES
src/BlueSCSI_disk.cpp
src/BlueSCSI_cdrom.cpp
src/BlueSCSI_tape.cpp
+ src/BlueSCSI_printer.cpp
src/BlueSCSI_log.cpp
src/BlueSCSI_log_trace.cpp
src/BlueSCSI_blink.cpp
diff --git a/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld b/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld
index ed721862..4931c40a 100644
--- a/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld
+++ b/lib/BlueSCSI_platform_RP2MCU/rp2040-template.ld
@@ -159,6 +159,8 @@ SECTIONS
*BlueSCSI_settings.cpp.o(.text .text*)
*QuirksCheck.cpp.o(.text .text*)
*(.text*scsiDiskFilenameValid*)
+ *(.text*scsiDiskFolderIs*)
+ *(.text*scsiDiskFolderContainsCueSheet*)
*(.text*scsiDiskOpenHDDImage*)
*(.text*scsiDiskGetNextImageName*)
*(.text*scsiDiskProgramRomDrive*)
@@ -189,6 +191,7 @@ SECTIONS
*(.text*scsiTapeCommand*)
*(.text*scsiCDRomCommand*)
*(.text*scsiMOCommand*)
+ *BlueSCSI_printer.cpp.o(.text .text*)
*(.text*doReadCD*)
*(.text*doReadTOC*)
*(.text*doReadHeader*)
diff --git a/lib/SCSI2SD/include/scsi2sd.h b/lib/SCSI2SD/include/scsi2sd.h
index d1d39f28..e321d3df 100755
--- a/lib/SCSI2SD/include/scsi2sd.h
+++ b/lib/SCSI2SD/include/scsi2sd.h
@@ -80,6 +80,7 @@ typedef enum
S2S_CFG_NETWORK = 6,
S2S_CFG_ZIP100 = 7,
S2S_CFG_AMIGAWIFI = 8,
+ S2S_CFG_PRINTER = 9,
S2S_CFG_NOT_SET = 255
} S2S_CFG_TYPE;
diff --git a/lib/SCSI2SD/src/firmware/inquiry.c b/lib/SCSI2SD/src/firmware/inquiry.c
index c049597a..90fa3f72 100755
--- a/lib/SCSI2SD/src/firmware/inquiry.c
+++ b/lib/SCSI2SD/src/firmware/inquiry.c
@@ -224,7 +224,14 @@ void s2s_scsiInquiry()
case S2S_CFG_AMIGAWIFI:
scsiDev.data[2] = 0x01; // Page code.
break;
-
+
+ case S2S_CFG_PRINTER:
+ // Apple LaserWriter IISC reports pre-SCSI-2 (ANSI version 0,
+ // response format SCSI-1 CCS).
+ scsiDev.data[2] = 0x00;
+ scsiDev.data[3] = 0x00;
+ break;
+
default:
// Accept defaults for a fixed disk.
break;
@@ -282,8 +289,21 @@ uint32_t s2s_getStandardInquiry(
out[7] = 0x00; // Disable sync and linked commands
out[4] = 0x75; // 117 length
}
+ if(cfg->deviceType == S2S_CFG_PRINTER)
+ {
+ // Apple LaserWriter IISC trailing vendor-specific bytes (firmware/ROM
+ // rev). The driver only checks the first 36 bytes, but TattleTech and
+ // other diagnostics may read these.
+ static const uint8_t LaserWriterVendor[] =
+ {0x00, 0x00, 0x00, 0xFE, 0x20, 0x27, 0x20, 0xFF};
+ if (size + sizeof(LaserWriterVendor) <= maxlen)
+ {
+ memcpy(&out[size], LaserWriterVendor, sizeof(LaserWriterVendor));
+ size += sizeof(LaserWriterVendor);
+ }
+ }
// Iomega already has a vendor inquiry
- if(cfg->deviceType != S2S_CFG_NETWORK && cfg->deviceType != S2S_CFG_ZIP100) {
+ else if(cfg->deviceType != S2S_CFG_NETWORK && cfg->deviceType != S2S_CFG_ZIP100) {
memcpy(&out[size], INQUIRY_NAME, sizeof(INQUIRY_NAME) - 1);
size += sizeof(INQUIRY_NAME) - 1;
out[size++] = TOOLBOX_API;
@@ -322,6 +342,11 @@ uint8_t getDeviceTypeQualifier()
return 0x03;
break;
+ case S2S_CFG_PRINTER:
+ // printer device
+ return 0x02;
+ break;
+
default:
// Accept defaults for a fixed disk.
return 0;
diff --git a/lib/SCSI2SD/src/firmware/printer.h b/lib/SCSI2SD/src/firmware/printer.h
new file mode 100644
index 00000000..8d4114e2
--- /dev/null
+++ b/lib/SCSI2SD/src/firmware/printer.h
@@ -0,0 +1,25 @@
+/**
+* Copyright (C) 2026 Eric Helgeson
+*
+* This file is part of BlueSCSI
+*
+* This program is free software: you can redistribute it and/or modify
+* it under the terms of the GNU General Public License as published by
+* the Free Software Foundation, either version 3 of the License, or
+* (at your option) any later version.
+*
+* This program is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU General Public License for more details.
+*
+* You should have received a copy of the GNU General Public License
+* along with this program. If not, see .
+**/
+
+#ifndef PRINTER_H
+#define PRINTER_H
+
+int scsiPrinterCommand(void);
+
+#endif
diff --git a/lib/SCSI2SD/src/firmware/scsi.c b/lib/SCSI2SD/src/firmware/scsi.c
index 36e9d28e..83776ea8 100755
--- a/lib/SCSI2SD/src/firmware/scsi.c
+++ b/lib/SCSI2SD/src/firmware/scsi.c
@@ -2,6 +2,7 @@
// Copyright (c) 2023 joshua stein
// Copyright (c) 2023 Andrea Ottaviani
// Copyright (c) 2024-2025 Rabbit Hole Computing™
+// Copyright (c) 2026 Eric Helgeson
//
// This file is part of SCSI2SD.
//
@@ -33,6 +34,7 @@
#include "bsp.h"
#include "cdrom.h"
#include "network.h"
+#include "printer.h"
#include "tape.h"
#include "mo.h"
#include "vendor.h"
@@ -717,6 +719,7 @@ static void process_Command()
((cfg->deviceType == S2S_CFG_AMIGAWIFI && amigaWifiCommand())) ||
((cfg->deviceType == S2S_CFG_NETWORK && scsiNetworkCommand())) ||
#endif // BLUESCSI_NETWORK
+ ((cfg->deviceType == S2S_CFG_PRINTER) && scsiPrinterCommand()) ||
((cfg->deviceType == S2S_CFG_MO) && scsiMOCommand()))
{
// Already handled.
diff --git a/src/BlueSCSI.cpp b/src/BlueSCSI.cpp
index 828df74f..2f86e289 100644
--- a/src/BlueSCSI.cpp
+++ b/src/BlueSCSI.cpp
@@ -251,6 +251,8 @@ static const char * typeToChar(int deviceType)
return "Removable";
case S2S_CFG_ZIP100:
return "ZIP100";
+ case S2S_CFG_PRINTER:
+ return "Printer";
default:
return "Unknown";
}
@@ -494,7 +496,7 @@ bool findHDDImages()
}
char name[MAX_FILE_PATH+1];
- if(!file.isDir() || scsiDiskFolderContainsCueSheet(&file) || scsiDiskFolderIsTapeFolder(&file)) {
+ if(!file.isDir() || scsiDiskFolderContainsCueSheet(&file) || scsiDiskFolderIsTapeFolder(&file) || scsiDiskFolderIsPrinterFolder(&file)) {
file.getName(name, MAX_FILE_PATH+1);
file.close();
@@ -555,12 +557,13 @@ bool findHDDImages()
bool is_re = (tolower(name[0]) == 'r' && tolower(name[1]) == 'e');
bool is_tp = (tolower(name[0]) == 't' && tolower(name[1]) == 'p');
bool is_zp = (tolower(name[0]) == 'z' && tolower(name[1]) == 'p');
+ bool is_pr = (tolower(name[0]) == 'p' && tolower(name[1]) == 'r');
#ifdef BLUESCSI_NETWORK
bool is_ne = (tolower(name[0]) == 'n' && tolower(name[1]) == 'e');
bool is_am = (tolower(name[0]) == 'a' && tolower(name[1]) == 'm');
#endif // BLUESCSI_NETWORK
- if (is_hd || is_cd || is_fd || is_mo || is_re || is_tp || is_zp
+ if (is_hd || is_cd || is_fd || is_mo || is_re || is_tp || is_zp || is_pr
#ifdef BLUESCSI_NETWORK
|| is_ne || is_am
#endif // BLUESCSI_NETWORK
@@ -646,6 +649,7 @@ bool findHDDImages()
if (is_re) type = S2S_CFG_REMOVABLE;
if (is_tp) type = S2S_CFG_SEQUENTIAL;
if (is_zp) type = S2S_CFG_ZIP100;
+ if (is_pr) type = S2S_CFG_PRINTER;
g_scsi_settings.initDevice(id & 7, type);
// Open the image file
@@ -700,7 +704,8 @@ bool findHDDImages()
{
int capacity_kB = ((uint64_t)cfg->scsiSectors * cfg->bytesPerSector) / 1024;
- if (cfg->deviceType == S2S_CFG_NETWORK || cfg->deviceType == S2S_CFG_AMIGAWIFI)
+ if (cfg->deviceType == S2S_CFG_NETWORK || cfg->deviceType == S2S_CFG_AMIGAWIFI ||
+ cfg->deviceType == S2S_CFG_PRINTER)
{
logmsg("ID: ", (int)s2s_getTargetId(cfg),
", Type: ", typeToChar((int)cfg->deviceType));
diff --git a/src/BlueSCSI_config.h b/src/BlueSCSI_config.h
index 58f629a4..210f365b 100644
--- a/src/BlueSCSI_config.h
+++ b/src/BlueSCSI_config.h
@@ -3,6 +3,7 @@
*
* ZuluSCSI™ - Copyright (c) 2022-2025 Rabbit Hole Computing™
* Portions copyright (c) 2023 joshua stein
+ * Copyright (c) 2026 Eric Helgeson
*
* ZuluSCSI™ firmware is licensed under the GPL version 3 or any later version.
*
@@ -105,6 +106,7 @@
#define DRIVEINFO_NETWORK {"Dayna", "SCSI/Link", "2.0f", ""}
#define DRIVEINFO_TAPE {"BLUESCSI", "TAPE", PLATFORM_REVISION, ""}
#define DRIVEINFO_AMIGAWIFI {"AmigaNET", "SCSI/Link", "1.0f", ""}
+#define DRIVEINFO_PRINTER {"APPLE", "PERSONAL LASER", "1.00", ""}
// Default block size
#define DEFAULT_BLOCKSIZE 512
@@ -126,6 +128,7 @@
#define APPLE_DRIVEINFO_MAGOPT {"MOST", "RMD-5200", PLATFORM_REVISION, "1.0"}
#define APPLE_DRIVEINFO_NETWORK {"Dayna", "SCSI/Link", "2.0f", ""}
#define APPLE_DRIVEINFO_TAPE {"BlueSCSI", "APPLE_TAPE", PLATFORM_REVISION, ""}
+#define APPLE_DRIVEINFO_PRINTER {"APPLE", "PERSONAL LASER", "1.00", ""}
// Default Iomega ZIP drive information
#define IOMEGA_DRIVEINFO_ZIP100 {"IOMEGA", "ZIP 100", "D.13", ""}
diff --git a/src/BlueSCSI_disk.cpp b/src/BlueSCSI_disk.cpp
index a64c72fd..4fc2c10b 100644
--- a/src/BlueSCSI_disk.cpp
+++ b/src/BlueSCSI_disk.cpp
@@ -504,7 +504,7 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_lun, in
img.scsiId = target_idx | S2S_CFG_TARGET_ENABLED;
img.sdSectorStart = 0;
- if (img.scsiSectors == 0 && type != S2S_CFG_NETWORK && type != S2S_CFG_AMIGAWIFI && !img.file.isFolder())
+ if (img.scsiSectors == 0 && type != S2S_CFG_NETWORK && type != S2S_CFG_AMIGAWIFI && type != S2S_CFG_PRINTER && !img.file.isFolder())
{
logmsg("---- Error: image file ", filename, " is empty");
img.file.close();
@@ -607,8 +607,29 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_lun, in
logmsg("---- Zip 100 disk (", (int)img.file.size(), " bytes) is not exactly ", ZIP100_DISK_SIZE, " bytes, may not work correctly");
}
}
+ else if (type == S2S_CFG_PRINTER)
+ {
+ logmsg("---- Configuring as printer (LaserWriter IISC)");
+ img.deviceType = S2S_CFG_PRINTER;
+ // The PR/ folder doubles as the spool target. If the user
+ // registered the device with a placeholder file, create the
+ // folder for them so they have a clear place to look for prints.
+ char dirpath[16];
+ snprintf(dirpath, sizeof(dirpath), "/PR%d", target_idx);
+ if (!SD.exists(dirpath))
+ {
+ if (SD.mkdir(dirpath))
+ {
+ logmsg("---- Created printer spool folder ", dirpath);
+ }
+ else
+ {
+ logmsg("---- WARNING: failed to create printer spool folder ", dirpath);
+ }
+ }
+ }
- if (type != S2S_CFG_OPTICAL && type != S2S_CFG_NETWORK && type != S2S_CFG_AMIGAWIFI)
+ if (type != S2S_CFG_OPTICAL && type != S2S_CFG_NETWORK && type != S2S_CFG_AMIGAWIFI && type != S2S_CFG_PRINTER)
{
autoConfigGeometry(img);
}
@@ -825,7 +846,21 @@ bool scsiDiskFolderIsTapeFolder(FsFile *dir)
dir->getName(filename, sizeof(filename));
// string starts with 'tp', the 3rd character is a SCSI ID, and it has more 3 charters
// e.g. "tp0 - tape 01"
- if (strlen(filename) > 3 && strncasecmp("tp", filename, 2) == 0
+ if (strlen(filename) > 3 && strncasecmp("tp", filename, 2) == 0
+ && filename[2] >= '0' && filename[2] - '0' < NUM_SCSIID)
+ {
+ return true;
+ }
+ return false;
+}
+
+bool scsiDiskFolderIsPrinterFolder(FsFile *dir)
+{
+ // A PR/ directory (e.g. "PR4") both registers the printer and acts
+ // as the spool target. The 3rd character must be a valid SCSI ID digit.
+ char filename[MAX_FILE_PATH + 1];
+ dir->getName(filename, sizeof(filename));
+ if (strlen(filename) >= 3 && strncasecmp("pr", filename, 2) == 0
&& filename[2] >= '0' && filename[2] - '0' < NUM_SCSIID)
{
return true;
diff --git a/src/BlueSCSI_disk.h b/src/BlueSCSI_disk.h
index 7ff60caf..e02091b2 100644
--- a/src/BlueSCSI_disk.h
+++ b/src/BlueSCSI_disk.h
@@ -154,6 +154,11 @@ bool scsiDiskFolderContainsCueSheet(FsFile *dir);
// Checks if the directory name is for multi tagged tapes
bool scsiDiskFolderIsTapeFolder(FsFile *dir);
+// Checks if the directory name matches the PR/ printer convention
+// (e.g. "PR4"). Such a folder both registers a SCSI printer and serves
+// as the spool target for captured print jobs.
+bool scsiDiskFolderIsPrinterFolder(FsFile *dir);
+
// Clear the ROM drive header from flash
bool scsiDiskClearRomDrive();
// Program ROM drive and rename image file
diff --git a/src/BlueSCSI_printer.cpp b/src/BlueSCSI_printer.cpp
new file mode 100644
index 00000000..da0c865e
--- /dev/null
+++ b/src/BlueSCSI_printer.cpp
@@ -0,0 +1,497 @@
+/*
+ * Copyright (c) 2026 Eric Helgeson
+ *
+ * This file is part of BlueSCSI.
+ *
+ * BlueSCSI is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * BlueSCSI is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+/* Apple LaserWriter IISC SCSI printer emulation.
+ *
+ * Spools raw printer SCSI commands and data into /PR/print_NNNN.bin
+ * for offline post-processing. The framebuffer rasterization is done by the
+ * host driver; we just capture the byte stream coming over the bus.
+ *
+ * Spool file format: a stream of records, each
+ * uint8_t cdb[6]; // raw 6-byte CDB
+ * uint32_t payload_len; // big-endian byte count
+ * uint8_t payload[payload_len];
+ *
+ * PRINT (0x0A) with bit 7 of cdb[5] set finalizes the file. Any later
+ * command rotates to a new spool file (next sequence number).
+ *
+ * The SCSI command-set emulated here is based on demik's reverse-engineering
+ * research of the LaserWriter IISC controller and the System 6 (6.0.5)
+ * driver, posted at
+ * https://web.archive.org/web/20260425223154/https://68kmla.org/bb/threads/laserwriter-iisc-scsi-protocol-reversed-for-emulator-implementation.52165/
+ */
+
+#include "BlueSCSI_printer.h"
+#include "BlueSCSI_disk.h"
+#include "BlueSCSI_log.h"
+#include "BlueSCSI_config.h"
+#include
+#include
+#include
+#include
+
+extern "C" {
+#include
+#include
+}
+
+extern SdFs SD;
+
+// If the current spool file goes this long without seeing any band data
+// (a 0x05 READ BLOCK LIMITS record), we treat it as abandoned — discard
+// the file and recycle the sequence number. This handles the trailing
+// SETUP the LaserWriter driver always sends after PRINT.
+static constexpr uint32_t PRINTER_ABANDON_MS = 10 * 1000;
+
+// Single shared printer state. Multiple targets configured as printers
+// share this slot.
+namespace {
+struct PrinterState
+{
+ FsFile spool_file;
+ uint32_t next_seq = 0;
+ uint32_t last_write_ms = 0;
+ int8_t owner_id = -1; // SCSI target ID owning the slot, -1 = unowned
+ bool seq_initialized = false;
+ bool dir_ready = false;
+ bool has_band_data = false; // any 0x05 record written to current file?
+ char spool_dir[8] = {0}; // "/PR" — fits up to /PR15 + NUL
+};
+
+PrinterState g_printer_state;
+
+// Pending stage-2 work for the 0x05 READ BLOCK LIMITS command. The header
+// is received via DATA_OUT + postDataOutHook; the hook then synchronously
+// pulls the mask bytes off the bus and writes them straight to the spool.
+struct PrinterPendingMask
+{
+ uint8_t target_id;
+ bool active;
+};
+
+PrinterPendingMask g_printer_pending = {0, false};
+
+bool printerEnsureDir(PrinterState &ps, int target_id)
+{
+ if (ps.dir_ready) return true;
+
+ // PR/ folder doubles as both the device-registration trigger (in
+ // findHDDImages) and the spool target. scsiDiskOpenHDDImage creates it
+ // if the user only supplied a placeholder file, so it should normally
+ // exist by the time we get here.
+ snprintf(ps.spool_dir, sizeof(ps.spool_dir), "/PR%d", target_id);
+
+ FsFile dir;
+ if (!dir.open(ps.spool_dir))
+ {
+ if (!SD.mkdir(ps.spool_dir) || !dir.open(ps.spool_dir))
+ {
+ logmsg("PRINTER: could not open or create spool dir ", ps.spool_dir);
+ return false;
+ }
+ }
+ dir.close();
+ ps.dir_ready = true;
+ return true;
+}
+
+// Walk the spool dir once to find the highest existing print_NNNN.bin
+// sequence number, so a reboot doesn't overwrite previous jobs.
+void printerInitSeq(PrinterState &ps)
+{
+ if (ps.seq_initialized) return;
+ ps.seq_initialized = true;
+ ps.next_seq = 0;
+
+ FsFile dir;
+ if (!dir.open(ps.spool_dir)) return;
+
+ FsFile entry;
+ char name[64];
+ while (entry.openNext(&dir, O_RDONLY))
+ {
+ if (entry.getName(name, sizeof(name)))
+ {
+ // Match "print_NNNN.bin" (case-insensitive).
+ if (strncasecmp(name, "print_", 6) == 0)
+ {
+ uint32_t n = (uint32_t)strtoul(&name[6], NULL, 10);
+ if (n + 1 > ps.next_seq) ps.next_seq = n + 1;
+ }
+ }
+ entry.close();
+ }
+ dir.close();
+}
+
+// If the current file is open, has no band data, and has gone idle past
+// PRINTER_ABANDON_MS, delete it and recycle the sequence number. This is
+// what cleans up the trailing-SETUP-after-PRINT case for single-page
+// jobs. Returns true iff a file was discarded.
+bool printerDiscardIfAbandoned(PrinterState &ps)
+{
+ if (!ps.spool_file.isOpen()) return false;
+ if (ps.has_band_data) return false;
+ if ((uint32_t)(platform_millis() - ps.last_write_ms) < PRINTER_ABANDON_MS)
+ return false;
+
+ // The currently-open file was assigned next_seq-1 when opened.
+ char path[64];
+ snprintf(path, sizeof(path), "%s/print_%04lu.bin",
+ ps.spool_dir, (unsigned long)(ps.next_seq - 1));
+ ps.spool_file.close();
+ SD.remove(path);
+ ps.next_seq--;
+ logmsg("PRINTER: discarded abandoned spool ", path);
+ return true;
+}
+
+// Switch ownership of the shared spool slot to `target_id`. If a different
+// target was using it, finalize that target's spool first.
+void printerSwitchOwner(int target_id)
+{
+ PrinterState &ps = g_printer_state;
+ if (ps.owner_id == target_id) return;
+
+ if (ps.spool_file.isOpen())
+ {
+ ps.spool_file.sync();
+ ps.spool_file.close();
+ }
+ ps.owner_id = (int8_t)target_id;
+ ps.next_seq = 0;
+ ps.last_write_ms = 0;
+ ps.seq_initialized = false;
+ ps.dir_ready = false;
+ ps.has_band_data = false;
+ ps.spool_dir[0] = 0;
+}
+
+bool printerOpenSpoolIfNeeded(int target_id)
+{
+ printerSwitchOwner(target_id);
+ PrinterState &ps = g_printer_state;
+
+ // Reclaim the slot if the previous file was an unused trailing-SETUP
+ // orphan. Must run before the isOpen() short-circuit below.
+ printerDiscardIfAbandoned(ps);
+
+ if (ps.spool_file.isOpen()) return true;
+
+ if (!printerEnsureDir(ps, target_id)) return false;
+ printerInitSeq(ps);
+
+ char path[64];
+ snprintf(path, sizeof(path), "%s/print_%04lu.bin",
+ ps.spool_dir, (unsigned long)ps.next_seq);
+
+ // Use SD.open() (returns FsFile by value) instead of reusing the closed
+ // member; SdFat doesn't fully reset internal state on close() and a second
+ // .open() on the same FsFile fails silently for a different path.
+ ps.spool_file = SD.open(path, O_WRONLY | O_CREAT | O_TRUNC);
+ if (!ps.spool_file.isOpen())
+ {
+ logmsg("PRINTER: failed to open spool file ", path);
+ return false;
+ }
+ logmsg("PRINTER: spooling to ", path);
+ ps.next_seq++;
+ ps.has_band_data = false;
+ ps.last_write_ms = platform_millis();
+ return true;
+}
+
+void printerCloseSpool(int target_id)
+{
+ PrinterState &ps = g_printer_state;
+ if (ps.spool_file.isOpen())
+ {
+ ps.spool_file.sync();
+ ps.spool_file.close();
+ }
+}
+
+void printerBuildRecordHeader(uint8_t out[10], uint32_t payload_len)
+{
+ memcpy(out, scsiDev.cdb, 6);
+ out[6] = (uint8_t)(payload_len >> 24);
+ out[7] = (uint8_t)(payload_len >> 16);
+ out[8] = (uint8_t)(payload_len >> 8);
+ out[9] = (uint8_t)(payload_len);
+}
+
+// Record a command (CDB + optional payload) into the spool stream.
+// Opens the spool file (creating a new one if needed) before writing.
+void printerRecord(int target_id, const uint8_t *payload, uint32_t payload_len)
+{
+ if (!printerOpenSpoolIfNeeded(target_id)) return;
+
+ PrinterState &ps = g_printer_state;
+ uint8_t hdr[10];
+ printerBuildRecordHeader(hdr, payload_len);
+
+ ps.spool_file.write(hdr, sizeof(hdr));
+ if (payload && payload_len > 0)
+ {
+ ps.spool_file.write(payload, payload_len);
+ }
+ ps.last_write_ms = platform_millis();
+}
+
+// Streams `count` bytes from the SCSI bus into the spool file, in chunks
+// sized so they fit in scsiDev.data[] without consuming extra RAM.
+void printerStreamMaskFromBus(int target_id, uint32_t count)
+{
+ PrinterState &ps = g_printer_state;
+ scsiEnterPhase(DATA_OUT);
+ while (count > 0)
+ {
+ uint32_t chunk = count;
+ if (chunk > sizeof(scsiDev.data)) chunk = sizeof(scsiDev.data);
+
+ int parityError = 0;
+ scsiRead(scsiDev.data, chunk, &parityError);
+
+ if (ps.spool_file.isOpen())
+ {
+ ps.spool_file.write(scsiDev.data, chunk);
+ }
+ count -= chunk;
+ }
+}
+
+void printerHandleReadBlockLimitsHeader();
+
+// Hook fires once the 10-byte header has been received in scsiDev.data[].
+void printerHandleReadBlockLimitsHeader()
+{
+ scsiDev.postDataOutHook = NULL;
+ if (!g_printer_pending.active) return;
+ int target_id = g_printer_pending.target_id;
+ g_printer_pending.active = false;
+
+ // Header layout (big-endian):
+ // bytes 0..1 = x1, 2..3 = y1, 4..5 = x2, 6..7 = y2,
+ // bytes 8..9 = padding | mask-type bits.
+ uint16_t x1 = ((uint16_t)scsiDev.data[0] << 8) | scsiDev.data[1];
+ uint16_t y1 = ((uint16_t)scsiDev.data[2] << 8) | scsiDev.data[3];
+ uint16_t x2 = ((uint16_t)scsiDev.data[4] << 8) | scsiDev.data[5];
+ uint16_t y2 = ((uint16_t)scsiDev.data[6] << 8) | scsiDev.data[7];
+
+ uint32_t width = (x2 > x1) ? (uint32_t)(x2 - x1) : 0;
+ uint32_t height = (y2 > y1) ? (uint32_t)(y2 - y1) : 0;
+ uint32_t mask_bytes = ((width + 7) / 8) * height;
+
+ dbgmsg("PRINTER: band ", (int)x1, ",", (int)y1, " -> ",
+ (int)x2, ",", (int)y2, " mask=", (int)mask_bytes, "B");
+
+ if (printerOpenSpoolIfNeeded(target_id))
+ {
+ // Write the band record header + the 10-byte band header payload
+ // directly; the mask streams in via printerStreamMaskFromBus below.
+ uint8_t hdr[10];
+ printerBuildRecordHeader(hdr, 10 + mask_bytes);
+ PrinterState &ps = g_printer_state;
+ ps.spool_file.write(hdr, sizeof(hdr));
+ ps.spool_file.write(scsiDev.data, 10);
+ ps.has_band_data = true; // protects file from abandonment
+ ps.last_write_ms = platform_millis();
+ }
+
+ // Stage 3: blind data write of mask_bytes. We must not reselect or the
+ // host's "blind" write is lost.
+ if (mask_bytes > 0)
+ {
+ printerStreamMaskFromBus(target_id, mask_bytes);
+ g_printer_state.last_write_ms = platform_millis();
+ }
+
+ scsiDev.phase = STATUS;
+ scsiDev.status = GOOD;
+}
+
+// Hook for FORMAT/SETUP-style data-out: record the CDB + payload now that
+// scsiDev.data[] holds the parameter list.
+void printerHandlePayloadReceived()
+{
+ int target_id = scsiDev.target->targetId;
+ scsiDev.postDataOutHook = NULL;
+ printerRecord(target_id, scsiDev.data, scsiDev.dataLen);
+ scsiDev.phase = STATUS;
+ scsiDev.status = GOOD;
+}
+
+// SETUP (0x06) completion hook. Logs a human-readable paper name on a
+// full-page setup (x_off == y_off == 0) using the LaserWriter IISC Service
+// Manual printable-area table, then defers to the generic record/spool
+// path. Per-region setups (non-zero offsets) are spooled silently.
+void printerHandleSetupReceived()
+{
+ int target_id = scsiDev.target->targetId;
+ scsiDev.postDataOutHook = NULL;
+
+ if (scsiDev.dataLen >= 8)
+ {
+ uint16_t y_off = ((uint16_t)scsiDev.data[0] << 8) | scsiDev.data[1];
+ uint16_t x_off = ((uint16_t)scsiDev.data[2] << 8) | scsiDev.data[3];
+ uint16_t w = ((uint16_t)scsiDev.data[4] << 8) | scsiDev.data[5];
+ uint16_t h = ((uint16_t)scsiDev.data[6] << 8) | scsiDev.data[7];
+
+ if (x_off == 0 && y_off == 0)
+ {
+ // Printable-area dimensions from the LaserWriter IISC Service
+ // Manual. These are NOT full-paper sizes (host-side renderers
+ // pick paper independently); they're what the driver sends.
+ const char *paper = "Custom";
+ if (w == 2400 && h == 3175) paper = "US Letter (8.0\"x10.6\")";
+ else if (w == 2000 && h == 3750) paper = "US Legal (6.72\"x12.5\")";
+ else if (w == 2400 && h == 3375) paper = "A4 (8.0\"x11.27\")";
+ else if (w == 2000 && h == 2825) paper = "B5 (6.67\"x9.43\")";
+ else if (w == 1136 && h == 2725) paper = "#10 Envelope (3.84\"x9.1\")";
+
+ logmsg("PRINTER: page setup ", (int)w, "x", (int)h,
+ " printable area: ", paper);
+ }
+ }
+
+ printerRecord(target_id, scsiDev.data, scsiDev.dataLen);
+ scsiDev.phase = STATUS;
+ scsiDev.status = GOOD;
+}
+
+void printerStartDataOut(uint32_t len)
+{
+ scsiDev.dataLen = len;
+ scsiDev.dataPtr = 0;
+ scsiDev.phase = DATA_OUT;
+ scsiDev.postDataOutHook = printerHandlePayloadReceived;
+}
+
+} // namespace
+
+extern "C" int scsiPrinterCommand(void)
+{
+ uint8_t command = scsiDev.cdb[0];
+ int target_id = scsiDev.target->targetId;
+
+ switch (command)
+ {
+ case 0x04:
+ {
+ // FORMAT. Driver passes 4 bytes of timing data which we capture
+ // for the spool stream; the bytes themselves don't matter for
+ // emulation.
+ uint32_t len = scsiDev.cdb[4];
+ if (len == 0)
+ {
+ printerRecord(target_id, NULL, 0);
+ scsiDev.phase = STATUS;
+ scsiDev.status = GOOD;
+ }
+ else
+ {
+ printerStartDataOut(len);
+ }
+ return 1;
+ }
+
+ case 0x05:
+ {
+ // READ BLOCK LIMITS — vendor 2-stage. Stage 1 receives the
+ // 10-byte band header; the postDataOutHook does the blind read
+ // of the mask data.
+ g_printer_pending.target_id = (uint8_t)target_id;
+ g_printer_pending.active = true;
+ scsiDev.dataLen = 10;
+ scsiDev.dataPtr = 0;
+ scsiDev.phase = DATA_OUT;
+ scsiDev.postDataOutHook = printerHandleReadBlockLimitsHeader;
+ return 1;
+ }
+
+ case 0x06:
+ {
+ // SETUP — page setup. cdb[4] is parameter list length (8).
+ // Always opens a spool file. If no bands follow within
+ // PRINTER_ABANDON_MS, the orphan is reaped on the next command.
+ // Use a SETUP-specific completion hook so we can log the
+ // recognised paper size on a full-page setup.
+ uint32_t len = scsiDev.cdb[4];
+ if (len == 0)
+ {
+ printerRecord(target_id, NULL, 0);
+ scsiDev.phase = STATUS;
+ scsiDev.status = GOOD;
+ }
+ else
+ {
+ scsiDev.dataLen = len;
+ scsiDev.dataPtr = 0;
+ scsiDev.phase = DATA_OUT;
+ scsiDev.postDataOutHook = printerHandleSetupReceived;
+ }
+ return 1;
+ }
+
+ case 0x0A:
+ {
+ // PRINT. Bit 7 of cdb[5] = "actually print this page".
+ // Only record/finalize if real band data has been written —
+ // PRINT with no preceding bands is just the driver acking the
+ // engine and has nothing to print.
+ PrinterState &ps = g_printer_state;
+ bool finalize = (scsiDev.cdb[5] & 0x80) != 0;
+
+ if (ps.has_band_data)
+ {
+ printerRecord(target_id, NULL, 0);
+ if (finalize)
+ {
+ logmsg("PRINTER: page complete, closing spool (control=",
+ bytearray(&scsiDev.cdb[5], 1), ")");
+ printerCloseSpool(target_id);
+ }
+ }
+ scsiDev.phase = STATUS;
+ scsiDev.status = GOOD;
+ return 1;
+ }
+
+ case 0x08: // READ(6)
+ case 0x28: // READ(10)
+ case 0x2A: // WRITE(10)
+ case 0x25: // READ CAPACITY
+ // The LaserWriter is print-only and uses group 0 only. Hosts
+ // probing the bus may still issue these — reject silently with
+ // INVALID_COMMAND_OPERATION_CODE rather than letting them fall
+ // through to scsiDiskCommand and log a misleading "exceeding
+ // image size" warning against the zero-size placeholder file.
+ scsiDev.status = CHECK_CONDITION;
+ scsiDev.target->sense.code = ILLEGAL_REQUEST;
+ scsiDev.target->sense.asc = INVALID_COMMAND_OPERATION_CODE;
+ scsiDev.phase = STATUS;
+ return 1;
+
+ default:
+ // Let the standard handlers deal with INQUIRY (0x12),
+ // REQUEST SENSE (0x03), TEST UNIT READY (0x00),
+ // MODE SELECT (0x15), MODE SENSE (0x1A), etc.
+ return 0;
+ }
+}
diff --git a/src/BlueSCSI_printer.h b/src/BlueSCSI_printer.h
new file mode 100644
index 00000000..00906018
--- /dev/null
+++ b/src/BlueSCSI_printer.h
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2026 Eric Helgeson
+ *
+ * This file is part of BlueSCSI.
+ *
+ * BlueSCSI is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * BlueSCSI is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+// Apple LaserWriter IISC SCSI printer emulation.
+
+#ifndef BLUESCSI_PRINTER_H
+#define BLUESCSI_PRINTER_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Returns 1 if the command was handled by the printer layer, 0 to fall
+// through to the generic disk handler.
+int scsiPrinterCommand(void);
+
+// Format of the spool file (per-print-job, /PR/print_NNNN.bin):
+//
+// repeat:
+// uint8_t cdb[6]; // raw 6-byte CDB
+// uint32_t payload_len; // big-endian byte count
+// uint8_t payload[payload_len];
+//
+// The PRINT (0x0A) command with bit 7 of byte 5 set marks end-of-job and
+// closes the file. SETUP (0x06) at the start of a new job truncates the
+// previous file (PRINT may not have been seen between jobs on cancel).
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // BLUESCSI_PRINTER_H
diff --git a/src/BlueSCSI_settings.cpp b/src/BlueSCSI_settings.cpp
index 5ec06827..c2cc35b5 100644
--- a/src/BlueSCSI_settings.cpp
+++ b/src/BlueSCSI_settings.cpp
@@ -129,6 +129,7 @@ void BlueSCSISettings::setDefaultDriveInfo(uint8_t scsiId, const char *presetNam
static const char * const driveinfo_network[4] = DRIVEINFO_NETWORK;
static const char * const driveinfo_tape[4] = DRIVEINFO_TAPE;
static const char * const driveinfo_amigawifi[4] = DRIVEINFO_AMIGAWIFI;
+ static const char * const driveinfo_printer[4] = DRIVEINFO_PRINTER;
static const char * const apl_driveinfo_fixed[4] = APPLE_DRIVEINFO_FIXED;
static const char * const apl_driveinfo_removable[4] = APPLE_DRIVEINFO_REMOVABLE;
@@ -137,6 +138,7 @@ void BlueSCSISettings::setDefaultDriveInfo(uint8_t scsiId, const char *presetNam
static const char * const apl_driveinfo_magopt[4] = APPLE_DRIVEINFO_MAGOPT;
static const char * const apl_driveinfo_network[4] = APPLE_DRIVEINFO_NETWORK;
static const char * const apl_driveinfo_tape[4] = APPLE_DRIVEINFO_TAPE;
+ static const char * const apl_driveinfo_printer[4] = APPLE_DRIVEINFO_PRINTER;
static const char * const iomega_driveinfo_removeable[4] = IOMEGA_DRIVEINFO_ZIP100;
@@ -197,6 +199,7 @@ void BlueSCSISettings::setDefaultDriveInfo(uint8_t scsiId, const char *presetNam
case S2S_CFG_SEQUENTIAL: driveinfo = apl_driveinfo_tape; break;
case S2S_CFG_ZIP100: driveinfo = iomega_driveinfo_removeable; break;
case S2S_CFG_AMIGAWIFI: driveinfo = driveinfo_amigawifi; break;
+ case S2S_CFG_PRINTER: driveinfo = apl_driveinfo_printer; break;
default: driveinfo = apl_driveinfo_fixed; break;
}
}
@@ -214,6 +217,7 @@ void BlueSCSISettings::setDefaultDriveInfo(uint8_t scsiId, const char *presetNam
case S2S_CFG_AMIGAWIFI: driveinfo = driveinfo_amigawifi; break;
case S2S_CFG_SEQUENTIAL: driveinfo = driveinfo_tape; break;
case S2S_CFG_ZIP100: driveinfo = iomega_driveinfo_removeable; break;
+ case S2S_CFG_PRINTER: driveinfo = driveinfo_printer; break;
default: driveinfo = driveinfo_fixed; break;
}
}