diff --git a/src/sprites/PhutilSpriteSheet.php b/src/sprites/PhutilSpriteSheet.php
index 01c6eb9..67a50d4 100644
--- a/src/sprites/PhutilSpriteSheet.php
+++ b/src/sprites/PhutilSpriteSheet.php
@@ -1,247 +1,291 @@
 <?php
 
 /**
  * NOTE: This is very new and unstable.
  */
 final class PhutilSpriteSheet {
 
+  const MANIFEST_VERSION = 1;
+
   private $sprites = array();
   private $sources = array();
+  private $hashes  = array();
   private $cssHeader;
   private $generated;
   private $scales = array(1);
 
   private $css;
   private $images;
 
   public function addSprite(PhutilSprite $sprite) {
     $this->generated = false;
     $this->sprites[] = $sprite;
     return $this;
   }
 
   public function setCSSHeader($header) {
     $this->generated = false;
     $this->cssHeader = $header;
     return $this;
   }
 
   public function setScales(array $scales) {
-    $this->scales = $scales;
+    $this->scales = array_values($scales);
     return $this;
   }
 
   private function generate() {
     if ($this->generated) {
       return;
     }
 
     $css = array();
     if ($this->cssHeader) {
       $css[] = $this->cssHeader;
     }
 
     $margin_w = 1;
     $margin_h = 1;
 
     $out_w = 0;
     $out_h = 0;
 
     // Lay out the sprite sheet. We attempt to build a roughly square sheet
     // so it's easier to manage, since 2000x20 is more cumbersome for humans
     // to deal with than 200x200.
     //
     // To do this, we use a simple greedy algorithm, adding sprites one at a
     // time. For each sprite, if the sheet is at least as wide as it is tall
     // we create a new row. Otherwise, we try to add it to an existing row.
     //
     // This isn't optimal, but does a reasonable job in most cases and isn't
     // too messy.
 
     // Group the sprites by their sizes. We lay them out in the sheet as
     // boxes, but then put them into the boxes in the order they were added
     // so similar sprites end up nearby on the final sheet.
     $boxes = array();
     foreach (array_reverse($this->sprites) as $sprite) {
-      $s_w = $sprite->getSourceH() + $margin_w;
-      $s_h = $sprite->getSourceW() + $margin_h;
+      $s_w = $sprite->getSourceW() + $margin_w;
+      $s_h = $sprite->getSourceH() + $margin_h;
       $boxes[$s_w][$s_h][] = $sprite;
     }
 
     $rows = array();
     foreach ($this->sprites as $sprite) {
-      $s_w = $sprite->getSourceH() + $margin_w;
-      $s_h = $sprite->getSourceW() + $margin_h;
+      $s_w = $sprite->getSourceW() + $margin_w;
+      $s_h = $sprite->getSourceH() + $margin_h;
 
       // Choose a row for this sprite.
       $maybe = array();
       foreach ($rows as $key => $row) {
         if ($row['h'] < $s_h) {
           // We can only add it to a row if the row is at least as tall as the
           // sprite.
           continue;
         }
         // We prefer rows which have the same height as the sprite, and then
         // rows which aren't yet very wide.
         $wasted_v = ($row['h'] - $s_h);
         $wasted_h = ($row['w'] / $out_w);
         $maybe[$key] = $wasted_v + $wasted_h;
       }
 
       $row_key = null;
       if ($maybe) {
         // If there were any candidate rows, pick the best one.
         asort($maybe);
         $row_key = head_key($maybe);
       }
 
       if ($row_key !== null) {
         // If there's a candidate row, but adding the sprite to it would make
         // the sprite wider than it is tall, create a new row instead. This
         // generally keeps the sprite square-ish.
         if ($rows[$row_key]['w'] + $s_w > $out_h) {
           $row_key = null;
         }
       }
 
       if ($row_key === null) {
         // Add a new row.
         $rows[] = array(
           'w'       => 0,
           'h'       => $s_h,
           'boxes'   => array(),
         );
         $row_key = last_key($rows);
         $out_h += $s_h;
       }
 
       // Add the sprite box to the row.
       $row = $rows[$row_key];
       $row['w'] += $s_w;
       $row['boxes'][] = array($s_w, $s_h);
       $rows[$row_key] = $row;
 
       $out_w = max($row['w'], $out_w);
     }
 
     $images = array();
     foreach ($this->scales as $scale) {
       $img = imagecreatetruecolor($out_w * $scale, $out_h * $scale);
       imagesavealpha($img, true);
       imagefill($img, 0, 0, imagecolorallocatealpha($img, 0, 0, 0, 127));
 
       $images[$scale] = $img;
     }
 
 
     // Put the shorter rows first. At the same height, put the wider rows first.
     // This makes the resulting sheet more human-readable.
     foreach ($rows as $key => $row) {
       $rows[$key]['sort'] = $row['h'] + (1 - ($row['w'] / $out_w));
     }
     $rows = isort($rows, 'sort');
 
     $pos_x = 0;
     $pos_y = 0;
     foreach ($rows as $row) {
       $max_h = 0;
       foreach ($row['boxes'] as $box) {
         $sprite = array_pop($boxes[$box[0]][$box[1]]);
 
         foreach ($images as $scale => $img) {
           $src = $this->loadSource($sprite, $scale);
           imagecopy(
             $img,
             $src,
             $scale * $pos_x,                $scale * $pos_y,
             $scale * $sprite->getSourceX(), $scale * $sprite->getSourceY(),
             $scale * $sprite->getSourceW(), $scale * $sprite->getSourceH());
         }
 
         $rule = $sprite->getTargetCSS();
         $cssx = (-$pos_x).'px';
         $cssy = (-$pos_y).'px';
 
         $css[] = "{$rule} {\n  background-position: {$cssx} {$cssy};\n}";
 
         $pos_x += $sprite->getSourceW() + $margin_w;
         $max_h = max($max_h, $sprite->getSourceH());
       }
       $pos_x = 0;
       $pos_y += $max_h + $margin_h;
     }
 
     $this->images = $images;
     $this->css = implode("\n\n", $css)."\n";
     $this->generated = true;
   }
 
   public function generateImage($path, $scale = 1) {
     $this->generate();
     $this->log("Writing sprite '{$path}'...");
     imagepng($this->images[$scale], $path);
     return $this;
   }
 
   public function generateCSS($path) {
     $this->generate();
     $this->log("Writing CSS '{$path}'...");
 
     $out = $this->css;
     $out = str_replace('{X}', imagesx($this->images[1]), $out);
     $out = str_replace('{Y}', imagesy($this->images[1]), $out);
 
     Filesystem::writeFile($path, $out);
     return $this;
   }
 
-  public function generateManifest($path) {
-    $sprites = mpull($this->sprites, 'getName');
-    sort($sprites);
+  public function needsRegeneration(array $manifest) {
+    return ($this->buildManifest() !== $manifest);
+  }
+
+  private function buildManifest() {
+    $output = array();
+    foreach ($this->sprites as $sprite) {
+      $output[$sprite->getName()] = array(
+        'name' => $sprite->getName(),
+        'rule' => $sprite->getTargetCSS(),
+        'hash' => $this->loadSourceHash($sprite),
+      );
+    }
+
+    ksort($output);
 
     $data = array(
-      'sprites' => $sprites,
+      'version' => self::MANIFEST_VERSION,
+      'sprites' => $output,
+      'scales'  => $this->scales,
+      'header'  => $this->cssHeader,
     );
 
+    return $data;
+  }
+
+  public function generateManifest($path) {
+    $data = $this->buildManifest();
+
     $json = new PhutilJSON();
     $data = $json->encodeFormatted($data);
     Filesystem::writeFile($path, $data);
     return $this;
   }
 
   private function log($message) {
     echo $message."\n";
   }
 
+  private function loadSourceHash(PhutilSprite $sprite) {
+    $inputs = array();
+
+    foreach ($this->scales as $scale) {
+      $file = $sprite->getSourceFile($scale);
+      if (empty($this->hashes[$file])) {
+        $this->hashes[$file] = md5(Filesystem::readFile($file));
+      }
+      $inputs[] = $file;
+      $inputs[] = $this->hashes[$file];
+    }
+
+    $inputs[] = $sprite->getSourceX();
+    $inputs[] = $sprite->getSourceY();
+    $inputs[] = $sprite->getSourceW();
+    $inputs[] = $sprite->getSourceH();
+
+    return md5(implode(':', $inputs));
+  }
+
   private function loadSource(PhutilSprite $sprite, $scale) {
     $file = $sprite->getSourceFile($scale);
     if (empty($this->sources[$file])) {
       $data = Filesystem::readFile($file);
       $image = imagecreatefromstring($data);
       $this->sources[$file] = array(
         'image' => $image,
         'x'     => imagesx($image),
         'y'     => imagesy($image),
       );
     }
 
     $s_w = $sprite->getSourceW() * $scale;
     $i_w = $this->sources[$file]['x'];
     if ($s_w > $i_w) {
       throw new Exception(
         "Sprite source for '{$file}' is too small (expected width {$s_w}, ".
         "found {$i_w}).");
     }
 
     $s_h = $sprite->getSourceH() * $scale;
     $i_h = $this->sources[$file]['y'];
     if ($s_h > $i_h) {
       throw new Exception(
         "Sprite source for '{$file}' is too small (expected height {$s_h}, ".
         "found {$i_h}).");
     }
 
     return $this->sources[$file]['image'];
   }
 
 }