Compare commits

...
Sign in to create a new pull request.

1 commit

14 changed files with 994 additions and 24 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
target/
JAPS2ME/
memcardrex/
save-icons/
*-workspace/
.japs1mesavedir

View file

@ -11,12 +11,13 @@ Current CLI commands:
- `convert <INPUT.gme> <OUTPUT.srm|OUTPUT.mcd>` — validate a DexDrive `.gme` file and write its raw payload.
- `import <CARD.srm|CARD.mcd> <SOURCE.gme> <SAVE-NAME>` — copy a named save from a `.gme` file into a raw card.
- `delete <CARD.srm|CARD.mcd> <SLOT-OR-SAVE>` — delete a save from a raw card by slot number or save name.
- `export-icon <CARD.gme|CARD.srm|CARD.mcd> <SLOT-OR-SAVE> <OUTPUT.png>` — export a save's 16x16 icon as a PNG image; `--scale N` (default 1) applies integer nearest-neighbor upscaling.
- `copy <SOURCE.srm|SOURCE.mcd> <DEST|DEST-DIR>` — copy a card from the configured saves directory into the workspace; directory destinations use the source filename.
- `setup` — configure the RetroArch or DuckStation memory-card directory used by `copy` and `install`.
- `install [--new|-n] <SOURCE.srm|SOURCE.mcd>` — overwrite a selected/matching raw card or create a convention-named new card in the configured emulator directory, inferring `.srm`/`.mcd` from its path.
- `--version` / `-V` — print the package version and exit.
The current package version is `0.2.1`; keep the root package versions in `Cargo.toml` and `Cargo.lock` in sync.
The current package version is `0.2.2`; keep the root package versions in `Cargo.toml` and `Cargo.lock` in sync.
The project is licensed under GPL-3.0-or-later. `memcardrex` is a technical
reference for PS1 memory-card and DexDrive behavior; see its attribution in
@ -27,10 +28,12 @@ material, not part of the build.
- `src/main.rs` — Clap CLI, command dispatch, `.gme`/raw input detection, interactive prompts, and atomic output writes.
- `src/commands/list.rs` — Unicode table rendering and byte-size formatting for `list`.
- `src/commands/icon.rs` — PNG encoding and integer nearest-neighbor scaling of decoded icons for `export-icon`.
- `src/gme.rs` — DexDrive `.gme` header parsing, headerless fallback, and comment decoding.
- `src/install.rs` — emulator saves-directory config, path inference, and `.srm`/`.mcd` discovery for `copy` and `install`.
- `src/ps1/card.rs` — raw card parsing, slot/link chains, region names, XOR warnings, save summaries, and slot formatting shared by delete and overwrite imports.
- `src/ps1/card.rs` — raw card parsing, slot/link chains, region names, XOR warnings, save summaries, and shared save-selection helpers.
- `src/ps1/delete.rs` — save deletion by slot number or save name from a target raw image.
- `src/ps1/icon.rs` — 16x16 save-icon palette/bitmap decoding into RGBA pixels for `export-icon`.
- `src/ps1/import.rs` — save imports from a source card into a target raw image.
- `src/ps1/title.rs` — Shift-JIS save-title decoding.
- `src/lib.rs` — crate modules and public exports.

83
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "1.0.0"
@ -122,6 +128,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
@ -152,6 +167,26 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@ -177,11 +212,12 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "japs1me"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"clap",
"encoding_rs",
"png",
"tempfile",
"unicode-width",
]
@ -198,6 +234,26 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@ -216,6 +272,19 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@ -265,6 +334,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simdutf8"
version = "0.1.5"
@ -333,3 +408,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"

View file

@ -1,6 +1,6 @@
[package]
name = "japs1me"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
description = "JAPS1ME - Just A PS1 Memory Card Editor (DexDrive .gme to RetroArch .srm)"
license = "GPL-3.0-or-later"
@ -9,6 +9,7 @@ license = "GPL-3.0-or-later"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
encoding_rs = "0.8"
png = "0.18"
unicode-width = "0.2"
tempfile = "3"

View file

@ -3,8 +3,8 @@
**JAPS1ME - Just A PS1 Memory Card Editor** is a command-line tool for managing
PlayStation 1 memory-card saves for RetroArch and DuckStation. It converts
DexDrive (`.gme`) card images to raw card images, lists saves on `.gme` and raw
(`.srm`/`.mcd`/`.mcr`) cards, and imports, deletes, copies, and installs saves
and cards.
(`.srm`/`.mcd`/`.mcr`) cards, and imports, deletes, exports save icons, copies,
and installs saves and cards.
| Made for | Developed on |
| --- | --- |
@ -15,6 +15,7 @@ and cards.
- Convert DexDrive `.gme` files (including headerless/zeroed-header variants) to raw card images with validated stripping.
- Import a named save from a `.gme` file (or a raw card misnamed `.gme`) into a raw card, with duplicate and free-space checks.
- Delete saves from raw cards by slot number or save name.
- Export a save's 16x16 icon as a PNG image, optionally upscaled with an integer nearest-neighbor factor.
- Copy an emulator card from the RetroArch or DuckStation saves directory into this workspace for JAPS1ME work (bare file name or prefix; asks before overwriting).
- Infer RetroArch `.srm` versus DuckStation `.mcd` from conventional directory names, without an emulator prompt.
- Configure the emulator memory-card directory ahead of time with `setup`.
@ -59,6 +60,7 @@ The Windows executable is `target/x86_64-pc-windows-gnu/release/japs1me.exe`.
| `japs1me convert <INPUT.gme> <OUTPUT.srm\|OUTPUT.mcd>` | Convert a DexDrive `.gme` file to a raw card image (use `.mcd` when targeting DuckStation). |
| `japs1me import <CARD.srm\|CARD.mcd> <SOURCE.gme> <SAVE-NAME>` | Import a save from a `.gme` (or raw misnamed `.gme`) file into a raw card. |
| `japs1me delete <CARD.srm\|CARD.mcd> <SLOT-OR-SAVE>` | Delete a save from a raw card by 1-based slot number or save name. |
| `japs1me export-icon <CARD.gme\|CARD.srm\|CARD.mcd> <SLOT-OR-SAVE> <OUTPUT.png>` | Export a save's 16x16 icon as a PNG image (`--scale N` upscales; `--frame circle` and `--palette NAME\|#RRGGBB` restyle it). |
| `japs1me copy <SOURCE.srm\|SOURCE.mcd> <DEST\|DEST-DIR>` | Copy an emulator card into the workspace; a directory destination keeps the source filename. |
| `japs1me setup` | Configure the RetroArch or DuckStation memory-card directory used by `copy` and `install`. |
| `japs1me install [--new\|-n] <SOURCE.srm\|SOURCE.mcd>` | Install into the configured emulator directory; `--new` creates a convention-named card and converts its extension. |
@ -71,6 +73,10 @@ target/release/japs1me list save-games/*.gme
target/release/japs1me convert save-games/pro-evolution-soccer-2.22507.gme pes2.srm
target/release/japs1me import mysaves.srm save-games/pro-evolution-soccer-2.22507.gme BESLES-03946PES-OPT
target/release/japs1me delete mysaves.srm BESLES-03946PES-OPT
target/release/japs1me export-icon save-games/pro-evolution-soccer-2.22507.gme BESLES-03946PES-OPT pes2-icon.png
target/release/japs1me export-icon mysaves.srm 1 icon-8x.png --scale 8
target/release/japs1me export-icon mysaves.srm 1 avatar.png --frame circle --palette BLUE --scale 8
target/release/japs1me export-icon mysaves.srm 1 avatar.png --frame circle --palette "#33AAFF" --scale 8
target/release/japs1me copy "ISS Pro Evolution (USA).srm" /tmp/work.srm
target/release/japs1me copy "ISS Pro Evolution (USA).srm" .
target/release/japs1me setup
@ -112,6 +118,15 @@ paths are treated as RetroArch and use `.srm` (including paths such as
- The save's slots are reset to formatted with fresh checksums and zeroed data, freeing them for future imports.
- `.srm` targets only; the updated card is staged, re-validated, and only then replaced.
### `export-icon`
- Exports one save's 16x16 game icon as a PNG. Accepts `.gme` files and raw `.srm`/`.mcd`/`.mcr` images; the card is read-only and never modified.
- The save is selected by 1-based slot number (the initial slot shown in `list`) or exact save name, the same way `delete` selects one.
- `--scale N` (integer, default `1`) upscales the icon with nearest-neighbor sampling, so `--scale 8` produces a 128x128 image.
- `--frame circle` scales the icon down to fit inside the canvas's inscribed circle (the whole square stays visible, nothing is cropped), centers it on transparent padding, then applies the circular alpha mask. The output stays square; `--scale` sets that square canvas size. Whole-number zooms are preferred so the pixel art stays crisp.
- `--palette NAME` or `--palette #RRGGBB` changes palette hues in HSL while preserving saturation and lightness. `NAME` is `RED`, `GREEN`, `BLUE`, `YELLOW`, `PURPLE`, or `ORANGE` (case-insensitive); hex colors may be written with or without `#` and must be saturated (grayscale is rejected because it has no hue). Transparent colors, exact black/white, and gray shades remain unchanged.
- The output path must end in `.png`; transparent palette entries are preserved as an alpha channel. Existing output files are not overwritten, and the file is written via a temporary file.
### `copy`
- Pull an emulator save out of the configured RetroArch or DuckStation directory into this workspace copy, so `list`/`import`/`delete` work happens here and the emulator original is never modified (raw `.srm`/`.mcd`, 131072 bytes starting with `MC`).
@ -141,6 +156,7 @@ paths are treated as RetroArch and use `.srm` (including paths such as
- Back up saves before converting.
- `convert` refuses to overwrite; delete or rename a previous `.srm` first if you intend to replace it.
- `import` and `delete` replace the target raw card after staging and validation; back it up first and keep the emulator closed while modifying it.
- `export-icon` only reads the source card and refuses to overwrite an existing output file.
- `install` backs up the overwritten card automatically as `<card>.<ext>.japs1me-bak`, but keep an independent backup of anything irreplaceable.
- Avoid testing against your only copy of a save; the fixture in `save-games/` is read-only test input.
@ -170,6 +186,7 @@ importing rather than general memory-card editing:
- `list` covers saves and free slots; there is no per-frame browser.
- `convert` handles DexDrive `.gme` only; there is no VGS/VMP/MCX support and no reverse conversion.
- `import` copies whole saves from `.gme` (or misnamed raw) into `.srm`; there is no single-save (`.mcs`/`.psx`) support and no import into `.gme`.
- `export-icon` writes the primary 16x16 icon as a PNG only; there is no JPEG output or multi-frame animation export.
- There is no GUI, card formatting, comment editing, or hardware (DexDrive/serial) support. JAPS1ME validates directory checksums read-only and never recalculates them, except when relinking frames during imports and resetting them during deletions.
For details about the card formats, see the [technical format guide](docs/FORMATS.md). The [save-games collection](save-games/README.md) lists the saves shared in this repository.

View file

@ -78,6 +78,30 @@ NUL-terminated on an even byte boundary. JAPS1ME decodes it with a
malformed-sequence replacement and falls back to the save name
(`region + product code + identifier`) when no usable title exists.
### Save icons
Each initial save block carries a 16x16, 4bpp icon (the grid shown on the PS1
BIOS memory-card screen). JAPS1ME reads it for `export-icon` only and never
writes it back.
| Block offset | Size | Meaning |
| --- | ---: | --- |
| `0x60` | 32 | Palette: 16 x 16-bit entries (`R` bits 0–4, `G` bits 5–9, `B` bits 10–14, bit 15 flag) |
| `0x80` | 128 | Primary icon bitmap: 16 rows x 8 bytes, low nibble = left pixel, high nibble = right pixel |
Two further frames follow at `0x100`/`0x180` for animated icons; JAPS1ME
exports only the primary frame. Any palette entry whose red, green, blue, and
bit-15 flag are all zero is treated as transparent, matching MemcardRex's
`loadPalette`/`loadIcons`. Channel values are expanded to 8-bit by shifting the
5-bit components left by 3. `export-icon --palette NAME` can replace the HSL hue
of colored palette entries while preserving saturation and lightness; `NAME` is a
preset (`RED`, `GREEN`, `BLUE`, `YELLOW`, `PURPLE`, `ORANGE`) or a saturated
`#RRGGBB` hex color, and transparent entries, exact black/white, and achromatic
grays remain unchanged. Its optional `--frame circle` scales the icon down so the
whole square fits inside the canvas's inscribed circle, centers it on transparent
padding, and then applies a binary circular alpha mask, so no icon pixel is
cropped and no border is drawn.
### Imports
`import` copies a save's directory frames and data blocks from a source card
@ -92,6 +116,6 @@ target.
- [MemcardRex](https://github.com/ShendoXT/memcardrex): `MemcardRex.Core/ps1card.cs`
(`getGmeHeader`, `OpenMemoryCard`, `calculateXOR`, `loadStringData`,
`FindSaveLinks`) and `MemcardRex.Core/Hardware/DexDrive.cs` (128-byte frame
`FindSaveLinks`, `loadPalette`, `loadIcons`) and `MemcardRex.Core/Hardware/DexDrive.cs` (128-byte frame
protocol and XOR framing on the wire).
- The corresponding JAPS1ME implementations: `src/gme.rs`, `src/ps1/`.

492
src/commands/icon.rs Normal file
View file

@ -0,0 +1,492 @@
//! PNG encoding for `japs1me export-icon`.
use crate::ps1::icon::IconImage;
use anyhow::{bail, Context, Result};
/// Target hue for remapping PS1 save-icon palettes, either one of the named
/// colors or a custom saturated RGB hex color.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconPalette {
Red,
Green,
Blue,
Yellow,
Purple,
Orange,
Rgb(u8, u8, u8),
}
impl std::str::FromStr for IconPalette {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"red" => Ok(Self::Red),
"green" => Ok(Self::Green),
"blue" => Ok(Self::Blue),
"yellow" => Ok(Self::Yellow),
"purple" => Ok(Self::Purple),
"orange" => Ok(Self::Orange),
_ => parse_hex_palette(value),
}
}
}
fn parse_hex_palette(value: &str) -> std::result::Result<IconPalette, String> {
let hex = value.strip_prefix('#').unwrap_or(value);
if hex.len() == 6 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
let red = u8::from_str_radix(&hex[0..2], 16).expect("validated hex digits");
let green = u8::from_str_radix(&hex[2..4], 16).expect("validated hex digits");
let blue = u8::from_str_radix(&hex[4..6], 16).expect("validated hex digits");
let (_, saturation, _) = rgb_to_hsl(red, green, blue);
if saturation == 0.0 {
return Err(format!(
"palette '{value}' is grayscale; choose a saturated color for hue remapping"
));
}
return Ok(IconPalette::Rgb(red, green, blue));
}
Err(format!(
"unknown palette '{value}'; choose RED, GREEN, BLUE, YELLOW, PURPLE, ORANGE, or an RGB hex color like #33AAFF"
))
}
impl IconPalette {
fn hue_degrees(self) -> f64 {
match self {
Self::Red => 0.0,
Self::Orange => 30.0,
Self::Yellow => 60.0,
Self::Green => 120.0,
Self::Blue => 240.0,
Self::Purple => 270.0,
Self::Rgb(red, green, blue) => rgb_to_hsl(red, green, blue).0,
}
}
}
/// Apply an HSL hue replacement to each icon palette entry, preserving
/// saturation, lightness, and alpha. Transparent colors and exact black/white
/// entries are unchanged; neutral gray shades are also unchanged because
/// their HSL saturation is zero. Each pixel's stored palette index is used,
/// keeping duplicate RGB entries distinct.
pub fn remap_icon_palette(icon: &IconImage, palette: IconPalette) -> IconImage {
let mut out = icon.clone();
for color in &mut out.palette {
if color[3] == 0 || is_exact_black_or_white(color) {
continue;
}
let (_, saturation, lightness) = rgb_to_hsl(color[0], color[1], color[2]);
if saturation == 0.0 {
continue;
}
let (red, green, blue) = hsl_to_rgb(palette.hue_degrees(), saturation, lightness);
*color = [red, green, blue, color[3]];
}
for (pixel, &index) in out.rgba.chunks_exact_mut(4).zip(&icon.palette_indices) {
pixel.copy_from_slice(&out.palette[index as usize]);
}
out
}
fn is_exact_black_or_white(color: &[u8; 4]) -> bool {
(color[0] == 0 && color[1] == 0 && color[2] == 0)
|| (color[0] == 255 && color[1] == 255 && color[2] == 255)
}
fn rgb_to_hsl(red: u8, green: u8, blue: u8) -> (f64, f64, f64) {
let (red, green, blue) = (
red as f64 / 255.0,
green as f64 / 255.0,
blue as f64 / 255.0,
);
let max = red.max(green).max(blue);
let min = red.min(green).min(blue);
let delta = max - min;
let lightness = (max + min) / 2.0;
if delta == 0.0 {
return (0.0, 0.0, lightness);
}
let saturation = delta / (1.0 - (2.0 * lightness - 1.0).abs());
let hue = if max == red {
60.0 * (((green - blue) / delta) % 6.0)
} else if max == green {
60.0 * ((blue - red) / delta + 2.0)
} else {
60.0 * ((red - green) / delta + 4.0)
}
.rem_euclid(360.0);
(hue, saturation, lightness)
}
fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> (u8, u8, u8) {
let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
let hue_section = hue / 60.0;
let x = chroma * (1.0 - (hue_section.rem_euclid(2.0) - 1.0).abs());
let (red, green, blue) = match hue_section as u8 {
0 => (chroma, x, 0.0),
1 => (x, chroma, 0.0),
2 => (0.0, chroma, x),
3 => (0.0, x, chroma),
4 => (x, 0.0, chroma),
_ => (chroma, 0.0, x),
};
let match_value = lightness - chroma / 2.0;
let to_channel = |value: f64| ((value + match_value) * 255.0).round().clamp(0.0, 255.0) as u8;
(to_channel(red), to_channel(green), to_channel(blue))
}
/// Scale an icon by an integer nearest-neighbor factor and encode it as an
/// 8-bit RGBA PNG.
///
/// `scale` must be at least 1. Transparency in the source icon is preserved.
pub fn encode_icon_png(icon: &IconImage, scale: usize, circle_mask: bool) -> Result<Vec<u8>> {
if scale == 0 {
bail!("scale must be at least 1");
}
let width = icon.width * scale;
let height = icon.height * scale;
let mut rgba = if circle_mask {
render_fitted_circle_icon(icon, scale)
} else {
scale_nearest(icon, scale)
};
if circle_mask {
apply_circle_mask(&mut rgba, width, height);
}
let mut out = Vec::new();
{
let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().context("encoding icon PNG header")?;
writer
.write_image_data(&rgba)
.context("encoding icon PNG data")?;
}
Ok(out)
}
/// Render an icon centered on a transparent square canvas, scaled down so the
/// whole square fits inside the canvas's inscribed circle.
///
/// Uses the largest whole-number zoom of the source icon that still fits, so
/// the pixel art stays crisp; falls back to nearest-neighbor scaling when the
/// canvas is too small for a whole-number fit (for example `--scale 1`).
fn render_fitted_circle_icon(icon: &IconImage, scale: usize) -> Vec<u8> {
let width = icon.width * scale;
let height = icon.height * scale;
let mut rgba = vec![0u8; width * height * 4];
let max_side = ((width.min(height) as f64) / std::f64::consts::SQRT_2).floor() as usize;
let longest = icon.width.max(icon.height);
let zoom = max_side / longest;
if zoom >= 1 {
let draw_width = icon.width * zoom;
let draw_height = icon.height * zoom;
let offset_x = (width - draw_width) / 2;
let offset_y = (height - draw_height) / 2;
for source_y in 0..icon.height {
for source_x in 0..icon.width {
let start = (source_y * icon.width + source_x) * 4;
let pixel = &icon.rgba[start..start + 4];
for dy in 0..zoom {
for dx in 0..zoom {
let x = offset_x + source_x * zoom + dx;
let y = offset_y + source_y * zoom + dy;
let target = (y * width + x) * 4;
rgba[target..target + 4].copy_from_slice(pixel);
}
}
}
}
} else {
let fit_width = max_side.max(1);
let fit_height = (((height as f64) / std::f64::consts::SQRT_2).floor() as usize).max(1);
let offset_x = (width - fit_width) / 2;
let offset_y = (height - fit_height) / 2;
for target_y in 0..fit_height {
for target_x in 0..fit_width {
let source_x = target_x * icon.width / fit_width;
let source_y = target_y * icon.height / fit_height;
let start = (source_y * icon.width + source_x) * 4;
let pixel = &icon.rgba[start..start + 4];
let x = offset_x + target_x;
let y = offset_y + target_y;
let target = (y * width + x) * 4;
rgba[target..target + 4].copy_from_slice(pixel);
}
}
}
rgba
}
/// Apply a centered circular alpha mask. Pixel centers on or inside the circle
/// remain visible; pixel centers outside are set fully transparent.
fn apply_circle_mask(rgba: &mut [u8], width: usize, height: usize) {
let center_x = width as f64 / 2.0;
let center_y = height as f64 / 2.0;
let radius = width.min(height) as f64 / 2.0;
let radius_squared = radius * radius;
for y in 0..height {
for x in 0..width {
let dx = x as f64 + 0.5 - center_x;
let dy = y as f64 + 0.5 - center_y;
if dx * dx + dy * dy > radius_squared {
rgba[(y * width + x) * 4 + 3] = 0;
}
}
}
}
/// Nearest-neighbor scale of an icon's RGBA buffer by an integer factor.
fn scale_nearest(icon: &IconImage, scale: usize) -> Vec<u8> {
if scale == 1 {
return icon.rgba.clone();
}
let width = icon.width * scale;
let mut out = Vec::with_capacity(width * icon.height * scale * 4);
for y in 0..icon.height {
let mut row = Vec::with_capacity(width * 4);
for x in 0..icon.width {
let start = (y * icon.width + x) * 4;
let pixel = &icon.rgba[start..start + 4];
for _ in 0..scale {
row.extend_from_slice(pixel);
}
}
for _ in 0..scale {
out.extend_from_slice(&row);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_icon() -> IconImage {
let mut rgba = vec![0u8; 16 * 16 * 4];
// Pixel (0,0) opaque red, default pixel (1,0) transparent.
rgba[0..4].copy_from_slice(&[248, 0, 0, 255]);
IconImage {
width: 16,
height: 16,
rgba,
palette: [
[0, 0, 0, 0],
[248, 0, 0, 255],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
[0; 4],
],
palette_indices: {
let mut indices = vec![0; 16 * 16];
indices[0] = 1;
indices
},
}
}
fn decode_png(bytes: &[u8]) -> (usize, usize, Vec<u8>) {
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
let mut reader = decoder.read_info().expect("png header");
let mut buffer = vec![0u8; reader.output_buffer_size().expect("output size")];
let info = reader.next_frame(&mut buffer).expect("png frame");
assert_eq!(info.color_type, png::ColorType::Rgba);
assert_eq!(info.bit_depth, png::BitDepth::Eight);
buffer.truncate(info.buffer_size());
(info.width as usize, info.height as usize, buffer)
}
#[test]
fn encodes_decodable_sixteen_pixel_png() {
let icon = sample_icon();
let bytes = encode_icon_png(&icon, 1, false).expect("encodes");
let (width, height, rgba) = decode_png(&bytes);
assert_eq!((width, height), (16, 16));
assert_eq!(rgba, icon.rgba);
}
#[test]
fn scale_duplicates_pixels() {
let icon = sample_icon();
let bytes = encode_icon_png(&icon, 2, false).expect("encodes");
let (width, height, rgba) = decode_png(&bytes);
assert_eq!((width, height), (32, 32));
// Each scaled row is 32 px = 128 bytes.
assert_eq!(&rgba[0..4], &[248, 0, 0, 255]);
assert_eq!(&rgba[4..8], &[248, 0, 0, 255]);
assert_eq!(&rgba[8..12], &[0, 0, 0, 0]);
// Row duplication: row 1 mirrors row 0.
assert_eq!(&rgba[128..132], &[248, 0, 0, 255]);
// Row 2 comes from the source's second row, which is empty.
assert_eq!(&rgba[256..260], &[0, 0, 0, 0]);
}
#[test]
fn scale_and_circle_mask_compose() {
let icon = IconImage {
width: 16,
height: 16,
rgba: vec![255; 16 * 16 * 4],
palette: [[255; 4]; 16],
palette_indices: vec![0; 16 * 16],
};
let bytes = encode_icon_png(&icon, 2, true).expect("encodes");
let (width, height, rgba) = decode_png(&bytes);
assert_eq!((width, height), (32, 32));
assert_eq!(rgba[3], 0);
assert_eq!(rgba[(16 * 32 + 16) * 4 + 3], 255);
}
#[test]
fn rejects_zero_scale() {
let icon = sample_icon();
assert!(encode_icon_png(&icon, 0, false).is_err());
}
#[test]
fn circle_frame_fits_whole_icon_inside_without_cropping() {
let mut icon = IconImage {
width: 16,
height: 16,
rgba: vec![255; 16 * 16 * 4],
palette: [[255; 4]; 16],
palette_indices: vec![0; 16 * 16],
};
// Marker in the icon's top-left source pixel.
icon.rgba[0..4].copy_from_slice(&[1, 2, 3, 255]);
let bytes = encode_icon_png(&icon, 8, true).expect("encodes");
let (width, height, rgba) = decode_png(&bytes);
assert_eq!((width, height), (128, 128));
// A whole-number zoom of 5 (80x80) is centered at offset 24.
let pixel = |x: usize, y: usize| -> [u8; 4] {
let start = (y * width + x) * 4;
rgba[start..start + 4].try_into().expect("four bytes")
};
assert_eq!(pixel(0, 0), [0, 0, 0, 0]);
assert_eq!(pixel(23, 23), [0, 0, 0, 0]);
assert_eq!(pixel(24, 24), [1, 2, 3, 255]);
// All four extreme icon corners survive the mask.
assert_eq!(pixel(103, 24), [255, 255, 255, 255]);
assert_eq!(pixel(24, 103), [255, 255, 255, 255]);
assert_eq!(pixel(103, 103), [255, 255, 255, 255]);
assert_eq!(pixel(104, 104), [0, 0, 0, 0]);
}
#[test]
fn circle_frame_falls_back_to_nearest_fit_when_too_small() {
let mut icon = IconImage {
width: 16,
height: 16,
rgba: vec![255; 16 * 16 * 4],
palette: [[255; 4]; 16],
palette_indices: vec![0; 16 * 16],
};
icon.rgba[0..4].copy_from_slice(&[1, 2, 3, 255]);
let bytes = encode_icon_png(&icon, 1, true).expect("encodes");
let (width, height, rgba) = decode_png(&bytes);
assert_eq!((width, height), (16, 16));
let pixel = |x: usize, y: usize| -> [u8; 4] {
let start = (y * width + x) * 4;
rgba[start..start + 4].try_into().expect("four bytes")
};
// The 11x11 fitted icon spans 2..13.
assert_eq!(pixel(1, 1), [0, 0, 0, 0]);
assert_eq!(pixel(2, 2), [1, 2, 3, 255]);
assert_eq!(pixel(12, 12), [255, 255, 255, 255]);
assert_eq!(pixel(13, 13), [0, 0, 0, 0]);
}
#[test]
fn palette_remap_preserves_black_white_gray_and_transparency() {
let mut icon = sample_icon();
icon.palette[1] = [128, 64, 32, 255];
icon.palette[2] = [0, 0, 0, 255];
icon.palette[3] = [255, 255, 255, 255];
icon.palette[4] = [128, 128, 128, 255];
icon.palette[5] = [20, 40, 60, 0];
icon.rgba[0..4].copy_from_slice(&icon.palette[1]);
icon.palette_indices[0] = 1;
let mapped = remap_icon_palette(&icon, IconPalette::Blue);
assert_eq!(mapped.palette[1][3], 255);
assert_ne!(mapped.palette[1][0..3], icon.palette[1][0..3]);
assert_eq!(mapped.palette[2], icon.palette[2]);
assert_eq!(mapped.palette[3], icon.palette[3]);
assert_eq!(mapped.palette[4], icon.palette[4]);
assert_eq!(mapped.palette[5], icon.palette[5]);
assert_eq!(&mapped.rgba[0..4], &mapped.palette[1]);
}
#[test]
fn remap_follows_palette_indices_rather_than_rgb_matching() {
let mut icon = sample_icon();
icon.palette[1] = [248, 0, 0, 255];
icon.palette_indices[0] = 1;
// The stored pixel color matches no palette entry; index-based remapping
// still recolors it via entry 1, while RGB matching would leave it alone.
icon.rgba[0..4].copy_from_slice(&[10, 20, 30, 255]);
let mapped = remap_icon_palette(&icon, IconPalette::Blue);
assert_eq!(&mapped.rgba[0..4], &mapped.palette[1]);
assert_eq!(&mapped.rgba[0..4], &[0, 0, 248, 255]);
}
#[test]
fn parses_palette_names_case_insensitively() {
for name in ["RED", "green", "Blue", "YELLOW", "purple", "ORANGE"] {
assert!(name.parse::<IconPalette>().is_ok(), "{name}");
}
assert!("pink".parse::<IconPalette>().is_err());
}
#[test]
fn parses_hex_palette_colors() {
for value in ["#33AAFF", "33aaff", "#ff8800"] {
assert!(value.parse::<IconPalette>().is_ok(), "{value}");
}
// Hex hue is derived from the color itself.
let parsed = "#33AAFF".parse::<IconPalette>().expect("parses");
assert_eq!(parsed, IconPalette::Rgb(0x33, 0xAA, 0xFF));
match parsed {
IconPalette::Rgb(red, green, blue) => {
let (hue, _, _) = rgb_to_hsl(red, green, blue);
assert!((hue - 205.0).abs() < 0.001, "hue {hue}");
}
other => panic!("expected Rgb, got {other:?}"),
}
}
#[test]
fn rejects_invalid_or_grayscale_hex_palette_colors() {
// Wrong length / non-hex digits.
for value in ["#12345", "#1234567", "#GGGGGG", "#"] {
assert!(value.parse::<IconPalette>().is_err(), "{value}");
}
// Grayscale has no hue to remap to.
for value in ["#808080", "#000000", "#FFFFFF"] {
assert!(value.parse::<IconPalette>().is_err(), "{value}");
}
}
}

View file

@ -1,4 +1,5 @@
//! Unicode table rendering for `japs1me list`.
pub mod icon;
pub mod list;
pub use list::{format_save_table, format_size};

View file

@ -9,9 +9,11 @@ pub mod gme;
pub mod install;
pub mod ps1;
pub use commands::icon::{encode_icon_png, remap_icon_palette, IconPalette};
pub use commands::list::{format_save_table, format_size};
pub use gme::{parse_gme, GmeCard, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
pub use ps1::card::{parse_raw, CardSummary, SlotInfo, SlotType};
pub use ps1::delete::{delete_save, DeleteReport};
pub use ps1::icon::{decode_icon, IconImage};
pub use ps1::import::{import_save, import_save_overwrite, ImportReport};
pub use ps1::title::decode_title;

View file

@ -1,11 +1,15 @@
use anyhow::{anyhow, bail, Context, Result};
use clap::{Parser, Subcommand};
use japs1me::commands::icon::{remap_icon_palette, IconPalette};
use japs1me::gme::{parse_gme, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
use japs1me::install::{
backup_path, detect_emulator, find_card_files, find_match_by_name, is_multi_disc,
load_saved_dir, new_card_path, parse_selection, resolve_in_saves_dir, save_dir_config,
};
use japs1me::{delete_save, format_save_table, import_save, import_save_overwrite, parse_raw};
use japs1me::{
decode_icon, delete_save, encode_icon_png, format_save_table, import_save,
import_save_overwrite, parse_raw,
};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
@ -37,6 +41,26 @@ enum Command {
},
/// Delete a save from a raw .srm/.mcd card by slot number or save name.
Delete { card: String, target: String },
/// Export a save's 16x16 icon as a PNG image.
ExportIcon {
/// Card to read: a .gme file or a raw .srm/.mcd/.mcr image.
card: String,
/// Save to export, by 1-based slot number or exact save name.
target: String,
/// Destination PNG path (must end in .png); must not already exist.
output: String,
/// Integer nearest-neighbor upscale factor.
#[arg(long, default_value_t = 1)]
scale: usize,
/// Optional frame around the icon (currently: circle).
#[arg(long, value_parser = parse_icon_frame)]
frame: Option<String>,
/// Recolor the icon's built-in palette by HSL hue: a name (RED, GREEN,
/// BLUE, YELLOW, PURPLE, ORANGE) or a saturated RGB hex color such as
/// #33AAFF. Saturation, lightness, and alpha are preserved.
#[arg(long)]
palette: Option<IconPalette>,
},
/// Copy an emulator card from its configured saves directory into this workspace.
Copy { source: String, dest: String },
/// Configure the RetroArch or DuckStation memory-card directory used by copy/install.
@ -186,6 +210,42 @@ fn main() -> Result<()> {
report.save_name, report.blocks, summary.free_slots
);
}
Command::ExportIcon {
card,
target,
output,
scale,
frame,
palette,
} => {
if scale == 0 {
bail!("--scale must be at least 1");
}
let output_path = Path::new(&output);
let is_png = output_path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"));
if !is_png {
bail!("output must be a .png file: '{output}'");
}
let bytes = std::fs::read(&card)
.with_context(|| format!("reading {}", Path::new(&card).display()))?;
let (raw, warnings) = load_raw(&bytes, &card)?;
for warning in warnings {
eprintln!("warning: {}: {warning}", Path::new(&card).display());
}
let mut icon = decode_icon(&raw, &target)?;
if let Some(palette) = palette {
icon = remap_icon_palette(&icon, palette);
}
let png = encode_icon_png(&icon, scale, frame.as_deref() == Some("circle"))?;
write_output_atomically(&output, &png)?;
let dimension = icon.width * scale;
eprintln!(
"Exported icon for '{target}' to {} ({dimension}x{dimension})",
output_path.display()
);
}
Command::Copy { source, dest } => {
let resolved = resolve_copy_source(&source)?;
let source_bytes = std::fs::read(&resolved)
@ -368,6 +428,14 @@ fn main() -> Result<()> {
Ok(())
}
fn parse_icon_frame(value: &str) -> std::result::Result<String, String> {
if value.eq_ignore_ascii_case("circle") {
Ok("circle".to_owned())
} else {
Err(format!("unsupported frame '{value}'; choose circle"))
}
}
/// Abort with `message` when stdin is not a terminal.
fn require_terminal(message: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
@ -801,4 +869,80 @@ mod tests {
assert!(write_new_file_atomically(&target, b"new").is_err());
assert_eq!(std::fs::read(&target).expect("read target"), b"existing");
}
const PES2_GME: &[u8] = include_bytes!("../save-games/pro-evolution-soccer-2.22507.gme");
#[test]
fn cli_export_icon_parses_scale_and_requires_output() {
assert!(
Cli::try_parse_from(["japs1me", "export-icon", "card.srm", "1", "out.png"]).is_ok()
);
assert!(Cli::try_parse_from([
"japs1me",
"export-icon",
"card.srm",
"BESLES-03946PES-OPT",
"out.png",
"--scale",
"4",
])
.is_ok());
assert!(Cli::try_parse_from(["japs1me", "export-icon", "card.srm", "1"]).is_err());
assert!(Cli::try_parse_from([
"japs1me",
"export-icon",
"card.srm",
"1",
"out.png",
"--frame",
"circle",
"--palette",
"PURPLE",
])
.is_ok());
assert!(Cli::try_parse_from([
"japs1me",
"export-icon",
"card.srm",
"1",
"out.png",
"--frame",
"square",
])
.is_err());
// Hex palette colors are accepted, with or without the leading '#'.
assert!(Cli::try_parse_from([
"japs1me",
"export-icon",
"card.srm",
"1",
"out.png",
"--palette",
"#33AAFF",
])
.is_ok());
assert!(Cli::try_parse_from([
"japs1me",
"export-icon",
"card.srm",
"1",
"out.png",
"--palette",
"not-a-color",
])
.is_err());
}
#[test]
fn export_icon_writes_png_and_refuses_overwrite() {
let raw = &PES2_GME[japs1me::GME_HEADER_LEN..];
let icon = decode_icon(raw, "BESLES-03946PES-OPT").expect("decodes");
let png = encode_icon_png(&icon, 4, false).expect("encodes");
let dir = tempfile::tempdir().expect("tempdir");
let output = dir.path().join("icon.png");
write_output_atomically(&output.to_string_lossy(), &png).expect("writes");
assert!(write_output_atomically(&output.to_string_lossy(), &png).is_err());
assert_eq!(std::fs::read(&output).expect("reads"), png);
}
}

View file

@ -187,6 +187,27 @@ pub(crate) fn format_slots(raw: &mut [u8], chain: &[usize]) {
}
}
/// Resolve a 1-based slot number or exact save name to the initial slot that
/// starts the save.
///
/// All-digit selections are slot numbers (which must start a save); anything
/// else is matched against save names. Shared by `delete` and `export-icon`.
pub(crate) fn find_save_start(frames: &[DirFrame], select: &str) -> Result<usize> {
if let Ok(number) = select.trim().parse::<usize>() {
if number == 0 || number > SLOT_COUNT {
bail!("no save at slot '{select}': slots run 1..={SLOT_COUNT}");
}
if !frames[number - 1].slot_type.is_initial() {
bail!("no save starts at slot {number}");
}
Ok(number - 1)
} else {
(0..SLOT_COUNT)
.find(|&slot| frames[slot].slot_type.is_initial() && frames[slot].save_name() == select)
.ok_or_else(|| anyhow::anyhow!("save '{select}' not found on card"))
}
}
/// Read and validate the 15 directory frames of a raw card image.
///
/// Returns the frames plus non-fatal checksum warnings. Length and `MC`

View file

@ -3,8 +3,8 @@
//! The save's chain is reset to formatted slots (frames plus zeroed data
//! blocks), freeing them for future imports.
use crate::ps1::card::{find_links, format_slots, read_frames, SLOT_COUNT};
use anyhow::{bail, Result};
use crate::ps1::card::{find_links, find_save_start, format_slots, read_frames};
use anyhow::Result;
/// Outcome of [`delete_save`].
#[derive(Debug)]
@ -24,19 +24,7 @@ pub struct DeleteReport {
/// a report.
pub fn delete_save(target_raw: &[u8], select: &str) -> Result<(Vec<u8>, DeleteReport)> {
let (frames, _) = read_frames(target_raw)?;
let start = if let Ok(number) = select.trim().parse::<usize>() {
if number == 0 || number > SLOT_COUNT {
bail!("no save at slot '{select}': slots run 1..={SLOT_COUNT}");
}
if !frames[number - 1].slot_type.is_initial() {
bail!("no save starts at slot {number}");
}
number - 1
} else {
(0..SLOT_COUNT)
.find(|&slot| frames[slot].slot_type.is_initial() && frames[slot].save_name() == select)
.ok_or_else(|| anyhow::anyhow!("save '{select}' not found on card"))?
};
let start = find_save_start(&frames, select)?;
let chain = find_links(&frames, start);
let save_name = frames[start].save_name();
let mut out = target_raw.to_vec();
@ -54,7 +42,9 @@ pub fn delete_save(target_raw: &[u8], select: &str) -> Result<(Vec<u8>, DeleteRe
#[cfg(test)]
mod tests {
use super::*;
use crate::ps1::card::{DIR_FRAMES_OFFSET, DIR_FRAME_LEN, SAVE_BLOCK_LEN, SAVE_DATA_OFFSET};
use crate::ps1::card::{
DIR_FRAMES_OFFSET, DIR_FRAME_LEN, SAVE_BLOCK_LEN, SAVE_DATA_OFFSET, SLOT_COUNT,
};
fn blank_raw() -> Vec<u8> {
let mut raw = vec![0u8; crate::gme::RAW_CARD_LEN];

192
src/ps1/icon.rs Normal file
View file

@ -0,0 +1,192 @@
//! PS1 save-icon decoding (16x16, 4bpp) into RGBA pixels.
//!
//! In a save's initial data block:
//! - Palette: 32 bytes at block offset `0x60` (16 x 16-bit BGR555 entries).
//! - Icon bitmap: 128 bytes at block offset `0x80` (16 rows of 8 bytes; the
//! low nibble is the left pixel and the high nibble the right pixel).
//!
//! A palette entry whose red, green, blue, and bit-15 flag are all zero is
//! treated as transparent, matching MemcardRex's `loadPalette`/`loadIcons`.
use crate::ps1::card::{find_save_start, read_frames, SAVE_BLOCK_LEN, SAVE_DATA_OFFSET};
use anyhow::Result;
/// Palette offset within a save data block.
pub const PALETTE_OFFSET: usize = 0x60;
/// Palette length in bytes (16 colors x 2 bytes).
pub const PALETTE_LEN: usize = 32;
/// Offset of the primary icon bitmap within a save data block.
pub const ICON_BITMAP_OFFSET: usize = 0x80;
/// Icon bitmap length in bytes (16x16 pixels at 4bpp).
pub const ICON_BITMAP_LEN: usize = 128;
/// Icon width and height in pixels.
pub const ICON_DIM: usize = 16;
/// A decoded icon as 8-bit RGBA pixels, row-major.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IconImage {
/// Width in pixels.
pub width: usize,
/// Height in pixels.
pub height: usize,
/// `width * height` RGBA quadbytes.
pub rgba: Vec<u8>,
/// Decoded 16-color palette in row-major RGBA bytes.
pub palette: [[u8; 4]; 16],
/// Original palette index for each pixel, row-major.
pub palette_indices: Vec<u8>,
}
/// Decode the primary 16x16 icon for the save selected by 1-based slot number
/// or exact save name.
pub fn decode_icon(raw: &[u8], select: &str) -> Result<IconImage> {
let (frames, _) = read_frames(raw)?;
let slot = find_save_start(&frames, select)?;
let block_start = SAVE_DATA_OFFSET + slot * SAVE_BLOCK_LEN;
let block = &raw[block_start..block_start + SAVE_BLOCK_LEN];
let palette = decode_palette(&block[PALETTE_OFFSET..PALETTE_OFFSET + PALETTE_LEN]);
let bitmap = &block[ICON_BITMAP_OFFSET..ICON_BITMAP_OFFSET + ICON_BITMAP_LEN];
let mut rgba = Vec::with_capacity(ICON_DIM * ICON_DIM * 4);
let mut palette_indices = Vec::with_capacity(ICON_DIM * ICON_DIM);
for row in bitmap.chunks_exact(ICON_DIM / 2) {
for &byte in row {
let low = byte & 0x0F;
let high = byte >> 4;
rgba.extend_from_slice(&palette[low as usize]);
rgba.extend_from_slice(&palette[high as usize]);
palette_indices.extend_from_slice(&[low, high]);
}
}
Ok(IconImage {
width: ICON_DIM,
height: ICON_DIM,
rgba,
palette,
palette_indices,
})
}
/// Decode the 16-entry palette into RGBA colors.
fn decode_palette(bytes: &[u8]) -> [[u8; 4]; 16] {
let mut palette = [[0u8; 4]; 16];
for (index, chunk) in bytes.chunks_exact(2).enumerate() {
let low = chunk[0];
let high = chunk[1];
let red = (low & 0x1F) << 3;
let green = ((high & 0x03) << 6) | ((low & 0xE0) >> 2);
let blue = (high & 0x7C) << 1;
let opaque = (red | green | blue | (high & 0x80)) != 0;
palette[index] = [red, green, blue, if opaque { 0xFF } else { 0x00 }];
}
palette
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gme::RAW_CARD_LEN;
use crate::ps1::card::{DIR_FRAMES_OFFSET, DIR_FRAME_LEN, SLOT_COUNT};
fn blank_raw() -> Vec<u8> {
let mut raw = vec![0u8; RAW_CARD_LEN];
raw[0] = b'M';
raw[1] = b'C';
for slot in 0..SLOT_COUNT {
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
raw[start] = 0xA0;
raw[start + 8] = 0xFF;
raw[start + 9] = 0xFF;
fix_xor(&mut raw, start);
}
raw
}
fn fix_xor(raw: &mut [u8], start: usize) {
let mut xor = 0u8;
for byte in &raw[start..start + DIR_FRAME_LEN - 1] {
xor ^= *byte;
}
raw[start + DIR_FRAME_LEN - 1] = xor;
}
fn make_save(raw: &mut [u8], slot: usize, region: &[u8; 2], prod: &[u8], ident: &[u8]) {
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
raw[start] = 0x51;
raw[start + 4] = 0x00;
raw[start + 5] = 0x20;
raw[start + 8] = 0xFF;
raw[start + 9] = 0xFF;
raw[start + 10..start + 12].copy_from_slice(region);
raw[start + 12..start + 12 + prod.len().min(10)]
.copy_from_slice(&prod[..prod.len().min(10)]);
raw[start + 22..start + 22 + ident.len().min(8)]
.copy_from_slice(&ident[..ident.len().min(8)]);
fix_xor(raw, start);
}
fn data_block(raw: &mut [u8], slot: usize) -> &mut [u8] {
let start = SAVE_DATA_OFFSET + slot * SAVE_BLOCK_LEN;
&mut raw[start..start + SAVE_BLOCK_LEN]
}
#[test]
fn decodes_palette_and_pixels() {
let mut raw = blank_raw();
make_save(&mut raw, 0, b"BE", b"SLES-03946", b"PES-OPT");
let block = data_block(&mut raw, 0);
// Entry 0 transparent, entry 1 red, entry 2 green.
block[PALETTE_OFFSET..PALETTE_OFFSET + 6]
.copy_from_slice(&[0x00, 0x00, 0x1F, 0x00, 0xE0, 0x03]);
// Pixel (0,0) = index 1 (red), pixel (1,0) = index 0 (transparent).
block[ICON_BITMAP_OFFSET] = 0x01;
let icon = decode_icon(&raw, "BESLES-03946PES-OPT").expect("decodes");
assert_eq!(icon.width, 16);
assert_eq!(icon.height, 16);
assert_eq!(icon.rgba.len(), 16 * 16 * 4);
assert_eq!(icon.palette_indices.len(), 16 * 16);
assert_eq!(&icon.rgba[0..4], &[248, 0, 0, 255]);
assert_eq!(&icon.rgba[4..8], &[0, 0, 0, 0]);
assert_eq!(&icon.palette_indices[0..2], &[1, 0]);
}
#[test]
fn selects_by_slot_number() {
let mut raw = blank_raw();
make_save(&mut raw, 0, b"BE", b"SLES-03946", b"PES-OPT");
make_save(&mut raw, 3, b"BA", b"SLUS-12345", b"SAVE1");
let block = data_block(&mut raw, 3);
block[PALETTE_OFFSET + 2..PALETTE_OFFSET + 4].copy_from_slice(&[0x1F, 0x00]);
block[ICON_BITMAP_OFFSET] = 0x01;
let icon = decode_icon(&raw, "4").expect("decodes by slot");
assert_eq!(&icon.rgba[0..4], &[248, 0, 0, 255]);
}
#[test]
fn rejects_unknown_selection() {
let mut raw = blank_raw();
make_save(&mut raw, 0, b"BE", b"SLES-03946", b"PES-OPT");
assert!(decode_icon(&raw, "BENOPE-00000NOPE").is_err());
assert!(decode_icon(&raw, "2").is_err());
assert!(decode_icon(&raw, "0").is_err());
}
const PES2_GME: &[u8] = include_bytes!("../../save-games/pro-evolution-soccer-2.22507.gme");
#[test]
fn decodes_fixture_icon() {
let raw = &PES2_GME[crate::gme::GME_HEADER_LEN..];
let icon = decode_icon(raw, "BESLES-03946PES-OPT").expect("fixture decodes");
assert_eq!(icon.width, 16);
assert_eq!(icon.height, 16);
// Row 0 is all index 0, which the fixture palette marks transparent.
assert_eq!(&icon.rgba[0..4], &[0, 0, 0, 0]);
// Row 1, pixel 0 uses palette index 0xB (136, 152, 136).
assert_eq!(&icon.rgba[16 * 4..16 * 4 + 4], &[136, 152, 136, 255]);
}
}

View file

@ -2,5 +2,6 @@
pub mod card;
pub mod delete;
pub mod icon;
pub mod import;
pub mod title;