From 28938f7301560a33bc8f28774b66a5bab2635373 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 18 Mar 2026 02:43:39 +0100 Subject: [PATCH] Refactor game loop to await start and enhance Sixel rasterizer terminal support detection Signed-off-by: Hans Kokx --- apps/wolf_3d_cli/bin/main.dart | 2 +- apps/wolf_3d_cli/lib/cli_game_loop.dart | 38 +++- .../lib/src/rasterizer/sixel_rasterizer.dart | 174 ++++++++++++++++-- 3 files changed, 187 insertions(+), 27 deletions(-) diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index c005707..6bce90e 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -56,5 +56,5 @@ void main() async { onExit: stopAndExit, ); - gameLoop.start(); + await gameLoop.start(); } diff --git a/apps/wolf_3d_cli/lib/cli_game_loop.dart b/apps/wolf_3d_cli/lib/cli_game_loop.dart index bf91180..70e112d 100644 --- a/apps/wolf_3d_cli/lib/cli_game_loop.dart +++ b/apps/wolf_3d_cli/lib/cli_game_loop.dart @@ -9,7 +9,13 @@ class CliGameLoop { CliGameLoop({ required this.engine, required this.onExit, - }) : input = CliInput(), + }) : input = engine.input is CliInput + ? engine.input as CliInput + : throw ArgumentError.value( + engine.input, + 'engine.input', + 'CliGameLoop requires a CliInput instance.', + ), primaryRasterizer = SixelRasterizer(), secondaryRasterizer = AsciiRasterizer(isTerminal: true) { _rasterizer = primaryRasterizer; @@ -22,23 +28,37 @@ class CliGameLoop { final void Function(int code) onExit; final Stopwatch _stopwatch = Stopwatch(); + final Stream> _stdinStream = stdin.asBroadcastStream(); late CliRasterizer _rasterizer; StreamSubscription>? _stdinSubscription; Timer? _timer; bool _isRunning = false; Duration _lastTick = Duration.zero; - void start() { + Future start() async { if (_isRunning) { return; } - stdin.echoMode = false; - stdin.lineMode = false; + if (primaryRasterizer is SixelRasterizer) { + final sixel = primaryRasterizer as SixelRasterizer; + sixel.isSixelSupported = await SixelRasterizer.checkTerminalSixelSupport( + inputStream: _stdinStream, + ); + } + + if (stdin.hasTerminal) { + try { + stdin.echoMode = false; + stdin.lineMode = false; + } catch (_) { + // Keep running without raw mode when stdin is not mutable. + } + } stdout.write('\x1b[?25l\x1b[2J'); - _stdinSubscription = stdin.listen(_handleInput); + _stdinSubscription = _stdinStream.listen(_handleInput); _stopwatch.start(); _timer = Timer.periodic(const Duration(milliseconds: 33), _tick); _isRunning = true; @@ -59,8 +79,12 @@ class CliGameLoop { } if (stdin.hasTerminal) { - stdin.echoMode = true; - stdin.lineMode = true; + try { + stdin.echoMode = true; + stdin.lineMode = true; + } catch (_) { + // Ignore cleanup failures if stdin is no longer a mutable TTY. + } } if (stdout.hasTerminal) { diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart index 3c052d4..d9b3ece 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; @@ -24,6 +26,105 @@ class SixelRasterizer extends CliRasterizer { int _outputHeight = 1; bool _needsBackgroundClear = true; + /// Flag to determine if Sixel should actually be encoded and rendered. + /// This should be updated by calling [checkTerminalSixelSupport] before the game loop. + bool isSixelSupported = true; + + // =========================================================================== + // TERMINAL AUTODETECT + // =========================================================================== + + /// Asynchronously checks if the current terminal emulator supports Sixel graphics. + /// Ports the device attribute query logic from lsix. + static Future checkTerminalSixelSupport({ + Stream>? inputStream, + }) async { + // Match lsix behavior: allow explicit user override for misreporting terminals. + if (Platform.environment.containsKey('LSIX_FORCE_SIXEL_SUPPORT')) { + return true; + } + + // YAFT is vt102 compatible and cannot respond to the vt220 sequence, but supports Sixel. + final term = Platform.environment['TERM'] ?? ''; + if (term.startsWith('yaft')) { + return true; + } + + if (!stdin.hasTerminal) return false; + + bool terminalModesChanged = false; + bool originalLineMode = true; + bool originalEchoMode = true; + + try { + // Some runtimes report hasTerminal=true but still reject mode mutation + // with EBADF (bad file descriptor). Treat that as unsupported. + originalLineMode = stdin.lineMode; + originalEchoMode = stdin.echoMode; + + // Don't show escape sequences the terminal doesn't understand[cite: 24]. + stdin.lineMode = false; + stdin.echoMode = false; + terminalModesChanged = true; + + // Send Device Attributes query[cite: 25]. + stdout.write('\x1b[c'); + await stdout.flush(); + + final responseBytes = []; + final completer = Completer(); + + final sub = (inputStream ?? stdin).listen((List data) { + for (var byte in data) { + responseBytes.add(byte); + // Wait for the 'c' terminating character[cite: 25]. + if (byte == 99) { + if (!completer.isCompleted) completer.complete(true); + } + } + }); + + try { + // Wait up to 1 second for the terminal to respond[cite: 25]. + await completer.future.timeout(const Duration(seconds: 1)); + } catch (_) { + // Timeout occurred + } finally { + sub.cancel(); + } + + final response = String.fromCharCodes(responseBytes); + + // Some terminals include full CSI sequences like "\x1b[?62;4;6c". + // Keep only numeric attribute codes to mirror lsix's shell splitting behavior. + final attributeCodes = RegExp( + r'\d+', + ).allMatches(response).map((m) => m.group(0)).whereType().toSet(); + + // Split by ';' or '?' or 'c' to parse attributes[cite: 25]. + final parts = response.split(RegExp(r'[;?c]')); + + // Code "4" indicates Sixel support[cite: 26]. + return parts.contains('4') || attributeCodes.contains('4'); + } catch (e) { + return false; + } finally { + // Restore standard terminal settings + if (terminalModesChanged) { + try { + stdin.lineMode = originalLineMode; + stdin.echoMode = originalEchoMode; + } catch (_) { + // Ignore restoration failures to avoid crashing shutdown paths. + } + } + } + } + + // =========================================================================== + // RENDERING ENGINE + // =========================================================================== + FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) { final int previousOffsetColumns = _offsetColumns; final int previousOffsetRows = _offsetRows; @@ -91,6 +192,7 @@ class SixelRasterizer extends CliRasterizer { _engine = engine; final FrameBuffer originalBuffer = engine.frameBuffer; final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer); + // We only need 8-bit indices for the 256 VGA colors _screen = Uint8List(scaledBuffer.width * scaledBuffer.height); engine.frameBuffer = scaledBuffer; @@ -129,9 +231,6 @@ class SixelRasterizer extends CliRasterizer { int texY = (relativeY * 64).toInt().clamp(0, 63); int colorByte = texture.pixels[texX * 64 + texY]; - - // Note: Directional shading is omitted here to preserve strict VGA palette indices. - // Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table. _screen[y * width + x] = colorByte; } } @@ -215,6 +314,10 @@ class SixelRasterizer extends CliRasterizer { @override String finalizeFrame() { + if (!isSixelSupported) { + return _renderNotSupportedMessage(); + } + final String clearPrefix = _needsBackgroundClear ? '$_terminalTealBackground\x1b[2J\x1b[0m' : ''; @@ -222,18 +325,62 @@ class SixelRasterizer extends CliRasterizer { return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}'; } + // =========================================================================== + // UI FALLBACK + // =========================================================================== + + String _renderNotSupportedMessage() { + int cols = 80; + int rows = 24; + try { + if (stdout.hasTerminal) { + cols = stdout.terminalColumns; + rows = stdout.terminalLines; + } + } catch (_) {} + + const String msg1 = "Terminal does not support Sixel."; + const String msg2 = "Press TAB to switch renderers."; + + final int boxWidth = math.max(msg1.length, msg2.length) + 6; + const int boxHeight = 5; + + final int startX = math.max(1, (cols - boxWidth) ~/ 2); + final int startY = math.max(1, (rows - boxHeight) ~/ 2); + + final StringBuffer sb = StringBuffer(); + if (_needsBackgroundClear) { + sb.write('\x1b[0m\x1b[2J'); + _needsBackgroundClear = false; + } + + String center(String text) { + final int padLeft = (boxWidth - 2 - text.length) ~/ 2; + final int padRight = boxWidth - 2 - text.length - padLeft; + return (' ' * padLeft) + text + (' ' * padRight); + } + + // Draw centered box + sb.write('\x1b[$startY;${startX}H┌${'─' * (boxWidth - 2)}┐'); + sb.write('\x1b[${startY + 1};${startX}H│${center(msg1)}│'); + sb.write('\x1b[${startY + 2};${startX}H│${center("")}│'); + sb.write('\x1b[${startY + 3};${startX}H│${center(msg2)}│'); + sb.write('\x1b[${startY + 4};${startX}H└${'─' * (boxWidth - 2)}┘'); + + // Park the cursor at the bottom out of the way + sb.write('\x1b[$rows;1H'); + + return sb.toString(); + } + // =========================================================================== // SIXEL ENCODER // =========================================================================== - /// Converts the 8-bit index buffer into a standard Sixel sequence String toSixelString() { StringBuffer sb = StringBuffer(); - - // Start Sixel sequence (q = Sixel format) sb.write('\x1bPq'); - // 1. Define the Palette (and apply damage flash directly to the palette!) double damageIntensity = _engine.player.damageFlash; int redBoost = (150 * damageIntensity).toInt(); double colorDrop = 1.0 - (0.5 * damageIntensity); @@ -250,7 +397,6 @@ class SixelRasterizer extends CliRasterizer { b = (b * colorDrop).toInt().clamp(0, 255); } - // Sixel RGB ranges from 0 to 100 int sixelR = (r * 100) ~/ 255; int sixelG = (g * 100) ~/ 255; int sixelB = (b * 100) ~/ 255; @@ -258,11 +404,9 @@ class SixelRasterizer extends CliRasterizer { sb.write('#$i;2;$sixelR;$sixelG;$sixelB'); } - // 2. Encode scaled image in 6-pixel vertical bands. for (int band = 0; band < _outputHeight; band += 6) { Map colorMap = {}; - // Map out which pixels use which color in this 6px high band for (int x = 0; x < _outputWidth; x++) { for (int yOffset = 0; yOffset < 6; yOffset++) { int y = band + yOffset; @@ -272,28 +416,22 @@ class SixelRasterizer extends CliRasterizer { if (!colorMap.containsKey(colorIdx)) { colorMap[colorIdx] = Uint8List(_outputWidth); } - // Set the bit corresponding to the vertical position (0-5) colorMap[colorIdx]![x] |= (1 << yOffset); } } - // Write the encoded Sixel characters for each color present in the band bool firstColor = true; for (var entry in colorMap.entries) { if (!firstColor) { - // Carriage return to overlay colors on the same band sb.write('\$'); } firstColor = false; - - // Select color index sb.write('#${entry.key}'); Uint8List cols = entry.value; int currentVal = -1; int runLength = 0; - // Run-Length Encoding (RLE) loop for (int x = 0; x < _outputWidth; x++) { int val = cols[x]; if (val == currentVal) { @@ -312,7 +450,6 @@ class SixelRasterizer extends CliRasterizer { } } - // End Sixel sequence sb.write('\x1b\\'); return sb.toString(); } @@ -335,7 +472,6 @@ class SixelRasterizer extends CliRasterizer { void _writeSixelRle(StringBuffer sb, int value, int runLength) { String char = String.fromCharCode(value + 63); - // Sixel RLE format: ! (only worth it if count > 3) if (runLength > 3) { sb.write('!$runLength$char'); } else { @@ -344,7 +480,7 @@ class SixelRasterizer extends CliRasterizer { } // =========================================================================== - // PRIVATE HUD HELPERS (Adapted for 8-bit index buffer) + // PRIVATE HUD HELPERS // =========================================================================== void _blitVgaImage(VgaImage image, int startX, int startY) {