zart 1.7.12
zart: ^1.7.12 copied to clipboard
This is a Dart implementation of the Infocom Z-Machine.
example/main.dart
import 'dart:io';
import 'package:zart/src/logging.dart' show log;
import 'package:logging/logging.dart' show Level;
import 'package:zart/zart.dart';
/// A basic Console player for Z-Machine using the pump API
void main(List<String> args) async {
log.level = Level.WARNING;
log.onRecord.listen((record) {
print(record);
});
if (args.isEmpty) {
stdout.writeln('Usage: zart <game>');
exit(1);
}
final filename = args.first;
final f = File(filename);
if (!f.existsSync()) {
stdout.writeln('Error: Game file not found at "$filename"');
stdout.writeln('Current Directory: ${Directory.current.path}');
exit(1);
}
try {
final bytes = f.readAsBytesSync();
final gameData = Blorb.getZData(bytes);
if (gameData == null) {
stdout.writeln('Unable to load game.');
exit(1);
}
// Set IoProvider before loading so visitHeader() can read flags
Z.io = ConsoleProvider() as IoProvider;
Z.load(gameData);
} catch (fe) {
stdout.writeln("Exception occurred while trying to load game: $fe");
exit(1);
}
//enableDebug enables the other flags (verbose, trace, breakpoints, etc)
Debugger.enableDebug = false;
Debugger.enableVerbose = false;
Debugger.enableTrace = false;
Debugger.enableStackTrace = false;
//Debugger.setBreaks([0x2bfd]);
stdout.writeln(getPreamble().join('\n'));
stdout.writeln();
try {
// Command queue for chained commands (e.g., "get up.take all.north")
final commandQueue = <String>[];
// Pump API: run until input needed, then get input and continue
var state = await Z.runUntilInput();
while (state != ZMachineRunState.quit) {
switch (state) {
case ZMachineRunState.needsLineInput:
// Check if we have queued commands from a previous chained input
if (commandQueue.isEmpty) {
(Z.io as ConsoleProvider).flush(addPrompt: true);
final line = stdin.readLineSync() ?? '';
stdout.writeln(); // Blank line after input for visual separation
// Split by '.' to support chained commands like "get up.take all.n"
final commands = line
.split('.')
.map((c) => c.trim())
.where((c) => c.isNotEmpty)
.toList();
if (commands.isEmpty) {
// Empty input, just submit empty string
state = await Z.submitLineInput('');
} else {
// Queue all commands and process the first one
commandQueue.addAll(commands);
state = await Z.submitLineInput(commandQueue.removeAt(0));
}
} else {
// Process next queued command - print as if user typed it
(Z.io as ConsoleProvider).flush(addPrompt: true);
final cmd = commandQueue.removeAt(0);
stdout.writeln('$cmd');
stdout.writeln();
state = await Z.submitLineInput(cmd);
}
break;
case ZMachineRunState.needsCharInput:
(Z.io as ConsoleProvider).flush(addPrompt: false);
// Use raw terminal mode for single character input
// This allows immediate keypress detection without requiring Enter
stdin.echoMode = false;
stdin.lineMode = false;
final charCode = stdin.readByteSync();
String char;
// Debug: print the byte received (uncomment to diagnose key detection)
stderr.writeln(
'[DEBUG] charCode: $charCode (0x${charCode.toRadixString(16)})',
);
// Check for escape sequences (arrow keys, function keys, etc.)
if (charCode == 0x1B) {
// ESC detected - check for CSI sequence (ESC [)
final next = stdin.readByteSync();
if (next == 0x5B) {
// CSI sequence - read the final byte
final code = stdin.readByteSync();
switch (code) {
case 0x41: // ESC [ A = Up arrow
char = String.fromCharCode(129); // ZSCII cursor up
break;
case 0x42: // ESC [ B = Down arrow
char = String.fromCharCode(130); // ZSCII cursor down
break;
case 0x43: // ESC [ C = Right arrow
char = String.fromCharCode(132); // ZSCII cursor right
break;
case 0x44: // ESC [ D = Left arrow
char = String.fromCharCode(131); // ZSCII cursor left
break;
default:
char = String.fromCharCode(
0x1B,
); // Unknown sequence, send ESC
}
} else {
// Not a CSI sequence, just ESC followed by something else
char = String.fromCharCode(0x1B);
}
} else if (charCode == 0xE0 || charCode == 0x00) {
// Windows extended key code - arrow keys, function keys, etc.
// On Windows console, special keys send 0xE0 (or 0x00) followed by a scan code
final scanCode = stdin.readByteSync();
switch (scanCode) {
case 0x48: // Up arrow
char = String.fromCharCode(129); // ZSCII cursor up
break;
case 0x50: // Down arrow
char = String.fromCharCode(130); // ZSCII cursor down
break;
case 0x4D: // Right arrow
char = String.fromCharCode(132); // ZSCII cursor right
break;
case 0x4B: // Left arrow
char = String.fromCharCode(131); // ZSCII cursor left
break;
default:
// Unknown extended key, send empty
char = '';
}
} else if (charCode == 13 || charCode == 10) {
// Enter key - ZSCII 13 is newline
char = '\n';
} else {
// Regular character
char = String.fromCharCode(charCode);
}
stdin.lineMode = true;
stdin.echoMode = true;
if (char.isNotEmpty) {
state = await Z.submitCharInput(char);
}
break;
case ZMachineRunState.quit:
case ZMachineRunState.error:
case ZMachineRunState.running:
break;
}
}
stdout.writeln('Zart: Game Over!');
exit(0);
} on GameException catch (e) {
log.severe('A game error occurred: $e');
exit(1);
} catch (err, stack) {
log.severe('A system error occurred. $err\n$stack');
exit(1);
}
}
/// Console provider with ANSI styling support and proper output ordering.
/// - Buffers output to ensure status bar displays before game text
/// - Supports ANSI colors and text styles when terminal supports them
/// - Uses simple linear output (no cursor positioning or scroll regions)
class ConsoleProvider implements IoProvider {
final int cols = 80;
// ANSI escape code support
final bool _supportsAnsi = stdout.supportsAnsiEscapes;
// Current text style (Z-Machine bitmask)
int _currentStyle = 0;
// Current colors
int _foregroundColor = 1;
int _backgroundColor = 1;
// Current window and split height
int _currentWindow = 0;
int _splitWindowLines = 0;
// Status line buffer (fixed width, filled with spaces)
late List<String> _statusLine;
int _cursorColumn = 0;
// Buffer for Window 0 output
final List<String> _window0Buffer = [];
ConsoleProvider() {
_resetStatusLine();
}
void _resetStatusLine() {
_statusLine = List.filled(cols, ' ');
_cursorColumn = 0;
}
// Z-Machine color to ANSI mapping
int _zColorToAnsiFg(int zColor) {
const map = {2: 30, 3: 31, 4: 32, 5: 33, 6: 34, 7: 35, 8: 36, 9: 37};
return map[zColor] ?? 39;
}
int _zColorToAnsiBg(int zColor) {
const map = {2: 40, 3: 41, 4: 42, 5: 43, 6: 44, 7: 45, 8: 46, 9: 47};
return map[zColor] ?? 49;
}
String _getAnsiPrefix() {
if (!_supportsAnsi) return '';
final codes = <String>[];
if (_currentStyle & 1 != 0) codes.add('7');
if (_currentStyle & 2 != 0) codes.add('1');
if (_currentStyle & 4 != 0) codes.add('3');
if (_foregroundColor > 1) codes.add('${_zColorToAnsiFg(_foregroundColor)}');
if (_backgroundColor > 1) codes.add('${_zColorToAnsiBg(_backgroundColor)}');
return codes.isEmpty ? '' : '\x1B[${codes.join(";")}m';
}
String _getAnsiReset() => _supportsAnsi ? '\x1B[0m' : '';
@override
Future<dynamic> command(Map<String, dynamic> command) async {
final cmd = command['command'];
switch (cmd) {
case IoCommands.print:
_handlePrint(command['window'], command['buffer']);
return null;
case IoCommands.splitWindow:
_splitWindowLines = command['lines'] ?? 0;
return null;
case IoCommands.setWindow:
final newWindow = command['window'] ?? 0;
if (_currentWindow == 1 && newWindow == 0) {
// Leaving Window 1 - emit status line, then flush Window 0 buffer
_emitStatusLine();
flush(addPrompt: false);
} else if (_currentWindow == 0 && newWindow == 1) {
// Entering Window 1 - reset status line
_resetStatusLine();
}
_currentWindow = newWindow;
return null;
case IoCommands.setCursor:
// Track cursor column for status line positioning
_cursorColumn = (command['column'] ?? 1) - 1;
return null;
case IoCommands.setTextStyle:
_currentStyle = command['style'] ?? 0;
return null;
case IoCommands.setColour:
_foregroundColor = command['foreground'] ?? 1;
_backgroundColor = command['background'] ?? 1;
return null;
case IoCommands.status:
// V3-style status - format and print directly
final roomName = (command['room_name'] ?? '').toString().toUpperCase();
final score =
'Score: ${command['score_one']} / ${command['score_two']}';
_resetStatusLine();
_writeToStatusLine(0, roomName);
_writeToStatusLine(cols - score.length, score);
_emitStatusLine();
return null;
case IoCommands.save:
return await _saveGame(command['file_data']);
case IoCommands.clearScreen:
_resetStatusLine();
_window0Buffer.clear();
_currentStyle = 0;
_foregroundColor = 1;
_backgroundColor = 1;
stdout.write(_getAnsiReset());
stdout.writeln('\n' * 24);
return null;
case IoCommands.restore:
return await _restore();
case IoCommands.printDebug:
stdout.writeln(command['message']);
return null;
case IoCommands.quit:
_emitStatusLine();
flush(addPrompt: false);
stdout.write(_getAnsiReset());
return null;
case IoCommands.read:
case IoCommands.readChar:
// (This path is for non-pump mode, if ever used)
// Emit any pending status and buffered text before input
_emitStatusLine();
flush(addPrompt: true);
return null;
default:
return null;
}
}
@override
int getFlags1() {
if (!_supportsAnsi) return 0;
return Header.flag1V4BoldfaceAvail |
Header.flag1V4ItalicAvail |
Header.flag1VSColorAvail;
}
void _writeToStatusLine(int column, String text) {
for (int i = 0; i < text.length && column + i < cols; i++) {
_statusLine[column + i] = text[i];
}
}
void _emitStatusLine() {
final line = _statusLine.join();
if (line.trim().isNotEmpty) {
if (_supportsAnsi) {
stdout.writeln('\x1B[1m$line\x1B[0m');
} else {
stdout.writeln(line);
}
}
_resetStatusLine();
}
void flush({bool addPrompt = false}) {
if (_window0Buffer.isEmpty && !addPrompt) return;
var fullText = _window0Buffer.join('');
// Always strip trailing prompt content from the buffer to avoid duplication
// (Z5 games often include their own prompt, which we want to replace with our controlled one)
// Regex matches a prompt '>' at the start of line/string, followed by optional whitespace and ANSI codes.
fullText = fullText.replaceAll(
RegExp(r'(?:^|[\n\r]+)>\s*(?:\x1B\[[\d;]*m)*$'),
'',
);
if (fullText.isNotEmpty) {
_printWrapped(fullText);
}
_window0Buffer.clear();
// Print our prompt without newline so cursor stays on same line
if (addPrompt) {
stdout.write('> ');
}
}
void _handlePrint(int? windowID, String text) {
if (text.isEmpty) return;
if (text.startsWith('["STATUS",') && text.endsWith(']')) return;
if (windowID == 1) {
// Skip quote box content (Window 1 when split > 2)
if (_splitWindowLines > 2) return;
// Status window - write to status line buffer at cursor column
_writeToStatusLine(_cursorColumn, text.replaceAll('\n', ''));
_cursorColumn += text.length;
} else {
// Main window - buffer for later
_window0Buffer.add(text);
}
}
void _printWrapped(String text) {
final prefix = _getAnsiPrefix();
final reset = prefix.isNotEmpty ? _getAnsiReset() : '';
for (final line in text.split('\n')) {
if (line.isEmpty) {
stdout.writeln();
continue;
}
final words = line.split(' ');
var currentLine = StringBuffer();
for (final word in words) {
if (currentLine.length + word.length + 1 > cols &&
currentLine.isNotEmpty) {
stdout.writeln('$prefix${currentLine.toString().trimRight()}$reset');
currentLine = StringBuffer();
}
if (currentLine.isNotEmpty) currentLine.write(' ');
currentLine.write(word);
}
if (currentLine.isNotEmpty) {
stdout.writeln('$prefix${currentLine.toString().trimRight()}$reset');
}
}
}
Future<bool> _saveGame(List<int>? saveBytes) async {
stdout.writeln('(Caution: will overwrite existing file!)');
stdout.writeln('Enter file name to save to (no extension):');
final fn = stdin.readLineSync();
if (fn == null || fn.isEmpty) {
stdout.writeln('Invalid file name given.');
return false;
}
try {
stdout.writeln('Saving game "$fn.sav".');
File('$fn.sav').writeAsBytesSync(saveBytes!);
return true;
} catch (_) {
stderr.writeln('File IO error.');
return false;
}
}
Future<List<int>?> _restore() async {
stdout.writeln('Enter game file name to load (no extension):');
final fn = stdin.readLineSync();
if (fn == null || fn.isEmpty) {
stdout.writeln('Invalid file name given.');
return null;
}
try {
stdout.writeln('Restoring game "$fn.sav"...');
final result = File('$fn.sav').readAsBytesSync().toList();
print(result.length);
return result;
} catch (_) {
stderr.writeln('File IO error.');
return null;
}
}
}