[Scummvm-git-logs] scummvm master -> f7a3e571f06a00b26c82d662efcb09cb175821c0

elasota noreply at scummvm.org
Wed Aug 10 01:55:05 UTC 2022


This automated email contains information about 1 new commit which have been
pushed to the 'scummvm' repo located at https://github.com/scummvm/scummvm .

Summary:
f7a3e571f0 MTROPOLIS: Add subtitles support


Commit: f7a3e571f06a00b26c82d662efcb09cb175821c0
    https://github.com/scummvm/scummvm/commit/f7a3e571f06a00b26c82d662efcb09cb175821c0
Author: elasota (ejlasota at gmail.com)
Date: 2022-08-09T21:54:08-04:00

Commit Message:
MTROPOLIS: Add subtitles support

Changed paths:
  A engines/mtropolis/subtitles.cpp
  A engines/mtropolis/subtitles.h
    engines/mtropolis/boot.cpp
    engines/mtropolis/detection.cpp
    engines/mtropolis/detection_tables.h
    engines/mtropolis/elements.cpp
    engines/mtropolis/elements.h
    engines/mtropolis/module.mk
    engines/mtropolis/render.cpp
    engines/mtropolis/render.h
    engines/mtropolis/runtime.cpp
    engines/mtropolis/runtime.h


diff --git a/engines/mtropolis/boot.cpp b/engines/mtropolis/boot.cpp
index 19b838ae0f7..6774bb6ac89 100644
--- a/engines/mtropolis/boot.cpp
+++ b/engines/mtropolis/boot.cpp
@@ -31,6 +31,7 @@
 #include "mtropolis/boot.h"
 #include "mtropolis/detection.h"
 #include "mtropolis/runtime.h"
+#include "mtropolis/subtitles.h"
 
 #include "mtropolis/plugin/obsidian.h"
 #include "mtropolis/plugin/standard.h"
@@ -535,9 +536,32 @@ Common::SharedPtr<ProjectDescription> bootProject(const MTropolisGameDescription
 
 	Common::SharedPtr<Boot::GameDataHandler> gameDataHandler;
 
+	Common::SharedPtr<SubtitleAssetMappingTable> subsAssetMappingTable;
+	Common::SharedPtr<SubtitleModifierMappingTable> subsModifierMappingTable;
+	Common::SharedPtr<SubtitleSpeakerTable> subsSpeakerTable;
+	Common::SharedPtr<SubtitleLineTable> subsLineTable;
+
+	Common::String speakerTablePath;
+	Common::String linesTablePath;
+	Common::String assetMappingTablePath;
+	Common::String modifierMappingTablePath;
+
 	switch (gameDesc.gameID) {
 	case GID_OBSIDIAN:
 		gameDataHandler.reset(new Boot::ObsidianGameDataHandler(gameDesc));
+
+		if ((gameDesc.desc.flags & ADGF_DEMO) == 0 && gameDesc.desc.language == Common::EN_ANY) {
+			linesTablePath = "subtitles_lines_obsidian_en.csv";
+			speakerTablePath = "subtitles_speakers_obsidian_en.csv";
+
+			if (gameDesc.desc.platform == Common::kPlatformWindows) {
+				assetMappingTablePath = "subtitles_asset_mapping_obsidian_win_en.csv";
+				modifierMappingTablePath = "subtitles_modifier_mapping_obsidian_win_en.csv";
+			} else if (gameDesc.desc.platform == Common::kPlatformMacintosh) {
+				assetMappingTablePath = "subtitles_asset_mapping_obsidian_mac_en.csv";
+				modifierMappingTablePath = "subtitles_modifier_mapping_obsidian_mac_en.csv";
+			}
+		}
 		break;
 	default:
 		gameDataHandler.reset(new Boot::GameDataHandler());
@@ -885,6 +909,41 @@ Common::SharedPtr<ProjectDescription> bootProject(const MTropolisGameDescription
 
 	desc->setResources(resources);
 
+	if (assetMappingTablePath.size() > 0 && linesTablePath.size() > 0) {
+		subsAssetMappingTable.reset(new SubtitleAssetMappingTable());
+		subsModifierMappingTable.reset(new SubtitleModifierMappingTable());
+		subsSpeakerTable.reset(new SubtitleSpeakerTable());
+		subsLineTable.reset(new SubtitleLineTable());
+
+		Common::ErrorCode assetMappingError = subsAssetMappingTable->load(assetMappingTablePath);
+		Common::ErrorCode modifierMappingError = subsModifierMappingTable->load(modifierMappingTablePath);
+		Common::ErrorCode speakerError = subsSpeakerTable->load(speakerTablePath);
+		Common::ErrorCode linesError = speakerError;
+
+		if (speakerError == Common::kNoError)
+			linesError = subsLineTable->load(linesTablePath, *subsSpeakerTable);
+
+		if (assetMappingError != Common::kNoError || modifierMappingError != Common::kNoError || linesError != Common::kNoError) {
+			// If all sub files are missing, then the user hasn't installed them
+			if (assetMappingError != Common::kPathDoesNotExist || modifierMappingError != Common::kPathDoesNotExist || linesError != Common::kPathDoesNotExist) {
+				warning("Failed to load subtitles data");
+			}
+
+			subsAssetMappingTable.reset();
+			subsModifierMappingTable.reset();
+			subsLineTable.reset();
+			subsSpeakerTable.reset();
+		}
+	}
+
+	SubtitleTables subTables;
+	subTables.assetMapping = subsAssetMappingTable;
+	subTables.lines = subsLineTable;
+	subTables.modifierMapping = subsModifierMappingTable;
+	subTables.speakers = subsSpeakerTable;
+
+	desc->getSubtitles(subTables);
+
 	return desc;
 }
 
diff --git a/engines/mtropolis/detection.cpp b/engines/mtropolis/detection.cpp
index c004792abc7..25a02eca970 100644
--- a/engines/mtropolis/detection.cpp
+++ b/engines/mtropolis/detection.cpp
@@ -60,7 +60,7 @@ static const ADExtraGuiOptionsMap optionsList[] = {
 		}
 	},
 	{
-		GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS,
+		GAMEOPTION_SOUND_GAMEPLAY_SUBTITLES,
 		{
 			_s("Autosave at progress points"),
 			_s("Automatically saves the game after completing puzzles and chapters."),
@@ -81,6 +81,17 @@ static const ADExtraGuiOptionsMap optionsList[] = {
 			0
 		}
 	},
+	{
+		GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS,
+		{
+			_s("Use subtitles for sound-based gameplay elements"),
+			_s("Enables subtitles for gameplay elements that depend on hearing an audible sound."),
+			"mtropolis_mod_sound_gameplay_subtitles",
+			false,
+			0,
+			0
+		}
+	},
 	{
 		GAMEOPTION_LAUNCH_DEBUG,
 		{
diff --git a/engines/mtropolis/detection_tables.h b/engines/mtropolis/detection_tables.h
index e69a504ee20..ac1e262a232 100644
--- a/engines/mtropolis/detection_tables.h
+++ b/engines/mtropolis/detection_tables.h
@@ -26,12 +26,12 @@
 
 #include "mtropolis/detection.h"
 
-#define GAMEOPTION_WIDESCREEN_MOD			GUIO_GAMEOPTIONS1
-#define GAMEOPTION_DYNAMIC_MIDI				GUIO_GAMEOPTIONS2
-#define GAMEOPTION_LAUNCH_DEBUG				GUIO_GAMEOPTIONS3
-//#define GAMEOPTION_LAUNCH_BREAK			GUIO_GAMEOPTIONS4	// Disabled due to not being functional
-#define GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS	GUIO_GAMEOPTIONS5
-#define GAMEOPTION_ENABLE_SHORT_TRANSITIONS GUIO_GAMEOPTIONS6
+#define GAMEOPTION_WIDESCREEN_MOD				GUIO_GAMEOPTIONS1
+#define GAMEOPTION_DYNAMIC_MIDI					GUIO_GAMEOPTIONS2
+#define GAMEOPTION_LAUNCH_DEBUG					GUIO_GAMEOPTIONS3
+#define GAMEOPTION_SOUND_GAMEPLAY_SUBTITLES		GUIO_GAMEOPTIONS4
+#define GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS		GUIO_GAMEOPTIONS5
+#define GAMEOPTION_ENABLE_SHORT_TRANSITIONS		GUIO_GAMEOPTIONS6
 
 namespace MTropolis {
 
@@ -53,7 +53,7 @@ static const MTropolisGameDescription gameDescriptions[] = {
 			Common::EN_ANY,
 			Common::kPlatformMacintosh,
 			ADGF_TESTING,
-			GUIO2(GAMEOPTION_WIDESCREEN_MOD, GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS)
+			GUIO3(GAMEOPTION_WIDESCREEN_MOD, GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS, GAMEOPTION_SOUND_GAMEPLAY_SUBTITLES)
 		},
 		GID_OBSIDIAN,
 		0,
@@ -75,7 +75,7 @@ static const MTropolisGameDescription gameDescriptions[] = {
 			Common::EN_ANY,
 			Common::kPlatformMacintosh,
 			ADGF_TESTING,
-			GUIO2(GAMEOPTION_WIDESCREEN_MOD, GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS)
+			GUIO3(GAMEOPTION_WIDESCREEN_MOD, GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS, GAMEOPTION_SOUND_GAMEPLAY_SUBTITLES)
 		},
 		GID_OBSIDIAN,
 		0,
@@ -102,7 +102,7 @@ static const MTropolisGameDescription gameDescriptions[] = {
 			Common::EN_ANY,
 			Common::kPlatformWindows,
 			ADGF_TESTING,
-			GUIO2(GAMEOPTION_WIDESCREEN_MOD, GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS)
+			GUIO3(GAMEOPTION_WIDESCREEN_MOD, GAMEOPTION_AUTO_SAVE_AT_CHECKPOINTS, GAMEOPTION_SOUND_GAMEPLAY_SUBTITLES)
 		},
 		GID_OBSIDIAN,
 		0,
diff --git a/engines/mtropolis/elements.cpp b/engines/mtropolis/elements.cpp
index bcccbd60921..50257d9ce6d 100644
--- a/engines/mtropolis/elements.cpp
+++ b/engines/mtropolis/elements.cpp
@@ -434,6 +434,8 @@ MovieElement::~MovieElement() {
 		_unloadSignaller->removeReceiver(this);
 	if (_playMediaSignaller)
 		_playMediaSignaller->removeReceiver(this);
+
+	stopSubtitles();
 }
 
 bool MovieElement::load(ElementLoaderContext &context, const Data::MovieElement &data) {
@@ -513,6 +515,8 @@ VThreadState MovieElement::consumeCommand(Runtime *runtime, const Common::Shared
 	}
 	if (Event(EventIDs::kStop, 0).respondsTo(msg->getEvent())) {
 		if (!_paused) {
+			stopSubtitles();
+
 			_paused = true;
 			Common::SharedPtr<MessageProperties> msgProps(new MessageProperties(Event(EventIDs::kPause, 0), DynamicValue(), getSelfReference()));
 			Common::SharedPtr<MessageDispatch> dispatch(new MessageDispatch(msgProps, this, false, true, false));
@@ -583,6 +587,18 @@ void MovieElement::activate() {
 
 	if (_name.empty())
 		_name = project->getAssetNameByID(_assetID);
+
+	const SubtitleTables &subtitleTables = project->getSubtitles();
+	if (subtitleTables.assetMapping) {
+		const Common::String *subSetIDPtr = subtitleTables.assetMapping->findSubtitleSetForAssetID(_assetID);
+		if (!subSetIDPtr) {
+			Common::String assetName = project->getAssetNameByID(_assetID);
+			subSetIDPtr = subtitleTables.assetMapping->findSubtitleSetForAssetName(assetName);
+		}
+
+		if (subSetIDPtr)
+			_subtitles.reset(new SubtitlePlayer(_runtime, *subSetIDPtr, subtitleTables));
+	}
 }
 
 void MovieElement::deactivate() {
@@ -747,6 +763,9 @@ void MovieElement::playMedia(Runtime *runtime, Project *project) {
 			for (MediaCueState *mediaCue : _mediaCues)
 				mediaCue->checkTimestampChange(runtime, _currentTimestamp, targetTS, checkContinuously, true);
 
+			if (_subtitles)
+				_subtitles->update(_currentTimestamp * 1000 / _timeScale, targetTS * 1000 / _timeScale);
+
 			_currentTimestamp = targetTS;
 
 			if (_currentTimestamp == maxTS) {
@@ -761,6 +780,7 @@ void MovieElement::playMedia(Runtime *runtime, Project *project) {
 		if (triggerEndEvents) {
 			if (!_loop) {
 				_paused = true;
+				stopSubtitles();
 
 				Common::SharedPtr<MessageProperties> msgProps(new MessageProperties(Event(EventIDs::kPause, 0), DynamicValue(), getSelfReference()));
 				Common::SharedPtr<MessageDispatch> dispatch(new MessageDispatch(msgProps, this, false, true, false));
@@ -778,6 +798,8 @@ void MovieElement::playMedia(Runtime *runtime, Project *project) {
 			_currentPlayState = kMediaStateStopped;
 
 			if (_loop) {
+				stopSubtitles();
+
 				_needsReset = true;
 				_currentTimestamp = _reversed ? realRange.max : realRange.min;
 				_contentsDirty = true;
@@ -801,6 +823,18 @@ IntRange MovieElement::computeRealRange() const {
 	return _playRange;
 }
 
+void MovieElement::stopSubtitles() {
+	if (_subtitles)
+		_subtitles->stop();
+}
+
+void MovieElement::onPauseStateChanged() {
+	VisualElement::onPauseStateChanged();
+
+	if (_paused && _subtitles)
+		_subtitles->stop();
+}
+
 MiniscriptInstructionOutcome MovieElement::scriptSetRange(MiniscriptThread *thread, const DynamicValue &value) {
 	if (value.getType() != DynamicValueTypes::kIntegerRange) {
 		thread->error("Wrong type for movie element range");
@@ -936,6 +970,8 @@ VThreadState MovieElement::startPlayingTask(const StartPlayingTaskData &taskData
 
 		_shouldPlayIfNotPaused = true;
 		_paused = false;
+
+		stopSubtitles();
 	}
 
 	return kVThreadReturn;
@@ -963,6 +999,8 @@ VThreadState MovieElement::seekToTimeTask(const SeekToTimeTaskData &taskData) {
 	_needsReset = true;
 	_contentsDirty = true;
 
+	stopSubtitles();
+
 	return kVThreadReturn;
 }
 
@@ -1952,8 +1990,8 @@ MiniscriptInstructionOutcome TextLabelElement::TextLabelLineWriteInterface::refA
 }
 
 SoundElement::SoundElement()
-	: _leftVolume(0), _rightVolume(0), _balance(0), _assetID(0), _finishTime(0),
-	  _shouldPlayIfNotPaused(true), _needsReset(true), _runtime(nullptr) {
+	: _leftVolume(0), _rightVolume(0), _balance(0), _assetID(0), _startTime(0), _finishTime(0), _cueCheckTime(0),
+	  _startTimestamp(0), _shouldPlayIfNotPaused(true), _needsReset(true), _runtime(nullptr) {
 }
 
 SoundElement::~SoundElement() {
@@ -2041,6 +2079,19 @@ void SoundElement::activate() {
 
 	if (_name.empty())
 		_name = project->getAssetNameByID(_assetID);
+
+	const SubtitleTables &subTables = project->getSubtitles();
+	if (subTables.assetMapping) {
+		const Common::String *subtitleSetIDPtr = subTables.assetMapping->findSubtitleSetForAssetID(_assetID);
+		if (!subtitleSetIDPtr) {
+			Common::String assetName = project->getAssetNameByID(_assetID);
+			if (assetName.size() > 0)
+				subtitleSetIDPtr = subTables.assetMapping->findSubtitleSetForAssetName(assetName);
+		}
+
+		if (subtitleSetIDPtr)
+			_subtitlePlayer.reset(new SubtitlePlayer(_runtime, *subtitleSetIDPtr, subTables));
+	}
 }
 
 
@@ -2064,31 +2115,45 @@ void SoundElement::playMedia(Runtime *runtime, Project *project) {
 		if (_paused) {
 			// Goal state is paused
 			// TODO: Track pause time
-			_player.reset();
+			stopPlayer();
 		} else {
 			// Goal state is playing
 			if (_needsReset) {
 				// TODO: Reset to start time
-				_player.reset();
+				stopPlayer();
 				_needsReset = false;
 			}
 
 			if (!_player) {
 				_finishTime = _runtime->getPlayTime() + _metadata->durationMSec;
 
-				_player.reset();
-
 				int normalizedVolume = (_leftVolume + _rightVolume) * 255 / 200;
 				int normalizedBalance = _balance * 127 / 100;
 
 				// TODO: Support ranges
 				size_t numSamples = _cachedAudio->getNumSamples(*_metadata);
 				_player.reset(new AudioPlayer(_runtime->getAudioMixer(), normalizedVolume, normalizedBalance, _metadata, _cachedAudio, _loop, 0, 0, numSamples));
+
+				_startTime = runtime->getPlayTime();
+				_cueCheckTime = _startTime;
+				_startTimestamp = 0;
+			}
+
+			uint64 newTime = _runtime->getPlayTime();
+			if (newTime > _cueCheckTime) {
+				uint64 oldTimeRelative = _cueCheckTime - _startTime + _startTimestamp;
+				uint64 newTimeRelative = newTime - _startTime + _startTimestamp;
+
+				_cueCheckTime = newTime;
+
+				if (_subtitlePlayer)
+					_subtitlePlayer->update(oldTimeRelative, newTimeRelative);
+
+				// TODO: Check cue points and queue them here
 			}
 
-			// TODO: Check cue points and queue them here
 			
-			if (!_loop && _runtime->getPlayTime() >= _finishTime) {
+			if (!_loop && newTime >= _finishTime) {
 				// Don't throw out the handle - It can still be playing but we just treat it like it's not.
 				// If it has anything left, then we let it finish and avoid clipping the sound, but we need
 				// to know that the handle is still here so we can actually stop it if the element is
@@ -2099,14 +2164,22 @@ void SoundElement::playMedia(Runtime *runtime, Project *project) {
 				runtime->queueMessage(dispatch);
 
 				_shouldPlayIfNotPaused = false;
+				if (_subtitlePlayer)
+					_subtitlePlayer->stop();
 			}
 		}
 	} else {
 		// Goal state is stopped
-		_player.reset();
+		stopPlayer();
 	}
 }
 
+void SoundElement::stopPlayer() {
+	_player.reset();
+	if (_subtitlePlayer)
+		_subtitlePlayer->stop();
+}
+
 #ifdef MTROPOLIS_DEBUG_ENABLE
 void SoundElement::debugInspect(IDebugInspectionReport *report) const {
 	NonVisualElement::debugInspect(report);
diff --git a/engines/mtropolis/elements.h b/engines/mtropolis/elements.h
index 94044aa0e95..a024f56fa0c 100644
--- a/engines/mtropolis/elements.h
+++ b/engines/mtropolis/elements.h
@@ -109,11 +109,15 @@ public:
 	SupportStatus debugGetSupportStatus() const override { return kSupportStatusDone; }
 #endif
 
-private:
+protected:
+	void onPauseStateChanged() override;
 	void onSegmentUnloaded(int segmentIndex) override;
 
+private:
 	IntRange computeRealRange() const;
 
+	void stopSubtitles();
+
 	MiniscriptInstructionOutcome scriptSetRange(MiniscriptThread *thread, const DynamicValue &value);
 	MiniscriptInstructionOutcome scriptSetRangeStart(MiniscriptThread *thread, const DynamicValue &value);
 	MiniscriptInstructionOutcome scriptSetRangeEnd(MiniscriptThread *thread, const DynamicValue &value);
@@ -164,6 +168,8 @@ private:
 	Common::SharedPtr<SegmentUnloadSignaller> _unloadSignaller;
 	Common::SharedPtr<PlayMediaSignaller> _playMediaSignaller;
 
+	Common::SharedPtr<SubtitlePlayer> _subtitles;
+
 	Common::Array<int> _damagedFrames;
 
 	Runtime *_runtime;
@@ -380,6 +386,8 @@ public:
 #endif
 
 private:
+	void stopPlayer();
+
 	MiniscriptInstructionOutcome scriptSetLoop(MiniscriptThread *thread, const DynamicValue &value);
 	MiniscriptInstructionOutcome scriptSetVolume(MiniscriptThread *thread, const DynamicValue &value);
 	MiniscriptInstructionOutcome scriptSetBalance(MiniscriptThread *thread, const DynamicValue &value);
@@ -405,12 +413,17 @@ private:
 	Common::SharedPtr<CachedAudio> _cachedAudio;
 	Common::SharedPtr<AudioMetadata> _metadata;
 	Common::SharedPtr<AudioPlayer> _player;
+	uint64 _startTime;
 	uint64 _finishTime;
+	uint64 _startTimestamp;	// Time in the sound corresponding to the start time
+	uint64 _cueCheckTime;
 	bool _shouldPlayIfNotPaused;
 	bool _needsReset;
 
 	Common::SharedPtr<PlayMediaSignaller> _playMediaSignaller;
 
+	Common::SharedPtr<SubtitlePlayer> _subtitlePlayer;
+
 	Runtime *_runtime;
 };
 
diff --git a/engines/mtropolis/module.mk b/engines/mtropolis/module.mk
index c8f0a9ce5dc..523ceed12d2 100644
--- a/engines/mtropolis/module.mk
+++ b/engines/mtropolis/module.mk
@@ -23,6 +23,7 @@ MODULE_OBJS = \
 	render.o \
 	runtime.o \
 	saveload.o \
+	subtitles.o \
 	vthread.o
 
 # This module can be built as a plugin
diff --git a/engines/mtropolis/render.cpp b/engines/mtropolis/render.cpp
index 083265167a2..86c2d49fb57 100644
--- a/engines/mtropolis/render.cpp
+++ b/engines/mtropolis/render.cpp
@@ -259,7 +259,7 @@ static void renderDirectElement(const RenderItem &item, Window *mainWindow) {
 	renderNormalElement(item, mainWindow);	// Meh
 }
 
-void renderProject(Runtime *runtime, Window *mainWindow) {
+void renderProject(Runtime *runtime, Window *mainWindow, bool *outSkipped) {
 	bool sceneChanged = runtime->isSceneGraphDirty();
 
 	Common::Array<Structural *> scenes;
@@ -296,6 +296,9 @@ void renderProject(Runtime *runtime, Window *mainWindow) {
 	}
 
 	if (sceneChanged) {
+		if (outSkipped)
+			*outSkipped = false;
+
 		for (Common::Array<RenderItem>::const_iterator it = normalBucket.begin(), itEnd = normalBucket.end(); it != itEnd; ++it)
 			renderNormalElement(*it, mainWindow);
 
@@ -304,6 +307,9 @@ void renderProject(Runtime *runtime, Window *mainWindow) {
 
 		for (const IPostEffect *postEffect : runtime->getPostEffects())
 			postEffect->renderPostEffect(*mainWindow->getSurface());
+	} else {
+		if (outSkipped)
+			*outSkipped = true;
 	}
 
 	runtime->clearSceneGraphDirty();
diff --git a/engines/mtropolis/render.h b/engines/mtropolis/render.h
index 1d3ba7b7a7d..9b0305f837e 100644
--- a/engines/mtropolis/render.h
+++ b/engines/mtropolis/render.h
@@ -135,7 +135,7 @@ protected:
 namespace Render {
 
 uint32 resolveRGB(uint8 r, uint8 g, uint8 b, const Graphics::PixelFormat &fmt);
-void renderProject(Runtime *runtime, Window *mainWindow);
+void renderProject(Runtime *runtime, Window *mainWindow, bool *outSkipped);
 void renderSceneTransition(Runtime *runtime, Window *mainWindow, const SceneTransitionEffect &effect, uint32 startTime, uint32 endTime, uint32 currentTime, const Graphics::ManagedSurface &oldFrame, const Graphics::ManagedSurface &newFrame);
 
 void convert32To16(Graphics::Surface &destSurface, const Graphics::Surface &srcSurface);
diff --git a/engines/mtropolis/runtime.cpp b/engines/mtropolis/runtime.cpp
index acdaaaa3b79..1a739f2baee 100644
--- a/engines/mtropolis/runtime.cpp
+++ b/engines/mtropolis/runtime.cpp
@@ -2330,6 +2330,14 @@ ProjectPlatform ProjectDescription::getPlatform() const {
 	return _platform;
 }
 
+const SubtitleTables &ProjectDescription::getSubtitles() const {
+	return _subtitles;
+}
+
+void ProjectDescription::getSubtitles(const SubtitleTables &subs) {
+	_subtitles = subs;
+}
+
 const Common::Array<Common::SharedPtr<Modifier> >& SimpleModifierContainer::getModifiers() const {
 	return _modifiers;
 }
@@ -3855,6 +3863,7 @@ Runtime::Runtime(OSystem *system, Audio::Mixer *mixer, ISaveUIProvider *saveProv
 	_random.reset(new Common::RandomSource("mtropolis"));
 
 	_vthread.reset(new VThread());
+	_subtitleRenderer.reset(new SubtitleRenderer());
 
 	for (int i = 0; i < kColorDepthModeCount; i++) {
 		_displayModeSupported[i] = false;
@@ -3901,6 +3910,13 @@ Runtime::Runtime(OSystem *system, Audio::Mixer *mixer, ISaveUIProvider *saveProv
 	_getSetAttribIDsToAttribName[AttributeIDs::kAttribUserTimeout] = "usertimeout";
 }
 
+Runtime::~Runtime() {
+	// Clear the project first, which should detach any references to other things
+	_project.reset();
+
+	_subtitleRenderer.reset();
+}
+
 bool Runtime::runFrame() {
 	uint32 timeMillis = _system->getMillis();
 
@@ -4106,7 +4122,7 @@ bool Runtime::runFrame() {
 
 					_sceneTransitionOldFrame->copyFrom(*mainWindow->getSurface());
 
-					Render::renderProject(this, mainWindow.get());
+					Render::renderProject(this, mainWindow.get(), nullptr);
 
 					_sceneTransitionNewFrame->copyFrom(*mainWindow->getSurface());
 				}
@@ -4194,14 +4210,26 @@ void Runtime::drawFrame() {
 
 	_system->fillScreen(Render::resolveRGB(0, 0, 0, getRenderPixelFormat()));
 
+	bool needToRenderSubtitles = false;
+	if (_subtitleRenderer->update(_playTime))
+		setSceneGraphDirty();
+
 	{
 		Common::SharedPtr<Window> mainWindow = _mainWindow.lock();
 		if (mainWindow) {
 			if (_sceneTransitionState == kSceneTransitionStateTransitioning) {
 				assert(_activeSceneTransitionEffect != nullptr);
 				Render::renderSceneTransition(this, mainWindow.get(), *_activeSceneTransitionEffect, _sceneTransitionStartTime, _sceneTransitionEndTime, _playTime, *_sceneTransitionOldFrame, *_sceneTransitionNewFrame);
-			} else
-				Render::renderProject(this, mainWindow.get());
+				needToRenderSubtitles = true;
+			} else {
+				bool skipped = false;
+				Render::renderProject(this, mainWindow.get(), &skipped);
+
+				needToRenderSubtitles = !skipped;
+			}
+
+			if (needToRenderSubtitles)
+				_subtitleRenderer->composite(*mainWindow);
 		}
 	}
 
@@ -5833,6 +5861,10 @@ bool Runtime::isIdle() const {
 	return true;
 }
 
+const Common::SharedPtr<SubtitleRenderer> &Runtime::getSubtitleRenderer() const {
+	return _subtitleRenderer;
+}
+
 void Runtime::ensureMainWindowExists() {
 	// Maybe there's a better spot for this
 	if (_mainWindow.expired() && _project) {
@@ -6279,6 +6311,7 @@ VThreadState Project::consumeCommand(Runtime *runtime, const Common::SharedPtr<M
 void Project::loadFromDescription(const ProjectDescription &desc, const Hacks &hacks) {
 	_resources = desc.getResources();
 	_cursorGraphics = desc.getCursorGraphics();
+	_subtitles = desc.getSubtitles();
 
 	debug(1, "Loading new project...");
 
@@ -6682,6 +6715,10 @@ void Project::loadBootStream(size_t streamIndex, const Hacks &hacks) {
 	assignAssets(assetDefLoader.assets, hacks);
 }
 
+const SubtitleTables &Project::getSubtitles() const {
+	return _subtitles;
+}
+
 void Project::loadPresentationSettings(const Data::PresentationSettings &presentationSettings) {
 	_presentationSettings.bitsPerPixel = presentationSettings.bitsPerPixel;
 	if (_presentationSettings.bitsPerPixel != 16) {
diff --git a/engines/mtropolis/runtime.h b/engines/mtropolis/runtime.h
index 23841938e8c..3686da31101 100644
--- a/engines/mtropolis/runtime.h
+++ b/engines/mtropolis/runtime.h
@@ -38,6 +38,7 @@
 #include "mtropolis/data.h"
 #include "mtropolis/debug.h"
 #include "mtropolis/hacks.h"
+#include "mtropolis/subtitles.h"
 #include "mtropolis/vthread.h"
 
 class OSystem;
@@ -1215,12 +1216,16 @@ public:
 
 	ProjectPlatform getPlatform() const;
 
+	const SubtitleTables &getSubtitles() const;
+	void getSubtitles(const SubtitleTables &subs);
+
 private:
 	Common::Array<SegmentDescription> _segments;
 	Common::Array<Common::SharedPtr<PlugIn> > _plugIns;
 	Common::SharedPtr<ProjectResources> _resources;
 	Common::SharedPtr<CursorGraphicCollection> _cursorGraphics;
 	Common::Language _language;
+	SubtitleTables _subtitles;
 	ProjectPlatform _platform;
 };
 
@@ -1483,6 +1488,7 @@ public:
 class Runtime {
 public:
 	explicit Runtime(OSystem *system, Audio::Mixer *mixer, ISaveUIProvider *saveProvider, ILoadUIProvider *loadProvider);
+	~Runtime();
 
 	bool runFrame();
 	void drawFrame();
@@ -1607,6 +1613,8 @@ public:
 
 	bool isIdle() const;
 
+	const Common::SharedPtr<SubtitleRenderer> &getSubtitleRenderer() const;
+
 #ifdef MTROPOLIS_DEBUG_ENABLE
 	void debugSetEnabled(bool enabled);
 	void debugBreak();
@@ -1843,6 +1851,8 @@ private:
 
 	Common::Array<IPostEffect *> _postEffects;
 
+	Common::SharedPtr<SubtitleRenderer> _subtitleRenderer;
+
 	Hacks _hacks;
 
 	Common::HashMap<uint32, Common::String> _getSetAttribIDsToAttribName;
@@ -2256,6 +2266,8 @@ public:
 
 	const Common::SharedPtr<CursorGraphicCollection> &getCursorGraphics() const;
 
+	const SubtitleTables &getSubtitles() const;
+
 #ifdef MTROPOLIS_DEBUG_ENABLE
 	const char *debugGetTypeName() const override { return "Project"; }
 #endif
@@ -2365,6 +2377,8 @@ private:
 	Common::SharedPtr<PlayMediaSignaller> _playMediaSignaller;
 	Common::SharedPtr<KeyboardEventSignaller> _keyboardEventSignaller;
 
+	SubtitleTables _subtitles;
+
 	Runtime *_runtime;
 };
 
diff --git a/engines/mtropolis/subtitles.cpp b/engines/mtropolis/subtitles.cpp
new file mode 100644
index 00000000000..f5745e6c81b
--- /dev/null
+++ b/engines/mtropolis/subtitles.cpp
@@ -0,0 +1,742 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "mtropolis/subtitles.h"
+
+#include "common/archive.h"
+#include "common/array.h"
+#include "common/file.h"
+#include "common/hashmap.h"
+#include "common/stream.h"
+#include "common/hash-str.h"
+
+#include "graphics/font.h"
+#include "graphics/fonts/ttf.h"
+#include "graphics/fontman.h"
+#include "graphics/managed_surface.h"
+
+#include "mtropolis/render.h"
+#include "mtropolis/runtime.h"
+
+namespace MTropolis {
+
+class SubtitleCSVLoader {
+public:
+	explicit SubtitleCSVLoader(Common::ReadStream *stream);
+
+	bool readLine(Common::Array<Common::String> &outStrings);
+
+private:
+	bool readQuotedCel(Common::String &str);
+	bool readUnquotedCel(Common::String &str);
+
+	Common::Array<char> _contents;
+	uint _readOffset;
+	uint _line;
+};
+
+SubtitleCSVLoader::SubtitleCSVLoader(Common::ReadStream *stream) : _readOffset(0), _line(1) {
+	char chunk[4096];
+	while (!stream->eos() && !stream->err()) {
+		uint32 countRead = stream->read(chunk, sizeof(chunk));
+		if (countRead == 0)
+			return;
+
+		_contents.resize(_contents.size() + countRead);
+		memcpy(&_contents[_contents.size() - countRead], chunk, countRead);
+	}
+}
+
+bool SubtitleCSVLoader::readLine(Common::Array<Common::String> &outStrings) {
+	uint numStrs = 0;
+	while (_readOffset < _contents.size()) {
+		if (outStrings.size() == numStrs)
+			outStrings.push_back(Common::String());
+
+		Common::String &celStr = outStrings[numStrs];
+
+		char nextChar = _contents[_readOffset];
+		bool celStatus = false;
+		if (nextChar == '\"')
+			celStatus = readQuotedCel(celStr);
+		else
+			celStatus = readUnquotedCel(celStr);
+
+		if (!celStatus)
+			return false;
+
+		numStrs++;
+
+		if (_readOffset == _contents.size())
+			break;
+
+		char dividerChar = _contents[_readOffset];
+		if (dividerChar == ',')
+			_readOffset++;
+		else if (dividerChar == '\r' || dividerChar == '\n') {
+			_readOffset++;
+			if (dividerChar == '\r' && _readOffset < _contents.size() && _contents[_readOffset] == '\n')
+				_readOffset++;
+			break;
+		} else {
+			return false;
+		}
+	}
+
+	outStrings.resize(numStrs);
+	_line++;
+	return true;
+}
+
+bool SubtitleCSVLoader::readQuotedCel(Common::String &str) {
+	assert(_contents[_readOffset] == '\"');
+	_readOffset++;
+
+	str.clear();
+	for (;;) {
+		if (_readOffset == _contents.size())
+			return false;
+
+		char nextChar = _contents[_readOffset];
+		if (nextChar == '\"') {
+			_readOffset++;
+			if (_readOffset < _contents.size()) {
+				char subsequentChar = _contents[_readOffset];
+				if (subsequentChar == '\"') {
+					str += '\"';
+					_readOffset++;
+				} else
+					break;
+			}
+		} else {
+			str += nextChar;
+			_readOffset++;
+		}
+	}
+
+	return true;
+}
+
+bool SubtitleCSVLoader::readUnquotedCel(Common::String &str) {
+	assert(_contents[_readOffset] != '\"');
+
+	str.clear();
+	for (;;) {
+		if (_readOffset == _contents.size())
+			return true;
+
+		char nextChar = _contents[_readOffset];
+		if (nextChar == ',' || nextChar == '\n' || nextChar == '\r')
+			break;
+
+		str += nextChar;
+		_readOffset++;
+	}
+
+	return true;
+}
+
+SubtitleAssetMappingTable::SubtitleAssetMappingTable() {
+}
+
+Common::ErrorCode SubtitleAssetMappingTable::load(const Common::String &filePath) {
+	Common::File f;
+	if (!f.open(Common::Path(filePath)))
+		return Common::kPathDoesNotExist;
+
+	SubtitleCSVLoader loader(&f);
+
+	Common::Array<Common::String> strs;
+	if (!loader.readLine(strs))
+		return Common::kReadingFailed;
+
+	if (strs.size() != 3 || strs[0] != "subtitle_set_id" || strs[1] != "asset_id" || strs[2] != "asset_name")
+		return Common::kReadingFailed;
+
+	while (loader.readLine(strs)) {
+		if (strs.size() == 0)
+			break;
+
+		if (strs.size() != 3)
+			return Common::kReadingFailed;
+
+		uint assetID = 0;
+		if (sscanf(strs[1].c_str(), "%u", &assetID) == 1 && assetID != 0)
+			_assetIDToSubtitleSet[assetID] = strs[0];
+
+		if (strs[2].size() > 0)
+			_assetNameToSubtitleSet[strs[2]] = strs[0];
+	}
+
+	return Common::kNoError;
+}
+
+const Common::String *SubtitleAssetMappingTable::findSubtitleSetForAssetID(uint32 assetID) const {
+	Common::HashMap<uint32, Common::String>::const_iterator it = _assetIDToSubtitleSet.find(assetID);
+	if (it == _assetIDToSubtitleSet.end())
+		return nullptr;
+	return &it->_value;
+}
+
+const Common::String *SubtitleAssetMappingTable::findSubtitleSetForAssetName(const Common::String &assetName) const {
+	Common::HashMap<Common::String, Common::String>::const_iterator it = _assetNameToSubtitleSet.find(assetName);
+	if (it == _assetNameToSubtitleSet.end())
+		return nullptr;
+	return &it->_value;
+}
+
+
+SubtitleModifierMappingTable::SubtitleModifierMappingTable() {
+}
+
+Common::ErrorCode SubtitleModifierMappingTable::load(const Common::String &filePath) {
+	Common::File f;
+	if (!f.open(Common::Path(filePath)))
+		return Common::kPathDoesNotExist;
+
+	SubtitleCSVLoader loader(&f);
+
+	Common::Array<Common::String> strs;
+	if (!loader.readLine(strs))
+		return Common::kReadingFailed;
+
+	if (strs.size() != 2 || strs[0] != "subtitle_set_id" || strs[1] != "modifier_guid")
+		return Common::kReadingFailed;
+
+	while (loader.readLine(strs)) {
+		if (strs.size() == 0)
+			break;
+
+		if (strs.size() != 2)
+			return Common::kReadingFailed;
+
+		uint modifierGUID = 0;
+		if (sscanf(strs[1].c_str(), "%u", &modifierGUID) == 1 && modifierGUID != 0)
+			_guidToSubtitleSet[modifierGUID] = strs[0];
+		else
+			return Common::kReadingFailed;
+	}
+
+	return Common::kNoError;
+}
+
+const Common::String *SubtitleModifierMappingTable::findSubtitleSetForModifierGUID(uint32 guid) const {
+	Common::HashMap<uint32, Common::String>::const_iterator it = _guidToSubtitleSet.find(guid);
+	if (it == _guidToSubtitleSet.end())
+		return nullptr;
+	return &it->_value;
+}
+
+SubtitleSpeakerTable::SubtitleSpeakerTable() {
+}
+
+Common::ErrorCode SubtitleSpeakerTable::load(const Common::String &filePath) {
+	Common::File f;
+	if (!f.open(Common::Path(filePath)))
+		return Common::kPathDoesNotExist;
+
+	SubtitleCSVLoader loader(&f);
+
+	Common::Array<Common::String> strs;
+	if (!loader.readLine(strs))
+		return Common::kReadingFailed;
+
+	if (strs.size() != 2 || strs[0] != "speaker" || strs[1] != "text")
+		return Common::kReadingFailed;
+
+	_speakers.resize(1);
+
+	while (loader.readLine(strs)) {
+		if (strs.size() == 0)
+			break;
+
+		if (strs.size() != 2)
+			return Common::kReadingFailed;
+
+		_speakerToID[strs[0]] = _speakers.size();
+		_speakers.push_back(strs[1]);
+	}
+
+	return Common::kNoError;
+}
+
+const Common::Array<Common::String> &SubtitleSpeakerTable::getSpeakers() const {
+	return _speakers;
+}
+
+uint SubtitleSpeakerTable::getSpeakerID(const Common::String &speakerName) const {
+	Common::HashMap<Common::String, uint>::const_iterator it = _speakerToID.find(speakerName);
+	if (it == _speakerToID.end())
+		return 0;
+	return it->_value;
+}
+
+
+SubtitleLineTable::SubtitleLineTable() {
+}
+
+Common::ErrorCode SubtitleLineTable::load(const Common::String &filePath, const SubtitleSpeakerTable &speakerTable) {
+	Common::File f;
+	if (!f.open(Common::Path(filePath)))
+		return Common::kPathDoesNotExist;
+
+	SubtitleCSVLoader loader(&f);
+
+	Common::Array<Common::String> strs;
+	if (!loader.readLine(strs))
+		return Common::kReadingFailed;
+
+	if (strs.size() != 7 || strs[0] != "subtitle_set_id" || strs[1] != "text" || strs[2] != "time" || strs[3] != "duration" || strs[4] != "location" || strs[5] != "speaker" || strs[6] != "is_gameplay")
+		return Common::kReadingFailed;
+
+	uint currentLine = 0;
+	while (loader.readLine(strs)) {
+		if (strs.size() == 0)
+			break;
+
+		if (strs.size() != 7)
+			return Common::kReadingFailed;
+
+		double timestamp = 0;
+		double duration = 0;
+		uint location = 0;
+		uint isGameplay = 0;
+		if (sscanf(strs[2].c_str(), "%lf", &timestamp) != 1)
+			timestamp = 0;
+
+		if (sscanf(strs[3].c_str(), "%lf", &duration) != 1)
+			duration = 0;
+
+		if (sscanf(strs[4].c_str(), "%u", &location) != 1)
+			location = 0;
+
+		if (sscanf(strs[6].c_str(), "%u", &isGameplay) != 1)
+			isGameplay = 0;
+
+		LineData lineData;
+		lineData.durationMSec = static_cast<uint32>(duration * 1000.0);
+		lineData.timeOffsetMSec = static_cast<uint32>(timestamp * 1000.0);
+		lineData.textUTF8 = strs[1];
+		lineData.location = location;
+		lineData.speakerID = speakerTable.getSpeakerID(strs[5]);
+		lineData.isGameplay = isGameplay;
+
+		LineRange &range = _lineRanges[strs[0]];
+		if (range.numLines == 0)
+			range.linesStart = currentLine;
+		else if (range.linesStart + range.numLines != currentLine) {
+			warning("Failed to load lines table, subtitle set '%s' was not contiguous", strs[0].c_str());
+			return Common::kReadingFailed;
+		}
+
+		range.numLines++;
+
+		_lines.push_back(lineData);
+
+		currentLine++;
+	}
+
+	return Common::kNoError;
+}
+
+SubtitleLineTable::LineRange::LineRange() : linesStart(0), numLines(0) {
+}
+
+
+SubtitleLineTable::LineData::LineData() : timeOffsetMSec(0), location(0), durationMSec(0), speakerID(0), isGameplay(false) {
+}
+
+const Common::Array<SubtitleLineTable::LineData> &SubtitleLineTable::getAllLines() const {
+	return _lines;
+}
+
+const SubtitleLineTable::LineRange *SubtitleLineTable::getLinesForSubtitleSetID(const Common::String &subtitleSetID) const {
+	Common::HashMap<Common::String, LineRange>::const_iterator it = _lineRanges.find(subtitleSetID);
+	if (it == _lineRanges.end())
+		return nullptr;
+	return &it->_value;
+}
+
+SubtitleRenderer::DisplayItem::DisplayItem() : expireTime(0) {
+}
+
+SubtitleDisplayItem::SubtitleDisplayItem(const Common::String &text, const Common::String &speaker, uint slot) : _slot(slot) {
+	_text = text.decode(Common::kUtf8);
+	_speaker = speaker.decode(Common::kUtf8);
+}
+
+const Common::U32String &SubtitleDisplayItem::getText() const {
+	return _text;
+}
+
+const Common::U32String &SubtitleDisplayItem::getSpeaker() const {
+	return _speaker;
+}
+
+uint SubtitleDisplayItem::getSlot() const {
+	return _slot;
+}
+
+SubtitleRenderer::SubtitleRenderer() : _nonImmediateDisappearTime(3500), _isDirty(true), _lastTime(0) {
+	Common::File fontFile;
+	const char *fontPath = "LiberationSans-Bold.ttf";
+
+	_font.reset(Graphics::loadTTFFontFromArchive(fontPath, 14, Graphics::kTTFSizeModeCell));
+
+	if (!_font)
+		warning("Couldn't open '%s', subtitles will not work", fontPath);
+}
+
+void SubtitleRenderer::addDisplayItem(const Common::SharedPtr<SubtitleDisplayItem> &item, uint duration) {
+	assert(item.get() != nullptr);
+
+	_isDirty = true;
+
+	for (DisplayItem &existingItem : _displayItems) {
+		if (existingItem.item->getSlot() == item->getSlot()) {
+			existingItem.item = item;
+			if (duration > 0)
+				existingItem.expireTime = _lastTime + duration;
+			else
+				existingItem.expireTime = 0;
+			return;
+		}
+	}
+
+	DisplayItem newItem;
+	newItem.expireTime = 0;
+	newItem.item = item;
+
+	if (duration > 0)
+		newItem.expireTime = _lastTime + duration;
+
+	_displayItems.push_back(newItem);
+}
+
+void SubtitleRenderer::removeDisplayItem(const SubtitleDisplayItem *item, bool immediately) {
+	if (item == nullptr)
+		return;
+
+	for (uint i = 0; i < _displayItems.size(); i++) {
+		DisplayItem &existingItem = _displayItems[i];
+		if (existingItem.item.get() == item) {
+			if (immediately) {
+				_displayItems.remove_at(i);
+				_isDirty = true;
+			} else {
+				if (existingItem.expireTime == 0)
+					existingItem.expireTime = _lastTime + _nonImmediateDisappearTime;
+			}
+
+			return;
+		}
+	}
+}
+
+bool SubtitleRenderer::update(uint64 currentTime) {
+	_lastTime = currentTime;
+
+	for (uint ridx = _displayItems.size(); ridx > 0; ridx--) {
+		uint i = ridx - 1;
+		DisplayItem &item = _displayItems[i];
+		if (item.expireTime != 0 && item.expireTime <= currentTime) {
+			_displayItems.remove_at(i);
+			_isDirty = true;
+		}
+	}
+
+	if (_isDirty) {
+		render();
+		_isDirty = false;
+		return true;
+	}
+
+	return false;
+}
+
+void SubtitleRenderer::composite(Window &window) const {
+	if (_surface) {
+		Graphics::ManagedSurface *windowSurf = window.getSurface().get();
+		if (windowSurf) {
+			int x = (windowSurf->w - _surface->w) / 2;
+			int y = (windowSurf->h + 300) / 2 - _surface->h;
+			windowSurf->blitFrom(*_surface, Common::Point(x, y));
+		}
+	}
+}
+
+bool SubtitleRenderer::isDirty() const {
+	return _isDirty;
+}
+
+void SubtitleRenderer::render() {
+	if (!_font)
+		return;
+
+	uint widestLine = 0;
+	uint numLines = 0;
+	for (const DisplayItem &item : _displayItems) {
+		if (item.item) {
+			const SubtitleDisplayItem &dispItem = *item.item;
+			Common::Array<Common::U32String> lines;
+
+			splitLines(dispItem.getText(), lines);
+
+			for (const Common::U32String &str : lines) {
+				uint width = _font->getStringWidth(str);
+				if (width > widestLine)
+					widestLine = width;
+			}
+
+			uint itemLines = lines.size();
+
+			const Common::U32String &speaker = item.item->getSpeaker();
+
+			if (speaker.size() > 0) {
+				itemLines++;
+				uint speakerWidth = _font->getStringWidth(speaker);
+				if (speakerWidth > widestLine)
+					widestLine = speakerWidth;
+			}
+
+			if (itemLines > numLines)
+				numLines = itemLines;
+		}
+	}
+
+	_surface.reset();
+
+	if (numLines == 0 || widestLine == 0)
+		return;
+
+	int fontHeight = _font->getFontHeight();
+
+	const int borderWidth = 1;
+	const int verticalPadding = 5;
+	const int horizontalPadding = 20;
+
+	int surfaceWidth = static_cast<int>(widestLine) + borderWidth * 2 + horizontalPadding * 2;
+	int surfaceHeight = static_cast<int>(numLines) * fontHeight + borderWidth * 2 + verticalPadding * 2;
+
+	Graphics::PixelFormat fmt = Graphics::createPixelFormat<8888>();
+	_surface.reset(new Graphics::ManagedSurface(surfaceWidth, surfaceHeight, fmt));
+
+	_surface->fillRect(Common::Rect(0, 0, surfaceWidth, surfaceHeight), fmt.RGBToColor(0, 0, 0));
+
+	for (int drawPass = 0; drawPass < 2; drawPass++) {
+		for (const DisplayItem &item : _displayItems) {
+			if (item.item) {
+				const SubtitleDisplayItem &dispItem = *item.item;
+				Common::Array<Common::U32String> lines;
+
+				const Common::U32String &itemText = dispItem.getText();
+
+				int speakerLine = 0;
+				for (uint i = 0; i < itemText.size(); i++) {
+					if (itemText[i] == '\\')
+						speakerLine++;
+					else
+						break;
+				}
+
+				splitLines(itemText, lines);
+
+				for (const Common::U32String &str : lines) {
+					uint width = _font->getStringWidth(str);
+					if (width > widestLine)
+						widestLine = width;
+				}
+
+				const Common::U32String &speaker = item.item->getSpeaker();
+
+				int textStartLine = 0;
+				if (speaker.size() > 0) {
+					textStartLine++;
+
+					int speakerWidth = _font->getStringWidth(speaker);
+
+					int startX = (surfaceWidth - speakerWidth) / 2;
+
+					uint32 drawColor = 0;
+					if (drawPass == 0)
+						drawColor = fmt.RGBToColor(255, 0, 0);
+					else
+						drawColor = fmt.RGBToColor(255, 255, 127);
+
+					_font->drawString(_surface.get(), speaker, startX, speakerLine * fontHeight + verticalPadding + borderWidth, speakerWidth, drawColor);
+				}
+
+				for (uint lineIndex = 0; lineIndex < lines.size(); lineIndex++) {
+					const Common::U32String &line = lines[lineIndex];
+
+					int lineWidth = _font->getStringWidth(line);
+
+					int startX = (surfaceWidth - lineWidth) / 2;
+
+					uint32 drawColor = 0;
+					if (drawPass == 0)
+						drawColor = fmt.RGBToColor(255, 0, 0);
+					else
+						drawColor = fmt.RGBToColor(255, 255, 255);
+
+					_font->drawString(_surface.get(), line, startX, (textStartLine + static_cast<int>(lineIndex)) * fontHeight + verticalPadding + borderWidth, lineWidth, drawColor);
+				}
+			}
+		}
+
+		if (drawPass == 0) {
+			int w = _surface->w;
+			int h = _surface->h;
+
+			// Horizontal pass (max r -> g)
+			for (int y = borderWidth; y < h - borderWidth; y++) {
+				uint32 *rowPixels = static_cast<uint32 *>(_surface->getBasePtr(0, y));
+
+				for (int x = borderWidth; x < w - borderWidth; x++) {
+					uint8 r, g, b, a;
+					uint8 brightest = 0;
+
+					for (int kxo = -borderWidth; kxo < borderWidth; kxo++) {
+						fmt.colorToARGB(rowPixels[x + kxo], a, r, g, b);
+
+						if (r > brightest)
+							brightest = r;
+					}
+
+					fmt.colorToARGB(rowPixels[x], a, r, g, b);
+					rowPixels[x] = fmt.ARGBToColor(a, r, brightest, b);
+				}
+			}
+
+			// Vertical pass (max g -> b)
+			int pitch = _surface->pitch;
+			for (int x = borderWidth; x < w - borderWidth; x++) {
+				char *basePixelPtr = static_cast<char *>(_surface->getBasePtr(x, 0));
+
+				for (int y = borderWidth; y < h - borderWidth; y++) {
+					uint8 r, g, b, a;
+					uint8 brightest = 0;
+
+					for (int kyo = -borderWidth; kyo < borderWidth; kyo++) {
+						uint32 *offsetPxPtr = reinterpret_cast<uint32 *>(basePixelPtr + (y + kyo) * pitch);
+						fmt.colorToARGB(*offsetPxPtr, a, r, g, b);
+
+						if (g > brightest)
+							brightest = g;
+					}
+
+					uint32 *pxPtr = reinterpret_cast<uint32 *>(basePixelPtr + y * pitch);
+					fmt.colorToARGB(*pxPtr, a, r, g, b);
+					*pxPtr = fmt.ARGBToColor(a, r, g, brightest);
+				}
+			}
+
+			// Shadow backdrop pass
+			for (int y = 0; y < h; y++) {
+				uint32 *rowPixels = static_cast<uint32 *>(_surface->getBasePtr(0, y));
+
+				for (int x = 0; x < w; x++) {
+					uint8 a, r, g, b;
+					fmt.colorToARGB(rowPixels[x], a, r, g, b);
+
+					uint8 minGrayAlpha = 224;
+					uint8 grayAlpha = (((256 - minGrayAlpha) * b) >> 8) + minGrayAlpha;
+
+					rowPixels[x] = fmt.ARGBToColor(grayAlpha, 0, 0, 0);
+				}
+			}
+		}
+	}
+}
+
+void SubtitleRenderer::splitLines(const Common::U32String &str, Common::Array<Common::U32String> &outLines) {
+	uint32 splitScanStart = 0;
+	while (splitScanStart < str.size()) {
+		size_t splitLoc = str.find('\\', splitScanStart);
+		if (splitLoc == Common::U32String::npos)
+			break;
+
+		outLines.push_back(str.substr(splitScanStart, splitLoc - splitScanStart));
+		splitScanStart = static_cast<uint32>(splitLoc + 1);
+	}
+
+	outLines.push_back(str.substr(splitScanStart));
+}
+
+
+SubtitlePlayer::SubtitlePlayer(Runtime *runtime, const Common::String &subtitleSetID, const SubtitleTables &tables) : _runtime(runtime) {
+	const SubtitleLineTable::LineRange *lineRange = tables.lines->getLinesForSubtitleSetID(subtitleSetID);
+	if (lineRange) {
+		_lineRange = *lineRange;
+	} else {
+		warning("Subtitle set '%s' was defined, but no lines were defined", subtitleSetID.c_str());
+		return;
+	}
+
+	_speakers = tables.speakers;
+	_lines = tables.lines;
+}
+
+SubtitlePlayer::~SubtitlePlayer() {
+	stop();
+}
+
+void SubtitlePlayer::update(uint64 prevTime, uint64 newTime) {
+	if (!_lineRange.numLines)
+		return;
+
+	const Common::Array<SubtitleLineTable::LineData> &allLines = _lines->getAllLines();
+
+	uint numLines = _lineRange.numLines;
+	for (uint i = 0; i < _lineRange.numLines; i++) {
+		const SubtitleLineTable::LineData &line = allLines[_lineRange.linesStart + i];
+		if (line.timeOffsetMSec >= prevTime && line.timeOffsetMSec < newTime)
+			triggerSubtitleLine(line);
+	}
+}
+
+void SubtitlePlayer::stop() {
+	SubtitleRenderer *renderer = _runtime->getSubtitleRenderer().get();
+	if (renderer) {
+		for (const Common::SharedPtr<SubtitleDisplayItem> &item : _items)
+			renderer->removeDisplayItem(item.get(), false);
+	}
+	_items.clear();
+}
+
+void SubtitlePlayer::triggerSubtitleLine(const SubtitleLineTable::LineData &line) {
+	SubtitleRenderer *renderer = _runtime->getSubtitleRenderer().get();
+	if (renderer) {
+		Common::SharedPtr<SubtitleDisplayItem> dispItem(new SubtitleDisplayItem(line.textUTF8, _speakers->getSpeakers()[line.speakerID], line.location));
+		for (uint i = 0; i < _items.size(); i++) {
+			if (_items[i]->getSlot() == line.location) {
+				renderer->removeDisplayItem(_items[i].get(), true);
+				_items.remove_at(i);
+				break;
+			}
+		}
+
+		renderer->addDisplayItem(dispItem, line.durationMSec);
+		_items.push_back(dispItem);
+	}
+}
+
+} // End of namespace MTropolis
diff --git a/engines/mtropolis/subtitles.h b/engines/mtropolis/subtitles.h
new file mode 100644
index 00000000000..d226bb59fc6
--- /dev/null
+++ b/engines/mtropolis/subtitles.h
@@ -0,0 +1,191 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MTROPOLIS_SUBTITLES_H
+#define MTROPOLIS_SUBTITLES_H
+
+#include "common/array.h"
+#include "common/error.h"
+#include "common/str.h"
+#include "common/hash-str.h"
+#include "common/hashmap.h"
+#include "common/ptr.h"
+
+namespace Graphics {
+
+class Font;
+class ManagedSurface;
+
+} // End of namespace Graphics
+
+namespace MTropolis {
+
+class Runtime;
+class Window;
+
+class SubtitleAssetMappingTable {
+public:
+	SubtitleAssetMappingTable();
+
+	Common::ErrorCode load(const Common::String &filePath);
+
+	const Common::String *findSubtitleSetForAssetID(uint32 assetID) const;
+	const Common::String *findSubtitleSetForAssetName(const Common::String &assetName) const;
+
+private:
+	Common::HashMap<uint32, Common::String> _assetIDToSubtitleSet;
+	Common::HashMap<Common::String, Common::String> _assetNameToSubtitleSet;
+};
+
+class SubtitleModifierMappingTable {
+public:
+	SubtitleModifierMappingTable();
+
+	Common::ErrorCode load(const Common::String &filePath);
+
+	const Common::String *findSubtitleSetForModifierGUID(uint32 guid) const;
+
+private:
+	Common::HashMap<uint32, Common::String> _guidToSubtitleSet;
+};
+
+class SubtitleSpeakerTable {
+public:
+	SubtitleSpeakerTable();
+
+	Common::ErrorCode load(const Common::String &filePath);
+
+	const Common::Array<Common::String> &getSpeakers() const;
+	uint getSpeakerID(const Common::String &speakerName) const;
+
+private:
+	Common::Array<Common::String> _speakers;
+	Common::HashMap<Common::String, uint> _speakerToID;
+};
+
+class SubtitleLineTable {
+public:
+	SubtitleLineTable();
+
+	Common::ErrorCode load(const Common::String &filePath, const SubtitleSpeakerTable &speakerTable);
+
+	struct LineRange {
+		LineRange();
+
+		uint linesStart;
+		uint numLines;
+	};
+
+	struct LineData {
+		LineData();
+
+		uint32 timeOffsetMSec;
+		uint location;
+		uint durationMSec;
+		Common::String textUTF8;
+		uint speakerID;
+		bool isGameplay;
+	};
+
+	const Common::Array<LineData> &getAllLines() const;
+	const LineRange *getLinesForSubtitleSetID(const Common::String &subtitleSetID) const;
+
+private:
+	Common::Array<LineData> _lines;
+	Common::HashMap<Common::String, LineRange> _lineRanges;
+};
+
+
+struct SubtitleTables {
+	Common::SharedPtr<SubtitleAssetMappingTable> assetMapping;
+	Common::SharedPtr<SubtitleModifierMappingTable> modifierMapping;
+	Common::SharedPtr<SubtitleSpeakerTable> speakers;
+	Common::SharedPtr<SubtitleLineTable> lines;
+};
+
+class SubtitleDisplayItem {
+public:
+	SubtitleDisplayItem(const Common::String &text, const Common::String &speaker, uint slot);
+
+	const Common::U32String &getText() const;
+	const Common::U32String &getSpeaker() const;
+	uint getSlot() const;
+
+private:
+	Common::U32String _text;
+	Common::U32String _speaker;
+	uint _slot;
+};
+
+class SubtitleRenderer {
+public:
+	SubtitleRenderer();
+
+	void addDisplayItem(const Common::SharedPtr<SubtitleDisplayItem> &item, uint duration);
+	void removeDisplayItem(const SubtitleDisplayItem *item, bool immediately);
+
+	bool update(uint64 currentTime);	// Updates the subtitle player, returns true if any changes occurred since last update
+
+	void composite(Window &window) const;
+	bool isDirty() const;
+
+private:
+	struct DisplayItem {
+		DisplayItem();
+
+		Common::SharedPtr<SubtitleDisplayItem> item;
+		uint64 expireTime;
+	};
+
+	void render();
+	static void splitLines(const Common::U32String &str, Common::Array<Common::U32String> &outLines);
+
+	Common::Array<DisplayItem> _displayItems;
+	Common::SharedPtr<Graphics::ManagedSurface> _surface;
+	Common::SharedPtr<Graphics::Font> _font;
+	uint64 _lastTime;
+	uint _nonImmediateDisappearTime;
+	bool _isDirty;
+};
+
+class SubtitlePlayer {
+public:
+	SubtitlePlayer(Runtime *runtime, const Common::String &subtitleSetID, const SubtitleTables &tables);
+	~SubtitlePlayer();
+
+	void update(uint64 prevTime, uint64 newTime);
+	void stop();
+
+private:
+	void triggerSubtitleLine(const SubtitleLineTable::LineData &line);
+
+	Common::Array<Common::SharedPtr<SubtitleDisplayItem> > _items;
+
+	Common::SharedPtr<SubtitleSpeakerTable> _speakers;
+	Common::SharedPtr<SubtitleLineTable> _lines;
+
+	SubtitleLineTable::LineRange _lineRange;
+	Runtime *_runtime;
+};
+
+} // End of namespace MTropolis
+
+#endif




More information about the Scummvm-git-logs mailing list