From fafa3c7b176e0dc84f3e173b035ed3ff8962da92 Mon Sep 17 00:00:00 2001 From: Paulo Garcia Date: Thu, 5 Mar 2026 09:06:23 -0500 Subject: [PATCH] Add support for Fujisan emulator Handles fujisan xex deployment via TCP Updated documentation --- CHANGELOG.md | 6 + package-lock.json | 4 +- package.json | 81 ++++++++++- readme.md | 77 +++++++--- sampleWorkspace/.vscode/launch.json | 31 +++- src/activateDebugger.ts | 77 +++++++--- src/debugger.ts | 130 ++++++++++------- src/fujisanClient.ts | 211 ++++++++++++++++++++++++++++ src/runtime.ts | 102 +++++++++++++- 9 files changed, 620 insertions(+), 99 deletions(-) create mode 100644 src/fujisanClient.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f0054..2109f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,3 +28,9 @@ * Bug Fix: Fix issue where Atari800MacX would not start if multiple copies on disk * Tighter Atari800MacX debug integration - start/stop stays in sync +## 0.9.0 - 2025-02-01 +* Feature: Fujisan emulator support via TCP (emulatorType: "fujisan") +* Hybrid approach: TCP for XEX deployment, H4: file protocol for debug +* Add emulatorType, fujisanHost, fujisanPort to launch config +* Fujisan must be running with TCP server enabled; H4: must map to project bin folder + diff --git a/package-lock.json b/package-lock.json index b697e2e..10b95dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fastbasic-debugger", - "version": "0.2.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fastbasic-debugger", - "version": "0.2.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "decompress": "^4.2.1", diff --git a/package.json b/package.json index 65eaf41..bf49cf9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fastbasic-debugger", "displayName": "FastBasic Debugger", - "version": "0.8.0", + "version": "0.9.0", "publisher": "ericcarr", "description": "Runs FastBasic on an Atari emulator, providing debugging support to set breakpoints, step through code, view and change variables. Downloads FastBasic and Atari Emulator automatically on Windows and Mac", "author": "Eric Carr", @@ -73,6 +73,22 @@ "request": "never" }, "contributes": { + "configuration": { + "title": "FastBasic Debug", + "properties": { + "fastbasic.defaultEmulator": { + "type": "string", + "enum": ["atari800", "fujisan"], + "default": "atari800", + "description": "Emulator to use when you run or debug from the editor without a launch config, or when a launch config doesn't set emulatorType. 'atari800' = Atari800MacX or Altirra; 'fujisan' = Fujisan over TCP." + }, + "fastbasic.defaultLaunchConfiguration": { + "type": "string", + "default": "", + "description": "Launch configuration name to use for 'Debug File' and 'Run File' (e.g. 'Debug FastBASIC (Fujisan)'). If set, the editor buttons use this config from launch.json; otherwise they use an inline config with the default emulator. Works on Windows, macOS, and Linux." + } + } + }, "themes": [ { "label": "FastBasic Atari", @@ -170,8 +186,7 @@ "launch": { "required": [ "sourceFile", - "compilerPath", - "emulatorPath" + "compilerPath" ], "properties": { "sourceFile": { @@ -189,15 +204,47 @@ "description": "Full path to the FastBasic compiler.", "default": "E.G. c:/fastbasic/fastbasic.exe" }, + "emulatorType": { + "type": "string", + "enum": ["atari800", "fujisan"], + "description": "Which emulator to use. Use 'atari800' to launch Atari800MacX or Altirra. Use 'fujisan' to connect to an already-running Fujisan instance via its TCP server.", + "default": "atari800" + }, "emulatorPath": { "type": "string", - "description": "Full path to the Atari Emulator.", + "description": "Path to the emulator executable. Only used when emulatorType is 'atari800'. Ignored for Fujisan.", "default": "E.G. C:/atari/Altirra/Altirra64.exe" }, "windowsPaths": { "type": "boolean", "default": true, "description": "Set to true to force calling emulatorPath with Windows path style (for running Altirra using Wine)" + }, + "fujisanHost": { + "type": "string", + "description": "Host where Fujisan's TCP server is running. Only used when emulatorType is 'fujisan'.", + "default": "localhost" + }, + "fujisanPort": { + "type": "number", + "description": "Port for Fujisan's TCP server. Only used when emulatorType is 'fujisan'.", + "default": 6502 + }, + "autoConfigureH4": { + "type": "boolean", + "description": "When using Fujisan, set H4: to your project's bin folder over TCP so the debugger can talk to the program. Set to false if you map H4: yourself.", + "default": true + }, + "fujisanBootMode": { + "type": "string", + "enum": ["none", "warm", "cold"], + "enumDescriptions": [ + "No explicit boot — BINLOAD handles the internal reset when loading the XEX. FujiNet stays running. Recommended when using FujiNet.", + "Warm boot before loading XEX. Resets Atari state without restarting FujiNet. Use for extra state cleanup with FujiNet.", + "Stop FujiNet-PC, restart it fresh, then load XEX. Use when you need a clean FujiNet state so network apps work correctly after load." + ], + "description": "Boot mode for XEX load on Fujisan. 'none' (default): just load XEX, FujiNet stays up. 'warm': warm boot then load XEX. 'cold': stop FujiNet → start FujiNet → load XEX.", + "default": "none" } } } @@ -209,8 +256,19 @@ "name": "Debug FastBASIC", "sourceFile": "${file}", "compilerPath": "E.G. c:/fastbasic/fastbasic.exe", + "emulatorType": "atari800", "emulatorPath": "E.G. C:/atari/Altirra/Altirra64.exe", "windowsPaths": true + }, + { + "type": "fastbasic", + "request": "launch", + "name": "Debug FastBASIC (Fujisan)", + "sourceFile": "${file}", + "compilerPath": "E.G. c:/fastbasic/fastbasic.exe", + "emulatorType": "fujisan", + "fujisanHost": "localhost", + "fujisanPort": 6502 } ], "configurationSnippets": [ @@ -223,9 +281,24 @@ "name": "Debug FastBASIC", "sourceFile": "${file}", "compilerPath": "E.G. c:/fastbasic/fastbasic.exe", + "emulatorType": "atari800", "emulatorPath": "E.G. C:/atari/Altirra/Altirra64.exe", "windowsPaths": true } + }, + { + "label": "FastBasic Debug: Fujisan", + "description": "Debug with Fujisan over TCP. H4: is set to your project's bin folder automatically. Start Fujisan and turn on Tools → TCP Server first.", + "body": { + "type": "fastbasic", + "request": "launch", + "name": "Debug FastBASIC (Fujisan)", + "sourceFile": "${file}", + "compilerPath": "E.G. c:/fastbasic/fastbasic.exe", + "emulatorType": "fujisan", + "fujisanHost": "localhost", + "fujisanPort": 6502 + } } ], "variables": { diff --git a/readme.md b/readme.md index d7eba1d..8c69f2e 100644 --- a/readme.md +++ b/readme.md @@ -1,18 +1,19 @@ # FastBasic Debugger -This a work-in-progress extension with the goal to provide a first class debugging experience for FastBasic in Visual Studio Code on Windows or Mac. +This a work-in-progress extension with the goal to provide a first class debugging experience for FastBasic in Visual Studio Code on **Windows**, **macOS**, and **Linux**. -Press F5 to debug a file, or Ctrl+F5 to run it without debugging. +**To debug:** Press F5, or use the **Debug File** button above the editor. +**To run without debugging:** Press Ctrl+F5 (Cmd+F5 on Mac), or use the **Run File** button. -On first debug, the extension will prompt to download the FastBasic compiler and a platform specific Atari emulator. It will then configure that emulator to work for debugging. You can tweak the emulator settings (NTSC vs PAL, Enable Joystick, etc) while it is running. +On first debug, the extension will prompt to download the FastBasic compiler and a platform-specific Atari emulator (Altirra on Windows, Atari800MacX on macOS). It will then configure that emulator to work for debugging. You can tweak the emulator settings (NTSC vs PAL, Enable Joystick, etc) while it is running. -**Advanced use:** You can create a custom launch.json to start a customer emulator (e.g. Altirra on Mac for FujiNet development). More setup steps are needed. See below if desired. +**Advanced use:** You can create a custom `launch.json` to use a different emulator (e.g. Altirra via Wine on Mac, or Fujisan on any platform). See below. **If you encounter ISSUES,** please let me know at: https://forums.atariage.com/topic/351055-fastbasic-debugger-extension-for-vscode/ ## Features -* Automatically downloads the latest supported FastBasic and Atari Emulator (Altirra or AtariMacX) on Windows or Mac +* Automatically downloads the latest supported FastBasic and Atari emulator (Altirra on Windows, Atari800MacX on macOS) - If you are using Fujisan you have to download and install it manually. * Compiles and run or debug in emulator with a single key press * Inspect and change variables while debugging * See variable value in decimal/hex along with address on hover @@ -25,18 +26,20 @@ On first debug, the extension will prompt to download the FastBasic compiler and ## Running or Debugging -To **debug**, press F5. -To **run** without any debugging code, press Ctrl-F5. +You can start a session in two ways: -This extension will compile the source code to an XEX and run it in the emulator. If compiling fails, that message will be displayed in the Output pane. +1. **Run view:** Open the Run and Debug view (Ctrl+Shift+D / Cmd+Shift+D), choose a launch configuration (e.g. **Debug FastBASIC (Fujisan)** or **Debug FastBASIC (Atari800MacX)**), then press **F5** to debug or **Ctrl+F5** / **Cmd+F5** to run without debugging. +2. **Editor buttons:** With a `.bas` file active, use the **Debug File** or **Run File** button above the editor. These use your **default launch configuration** (if set in settings) or the **default emulator** (see Settings below). -When debugging with F5, a "GET" statement will be added to the last line so the program waits for a final key press before exiting. When running via Ctrl+F5, this line is not added. +This extension will compile the source code to an XEX and run it in the emulator. If compiling fails, that message will be displayed in the Output pane. + +When debugging, a "GET" statement will be added to the last line so the program waits for a final key press before exiting. When running without debugging, this line is not added. ## Variables Variables will show while debugging is stopped on a line and can be viewed multiple ways: 1. In the Variables pane -2. By hovering over the varible name in source code. In this view, the value is shown in both decimal and hex, along with the address of where the variable exists in memory +2. By hovering over the variable name in source code. In this view, the value is shown in both decimal and hex, along with the address of where the variable exists in memory 3. By right clicking on a variable in source and adding it to the watch pane. All variable types are supported: @@ -59,16 +62,17 @@ All variable types are supported: This is currently a work in progress, but I am working on a theme that gives an experience very close to writing original Atari BASIC, including font, with some color syntax highlighting as a bonus. -## Custom Emulator +## Custom Emulator (Atari800MacX / Altirra) -You can specify a custom emulator path. This is useful to run Altirra using Wine on non Windows machine, for a FujiNet PC setup. You control this by creating a `launch.json` file in a special folder ( `.vscode` ) under the main project folder. +On **Windows**, the default emulator is **Altirra**. On **macOS**, it is **Atari800MacX**. You can specify a custom emulator path in `launch.json` (e.g. to run Altirra via Wine on Mac for FujiNet development, or even better, use Fujisan with built-in Fujinet support on macOS and Linux). -You can also generate it by going to the vscode debug view and clicking **Create a launch json file**. +Create a `launch.json` in the `.vscode` folder under your project, or use the Run view and click **Create a launch.json file**. -#### Two important notes!! -1. The `emulatorPath` contains the path to wine/altirrra, and `windowsPaths` is set to true so the extension passes a windows like path to altirra -2. When starting an emulator manually, you need to map H4: to the bin folder under the main project folder. This means, in Altirra, go to System->Configure System->Peripherals>Devices, click Add, choose `Host Device (H:)`, set `H4 / H9` to the path, e.g. `Z:\Users\eric\Documents\projects\my-project\bin\` -3. You may need to end the emulator to complete a debugging run. This is a bit tedius, sorry. +**Notes:** +1. **compilerPath** can be the path to the FastBasic **folder** (e.g. `C:\atari\fastbasic-4.7HF` or `/path/to/fastbasic-4.7HF`); the extension will use the `fastbasic` (or `fastbasic.exe`) executable inside it. +2. For a custom emulator, set **emulatorPath** to the executable (or e.g. `wine /path/to/Altirra64.exe` on Mac). Set **windowsPaths** to `true` when passing Windows-style paths to Altirra (e.g. under Wine). +3. When starting an emulator manually, map **H4:** to your project’s **bin** folder (e.g. in Altirra: System → Configure System → Peripherals → Devices → Add → Host Device (H:) → set H4 to the `bin` path). +4. You may need to close the emulator to end a debugging run. For reference, here is a sample file that loads Altirra using wine. ``` @@ -89,6 +93,43 @@ For reference, here is a sample file that loads Altirra using wine. } ``` +## Fujisan Emulator + +The extension supports **Fujisan** (libatari800-based emulator) on macOS, Windows, and Linux via its TCP Server API. You must use Fujisan 1.1.4 or newer. The latest version is always available here: https://github.com/pedgarcia/fujisan/releases + +**Setup:** Fujisan must be running with the TCP server enabled (Tools → TCP Server). The extension configures **H4:** to your project’s `bin` folder automatically over TCP, so you do not need to map H4: manually in Fujisan. + +Add a launch configuration in `.vscode/launch.json`: +```json +{ + "type": "fastbasic", + "request": "launch", + "name": "Debug FastBASIC (Fujisan)", + "sourceFile": "${file}", + "compilerPath": "/path/to/fastbasic", + "emulatorType": "fujisan", + "fujisanHost": "localhost", + "fujisanPort": 6502, + "bootMode": "warm", +} +``` + +You can set **compilerPath** to the folder that contains the FastBasic binary (e.g. `/path/to/fastbasic-4.7HF`); the extension will use the executable inside it. Set **autoConfigureH4** to `false` in the config if you prefer to map H4: manually in Fujisan. + +**bootMode** in particular controls how the machine is reset before loading the XEX: +- `none` (default): no explicit boot before `media.load_xex`. +- `warm`: warm boot before loading. +- `cold`: perform a FujiNet restart sequence before loading. + +Benefits: deploy and load XEX via TCP; H4: auto-configured for the current project; same step-by-step debugging as with other emulators. + +## Settings + +In **Settings** (search for **FastBasic**), you can set: + +* **Default Emulator** — `atari800` or `fujisan`. Used when you use **Debug File** / **Run File** without a default launch config, or when a launch config does not specify `emulatorType`. On Windows, `atari800` uses Altirra; on macOS, it uses Atari800MacX. + +* **Default Launch Configuration** — The name of a launch configuration from `launch.json` (e.g. `Debug FastBASIC (Fujisan)`). When set, **Debug File** and **Run File** use this configuration instead of building an inline config. Ensures the editor buttons use the same emulator and options as the Run view. ## Current Limitations @@ -96,7 +137,7 @@ This is a work in progress, with the following limitations: * The program opens files on #4 and #5 to communicate with the debugger, so your program must use different channels (e.g. #1, #2) for I/O. I chose #4 and #5 because these are not typically used. * You can only set/remove breakpoints when the program is stopped for debugging, or not running. This is to keep the program execution speed fast. -* If your program has a lot of variables (or arrays with many entries), tthere will be a noticable pause when stepping through (F10) line by line. This is because all variable memory is sent to the debugger after each line. +* If your program has a lot of variables (or arrays with many entries), there will be a noticeable pause when stepping through (F10) line by line. This is because all variable memory is sent to the debugger after each line. * These limitations may be solved in the future if needed, using a different approach from the H4: host drive for communication. ## FAQ / Troubleshooting diff --git a/sampleWorkspace/.vscode/launch.json b/sampleWorkspace/.vscode/launch.json index a12977a..892e66c 100644 --- a/sampleWorkspace/.vscode/launch.json +++ b/sampleWorkspace/.vscode/launch.json @@ -1,17 +1,36 @@ -/*{ - // Example launch.json that starts Altirra using wine (for Mac users who don't want to use Atari800MacX) +{ "version": "0.2.0", "configurations": [ - { "type": "fastbasic", "request": "launch", - "name": "Debug FastBASIC", + "name": "Debug FastBASIC (Fujisan)", + "fujisanBootMode": "cold", + "sourceFile": "${file}", + "compilerPath": "/Users/pgarcia/atari/fastbasic-4.7HF", + "emulatorType": "fujisan", + "fujisanHost": "localhost", + "fujisanPort": 6502, + "bootMode": "warm", + }, + { + "type": "fastbasic", + "request": "launch", + "name": "Debug FastBASIC (Atari800MacX)", + "sourceFile": "${file}", + "compilerPath": "/Users/pgarcia/atari/fastbasic-4.7HF", + "emulatorType": "atari800", + "emulatorPath": "" + }, + { + "type": "fastbasic", + "request": "launch", + "name": "Debug FastBASIC (Altirra via Wine)", "sourceFile": "${file}", "compilerPath": "", - "emulatorPath": "wine /Users/eric/Documents/Altirra/Altirra64.exe", + "emulatorType": "atari800", + "emulatorPath": "wine /Applications/Emulators/Altirra/Altirra64.exe", "windowsPaths": true } ] } - */ \ No newline at end of file diff --git a/src/activateDebugger.ts b/src/activateDebugger.ts index af61655..f22ea91 100644 --- a/src/activateDebugger.ts +++ b/src/activateDebugger.ts @@ -24,14 +24,30 @@ export function activateDebugger(context: vscode.ExtensionContext, factory?: vsc targetResource = vscode.window.activeTextEditor.document.uri; } if (targetResource) { - vscode.debug.startDebugging(undefined, { - type: 'fastbasic', - name: 'Run File', - request: 'launch', - sourceFile: targetResource.fsPath - }, - { noDebug: true } - ); + const config = vscode.workspace.getConfiguration('fastbasic'); + const configName = config.get('defaultLaunchConfiguration', '').trim(); + const folder = vscode.workspace.getWorkspaceFolder(targetResource) ?? vscode.workspace.workspaceFolders?.[0]; + if (configName && folder) { + vscode.debug.startDebugging(folder, configName, { noDebug: true }); + } else { + const defaultEmulator = config.get('defaultEmulator', 'atari800'); + const inline: vscode.DebugConfiguration = { + type: 'fastbasic', + name: 'Run File', + request: 'launch', + sourceFile: targetResource.fsPath, + compilerPath: '', + emulatorPath: '', + emulatorType: defaultEmulator, + windowsPaths: process.platform === 'win32' + }; + // Fujisan uses TCP; provide default host/port when it's the default emulator + if (defaultEmulator === 'fujisan') { + inline.fujisanHost = 'localhost'; + inline.fujisanPort = 6502; + } + vscode.debug.startDebugging(undefined, inline, { noDebug: true }); + } } }), vscode.commands.registerCommand('extension.fastbasic-debugger.debugEditorContents', (resource: vscode.Uri) => { @@ -40,15 +56,29 @@ export function activateDebugger(context: vscode.ExtensionContext, factory?: vsc targetResource = vscode.window.activeTextEditor.document.uri; } if (targetResource) { - vscode.debug.startDebugging(undefined, { - type: 'fastbasic', - name: 'Debug File', - request: 'launch', - sourceFile: "${file}", - compilerPath: "", - emulatorPath: "", - windowsPaths: true - }); + const config = vscode.workspace.getConfiguration('fastbasic'); + const configName = config.get('defaultLaunchConfiguration', '').trim(); + const folder = vscode.workspace.getWorkspaceFolder(targetResource) ?? vscode.workspace.workspaceFolders?.[0]; + if (configName && folder) { + vscode.debug.startDebugging(folder, configName); + } else { + const defaultEmulator = config.get('defaultEmulator', 'atari800'); + const inline: vscode.DebugConfiguration = { + type: 'fastbasic', + name: 'Debug File', + request: 'launch', + sourceFile: targetResource.fsPath, + compilerPath: '', + emulatorPath: '', + emulatorType: defaultEmulator, + windowsPaths: process.platform === 'win32' + }; + if (defaultEmulator === 'fujisan') { + inline.fujisanHost = 'localhost'; + inline.fujisanPort = 6502; + } + vscode.debug.startDebugging(undefined, inline); + } } }), vscode.commands.registerCommand('extension.fastbasic-debugger.toggleFormatting', (variable) => { @@ -124,6 +154,19 @@ class FastbasicConfigurationProvider implements vscode.DebugConfigurationProvide }); } + // Resolve ${file} if it was not substituted (e.g. when launching from Run view with no .bas file focused) + if (config.sourceFile === '${file}' || config.sourceFile === "${file}") { + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'basic') { + config.sourceFile = editor.document.uri.fsPath; + } + } + + // Apply default emulator when launch config omits emulatorType + if (config.type === 'fastbasic' && !config.emulatorType) { + config.emulatorType = vscode.workspace.getConfiguration('fastbasic').get('defaultEmulator', 'atari800'); + } + return config; } } diff --git a/src/debugger.ts b/src/debugger.ts index 421f7f1..e8ced66 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -33,24 +33,26 @@ const URL_EMULATOR_MAC="https://www.carr-designs.com/downloads/Atari800MacX-6.1. const URL_EMULATOR_WIN="https://www.carr-designs.com/downloads/Altirra-4.31.zip"; /** - * This interface describes the fastbasic-debugger specific launch attributes - * (which are not part of the Debug Adapter Protocol). - * The schema for these attributes lives in the package.json of the fastbasic-debugger extension. - * The interface should always match this schema. + * Launch config fields for this debugger. The schema is in package.json; + * this interface should stay in sync with it. */ interface ILaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { - /** An absolute path to the "program" to debug. */ sourceFile: string; - /** enable logging the Debug Adapter Protocol */ trace?: boolean; - /** run without debugging */ noDebug?: boolean; - /** absolute path to fastbasic compiler */ compilerPath: string; - /** absolute path to atari emulator compiler */ - emulatorPath: string; - /** force windows paths */ + /** 'atari800' = launch Atari800MacX/Altirra; 'fujisan' = connect to Fujisan via TCP */ + emulatorType?: 'atari800' | 'fujisan'; + /** Path to the emulator executable. Only used when emulatorType is atari800. */ + emulatorPath?: string; windowsPaths?: boolean; + /** Where to find Fujisan's TCP server. Only used when emulatorType is fujisan. */ + fujisanHost?: string; + fujisanPort?: number; + /** When using Fujisan, set H4: to the project bin folder over TCP. Default true. */ + autoConfigureH4?: boolean; + /** Boot mode before loading XEX on Fujisan: 'none' | 'warm' | 'cold'. Default 'none'. */ + fujisanBootMode?: 'none' | 'warm' | 'cold'; } interface IAttachRequestArguments extends ILaunchRequestArguments { } @@ -70,6 +72,7 @@ export class FastbasicDebugSession extends LoggingDebugSession { private _valuesInHex = false; private _useInvalidatedEvent = false; + private _emulatorType: 'atari800' | 'fujisan' = 'atari800'; private _fileAccessor: FileAccessor; /** @@ -105,8 +108,9 @@ export class FastbasicDebugSession extends LoggingDebugSession { this._runtime.on('end', () => { this.sendEvent(new TerminatedEvent()); - // Stop existing emualator instance for Mac - if ('win32' !== process.platform) { + this._runtime.cleanup(); + // Stop Atari800MacX only instance on Mac + if (this._emulatorType === 'atari800' && process.platform !== 'win32') { exec(`osascript -e 'quit app "Atari800MacX"'`); } }); @@ -166,9 +170,11 @@ export class FastbasicDebugSession extends LoggingDebugSession { protected disconnectRequest(response: DebugProtocol.DisconnectResponse, args: DebugProtocol.DisconnectArguments, request?: DebugProtocol.Request): void { console.log(`disconnectRequest suspend: ${args.suspendDebuggee}, terminate: ${args.terminateDebuggee}`); - if ('win32' !== process.platform) { - exec(`osascript -e 'quit app "Atari800MacX"'`); - } + this._runtime.cleanup(); + if (this._emulatorType === 'atari800' && process.platform !== 'win32') { + exec(`osascript -e 'quit app "Atari800MacX"'`); + } + super.disconnectRequest(response, args, request); } protected async attachRequest(response: DebugProtocol.AttachResponse, args: IAttachRequestArguments) { @@ -181,6 +187,15 @@ export class FastbasicDebugSession extends LoggingDebugSession { // Check if args provided path exists if (currentPath && currentPath.trim().length>0) { if (await this._fileAccessor.doesFileExist(currentPath)) { + // If path looks like a directory (does not end with the executable name), resolve to executable inside it + const normalizedPath = currentPath.replace(/[\\/]+$/, ''); + const executableName = executable.split(/[/\\]/).pop() || executable; + if (!normalizedPath.endsWith(executableName)) { + const candidate = normalizedPath + '/' + executable; + if (await this._fileAccessor.doesFileExist(candidate)) { + return candidate; + } + } return currentPath; } else { return ""; @@ -291,7 +306,18 @@ export class FastbasicDebugSession extends LoggingDebugSession { args.compilerPath = args.compilerPath || ""; args.emulatorPath = args.emulatorPath || ""; - + this._emulatorType = (args.emulatorType || 'atari800') as 'atari800' | 'fujisan'; + const emulatorType = this._emulatorType; + const fujisanHost = args.fujisanHost || 'localhost'; + const fujisanPort = args.fujisanPort ?? 6502; + + if (emulatorType === 'fujisan' && (fujisanPort < 1 || fujisanPort > 65535)) { + response.success = false; + response.message = `Fujisan port must be between 1 and 65535 (got ${fujisanPort}).`; + this.sendResponse(response); + return undefined; + } + if (args.sourceFile.toLocaleLowerCase().endsWith(".json")) { this.sendEvent(new TerminatedEvent()); return undefined; @@ -321,37 +347,37 @@ export class FastbasicDebugSession extends LoggingDebugSession { return undefined; } - let emulatorPath = args.emulatorPath.trim(); + let emulatorPath = args.emulatorPath?.trim() || ""; - let emulatorPathManuallySet = emulatorPath.trim().length > 0; + // For Fujisan we don't launch an executable; we connect to the TCP server. + if (emulatorType !== 'fujisan') { + let emulatorPathManuallySet = emulatorPath.length > 0; - // Attempt to find installed Mac Emulator. If multiple installed, use the first one found - if (!isWindows && !emulatorPathManuallySet) { - const { stdout } = await exec("mdfind -name 'Atari800Macx.app'"); - emulatorPath = stdout.split('\n')[0].trim(); - } - - - if (emulatorPath.trim().length === 0) { - - emulatorPath = await this.validateDependency( - "emulatorPath", - (isWindows ? "Altirra" : "Atari800MacX" ) + " Emulator", - isWindows ? URL_EMULATOR_WIN : URL_EMULATOR_MAC, - isWindows ? "Altirra" : "Atari800MacX", - emulatorPath, - isWindows ? "altirra64.exe" : "atari800macx.app", - true, - "An valid emulator is required to run the program." + // Attempt to find installed Mac Emulator. If multiple installed, use the first one found + if (!isWindows && !emulatorPathManuallySet) { + const { stdout } = await exec("mdfind -name 'Atari800Macx.app'"); + emulatorPath = stdout.split('\n')[0].trim(); + } + + if (emulatorPath.trim().length === 0) { + emulatorPath = await this.validateDependency( + "emulatorPath", + (isWindows ? "Altirra" : "Atari800MacX" ) + " Emulator", + isWindows ? URL_EMULATOR_WIN : URL_EMULATOR_MAC, + isWindows ? "Altirra" : "Atari800MacX", + emulatorPath, + isWindows ? "altirra64.exe" : "atari800macx.app", + true, + "An valid emulator is required to run the program." ); + } - } - - if (emulatorPath==="") { - response.success = false; - response.message = "Could not find Atari Emulator. Re-install or check the emulatorPath in launch.json."; - this.sendResponse(response); - return undefined; + if (emulatorPath==="") { + response.success = false; + response.message = "Could not find Atari Emulator. Re-install or check the emulatorPath in launch.json."; + this.sendResponse(response); + return undefined; + } } @@ -363,7 +389,6 @@ export class FastbasicDebugSession extends LoggingDebugSession { fileParts[fileParts.length - 1] = ""; let filePath = fileParts.join('/'); - let fileNoExt = filePath + filenameNoExt; let binFolder = filePath+"bin"; let sourceNoExt = filePath + filename + '.debug.'; @@ -467,11 +492,18 @@ export class FastbasicDebugSession extends LoggingDebugSession { // Copy the .xex file to the bin folder to run in the emulator await vscode.workspace.fs.rename(vscode.Uri.file(atariExecutableTemp), vscode.Uri.file(atariExecutable), { overwrite: true }); - - fastBasicChannel.appendLine(`Running in emulator..`); - // start the program in the runtime - await this._runtime.start(debugFileNoExt, file, noDebug, emulatorPath, atariExecutable, emulatorPathManuallySet, true === (isWindows || args.windowsPaths)); + fastBasicChannel.appendLine(`Running in emulator..`); + + if (emulatorType === 'fujisan') { + const autoConfigureH4 = args.autoConfigureH4 !== false; + const fujisanBootMode = args.fujisanBootMode ?? 'none'; + await this._runtime.startWithFujisan(debugFileNoExt, file, noDebug, fujisanHost, fujisanPort, atariExecutable, binFolder, autoConfigureH4, fujisanBootMode); + } else { + // Launch the emulator and point H4: at the bin folder + const emulatorPathManuallySet = (args.emulatorPath?.trim().length ?? 0) > 0; + await this._runtime.start(debugFileNoExt, file, noDebug, emulatorPath, atariExecutable, emulatorPathManuallySet, true === (isWindows || args.windowsPaths)); + } if (Boolean(args.noDebug)) { this.sendEvent(new TerminatedEvent()); diff --git a/src/fujisanClient.ts b/src/fujisanClient.ts new file mode 100644 index 0000000..05ddad0 --- /dev/null +++ b/src/fujisanClient.ts @@ -0,0 +1,211 @@ +/** + * Client for the Fujisan emulator's TCP server. + * When you choose emulatorType "fujisan", the debugger talks to Fujisan over TCP to + * deploy the XEX, set H4:, and trigger a cold boot. Breakpoints and variables still + * go through the H4: host drive (debug.in, debug.out, debug.mem) like with other emulators. + */ + +import * as net from 'net'; + +export interface FujisanResponse { + type: 'response' | 'event'; + status?: 'success' | 'error'; + id?: string; + result?: unknown; + error?: string; + event?: string; + data?: unknown; +} + +export interface FujisanCommand { + command: string; + id?: string; + params?: unknown; +} + +export class FujisanClient { + private static readonly CONNECTION_TIMEOUT_MS = 5000; + private static readonly REQUEST_TIMEOUT_MS = 10000; + + private client: net.Socket | null = null; + private connected: boolean = false; + private requestId: number = 0; + private pendingRequests: Map void> = new Map(); + private buffer: string = ''; + + constructor(private host: string = 'localhost', private port: number = 6502) { + } + + /** + * Connect to Fujisan's TCP server. Call this before sending any commands. + * Fails with an error if the connection fails or times out (5 seconds). + */ + public async connect(): Promise { + return new Promise((resolve, reject) => { + this.client = new net.Socket(); + + const timeoutHandle = setTimeout(() => { + if (!this.connected) { + this.client?.destroy(); + reject(new Error('Connection timeout')); + } + }, FujisanClient.CONNECTION_TIMEOUT_MS); + + this.client.connect(this.port, this.host, () => { + this.connected = true; + clearTimeout(timeoutHandle); + resolve(); + }); + + this.client.on('data', (data) => { + this.handleData(data.toString()); + }); + + this.client.on('error', (error) => { + if (!this.connected) { + clearTimeout(timeoutHandle); + reject(error); + } else { + console.error('Fujisan TCP error:', error); + this.connected = false; + this.pendingRequests.forEach((handler) => { + handler({ type: 'response', status: 'error', error: 'Connection lost' }); + }); + this.pendingRequests.clear(); + } + }); + + this.client.on('close', () => { + this.connected = false; + }); + }); + } + + /** Close the connection to Fujisan. Safe to call even if already disconnected. */ + public disconnect(): void { + if (this.client) { + this.client.destroy(); + this.client = null; + this.connected = false; + } + } + + /** True if we're still connected to the TCP server. */ + public isConnected(): boolean { + return this.connected; + } + + private handleData(data: string): void { + this.buffer += data; + + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const response: FujisanResponse = JSON.parse(line); + this.handleResponse(response); + } catch (error) { + console.error('Bad JSON from Fujisan:', line, error); + } + } + } + } + + private handleResponse(response: FujisanResponse): void { + if (response.type === 'response' && response.id) { + const handler = this.pendingRequests.get(response.id); + if (handler) { + handler(response); + this.pendingRequests.delete(response.id); + } + } + } + + /** + * Send a command and wait for the response. + * @param command e.g. 'config.set_hard_drive', 'system.cold_boot', 'media.load_xex' + * @param params optional payload for the command + */ + public async sendCommand(command: string, params?: unknown): Promise { + if (!this.connected) { + throw new Error('Not connected to Fujisan. Start Fujisan and turn on the TCP server (Tools → TCP Server).'); + } + + const id = `req-${++this.requestId}`; + const request: FujisanCommand = { + command, + id, + params + }; + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, (response) => { + if (response.status === 'error') { + const errorMsg = response.error || 'Unknown error'; + reject(new Error(errorMsg)); + } else { + resolve(response.result); + } + }); + + const message = JSON.stringify(request) + '\n'; + this.client!.write(message); + + setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error('Fujisan did not respond in time')); + } + }, FujisanClient.REQUEST_TIMEOUT_MS); + }); + } + + /** Perform a full cold boot in Fujisan. */ + public async coldBoot(): Promise { + return this.sendCommand('system.cold_boot'); + } + + /** Perform a warm boot in Fujisan. */ + public async warmBoot(): Promise { + return this.sendCommand('system.warm_boot'); + } + + /** Stop the FujiNet-PC process managed by Fujisan. */ + public async stopFujiNet(): Promise { + return this.sendCommand('system.stop_fujinet'); + } + + /** Start the FujiNet-PC process with Fujisan's saved settings. */ + public async startFujiNet(): Promise { + return this.sendCommand('system.start_fujinet'); + } + + /** + * Configure H4: hard drive mapping via TCP. + * @param path Absolute path to the folder to map to H4: + * @param drive Drive number (default: 4 for H4:) + */ + public async setHardDrive(path: string, drive: number = 4): Promise { + return this.sendCommand('config.set_hard_drive', { drive, path }); + } + + /** Load a XEX file into the emulator. path is the full path to the .xex file. */ + public async loadXex(path: string): Promise { + return this.sendCommand('media.load_xex', { path }); + } + + /** + * Reserved for when Fujisan adds a debug-specific load (e.g. with symbols). + * Right now we just use loadXex() for both run and debug. + */ + public async loadXexForDebug(path: string): Promise { + return this.sendCommand('debug.load_xex_for_debug', { path }); + } + + /** Query current emulator state (for future use). */ + public async getState(): Promise { + return this.sendCommand('status.get_state'); + } +} diff --git a/src/runtime.ts b/src/runtime.ts index 0a7ccd8..a20e1d2 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -5,6 +5,7 @@ import util = require('util'); const execPromise = util.promisify(require('child_process').exec); import { GetEmulatorSettingsWin } from './emulatorSettingsFiles'; import { exec } from "node:child_process"; +import { FujisanClient } from './fujisanClient'; export interface FileAccessor { isWindows: boolean; @@ -130,11 +131,34 @@ export class FastbasicRuntime extends EventEmitter { private _addressToLineMap = new Map(); private _lineToAddressMap = new Map(); private _maxLine : number = 0; - private _debugCheckAddress: number = 0; + private _debugCheckAddress: number = 0; private _debugBreakAddress: number = 0; private _debugTokRET: number = 0; private _debugTokJUMP: number = 0; - + + private _fujisanClient: FujisanClient | null = null; + /** Last H4: path configured via Fujisan. */ + private _lastConfiguredH4Path: string | null = null; + + /** Delay after configuring H4: so Fujisan's restartEmulator() can complete and FujiNet-PC + * can stabilize before media.load_xex triggers a second Atari800_Coldstart via BINLOAD. */ + private static readonly H4_CONFIG_SETTLE_TIME_MS = 1500; + /** Delay after warm boot before loading the XEX. */ + private static readonly WARM_BOOT_SETTLE_TIME_MS = 500; + /** + * Delay after starting FujiNet-PC (cold mode: stop → load XEX → start) so it is ready + * before the loaded program tries to use the network. + */ + private static readonly FUJINET_START_SETTLE_TIME_MS = 3000; + + /** Disconnect from Fujisan and release the TCP client, if any. */ + public cleanup(): void { + if (this._fujisanClient) { + this._fujisanClient.disconnect(); + this._fujisanClient = null; + } + } + constructor(private fileAccessor: FileAccessor) { super(); } @@ -255,8 +279,80 @@ export class FastbasicRuntime extends EventEmitter { } } + /** + * Run with Fujisan over TCP. + * + * Fujisan must already be running with the TCP server enabled. When `autoConfigureH4` is true, + * H4: is pointed at `binFolder` via the TCP API (only when the path changes, to avoid extra restarts). + * + * `bootMode` controls how the machine is reset before loading the XEX: + * - 'none' (default): no explicit boot before `media.load_xex`. + * - 'warm': warm boot before loading. + * - 'cold': perform a FujiNet restart sequence before loading. + */ + public async startWithFujisan(program: string, originalSource: string, noDebug: boolean, fujisanHost: string, fujisanPort: number, executable: string, binFolder: string, autoConfigureH4: boolean = true, bootMode: 'none' | 'warm' | 'cold' = 'none'): Promise { + if (!noDebug) { + await this.loadSource(program, originalSource); + await this.sendMessageToProgram(MessageCommand.continue); + } - + fastBasicChannel.appendLine(`Connecting to Fujisan at ${fujisanHost}:${fujisanPort}...`); + this._fujisanClient = new FujisanClient(fujisanHost, fujisanPort); + try { + await this._fujisanClient.connect(); + fastBasicChannel.appendLine('Connected.'); + } catch (err: any) { + fastBasicChannel.appendLine(`Connection failed: ${err.message}`); + fastBasicChannel.appendLine('Make sure Fujisan is running and TCP server is on (Tools → TCP Server).'); + this.sendEvent('end'); + return; + } + + try { + if (autoConfigureH4) { + if (this._lastConfiguredH4Path !== binFolder) { + fastBasicChannel.appendLine(`Setting H4: to ${binFolder}...`); + await this._fujisanClient.setHardDrive(binFolder, 4); + this._lastConfiguredH4Path = binFolder; + await timeout(FastbasicRuntime.H4_CONFIG_SETTLE_TIME_MS); + } else { + fastBasicChannel.appendLine(`H4: already set to ${binFolder}, skipping.`); + } + } + + if (bootMode === 'warm') { + fastBasicChannel.appendLine('Warm boot...'); + await this._fujisanClient.warmBoot(); + await timeout(FastbasicRuntime.WARM_BOOT_SETTLE_TIME_MS); + } + + if (bootMode === 'cold') { + fastBasicChannel.appendLine('Stopping FujiNet-PC for clean restart...'); + await this._fujisanClient.stopFujiNet(); + await timeout(2000); + fastBasicChannel.appendLine('Starting FujiNet-PC...'); + await this._fujisanClient.startFujiNet(); + await timeout(FastbasicRuntime.FUJINET_START_SETTLE_TIME_MS); + } + + fastBasicChannel.appendLine(`Loading ${executable}...`); + await this._fujisanClient.loadXex(executable); + if (!autoConfigureH4) { + fastBasicChannel.appendLine('XEX loaded. Map H4: in Fujisan to: ' + binFolder); + } else { + fastBasicChannel.appendLine('XEX loaded.'); + } + } catch (err: any) { + fastBasicChannel.appendLine(`Fujisan: ${err.message}`); + this._fujisanClient.disconnect(); + this.sendEvent('end'); + return; + } + + if (!noDebug) { + await this.waitOnProgram(); + } + } /** * Continue execution to the next breakpoint or end of program