diff --git a/sample_project/Palettes/Dirt.npal b/sample_project/Palettes/Dirt.npal
deleted file mode 100644
index 5c1f6ea7..00000000
--- a/sample_project/Palettes/Dirt.npal
+++ /dev/null
@@ -1 +0,0 @@
-ûÿ³Ö
\ No newline at end of file
diff --git a/sample_project/TileSheets/Charset.ng b/sample_project/TileSheets/Charset.ng
index 30c42567..8e007bfa 100644
Binary files a/sample_project/TileSheets/Charset.ng and b/sample_project/TileSheets/Charset.ng differ
diff --git a/sample_project/TileSheets/Dirt.ng b/sample_project/TileSheets/Dirt.ng
deleted file mode 100644
index 75f840e5..00000000
Binary files a/sample_project/TileSheets/Dirt.ng and /dev/null differ
diff --git a/src/nostalgia/core/gfx.hpp b/src/nostalgia/core/gfx.hpp
index 04121680..3a295e76 100644
--- a/src/nostalgia/core/gfx.hpp
+++ b/src/nostalgia/core/gfx.hpp
@@ -34,8 +34,11 @@ struct NostalgiaPalette {
 };
 
 struct NostalgiaGraphic {
-	static constexpr auto Fields = 4;
-	uint8_t bpp = 0;
+	static constexpr auto Fields = 6;
+	int8_t bpp = 0;
+	// rows and columns are really only used by TileSheetEditor
+	int rows = 1;
+	int columns = 1;
 	ox::FileAddress defaultPalette;
 	NostalgiaPalette pal;
 	ox::Vector<uint8_t> tiles;
@@ -52,6 +55,8 @@ template<typename T>
 ox::Error model(T *io, NostalgiaGraphic *ng) {
 	io->setTypeInfo("nostalgia::core::NostalgiaGraphic", NostalgiaGraphic::Fields);
 	oxReturnError(io->field("bpp", &ng->bpp));
+	oxReturnError(io->field("rows", &ng->rows));
+	oxReturnError(io->field("columns", &ng->columns));
 	oxReturnError(io->field("defaultPalette", &ng->defaultPalette));
 	oxReturnError(io->field("pal", &ng->pal));
 	oxReturnError(io->field("tiles", &ng->tiles));
diff --git a/src/nostalgia/core/studio/TileSheetEditor.qml b/src/nostalgia/core/studio/TileSheetEditor.qml
index ff735b01..7ec09331 100644
--- a/src/nostalgia/core/studio/TileSheetEditor.qml
+++ b/src/nostalgia/core/studio/TileSheetEditor.qml
@@ -104,6 +104,10 @@ Rectangle {
 		height: tileGrid.rows * tileGrid.baseTileSize * tileGrid.scaleFactor
 		rows: sheetData ? sheetData.rows : 1
 		columns: sheetData ? sheetData.columns : 1
+		states: State {
+			name: "widthChanged"
+			PropertyChanges { target: tileGrid.width; width: tileGrid.columns * tileGrid.baseTileSize * tileGrid.scaleFactor }
+		}
 		Repeater {
 			model: tileGrid.rows * tileGrid.columns
 			Tile {
diff --git a/src/nostalgia/core/studio/consts.hpp b/src/nostalgia/core/studio/consts.hpp
index 1aa1b904..4361307b 100644
--- a/src/nostalgia/core/studio/consts.hpp
+++ b/src/nostalgia/core/studio/consts.hpp
@@ -15,6 +15,7 @@ constexpr auto PluginName = "NostalgiaCore";
 // Command IDs to use with QUndoCommand::id()
 enum class CommandId {
 	UpdatePixel = 1,
+	UpdateDimension = 2,
 };
 
 }
diff --git a/src/nostalgia/core/studio/tilesheeteditor.cpp b/src/nostalgia/core/studio/tilesheeteditor.cpp
index f6711ea2..c234b625 100644
--- a/src/nostalgia/core/studio/tilesheeteditor.cpp
+++ b/src/nostalgia/core/studio/tilesheeteditor.cpp
@@ -10,14 +10,17 @@
 #include <QHeaderView>
 #include <QPointer>
 #include <QQmlContext>
+#include <QQuickItem>
 #include <QQuickWidget>
 #include <QSet>
 #include <QSettings>
 #include <QSpinBox>
 #include <QSplitter>
 #include <QUndoCommand>
+#include <QTableWidget>
 #include <QToolBar>
 #include <QVBoxLayout>
+#include <memory>
 
 #include "consts.hpp"
 #include "tilesheeteditor.hpp"
@@ -53,6 +56,59 @@ struct LabeledSpinner: public QWidget {
 };
 
 
+class UpdateDimensionsCommand: public QUndoCommand {
+	public:
+		enum class Dimension {
+			Rows,
+			Columns,
+		};
+
+	private:
+		Dimension m_dimension = Dimension::Rows;
+		int m_oldVal = 0;
+		int m_newVal = 0;
+		SheetData *m_sheetData = nullptr;
+
+	public:
+		UpdateDimensionsCommand(SheetData *sheetData, Dimension dim, int oldVal, int newVal) {
+			m_sheetData = sheetData;
+			m_dimension = dim;
+			m_newVal = newVal;
+			m_oldVal = oldVal;
+			setObsolete(newVal == oldVal);
+		}
+
+		virtual ~UpdateDimensionsCommand() = default;
+
+		int id() const override {
+			return static_cast<int>(CommandId::UpdateDimension);
+		}
+
+		void redo() override {
+			switch (m_dimension) {
+				case Dimension::Rows:
+					m_sheetData->setRows(m_newVal);
+					break;
+				case Dimension::Columns:
+					m_sheetData->setColumns(m_newVal);
+					break;
+			}
+		}
+
+		void undo() override {
+			switch (m_dimension) {
+				case Dimension::Rows:
+					m_sheetData->setRows(m_oldVal);
+					break;
+				case Dimension::Columns:
+					m_sheetData->setColumns(m_oldVal);
+					break;
+			}
+		}
+
+};
+
+
 class UpdatePixelsCommand: public QUndoCommand {
 	private:
 		struct PixelUpdate {
@@ -96,8 +152,8 @@ class UpdatePixelsCommand: public QUndoCommand {
 		}
 
 		bool mergeWith(const QUndoCommand *cmd) override {
-			auto other = static_cast<const UpdatePixelsCommand*>(cmd);
-			if (m_cmdIdx == other->m_cmdIdx) {
+			auto other = dynamic_cast<const UpdatePixelsCommand*>(cmd);
+			if (other && m_cmdIdx == other->m_cmdIdx) {
 				m_pixelUpdates.unite(other->m_pixelUpdates);
 				return true;
 			}
@@ -107,7 +163,14 @@ class UpdatePixelsCommand: public QUndoCommand {
 		void redo() override {
 			for (const auto &pu : m_pixelUpdates) {
 				pu.item->setProperty("color", m_palette[m_newColorId]);
-				m_pixels[pu.item->property("pixelNumber").toInt()] = m_newColorId;
+				const auto index = pu.item->property("pixelNumber").toInt();
+				// resize to appropriate number of tiles if pixel beyond current
+				// range
+				if (m_pixels.size() < index) {
+					auto sz = (index / 64 + 1) * 64;
+					m_pixels.resize(sz);
+				}
+				m_pixels[index] = m_newColorId;
 			}
 		}
 
@@ -117,6 +180,7 @@ class UpdatePixelsCommand: public QUndoCommand {
 				m_pixels[pu.item->property("pixelNumber").toInt()] = pu.oldColorId;
 			}
 		}
+
 };
 
 
@@ -145,19 +209,49 @@ QStringList SheetData::palette() {
 	return m_palette;
 }
 
-void SheetData::updatePixels(const studio::Context *ctx, QString ngPath, QString palPath) {
+void SheetData::load(const studio::Context *ctx, QString ngPath, QString palPath) {
 	auto ng = ctx->project->loadObj<NostalgiaGraphic>(ngPath);
-	std::unique_ptr<NostalgiaPalette> npal;
 	if (palPath == "" && ng->defaultPalette.type() == ox::FileAddressType::Path) {
 		palPath = ng->defaultPalette.getPath().value;
 	}
+	m_tilesheetPath = ngPath;
+	m_currentPalettePath = palPath;
+	setRows(ng->rows);
+	setColumns(ng->columns);
+	if (palPath != "") {
+		setPalette(ctx, palPath);
+	} else {
+		setPalette(&ng->pal);
+	}
+	updatePixels(ng.get());
+}
+
+void SheetData::save(const studio::Context *ctx, QString ngPath) {
+	auto ng = toNostalgiaGraphic();
+	ctx->project->writeObj(ngPath, ng.get());
+}
+
+void SheetData::setPalette(const NostalgiaPalette *npal) {
+	// load palette
+	m_palette.clear();
+	for (std::size_t i = 0; i < npal->colors.size(); i++) {
+		const auto c = toQColor(npal->colors[i]);
+		const auto color = c.name(QColor::HexArgb);
+		m_palette.append(color);
+	}
+}
+
+void SheetData::setPalette(const studio::Context *ctx, QString palPath) {
+	std::unique_ptr<NostalgiaPalette> npal;
 	try {
 		npal = ctx->project->loadObj<NostalgiaPalette>(palPath);
 		qInfo() << "Opened palette" << palPath;
 	} catch (ox::Error) {
 		qWarning() << "Could not open palette" << palPath;
 	}
-	updatePixels(ng.get(), npal.get());
+	if (npal) {
+		setPalette(npal.get());
+	}
 }
 
 void SheetData::setSelectedColor(int index) {
@@ -171,25 +265,24 @@ QUndoStack *SheetData::undoStack() {
 void SheetData::setColumns(int columns) {
 	m_columns = columns;
 	emit columnsChanged(columns);
+	emit changeOccurred();
 }
 
 void SheetData::setRows(int rows) {
 	m_rows = rows;
 	emit rowsChanged(rows);
+	emit changeOccurred();
 }
 
-void SheetData::updatePixels(const NostalgiaGraphic *ng, const NostalgiaPalette *npal) {
-	if (!npal) {
-		npal = &ng->pal;
-	}
+void SheetData::updateColumns(int columns) {
+	m_cmdStack.push(new UpdateDimensionsCommand(this, UpdateDimensionsCommand::Dimension::Columns, m_columns, columns));
+}
 
-	// load palette
-	for (std::size_t i = 0; i < npal->colors.size(); i++) {
-		const auto c = toQColor(npal->colors[i]);
-		const auto color = c.name(QColor::HexArgb);
-		m_palette.append(color);
-	}
+void SheetData::updateRows(int rows) {
+	m_cmdStack.push(new UpdateDimensionsCommand(this, UpdateDimensionsCommand::Dimension::Rows, m_rows, rows));
+}
 
+void SheetData::updatePixels(const NostalgiaGraphic *ng) {
 	if (ng->bpp == 8) {
 		for (std::size_t i = 0; i < ng->tiles.size(); i++) {
 			m_pixels.push_back(ng->tiles[i]);
@@ -209,6 +302,31 @@ void SheetData::updatePixels(const NostalgiaGraphic *ng, const NostalgiaPalette
 	emit paletteChanged();
 }
 
+std::unique_ptr<NostalgiaGraphic> SheetData::toNostalgiaGraphic() {
+	auto ng = std::make_unique<NostalgiaGraphic>();
+	const auto highestColorIdx = static_cast<uint8_t>(*std::max_element(m_pixels.begin(), m_pixels.end()));
+	ng->defaultPalette = m_currentPalettePath.toUtf8().data();
+	ng->bpp = highestColorIdx > 15 ? 8 : 4;
+	ng->columns = m_columns;
+	ng->rows = m_rows;
+	if (ng->bpp == 4) {
+		ng->tiles.resize(m_pixels.size() / 2);
+		for (int i = 0; i < m_pixels.size(); ++i) {
+			if (i & 1) {
+				ng->tiles[i / 2] |= static_cast<uint8_t>(m_pixels[i]) << 4;
+			} else {
+				ng->tiles[i / 2] = static_cast<uint8_t>(m_pixels[i]);
+			}
+		}
+	} else {
+		ng->tiles.resize(m_pixels.size());
+		for (int i = 0; i < m_pixels.size(); ++i) {
+			ng->tiles[i] = static_cast<uint8_t>(m_pixels[i]);
+		}
+	}
+	return ng;
+}
+
 void SheetData::beginCmd() {
 	++m_cmdIdx;
 }
@@ -220,6 +338,7 @@ void SheetData::endCmd() {
 
 TileSheetEditor::TileSheetEditor(QString path, const studio::Context *ctx, QWidget *parent): studio::Editor(parent) {
 	m_ctx = ctx;
+	m_itemPath = path;
 	m_itemName = path.mid(path.lastIndexOf('/'));
 	auto lyt = new QVBoxLayout(this);
 	m_splitter = new QSplitter(this);
@@ -227,12 +346,29 @@ TileSheetEditor::TileSheetEditor(QString path, const studio::Context *ctx, QWidg
 	auto canvasLyt = new QVBoxLayout(canvasParent);
 	m_canvas = new QQuickWidget(canvasParent);
 	canvasLyt->addWidget(m_canvas);
-	canvasLyt->setMenuBar(setupToolBar());
+	auto tb = new QToolBar(tr("Tile Sheet Options"));
+	m_tilesX = new LabeledSpinner(tr("Tiles &X:"), 1, m_sheetData.columns());
+	m_tilesY = new LabeledSpinner(tr("Tiles &Y:"), 1, m_sheetData.rows());
+	tb->addWidget(m_tilesX);
+	tb->addWidget(m_tilesY);
+	canvasLyt->setMenuBar(tb);
 	lyt->addWidget(m_splitter);
 	m_splitter->addWidget(canvasParent);
 	m_splitter->addWidget(setupColorPicker(m_splitter));
 	m_splitter->setStretchFactor(0, 1);
-	m_sheetData.updatePixels(m_ctx, path);
+	connect(&m_sheetData, &SheetData::columnsChanged, [this](int val) {
+		disconnect(m_tilesX->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateColumns);
+		m_tilesX->spinBox->setValue(val);
+		connect(m_tilesX->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateColumns);
+	});
+	connect(&m_sheetData, &SheetData::rowsChanged, [this](int val) {
+		disconnect(m_tilesY->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateRows);
+		m_tilesY->spinBox->setValue(val);
+		connect(m_tilesY->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateRows);
+	});
+	m_sheetData.load(m_ctx, m_itemPath);
+	connect(m_tilesX->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateColumns);
+	connect(m_tilesY->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::updateRows);
 	m_canvas->rootContext()->setContextProperty("sheetData", &m_sheetData);
 	m_canvas->setSource(QUrl::fromLocalFile(":/qml/TileSheetEditor.qml"));
 	m_canvas->setResizeMode(QQuickWidget::SizeRootObjectToView);
@@ -249,24 +385,12 @@ QString TileSheetEditor::itemName() {
 	return m_itemName;
 }
 
-void TileSheetEditor::saveItem() {
-}
-
 QUndoStack *TileSheetEditor::undoStack() {
 	return m_sheetData.undoStack();
 }
 
-QWidget *TileSheetEditor::setupToolBar() {
-	auto tb = new QToolBar(tr("Tile Sheet Options"));
-	m_tilesX = new LabeledSpinner(tr("Tiles &X:"), 1, m_sheetData.columns());
-	m_tilesY = new LabeledSpinner(tr("Tiles &Y:"), 1, m_sheetData.rows());
-	tb->addWidget(m_tilesX);
-	tb->addWidget(m_tilesY);
-	connect(&m_sheetData, &SheetData::columnsChanged, m_tilesX->spinBox, &QSpinBox::setValue);
-	connect(&m_sheetData, &SheetData::rowsChanged, m_tilesY->spinBox, &QSpinBox::setValue);
-	connect(m_tilesX->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::setColumns);
-	connect(m_tilesY->spinBox, QOverload<int>::of(&QSpinBox::valueChanged), &m_sheetData, &SheetData::setRows);
-	return tb;
+void TileSheetEditor::saveItem() {
+	m_sheetData.save(m_ctx, m_itemPath);
 }
 
 QWidget *TileSheetEditor::setupColorPicker(QWidget *parent) {
@@ -314,8 +438,6 @@ void TileSheetEditor::restoreState() {
 	QSettings settings(m_ctx->orgName, PluginName);
 	settings.beginGroup("TileSheetEditor/" + m_itemName);
 	m_splitter->restoreState(settings.value("m_splitter/state", m_splitter->saveState()).toByteArray());
-	m_sheetData.setRows(settings.value("m_sheetData/tileRows", 1).toInt());
-	m_sheetData.setColumns(settings.value("m_sheetData/tileColumns", 1).toInt());
 	m_colorPicker.colorTable->horizontalHeader()->restoreState(settings.value("m_colorPicker.colorTable/geometry", m_colorPicker.colorTable->horizontalHeader()->saveState()).toByteArray());
 	settings.endGroup();
 }
diff --git a/src/nostalgia/core/studio/tilesheeteditor.hpp b/src/nostalgia/core/studio/tilesheeteditor.hpp
index 613600a8..4666c88c 100644
--- a/src/nostalgia/core/studio/tilesheeteditor.hpp
+++ b/src/nostalgia/core/studio/tilesheeteditor.hpp
@@ -8,14 +8,9 @@
 
 #pragma once
 
-#include <QQuickItem>
-#include <QSplitter>
 #include <QStringList>
-#include <QStringView>
-#include <QTableWidget>
 #include <QUndoStack>
 #include <QVariant>
-#include <QWidget>
 
 #include <nostalgia/core/gfx.hpp>
 #include <nostalgia/studio/studio.hpp>
@@ -30,7 +25,9 @@ class SheetData: public QObject {
 	Q_PROPERTY(QStringList palette READ palette NOTIFY paletteChanged)
 
 	private:
-		QQuickItem *m_prevPixelUpdated = nullptr;
+		class QQuickItem *m_prevPixelUpdated = nullptr;
+		QString m_tilesheetPath;
+		QString m_currentPalettePath;
 		uint64_t m_cmdIdx = 0;
 		QUndoStack m_cmdStack;
 		QStringList m_palette;
@@ -54,7 +51,13 @@ class SheetData: public QObject {
 
 		QStringList palette();
 
-		void updatePixels(const studio::Context *ctx, QString ngPath, QString palPath = "");
+		void load(const studio::Context *ctx, QString ngPath, QString palPath = "");
+
+		void save(const studio::Context *ctx, QString ngPath);
+
+		void setPalette(const NostalgiaPalette *pal);
+
+		void setPalette(const studio::Context *ctx, QString palPath);
 
 		void setSelectedColor(int index);
 
@@ -65,8 +68,20 @@ class SheetData: public QObject {
 
 		void setRows(int rows);
 
+		/**
+		 * Sets columns through a QUndoCommand.
+		 */
+		void updateColumns(int columns);
+
+		/**
+		 * Sets rows through a QUndoCommand.
+		 */
+		void updateRows(int rows);
+
 	private:
-		void updatePixels(const NostalgiaGraphic *ng, const NostalgiaPalette *npal);
+		void updatePixels(const NostalgiaGraphic *ng);
+
+		[[nodiscard]] std::unique_ptr<NostalgiaGraphic> toNostalgiaGraphic();
 
 	signals:
 		void changeOccurred();
@@ -86,16 +101,17 @@ class TileSheetEditor: public studio::Editor {
 	Q_OBJECT
 
 	private:
+		QString m_itemPath;
 		QString m_itemName;
 		const studio::Context *m_ctx = nullptr;
 		SheetData m_sheetData;
-		QSplitter *m_splitter = nullptr;
+		class QSplitter *m_splitter = nullptr;
 		struct LabeledSpinner *m_tilesX = nullptr;
 		struct LabeledSpinner *m_tilesY = nullptr;
 		class QQuickWidget* m_canvas = nullptr;
 		struct {
 			QComboBox *palette = nullptr;
-			QTableWidget *colorTable = nullptr;
+			class QTableWidget *colorTable = nullptr;
 		} m_colorPicker;
 
 	public:
@@ -105,13 +121,12 @@ class TileSheetEditor: public studio::Editor {
 
 		QString itemName() override;
 
-		void saveItem() override;
-
 		QUndoStack *undoStack() override;
 
-	private:
-		QWidget *setupToolBar();
+	protected:
+		void saveItem() override;
 
+	private:
 		QWidget *setupColorPicker(QWidget *widget);
 
 		void setColorTable(QStringList hexColors);
