diff --git a/.java-version b/.java-version index 4099407..98d9bcb 100644 --- a/.java-version +++ b/.java-version @@ -1 +1 @@ -23 +17 diff --git a/src/main/java/jace/hardware/CardDiskII.java b/src/main/java/jace/hardware/CardDiskII.java index 7f5f134..93d23cb 100644 --- a/src/main/java/jace/hardware/CardDiskII.java +++ b/src/main/java/jace/hardware/CardDiskII.java @@ -46,8 +46,8 @@ import jace.library.MediaConsumerParent; public class CardDiskII extends Card implements MediaConsumerParent { DiskIIDrive currentDrive; - DiskIIDrive drive1 = new DiskIIDrive(); - DiskIIDrive drive2 = new DiskIIDrive(); + public DiskIIDrive drive1 = new DiskIIDrive(); + public DiskIIDrive drive2 = new DiskIIDrive(); @ConfigurableField(category = "Disk", defaultValue = "254", name = "Default volume", description = "Value to use for disk volume number") static public int DEFAULT_VOLUME_NUMBER = 0x0FE; @ConfigurableField(category = "Disk", defaultValue = "true", name = "Speed boost", description = "If enabled, emulator will run at max speed during disk access") @@ -66,6 +66,8 @@ public class CardDiskII extends Card implements MediaConsumerParent { } drive1.setIcon(Utility.loadIconLabel("disk_ii.png")); drive2.setIcon(Utility.loadIconLabel("disk_ii.png")); + addChildDevice(drive1); + addChildDevice(drive2); reset(); } @@ -191,9 +193,6 @@ public class CardDiskII extends Card implements MediaConsumerParent { @Override public void tick() { - // Do nothing (if you want 1mhz timing control, you can do that here...) -// drive1.tick(); -// drive2.tick(); } @Override @@ -211,7 +210,6 @@ public class CardDiskII extends Card implements MediaConsumerParent { } catch (IOException ex) { Logger.getLogger(CardDiskII.class.getName()).log(Level.SEVERE, null, ex); } - } private void tweakTiming() { diff --git a/src/main/java/jace/hardware/DiskIIDrive.java b/src/main/java/jace/hardware/DiskIIDrive.java index 4053e98..dad65af 100644 --- a/src/main/java/jace/hardware/DiskIIDrive.java +++ b/src/main/java/jace/hardware/DiskIIDrive.java @@ -26,6 +26,7 @@ import java.util.concurrent.locks.LockSupport; import jace.Emulator; import jace.EmulatorUILogic; +import jace.core.Device; import jace.library.MediaConsumer; import jace.library.MediaEntry; import jace.library.MediaEntry.MediaFile; @@ -43,13 +44,17 @@ import javafx.scene.control.Label; * @author Brendan Robert (BLuRry) brendan.robert@gmail.com */ @Stateful -public class DiskIIDrive implements MediaConsumer { +public class DiskIIDrive extends Device implements MediaConsumer { public DiskIIDrive() { } public boolean DEBUG = false; + // 4 ticks per bit, 6 bits per nibble + public static final int TICKS_PER_NIBBLE = 34; + public static final int TICKS_PER_BIT = 7; + FloppyDisk disk; // Number of milliseconds to wait between last write and update to disk image public static long WRITE_UPDATE_DELAY = 1000; @@ -80,6 +85,10 @@ public class DiskIIDrive implements MediaConsumer { public byte latch; @Stateful public int spinCount; + @Stateful + public int tickCount; + boolean alreadyAccessedData = false; + Set dirtyTracks; public void reset() { @@ -126,6 +135,11 @@ public class DiskIIDrive implements MediaConsumer { System.out.println("Drive setOn: "+b); } driveOn = b; + if (driveOn) { + resume(); + } else { + suspend(); + } } boolean isOn() { @@ -133,27 +147,26 @@ public class DiskIIDrive implements MediaConsumer { } byte readLatch() { + if (disk == null) { + return (byte) 0x0ff; + } byte result = 0x07f; + // spinCount = (spinCount + 1) & 0x0F; if (!writeMode) { - spinCount = (spinCount + 1) & 0x0F; - if (spinCount > 0) { - if (disk != null) { - result = disk.nibbles[trackStartOffset + nibbleOffset]; - if (isOn()) { - nibbleOffset++; - if (nibbleOffset >= FloppyDisk.TRACK_NIBBLE_LENGTH) { - nibbleOffset = 0; - } - } - } else { - result = (byte) 0x0ff; - } + if (!alreadyAccessedData) { + result = disk.nibbles[trackStartOffset + nibbleOffset]; + alreadyAccessedData = true; + // System.out.print("Y"); + } else { + result = (byte) (disk.nibbles[trackStartOffset + nibbleOffset] & 0x07f); + // System.out.print("N"); } } else { - spinCount = (spinCount + 1) & 0x0F; - if (spinCount > 0) { + if (alreadyAccessedData) + // if (spinCount > 0) { result = (byte) 0x080; - } + // System.out.print("?"); + // } } return result; } @@ -170,16 +183,12 @@ public class DiskIIDrive implements MediaConsumer { // Do nothing if write-protection is enabled! if (getMediaEntry() == null || !getMediaEntry().writeProtected) { dirtyTracks.add(trackStartOffset / FloppyDisk.TRACK_NIBBLE_LENGTH); - disk.nibbles[trackStartOffset + nibbleOffset++] = latch; + disk.nibbles[trackStartOffset + nibbleOffset] = latch; triggerDiskUpdate(); StateManager.markDirtyValue(disk.nibbles); } } } - - if (nibbleOffset >= FloppyDisk.TRACK_NIBBLE_LENGTH) { - nibbleOffset = 0; - } } } @@ -234,7 +243,7 @@ public class DiskIIDrive implements MediaConsumer { } } - void insertDisk(File diskPath) throws IOException { + public void insertDisk(File diskPath) throws IOException { if (DEBUG) { System.out.println("inserting disk " + diskPath.getAbsolutePath() + " into drive"); } @@ -330,4 +339,38 @@ public class DiskIIDrive implements MediaConsumer { Thread.onSpinWait(); } } + + @Override + public String getShortName() { + return "DiskDrive"; + } + + @Override + public void reconfigure() { + // Nothing to do + } + + @Override + protected String getDeviceName() { + return "DiskDrive"; + } + + @Override + public void tick() { + if(isOn() && disk != null) { + tickCount = (tickCount + 1) % TICKS_PER_NIBBLE; + if (tickCount == 0) { + alreadyAccessedData = false; + // System.out.print("+"); + nibbleOffset++; + if (nibbleOffset >= FloppyDisk.TRACK_NIBBLE_LENGTH) { + nibbleOffset = 0; + } + } else if (tickCount == TICKS_PER_BIT) { + // After 7 ticks, data is no longer accessible for this nibble + alreadyAccessedData = true; + // System.out.print("-"); + } + } + } } \ No newline at end of file diff --git a/src/main/java/jace/hardware/FloppyDisk.java b/src/main/java/jace/hardware/FloppyDisk.java index 724d1a9..20892f2 100644 --- a/src/main/java/jace/hardware/FloppyDisk.java +++ b/src/main/java/jace/hardware/FloppyDisk.java @@ -378,6 +378,9 @@ public class FloppyDisk { for (int i = 0; i < SECTOR_COUNT; i++) { // Loop through number of sectors pos = locatePattern(pos, trackNibbles, 0x0d5, 0x0aa, 0x096); + if (pos < 0) { + continue; + } // Locate track number int trackVerify = decodeOddEven(trackNibbles[pos + 5], trackNibbles[pos + 6]); // Locate sector number @@ -387,8 +390,15 @@ public class FloppyDisk { } // Skip to end of address block pos = locatePattern(pos, trackNibbles, 0x0de, 0x0aa /*, 0x0eb this is sometimes being written as FF??*/); + if (pos < 0) { + continue; + } // Locate start of sector data pos = locatePattern(pos, trackNibbles, 0x0d5, 0x0aa, 0x0ad); + if (pos < 0) { + continue; + } + // Determine offset in output data for sector //int offset = reverseLoopkup(currentSectorOrder, sector) * 256; int offset = currentSectorOrder[sector] * 256; @@ -423,7 +433,7 @@ public class FloppyDisk { pos = (pos + 1) % data.length; max--; if (max < 0) { - throw new Throwable("Could not match pattern!"); + return -1; } } // System.out.print("Found pattern at "+pos+": "); @@ -433,6 +443,9 @@ public class FloppyDisk { } private boolean matchPattern(int pos, byte[] data, int... pattern) { + if (pos < 0 || pos >= data.length) { + return false; + } int matched = 0; for (int i : pattern) { int d = data[pos] & 0x0ff; diff --git a/src/main/java/jace/hardware/massStorage/CardMassStorage.java b/src/main/java/jace/hardware/massStorage/CardMassStorage.java index 71bdbc1..a491e3f 100644 --- a/src/main/java/jace/hardware/massStorage/CardMassStorage.java +++ b/src/main/java/jace/hardware/massStorage/CardMassStorage.java @@ -50,8 +50,8 @@ public class CardMassStorage extends Card implements MediaConsumerParent { public String disk1; @ConfigurableField(category = "Disk", shortName = "d2", name = "Drive 2 disk image", description = "Path of disk 2") public String disk2; - MassStorageDrive drive1; - MassStorageDrive drive2; + public MassStorageDrive drive1; + public MassStorageDrive drive2; public CardMassStorage() { super(false); diff --git a/src/main/java/jace/terminal/MainMode.java b/src/main/java/jace/terminal/MainMode.java index c860492..b24046f 100644 --- a/src/main/java/jace/terminal/MainMode.java +++ b/src/main/java/jace/terminal/MainMode.java @@ -16,6 +16,8 @@ package jace.terminal; +import java.io.File; +import java.io.IOException; import java.io.PrintStream; import java.util.HashMap; import java.util.Map; @@ -66,6 +68,12 @@ public class MainMode implements TerminalMode { commands.put("insertdisk", this::insertDisk); commands.put("ejectdisk", this::ejectDisk); + commands.put("bootdisk", this::bootDisk); + commands.put("showtext", this::showTextScreen); + commands.put("nohints", args -> disableHints()); + commands.put("waitkey", this::waitForKeypress); + commands.put("type", this::typeString); + commands.put("expect", this::expectString); commands.put("loadbin", this::loadBinary); commands.put("savebin", this::saveBinary); @@ -82,6 +90,8 @@ public class MainMode implements TerminalMode { addAlias("g", "run"); addAlias("id", "insertdisk"); addAlias("ed", "ejectdisk"); + addAlias("bd", "bootdisk"); + addAlias("st", "showtext"); addAlias("lb", "loadbin"); addAlias("sb", "savebin"); addAlias("k", "key"); @@ -111,13 +121,39 @@ public class MainMode implements TerminalMode { "If breakpoint is specified with # prefix, stops when that address is reached."); commandHelp.put("insertdisk", - "Inserts a disk image into a specified drive.\nUsage: insertdisk d (or id d)\n" + "Inserts a disk image into a specified drive.\nUsage: insertdisk d [slot] (or id d [slot])\n" + - "Example: insertdisk d1"); + "Example: insertdisk d1 /path/to/disk.po\nExample: insertdisk d1 /path/to/disk.po 6"); commandHelp.put("ejectdisk", - "Ejects a disk from a specified drive.\nUsage: ejectdisk d (or ed d)\n" + - "Example: ejectdisk d2"); + "Ejects a disk from a specified drive.\nUsage: ejectdisk d [slot] (or ed d [slot])\n" + + "Example: ejectdisk d2\nExample: ejectdisk d1 6"); + + commandHelp.put("bootdisk", + "Inserts a disk image, boots it, and runs until PC >= $2000.\nUsage: bootdisk d [slot] (or bd d [slot])\n" + + + "This is a convenience command that combines insertdisk, reset, and running until the system boots.\n" + + "Example: bootdisk d1 /path/to/disk.po"); + + commandHelp.put("showtext", + "Displays the current text screen contents.\nUsage: showtext (or st)\n" + + "Automatically detects 40-column vs 80-column mode and linearizes the screen memory."); + + commandHelp.put("waitkey", + "Waits until the Apple II reads the keyboard ($C000).\nUsage: waitkey [timeout_ms]\n" + + "This detects when the system is waiting for input. Default timeout: 30000ms (30 seconds).\n" + + "Example: waitkey 5000"); + + commandHelp.put("type", + "Types a string by synchronizing each keypress with keyboard reads.\nUsage: type \n" + + "This command waits for keyboard read before each character, ensuring proper input.\n" + + "Example: type \"hello world\\n\"\nExample: type +fptest.mf\\n"); + + commandHelp.put("expect", + "Waits for a string to appear on the text screen.\nUsage: expect [timeout_seconds]\n" + + "Polls the screen every 500ms until the string is found or timeout occurs.\n" + + "Default timeout: 30 seconds\n" + + "Example: expect \"Press any key\" 10\nExample: expect \"TEST PASSED\""); commandHelp.put("loadbin", "Loads a binary file at a specified memory address.\nUsage: loadbin
(or lb
)\n" @@ -160,12 +196,20 @@ public class MainMode implements TerminalMode { @Override public boolean processCommand(String command) { - // Handle exit command + // Handle exit commands if ("qq".equals(command.trim())) { terminal.stop(); return true; } - + + // Handle full exit (terminate JVM) + if ("qqq".equals(command.trim()) || "exit!".equals(command.trim())) { + terminal.stop(); + output.println("Terminating emulator..."); + System.exit(0); + return true; + } + String[] parts = command.trim().split("\\s+", 2); String cmd = parts[0].toLowerCase(); String[] args = parts.length > 1 ? parts[1].split("\\s+") : new String[0]; @@ -193,17 +237,24 @@ public class MainMode implements TerminalMode { output.println("Available commands:"); output.println(" monitor/m - Enter Monitor mode (includes debugger functionality)"); output.println(" assembler/a - Enter Assembler mode"); - output.println(" qq - Exit terminal"); + output.println(" qq - Exit terminal loop"); + output.println(" qqq / exit! - Exit terminal AND terminate emulator"); output.println(); output.println(" swlog (sl) - Toggle softswitch state change logging"); output.println(" swstate (ss) - Display current state of all softswitches"); output.println(" reset (re) - Reset the Apple II"); output.println(" step (s) [count] - Step the CPU for count cycles (default: 1)"); output.println(" run (g) [count] - Run the CPU for count cycles or until breakpoint (default: 1000000)"); - output.println(" insertdisk (id) d# - Insert disk image in drive # (1 or 2)"); - output.println(" ejectdisk (ed) d# - Eject disk from drive # (1 or 2)"); + output.println(" insertdisk (id) d# file [slot] - Insert disk image in drive # (1 or 2)"); + output.println(" ejectdisk (ed) d# [slot] - Eject disk from drive # (1 or 2)"); + output.println(" bootdisk (bd) d# file [slot] - Insert disk and boot until PC >= $2000"); + output.println(" showtext (st) - Display current text screen contents (40/80 column)"); + output.println(" waitkey [timeout] - Wait until system reads keyboard (detects input prompt)"); + output.println(" type - Type string synchronized with keyboard reads"); + output.println(" expect [timeout] - Wait for string to appear on screen"); output.println(" loadbin (lb) file addr - Load binary file at specified address (hex)"); output.println(" savebin (sb) file addr size - Save binary data from memory to file"); + output.println(" key (k) value - Simulate keypresses"); output.println(" help/? - Show this help"); output.println(" help/? - Show detailed help for a specific command"); output.println(" exit/quit - Exit the Terminal"); @@ -468,40 +519,41 @@ public class MainMode implements TerminalMode { try { Emulator.withComputer((computer) -> { if (finalBreakpointAddress != null) { - output.println("Running until PC = $" + Integer.toHexString(finalBreakpointAddress).toUpperCase() + + output.println("Running until PC = $" + Integer.toHexString(finalBreakpointAddress).toUpperCase() + " or " + finalCycleCount + " cycles"); } else { output.println("Running for " + finalCycleCount + " cycles"); } - // Track cycles with our own counter - int currentCycles = 0; - + // Calculate run time in milliseconds (Apple II runs at ~1MHz) + // cycles / 1000 = milliseconds + long runTimeMs = finalCycleCount / 1000; + if (runTimeMs < 100) runTimeMs = 100; // Minimum 100ms + computer.resume(); - - // Poll periodically to check breakpoint or cycle count - while (currentCycles < finalCycleCount) { - if (finalBreakpointAddress != null && + long startTime = System.currentTimeMillis(); + + // Poll for breakpoint or time elapsed + while (System.currentTimeMillis() - startTime < runTimeMs) { + if (finalBreakpointAddress != null && computer.getCpu().getProgramCounter() == finalBreakpointAddress) { - output.println("Breakpoint hit at $" + + output.println("Breakpoint hit at $" + Integer.toHexString(finalBreakpointAddress).toUpperCase()); break; } - - // Increment our cycle counter - currentCycles += 100; - + try { - Thread.sleep(10); + Thread.sleep(50); // Check every 50ms } catch (InterruptedException e) { break; } } - + computer.pause(); - - output.println("Ran for approximately " + currentCycles + " cycles"); - + + long actualTimeMs = System.currentTimeMillis() - startTime; + output.println("Ran for approximately " + (actualTimeMs * 1000) + " cycles (" + actualTimeMs + "ms)"); + // Show CPU state after run MOS65C02 cpuAfterRun = (MOS65C02) computer.getCpu(); showCPUState(cpuAfterRun); @@ -513,29 +565,488 @@ public class MainMode implements TerminalMode { private void insertDisk(String[] args) { if (args.length < 2) { - output.println("Usage: insertdisk "); + output.println("Usage: insertdisk d [slot]"); + output.println("Example: insertdisk d1 /path/to/disk.po"); + output.println("Example: insertdisk d1 /path/to/disk.po 6"); return; } - String drive = args[0]; + String driveSpec = args[0]; String filename = args[1]; + int slot = args.length > 2 ? Integer.parseInt(args[2]) : 6; // Default to slot 6 - LOG.info("Disk insertion requested for drive " + drive + ": " + filename); - // TODO: Implement disk insertion - output.println("Disk insertion not yet implemented"); + if (!driveSpec.matches("d[12]")) { + output.println("Invalid drive specification: " + driveSpec + " (must be d1 or d2)"); + return; + } + + int driveNumber = Integer.parseInt(driveSpec.substring(1)); + + LOG.info("Disk insertion requested for slot " + slot + " drive " + driveNumber + ": " + filename); + + try { + File diskFile = new File(filename); + if (!diskFile.exists()) { + output.println("File not found: " + filename); + return; + } + + Emulator.withMemory(memory -> { + var cardOpt = memory.getCard(slot); + if (cardOpt.isEmpty()) { + output.println("No disk controller found in slot " + slot); + return; + } + + // Handle Disk ][ controller + if (cardOpt.get() instanceof jace.hardware.CardDiskII) { + jace.hardware.CardDiskII diskController = (jace.hardware.CardDiskII) cardOpt.get(); + jace.hardware.DiskIIDrive drive = driveNumber == 1 ? diskController.drive1 : diskController.drive2; + + try { + drive.insertDisk(diskFile); + output.println("Inserted " + diskFile.getName() + " into slot " + slot + " drive " + driveNumber); + } catch (IOException e) { + LOG.log(Level.WARNING, "Error inserting disk", e); + output.println("Error inserting disk: " + e.getMessage()); + } + return; + } + + // Handle Mass Storage controller + if (cardOpt.get() instanceof jace.hardware.massStorage.CardMassStorage) { + jace.hardware.massStorage.CardMassStorage massStorage = + (jace.hardware.massStorage.CardMassStorage) cardOpt.get(); + jace.hardware.massStorage.MassStorageDrive drive = + driveNumber == 1 ? massStorage.drive1 : massStorage.drive2; + + try { + // Create a MediaFile and MediaEntry to insert + jace.library.MediaEntry.MediaFile mediaFile = new jace.library.MediaEntry.MediaFile(); + mediaFile.path = diskFile; + jace.library.MediaEntry mediaEntry = new jace.library.MediaEntry(); + drive.insertMedia(mediaEntry, mediaFile); + output.println("Inserted " + diskFile.getName() + " into slot " + slot + " drive " + driveNumber); + } catch (IOException e) { + LOG.log(Level.WARNING, "Error inserting disk", e); + output.println("Error inserting disk: " + e.getMessage()); + } + return; + } + + output.println("Card in slot " + slot + " is not a supported disk controller"); + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error during disk insertion", e); + output.println("Error inserting disk: " + e.getMessage()); + } } private void ejectDisk(String[] args) { if (args.length < 1) { - output.println("Usage: ejectdisk "); + output.println("Usage: ejectdisk d [slot]"); + output.println("Example: ejectdisk d1"); + output.println("Example: ejectdisk d2 6"); return; } - String drive = args[0]; + String driveSpec = args[0]; + int slot = args.length > 1 ? Integer.parseInt(args[1]) : 6; // Default to slot 6 - LOG.info("Disk ejection requested for drive " + drive); - // TODO: Implement disk ejection - output.println("Disk ejection not yet implemented"); + if (!driveSpec.matches("d[12]")) { + output.println("Invalid drive specification: " + driveSpec + " (must be d1 or d2)"); + return; + } + + int driveNumber = Integer.parseInt(driveSpec.substring(1)); + + LOG.info("Disk ejection requested for slot " + slot + " drive " + driveNumber); + + try { + Emulator.withMemory(memory -> { + var cardOpt = memory.getCard(slot); + if (cardOpt.isEmpty()) { + output.println("No disk controller found in slot " + slot); + return; + } + + if (!(cardOpt.get() instanceof jace.hardware.CardDiskII)) { + output.println("Card in slot " + slot + " is not a Disk ][ controller"); + return; + } + + jace.hardware.CardDiskII diskController = (jace.hardware.CardDiskII) cardOpt.get(); + jace.hardware.DiskIIDrive drive = driveNumber == 1 ? diskController.drive1 : diskController.drive2; + + drive.eject(); + output.println("Ejected disk from slot " + slot + " drive " + driveNumber); + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error during disk ejection", e); + output.println("Error ejecting disk: " + e.getMessage()); + } + } + + private void bootDisk(String[] args) { + if (args.length < 2) { + output.println("Usage: bootdisk d [slot]"); + output.println("Example: bootdisk d1 /path/to/disk.po"); + return; + } + + // First insert the disk + insertDisk(args); + + // Reset the system + output.println("Resetting system..."); + performReset(); + + // Run until PC >= $2000 + output.println("Booting disk (running until PC >= $2000)..."); + try { + final int TARGET_PC = 0x2000; + final int MAX_CYCLES = 10_000_000; // 10 million cycles max + + Emulator.withComputer((computer) -> { + int currentCycles = 0; + computer.resume(); + + // Poll for PC >= $2000 + while (currentCycles < MAX_CYCLES) { + int pc = computer.getCpu().getProgramCounter(); + if (pc >= TARGET_PC) { + output.println("Boot complete - PC reached $" + + Integer.toHexString(pc).toUpperCase()); + break; + } + + currentCycles += 1000; + + try { + Thread.sleep(10); + } catch (InterruptedException e) { + break; + } + } + + computer.pause(); + + if (currentCycles >= MAX_CYCLES) { + output.println("Warning: Maximum cycles reached before PC >= $2000"); + } + + // Show CPU state + MOS65C02 cpu = (MOS65C02) computer.getCpu(); + showCPUState(cpu); + }); + } catch (Exception e) { + output.println("Error during boot: " + e.getMessage()); + } + } + + private String captureTextScreen() { + StringBuilder screenText = new StringBuilder(); + + try { + Emulator.withMemory(memory -> { + // Check if we're in 80-column mode + boolean col80 = SoftSwitches._80COL.isOn(); + + // Apple II text screen memory layout (interleaved rows) + int[] rowAddresses = { + 0x0400, 0x0480, 0x0500, 0x0580, 0x0600, 0x0680, 0x0700, 0x0780, + 0x0428, 0x04A8, 0x0528, 0x05A8, 0x0628, 0x06A8, 0x0728, 0x07A8, + 0x0450, 0x04D0, 0x0550, 0x05D0, 0x0650, 0x06D0, 0x0750, 0x07D0 + }; + + for (int row = 0; row < 24; row++) { + StringBuilder line = new StringBuilder(); + int baseAddr = rowAddresses[row]; + + if (col80) { + if (memory instanceof jace.apple2e.RAM128k) { + jace.apple2e.RAM128k ram128k = (jace.apple2e.RAM128k) memory; + jace.core.PagedMemory auxMem = ram128k.getAuxMemory(); + jace.core.PagedMemory mainMem = ram128k.getMainMemory(); + + for (int col = 0; col < 40; col++) { + int addr = baseAddr + col; + byte auxByte = auxMem.getMemoryPage(addr)[addr & 0xFF]; + char auxChar = convertAppleTextToAscii(auxByte); + line.append(auxChar); + byte mainByte = mainMem.getMemoryPage(addr)[addr & 0xFF]; + char mainChar = convertAppleTextToAscii(mainByte); + line.append(mainChar); + } + } + } else { + for (int col = 0; col < 40; col++) { + int addr = baseAddr + col; + byte b = memory.read(addr, RAMEvent.TYPE.READ_DATA, true, false); + char c = convertAppleTextToAscii(b); + line.append(c); + } + } + + screenText.append(line.toString()).append("\n"); + } + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error capturing text screen", e); + } + + return screenText.toString(); + } + + private void showTextScreen(String[] args) { + try { + Emulator.withMemory(memory -> { + boolean col80 = SoftSwitches._80COL.isOn(); + int columns = col80 ? 80 : 40; + output.println("=== Text Screen (" + columns + " columns) ==="); + }); + + String screenText = captureTextScreen(); + output.print(screenText); + output.println("=== End of Text Screen ==="); + + } catch (Exception e) { + LOG.log(Level.WARNING, "Error reading text screen", e); + output.println("Error reading text screen: " + e.getMessage()); + } + } + + /** + * Convert Apple II text screen byte to ASCII character + * Apple II uses high-bit ASCII with special character sets + */ + private void disableHints() { + try { + Emulator.withComputer(computer -> { + if (computer instanceof jace.apple2e.Apple2e) { + jace.apple2e.Apple2e apple = (jace.apple2e.Apple2e) computer; + apple.enableHints = false; + apple.reconfigure(); + output.println("Helpful hints disabled"); + } else { + output.println("This command is only supported on Apple //e"); + } + }); + } catch (Exception e) { + output.println("Error disabling hints: " + e.getMessage()); + } + } + + private void waitForKeypress(String[] args) { + int maxWaitMs = args.length > 0 ? Integer.parseInt(args[0]) : 30000; // Default 30 seconds + waitForKeyRead(maxWaitMs, true); + } + + private boolean waitForKeyRead(int maxWaitMs, boolean printOutput) { + try { + final boolean[] keyReadDetected = new boolean[1]; + final RAMListener[] listenerHolder = new RAMListener[1]; + + // Create a listener for keyboard reads at $C000 + RAMListener keyListener = new RAMListener("WaitForKey", RAMEvent.TYPE.READ, RAMEvent.SCOPE.ADDRESS, RAMEvent.VALUE.ANY) { + @Override + protected void doConfig() { + setScopeStart(0xC000); + } + + @Override + protected void doEvent(RAMEvent event) { + synchronized (keyReadDetected) { + keyReadDetected[0] = true; + keyReadDetected.notify(); + } + } + }; + + listenerHolder[0] = keyListener; + + Emulator.withMemory(memory -> { + memory.addListener(keyListener); + }); + + if (printOutput) { + output.println("Waiting for keyboard read (max " + maxWaitMs + "ms)..."); + } + + Emulator.withComputer(computer -> { + computer.resume(); + + synchronized (keyReadDetected) { + try { + keyReadDetected.wait(maxWaitMs); + } catch (InterruptedException e) { + // Interrupted, continue + } + } + + computer.pause(); + }); + + Emulator.withMemory(memory -> { + memory.removeListener(keyListener); + }); + + if (printOutput) { + if (keyReadDetected[0]) { + output.println("Keyboard read detected"); + } else { + output.println("Timeout waiting for keyboard read"); + } + } + + return keyReadDetected[0]; + + } catch (Exception e) { + LOG.log(Level.WARNING, "Error waiting for keypress", e); + if (printOutput) { + output.println("Error waiting for keypress: " + e.getMessage()); + } + return false; + } + } + + private void expectString(String[] args) { + if (args.length < 1) { + output.println("Usage: expect [timeout_seconds]"); + return; + } + + // Get the string to search for + String searchString = String.join(" ", args); + int timeoutSeconds = 30; // Default timeout + + // Check if last arg is a number (timeout) + try { + if (args.length > 1) { + int lastArgTimeout = Integer.parseInt(args[args.length - 1]); + timeoutSeconds = lastArgTimeout; + // Rebuild search string without the timeout + searchString = String.join(" ", java.util.Arrays.copyOf(args, args.length - 1)); + } + } catch (NumberFormatException e) { + // Last arg wasn't a number, use full string + } + + // Remove surrounding quotes if present + if (searchString.startsWith("\"") && searchString.endsWith("\"") && searchString.length() > 1) { + searchString = searchString.substring(1, searchString.length() - 1); + } + + output.println("Expecting: \"" + searchString + "\" (timeout: " + timeoutSeconds + "s)"); + + long startTime = System.currentTimeMillis(); + long timeoutMs = timeoutSeconds * 1000L; + + try { + while (System.currentTimeMillis() - startTime < timeoutMs) { + // Capture and check screen first (in case it's already there) + String screenText = captureTextScreen(); + if (screenText.contains(searchString)) { + long elapsed = System.currentTimeMillis() - startTime; + output.println("Match found after " + elapsed + "ms"); + return; + } + + // Run 500k cycles and wait 500ms + Emulator.withComputer(computer -> { + computer.resume(); + try { + Thread.sleep(500); // 500ms (~500k cycles at 1MHz) + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + computer.pause(); + }); + } + + // Timeout + output.println("Timeout waiting for: \"" + searchString + "\""); + + } catch (Exception e) { + LOG.log(Level.WARNING, "Error during expect", e); + output.println("Error during expect: " + e.getMessage()); + } + } + + private void typeString(String[] args) { + if (args.length < 1) { + output.println("Usage: type "); + return; + } + + // Join all args into one string + String text = String.join(" ", args); + + // Remove surrounding quotes if present + if (text.startsWith("\"") && text.endsWith("\"") && text.length() > 1) { + text = text.substring(1, text.length() - 1); + } + + // Process escape sequences + text = text.replace("\\n", "\r"); // Convert \n to carriage return + text = text.replace("\\t", "\t"); + + output.println("Typing string: \"" + text + "\""); + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + byte keyCode = (byte)(c & 0xFF); + + // Wait for keyboard read multiple times to skip ROM routines that ignore keypresses + for (int j = 0; j < 3; j++) { + if (!waitForKeyRead(5000, false)) { + output.println("Timeout waiting for keyboard read at character " + i + " (wait " + j + ")"); + return; + } + } + + // Press the key + Emulator.withComputer(computer -> { + Keyboard.pressKey(keyCode); + }); + + // Wait for the key to be read + if (!waitForKeyRead(5000, false)) { + output.println("Timeout waiting for key to be read at character " + i); + return; + } + } + + output.println("Typing complete"); + } + + private char convertAppleTextToAscii(byte b) { + int value = b & 0xFF; + + // Strip high bit and convert to printable ASCII + int asciiValue = value & 0x7F; + + // Handle special characters + if (asciiValue < 32) { + // Control characters - display as spaces or special symbols + return ' '; + } else if (asciiValue == 127) { + // Delete character + return ' '; + } + + // Check display mode based on high bit + if ((value & 0x80) != 0) { + // Normal display (high bit set) + return (char) asciiValue; + } else if ((value & 0x40) != 0) { + // Flashing (bit 6 set, bit 7 clear) - just display as normal + return (char) asciiValue; + } else { + // Inverse (bits 6 and 7 clear) - display in brackets for visibility + // or just return the character + return (char) asciiValue; + } } private void loadBinary(String[] args) {