docs/superpowers/plans/2026-05-29-pixel-pointers.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Wrap raylib's GetPixelColor / SetPixelColor C functions with safe, ergonomic Rust signatures over &[u8] / &mut [u8], plus a bytes_per_pixel helper, with full validation and Tier-1 tests.
Architecture: A new focused module raylib/src/core/pixel.rs exposing four public items — PixelColorError (thiserror), bytes_per_pixel, get_pixel_color, set_pixel_color. Re-exported through core::* and prelude. The exhaustive bytes_per_pixel match arm catches future enum additions at compile time; a private validate_slice helper centralizes length + compressed-format checks.
Tech Stack: Rust 1.85 (edition 2024), thiserror 2.0 (already a dep), cargo test --lib (no window needed — Tier-1 only).
Spec reference: docs/superpowers/specs/2026-05-29-pixel-pointers-design.md.
Files:
raylib/src/core/pixel.rsraylib/src/core/mod.rsraylib/src/prelude.rsThis task gets the module compiling with stub bodies before any tests are written. Stubs return predictable errors so a compile is enough validation.
pub mod declarations in raylib/src/core/mod.rsRun: grep -nE '^pub mod ' raylib/src/core/mod.rs
Expected output: a block of pub mod window;, pub mod drawing;, etc. Note the alphabetical convention (if any) so the new pub mod pixel; slots in correctly.
pub use declarations in raylib/src/prelude.rsRun: grep -nE '^pub use ' raylib/src/prelude.rs | head -20
Expected: a series of pub use crate::core::xxx::*; re-exports. Note the convention so the new pub use crate::core::pixel::*; slots in correctly.
raylib/src/core/pixel.rs with the error type and stubsWrite the file with this exact content (full module skeleton — tests come in Task 3):
//! Per-pixel reads and writes into raw byte buffers in a given [`PixelFormat`].
//!
//! These wrap raylib's `GetPixelColor` / `SetPixelColor` C functions and are
//! the right tools when you have a byte buffer (e.g. one you're about to
//! upload to a [`Texture2D`](crate::core::texture::Texture2D), or a CPU
//! framebuffer you generated yourself) and want to read or write a single
//! pixel value while staying format-aware.
//!
//! If you instead have an [`Image`](crate::core::texture::Image) and want
//! the pixel at coordinates `(x, y)`, use
//! [`Image::get_color`](crate::core::texture::Image::get_color) — that goes
//! through raylib's separate `GetImageColor` C function and handles the row
//! stride for you.
//!
//! # BGRA limitation
//!
//! raylib's [`PixelFormat`] enum has no `B8G8R8A8` variant. The rlsw
//! software-renderer test harness stores its framebuffer as BGRA bytes
//! while labeling them with `PIXELFORMAT_UNCOMPRESSED_R8G8B8A8`, and the
//! channel swap is expressed as a bespoke byte-swap loop in
//! `raylib::test_harness::normalize_readback`. These functions cannot
//! eliminate that loop — feeding them BGRA bytes labeled as
//! `R8G8B8A8` returns a `Color` with R and B swapped, which is the same
//! problem.
use crate::consts::PixelFormat;
use crate::core::Color;
use crate::ffi;
/// Errors returned by [`get_pixel_color`] / [`set_pixel_color`].
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PixelColorError {
/// The byte slice is shorter than the format requires.
#[error("pixel format {format:?} needs {expected} bytes per pixel, got {actual}")]
InsufficientBytes {
/// The format the call was made against.
format: PixelFormat,
/// Bytes required for one pixel in this format.
expected: usize,
/// Bytes actually provided.
actual: usize,
},
/// Compressed formats are block-addressed; they cannot be read or
/// written one pixel at a time. raylib's `GetPixelColor` does not
/// support them either.
#[error("compressed pixel format {0:?} cannot be addressed pixel-by-pixel")]
CompressedFormat(PixelFormat),
}
/// Bytes per single pixel for an uncompressed [`PixelFormat`].
///
/// Returns `None` for compressed variants — they are addressed by
/// block, not pixel.
///
/// The match is exhaustive (no wildcard). If raylib adds a new
/// `PixelFormat` variant in a future version, this function will fail
/// to compile, surfacing the change rather than silently returning
/// `None`.
pub fn bytes_per_pixel(format: PixelFormat) -> Option<usize> {
use PixelFormat::*;
Some(match format {
PIXELFORMAT_UNCOMPRESSED_GRAYSCALE => 1,
PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA => 2,
PIXELFORMAT_UNCOMPRESSED_R5G6B5 => 2,
PIXELFORMAT_UNCOMPRESSED_R5G5B5A1 => 2,
PIXELFORMAT_UNCOMPRESSED_R4G4B4A4 => 2,
PIXELFORMAT_UNCOMPRESSED_R8G8B8 => 3,
PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 => 4,
PIXELFORMAT_UNCOMPRESSED_R32 => 4,
PIXELFORMAT_UNCOMPRESSED_R32G32B32 => 12,
PIXELFORMAT_UNCOMPRESSED_R32G32B32A32 => 16,
PIXELFORMAT_UNCOMPRESSED_R16 => 2,
PIXELFORMAT_UNCOMPRESSED_R16G16B16 => 6,
PIXELFORMAT_UNCOMPRESSED_R16G16B16A16 => 8,
PIXELFORMAT_COMPRESSED_DXT1_RGB
| PIXELFORMAT_COMPRESSED_DXT1_RGBA
| PIXELFORMAT_COMPRESSED_DXT3_RGBA
| PIXELFORMAT_COMPRESSED_DXT5_RGBA
| PIXELFORMAT_COMPRESSED_ETC1_RGB
| PIXELFORMAT_COMPRESSED_ETC2_RGB
| PIXELFORMAT_COMPRESSED_ETC2_EAC_RGBA
| PIXELFORMAT_COMPRESSED_PVRT_RGB
| PIXELFORMAT_COMPRESSED_PVRT_RGBA
| PIXELFORMAT_COMPRESSED_ASTC_4x4_RGBA
| PIXELFORMAT_COMPRESSED_ASTC_8x8_RGBA => return None,
})
}
/// Validate that `len` bytes is enough for one pixel in `format` and
/// that `format` is uncompressed. Returns the required byte count on
/// success.
fn validate_slice(len: usize, format: PixelFormat) -> Result<usize, PixelColorError> {
match bytes_per_pixel(format) {
None => Err(PixelColorError::CompressedFormat(format)),
Some(expected) if len < expected => Err(PixelColorError::InsufficientBytes {
format,
expected,
actual: len,
}),
Some(expected) => Ok(expected),
}
}
/// Read a single pixel from `bytes` interpreted as `format`.
///
/// `bytes` must contain at least `bytes_per_pixel(format)?` bytes; any
/// trailing bytes are ignored. Returns
/// [`PixelColorError::CompressedFormat`] for block-compressed formats,
/// or [`PixelColorError::InsufficientBytes`] when the slice is too
/// short.
pub fn get_pixel_color(
bytes: &[u8],
format: PixelFormat,
) -> Result<Color, PixelColorError> {
validate_slice(bytes.len(), format)?;
// SAFETY: validate_slice ensured `bytes.len() >= bytes_per_pixel(format)`,
// so raylib reads only within the slice. GetPixelColor's signature is
// `void *` even though the C body only reads from srcPtr (verified in
// raylib-sys/raylib/src/rtextures.c around line 5174), so casting
// `*const u8` to `*mut u8` is sound.
Ok(unsafe { ffi::GetPixelColor(bytes.as_ptr() as *mut _, format as i32) })
}
/// Write `color` into `bytes` encoded as `format`.
///
/// Same length / format-validity rules as [`get_pixel_color`].
/// Trailing bytes beyond `bytes_per_pixel(format)?` are not touched.
pub fn set_pixel_color(
bytes: &mut [u8],
color: Color,
format: PixelFormat,
) -> Result<(), PixelColorError> {
validate_slice(bytes.len(), format)?;
// SAFETY: validate_slice ensured `bytes.len() >= bytes_per_pixel(format)`,
// so raylib writes only within the slice. SetPixelColor writes exactly
// `bytes_per_pixel(format)` bytes starting at dstPtr.
unsafe {
ffi::SetPixelColor(bytes.as_mut_ptr() as *mut _, color, format as i32);
}
Ok(())
}
raylib/src/core/mod.rsFind the existing pub mod block (per Step 1) and add a line pub mod pixel; in alphabetical order. Then add a corresponding pub use pixel::*; line (or follow whatever re-export convention you observed — some core/ modules are pub use crate::core::texture::*; etc.).
In raylib/src/prelude.rs, find the cluster of pub use crate::core::* lines and add:
pub use crate::core::pixel::{
PixelColorError, bytes_per_pixel, get_pixel_color, set_pixel_color,
};
If the prelude pattern is "glob-only" (i.e., it does pub use crate::core::texture::*; without naming each item), use pub use crate::core::pixel::*; to match the existing convention instead.
Run: cargo build -p raylib --features full 2>&1 | tail -10
Expected: Finished line, no error lines. Compilation warnings about unused validate_slice are acceptable (it's used by the public fns).
Run: cargo clippy -p raylib --features full -- -D warnings 2>&1 | tail -10
Expected: no errors. If clippy flags the cast bytes.as_ptr() as *mut _, add #[allow(clippy::cast_ref_to_mut)] on the get_pixel_color function with a comment explaining the FFI signature mismatch — but try without the allow first; clippy may not complain about this specific pattern.
Run via the Bash tool (heredoc syntax for the multi-line message):
git add raylib/src/core/pixel.rs raylib/src/core/mod.rs raylib/src/prelude.rs
git commit -m "$(cat <<'EOF'
feat(pixel): scaffold pixel-pointers module with stubs + error type
New raylib/src/core/pixel.rs surfaces:
- PixelColorError (thiserror with InsufficientBytes + CompressedFormat)
- bytes_per_pixel(PixelFormat) -> Option<usize> [exhaustive match]
- get_pixel_color(&[u8], PixelFormat) -> Result<Color, _>
- set_pixel_color(&mut [u8], Color, PixelFormat) -> Result<(), _>
- private validate_slice helper
Wired into raylib::core via `pub mod pixel;` + re-export, and into
raylib::prelude so `use raylib::prelude::*;` brings the four public
items into scope.
Implementation is complete (FFI calls + validation); tests follow in
the next tasks. Module-level rustdoc explains when to reach for these
vs Image::get_color, and documents the BGRA-not-supported limitation
that the test_harness BGRA quirk lives outside raylib's PixelFormat
model and cannot be wrapped through these fns.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files:
raylib/src/core/pixel.rsThe shared UNCOMPRESSED_FORMATS table is referenced by Tasks 3, 4, 5, and 6. Adding it first means subsequent tasks have one fewer "set up boilerplate" step.
#[cfg(test)] mod tests block to raylib/src/core/pixel.rsAfter the closing } of set_pixel_color, append:
#[cfg(test)]
mod tests {
use super::*;
use crate::consts::PixelFormat;
/// Every uncompressed PixelFormat paired with its bytes-per-pixel
/// count. Used by the round-trip tests, the bytes_per_pixel
/// cross-check, and the trailing-bytes test.
const UNCOMPRESSED_FORMATS: &[(PixelFormat, usize)] = &[
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_GRAYSCALE, 1),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA, 2),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R5G6B5, 2),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R5G5B5A1, 2),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R4G4B4A4, 2),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8, 3),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, 4),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R32, 4),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R32G32B32, 12),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R32G32B32A32, 16),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R16, 2),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R16G16B16, 6),
(PixelFormat::PIXELFORMAT_UNCOMPRESSED_R16G16B16A16, 8),
];
}
Run: cargo build -p raylib --tests --features full 2>&1 | tail -5
Expected: Finished with no errors.
git add raylib/src/core/pixel.rs
git commit -m "$(cat <<'EOF'
test(pixel): add shared UNCOMPRESSED_FORMATS table for the test module
The const table pairs each of the 13 uncompressed PixelFormat variants
with its bytes-per-pixel count. Used by upcoming round-trip,
bytes_per_pixel cross-check, and trailing-bytes tests so each test is
data-driven and a future enum addition only needs the table updated.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
bytes_per_pixel — cross-check raylib + None for compressedFiles:
raylib/src/core/pixel.rs (the mod tests block)These tests verify the existing implementation; they should pass immediately. The cross-check is the most important — if raylib ever changes a format's byte count, this fires.
mod testsAfter the UNCOMPRESSED_FORMATS const, add:
#[test]
fn bytes_per_pixel_agrees_with_raylib() {
for &(format, expected) in UNCOMPRESSED_FORMATS {
let bpp = bytes_per_pixel(format)
.unwrap_or_else(|| panic!("{format:?} should be uncompressed"));
assert_eq!(bpp, expected, "{format:?}: table says {expected}, fn says {bpp}");
// raylib computes the same byte count via GetPixelDataSize(1, 1, ...).
let raylib_bpp = unsafe {
crate::ffi::GetPixelDataSize(1, 1, format as i32)
} as usize;
assert_eq!(
bpp, raylib_bpp,
"{format:?}: rust says {bpp}, raylib's GetPixelDataSize(1,1,...) says {raylib_bpp}"
);
}
}
Append (still inside mod tests):
#[test]
fn bytes_per_pixel_none_for_every_compressed_variant() {
// Listed exhaustively (no for-loop over a slice) so an enum addition
// surfaces as a missing arm here, matching the exhaustive-match
// pattern in bytes_per_pixel itself.
use PixelFormat::*;
for format in [
PIXELFORMAT_COMPRESSED_DXT1_RGB,
PIXELFORMAT_COMPRESSED_DXT1_RGBA,
PIXELFORMAT_COMPRESSED_DXT3_RGBA,
PIXELFORMAT_COMPRESSED_DXT5_RGBA,
PIXELFORMAT_COMPRESSED_ETC1_RGB,
PIXELFORMAT_COMPRESSED_ETC2_RGB,
PIXELFORMAT_COMPRESSED_ETC2_EAC_RGBA,
PIXELFORMAT_COMPRESSED_PVRT_RGB,
PIXELFORMAT_COMPRESSED_PVRT_RGBA,
PIXELFORMAT_COMPRESSED_ASTC_4x4_RGBA,
PIXELFORMAT_COMPRESSED_ASTC_8x8_RGBA,
] {
assert_eq!(
bytes_per_pixel(format),
None,
"{format:?} should return None"
);
}
}
Run: cargo test -p raylib --lib --features full bytes_per_pixel 2>&1 | tail -15
Expected: test result: ok. 2 passed; 0 failed. If bytes_per_pixel_agrees_with_raylib fails, the diagnostic message names the offending format — fix the byte count in the match arm in the main bytes_per_pixel function.
git add raylib/src/core/pixel.rs
git commit -m "$(cat <<'EOF'
test(pixel): bytes_per_pixel cross-check vs raylib + None-for-compressed
Two tests:
- bytes_per_pixel_agrees_with_raylib iterates UNCOMPRESSED_FORMATS and
checks each entry against raylib's own GetPixelDataSize(1, 1, F).
A future raylib format-layout change trips here immediately.
- bytes_per_pixel_none_for_every_compressed_variant lists the 11
compressed variants explicitly (no wildcard) so an enum addition
surfaces as a missing entry in the test.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files:
raylib/src/core/pixel.rs (the mod tests block)Six assertions covering the two error variants from both get_pixel_color and set_pixel_color. Tests pass against the existing implementation.
Append to mod tests:
#[test]
fn get_returns_insufficient_bytes_on_short_slice() {
// R8G8B8A8 needs 4 bytes.
let empty = &[] as &[u8];
assert_eq!(
get_pixel_color(empty, PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8),
Err(PixelColorError::InsufficientBytes {
format: PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
expected: 4,
actual: 0,
}),
);
let two_bytes = [0u8; 2];
assert_eq!(
get_pixel_color(&two_bytes, PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8),
Err(PixelColorError::InsufficientBytes {
format: PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
expected: 4,
actual: 2,
}),
);
}
#[test]
fn set_returns_insufficient_bytes_on_short_slice() {
let mut empty: Vec<u8> = vec![];
assert_eq!(
set_pixel_color(
&mut empty,
Color::new(255, 0, 0, 255),
PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
),
Err(PixelColorError::InsufficientBytes {
format: PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
expected: 4,
actual: 0,
}),
);
let mut two_bytes = [0u8; 2];
assert_eq!(
set_pixel_color(
&mut two_bytes,
Color::new(255, 0, 0, 255),
PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
),
Err(PixelColorError::InsufficientBytes {
format: PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
expected: 4,
actual: 2,
}),
);
}
Append:
#[test]
fn get_returns_compressed_format_for_compressed_input() {
let bytes = [0u8; 8];
assert_eq!(
get_pixel_color(&bytes, PixelFormat::PIXELFORMAT_COMPRESSED_DXT1_RGB),
Err(PixelColorError::CompressedFormat(
PixelFormat::PIXELFORMAT_COMPRESSED_DXT1_RGB
)),
);
}
#[test]
fn set_returns_compressed_format_for_compressed_input() {
let mut bytes = [0u8; 8];
assert_eq!(
set_pixel_color(
&mut bytes,
Color::new(0, 0, 0, 0),
PixelFormat::PIXELFORMAT_COMPRESSED_DXT1_RGB,
),
Err(PixelColorError::CompressedFormat(
PixelFormat::PIXELFORMAT_COMPRESSED_DXT1_RGB
)),
);
}
Run: cargo test -p raylib --lib --features full pixel:: 2>&1 | tail -15
Expected: 6 tests pass (the 2 from Task 3 + the 4 here).
git add raylib/src/core/pixel.rs
git commit -m "$(cat <<'EOF'
test(pixel): error-variant coverage for get/set_pixel_color
Four #[test] functions covering the two error variants:
- get/set_returns_insufficient_bytes_on_short_slice: empty + 2-byte
slice against R8G8B8A8 (which needs 4 bytes).
- get/set_returns_compressed_format_for_compressed_input: 8-byte slice
fed to DXT1_RGB rejects with CompressedFormat regardless of slice
length — the format validation runs first.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files:
raylib/src/core/pixel.rs (the mod tests block)The "all channels at max" pixel is the easiest round-trip — every uncompressed format quantizes 0xFF cleanly to its max representable value and back.
Append to mod tests:
#[test]
fn round_trip_all_ff_for_every_uncompressed_format() {
let input = Color::new(0xFF, 0xFF, 0xFF, 0xFF);
for &(format, bpp) in UNCOMPRESSED_FORMATS {
let mut bytes = vec![0u8; bpp];
set_pixel_color(&mut bytes, input, format).unwrap_or_else(|e| {
panic!("set_pixel_color({format:?}) errored: {e}")
});
let got = get_pixel_color(&bytes, format).unwrap_or_else(|e| {
panic!("get_pixel_color({format:?}) errored: {e}")
});
assert_eq!(
got, input,
"{format:?}: all-FF round-trip diverged (got {got:?}, expected {input:?})"
);
}
}
Run: cargo test -p raylib --lib --features full round_trip_all_ff 2>&1 | tail -10
Expected: test result: ok. 1 passed.
If any format fails, the diagnostic names it. The most likely culprit is a misread of the C encoding: open raylib-sys/raylib/src/rtextures.c and search for SetPixelColor to verify what that variant does with Color::new(0xFF, 0xFF, 0xFF, 0xFF). The all-FF case is structured to round-trip exactly for every variant; a failure indicates either a real raylib bug or a misunderstanding of which channels participate (e.g. GRAYSCALE only stores r — but r=0xFF decodes back as (0xFF, 0xFF, 0xFF, 0xFF), so it's still all-FF).
git add raylib/src/core/pixel.rs
git commit -m "$(cat <<'EOF'
test(pixel): all-FF round-trip for every uncompressed format
For each of the 13 uncompressed PixelFormat variants, encode
Color::new(0xFF, 0xFF, 0xFF, 0xFF) via set_pixel_color and decode via
get_pixel_color; assert exact equality. Max values quantize cleanly
through every variant including the lossy 5/6/4-bit channels (31 → 255
for 5-bit, 63 → 255 for 6-bit, 15 → 255 for 4-bit).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files:
raylib/src/core/pixel.rs (the mod tests block)The channel-distinct pixel Color::new(0xC0, 0x80, 0x40, 0xFF) exercises encoding precision. Tolerance varies per format-family. The plan locks specific tolerance values; the implementer should run the test once and tighten any tolerance that's loose.
Append to mod tests:
/// Per-channel tolerance for a format's lossy quantization.
/// Returns (rgb_tol, alpha_tol) where each is the maximum allowed
/// `|got - expected|` for the given channel.
fn tolerance(format: PixelFormat) -> (u8, u8) {
use PixelFormat::*;
match format {
// 8-bit channels: exact except for the lossy GRAYSCALE collapse.
PIXELFORMAT_UNCOMPRESSED_R8G8B8 => (0, 0),
PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 => (0, 0),
// 32-bit float channels: ±1 LSB from the `u8 → f32 / 255 → * 255 → u8`
// truncation (e.g. 192 → 0.7529... → 191.999... → 191).
PIXELFORMAT_UNCOMPRESSED_R32 => (1, 0),
PIXELFORMAT_UNCOMPRESSED_R32G32B32 => (1, 0),
PIXELFORMAT_UNCOMPRESSED_R32G32B32A32 => (1, 1),
// 16-bit half-float: ±1 LSB from half-float precision.
PIXELFORMAT_UNCOMPRESSED_R16 => (1, 0),
PIXELFORMAT_UNCOMPRESSED_R16G16B16 => (1, 0),
PIXELFORMAT_UNCOMPRESSED_R16G16B16A16 => (1, 1),
// 5/6/4-bit channels: quantization step roughly 255/(2^k - 1):
// 5-bit -> step 8 -> tolerance 4 (half-step). Allow 8 to absorb
// any rounding choice the C code makes.
PIXELFORMAT_UNCOMPRESSED_R5G6B5 => (8, 0),
PIXELFORMAT_UNCOMPRESSED_R5G5B5A1 => (8, 0), // alpha is 0 or 255 only
PIXELFORMAT_UNCOMPRESSED_R4G4B4A4 => (16, 16), // 4-bit step ~17.
// Grayscale: collapses R/G/B to a single channel; the round-trip
// produces three identical channels. We don't compare per-channel
// tolerance for these — they get a separate assertion path.
PIXELFORMAT_UNCOMPRESSED_GRAYSCALE => (0, 0),
PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA => (0, 0),
// Compressed formats can't reach this code path.
f => unreachable!("tolerance() called with non-uncompressed format {f:?}"),
}
}
fn within_tolerance(got: u8, expected: u8, tol: u8) -> bool {
got.abs_diff(expected) <= tol
}
#[test]
fn round_trip_channel_distinct_with_tolerance() {
let input = Color::new(0xC0, 0x80, 0x40, 0xFF);
for &(format, bpp) in UNCOMPRESSED_FORMATS {
let mut bytes = vec![0u8; bpp];
set_pixel_color(&mut bytes, input, format).unwrap();
let got = get_pixel_color(&bytes, format).unwrap();
use PixelFormat::*;
match format {
// Grayscale collapses R/G/B; the round-trip's three RGB
// channels are identical to each other, and alpha is fixed
// to 255 (GRAYSCALE) or input.a (GRAY_ALPHA).
PIXELFORMAT_UNCOMPRESSED_GRAYSCALE => {
assert_eq!(got.r, got.g, "{format:?}: R==G after collapse");
assert_eq!(got.g, got.b, "{format:?}: G==B after collapse");
assert_eq!(got.a, 255, "{format:?}: alpha forced to 255");
}
PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA => {
assert_eq!(got.r, got.g, "{format:?}: R==G after collapse");
assert_eq!(got.g, got.b, "{format:?}: G==B after collapse");
assert_eq!(got.a, input.a, "{format:?}: alpha exact");
}
_ => {
let (rgb_tol, a_tol) = tolerance(format);
assert!(
within_tolerance(got.r, input.r, rgb_tol),
"{format:?}: R got {} expected {} (tol {rgb_tol})",
got.r, input.r
);
assert!(
within_tolerance(got.g, input.g, rgb_tol),
"{format:?}: G got {} expected {} (tol {rgb_tol})",
got.g, input.g
);
assert!(
within_tolerance(got.b, input.b, rgb_tol),
"{format:?}: B got {} expected {} (tol {rgb_tol})",
got.b, input.b
);
assert!(
within_tolerance(got.a, input.a, a_tol),
"{format:?}: A got {} expected {} (tol {a_tol})",
got.a, input.a
);
}
}
}
}
Run: cargo test -p raylib --lib --features full round_trip_channel_distinct 2>&1 | tail -15
Expected: test result: ok. 1 passed.
If a tolerance is too tight (e.g. some 5-bit format diverges by 9 with the listed tolerance of 8), the diagnostic shows the exact channel + got/expected values. Investigate by reading the corresponding C branch in raylib-sys/raylib/src/rtextures.c (search for the variant name; SetPixelColor cases are below the GetPixelColor cases starting around line 5350). Adjust the tolerance to match the actual quantization step. Don't loosen tolerances generously — pin them to the minimum value that passes, with a comment if the value is surprising.
If a 16-bit half-float branch is missing in raylib (the half-precision encoding may not have an SetPixelColor arm in older raylib versions), the test will fail with "no change" — get_pixel_color returns black or zero. In that case, mark the half-float formats as skipped with a // TODO(raylib upstream): half-float SetPixelColor not implemented and move on; the all-FF test (Task 5) is still valid because raylib defaults to no-op on missing arms.
git add raylib/src/core/pixel.rs
git commit -m "$(cat <<'EOF'
test(pixel): channel-distinct round-trip with per-format tolerance
Encode Color::new(0xC0, 0x80, 0x40, 0xFF) into every uncompressed
PixelFormat and verify the decoded color matches within a
quantization-step-aware tolerance:
- 8-bit RGB(A) formats: exact (tol 0).
- 32-bit / 16-bit float formats: ±1 LSB (float rounding).
- 5/6-bit channels: ±8 LSBs (5-bit step ~8.2).
- 4-bit channels: ±16 LSBs (4-bit step ~17).
- GRAYSCALE / GRAY_ALPHA: assert the channel-collapse invariant
(R==G==B post-round-trip) and alpha handling, not specific values.
Tolerances are pinned to the minimum that passes; if any future raylib
quantization change tightens or relaxes them, update the table.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files:
raylib/src/core/pixel.rs (the mod tests block)Quick test to document the "trailing bytes ignored" contract.
Append to mod tests:
#[test]
fn trailing_bytes_are_ignored() {
// 64-byte buffer fed to a 4-byte format must produce the same result
// as exactly 4 bytes.
let exact = [0x11, 0x22, 0x33, 0x44];
let long = [
0x11, 0x22, 0x33, 0x44, // first pixel
0xAA, 0xBB, 0xCC, 0xDD, // these bytes must be ignored
0xEE, 0xFF, 0x00, 0x11, //
0x22, 0x33, 0x44, 0x55, //
];
let got_exact = get_pixel_color(
&exact,
PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
)
.unwrap();
let got_long = get_pixel_color(
&long,
PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
)
.unwrap();
assert_eq!(got_exact, got_long, "trailing bytes must not affect read");
// Symmetric check for set: writing to a too-long buffer touches
// only the first N bytes.
let mut exact_out = [0u8; 4];
let mut long_out = [0u8; 16];
let sentinel = 0xA5;
long_out[4..].fill(sentinel);
let color = Color::new(0x11, 0x22, 0x33, 0x44);
set_pixel_color(
&mut exact_out,
color,
PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
)
.unwrap();
set_pixel_color(
&mut long_out,
color,
PixelFormat::PIXELFORMAT_UNCOMPRESSED_R8G8B8A8,
)
.unwrap();
assert_eq!(&long_out[..4], &exact_out, "first 4 bytes must match");
for (i, &b) in long_out[4..].iter().enumerate() {
assert_eq!(b, sentinel, "byte {} past the pixel must be untouched", i + 4);
}
}
Run: cargo test -p raylib --lib --features full trailing_bytes 2>&1 | tail -10
Expected: test result: ok. 1 passed.
git add raylib/src/core/pixel.rs
git commit -m "$(cat <<'EOF'
test(pixel): trailing-bytes-ignored contract
set/get_pixel_color must touch only the first bytes_per_pixel(format)
bytes of the slice. The test:
- Confirms get_pixel_color on a 16-byte buffer matches the result on a
4-byte buffer with the same prefix.
- Confirms set_pixel_color leaves bytes past the pixel untouched
(verified via a sentinel pattern).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files: none (verification only)
Run: cargo test -p raylib --lib --features full 2>&1 | tail -10
Expected: every test passes. The pixel module contributes 7 new tests; existing tests should be unaffected. The full count should be 58 (existing pre-pixel) + 7 = 65 or thereabouts. If the previous count was different, just confirm failed; 0.
Run: cargo clippy -p raylib --features full -- -D warnings 2>&1 | tail -10
Expected: no warnings. Lints to watch for:
clippy::cast_ref_to_mut on the as *mut _ casts — if it fires, add #[allow(clippy::cast_ref_to_mut)] on the offending function with a 1-line comment pointing at the SAFETY note.
clippy::missing_panics_doc on unwraps in test code — tests are exempt by configuration; ignore unless it fires.
Step 3: Rustdoc clean
Run: RUSTDOCFLAGS="-Dwarnings" cargo doc -p raylib --features full --no-deps 2>&1 | tail -10
Expected: clean build. Broken intradoc-links to Image, Image::get_color, etc. will fail here. If they do, double-check the import paths in the module-level doc.
target/debug/deps)Run: mdbook build book 2>&1 | tail -5
Expected: clean (no errors). No book-chapter rewrites needed; pixel-pointers is an internal module, not a user-facing core concept.
Files:
docs/superpowers/notes/cheatsheet-parity-audit.mdCHANGELOG.mdNow that the gap is filled, move it from §3 (small-gaps table) to §1 (covered list) in the audit, and add CHANGELOG entries.
GetPixelColor / SetPixelColor in the auditRun: grep -n 'GetPixelColor\|SetPixelColor' docs/superpowers/notes/cheatsheet-parity-audit.md | head -10
Expected: matches in §1's "Image manipulation" or "Color/pixel related" subsection (status 🟥 GAP), in §3's small-fixes table, and in §4's future-workstream list. Each needs a touch.
For each GetPixelColor / SetPixelColor line in §1, change the marker from 🟥 to ✅ and the action from "GAP" to a path reference. The pattern matches the rest of §1's ✅ rows:
Example transformation (the exact line will differ slightly):
- 🟥 `GetPixelColor` — GAP. Action: small fix-now (cheatsheet audit §3).
→
- ✅ `GetPixelColor` — wrapped at `raylib/src/core/pixel.rs::get_pixel_color`.
Same pattern for SetPixelColor.
The §0 summary opens with totals like "Covered: 486" / "Gaps: 13" / "Small fixes: 5". Decrement the gap counts by 2 and bump the covered count by 2:
Also adjust the headline "13, not 49" if the total in-scope gap count changes — re-derive: 13 - 2 = 11.
Remove the GetPixelColor / SetPixelColor rows from the small-fixes table at §3. Update the counter at the bottom (was "Five small gaps, ~27 lines total" — those two were ~10 lines; new line: "Three small gaps, ~17 lines total" or similar).
Wait — these were in §4 (future workstream), NOT §3. Re-check by grep. If they were in §4, remove the pixel-pointers workstream from §4 entirely and note in §5 reconciliation that this workstream is now ✅ done.
The §4 entry for pixel-pointers (containing GetPixelColor / SetPixelColor) is now done. Either:
hashes to #1 and mixed-audio to #2, OR**DONE (commit <SHA>):** to mark it complete.Pick whichever is more consistent with the rest of the doc's style. Default to "remove + renumber" if there's no clear precedent.
In CHANGELOG.md, under ## 6.0.0-rc.1 (unreleased) → ### Added, append:
- New module `raylib::core::pixel` with safe wrappers over raylib's
pixel-pointer C functions:
- `get_pixel_color(bytes: &[u8], format: PixelFormat) -> Result<Color, PixelColorError>`
- `set_pixel_color(bytes: &mut [u8], color: Color, format: PixelFormat) -> Result<(), PixelColorError>`
- `bytes_per_pixel(format: PixelFormat) -> Option<usize>` helper
- `PixelColorError` (thiserror) with `InsufficientBytes` and `CompressedFormat` variants
All four are re-exported through `raylib::prelude`.
Run: grep -n 'GetPixelColor\|SetPixelColor' docs/superpowers/notes/cheatsheet-parity-audit.md
Expected: every remaining match is in the ✅ list (§1) or §5 reconciliation notes; no 🟥 rows left.
Run: grep -n 'pixel::' CHANGELOG.md | head -3
Expected: the new bullet shows in the 6.0.0-rc.1 block.
git add docs/superpowers/notes/cheatsheet-parity-audit.md CHANGELOG.md
git commit -m "$(cat <<'EOF'
docs(pixel-pointers): mark GetPixelColor / SetPixelColor as covered
The pixel-pointers workstream from cheatsheet-parity-audit.md §4 #1 is
done; reconcile the audit + CHANGELOG accordingly.
- cheatsheet-parity-audit.md:
- §1 entries for GetPixelColor + SetPixelColor flip 🟥 -> ✅ pointing
at raylib/src/core/pixel.rs.
- §0 totals: Covered 486 -> 488, gaps 13 -> 11, small fixes 5 -> 3.
- §4 workstream #1 (pixel-pointers) removed; remaining items
renumbered (hashes -> #1, mixed-audio -> #2).
- CHANGELOG.md ## 6.0.0-rc.1 (unreleased) ### Added: lists the new
raylib::core::pixel module with the four public items.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
Files: none (push only)
Run: git status --short
Expected: only the three permitted untracked files (TODO.md, prompt.md, next-session-prompt.md); no M lines.
fork/6.0-rcRun: git push fork 6.0-rc 2>&1 | tail -3
Expected: a successful update like <old-SHA>..<new-SHA> 6.0-rc -> 6.0-rc.
fork/unstableRun: git push fork 6.0-rc:unstable 2>&1 | tail -3
Expected: same SHA range pushed to unstable.
Find the status line (grep -n 'pixel-pointers' CLAUDE.md — should match the Pre-WS9 queue ← NEXT block). Replace the existing pixel-pointers → hashes → mixed-audio prefix with pixel-pointers ✅ → hashes ← NEXT → mixed-audio → ....
Commit + push:
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(roadmap): pixel-pointers complete; hashes is next
CLAUDE.md status line: pre-WS9 queue head flips from pixel-pointers to
hashes. See docs/superpowers/notes/cheatsheet-parity-audit.md for the
updated cheatsheet coverage (488 / 500 in-scope).
Co-Authored-By: Claude Opus 4.7 <[email protected]>
EOF
)"
git push fork 6.0-rc
git push fork 6.0-rc:unstable
raylib/src/core/pixel.rs exists with the four public items + private validate_slice + module-level rustdoc explaining the BGRA limitation.raylib/src/core/mod.rs declares pub mod pixel; and re-exports.raylib/src/prelude.rs includes the four public items.bytes_per_pixel uses an exhaustive match (no wildcard).cargo test -p raylib --lib --features full.cargo build --workspace --features full clean.cargo clippy -p raylib --features full -- -D warnings clean.RUSTDOCFLAGS="-Dwarnings" cargo doc -p raylib --features full --no-deps clean.mdbook build book clean.cheatsheet-parity-audit.md reconciled: §0 totals, §1 ✅ rows, §4 workstream removed.CHANGELOG.md ## 6.0.0-rc.1 (unreleased) ### Added lists the new module + its four public items.CLAUDE.md status line: pre-WS9 queue head flips pixel-pointers ✅ → hashes ← NEXT.Co-Authored-By: Claude Opus 4.7 <[email protected]> trailer.fork/6.0-rc and fork/unstable.Next workstream: hashes (cheatsheet audit §4 #1 after renumbering — Compute{CRC32,MD5,SHA1,SHA256}). Brainstorm starts when the owner is ready.