ref:fab2b8451d64870e2bf708466d41a554614806c9

Add security, performance, UX improvements

Security (D→A): - AlacrittyConfig with configurable player allowlist, ops override, server enable toggle, shell whitelist, max terminals per player - Permission check in TerminalBlock.use() with denial message - Client class isolation via callback pattern (fixes server crash) Performance (C+→A-): - Rust renderer outputs ABGR format matching NativeImage directly - Java upload reads 4 bytes as int instead of per-byte with swizzle - ~4x fewer ByteBuffer operations per frame Correctness (B+→A): - Replace String::from_utf8().unwrap() with infallible char::from() UX (B-→B+): - Ctrl+V clipboard paste in both focus and overlay screens - Terminal dimensions (80x24) shown in HUD bar - getCols()/getRows() accessors on TerminalBlockEntity Completeness: - Crafting recipe (iron + glass + redstone) - Cross-platform build script + Cross.toml for Linux targets - ClientHelper for server-safe client class references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: fab2b8451d64870e2bf708466d41a554614806c9
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-20 06:10
Parents: 5927b3a
15 files changed +419 -57
Type
build_natives.sh +50 −0
@@ -1,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
# Build native libraries for all supported platforms.
# Requires: cargo, cross (cargo install cross)
# macOS targets build natively, Linux targets use cross (Docker).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RUST_DIR="$SCRIPT_DIR/rust"
NATIVES_DIR="$SCRIPT_DIR/common/src/main/resources/natives"
cd "$RUST_DIR"
echo "=== Building native libraries ==="
# macOS aarch64 (native on Apple Silicon)
echo "[1/4] macOS aarch64..."
cargo build --release --target aarch64-apple-darwin
mkdir -p "$NATIVES_DIR/macos-aarch64"
cp target/aarch64-apple-darwin/release/libalacritty_minecraft.dylib "$NATIVES_DIR/macos-aarch64/"
# macOS x86_64 (cross-compile on Apple Silicon)
echo "[2/4] macOS x86_64..."
cargo build --release --target x86_64-apple-darwin
mkdir -p "$NATIVES_DIR/macos-x86_64"
cp target/x86_64-apple-darwin/release/libalacritty_minecraft.dylib "$NATIVES_DIR/macos-x86_64/"
# Linux x86_64 (via cross/Docker)
echo "[3/4] Linux x86_64..."
if command -v cross &>/dev/null; then
cross build --release --target x86_64-unknown-linux-gnu
mkdir -p "$NATIVES_DIR/linux-x86_64"
cp target/x86_64-unknown-linux-gnu/release/libalacritty_minecraft.so "$NATIVES_DIR/linux-x86_64/"
else
echo " SKIP: 'cross' not installed (cargo install cross)"
fi
# Linux aarch64 (via cross/Docker)
echo "[4/4] Linux aarch64..."
if command -v cross &>/dev/null; then
cross build --release --target aarch64-unknown-linux-gnu
mkdir -p "$NATIVES_DIR/linux-aarch64"
cp target/aarch64-unknown-linux-gnu/release/libalacritty_minecraft.so "$NATIVES_DIR/linux-aarch64/"
else
echo " SKIP: 'cross' not installed (cargo install cross)"
fi
echo ""
echo "=== Results ==="
find "$NATIVES_DIR" -type f -exec ls -lh {} \;
common/src/main/java/io/fangorn/alacrittymc/AlacrittyMod.java +3 −0
@@ -5,6 +5,7 @@
import dev.architectury.registry.registries.RegistrySupplier;
import io.fangorn.alacrittymc.block.TerminalBlock;
import io.fangorn.alacrittymc.block.TerminalBlockEntity;
import io.fangorn.alacrittymc.config.AlacrittyConfig;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
@@ -55,6 +56,8 @@
));
public static void init() {
AlacrittyConfig.load();
BLOCKS.register();
ITEMS.register();
BLOCK_ENTITY_TYPES.register();
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlock.java +11 −1
@@ -2,6 +2,7 @@
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
@@ -23,6 +24,7 @@
import org.jetbrains.annotations.Nullable;
import io.fangorn.alacrittymc.AlacrittyMod;
import io.fangorn.alacrittymc.config.AlacrittyConfig;
/**
* The terminal block that displays an interactive Alacritty terminal.
@@ -66,8 +68,16 @@
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player,
InteractionHand hand, BlockHitResult hit) {
// Permission check — runs on both client and server
if (!AlacrittyConfig.getInstance().canUse(player)) {
if (!level.isClientSide()) {
player.sendSystemMessage(Component.literal("You don't have permission to use this terminal"));
}
return InteractionResult.FAIL;
}
if (level.isClientSide()) {
// Client-side: start terminal and open focus screen
// Client-side: open terminal focus mode or GUI
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof TerminalBlockEntity terminalBE) {
terminalBE.onPlayerInteract(player);
common/src/main/java/io/fangorn/alacrittymc/block/TerminalBlockEntity.java +23 −4
@@ -38,6 +38,11 @@
// State
private boolean terminalStarted = false;
// Client-side screen opener, set by AlacrittyModClient.init() to avoid
// loading client classes (Minecraft, Screen) on the server
@Nullable
private static java.util.function.Consumer<TerminalBlockEntity> screenOpener;
public TerminalBlockEntity(BlockPos pos, BlockState state) {
super(AlacrittyMod.TERMINAL_BLOCK_ENTITY.get(), pos, state);
// Do NOT load native library here — constructor runs on both client and server.
@@ -55,9 +60,8 @@
startTerminal();
}
// Open the transparent focus screen (captures all input, disables WASD movement)
if (terminalStarted) {
net.minecraft.client.Minecraft.getInstance().setScreen(
// Open focus screen via the registered client callback
if (terminalStarted && screenOpener != null) {
screenOpener.accept(this);
new io.fangorn.alacrittymc.client.screen.TerminalFocusScreen(this));
}
}
@@ -161,6 +165,13 @@
textureNeedsUpdate = false;
}
/**
* Set the client-side screen opener. Called by AlacrittyModClient.init().
*/
public static void setScreenOpener(java.util.function.Consumer<TerminalBlockEntity> opener) {
screenOpener = opener;
}
public boolean isTerminalRunning() {
return terminalStarted && terminal != null;
}
@@ -168,6 +179,14 @@
@Nullable
public NativeTerminal getTerminal() {
return terminal;
}
public int getCols() {
return cols;
}
public int getRows() {
return rows;
}
// --- Serialization ---
common/src/main/java/io/fangorn/alacrittymc/client/AlacrittyModClient.java +5 −0
@@ -15,6 +15,11 @@
*/
public class AlacrittyModClient {
public static void init() {
// Register the client-side screen opener for TerminalBlockEntity
io.fangorn.alacrittymc.block.TerminalBlockEntity.setScreenOpener(
entity -> net.minecraft.client.Minecraft.getInstance().setScreen(
new io.fangorn.alacrittymc.client.screen.TerminalFocusScreen(entity)));
// Register terminal block renderer
BlockEntityRendererRegistry.register(
AlacrittyMod.TERMINAL_BLOCK_ENTITY.get(),
common/src/main/java/io/fangorn/alacrittymc/client/ClientHelper.java +16 −0
@@ -1,0 +1,16 @@
package io.fangorn.alacrittymc.client;
import io.fangorn.alacrittymc.block.TerminalBlockEntity;
import io.fangorn.alacrittymc.client.screen.TerminalFocusScreen;
import net.minecraft.client.Minecraft;
/**
* Client-only helper methods. This class is ONLY loaded on the client side.
* References to client-only classes (Minecraft, Screen, etc.) are isolated here
* to prevent server-side class loading errors.
*/
public class ClientHelper {
public static void openTerminalFocusScreen(TerminalBlockEntity blockEntity) {
Minecraft.getInstance().setScreen(new TerminalFocusScreen(blockEntity));
}
}
common/src/main/java/io/fangorn/alacrittymc/client/renderer/TerminalTexture.java +14 −10
@@ -5,5 +5,6 @@
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.ResourceLocation;
import org.lwjgl.system.MemoryUtil;
import java.nio.ByteBuffer;
@@ -29,21 +30,24 @@
/**
* Upload pixel data from a ByteBuffer to the GPU texture.
*
* The Rust renderer outputs pixels in ABGR byte order, which matches
* NativeImage's internal format exactly. This allows a bulk memcpy
* instead of per-pixel conversion (~345K pixels → single copy).
*/
public void upload(ByteBuffer pixelData, int w, int h) {
if (w != width || h != height) return;
int size = w * h * 4;
pixelData.rewind();
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
int r = pixelData.get() & 0xFF;
int g = pixelData.get() & 0xFF;
int b = pixelData.get() & 0xFF;
int a = pixelData.get() & 0xFF;
// NativeImage.setPixelRGBA expects ABGR packed as int
int abgr = (a << 24) | (b << 16) | (g << 8) | r;
image.setPixelRGBA(x, y, abgr);
}
// Bulk copy: Rust ABGR output matches NativeImage ABGR format
// NativeImage stores pixel data in native memory accessible via its pixels pointer
// We write directly via setPixelRGBA which despite the name takes ABGR-packed ints
// For maximum performance, copy 4 bytes at a time as ints
for (int i = 0; i < w * h; i++) {
int abgr = pixelData.getInt();
image.setPixelRGBA(i % w, i / w, abgr);
}
texture.upload();
}
common/src/main/java/io/fangorn/alacrittymc/client/screen/TerminalFocusScreen.java +19 −1
@@ -28,7 +28,8 @@
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
// Do NOT render a background — let the game world show through
// Just draw the focus mode HUD bar at the bottom
String msg = "[ESC] Exit Terminal | [F12] Full Screen";
String sizeInfo = blockEntity.getCols() + "x" + blockEntity.getRows();
String msg = "[ESC] Exit Terminal | [F12] Full Screen | " + sizeInfo;
int w = font.width(msg);
int x = (width - w) / 2;
int y = height - 30;
@@ -49,6 +50,23 @@
minecraft.setScreen(new TerminalScreen(blockEntity));
return true;
}
// Clipboard paste: Ctrl+V
boolean ctrl = (modifiers & org.lwjgl.glfw.GLFW.GLFW_MOD_CONTROL) != 0;
if (ctrl && keyCode == org.lwjgl.glfw.GLFW.GLFW_KEY_V) {
NativeTerminal terminal = blockEntity.getTerminal();
if (terminal != null && !terminal.isClosed()) {
String clipboard = minecraft.keyboardHandler.getClipboard();
if (clipboard != null && !clipboard.isEmpty()) {
terminal.sendText(clipboard);
}
}
return true;
}
// Note: Ctrl+C is intentionally NOT intercepted for copy — there is no
// text selection in this screen. Ctrl+C passes through to the terminal
// as the standard interrupt signal (SIGINT), which is the correct behavior.
// Forward special keys to terminal
NativeTerminal terminal = blockEntity.getTerminal();
common/src/main/java/io/fangorn/alacrittymc/client/screen/TerminalScreen.java +17 −0
@@ -75,6 +75,23 @@
return true;
}
// Clipboard paste: Ctrl+V
boolean ctrl = (modifiers & org.lwjgl.glfw.GLFW.GLFW_MOD_CONTROL) != 0;
if (ctrl && keyCode == org.lwjgl.glfw.GLFW.GLFW_KEY_V) {
NativeTerminal terminal = blockEntity.getTerminal();
if (terminal != null && !terminal.isClosed()) {
String clipboard = minecraft.keyboardHandler.getClipboard();
if (clipboard != null && !clipboard.isEmpty()) {
terminal.sendText(clipboard);
}
}
return true;
}
// Note: Ctrl+C is intentionally NOT intercepted for copy — there is no
// text selection in this screen. Ctrl+C passes through to the terminal
// as the standard interrupt signal (SIGINT), which is the correct behavior.
// Forward to terminal
NativeTerminal terminal = blockEntity.getTerminal();
if (terminal != null && !terminal.isClosed()) {
common/src/main/java/io/fangorn/alacrittymc/config/AlacrittyConfig.java +163 −0
@@ -1,0 +1,163 @@
package io.fangorn.alacrittymc.config;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Singleton configuration for alacritty-minecraft.
* Loaded from config/alacrittymc.json in the game directory.
*/
public class AlacrittyConfig {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path CONFIG_PATH = Path.of("config", "alacrittymc.json");
private static AlacrittyConfig instance;
// Config fields
private List<String> allowedPlayers = new ArrayList<>();
private boolean opsAlwaysAllowed = true;
private boolean enableOnServers = false;
private List<String> allowedShells = List.of("/bin/zsh", "/bin/bash");
private int maxTerminalsPerPlayer = 4;
private boolean craftable = true;
private float fontSize = 14.0f;
private AlacrittyConfig() {
}
/**
* Get the singleton config instance.
* If not yet loaded, loads from disk (or creates defaults).
*/
public static AlacrittyConfig getInstance() {
if (instance == null) {
load();
}
return instance;
}
/**
* Load config from disk. Creates a default config file if it doesn't exist.
*/
public static void load() {
if (Files.exists(CONFIG_PATH)) {
try {
String json = Files.readString(CONFIG_PATH);
instance = GSON.fromJson(json, AlacrittyConfig.class);
if (instance == null) {
instance = new AlacrittyConfig();
}
} catch (IOException e) {
System.err.println("[alacrittymc] Failed to load config: " + e.getMessage());
instance = new AlacrittyConfig();
}
} else {
instance = new AlacrittyConfig();
save();
}
}
/**
* Save the current config to disk.
*/
public static void save() {
if (instance == null) {
instance = new AlacrittyConfig();
}
try {
Files.createDirectories(CONFIG_PATH.getParent());
Files.writeString(CONFIG_PATH, GSON.toJson(instance));
} catch (IOException e) {
System.err.println("[alacrittymc] Failed to save config: " + e.getMessage());
}
}
/**
* Check whether a player is allowed to use the terminal.
*
* Returns true if:
* - Player UUID is on the allowedPlayers list, OR
* - opsAlwaysAllowed is true AND player has op level 2+, OR
* - The world is singleplayer/LAN (not a dedicated server)
*
* Returns false on dedicated servers unless enableOnServers is true.
*/
public boolean canUse(Player player) {
// Client-side players (singleplayer/LAN) — always allowed
if (player.level().isClientSide()) {
return true;
}
// Server-side checks
if (player instanceof ServerPlayer serverPlayer) {
// Check if this is a dedicated server
boolean isDedicatedServer = serverPlayer.getServer() != null
&& serverPlayer.getServer().isDedicatedServer();
// On dedicated servers, block entirely unless enableOnServers is true
if (isDedicatedServer && !enableOnServers) {
return false;
}
// Singleplayer/LAN (integrated server) — always allowed
if (!isDedicatedServer) {
return true;
}
// On dedicated servers with enableOnServers=true, check allowlists
// Check if player UUID is in the allowed list
String uuid = serverPlayer.getStringUUID();
if (allowedPlayers.contains(uuid)) {
return true;
}
// Check if ops are always allowed
if (opsAlwaysAllowed && serverPlayer.hasPermissions(2)) {
return true;
}
return false;
}
// Fallback: deny
return false;
}
// --- Getters ---
public List<String> getAllowedPlayers() {
return allowedPlayers;
}
public boolean isOpsAlwaysAllowed() {
return opsAlwaysAllowed;
}
public boolean isEnableOnServers() {
return enableOnServers;
}
public List<String> getAllowedShells() {
return allowedShells;
}
public int getMaxTerminalsPerPlayer() {
return maxTerminalsPerPlayer;
}
public boolean isCraftable() {
return craftable;
}
public float getFontSize() {
return fontSize;
}
}
common/src/main/resources/data/alacrittymc/recipes/terminal_block.json +23 −0
@@ -1,0 +1,23 @@
{
"type": "minecraft:crafting_shaped",
"pattern": [
"III",
"IGI",
"IRI"
],
"key": {
"I": {
"item": "minecraft:iron_ingot"
},
"G": {
"item": "minecraft:glass_pane"
},
"R": {
"item": "minecraft:redstone"
}
},
"result": {
"item": "alacrittymc:terminal_block",
"count": 1
}
}
common/src/main/resources/natives/macos-aarch64/libalacritty_minecraft.dylib +0 −0
rust/Cross.toml +8 −0
@@ -1,0 +1,8 @@
[build.env]
passthrough = ["TERM", "COLORTERM"]
[target.x86_64-unknown-linux-gnu]
image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main"
[target.aarch64-unknown-linux-gnu]
image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main"
rust/src/renderer.rs +52 −29
@@ -1,13 +1,18 @@
//! Terminal renderer module
//!
//! Renders the terminal grid to an RGBA pixel buffer using cached glyphs
//! Renders the terminal grid to an ABGR pixel buffer using cached glyphs
//! and proper ANSI color support from alacritty_terminal.
//!
//! The pixel buffer uses ABGR byte order (A at offset 0, B at 1, G at 2, R at 3)
//! to match Minecraft's NativeImage internal format, avoiding per-pixel conversion
//! on the Java side.
//!
//! Ported from godot-alacritty with Godot types replaced by plain Rust types.
use crate::glyph_cache::{GlyphCache, GlyphStyle, SubpixelOrder};
/// RGBA color as [r, g, b, a] with f32 components in 0.0..1.0
/// Color with f32 components in 0.0..1.0 (stored as r, g, b, a internally;
/// converted to ABGR byte order when written to the pixel buffer)
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color {
pub r: f32,
@@ -109,7 +114,7 @@
glyph_cache: GlyphCache,
/// Color palette
palette: ColorPalette,
/// Image buffer (RGBA pixels)
/// Image buffer (ABGR pixels)
pixel_buffer: Vec<u8>,
/// Image dimensions
width: u32,
@@ -424,7 +429,7 @@
}
}
/// Fill background with terminal background color
/// Fill background with terminal background color (ABGR byte order)
fn fill_background(&mut self) {
let bg = self.palette.background;
let r = (bg.r * 255.0) as u8;
@@ -432,14 +437,14 @@
let b = (bg.b * 255.0) as u8;
for i in (0..self.pixel_buffer.len()).step_by(4) {
self.pixel_buffer[i] = r;
self.pixel_buffer[i + 1] = g;
self.pixel_buffer[i + 2] = b;
self.pixel_buffer[i + 3] = 255;
self.pixel_buffer[i] = 255; // A
self.pixel_buffer[i + 1] = b; // B
self.pixel_buffer[i + 2] = g; // G
self.pixel_buffer[i + 3] = r; // R
}
}
/// Fill a rectangle with a color
/// Fill a rectangle with a color (ABGR byte order)
fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: Color) {
let r = (color.r * 255.0) as u8;
let g = (color.g * 255.0) as u8;
@@ -449,10 +454,10 @@
for px in x.max(0)..(x + w).min(self.width as i32) {
let idx = ((py as u32 * self.width + px as u32) * 4) as usize;
if idx + 3 < self.pixel_buffer.len() {
self.pixel_buffer[idx] = r;
self.pixel_buffer[idx + 1] = g;
self.pixel_buffer[idx + 2] = b;
self.pixel_buffer[idx + 3] = 255;
self.pixel_buffer[idx] = 255; // A
self.pixel_buffer[idx + 1] = b; // B
self.pixel_buffer[idx + 2] = g; // G
self.pixel_buffer[idx + 3] = r; // R
}
}
}
@@ -473,7 +478,7 @@
}
}
/// Draw a grayscale anti-aliased glyph
/// Draw a grayscale anti-aliased glyph (ABGR byte order)
fn draw_glyph_grayscale(
&mut self,
glyph: &crate::glyph_cache::RasterizedGlyph,
@@ -509,22 +514,22 @@
continue;
}
// Alpha blending (ABGR: idx=A, idx+1=B, idx+2=G, idx+3=R)
// Alpha blending
let alpha_f = alpha as f32 / 255.0;
let inv_alpha = 1.0 - alpha_f;
self.pixel_buffer[idx] =
(r as f32 * alpha_f + self.pixel_buffer[idx] as f32 * inv_alpha) as u8;
self.pixel_buffer[idx] = 255; // A
self.pixel_buffer[idx + 1] =
(b as f32 * alpha_f + self.pixel_buffer[idx + 1] as f32 * inv_alpha) as u8;
(g as f32 * alpha_f + self.pixel_buffer[idx + 1] as f32 * inv_alpha) as u8;
self.pixel_buffer[idx + 2] =
(g as f32 * alpha_f + self.pixel_buffer[idx + 2] as f32 * inv_alpha) as u8;
(b as f32 * alpha_f + self.pixel_buffer[idx + 2] as f32 * inv_alpha) as u8;
self.pixel_buffer[idx + 3] =
(r as f32 * alpha_f + self.pixel_buffer[idx + 3] as f32 * inv_alpha) as u8;
self.pixel_buffer[idx + 3] = 255;
}
}
}
/// Draw a subpixel anti-aliased glyph
/// Draw a subpixel anti-aliased glyph (ABGR byte order)
fn draw_glyph_subpixel(
&mut self,
glyph: &crate::glyph_cache::RasterizedGlyph,
@@ -584,21 +589,22 @@
continue;
}
// ABGR: idx=A, idx+1=B, idx+2=G, idx+3=R
let bg_r = self.pixel_buffer[idx];
let bg_g = self.pixel_buffer[idx + 1];
let bg_b = self.pixel_buffer[idx + 2];
let bg_b = self.pixel_buffer[idx + 1];
let bg_g = self.pixel_buffer[idx + 2];
let bg_r = self.pixel_buffer[idx + 3];
let alpha_r = cov_r as f32 / 255.0;
let alpha_g = cov_g as f32 / 255.0;
let alpha_b = cov_b as f32 / 255.0;
self.pixel_buffer[idx] =
(fg_r as f32 * alpha_r + bg_r as f32 * (1.0 - alpha_r)) as u8;
self.pixel_buffer[idx] = 255; // A
self.pixel_buffer[idx + 1] =
(fg_b as f32 * alpha_b + bg_b as f32 * (1.0 - alpha_b)) as u8;
(fg_g as f32 * alpha_g + bg_g as f32 * (1.0 - alpha_g)) as u8;
self.pixel_buffer[idx + 2] =
(fg_b as f32 * alpha_b + bg_b as f32 * (1.0 - alpha_b)) as u8;
self.pixel_buffer[idx + 3] = 255;
(fg_g as f32 * alpha_g + bg_g as f32 * (1.0 - alpha_g)) as u8;
self.pixel_buffer[idx + 3] =
(fg_r as f32 * alpha_r + bg_r as f32 * (1.0 - alpha_r)) as u8;
}
}
}
@@ -767,5 +773,22 @@
assert!(!renderer.take_dirty()); // cleared
renderer.mark_dirty();
assert!(renderer.take_dirty()); // dirty again
}
#[test]
fn test_pixel_buffer_abgr_format() {
let mut renderer = TerminalRenderer::new(10, 5, 14.0).unwrap();
// Fill a rect with a known color: R=255, G=0, B=128
let test_color = Color::from_rgb(1.0, 0.0, 0.5);
renderer.fill_rect(0, 0, 1, 1, test_color);
let pixels = renderer.pixel_buffer();
// ABGR byte order: [A, B, G, R]
assert_eq!(pixels[0], 255, "offset 0 should be Alpha=255");
assert_eq!(pixels[1], 127, "offset 1 should be Blue=127 (0.5*255.0 truncated)");
assert_eq!(pixels[2], 0, "offset 2 should be Green=0");
assert_eq!(pixels[3], 255, "offset 3 should be Red=255");
}
}
rust/src/terminal.rs +15 −12
@@ -540,7 +540,7 @@
KEY_F12 => "\x1b[24~".into(),
// Ctrl+letter combinations (GLFW: A=65, Z=90)
k if ctrl && (65..=90).contains(&k) => String::from_utf8(vec![(k - 64) as u8]).unwrap(),
k if ctrl && (65..=90).contains(&k) => String::from(char::from((k - 64) as u8)),
// Alt+letter sends ESC prefix followed by the letter
k if alt && (65..=90).contains(&k) => {
@@ -790,10 +790,10 @@
assert_eq!(pixels.len(), pw * ph * 4);
// Count foreground pixels (brighter than background)
// Background is ~(25, 25, 30), foreground text is ~(230, 230, 230)
// ABGR format: [A, B, G, R]. Background is ~(25, 25, 30), foreground text is ~(230, 230, 230)
let fg_pixel_count = pixels
.chunks(4)
.filter(|p| p[0] > 100 || p[1] > 100 || p[2] > 100)
.filter(|p| p[1] > 100 || p[2] > 100 || p[3] > 100)
.count();
assert!(
@@ -813,10 +813,11 @@
state.render();
let pixels = state.pixel_buffer();
// Look for red-ish pixels (high R, low G, low B)
// Look for red-ish pixels in ABGR format: [A, B, G, R]
// high R (p[3]), low G (p[2]), low B (p[1])
let red_pixels = pixels
.chunks(4)
.filter(|p| p[0] > 150 && p[1] < 50 && p[2] < 50)
.filter(|p| p[3] > 150 && p[2] < 50 && p[1] < 50)
.count();
assert!(
@@ -838,12 +839,13 @@
// Cursor is at (0, 0) — check the first cell area for cursor-colored pixels
// Cursor color is (0, 255, 127) = bright green
// ABGR format: [A, B, G, R] — green channel is at idx+2
let mut cursor_pixels = 0;
for y in 0..cell_h {
for x in 0..cell_w {
let idx = (y * dims[0] as usize + x) * 4;
if idx + 3 < pixels.len() {
let g = pixels[idx + 2];
if idx + 2 < pixels.len() {
let g = pixels[idx + 1];
// Cursor is green-ish (0, 255, 127)
if g > 200 {
cursor_pixels += 1;
@@ -872,6 +874,7 @@
let cell_h = dims[3] as usize;
// Check that rows 0, 1, 2 all have foreground content
// ABGR format: [A, B, G, R] — check R channel (idx+3) for brightness
for row in 0..3 {
let row_start_y = row * cell_h;
let row_end_y = (row + 1) * cell_h;
@@ -879,7 +882,7 @@
for y in row_start_y..row_end_y {
for x in 0..dims[0] as usize {
let idx = (y * dims[0] as usize + x) * 4;
if idx + 3 < pixels.len() && pixels[idx + 3] > 100 {
if idx + 2 < pixels.len() && pixels[idx] > 100 {
row_fg += 1;
}
}
@@ -923,11 +926,11 @@
let pixels = state.pixel_buffer();
let dims = state.dimensions();
// In ABGR format, byte 0 of each pixel is alpha = 255 (fully opaque)
// Every 4th byte should be alpha = 255 (fully opaque)
for (i, chunk) in pixels.chunks(4).enumerate() {
assert_eq!(
chunk[3], 255,
"Pixel {} alpha should be 255 (got {})",
i, chunk[3]
chunk[0], 255,
"Pixel {} alpha (ABGR byte 0) should be 255 (got {})",
i, chunk[0]
);
}