Feat: anime matrix gets custom image and gif support for Scar 2025 with other minor fixes

This commit is contained in:
Ghoul
2026-01-24 02:43:53 +00:00
committed by Denis Benato
parent 2ea2cec172
commit 90af90850a
17 changed files with 1448 additions and 49 deletions

View File

@@ -100,8 +100,10 @@ impl AnimeType {
AnimeType::GA402
} else if board_name.contains("GU604V") {
AnimeType::GU604
} else if board_name.contains("G635L") || board_name.contains("G635L") {
} else if board_name.contains("G635L") {
AnimeType::G635L
} else if board_name.contains("G835L") {
AnimeType::G835L
} else {
AnimeType::Unsupported
}
@@ -111,7 +113,9 @@ impl AnimeType {
pub fn width(&self) -> usize {
match self {
AnimeType::GU604 => 70,
AnimeType::G835L => 74,
// TODO: Find G635L W*H
AnimeType::G635L => 68,
AnimeType::G835L => 68,
_ => 74,
}
}
@@ -121,7 +125,8 @@ impl AnimeType {
match self {
AnimeType::GA401 => 36,
AnimeType::GU604 => 43,
AnimeType::G835L => 39,
AnimeType::G635L => 34,
AnimeType::G835L => 34,
_ => 39,
}
}
@@ -130,8 +135,9 @@ impl AnimeType {
pub fn data_length(&self) -> usize {
match self {
AnimeType::GA401 => PANE_LEN * 2,
AnimeType::GU604 => PANE_LEN * 3,
AnimeType::G835L => PANE_LEN * 3,
// G835L has 810 LEDs: 210 (triangle) + 600 (staggered rectangle)
AnimeType::G635L => 810, // TODO: This is provisional until we have a G635L to test on
AnimeType::G835L => 810,
_ => PANE_LEN * 3,
}
}
@@ -195,29 +201,35 @@ impl TryFrom<AnimeDataBuffer> for AnimePacketType {
}
let mut buffers = match anime.anime {
AnimeType::GA401 => vec![[0; 640]; 2],
AnimeType::GA402
| AnimeType::GU604
| AnimeType::G635L
| AnimeType::G835L
| AnimeType::Unsupported => {
AnimeType::GA401 | AnimeType::G635L | AnimeType::G835L => vec![[0; 640]; 2],
AnimeType::GA402 | AnimeType::GU604 | AnimeType::Unsupported => {
vec![[0; 640]; 3]
}
};
for (idx, chunk) in anime.data.as_slice().chunks(PANE_LEN).enumerate() {
buffers[idx][BLOCK_START..BLOCK_END].copy_from_slice(chunk);
// G835L has different packing: 627 bytes in pane 1, 183 bytes in pane 2
if anime.anime == AnimeType::G835L || anime.anime == AnimeType::G635L {
let data = anime.data.as_slice();
// Pane 1: first 627 bytes
let pane1_len = PANE_LEN.min(data.len());
buffers[0][BLOCK_START..BLOCK_START + pane1_len].copy_from_slice(&data[..pane1_len]);
// Pane 2: remaining bytes (183)
if data.len() > PANE_LEN {
let pane2_len = data.len() - PANE_LEN;
buffers[1][BLOCK_START..BLOCK_START + pane2_len].copy_from_slice(&data[PANE_LEN..]);
}
} else {
for (idx, chunk) in anime.data.as_slice().chunks(PANE_LEN).enumerate() {
buffers[idx][BLOCK_START..BLOCK_START + chunk.len()].copy_from_slice(chunk);
}
}
buffers[0][..7].copy_from_slice(&USB_PREFIX1);
buffers[1][..7].copy_from_slice(&USB_PREFIX2);
if matches!(
anime.anime,
AnimeType::GA402
| AnimeType::GU604
| AnimeType::G635L
| AnimeType::G835L
| AnimeType::Unsupported
AnimeType::GA402 | AnimeType::GU604 | AnimeType::Unsupported
) {
buffers[2][..7].copy_from_slice(&USB_PREFIX3);
}

View File

@@ -146,6 +146,8 @@ impl AnimeDiagonal {
match anime_type {
AnimeType::GA401 => self.to_ga401_packets(),
AnimeType::GU604 => self.to_gu604_packets(),
AnimeType::G635L => self.to_g835l_packets(), // TODO: Verify with G635L model
AnimeType::G835L => self.to_g835l_packets(),
_ => self.to_ga402_packets(),
}
}
@@ -381,4 +383,80 @@ impl AnimeDiagonal {
AnimeDataBuffer::from_vec(crate::AnimeType::GA402, buf)
}
/// G835L diagonal packing - inverted geometry (rows grow then constant)
/// Triangle (rows 0-27): pairs grow from 1→14 LEDs
/// Rectangle (rows 28-67): constant 15 LEDs
///
/// Diagonal PNG layout for G835L:
/// - Image height = 34 (row pairs)
/// - Image width = 68 (half-step X grid)
/// - Even/odd rows are interleaved in X (staggered by 0.5 LED = 1 px)
fn to_g835l_packets(&self) -> Result<AnimeDataBuffer> {
use log::debug;
let mut buf = vec![0u8; AnimeType::G835L.data_length()];
let mut buf_idx = 0usize;
debug!(
"G835L packing: image dimensions {}x{}, buffer size {}",
self.1.first().map(|r| r.len()).unwrap_or(0),
self.1.len(),
buf.len()
);
// Helper: get row length for G835L
fn row_length(row: usize) -> usize {
if row < 28 {
row / 2 + 1
} else {
15
}
}
// Helper: starting X (in LED units) for the row
fn first_x(row: usize) -> usize {
if row < 28 {
0
} else {
(row - 28) / 2
}
}
// Process all 68 rows
for row in 0..68 {
let len = row_length(row);
let img_y = row / 2;
let base_x = first_x(row);
let stagger = row % 2;
for i in 0..len {
// Half-step X grid: even rows on even pixels, odd rows on odd pixels.
let img_x = (base_x + i) * 2 + stagger;
// Read from image, clamp to bounds
let val = if img_y < self.1.len() && img_x < self.1[img_y].len() {
self.1[img_y][img_x]
} else {
0
};
// Log first LED of each row for debugging
if i == 0 {
debug!(
"Row {}: len={}, first LED at img[{}][{}] = {}",
row, len, img_y, img_x, val
);
}
if buf_idx < buf.len() {
buf[buf_idx] = val;
}
buf_idx += 1;
}
}
debug!("G835L packing complete: {} bytes written", buf_idx);
AnimeDataBuffer::from_vec(AnimeType::G835L, buf)
}
}

View File

@@ -137,8 +137,8 @@ impl AnimeImage {
fn scale_y(anime_type: AnimeType) -> f32 {
match anime_type {
AnimeType::GA401 => 0.3,
AnimeType::GU604 => 0.28,
_ => 0.283,
AnimeType::GA402 => 0.283,
_ => 0.28,
}
}
@@ -149,6 +149,7 @@ impl AnimeImage {
/// square grid, so `first_x` is the x position on that grid where the
/// LED is actually positioned in relation to the Y.
///
/// For GA401/GA402/GU604 (shrinking pattern - diagonal cuts in from left):
/// ```text
/// +------------+
/// | |
@@ -162,6 +163,19 @@ impl AnimeImage {
/// ^ ------+
/// first_x
/// ```
///
/// For G835L/G635L (inverted pattern - triangle grows then rectangle shifts):
/// ```text
/// ● <- Row 0: first_x = 0, width = 1
/// ● <- Row 1: first_x = 0 (stagger), width = 1
/// ● ● <- Row 2: first_x = 0, width = 2
/// ● ● <- Row 3: first_x = 0 (stagger), width = 2
/// ... <- Triangle continues, first_x = 0 for rows 0-27
/// ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● <- Row 28+: first_x grows
/// ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● <- Rectangle shifts right
/// ```
/// Triangle (rows 0-27): first_x = 0 (no cumulative shift)
/// Rectangle (rows 28-67): first_x = (y - 28) / 2 (shifts right)
fn first_x(anime_type: AnimeType, y: u32) -> u32 {
match anime_type {
AnimeType::GA401 => {
@@ -179,6 +193,16 @@ impl AnimeImage {
// and then their offset grows by one every two rows
(y - 9) / 2
}
AnimeType::G635L | AnimeType::G835L => {
// G835L/G635L have inverted geometry - triangle at top-left, rectangle shifts right
// Triangle (rows 0-27): no cumulative shift, just alternating stagger
// Rectangle (rows 28-67): shifts right by ~0.5px per row
if y < 28 {
0
} else {
(y - 28) / 2
}
}
_ => {
// first 11 rows start at zero
if y <= 11 {
@@ -221,6 +245,16 @@ impl AnimeImage {
}
38 - Self::first_x(anime_type, y) + y % 2
}
AnimeType::G635L | AnimeType::G835L => {
// G835L/G635L rows GROW then stay constant (inverted from other devices)
// Triangle (rows 0-27): pairs of rows with same length, 1→14
// Rectangle (rows 28-67): constant 15 LEDs
if y < 28 {
y / 2 + 1
} else {
15
}
}
_ => {
if y <= 11 {
return 34;
@@ -235,8 +269,9 @@ impl AnimeImage {
match anime_type {
// 33.0 = Longest row LED count (physical) plus half-pixel offset
AnimeType::GA401 => (33.0 + 0.5) * Self::scale_x(anime_type),
AnimeType::GU604 => (38.0 + 0.5) * Self::scale_x(anime_type),
AnimeType::G635L => (33.0 + 0.5) * Self::scale_x(anime_type),
AnimeType::G835L => (33.0 + 0.5) * Self::scale_x(anime_type),
_ => (35.0 + 0.5) * Self::scale_x(anime_type),
}
}
@@ -246,6 +281,8 @@ impl AnimeImage {
match anime_type {
AnimeType::GA401 => 55,
AnimeType::GU604 => 62,
AnimeType::G635L => 68,
AnimeType::G835L => 68,
_ => 61,
}
}
@@ -256,6 +293,8 @@ impl AnimeImage {
// 54.0 = End column LED count (physical) plus one dead pixel
AnimeType::GA401 => (54.0 + 1.0) * Self::scale_y(anime_type),
AnimeType::GU604 => 62.0 * Self::scale_y(anime_type),
AnimeType::G635L => 68.0 * Self::scale_y(anime_type),
AnimeType::G835L => 68.0 * Self::scale_y(anime_type),
// GA402 may not have dead pixels and require only the physical LED count
_ => 61.0 * Self::scale_y(anime_type),
}
@@ -269,8 +308,8 @@ impl AnimeImage {
1 | 3 => 35, // Some rows are padded
_ => 36 - y / 2,
},
AnimeType::GU604 => AnimeImage::width(anime_type, y),
// GA402 does not have padding, equivalent to width
// Other devices don't have dead pixels
_ => AnimeImage::width(anime_type, y),
}
}
@@ -405,13 +444,35 @@ impl AnimeImage {
let transform =
Mat3::from_scale_angle_translation(self.scale, self.angle, self.translation);
let pos_in_leds = Mat3::from_translation(Vec2::new(20.0, 20.0));
let pos_in_leds = Mat3::from_translation(self.led_center());
// Get LED-to-image coords
let led_from_px = pos_in_leds * led_from_cm * transform * cm_from_px * center;
led_from_px.inverse()
}
fn led_center(&self) -> Vec2 {
if !matches!(self.anime_type, AnimeType::G635L | AnimeType::G835L) {
return Vec2::new(20.0, 20.0);
}
let mut min = Vec2::splat(f32::INFINITY);
let mut max = Vec2::splat(f32::NEG_INFINITY);
for led in self.led_pos.iter().flatten() {
let pos = Vec2::new(led.x(), led.y());
min = min.min(pos);
max = max.max(pos);
}
if min.x.is_finite() {
let mut center = (min + max) * 0.5;
center.y += 1.0;
center
} else {
Vec2::new(20.0, 20.0)
}
}
/// Generate the base image from inputs. The result can be displayed as is
/// or updated via scale, position, or angle then displayed again after
/// `update()`.