diff --git a/aegisub/build/Aegisub/Aegisub.vcxproj b/aegisub/build/Aegisub/Aegisub.vcxproj
index 6f2a3f842a2bc54decff685dcbd0ad201867cb28..83bdf3af9b43d102e4edb3514b923dddffc233be 100644
--- a/aegisub/build/Aegisub/Aegisub.vcxproj
+++ b/aegisub/build/Aegisub/Aegisub.vcxproj
@@ -265,6 +265,7 @@
     <ClInclude Include="$(SrcDir)ass_info.h" />
     <ClInclude Include="$(SrcDir)options.h" />
     <ClInclude Include="$(SrcDir)search_replace_engine.h" />
+    <ClInclude Include="$(SrcDir)subs_controller.h" />
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="$(SrcDir)aegisublocale.cpp" />
@@ -458,6 +459,7 @@
     <ClCompile Include="$(SrcDir)dialog_autosave.cpp" />
     <ClCompile Include="$(SrcDir)search_replace_engine.cpp" />
     <ClCompile Include="$(SrcDir)initial_line_state.cpp" />
+    <ClCompile Include="$(SrcDir)subs_controller.cpp" />
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="$(SrcDir)res.rc" />
diff --git a/aegisub/build/Aegisub/Aegisub.vcxproj.filters b/aegisub/build/Aegisub/Aegisub.vcxproj.filters
index da5ea5304fdbe7391db9f1eb5406da90edda3dc2..dd1a771bb0e967ef630e2aca760a0d0aea0b3db6 100644
--- a/aegisub/build/Aegisub/Aegisub.vcxproj.filters
+++ b/aegisub/build/Aegisub/Aegisub.vcxproj.filters
@@ -687,6 +687,9 @@
     <ClInclude Include="$(SrcDir)initial_line_state.h">
       <Filter>Utilities</Filter>
     </ClInclude>
+    <ClInclude Include="$(SrcDir)subs_controller.h">
+      <Filter>ASS</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="$(SrcDir)ass_dialogue.cpp">
@@ -1235,6 +1238,9 @@
     <ClCompile Include="$(SrcDir)initial_line_state.cpp">
       <Filter>Utilities</Filter>
     </ClCompile>
+    <ClCompile Include="$(SrcDir)subs_controller.cpp">
+      <Filter>ASS</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="$(SrcDir)res.rc">
diff --git a/aegisub/src/Makefile b/aegisub/src/Makefile
index ad47d3e48d26c3e4d8782e4321edd40095b258d6..6097b0db68f2c713489371da9b6fa585772e1b82 100644
--- a/aegisub/src/Makefile
+++ b/aegisub/src/Makefile
@@ -221,6 +221,7 @@ SRC += \
 	spline_curve.cpp \
 	standard_paths.cpp \
 	string_codec.cpp \
+	subs_controller.cpp \
 	subs_edit_box.cpp \
 	subs_edit_ctrl.cpp \
 	subs_grid.cpp \
diff --git a/aegisub/src/ass_exporter.cpp b/aegisub/src/ass_exporter.cpp
index 894e4ab42567761aaee1ab7e1dd2f89570416b4e..43f704b49503bf412e618e11d378b28b0512034e 100644
--- a/aegisub/src/ass_exporter.cpp
+++ b/aegisub/src/ass_exporter.cpp
@@ -40,6 +40,7 @@
 #include "ass_file.h"
 #include "compat.h"
 #include "include/aegisub/context.h"
+#include "subtitle_format.h"
 
 #include <algorithm>
 #include <memory>
@@ -106,7 +107,11 @@ AssFile *AssExporter::ExportTransform(wxWindow *export_dialog, bool copy) {
 
 void AssExporter::Export(agi::fs::path const& filename, std::string const& charset, wxWindow *export_dialog) {
 	std::unique_ptr<AssFile> subs(ExportTransform(export_dialog, true));
-	subs->Save(filename, false, false, charset);
+	const SubtitleFormat *writer = SubtitleFormat::GetWriter(filename);
+	if (!writer)
+		throw "Unknown file type.";
+
+	writer->WriteFile(subs.get(), filename, charset);
 }
 
 wxSizer *AssExporter::GetSettingsSizer(std::string const& name) {
diff --git a/aegisub/src/ass_file.cpp b/aegisub/src/ass_file.cpp
index a88595770f2c138848009d854d1728d8a5d1f118..44bb7354476315caa93f079421d18c817e01d5e7 100644
--- a/aegisub/src/ass_file.cpp
+++ b/aegisub/src/ass_file.cpp
@@ -40,23 +40,14 @@
 #include "ass_info.h"
 #include "ass_style.h"
 #include "options.h"
-#include "standard_paths.h"
-#include "subtitle_format.h"
-#include "text_file_reader.h"
-#include "text_file_writer.h"
 #include "utils.h"
 
 #include <libaegisub/dispatch.h>
-#include <libaegisub/fs.h>
 #include <libaegisub/of_type_adaptor.h>
-#include <libaegisub/util.h>
 
 #include <algorithm>
 #include <boost/algorithm/string/case_conv.hpp>
-#include <boost/algorithm/string/predicate.hpp>
-#include <boost/format.hpp>
-#include <boost/range/algorithm_ext/push_back.hpp>
-#include <list>
+#include <boost/filesystem/path.hpp>
 
 namespace std {
 	template<>
@@ -65,136 +56,12 @@ namespace std {
 	}
 }
 
-AssFile::AssFile ()
-: commitId(0)
-{
-}
-
 AssFile::~AssFile() {
 	auto copy = new EntryList;
 	copy->swap(Line);
 	agi::dispatch::Background().Async([=]{ delete copy; });
 }
 
-/// @brief Load generic subs
-void AssFile::Load(agi::fs::path const& filename, std::string const& charset) {
-	const SubtitleFormat *reader = SubtitleFormat::GetReader(filename);
-
-	try {
-		AssFile temp;
-		reader->ReadFile(&temp, filename, charset);
-
-		bool found_style = false;
-		bool found_dialogue = false;
-
-		// Check if the file has at least one style and at least one dialogue line
-		for (auto const& line : temp.Line) {
-			AssEntryGroup type = line.Group();
-			if (type == ENTRY_STYLE) found_style = true;
-			if (type == ENTRY_DIALOGUE) found_dialogue = true;
-			if (found_style && found_dialogue) break;
-		}
-
-		// And if it doesn't add defaults for each
-		if (!found_style)
-			temp.InsertLine(new AssStyle);
-		if (!found_dialogue)
-			temp.InsertLine(new AssDialogue);
-
-		swap(temp);
-	}
-	catch (agi::UserCancelException const&) {
-		return;
-	}
-
-	// Set general data
-	this->filename = filename;
-
-	// Add comments and set vars
-	SetScriptInfo("ScriptType", "v4.00+");
-
-	// Push the initial state of the file onto the undo stack
-	UndoStack.clear();
-	RedoStack.clear();
-	undoDescription.clear();
-	autosavedCommitId = savedCommitId = commitId + 1;
-	Commit("", COMMIT_NEW);
-
-	FileOpen(filename);
-}
-
-void AssFile::Save(agi::fs::path const& filename, bool setfilename, bool addToRecent, std::string const& encoding) {
-	const SubtitleFormat *writer = SubtitleFormat::GetWriter(filename);
-	if (!writer)
-		throw "Unknown file type.";
-
-	if (setfilename) {
-		autosavedCommitId = savedCommitId = commitId;
-		this->filename = filename;
-		StandardPaths::SetPathValue("?script", filename.parent_path());
-	}
-
-	FileSave();
-
-	writer->WriteFile(this, filename, encoding);
-
-	if (addToRecent)
-		AddToRecent(filename);
-}
-
-agi::fs::path AssFile::AutoSave() {
-	if (commitId == autosavedCommitId)
-		return "";
-
-	auto path = StandardPaths::DecodePath(OPT_GET("Path/Auto/Save")->GetString());
-	if (path.empty())
-		path = filename.parent_path();
-
-	agi::fs::CreateDirectory(path);
-
-	auto name = filename.filename();
-	if (name.empty())
-		name = "Untitled";
-
-	path /= str(boost::format("%s.%s.AUTOSAVE.ass") % name % agi::util::strftime("%Y-%m-%d-%H-%M-%S"));
-
-	Save(path, false, false);
-
-	autosavedCommitId = commitId;
-
-	return path;
-}
-
-void AssFile::SaveMemory(std::vector<char> &dst) {
-	// Check if subs contain at least one style
-	// Add a default style if they don't for compatibility with libass/asa
-	if (GetStyles().empty())
-		InsertLine(new AssStyle);
-
-	// Prepare vector
-	dst.clear();
-	dst.reserve(0x4000);
-
-	// Write file
-	AssEntryGroup group = ENTRY_GROUP_MAX;
-	for (auto const& line : Line) {
-		if (group != line.Group()) {
-			group = line.Group();
-			boost::push_back(dst, line.GroupHeader() + "\r\n");
-		}
-		boost::push_back(dst, line.GetEntryData() + "\r\n");
-	}
-}
-
-bool AssFile::CanSave() const {
-	try {
-		return SubtitleFormat::GetWriter(filename)->CanSave(this);
-	}
-	catch (...) {
-		return false;
-	}
-}
-
 void AssFile::LoadDefault(bool defline) {
 	Line.push_back(*new AssInfo("Title", "Default Aegisub file"));
 	Line.push_back(*new AssInfo("ScriptType", "v4.00+"));
@@ -211,26 +78,13 @@ void AssFile::LoadDefault(bool defline) {
 
 	if (defline)
 		Line.push_back(*new AssDialogue);
-
-	autosavedCommitId = savedCommitId = commitId + 1;
-	Commit("", COMMIT_NEW);
-	StandardPaths::SetPathValue("?script", "");
-	FileOpen("");
 }
 
 void AssFile::swap(AssFile &that) throw() {
-	// Intentionally does not swap undo stack related things
-	using std::swap;
-	swap(commitId, that.commitId);
-	swap(undoDescription, that.undoDescription);
-	swap(Line, that.Line);
+	Line.swap(that.Line);
 }
 
-AssFile::AssFile(const AssFile &from)
-: undoDescription(from.undoDescription)
-, commitId(from.commitId)
-, filename(from.filename)
-{
+AssFile::AssFile(const AssFile &from) {
 	Line.clone_from(from.Line, std::mem_fun_ref(&AssEntry::Clone), delete_ptr());
 }
 AssFile& AssFile::operator=(AssFile from) {
@@ -332,83 +186,17 @@ AssStyle *AssFile::GetStyle(std::string const& name) {
 	return nullptr;
 }
 
-void AssFile::AddToRecent(agi::fs::path const& file) const {
-	config::mru->Add("Subtitle", file);
-	OPT_SET("Path/Last/Subtitles")->SetString(file.parent_path().string());
-}
+int AssFile::Commit(wxString const& desc, int type, int amend_id, AssEntry *single_line) {
+	AssFileCommit c = { desc, &amend_id, single_line };
+	PushState(c);
 
-int AssFile::Commit(wxString const& desc, int type, int amendId, AssEntry *single_line) {
 	std::set<const AssEntry*> changed_lines;
 	if (single_line)
 		changed_lines.insert(single_line);
 
-	++commitId;
-	// Allow coalescing only if it's the last change and the file has not been
-	// saved since the last change
-	if (commitId == amendId+1 && RedoStack.empty() && savedCommitId+1 != commitId && autosavedCommitId+1 != commitId) {
-		// If only one line changed just modify it instead of copying the file
-		if (single_line) {
-			entryIter this_it = Line.begin(), undo_it = UndoStack.back().Line.begin();
-			while (&*this_it != single_line) {
-				++this_it;
-				++undo_it;
-			}
-			UndoStack.back().Line.insert(undo_it, *single_line->Clone());
-			delete &*undo_it;
-		}
-		else {
-			UndoStack.back() = *this;
-		}
-		AnnounceCommit(type, changed_lines);
-		return commitId;
-	}
-
-	RedoStack.clear();
-
-	// Place copy on stack
-	undoDescription = desc;
-	UndoStack.push_back(*this);
-
-	// Cap depth
-	int depth = std::max<int>(OPT_GET("Limits/Undo Levels")->GetInt(), 2);
-	while ((int)UndoStack.size() > depth) {
-		UndoStack.pop_front();
-	}
-
-	if (UndoStack.size() > 1 && OPT_GET("App/Auto/Save on Every Change")->GetBool() && !filename.empty() && CanSave())
-		Save(filename);
-
 	AnnounceCommit(type, changed_lines);
-	return commitId;
-}
-
-void AssFile::Undo() {
-	if (UndoStack.size() <= 1) return;
-
-	RedoStack.emplace_back();
-	swap(RedoStack.back());
-	UndoStack.pop_back();
-	*this = UndoStack.back();
-
-	AnnounceCommit(COMMIT_NEW, std::set<const AssEntry*>());
-}
-
-void AssFile::Redo() {
-	if (RedoStack.empty()) return;
-
-	swap(RedoStack.back());
-	UndoStack.push_back(*this);
-	RedoStack.pop_back();
-
-	AnnounceCommit(COMMIT_NEW, std::set<const AssEntry*>());
-}
 
-wxString AssFile::GetUndoDescription() const {
-	return IsUndoStackEmpty() ? "" : UndoStack.back().undoDescription;
-}
-
-wxString AssFile::GetRedoDescription() const {
-	return IsRedoStackEmpty() ? "" : RedoStack.back().undoDescription;
+	return amend_id;
 }
 
 bool AssFile::CompStart(const AssDialogue* lft, const AssDialogue* rgt) {
@@ -434,13 +222,6 @@ void AssFile::Sort(CompFunc comp, std::set<AssDialogue*> const& limit) {
 	Sort(Line, comp, limit);
 }
 namespace {
-	struct AssEntryComp : public std::binary_function<AssEntry, AssEntry, bool> {
-		AssFile::CompFunc comp;
-		bool operator()(AssEntry const&a, AssEntry const&b) const {
-			return comp(static_cast<const AssDialogue*>(&a), static_cast<const AssDialogue*>(&b));
-		}
-	};
-
 	inline bool is_dialogue(AssEntry *e, std::set<AssDialogue*> const& limit) {
 		AssDialogue *d = dynamic_cast<AssDialogue*>(e);
 		return d && (limit.empty() || limit.count(d));
@@ -448,8 +229,10 @@ namespace {
 }
 
 void AssFile::Sort(EntryList &lst, CompFunc comp, std::set<AssDialogue*> const& limit) {
-	AssEntryComp compE;
-	compE.comp = comp;
+	auto compE = [&](AssEntry const& a, AssEntry const& b) {
+		return comp(static_cast<const AssDialogue*>(&a), static_cast<const AssDialogue*>(&b));
+	};
+
 	// Sort each block of AssDialogues separately, leaving everything else untouched
 	for (entryIter begin = lst.begin(); begin != lst.end(); ++begin) {
 		if (!is_dialogue(&*begin, limit)) continue;
@@ -465,5 +248,3 @@ void AssFile::Sort(EntryList &lst, CompFunc comp, std::set<AssDialogue*> const&
 		begin = --end;
 	}
 }
-
-AssFile *AssFile::top;
diff --git a/aegisub/src/ass_file.h b/aegisub/src/ass_file.h
index 3e2a87a7a70077e4a5c84763e64a334fd41f37fd..932daf0d7da3014da1967c1021eaa42e72880066 100644
--- a/aegisub/src/ass_file.h
+++ b/aegisub/src/ass_file.h
@@ -32,61 +32,43 @@
 /// @ingroup subs_storage
 ///
 
-#include <boost/container/list.hpp>
-#include <boost/filesystem/path.hpp>
-#include <boost/intrusive/list.hpp>
-#include <set>
-#include <vector>
-#include <wx/string.h>
+#include "ass_entry.h"
 
 #include <libaegisub/fs_fwd.h>
 #include <libaegisub/signal.h>
 
-#include "ass_entry.h"
+#include <boost/intrusive/list.hpp>
+#include <set>
+#include <vector>
 
 class AssDialogue;
 class AssStyle;
 class AssAttachment;
+class wxString;
 
 typedef boost::intrusive::make_list<AssEntry, boost::intrusive::constant_time_size<false>>::type EntryList;
 typedef EntryList::iterator entryIter;
 typedef EntryList::const_iterator constEntryIter;
 
+struct AssFileCommit {
+	wxString const& message;
+	int *commit_id;
+	AssEntry *single_line;
+};
+
 class AssFile {
-	boost::container::list<AssFile> UndoStack;
-	boost::container::list<AssFile> RedoStack;
-	wxString undoDescription;
-	/// Revision counter for undo coalescing and modified state tracking
-	int commitId;
-	/// Last saved version of this file
-	int savedCommitId;
-	/// Last autosaved version of this file
-	int autosavedCommitId;
-
-	/// A set of changes has been committed to the file (AssFile::CommitType)
+	/// A set of changes has been committed to the file (AssFile::COMMITType)
 	agi::signal::Signal<int, std::set<const AssEntry*> const&> AnnounceCommit;
-	/// A new file has been opened (filename)
-	agi::signal::Signal<agi::fs::path> FileOpen;
-	/// The file is about to be saved
-	/// This signal is intended for adding metadata such as video filename,
-	/// frame number, etc. Ideally this would all be done immediately rather
-	/// than waiting for a save, but that causes (more) issues with undo
-	agi::signal::Signal<> FileSave;
-
+	agi::signal::Signal<AssFileCommit> PushState;
 public:
 	/// The lines in the file
 	EntryList Line;
-	/// The filename of this file, if any
-	agi::fs::path filename;
 
-	AssFile();
+	AssFile() { }
 	AssFile(const AssFile &from);
 	AssFile& operator=(AssFile from);
 	~AssFile();
 
-	/// Does the file have unsaved changes?
-	bool IsModified() const { return commitId != savedCommitId; };
-
 	/// @brief Load default file
 	/// @param defline Add a blank line to the file
 	void LoadDefault(bool defline=true);
@@ -103,30 +85,6 @@ public:
 
 	void swap(AssFile &) throw();
 
-	/// @brief Load from a file
-	/// @param file File name
-	/// @param charset Character set of file or empty to autodetect
-	void Load(agi::fs::path const& file, std::string const& charset="");
-
-	/// @brief Save to a file
-	/// @param file Path to save to
-	/// @param setfilename Should the filename be changed to the passed path?
-	/// @param addToRecent Should the file be added to the MRU list?
-	/// @param encoding Encoding to use, or empty to let the writer decide (which usually means "App/Save Charset")
-	void Save(agi::fs::path const& file, bool setfilename=false, bool addToRecent=true, std::string const& encoding="");
-
-	/// @brief Autosave the file if there have been any chances since the last autosave
-	/// @return File name used or empty if no save was performed
-	agi::fs::path AutoSave();
-
-	/// @brief Save to a memory buffer. Used for subtitle providers which support it
-	/// @param[out] dst Destination vector
-	void SaveMemory(std::vector<char> &dst);
-	/// Add file name to the MRU list
-	void AddToRecent(agi::fs::path const& file) const;
-	/// Can the file be saved in its current format?
-	bool CanSave() const;
-
 	/// @brief Get the script resolution
 	/// @param[out] w Width
 	/// @param[in] h Height
@@ -169,8 +127,7 @@ public:
 	};
 
 	DEFINE_SIGNAL_ADDERS(AnnounceCommit, AddCommitListener)
-	DEFINE_SIGNAL_ADDERS(FileOpen, AddFileOpenListener)
-	DEFINE_SIGNAL_ADDERS(FileSave, AddFileSaveListener)
+	DEFINE_SIGNAL_ADDERS(PushState, AddUndoManager)
 
 	/// @brief Flag the file as modified and push a copy onto the undo stack
 	/// @param desc        Undo description
@@ -179,21 +136,6 @@ public:
 	/// @param single_line Line which was changed, if only one line was
 	/// @return Unique identifier for the new undo group
 	int Commit(wxString const& desc, int type, int commitId = -1, AssEntry *single_line = 0);
-	/// @brief Undo the last set of changes to the file
-	void Undo();
-	/// @brief Redo the last undone changes
-	void Redo();
-	/// Check if undo stack is empty
-	bool IsUndoStackEmpty() const { return UndoStack.size() <= 1; };
-	/// Check if redo stack is empty
-	bool IsRedoStackEmpty() const { return RedoStack.empty(); };
-	/// Get the description of the first undoable change
-	wxString GetUndoDescription() const;
-	/// Get the description of the first redoable change
-	wxString GetRedoDescription() const;
-
-	/// Current script file. It is "above" the stack.
-	static AssFile *top;
 
 	/// Comparison function for use when sorting
 	typedef bool (*CompFunc)(const AssDialogue* lft, const AssDialogue* rgt);
diff --git a/aegisub/src/audio_controller.cpp b/aegisub/src/audio_controller.cpp
index dc3427362d7d38a0a16f057fd9354244e95cfda7..5abd7fa3c60cac206081eaa1f74861070cc0cabb 100644
--- a/aegisub/src/audio_controller.cpp
+++ b/aegisub/src/audio_controller.cpp
@@ -46,6 +46,7 @@
 #include "pen.h"
 #include "options.h"
 #include "selection_controller.h"
+#include "subs_controller.h"
 #include "utils.h"
 #include "video_context.h"
 
@@ -56,7 +57,7 @@
 
 AudioController::AudioController(agi::Context *context)
 : context(context)
-, subtitle_save_slot(context->ass->AddFileSaveListener(&AudioController::OnSubtitlesSave, this))
+, subtitle_save_slot(context->subsController->AddFileSaveListener(&AudioController::OnSubtitlesSave, this))
 , player(0)
 , provider(0)
 , playback_mode(PM_NotPlaying)
@@ -238,9 +239,7 @@ void AudioController::SetTimingController(AudioTimingController *new_controller)
 void AudioController::OnTimingControllerUpdatedPrimaryRange()
 {
 	if (playback_mode == PM_PrimaryRange)
-	{
 		player->SetEndPosition(SamplesFromMilliseconds(timing_controller->GetPrimaryPlaybackRange().end()));
-	}
 }
 
 void AudioController::OnSubtitlesSave()
diff --git a/aegisub/src/auto4_base.cpp b/aegisub/src/auto4_base.cpp
index 455f2576a4d35ff479402b3eb81ff753dc1d0c80..b479bba81423ab30cdd0ecd8e8130d75bfd99069 100644
--- a/aegisub/src/auto4_base.cpp
+++ b/aegisub/src/auto4_base.cpp
@@ -44,6 +44,7 @@
 #include "options.h"
 #include "standard_paths.h"
 #include "string_codec.h"
+#include "subs_controller.h"
 #include "subtitle_format.h"
 #include "utils.h"
 
@@ -379,8 +380,8 @@ namespace Automation4 {
 	LocalScriptManager::LocalScriptManager(agi::Context *c)
 	: context(c)
 	{
-		slots.push_back(c->ass->AddFileSaveListener(&LocalScriptManager::OnSubtitlesSave, this));
-		slots.push_back(c->ass->AddFileOpenListener(&LocalScriptManager::Reload, this));
+		slots.push_back(c->subsController->AddFileSaveListener(&LocalScriptManager::OnSubtitlesSave, this));
+		slots.push_back(c->subsController->AddFileOpenListener(&LocalScriptManager::Reload, this));
 	}
 
 	void LocalScriptManager::Reload()
@@ -403,7 +404,7 @@ namespace Automation4 {
 
 			agi::fs::path basepath;
 			if (first_char == '~') {
-				basepath = context->ass->filename.parent_path();
+				basepath = context->subsController->Filename().parent_path();
 			} else if (first_char == '$') {
 				basepath = autobasefn;
 			} else if (first_char == '/') {
diff --git a/aegisub/src/auto4_lua.cpp b/aegisub/src/auto4_lua.cpp
index 56695752493449d891f2306e2887d63823b8742d..d19e2dbc5945e866e6c1cf396d95b2f4fbc38fb4 100644
--- a/aegisub/src/auto4_lua.cpp
+++ b/aegisub/src/auto4_lua.cpp
@@ -47,6 +47,7 @@
 #include "include/aegisub/context.h"
 #include "main.h"
 #include "selection_controller.h"
+#include "subs_controller.h"
 #include "standard_paths.h"
 #include "video_context.h"
 #include "utils.h"
@@ -159,8 +160,8 @@ namespace {
 	int get_file_name(lua_State *L)
 	{
 		const agi::Context *c = get_context(L);
-		if (c && !c->ass->filename.empty())
-			push_value(L, c->ass->filename.filename());
+		if (c && !c->subsController->Filename().empty())
+			push_value(L, c->subsController->Filename().filename());
 		else
 			lua_pushnil(L);
 		return 1;
diff --git a/aegisub/src/base_grid.cpp b/aegisub/src/base_grid.cpp
index ad159ea9942fd476ca3e73c9d188d202b756f899..d62e4e3da95c50812010bbd0123bacca9a59f23f 100644
--- a/aegisub/src/base_grid.cpp
+++ b/aegisub/src/base_grid.cpp
@@ -58,6 +58,7 @@
 #include "frame_main.h"
 #include "options.h"
 #include "utils.h"
+#include "subs_controller.h"
 #include "video_context.h"
 #include "video_slider.h"
 
@@ -117,8 +118,8 @@ BaseGrid::BaseGrid(wxWindow* parent, agi::Context *context, const wxSize& size,
 	OPT_SUB("Subtitle/Grid/Font Size", &BaseGrid::UpdateStyle, this);
 	OPT_SUB("Subtitle/Grid/Highlight Subtitles in Frame", &BaseGrid::OnHighlightVisibleChange, this);
 	context->ass->AddCommitListener(&BaseGrid::OnSubtitlesCommit, this);
-	context->ass->AddFileOpenListener(&BaseGrid::OnSubtitlesOpen, this);
-	context->ass->AddFileSaveListener(&BaseGrid::OnSubtitlesSave, this);
+	context->subsController->AddFileOpenListener(&BaseGrid::OnSubtitlesOpen, this);
+	context->subsController->AddFileSaveListener(&BaseGrid::OnSubtitlesSave, this);
 
 	OPT_SUB("Colour/Subtitle Grid/Active Border", &BaseGrid::UpdateStyle, this);
 	OPT_SUB("Colour/Subtitle Grid/Background/Background", &BaseGrid::UpdateStyle, this);
diff --git a/aegisub/src/command/edit.cpp b/aegisub/src/command/edit.cpp
index c5872dec1c56055f324224c78485b7e29b3118e4..7fa6c114e41371973493cd22b59262670488a057 100644
--- a/aegisub/src/command/edit.cpp
+++ b/aegisub/src/command/edit.cpp
@@ -50,6 +50,7 @@
 #include "../initial_line_state.h"
 #include "../options.h"
 #include "../search_replace_engine.h"
+#include "../subs_controller.h"
 #include "../subs_edit_ctrl.h"
 #include "../subs_grid.h"
 #include "../text_selection_controller.h"
@@ -307,7 +308,7 @@ void show_color_picker(const agi::Context *c, agi::Color (AssStyle::*field), con
 	commit_text(c, _("set color"), -1, -1, &commit_id);
 
 	if (!ok) {
-		c->ass->Undo();
+		c->subsController->Undo();
 		c->textSelectionController->SetSelection(initial_sel_start, initial_sel_end);
 	}
 }
@@ -876,22 +877,22 @@ struct edit_redo : public Command {
 	CMD_TYPE(COMMAND_VALIDATE | COMMAND_DYNAMIC_NAME)
 
 	wxString StrMenu(const agi::Context *c) const {
-		return c->ass->IsRedoStackEmpty() ?
+		return c->subsController->IsRedoStackEmpty() ?
 			_("Nothing to &redo") :
-		wxString::Format(_("&Redo %s"), c->ass->GetRedoDescription());
+		wxString::Format(_("&Redo %s"), c->subsController->GetRedoDescription());
 	}
 	wxString StrDisplay(const agi::Context *c) const {
-		return c->ass->IsRedoStackEmpty() ?
+		return c->subsController->IsRedoStackEmpty() ?
 			_("Nothing to redo") :
-		wxString::Format(_("Redo %s"), c->ass->GetRedoDescription());
+		wxString::Format(_("Redo %s"), c->subsController->GetRedoDescription());
 	}
 
 	bool Validate(const agi::Context *c) {
-		return !c->ass->IsRedoStackEmpty();
+		return !c->subsController->IsRedoStackEmpty();
 	}
 
 	void operator()(agi::Context *c) {
-		c->ass->Redo();
+		c->subsController->Redo();
 	}
 };
 
@@ -902,22 +903,22 @@ struct edit_undo : public Command {
 	CMD_TYPE(COMMAND_VALIDATE | COMMAND_DYNAMIC_NAME)
 
 	wxString StrMenu(const agi::Context *c) const {
-		return c->ass->IsUndoStackEmpty() ?
+		return c->subsController->IsUndoStackEmpty() ?
 			_("Nothing to &undo") :
-			wxString::Format(_("&Undo %s"), c->ass->GetUndoDescription());
+			wxString::Format(_("&Undo %s"), c->subsController->GetUndoDescription());
 	}
 	wxString StrDisplay(const agi::Context *c) const {
-		return c->ass->IsUndoStackEmpty() ?
+		return c->subsController->IsUndoStackEmpty() ?
 			_("Nothing to undo") :
-			wxString::Format(_("Undo %s"), c->ass->GetUndoDescription());
+			wxString::Format(_("Undo %s"), c->subsController->GetUndoDescription());
 	}
 
 	bool Validate(const agi::Context *c) {
-		return !c->ass->IsUndoStackEmpty();
+		return !c->subsController->IsUndoStackEmpty();
 	}
 
 	void operator()(agi::Context *c) {
-		c->ass->Undo();
+		c->subsController->Undo();
 	}
 };
 
diff --git a/aegisub/src/command/recent.cpp b/aegisub/src/command/recent.cpp
index b448e4ea396665a2acde14680d98b0fbad79f431..c641e4c28e108e01ef7e1ec11faa6e9dafa1aaa9 100644
--- a/aegisub/src/command/recent.cpp
+++ b/aegisub/src/command/recent.cpp
@@ -38,10 +38,10 @@
 
 #include "../audio_controller.h"
 #include "../compat.h"
-#include "../frame_main.h"
 #include "../include/aegisub/context.h"
 #include "../main.h"
 #include "../options.h"
+#include "../subs_controller.h"
 #include "../video_context.h"
 
 #include <wx/event.h>
@@ -93,7 +93,7 @@ struct recent_subtitle_entry : public Command {
 	STR_HELP("Open recent subtitles")
 
 	void operator()(agi::Context *c, int id) {
-		wxGetApp().frame->LoadSubtitles(config::mru->GetEntry("Subtitle", id));
+		c->subsController->Load(config::mru->GetEntry("Subtitle", id));
 	}
 };
 
diff --git a/aegisub/src/command/subtitle.cpp b/aegisub/src/command/subtitle.cpp
index 6381dfbcf36a96d6b4e89f1d4a8a9a114414af19..e8627f47d5bc811e57fc81a654a871af7648af32 100644
--- a/aegisub/src/command/subtitle.cpp
+++ b/aegisub/src/command/subtitle.cpp
@@ -47,12 +47,12 @@
 #include "../dialog_properties.h"
 #include "../dialog_search_replace.h"
 #include "../dialog_spellchecker.h"
-#include "../frame_main.h"
 #include "../include/aegisub/context.h"
 #include "../main.h"
 #include "../options.h"
 #include "../search_replace_engine.h"
 #include "../selection_controller.h"
+#include "../subs_controller.h"
 #include "../subtitle_format.h"
 #include "../utils.h"
 #include "../video_context.h"
@@ -246,8 +246,8 @@ struct subtitle_new : public Command {
 	STR_HELP("New subtitles")
 
 	void operator()(agi::Context *c) {
-		if (wxGetApp().frame->TryToCloseSubs() != wxCANCEL)
-			c->ass->LoadDefault();
+		if (c->subsController->TryToClose() != wxCANCEL)
+			c->subsController->Close();
 	}
 };
 
@@ -262,7 +262,7 @@ struct subtitle_open : public Command {
 	void operator()(agi::Context *c) {
 		auto filename = OpenFileSelector(_("Open subtitles file"), "Path/Last/Subtitles", "","", SubtitleFormat::GetWildcards(0), c->parent);
 		if (!filename.empty())
-			wxGetApp().frame->LoadSubtitles(filename);
+			c->subsController->Load(filename);
 	}
 };
 
@@ -275,7 +275,7 @@ struct subtitle_open_autosave : public Command {
 	void operator()(agi::Context *c) {
 		DialogAutosave dialog(c->parent);
 		if (dialog.ShowModal() == wxID_OK)
-			wxGetApp().frame->LoadSubtitles(dialog.ChosenFile());
+			c->subsController->Load(dialog.ChosenFile());
 	}
 };
 
@@ -293,7 +293,7 @@ struct subtitle_open_charset : public Command {
 		wxString charset = wxGetSingleChoice(_("Choose charset code:"), _("Charset"), agi::charset::GetEncodingsList<wxArrayString>(), c->parent, -1, -1, true, 250, 200);
 		if (charset.empty()) return;
 
-		wxGetApp().frame->LoadSubtitles(filename, from_wx(charset));
+		c->subsController->Load(filename, from_wx(charset));
 	}
 };
 
@@ -306,7 +306,7 @@ struct subtitle_open_video : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	void operator()(agi::Context *c) {
-		wxGetApp().frame->LoadSubtitles(c->videoController->GetVideoName(), "binary");
+		c->subsController->Load(c->videoController->GetVideoName(), "binary");
 	}
 
 	bool Validate(const agi::Context *c) {
@@ -332,13 +332,13 @@ static void save_subtitles(agi::Context *c, agi::fs::path filename) {
 	if (filename.empty()) {
 		c->videoController->Stop();
 		filename = SaveFileSelector(_("Save subtitles file"), "Path/Last/Subtitles",
-			c->ass->filename.stem().string() + ".ass", "ass",
+			c->subsController->Filename().stem().string() + ".ass", "ass",
 			"Advanced Substation Alpha (*.ass)|*.ass", c->parent);
 		if (filename.empty()) return;
 	}
 
 	try {
-		c->ass->Save(filename, true, true);
+		c->subsController->Save(filename);
 	}
 	catch (const agi::Exception& err) {
 		wxMessageBox(to_wx(err.GetMessage()), "Error", wxOK | wxICON_ERROR | wxCENTER, c->parent);
@@ -360,11 +360,11 @@ struct subtitle_save : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	void operator()(agi::Context *c) {
-		save_subtitles(c, c->ass->CanSave() ? c->ass->filename : "");
+		save_subtitles(c, c->subsController->CanSave() ? c->subsController->Filename() : "");
 	}
 
 	bool Validate(const agi::Context *c) {
-		return c->ass->IsModified();
+		return c->subsController->IsModified();
 	}
 };
 
diff --git a/aegisub/src/dialog_export.cpp b/aegisub/src/dialog_export.cpp
index 14c410330ed3292e457a627689f774a42a955133..71cfda82309b77d3c3a1f7fd6a0fbf7d1b26cbe7 100644
--- a/aegisub/src/dialog_export.cpp
+++ b/aegisub/src/dialog_export.cpp
@@ -48,6 +48,7 @@
 #include <libaegisub/charset_conv.h>
 
 #include <algorithm>
+#include <boost/filesystem/path.hpp>
 #include <boost/tokenizer.hpp>
 
 #include <wx/button.h>
diff --git a/aegisub/src/dialog_fonts_collector.cpp b/aegisub/src/dialog_fonts_collector.cpp
index 13db7c8eb4bd5d67aa3f93e3975c3fd35d7e78a9..b21fd15f3a4cef143ff6efad63e2d9fe1b7ce763 100644
--- a/aegisub/src/dialog_fonts_collector.cpp
+++ b/aegisub/src/dialog_fonts_collector.cpp
@@ -35,6 +35,7 @@
 #include "scintilla_text_ctrl.h"
 #include "selection_controller.h"
 #include "standard_paths.h"
+#include "subs_controller.h"
 #include "utils.h"
 
 #include <libaegisub/dispatch.h>
diff --git a/aegisub/src/dialog_shift_times.cpp b/aegisub/src/dialog_shift_times.cpp
index 6b8737789ac3a938bf8a64ff9ddd25f42f4ba26f..848ad958ecb66da8e2140ad0adc271cd1a44a045 100644
--- a/aegisub/src/dialog_shift_times.cpp
+++ b/aegisub/src/dialog_shift_times.cpp
@@ -31,6 +31,7 @@
 #include "help_button.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "subs_controller.h"
 #include "standard_paths.h"
 #include "timeedit_ctrl.h"
 #include "video_context.h"
@@ -278,7 +279,7 @@ void DialogShiftTimes::OnHistoryClick(wxCommandEvent &evt) {
 
 void DialogShiftTimes::SaveHistory(json::Array const& shifted_blocks) {
 	json::Object new_entry;
-	new_entry["filename"] = context->ass->filename.filename().string();
+	new_entry["filename"] = context->subsController->Filename().filename().string();
 	new_entry["is by time"] = shift_by_time->GetValue();
 	new_entry["is backward"] = shift_backward->GetValue();
 	new_entry["amount"] = from_wx(shift_by_time->GetValue() ? shift_time->GetValue() : shift_frames->GetValue());
diff --git a/aegisub/src/dialog_style_manager.cpp b/aegisub/src/dialog_style_manager.cpp
index dca7f85872cf23ff8098969006572d5e65a41f70..27940848edb474613d494800e6f5d945797c609b 100644
--- a/aegisub/src/dialog_style_manager.cpp
+++ b/aegisub/src/dialog_style_manager.cpp
@@ -47,6 +47,7 @@
 #include "options.h"
 #include "persist_location.h"
 #include "selection_controller.h"
+#include "subs_controller.h"
 #include "standard_paths.h"
 #include "subtitle_format.h"
 #include "utils.h"
@@ -565,7 +566,11 @@ void DialogStyleManager::OnCurrentImport() {
 
 	AssFile temp;
 	try {
-		temp.Load(filename);
+		auto reader = SubtitleFormat::GetReader(filename);
+		if (!reader)
+			wxMessageBox("Unsupported subtitle format", "Error", wxOK | wxICON_ERROR | wxCENTER, this);
+		else
+			reader->ReadFile(&temp, filename);
 	}
 	catch (agi::Exception const& err) {
 		wxMessageBox(to_wx(err.GetChainedMessage()), "Error", wxOK | wxICON_ERROR | wxCENTER, this);
diff --git a/aegisub/src/frame_main.cpp b/aegisub/src/frame_main.cpp
index bc8fbb939a396dc6fcc79b67b5f0f50e9516c304..849ce44b545b6ccde779c42a3c1eb218cacd0614 100644
--- a/aegisub/src/frame_main.cpp
+++ b/aegisub/src/frame_main.cpp
@@ -60,10 +60,10 @@
 #include "main.h"
 #include "options.h"
 #include "search_replace_engine.h"
+#include "subs_controller.h"
 #include "subs_edit_box.h"
 #include "subs_edit_ctrl.h"
 #include "subs_grid.h"
-#include "text_file_reader.h"
 #include "utils.h"
 #include "version.h"
 #include "video_box.h"
@@ -213,18 +213,20 @@ FrameMain::FrameMain (wxArrayString args)
 
 	StartupLog("Initializing context models");
 	memset(context.get(), 0, sizeof(*context));
-	AssFile::top = context->ass = new AssFile;
-	context->ass->AddCommitListener(&FrameMain::UpdateTitle, this);
-	context->ass->AddFileOpenListener(&FrameMain::OnSubtitlesOpen, this);
-	context->ass->AddFileSaveListener(&FrameMain::UpdateTitle, this);
-
-	context->local_scripts = new Automation4::LocalScriptManager(context.get());
+	context->ass = new AssFile;
 
 	StartupLog("Initializing context controls");
+	context->subsController = new SubsController(context.get());
+	context->ass->AddCommitListener(&FrameMain::UpdateTitle, this);
+	context->subsController->AddFileOpenListener(&FrameMain::OnSubtitlesOpen, this);
+	context->subsController->AddFileSaveListener(&FrameMain::UpdateTitle, this);
+
 	context->audioController = new AudioController(context.get());
 	context->audioController->AddAudioOpenListener(&FrameMain::OnAudioOpen, this);
 	context->audioController->AddAudioCloseListener(&FrameMain::OnAudioClose, this);
 
+	context->local_scripts = new Automation4::LocalScriptManager(context.get());
+
 	// Initialized later due to that the selection controller is currently the subtitles grid
 	context->selectionController = 0;
 
@@ -280,7 +282,7 @@ FrameMain::FrameMain (wxArrayString args)
 	SetDropTarget(new AegisubFileDropTarget(this));
 
 	StartupLog("Load default file");
-	context->ass->LoadDefault();
+	context->subsController->Close();
 
 	StartupLog("Display main window");
 	AddFullScreenButton(this);
@@ -408,75 +410,6 @@ void FrameMain::InitContents() {
 	StartupLog("Leaving InitContents");
 }
 
-void FrameMain::LoadSubtitles(agi::fs::path const& filename, std::string const& charset) {
-	if (TryToCloseSubs() == wxCANCEL) return;
-
-	try {
-		// Make sure that file isn't actually a timecode file
-		try {
-			TextFileReader testSubs(filename, charset);
-			std::string cur = testSubs.ReadLineFromFile();
-			if (boost::starts_with(cur, "# timecode")) {
-				context->videoController->LoadTimecodes(filename);
-				return;
-			}
-		}
-		catch (...) {
-			// if trying to load the file as timecodes fails it's fairly
-			// safe to assume that it is in fact not a timecode file
-		}
-
-		context->ass->Load(filename, charset);
-
-		StandardPaths::SetPathValue("?script", filename);
-		config::mru->Add("Subtitle", filename);
-		OPT_SET("Path/Last/Subtitles")->SetString(filename.parent_path().string());
-
-		// Save backup of file
-		if (context->ass->CanSave() && OPT_GET("App/Auto/Backup")->GetBool()) {
-			if (agi::fs::FileExists(filename)) {
-				auto path_str = OPT_GET("Path/Auto/Backup")->GetString();
-				agi::fs::path path;
-				if (path_str.empty())
-					path = filename.parent_path();
-				else
-					path = StandardPaths::DecodePath(path_str);
-				agi::fs::CreateDirectory(path);
-				agi::fs::Copy(filename, path/(filename.stem().string() + ".ORIGINAL" + filename.extension().string()));
-			}
-		}
-	}
-	catch (agi::fs::FileNotFound const&) {
-		wxMessageBox(filename.wstring() + " not found.", "Error", wxOK | wxICON_ERROR | wxCENTER, this);
-		config::mru->Remove("Subtitle", filename);
-		return;
-	}
-	catch (agi::Exception const& err) {
-		wxMessageBox(to_wx(err.GetChainedMessage()), "Error", wxOK | wxICON_ERROR | wxCENTER, this);
-	}
-	catch (...) {
-		wxMessageBox("Unknown error", "Error", wxOK | wxICON_ERROR | wxCENTER, this);
-		return;
-	}
-}
-
-int FrameMain::TryToCloseSubs(bool enableCancel) {
-	if (context->ass->IsModified()) {
-		int flags = wxYES_NO;
-		if (enableCancel) flags |= wxCANCEL;
-		int result = wxMessageBox(wxString::Format(_("Do you want to save changes to %s?"), GetScriptFileName()), _("Unsaved changes"), flags, this);
-		if (result == wxYES) {
-			cmd::call("subtitle/save", context.get());
-			// If it fails saving, return cancel anyway
-			return context->ass->IsModified() ? wxCANCEL : wxYES;
-		}
-		return result;
-	}
-	else {
-		return wxYES;
-	}
-}
-
 void FrameMain::SetDisplayMode(int video, int audio) {
 	if (!IsShownOnScreen()) return;
 
@@ -512,8 +445,8 @@ void FrameMain::SetDisplayMode(int video, int audio) {
 
 void FrameMain::UpdateTitle() {
 	wxString newTitle;
-	if (context->ass->IsModified()) newTitle << "* ";
-	newTitle << GetScriptFileName();
+	if (context->subsController->IsModified()) newTitle << "* ";
+	newTitle << context->subsController->Filename().filename().wstring();
 
 #ifndef __WXMAC__
 	newTitle << " - Aegisub " << GetAegisubLongVersionString();
@@ -521,7 +454,7 @@ void FrameMain::UpdateTitle() {
 
 #if defined(__WXMAC__) && !defined(__LP64__)
 	// On Mac, set the mark in the close button
-	OSXSetModified(context->ass->IsModified());
+	OSXSetModified(context->subsController->IsModified());
 #endif
 
 	if (GetTitle() != newTitle) SetTitle(newTitle);
@@ -596,7 +529,7 @@ bool FrameMain::LoadList(wxArrayString list) {
 
 	// Load files
 	if (subs.size())
-		LoadSubtitles(subs);
+		context->subsController->Load(subs);
 
 	if (blockVideoLoad) {
 		blockVideoLoad = false;
@@ -634,21 +567,18 @@ BEGIN_EVENT_TABLE(FrameMain, wxFrame)
 	EVT_MOUSEWHEEL(FrameMain::OnMouseWheel)
 END_EVENT_TABLE()
 
-void FrameMain::OnCloseWindow (wxCloseEvent &event) {
-	// Stop audio and video
+void FrameMain::OnCloseWindow(wxCloseEvent &event) {
 	context->videoController->Stop();
 	context->audioController->Stop();
 
 	// Ask user if he wants to save first
-	bool canVeto = event.CanVeto();
-	int result = TryToCloseSubs(canVeto);
-	if (canVeto && result == wxCANCEL) {
+	if (context->subsController->TryToClose(event.CanVeto()) == wxCANCEL) {
 		event.Veto();
 		return;
 	}
 
 	delete context->dialog;
-	context->dialog = 0;
+	context->dialog = nullptr;
 
 	// Store maximization state
 	OPT_SET("App/Maximized")->SetBool(IsMaximized());
@@ -657,7 +587,7 @@ void FrameMain::OnCloseWindow (wxCloseEvent &event) {
 }
 
 void FrameMain::OnAutoSave(wxTimerEvent &) try {
-	auto fn = context->ass->AutoSave();
+	auto fn = context->subsController->AutoSave();
 	if (!fn.empty())
 		StatusTimeout(wxString::Format(_("File backup saved as \"%s\"."), fn.wstring()));
 }
@@ -766,18 +696,3 @@ void FrameMain::OnKeyDown(wxKeyEvent &event) {
 void FrameMain::OnMouseWheel(wxMouseEvent &evt) {
 	ForwardMouseWheelEvent(this, evt);
 }
-
-wxString FrameMain::GetScriptFileName() const {
-	if (context->ass->filename.empty()) {
-		// Apple HIG says "untitled" should not be capitalised
-		// and the window is a document window, it shouldn't contain the app name
-		// (The app name is already present in the menu bar)
-#ifndef __WXMAC__
-		return _("Untitled");
-#else
-		return _("untitled");
-#endif
-	}
-	else
-		return context->ass->filename.filename().wstring();
-}
diff --git a/aegisub/src/frame_main.h b/aegisub/src/frame_main.h
index 5e2a7daa7d3f4b9ea7f0a02b7a0cfa90c3367673..58f38bf50c5639843dc05c512865b94611c82001 100644
--- a/aegisub/src/frame_main.h
+++ b/aegisub/src/frame_main.h
@@ -45,6 +45,7 @@
 #include <wx/sizer.h>
 #include <wx/timer.h>
 
+class AegisubApp;
 class AegisubFileDropTarget;
 class AssFile;
 class AudioBox;
@@ -62,6 +63,7 @@ class VideoZoomSlider;
 namespace agi { struct Context; class OptionValue; }
 
 class FrameMain: public wxFrame {
+	friend class AegisubApp;
 	friend class AegisubFileDropTarget;
 
 	std::unique_ptr<agi::Context> context;
@@ -88,7 +90,6 @@ class FrameMain: public wxFrame {
 	void OnFilesDropped(wxThreadEvent &evt);
 	bool LoadList(wxArrayString list);
 	void UpdateTitle();
-	wxString GetScriptFileName() const;
 
 	void OnKeyDown(wxKeyEvent &event);
 	void OnMouseWheel(wxMouseEvent &evt);
@@ -134,11 +135,5 @@ public:
 	bool IsVideoShown() const { return showVideo; }
 	bool IsAudioShown() const { return showAudio; }
 
-	/// Close the currently open subs, asking the user if they want to save if there are unsaved changes
-	/// @param enableCancel Should the user be able to cancel the close?
-	int TryToCloseSubs(bool enableCancel=true);
-
-	void LoadSubtitles(agi::fs::path const& filename, std::string const& charset="");
-
 	DECLARE_EVENT_TABLE()
 };
diff --git a/aegisub/src/include/aegisub/context.h b/aegisub/src/include/aegisub/context.h
index 9ed70bf48bdcf99891c9508212737bcab5f3085f..c29035ee843d0c7efd487e1169dcafc66dcb7973 100644
--- a/aegisub/src/include/aegisub/context.h
+++ b/aegisub/src/include/aegisub/context.h
@@ -7,6 +7,7 @@ class DialogManager;
 class SearchReplaceEngine;
 class InitialLineState;
 template<class T> class SelectionController;
+class SubsController;
 class SubsTextEditCtrl;
 class SubtitlesGrid;
 class TextSelectionController;
@@ -26,6 +27,7 @@ struct Context {
 	// Controllers
 	AudioController *audioController;
 	SelectionController<AssDialogue *> *selectionController;
+	SubsController *subsController;
 	TextSelectionController *textSelectionController;
 	VideoContext *videoController;
 
diff --git a/aegisub/src/main.cpp b/aegisub/src/main.cpp
index 0da644497005297cb8422f00f84438129a883cfd..537f6c47e296181476b0a1329e0d3eb8a2e76854 100644
--- a/aegisub/src/main.cpp
+++ b/aegisub/src/main.cpp
@@ -45,11 +45,13 @@
 #include "export_fixstyle.h"
 #include "export_framerate.h"
 #include "frame_main.h"
+#include "include/aegisub/context.h"
 #include "main.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
 #include "plugin_manager.h"
 #include "standard_paths.h"
+#include "subs_controller.h"
 #include "subtitle_format.h"
 #include "version.h"
 #include "video_context.h"
@@ -364,15 +366,15 @@ StackWalker::~StackWalker() {
 /// Message displayed when an exception has occurred.
 const static wxString exception_message = _("Oops, Aegisub has crashed!\n\nAn attempt has been made to save a copy of your file to:\n\n%s\n\nAegisub will now close.");
 
-static void UnhandledExeception(bool stackWalk) {
+static void UnhandledExeception(bool stackWalk, agi::Context *c) {
 #if (!defined(_DEBUG) || defined(WITH_EXCEPTIONS)) && (wxUSE_ON_FATAL_EXCEPTION+0)
-	if (AssFile::top) {
+	if (c->ass && c->subsController) {
 		auto path = StandardPaths::DecodePath("?user/recovered");
 		agi::fs::CreateDirectory(path);
 
-		auto filename = AssFile::top->filename.empty() ? "untitled" : AssFile::top->filename.stem().string();
+		auto filename = c->subsController->Filename().stem();
 		path /= str(boost::format("%s.%s.ass") % filename % agi::util::strftime("%Y-%m-%d-%H-%M-%S"));
-		AssFile::top->Save(path, false, false);
+		c->subsController->Save(path);
 
 #if wxUSE_STACKWALKER == 1
 		if (stackWalk) {
@@ -397,11 +399,11 @@ static void UnhandledExeception(bool stackWalk) {
 }
 
 void AegisubApp::OnUnhandledException() {
-	UnhandledExeception(false);
+	UnhandledExeception(false, frame ? frame->context.get() : nullptr);
 }
 
 void AegisubApp::OnFatalException() {
-	UnhandledExeception(true);
+	UnhandledExeception(true, frame ? frame->context.get() : nullptr);
 }
 
 void AegisubApp::HandleEvent(wxEvtHandler *handler, wxEventFunction func, wxEvent& event) const {
@@ -456,9 +458,6 @@ int AegisubApp::OnRun() {
 }
 
 void AegisubApp::MacOpenFile(const wxString &filename) {
-	if (frame != nullptr && !filename.empty()) {
-		frame->LoadSubtitles(from_wx(filename));
-		wxFileName filepath(filename);
-		OPT_SET("Path/Last/Subtitles")->SetString(from_wx(filepath.GetPath()));
-	}
+	if (frame && !filename.empty())
+		frame->context->subsController->Load(agi::fs::path(filename));
 }
diff --git a/aegisub/src/subs_controller.cpp b/aegisub/src/subs_controller.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..edde055fd208c0d8b0cac609df5096c8d7b916f2
--- /dev/null
+++ b/aegisub/src/subs_controller.cpp
@@ -0,0 +1,311 @@
+// Copyright (c) 2013, Thomas Goyne <plorkyeran@aegisub.org>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+// Aegisub Project http://www.aegisub.org/
+
+#include "config.h"
+
+#include "subs_controller.h"
+
+#include "ass_dialogue.h"
+#include "ass_file.h"
+#include "ass_style.h"
+#include "compat.h"
+#include "command/command.h"
+#include "include/aegisub/context.h"
+#include "options.h"
+#include "standard_paths.h"
+#include "subtitle_format.h"
+#include "text_file_reader.h"
+#include "video_context.h"
+
+#include <libaegisub/fs.h>
+#include <libaegisub/util.h>
+
+#include <boost/algorithm/string/predicate.hpp>
+#include <boost/format.hpp>
+#include <wx/msgdlg.h>
+
+struct SubsController::UndoInfo {
+	AssFile file;
+	wxString undo_description;
+	int commit_id;
+	UndoInfo(AssFile const& f, wxString const& d, int c) : file(f), undo_description(d), commit_id(c) { }
+};
+
+SubsController::SubsController(agi::Context *context)
+: context(context)
+, undo_connection(context->ass->AddUndoManager(&SubsController::OnCommit, this))
+, commit_id(0)
+, saved_commit_id(0)
+, autosaved_commit_id(0)
+{
+}
+
+void SubsController::Load(agi::fs::path const& filename, std::string const& charset) {
+	if (TryToClose() == wxCANCEL) return;
+
+	// Make sure that file isn't actually a timecode file
+	try {
+		TextFileReader testSubs(filename, charset);
+		std::string cur = testSubs.ReadLineFromFile();
+		if (boost::starts_with(cur, "# timecode")) {
+			context->videoController->LoadTimecodes(filename);
+			return;
+		}
+	}
+	catch (...) {
+		// if trying to load the file as timecodes fails it's fairly
+		// safe to assume that it is in fact not a timecode file
+	}
+
+	const SubtitleFormat *reader = SubtitleFormat::GetReader(filename);
+
+	try {
+		AssFile temp;
+		reader->ReadFile(&temp, filename, charset);
+
+		bool found_style = false;
+		bool found_dialogue = false;
+
+		// Check if the file has at least one style and at least one dialogue line
+		for (auto const& line : temp.Line) {
+			AssEntryGroup type = line.Group();
+			if (type == ENTRY_STYLE) found_style = true;
+			if (type == ENTRY_DIALOGUE) found_dialogue = true;
+			if (found_style && found_dialogue) break;
+		}
+
+		// And if it doesn't add defaults for each
+		if (!found_style)
+			temp.InsertLine(new AssStyle);
+		if (!found_dialogue)
+			temp.InsertLine(new AssDialogue);
+
+		context->ass->swap(temp);
+	}
+	catch (agi::UserCancelException const&) {
+		return;
+	}
+	catch (agi::fs::FileNotFound const&) {
+		wxMessageBox(filename.wstring() + " not found.", "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
+		config::mru->Remove("Subtitle", filename);
+		return;
+	}
+	catch (agi::Exception const& err) {
+		wxMessageBox(to_wx(err.GetChainedMessage()), "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
+		return;
+	}
+	catch (std::exception const& err) {
+		wxMessageBox(to_wx(err.what()), "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
+		return;
+	}
+	catch (...) {
+		wxMessageBox("Unknown error", "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
+		return;
+	}
+
+	SetFileName(filename);
+
+	// Push the initial state of the file onto the undo stack
+	undo_stack.clear();
+	redo_stack.clear();
+	autosaved_commit_id = saved_commit_id = commit_id + 1;
+	context->ass->Commit("", AssFile::COMMIT_NEW);
+
+	// Save backup of file
+	if (CanSave() && OPT_GET("App/Auto/Backup")->GetBool()) {
+		auto path_str = OPT_GET("Path/Auto/Backup")->GetString();
+		agi::fs::path path;
+		if (path_str.empty())
+			path = filename.parent_path();
+		else
+			path = StandardPaths::DecodePath(path_str);
+		agi::fs::CreateDirectory(path);
+		agi::fs::Copy(filename, path/(filename.stem().string() + ".ORIGINAL" + filename.extension().string()));
+	}
+
+	FileOpen(filename);
+}
+
+void SubsController::Save(agi::fs::path const& filename, std::string const& encoding) {
+	const SubtitleFormat *writer = SubtitleFormat::GetWriter(filename);
+	if (!writer)
+		throw "Unknown file type.";
+
+	int old_autosaved_commit_id = autosaved_commit_id, old_saved_commit_id = saved_commit_id;
+	try {
+		autosaved_commit_id = saved_commit_id = commit_id;
+
+		// Have to set these now for the sake of things that want to save paths
+		// relative to the script in the header
+		this->filename = filename;
+		StandardPaths::SetPathValue("?script", filename.parent_path());
+
+		FileSave();
+
+		writer->WriteFile(context->ass, filename, encoding);
+	}
+	catch (...) {
+		autosaved_commit_id = old_autosaved_commit_id;
+		saved_commit_id = old_saved_commit_id;
+		throw;
+	}
+
+	SetFileName(filename);
+}
+
+void SubsController::Close() {
+	undo_stack.clear();
+	redo_stack.clear();
+	autosaved_commit_id = saved_commit_id = commit_id + 1;
+	filename.clear();
+	context->ass->Line.clear();
+	context->ass->LoadDefault();
+	context->ass->Commit("", AssFile::COMMIT_NEW);
+}
+
+int SubsController::TryToClose(bool allow_cancel) const {
+	if (!IsModified())
+		return wxYES;
+
+	int flags = wxYES_NO;
+	if (allow_cancel)
+		flags |= wxCANCEL;
+	int result = wxMessageBox(wxString::Format(_("Do you want to save changes to %s?"), Filename().wstring()), _("Unsaved changes"), flags, context->parent);
+	if (result == wxYES) {
+		cmd::call("subtitle/save", context);
+		// If it fails saving, return cancel anyway
+		return IsModified() ? wxCANCEL : wxYES;
+	}
+	return result;
+}
+
+agi::fs::path SubsController::AutoSave() {
+	if (commit_id == autosaved_commit_id)
+		return "";
+
+	auto path = StandardPaths::DecodePath(OPT_GET("Path/Auto/Save")->GetString());
+	if (path.empty())
+		path = filename.parent_path();
+
+	agi::fs::CreateDirectory(path);
+
+	auto name = filename.filename();
+	if (name.empty())
+		name = "Untitled";
+
+	path /= str(boost::format("%s.%s.AUTOSAVE.ass") % name.string() % agi::util::strftime("%Y-%m-%d-%H-%M-%S"));
+
+	SubtitleFormat::GetWriter(path)->WriteFile(context->ass, path);
+	autosaved_commit_id = commit_id;
+
+	return path;
+}
+
+bool SubsController::CanSave() const {
+	try {
+		return SubtitleFormat::GetWriter(filename)->CanSave(context->ass);
+	}
+	catch (...) {
+		return false;
+	}
+}
+
+void SubsController::SetFileName(agi::fs::path const& path) {
+	filename = path;
+	StandardPaths::SetPathValue("?script", path.parent_path());
+	config::mru->Add("Subtitle", path);
+	OPT_SET("Path/Last/Subtitles")->SetString(filename.parent_path().string());
+}
+
+void SubsController::OnCommit(AssFileCommit c) {
+	if (c.message.empty() && !undo_stack.empty()) return;
+
+	++commit_id;
+	// Allow coalescing only if it's the last change and the file has not been
+	// saved since the last change
+	if (commit_id == *c.commit_id+1 && redo_stack.empty() && saved_commit_id+1 != commit_id && autosaved_commit_id+1 != commit_id) {
+		// If only one line changed just modify it instead of copying the file
+		if (c.single_line) {
+			entryIter this_it = context->ass->Line.begin(), undo_it = undo_stack.back().file.Line.begin();
+			while (&*this_it != c.single_line) {
+				++this_it;
+				++undo_it;
+			}
+			undo_stack.back().file.Line.insert(undo_it, *c.single_line->Clone());
+			delete &*undo_it;
+		}
+		else
+			undo_stack.back().file = *context->ass;
+
+		*c.commit_id = commit_id;
+		return;
+	}
+
+	redo_stack.clear();
+
+	undo_stack.emplace_back(*context->ass, c.message, commit_id);
+
+	int depth = std::max<int>(OPT_GET("Limits/Undo Levels")->GetInt(), 2);
+	while ((int)undo_stack.size() > depth)
+		undo_stack.pop_front();
+
+	if (undo_stack.size() > 1 && OPT_GET("App/Auto/Save on Every Change")->GetBool() && !filename.empty() && CanSave())
+		Save(filename);
+
+	*c.commit_id = commit_id;
+}
+
+void SubsController::Undo() {
+	if (undo_stack.size() <= 1) return;
+
+	redo_stack.emplace_back(AssFile(), undo_stack.back().undo_description, commit_id);
+	context->ass->swap(redo_stack.back().file);
+	undo_stack.pop_back();
+	*context->ass = undo_stack.back().file;
+	commit_id = undo_stack.back().commit_id;
+
+	context->ass->Commit("", AssFile::COMMIT_NEW);
+}
+
+void SubsController::Redo() {
+	if (redo_stack.empty()) return;
+
+	context->ass->swap(redo_stack.back().file);
+	commit_id = redo_stack.back().commit_id;
+	undo_stack.emplace_back(*context->ass, redo_stack.back().undo_description, commit_id);
+	redo_stack.pop_back();
+
+	context->ass->Commit("", AssFile::COMMIT_NEW);
+}
+
+wxString SubsController::GetUndoDescription() const {
+	return IsUndoStackEmpty() ? "" : undo_stack.back().undo_description;
+}
+
+wxString SubsController::GetRedoDescription() const {
+	return IsRedoStackEmpty() ? "" : redo_stack.back().undo_description;
+}
+
+agi::fs::path SubsController::Filename() const {
+	if (!filename.empty()) return filename;
+
+	// Apple HIG says "untitled" should not be capitalised
+#ifndef __WXMAC__
+	return _("Untitled").wx_str();
+#else
+	return _("untitled").wx_str();
+#endif
+}
diff --git a/aegisub/src/subs_controller.h b/aegisub/src/subs_controller.h
new file mode 100644
index 0000000000000000000000000000000000000000..7a6417426cf15c82882075768779fe174d2139a7
--- /dev/null
+++ b/aegisub/src/subs_controller.h
@@ -0,0 +1,110 @@
+// Copyright (c) 2013, Thomas Goyne <plorkyeran@aegisub.org>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+// Aegisub Project http://www.aegisub.org/
+
+#include <libaegisub/fs_fwd.h>
+#include <libaegisub/signal.h>
+
+#include <boost/container/list.hpp>
+#include <boost/filesystem/path.hpp>
+#include <set>
+
+class AssEntry;
+class AssFile;
+struct AssFileCommit;
+
+namespace agi { struct Context; }
+
+class SubsController {
+	agi::Context *context;
+	agi::signal::Connection undo_connection;
+
+	struct UndoInfo;
+	boost::container::list<UndoInfo> undo_stack;
+	boost::container::list<UndoInfo> redo_stack;
+
+	/// Revision counter for undo coalescing and modified state tracking
+	int commit_id;
+	/// Last saved version of this file
+	int saved_commit_id;
+	/// Last autosaved version of this file
+	int autosaved_commit_id;
+
+	/// A new file has been opened (filename)
+	agi::signal::Signal<agi::fs::path> FileOpen;
+	/// The file is about to be saved
+	/// This signal is intended for adding metadata such as video filename,
+	/// frame number, etc. Ideally this would all be done immediately rather
+	/// than waiting for a save, but that causes (more) issues with undo
+	agi::signal::Signal<> FileSave;
+
+	/// The filename of the currently open file, if any
+	agi::fs::path filename;
+
+	void OnCommit(AssFileCommit c);
+
+	/// Set the filename, updating things like the MRU and last used path
+	void SetFileName(agi::fs::path const& file);
+
+public:
+	SubsController(agi::Context *context);
+
+	/// The file's path and filename if any, or platform-appropriate "untitled"
+	agi::fs::path Filename() const;
+
+	/// Does the file have unsaved changes?
+	bool IsModified() const { return commit_id != saved_commit_id; };
+
+	/// @brief Load from a file
+	/// @param file File name
+	/// @param charset Character set of file or empty to autodetect
+	void Load(agi::fs::path const& file, std::string const& charset="");
+
+	/// @brief Save to a file
+	/// @param file Path to save to
+	/// @param encoding Encoding to use, or empty to let the writer decide (which usually means "App/Save Charset")
+	void Save(agi::fs::path const& file, std::string const& encoding="");
+
+	/// Close the currently open file (i.e. open a new blank file)
+	void Close();
+
+	/// If there are unsaved changes, asl the user if they want to save them
+	/// @param allow_cancel Let the user cancel the closing
+	/// @return wxYES, wxNO or wxCANCEL (note: all three are true in a boolean context)
+	int TryToClose(bool allow_cancel = true) const;
+
+	/// @brief Autosave the file if there have been any chances since the last autosave
+	/// @return File name used or empty if no save was performed
+	agi::fs::path AutoSave();
+
+	/// Can the file be saved in its current format?
+	bool CanSave() const;
+
+	DEFINE_SIGNAL_ADDERS(FileOpen, AddFileOpenListener)
+	DEFINE_SIGNAL_ADDERS(FileSave, AddFileSaveListener)
+
+	/// @brief Undo the last set of changes to the file
+	void Undo();
+	/// @brief Redo the last undone changes
+	void Redo();
+	/// Check if undo stack is empty
+	bool IsUndoStackEmpty() const { return undo_stack.size() <= 1; };
+	/// Check if redo stack is empty
+	bool IsRedoStackEmpty() const { return redo_stack.empty(); };
+	/// Get the description of the first undoable change
+	wxString GetUndoDescription() const;
+	/// Get the description of the first redoable change
+	wxString GetRedoDescription() const;
+};
diff --git a/aegisub/src/subs_edit_box.h b/aegisub/src/subs_edit_box.h
index 998cc6331e091afb720c3e5ad05572fa1702ed21..f92202d1a63e15e784d7a41adc370194f67a29f9 100644
--- a/aegisub/src/subs_edit_box.h
+++ b/aegisub/src/subs_edit_box.h
@@ -183,7 +183,7 @@ class SubsEditBox : public wxPanel {
 	void SetSelectedRows(T AssDialogue::*field, wxString const& value, wxString const& desc, int type, bool amend = false);
 
 	/// @brief Reload the current line from the file
-	/// @param type AssFile::CommitType
+	/// @param type AssFile::COMMITType
 	void OnCommit(int type);
 
 	/// Regenerate a dropdown list with the unique values of a dialogue field
diff --git a/aegisub/src/subtitle_format_encore.cpp b/aegisub/src/subtitle_format_encore.cpp
index 13796a355d6bd1474fb3671a6f4f39913f2d350b..baf5920bb05df11b189de301a4809bf771eb564b 100644
--- a/aegisub/src/subtitle_format_encore.cpp
+++ b/aegisub/src/subtitle_format_encore.cpp
@@ -43,6 +43,7 @@
 #include <libaegisub/of_type_adaptor.h>
 
 #include <boost/algorithm/string/predicate.hpp>
+#include <boost/filesystem/path.hpp>
 #include <boost/format.hpp>
 
 EncoreSubtitleFormat::EncoreSubtitleFormat()
diff --git a/aegisub/src/subtitle_format_transtation.cpp b/aegisub/src/subtitle_format_transtation.cpp
index 6f38f7e0953cac0e91733df77a0c6c7e54157ace..4daf326eb500c7cd72127c0ea7cc03e36e562110 100644
--- a/aegisub/src/subtitle_format_transtation.cpp
+++ b/aegisub/src/subtitle_format_transtation.cpp
@@ -47,6 +47,7 @@
 #include <libaegisub/of_type_adaptor.h>
 
 #include <boost/algorithm/string/predicate.hpp>
+#include <boost/filesystem/path.hpp>
 #include <boost/format.hpp>
 
 TranStationSubtitleFormat::TranStationSubtitleFormat()
diff --git a/aegisub/src/subtitles_provider_csri.cpp b/aegisub/src/subtitles_provider_csri.cpp
index 15e8076c7d0a40f94762294ed203dfeb74e99f09..c31db5d5581d52b9d0aaef728a3a191322759da3 100644
--- a/aegisub/src/subtitles_provider_csri.cpp
+++ b/aegisub/src/subtitles_provider_csri.cpp
@@ -37,9 +37,8 @@
 #ifdef WITH_CSRI
 #include "subtitles_provider_csri.h"
 
-#include "ass_file.h"
+#include "subtitle_format.h"
 #include "standard_paths.h"
-#include "video_context.h"
 #include "video_frame.h"
 
 #include <libaegisub/fs.h>
@@ -81,7 +80,7 @@ CSRISubtitlesProvider::~CSRISubtitlesProvider() {
 void CSRISubtitlesProvider::LoadSubtitles(AssFile *subs) {
 	if (tempfile.empty())
 		tempfile = unique_path(StandardPaths::DecodePath("?temp/csri-%%%%-%%%%-%%%%-%%%%.ass"));
-	subs->Save(tempfile, false, false, "utf-8");
+	SubtitleFormat::GetWriter(tempfile)->WriteFile(subs, tempfile, "utf-8");
 
 	std::lock_guard<std::mutex> lock(csri_mutex);
 	instance = csri_open_file(renderer, tempfile.string().c_str(), nullptr);
diff --git a/aegisub/src/subtitles_provider_libass.cpp b/aegisub/src/subtitles_provider_libass.cpp
index 0db954c5109f9d12cd4f196bcf11e0e1c6700e98..38d80b0c68fb48a5954d403856e6991715054670 100644
--- a/aegisub/src/subtitles_provider_libass.cpp
+++ b/aegisub/src/subtitles_provider_libass.cpp
@@ -52,6 +52,7 @@
 #include <libaegisub/dispatch.h>
 #include <libaegisub/log.h>
 
+#include <boost/range/algorithm_ext/push_back.hpp>
 #include <boost/gil/gil_all.hpp>
 #include <memory>
 #include <mutex>
@@ -117,7 +118,17 @@ LibassSubtitlesProvider::~LibassSubtitlesProvider() {
 
 void LibassSubtitlesProvider::LoadSubtitles(AssFile *subs) {
 	std::vector<char> data;
-	subs->SaveMemory(data);
+	data.clear();
+	data.reserve(0x4000);
+
+	AssEntryGroup group = ENTRY_GROUP_MAX;
+	for (auto const& line : subs->Line) {
+		if (group != line.Group()) {
+			group = line.Group();
+			boost::push_back(data, line.GroupHeader() + "\r\n");
+		}
+		boost::push_back(data, line.GetEntryData() + "\r\n");
+	}
 
 	if (ass_track) ass_free_track(ass_track);
 	ass_track = ass_read_memory(library, &data[0], data.size(),(char *)"UTF-8");
diff --git a/aegisub/src/video_context.cpp b/aegisub/src/video_context.cpp
index 3b81d6d4d392c71d7ec68c1960f063a4362da69d..6593ac297d3156fa0c9b1e8dd1079357dd9fecc7 100644
--- a/aegisub/src/video_context.cpp
+++ b/aegisub/src/video_context.cpp
@@ -46,6 +46,7 @@
 #include "mkv_wrap.h"
 #include "options.h"
 #include "selection_controller.h"
+#include "subs_controller.h"
 #include "time_range.h"
 #include "threaded_frame_source.h"
 #include "utils.h"
@@ -116,7 +117,7 @@ void VideoContext::Reset() {
 void VideoContext::SetContext(agi::Context *context) {
 	this->context = context;
 	context->ass->AddCommitListener(&VideoContext::OnSubtitlesCommit, this);
-	context->ass->AddFileSaveListener(&VideoContext::OnSubtitlesSave, this);
+	context->subsController->AddFileSaveListener(&VideoContext::OnSubtitlesSave, this);
 }
 
 void VideoContext::SetVideo(const agi::fs::path &filename) {
diff --git a/aegisub/src/video_display.cpp b/aegisub/src/video_display.cpp
index c1687b713c7e7a7936df651fca68d4e0fc9056a8..d86009bf903d8e14a5a36956d2801ee353a21384 100644
--- a/aegisub/src/video_display.cpp
+++ b/aegisub/src/video_display.cpp
@@ -32,7 +32,6 @@
 /// @ingroup video main_ui
 ///
 
-// Includes
 #include "config.h"
 
 #include <algorithm>
@@ -60,6 +59,7 @@
 #include "include/aegisub/menu.h"
 #include "options.h"
 #include "spline_curve.h"
+#include "subs_controller.h"
 #include "threaded_frame_source.h"
 #include "utils.h"
 #include "video_out_gl.h"
@@ -111,7 +111,7 @@ VideoDisplay::VideoDisplay(
 	slots.push_back(con->videoController->AddVideoOpenListener(&VideoDisplay::UpdateSize, this));
 	slots.push_back(con->videoController->AddARChangeListener(&VideoDisplay::UpdateSize, this));
 
-	slots.push_back(con->ass->AddFileSaveListener(&VideoDisplay::OnSubtitlesSave, this));
+	slots.push_back(con->subsController->AddFileSaveListener(&VideoDisplay::OnSubtitlesSave, this));
 
 	Bind(wxEVT_PAINT, std::bind(&VideoDisplay::Render, this));
 	Bind(wxEVT_SIZE, &VideoDisplay::OnSizeEvent, this);