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
1922pub mod codex_import;
2023pub mod marketplace;
@@ -29,16 +32,21 @@ use image::{ImageFormat, ImageReader};
2932use crate :: app_error:: AppCommandError ;
3033use 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} ;
3438use crate :: paths:: codeg_pets_root;
3539
3640/// Smallest plausible sprite-sheet payload; rejecting tiny inputs early
3741/// avoids decoding random files.
3842const 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.
4145const 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) ]
423458mod 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}
0 commit comments