Skip to content

Commit 758f502

Browse files
committed
fix(pet): support variable-height v2 marketplace spritesheets
The marketplace now ships v2 pets whose sheet appends two animation rows (1536x2288, 11 rows) onto the base 9-row 1536x1872 layout. codeg assumed a fixed 8x9 grid, so v2 pets failed to install outright and rendered squished. - Install validation accepts any 1536-wide sheet whose height is a whole number of 208px rows (9 to 32) instead of requiring exactly 1536x1872, so the shared add / replace / marketplace-install path takes v2 packages. - Sprite geometry is derived from the sheet's measured pixel size rather than a hardcoded row count: the pet window, the installed-sheet preview grid, the marketplace filmstrip preview, and the manager thumbnail all scale to the actual row and frame counts, keeping the nine base states aligned no matter how many rows are appended. - The pet manifest preserves unknown fields (spriteVersionNumber, kind) through install and edit instead of dropping them. - The spritesheet spec hint is updated across all locales.
1 parent 7c48df5 commit 758f502

18 files changed

Lines changed: 399 additions & 57 deletions

File tree

src-tauri/src/models/pet.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@ use std::path::PathBuf;
88

99
use serde::{Deserialize, Serialize};
1010

11-
/// Sprite-sheet geometry locked to the Codex format.
11+
/// Sprite-sheet geometry. The grid is a fixed 8 columns with a constant
12+
/// 192×208 frame cell; only the row count varies. The base Codex format is
13+
/// 9 rows (1536×1872), while v2 marketplace pets append two animation rows
14+
/// for 11 rows (1536×2288). Height therefore grows in whole 208px steps and
15+
/// is validated per sheet rather than pinned to a single value.
1216
pub const SPRITE_SHEET_WIDTH: u32 = 1536;
17+
/// Height of the base 9-row sheet. Treated as the canonical *minimum*; taller
18+
/// sheets are accepted as long as the height is a whole multiple of a row.
1319
pub const SPRITE_SHEET_HEIGHT: u32 = 1872;
1420
#[allow(dead_code)]
1521
pub const SPRITE_GRID_COLS: u32 = 8;
16-
#[allow(dead_code)]
22+
/// Row count of the base format, i.e. the minimum number of rows a sheet must
23+
/// carry (all base animation states). Newer sheets append rows beyond this.
1724
pub const SPRITE_GRID_ROWS: u32 = 9;
1825
#[allow(dead_code)]
1926
pub const SPRITE_FRAME_WIDTH: u32 = SPRITE_SHEET_WIDTH / SPRITE_GRID_COLS; // 192
20-
#[allow(dead_code)]
2127
pub const SPRITE_FRAME_HEIGHT: u32 = SPRITE_SHEET_HEIGHT / SPRITE_GRID_ROWS; // 208
2228

2329
/// Filename codex writes inside each pet directory. Stored as a relative path
@@ -71,6 +77,12 @@ pub struct PetManifest {
7177
/// round-trip compatibility but ignore the value when locating the asset
7278
/// (see `SPRITESHEET_FILENAME`).
7379
pub spritesheet_path: String,
80+
/// Any additional manifest fields the upstream format carries (e.g.
81+
/// `spriteVersionNumber`, `kind`). Captured verbatim so install/edit
82+
/// round-trips preserve metadata newer formats introduce instead of
83+
/// silently dropping it.
84+
#[serde(flatten, default)]
85+
pub extra: serde_json::Map<String, serde_json::Value>,
7486
}
7587

7688
/// Flattened summary returned to the frontend's pet list / picker.

src-tauri/src/pets/mod.rs

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
//! ```
1515
//!
1616
//! Where `pet.json` carries `{ id, displayName, description?, spritesheetPath }`
17-
//! and the spritesheet is a 1536×1872 RGBA WebP (PNG also accepted).
17+
//! (plus any extra fields such as `spriteVersionNumber` / `kind`, preserved
18+
//! verbatim) and the spritesheet is a 1536-wide RGBA WebP (PNG also accepted)
19+
//! whose height is a whole number of 208px animation rows — 1872px (9 rows)
20+
//! for the base format, 2288px (11 rows) for v2 pets.
1821
1922
pub mod codex_import;
2023
pub mod marketplace;
@@ -29,16 +32,21 @@ use image::{ImageFormat, ImageReader};
2932
use crate::app_error::AppCommandError;
3033
use crate::models::pet::{
3134
NewPetInput, PetDetail, PetManifest, PetMetaPatch, PetSpriteAsset, PetSummary,
32-
PET_MANIFEST_FILENAME, SPRITESHEET_FILENAME, SPRITE_SHEET_HEIGHT, SPRITE_SHEET_WIDTH,
35+
PET_MANIFEST_FILENAME, SPRITESHEET_FILENAME, SPRITE_FRAME_HEIGHT, SPRITE_GRID_ROWS,
36+
SPRITE_SHEET_WIDTH,
3337
};
3438
use crate::paths::codeg_pets_root;
3539

3640
/// Smallest plausible sprite-sheet payload; rejecting tiny inputs early
3741
/// avoids decoding random files.
3842
const MIN_SPRITE_BYTES: usize = 1024;
39-
/// Cap raw sprite uploads at 16 MiB. A correctly-encoded 1536×1872 WebP is
40-
/// usually well under 1 MiB; this is purely a guardrail.
43+
/// Cap raw sprite uploads at 16 MiB. A correctly-encoded sheet is usually well
44+
/// under a few MiB; this is purely a guardrail.
4145
const MAX_SPRITE_BYTES: usize = 16 * 1024 * 1024;
46+
/// Upper bound on animation rows. The base format is 9 rows and v2 pets ship
47+
/// 11; 32 leaves generous headroom for future additive rows while bounding the
48+
/// decoded image size a hostile package can force us to allocate.
49+
const MAX_SPRITE_ROWS: u32 = 32;
4250

4351
/// Detected sprite-sheet container.
4452
#[derive(Debug, Clone, Copy)]
@@ -97,12 +105,7 @@ pub fn validate_spritesheet(bytes: &[u8]) -> Result<SpriteFormat, AppCommandErro
97105
let img = reader
98106
.decode()
99107
.map_err(|e| AppCommandError::invalid_input(format!("Cannot decode sprite: {e}")))?;
100-
let (w, h) = (img.width(), img.height());
101-
if w != SPRITE_SHEET_WIDTH || h != SPRITE_SHEET_HEIGHT {
102-
return Err(AppCommandError::invalid_input(format!(
103-
"Spritesheet must be {SPRITE_SHEET_WIDTH}x{SPRITE_SHEET_HEIGHT} pixels (got {w}x{h})."
104-
)));
105-
}
108+
check_sprite_dimensions(img.width(), img.height())?;
106109
if !img.color().has_alpha() {
107110
return Err(AppCommandError::invalid_input(
108111
"Spritesheet must contain an alpha channel (transparent background).",
@@ -112,6 +115,37 @@ pub fn validate_spritesheet(bytes: &[u8]) -> Result<SpriteFormat, AppCommandErro
112115
Ok(detected)
113116
}
114117

118+
/// Verify a decoded sprite sheet's pixel grid. The width is fixed at 8 columns
119+
/// (`SPRITE_SHEET_WIDTH`); the height must be a whole number of 208px rows,
120+
/// covering at least the base animation states and no more than the row cap.
121+
/// This is where format evolution is absorbed: v1 sheets are 9 rows (1872px)
122+
/// and v2 sheets are 11 rows (2288px), and both pass.
123+
fn check_sprite_dimensions(width: u32, height: u32) -> Result<(), AppCommandError> {
124+
if width != SPRITE_SHEET_WIDTH {
125+
return Err(AppCommandError::invalid_input(format!(
126+
"Spritesheet must be {SPRITE_SHEET_WIDTH}px wide (8 columns); got width {width}."
127+
)));
128+
}
129+
if height == 0 || !height.is_multiple_of(SPRITE_FRAME_HEIGHT) {
130+
return Err(AppCommandError::invalid_input(format!(
131+
"Spritesheet height must be a whole multiple of {SPRITE_FRAME_HEIGHT}px rows; got {height}."
132+
)));
133+
}
134+
let rows = height / SPRITE_FRAME_HEIGHT;
135+
if rows < SPRITE_GRID_ROWS {
136+
return Err(AppCommandError::invalid_input(format!(
137+
"Spritesheet must have at least {SPRITE_GRID_ROWS} rows ({}px); got {rows} rows ({height}px).",
138+
SPRITE_GRID_ROWS * SPRITE_FRAME_HEIGHT
139+
)));
140+
}
141+
if rows > MAX_SPRITE_ROWS {
142+
return Err(AppCommandError::invalid_input(format!(
143+
"Spritesheet has too many rows ({rows}); the maximum is {MAX_SPRITE_ROWS}."
144+
)));
145+
}
146+
Ok(())
147+
}
148+
115149
/// Slug-validate a pet id. Returns `Err` on invalid input. Defense in depth:
116150
/// the frontend slugifies but we must independently reject malformed ids
117151
/// before they touch the filesystem.
@@ -348,6 +382,7 @@ pub fn add_pet(input: NewPetInput) -> Result<PetSummary, AppCommandError> {
348382
.map(|s| s.trim().to_string())
349383
.filter(|s| !s.is_empty()),
350384
spritesheet_path: SPRITESHEET_FILENAME.to_string(),
385+
extra: serde_json::Map::new(),
351386
};
352387
if let Err(err) = write_manifest_atomic(&tmp_dir, &manifest) {
353388
let _ = fs::remove_dir_all(&tmp_dir);
@@ -422,6 +457,7 @@ pub fn delete_pet(id: &str) -> Result<(), AppCommandError> {
422457
#[cfg(test)]
423458
mod tests {
424459
use super::*;
460+
use crate::models::pet::SPRITE_SHEET_HEIGHT;
425461

426462
// Filesystem-touching tests rely on `CODEG_HOME`, which is shared global
427463
// state. Cargo runs tests in parallel, so we'd need cross-binary
@@ -516,4 +552,78 @@ mod tests {
516552
let format = validate_spritesheet(&bytes).unwrap();
517553
assert!(matches!(format, SpriteFormat::Png));
518554
}
555+
556+
#[test]
557+
fn validate_spritesheet_accepts_v2_eleven_row_sheet() {
558+
// v2 marketplace pets are 1536×2288 (11 rows). Before the format bump
559+
// this exact size was rejected outright, blocking every v2 install.
560+
let mut img = image::RgbaImage::new(SPRITE_SHEET_WIDTH, 11 * SPRITE_FRAME_HEIGHT);
561+
for (i, p) in img.pixels_mut().enumerate() {
562+
let v = (i % 251) as u8;
563+
*p = image::Rgba([v, v, v, v]);
564+
}
565+
let mut bytes: Vec<u8> = Vec::new();
566+
image::DynamicImage::ImageRgba8(img)
567+
.write_to(&mut std::io::Cursor::new(&mut bytes), ImageFormat::Png)
568+
.unwrap();
569+
assert!(validate_spritesheet(&bytes).is_ok());
570+
}
571+
572+
#[test]
573+
fn check_sprite_dimensions_accepts_v1_and_v2() {
574+
assert!(check_sprite_dimensions(SPRITE_SHEET_WIDTH, SPRITE_SHEET_HEIGHT).is_ok()); // 9 rows
575+
assert!(check_sprite_dimensions(SPRITE_SHEET_WIDTH, 2288).is_ok()); // 11 rows
576+
}
577+
578+
#[test]
579+
fn check_sprite_dimensions_rejects_bad_width() {
580+
let err = check_sprite_dimensions(1024, SPRITE_SHEET_HEIGHT).unwrap_err();
581+
assert!(err.message.contains("1536"), "got: {}", err.message);
582+
}
583+
584+
#[test]
585+
fn check_sprite_dimensions_rejects_non_row_multiple_height() {
586+
// 2000 / 208 = 9.6 → not a whole number of rows.
587+
let err = check_sprite_dimensions(SPRITE_SHEET_WIDTH, 2000).unwrap_err();
588+
assert!(err.message.contains("multiple"), "got: {}", err.message);
589+
}
590+
591+
#[test]
592+
fn check_sprite_dimensions_rejects_too_few_rows() {
593+
// 1664 / 208 = 8 rows — missing base animation states.
594+
let err = check_sprite_dimensions(SPRITE_SHEET_WIDTH, 8 * SPRITE_FRAME_HEIGHT).unwrap_err();
595+
assert!(err.message.contains("at least"), "got: {}", err.message);
596+
}
597+
598+
#[test]
599+
fn check_sprite_dimensions_rejects_too_many_rows() {
600+
let err = check_sprite_dimensions(SPRITE_SHEET_WIDTH, (MAX_SPRITE_ROWS + 1) * SPRITE_FRAME_HEIGHT)
601+
.unwrap_err();
602+
assert!(err.message.contains("too many"), "got: {}", err.message);
603+
}
604+
605+
#[test]
606+
fn check_sprite_dimensions_rejects_zero_height() {
607+
assert!(check_sprite_dimensions(SPRITE_SHEET_WIDTH, 0).is_err());
608+
}
609+
610+
#[test]
611+
fn pet_manifest_preserves_extra_fields() {
612+
// A v2 manifest carries `spriteVersionNumber` + `kind` that our struct
613+
// does not name explicitly; they must survive a parse → serialize
614+
// round-trip instead of being dropped on install/edit.
615+
let raw = r#"{"id":"cat","displayName":"Cat","spritesheetPath":"spritesheet.webp","spriteVersionNumber":2,"kind":"creature"}"#;
616+
let manifest: PetManifest = serde_json::from_str(raw).unwrap();
617+
assert_eq!(
618+
manifest.extra.get("spriteVersionNumber").and_then(|v| v.as_u64()),
619+
Some(2)
620+
);
621+
assert_eq!(
622+
manifest.extra.get("kind").and_then(|v| v.as_str()),
623+
Some("creature")
624+
);
625+
let out = serde_json::to_string(&manifest).unwrap();
626+
assert!(out.contains("\"spriteVersionNumber\":2"), "got: {out}");
627+
assert!(out.contains("\"kind\":\"creature\""), "got: {out}");
628+
}
519629
}

src/app/pet/_components/PetSprite.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import { useMemo } from "react"
44
import {
5-
SPRITE_BACKGROUND_SIZE,
65
backgroundPositionFor,
6+
spriteBackgroundSize,
7+
spriteRowsFromHeight,
78
type PetState,
89
} from "@/lib/pet/animation"
10+
import { useImageNaturalSize } from "@/lib/pet/use-image-natural-size"
911
import { usePetAnimator } from "../_hooks/usePetAnimator"
1012

1113
export interface PetSpriteProps {
@@ -30,6 +32,10 @@ export function PetSprite({
3032
() => `url("${spritesheetUrl}")`,
3133
[spritesheetUrl]
3234
)
35+
// Derive the row count from the actual sheet so v2 (11-row) pets don't render
36+
// squished against the legacy 9-row assumption.
37+
const size = useImageNaturalSize(spritesheetUrl)
38+
const rows = spriteRowsFromHeight(size?.height)
3339

3440
return (
3541
<div
@@ -40,8 +46,8 @@ export function PetSprite({
4046
height: `${FRAME_HEIGHT * scale}px`,
4147
backgroundImage,
4248
backgroundRepeat: "no-repeat",
43-
backgroundSize: SPRITE_BACKGROUND_SIZE,
44-
backgroundPosition: backgroundPositionFor(row, col),
49+
backgroundSize: spriteBackgroundSize(rows),
50+
backgroundPosition: backgroundPositionFor(row, col, rows),
4551
imageRendering: "pixelated",
4652
}}
4753
/>

src/components/settings/pet-action-preview-grid.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { useTranslations } from "next-intl"
55
import {
66
PET_FRAME_DURATIONS_MS,
77
PET_STATE_ROW,
8-
SPRITE_BACKGROUND_SIZE,
98
backgroundPositionFor,
9+
filmstripFrameCount,
10+
spriteBackgroundSize,
11+
spriteRowsFromHeight,
1012
type PetState,
1113
} from "@/lib/pet/animation"
14+
import { useImageNaturalSize } from "@/lib/pet/use-image-natural-size"
1215

1316
export const PET_ACTION_PREVIEW_STATES = [
1417
"idle",
@@ -59,6 +62,14 @@ export function PetActionPreviewGrid({
5962
}: PetActionPreviewGridProps) {
6063
const t = useTranslations("Pet.marketplace")
6164

65+
// Measure the asset once so every cell shares the same derived geometry.
66+
// Spritesheet sources need the row count; marketplace filmstrips need the
67+
// frame count. Both fall back to the legacy layout until the image loads.
68+
const size = useImageNaturalSize(source.url)
69+
const rows = spriteRowsFromHeight(size?.height)
70+
const measuredFrames = size ? filmstripFrameCount(size.width, size.height) : 0
71+
const totalFrames = Math.max(MARKETPLACE_PREVIEW_TOTAL_FRAMES, measuredFrames)
72+
6273
return (
6374
<div className="grid grid-cols-3 gap-1">
6475
{PET_ACTION_PREVIEW_STATES.map((state) => {
@@ -68,6 +79,8 @@ export function PetActionPreviewGrid({
6879
key={state}
6980
source={source}
7081
state={state}
82+
rows={rows}
83+
totalFrames={totalFrames}
7184
label={`${petName} ${actionName}`}
7285
actionName={actionName}
7386
/>
@@ -80,19 +93,23 @@ export function PetActionPreviewGrid({
8093
function PetActionPreviewCell({
8194
source,
8295
state,
96+
rows,
97+
totalFrames,
8398
label,
8499
actionName,
85100
}: {
86101
source: PetActionPreviewSource
87102
state: PetState
103+
rows: number
104+
totalFrames: number
88105
label: string
89106
actionName: string
90107
}) {
91108
const col = usePetActionPreviewFrame(state)
92109
const frameStyle =
93110
source.type === "marketplace"
94-
? marketplacePreviewFrameStyle(source.url, state, col)
95-
: spritesheetPreviewFrameStyle(source.url, state, col)
111+
? marketplacePreviewFrameStyle(source.url, state, col, totalFrames)
112+
: spritesheetPreviewFrameStyle(source.url, state, col, rows)
96113

97114
return (
98115
<div className="min-w-0 p-1">
@@ -144,25 +161,31 @@ function usePetActionPreviewFrame(state: PetState): number {
144161
function marketplacePreviewFrameStyle(
145162
previewUrl: string,
146163
state: PetState,
147-
col: number
164+
col: number,
165+
totalFrames: number
148166
): CSSProperties {
167+
// The 9 known states always occupy the front of the filmstrip; newer states
168+
// (if any) are appended after. Using the *actual* frame count for the
169+
// denominator keeps the known frames aligned even when the strip is longer.
149170
const frame = MARKETPLACE_PREVIEW_FRAME_START[state] + col
150-
const x = (frame / (MARKETPLACE_PREVIEW_TOTAL_FRAMES - 1)) * 100
171+
const denom = Math.max(1, totalFrames - 1)
172+
const x = (frame / denom) * 100
151173
return {
152174
backgroundImage: `url("${previewUrl}")`,
153-
backgroundSize: `${MARKETPLACE_PREVIEW_TOTAL_FRAMES * 100}% 100%`,
175+
backgroundSize: `${totalFrames * 100}% 100%`,
154176
backgroundPosition: `${x}% 0%`,
155177
}
156178
}
157179

158180
function spritesheetPreviewFrameStyle(
159181
spritesheetUrl: string,
160182
state: PetState,
161-
col: number
183+
col: number,
184+
rows: number
162185
): CSSProperties {
163186
return {
164187
backgroundImage: `url("${spritesheetUrl}")`,
165-
backgroundSize: SPRITE_BACKGROUND_SIZE,
166-
backgroundPosition: backgroundPositionFor(PET_STATE_ROW[state], col),
188+
backgroundSize: spriteBackgroundSize(rows),
189+
backgroundPosition: backgroundPositionFor(PET_STATE_ROW[state], col, rows),
167190
}
168191
}

0 commit comments

Comments
 (0)