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

dreammaster noreply at scummvm.org
Mon Aug 10 12:35:15 UTC 2026


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

Summary:
1690385297 MADS: Declare engine MIDI component dependency
0e9da88328 MADS: Add Rex Nebular Bonus Disk support
114d094748 MADS: NEBULAR: Fix Bonus Disk playback
986ad9c709 MADS: Fix AnimView cleanup after early exit
eb6c8285fb MADS: NEBULAR: Handle Bonus Disk PC Speaker music
08f28056a5 MADS: Reposition Bonus Disk goodbye message
c9322e3ae3 MADS: Remove obsolete Bonus Disk diagnostics


Commit: 1690385297fe7b246205b634f43bd567cac676ee
    https://github.com/scummvm/scummvm/commit/1690385297fe7b246205b634f43bd567cac676ee
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: Declare engine MIDI component dependency

Declare the MADS MIDI dependency so build configurations containing only
the MADS engine still build MIDI support. Without it, MIDI support is
omitted from the build, which also disables the MT-32 support required
by the engine's sound drivers.

Changed paths:
    engines/mads/configure.engine


diff --git a/engines/mads/configure.engine b/engines/mads/configure.engine
index 911539063d2..71627289188 100644
--- a/engines/mads/configure.engine
+++ b/engines/mads/configure.engine
@@ -1,3 +1,3 @@
 # This file is included from the main "configure" script
 # add_engine [name] [desc] [build-by-default] [subengines] [base games] [deps] [components]
-add_engine mads "MADS" no "" "Microprose Software Inc."
+add_engine mads "MADS" no "" "Microprose Software Inc." "" "midi"


Commit: 0e9da88328f66ca22c1151172ffb168ba3161f47
    https://github.com/scummvm/scummvm/commit/0e9da88328f66ca22c1151172ffb168ba3161f47
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: Add Rex Nebular Bonus Disk support

Add the Bonus Disk as a separate target detected through SECTION0.HAG.
Implement its DOS text interface, resource validation, text viewer,
AnimView presentations and music-selection controller using strings read
from BONUS.EXE.
Use the regular Rex sound manager to validate the selected driver
family, load section drivers and monitor natural completion.

Assisted-by: Codex:GPT-5.4

Changed paths:
  A engines/mads/nebular/bonus/bonus.cpp
  A engines/mads/nebular/bonus/bonus.h
  A engines/mads/nebular/bonus/bonus_exe_data.cpp
  A engines/mads/nebular/bonus/bonus_exe_data.h
  A engines/mads/nebular/bonus/bonus_text_ui.cpp
  A engines/mads/nebular/bonus/bonus_text_ui.h
  A engines/mads/nebular/bonus/dos_text_screen.cpp
  A engines/mads/nebular/bonus/dos_text_screen.h
    engines/mads/animview/animview.cpp
    engines/mads/core/sound_manager.cpp
    engines/mads/core/sound_manager.h
    engines/mads/detection.cpp
    engines/mads/detection.h
    engines/mads/detection_tables.h
    engines/mads/metaengine.cpp
    engines/mads/module.mk
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound.h
    engines/mads/nebular/sound/sound.cpp


diff --git a/engines/mads/animview/animview.cpp b/engines/mads/animview/animview.cpp
index d07124185ae..361b860567b 100644
--- a/engines/mads/animview/animview.cpp
+++ b/engines/mads/animview/animview.cpp
@@ -355,7 +355,7 @@ static void run_animation(int animIndex) {
 
 			if (!exit_immediately_at_end)
 				continue;
-			if (g_engine->_soundManager->command(8))
+			if (g_engine->_soundManager->isDriverActive())
 				continue;
 			current_error_code = 1;
 		}
diff --git a/engines/mads/core/sound_manager.cpp b/engines/mads/core/sound_manager.cpp
index 3e080060506..6b20a316961 100644
--- a/engines/mads/core/sound_manager.cpp
+++ b/engines/mads/core/sound_manager.cpp
@@ -68,6 +68,10 @@ void SoundManager::init(int sectionNumber) {
 	_driver->setVolume(_masterVolume);
 }
 
+bool SoundManager::isDriverActive() {
+	return _driver && _soundFlag && _driver->command(8, 0) != 0;
+}
+
 void SoundManager::closeDriver() {
 	if (_driver) {
 		command(0);
diff --git a/engines/mads/core/sound_manager.h b/engines/mads/core/sound_manager.h
index de6bbd35aff..4bbbd2b81a6 100644
--- a/engines/mads/core/sound_manager.h
+++ b/engines/mads/core/sound_manager.h
@@ -125,6 +125,11 @@ public:
 		return _driver != nullptr;
 	}
 
+	/**
+	 * Returns whether the current driver reports an active sound.
+	 */
+	bool isDriverActive();
+
 	/**
 	 * Stop any currently active sound and remove the driver
 	 */
diff --git a/engines/mads/detection.cpp b/engines/mads/detection.cpp
index a16ba8a9566..04fe3e974c0 100644
--- a/engines/mads/detection.cpp
+++ b/engines/mads/detection.cpp
@@ -31,6 +31,7 @@
 static const PlainGameDescriptor MADSGames[] = {
 	{"dragonsphere", "Dragonsphere"},
 	{"nebular", "Rex Nebular and the Cosmic Gender Bender"},
+	{"nebularbonus", "Rex Nebular Bonus Disk"},
 	{"phantom", "Return of the Phantom"},
 	{"forest", "Once Upon a Forest"},
 	{nullptr, nullptr}
diff --git a/engines/mads/detection.h b/engines/mads/detection.h
index 20b72658739..f729e9cb2f4 100644
--- a/engines/mads/detection.h
+++ b/engines/mads/detection.h
@@ -35,7 +35,8 @@ enum {
 };
 
 enum {
-	GF_INSTALLER = 1
+	GF_INSTALLER = 1,
+	GF_BONUS_DISK = 1 << 1
 };
 
 struct MADSGameDescription {
diff --git a/engines/mads/detection_tables.h b/engines/mads/detection_tables.h
index 24260a79829..98773ee3ef9 100644
--- a/engines/mads/detection_tables.h
+++ b/engines/mads/detection_tables.h
@@ -169,6 +169,21 @@ static const MADSGameDescription gameDescriptions[] = {
 		0
 	},
 
+	{
+		// Rex Nebular and the Cosmic Gender Bender Bonus Disk
+		{
+			"nebularbonus",
+			0,
+			AD_ENTRY1s("section0.hag", "6aee8cef63774def1f9b33fef6262a47", 50710),
+			Common::EN_ANY,
+			Common::kPlatformDOS,
+			ADGF_UNSTABLE,
+			GUIO2(GUIO_NOSPEECH, GUIO_NOLAUNCHLOAD)
+		},
+		GType_RexNebular,
+		GF_BONUS_DISK
+	},
+
 	{
 		// Return of the Phantom DOS English
 		{
diff --git a/engines/mads/metaengine.cpp b/engines/mads/metaengine.cpp
index e35413c5035..b0f6ad9de83 100644
--- a/engines/mads/metaengine.cpp
+++ b/engines/mads/metaengine.cpp
@@ -34,6 +34,7 @@
 #include "graphics/surface.h"
 #include "mads/detection.h"
 #include "mads/nebular/nebular.h"
+#include "mads/nebular/bonus/bonus.h"
 #include "mads/phantom/phantom.h"
 #include "mads/dragonsphere/dragonsphere.h"
 #include "mads/forest/forest.h"
@@ -210,9 +211,12 @@ bool MADS::MADSEngine::hasFeature(EngineFeature f) const {
 }
 
 Common::Error MADSMetaEngine::createInstance(OSystem *syst, Engine **engine, const MADS::MADSGameDescription *desc) const {
-	if (desc->gameID == MADS::GType_RexNebular)
-		*engine = new MADS::RexNebular::RexNebularEngine(syst, desc);
-	else if (desc->gameID == MADS::GType_Phantom)
+	if (desc->gameID == MADS::GType_RexNebular) {
+		if (desc->features & MADS::GF_BONUS_DISK)
+			*engine = new MADS::RexNebular::BonusEngine(syst, desc);
+		else
+			*engine = new MADS::RexNebular::RexNebularEngine(syst, desc);
+	} else if (desc->gameID == MADS::GType_Phantom)
 		*engine = new MADS::Phantom::PhantomEngine(syst, desc);
 	else if (desc->gameID == MADS::GType_Forest)
 		*engine = new MADS::Forest::ForestEngine(syst, desc);
diff --git a/engines/mads/module.mk b/engines/mads/module.mk
index a5610f87a95..3a9881231d7 100644
--- a/engines/mads/module.mk
+++ b/engines/mads/module.mk
@@ -67,6 +67,10 @@ MODULE_OBJS := \
 	core/vocab.o \
 	core/window.o \
 	core/xms.o \
+	nebular/bonus/bonus.o \
+	nebular/bonus/bonus_exe_data.o \
+	nebular/bonus/bonus_text_ui.o \
+	nebular/bonus/dos_text_screen.o \
 	nebular/nebular.o \
 	nebular/console.o \
 	nebular/copy.o \
diff --git a/engines/mads/nebular/bonus/bonus.cpp b/engines/mads/nebular/bonus/bonus.cpp
new file mode 100644
index 00000000000..eac5016b317
--- /dev/null
+++ b/engines/mads/nebular/bonus/bonus.cpp
@@ -0,0 +1,326 @@
+/* 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.
+ */
+
+#include "mads/nebular/bonus/bonus.h"
+
+#include "common/array.h"
+#include "common/file.h"
+#include "common/func.h"
+#include "common/path.h"
+#include "common/str.h"
+#include "common/textconsole.h"
+#include "common/util.h"
+#include "engines/util.h"
+#include "graphics/pixelformat.h"
+#include "graphics/screen.h"
+#include "gui/message.h"
+#include "mads/animview/animview.h"
+#include "mads/core/env.h"
+#include "mads/core/game.h"
+#include "mads/core/screen.h"
+#include "mads/nebular/bonus/bonus_exe_data.h"
+#include "mads/nebular/bonus/bonus_text_ui.h"
+#include "mads/nebular/console.h"
+#include "mads/nebular/sound/sound.h"
+
+namespace MADS {
+namespace RexNebular {
+
+namespace {
+
+struct BonusTrack {
+	byte section;
+	byte command;
+};
+
+static const BonusTrack kBonusTracks[] = {
+	{ 1, 12 }, { 1, 10 }, { 9, 49 }, { 2, 9 },
+	{ 2, 15 }, { 2, 19 }, { 3, 11 }, { 3, 10 },
+	{ 3, 16 }, { 3, 17 }, { 4, 12 }, { 5, 29 },
+	{ 6, 24 }, { 7, 24 }, { 7, 27 }, { 7, 9 }
+};
+
+static void showError(const Common::String &message) {
+	GUI::MessageDialog dialog(message);
+	dialog.runModal();
+}
+
+static bool containsIgnoreCase(const Common::Array<Common::String> &strings,
+		const Common::String &value) {
+	for (uint i = 0; i < strings.size(); ++i) {
+		if (strings[i].equalsIgnoreCase(value))
+			return true;
+	}
+	return false;
+}
+
+static bool reportMissingFiles(const char *action,
+		const Common::Array<Common::String> &missingFiles) {
+	if (missingFiles.empty())
+		return true;
+
+	Common::String names;
+	for (uint i = 0; i < missingFiles.size(); ++i) {
+		Common::String displayName = missingFiles[i];
+		displayName.toUppercase();
+		warning("MADS Bonus: %s requires missing file %s",
+				action, displayName.c_str());
+		if (!names.empty())
+			names += '\n';
+		names += displayName;
+	}
+
+	Common::String message = Common::String::format(
+			"The Rex Nebular Bonus Disk cannot %s because the following "
+			"file%s missing:\n\n%s",
+			action, missingFiles.size() == 1 ? " is" : "s are", names.c_str());
+	showError(message);
+	return false;
+}
+
+static const char *skipResourceFlags(const char *line) {
+	while (*line == '/' || *line == '-') {
+		const char *space = strchr(line, ' ');
+		if (!space)
+			return line + strlen(line);
+		line = space;
+		while (*line == ' ')
+			++line;
+	}
+	return line;
+}
+
+static bool checkPresentationFiles(const char *resourceName,
+		const char *action) {
+	Common::Array<Common::String> missingFiles;
+	Common::File resource;
+	if (!resource.open(resourceName)) {
+		missingFiles.push_back(resourceName);
+		return reportMissingFiles(action, missingFiles);
+	}
+
+	while (!resource.eos() && !resource.err()) {
+		Common::String line = resource.readLine();
+		line.trim();
+		if (line.empty())
+			continue;
+
+		const char *animationName = skipResourceFlags(line.c_str());
+		if (!*animationName)
+			continue;
+
+		Common::String lookupName(animationName);
+		if (!strchr(lookupName.c_str(), '.'))
+			lookupName += ".aa";
+		lookupName = Common::String("*") + lookupName;
+
+		char dataFilename[80] = { 0 };
+		if (!env_get_path(dataFilename, lookupName.c_str())) {
+			if (!containsIgnoreCase(missingFiles, animationName))
+				missingFiles.push_back(animationName);
+			continue;
+		}
+		if (!Common::File::exists(dataFilename) &&
+				!containsIgnoreCase(missingFiles, dataFilename))
+			missingFiles.push_back(dataFilename);
+	}
+
+	return reportMissingFiles(action, missingFiles);
+}
+
+class BonusApplication {
+public:
+	explicit BonusApplication(Sound::RexSoundManager &soundManager) :
+			_ui(_text), _soundManager(soundManager),
+			_mainSelection(0), _musicSelection(0),
+			_section3ExtraCommand(false) {
+	}
+
+	bool init(Common::String &errorMessage) {
+		if (!_text.load("bonus.exe", errorMessage))
+			return false;
+		return _ui.init(errorMessage);
+	}
+
+	void run() {
+		while (!g_engine->shouldQuit()) {
+			switch (_ui.runMainMenu(_mainSelection)) {
+			case BonusTextUI::kDeathScenes:
+				if (checkPresentationFiles("death.res", "show the death scenes"))
+					AnimView::animview_main("@death");
+				break;
+			case BonusTextUI::kEvolution:
+				if (checkPresentationFiles("evolve.res", "show the evolution sequence"))
+					AnimView::animview_main("@evolve");
+				break;
+			case BonusTextUI::kSets:
+				if (checkPresentationFiles("sets.res", "show the Rex Nebular sets"))
+					AnimView::animview_main("@sets");
+				break;
+			case BonusTextUI::kMusic:
+				runMusicMenu();
+				break;
+			case BonusTextUI::kCoolStuff:
+				showBonusText();
+				break;
+			case BonusTextUI::kExit:
+				_ui.showGoodbye();
+				g_engine->quitGame();
+				return;
+			case BonusTextUI::kAbort:
+				return;
+			}
+		}
+	}
+
+private:
+	BonusExeData _text;
+	BonusTextUI _ui;
+	Sound::RexSoundManager &_soundManager;
+	int _mainSelection;
+	int _musicSelection;
+	bool _section3ExtraCommand;
+
+	void runMusicMenu() {
+		while (!g_engine->shouldQuit()) {
+			const int selected = _ui.runMusicMenu(_musicSelection);
+			if (selected < 0 || selected == (int)ARRAYSIZE(kBonusTracks))
+				return;
+			playTrack(selected);
+		}
+	}
+
+	void playTrack(int index) {
+		if (index < 0 || index >= (int)ARRAYSIZE(kBonusTracks))
+			return;
+
+		const BonusTrack &track = kBonusTracks[index];
+		_soundManager.init(track.section);
+		_soundManager.command(track.command, 127);
+
+		// These follow the control flow surrounding the native track table in
+		// BONUS.EXE; they are player commands, not additional track entries.
+		if (track.section == 2 && track.command == 9) {
+			_soundManager.command(17);
+		} else if (track.section == 3 && track.command == 16) {
+			_section3ExtraCommand = !_section3ExtraCommand;
+			if (!_section3ExtraCommand)
+				_soundManager.command(16);
+		} else if (track.section == 7 && track.command == 9 &&
+				!_soundManager.isDriverActive()) {
+			_soundManager.command(25, 127);
+		}
+
+		Common::Functor0Mem<bool, BonusApplication> isPlaying(
+				this, &BonusApplication::isDriverActive);
+		_ui.showNowPlaying(_text.musicTitles[index], isPlaying);
+		if (_soundManager.isLoaded())
+			_soundManager.closeDriver();
+	}
+
+	bool isDriverActive() {
+		return _soundManager.isDriverActive();
+	}
+
+	void showBonusText() {
+		if (_ui.showBonusText(Common::Path(_text.bonusTextFilename)) ||
+				g_engine->shouldQuit())
+			return;
+
+		Common::Array<Common::String> missingFiles;
+		missingFiles.push_back(_text.bonusTextFilename);
+		reportMissingFiles("show the Cool Stuff list", missingFiles);
+	}
+};
+
+} // namespace
+
+BonusEngine::BonusEngine(OSystem *syst, const MADSGameDescription *gameDesc) :
+		RexNebularEngine(syst, gameDesc) {
+}
+
+BonusEngine::~BonusEngine() {
+	_presentation.free();
+}
+
+Common::Error BonusEngine::run() {
+	initGraphics(640, 400);
+	applyGameSettings();
+
+	_screen = new Graphics::Screen(320, 200);
+	scr_live.data = (byte *)_screen->getPixels();
+	_presentation.create(640, 400, Graphics::PixelFormat::createFormatCLUT8());
+
+	setDebugger(new Console());
+
+	if (!Common::File::exists("bonus.exe")) {
+		warning("MADS Bonus: required file BONUS.EXE is missing");
+		return Common::Error(Common::kNoGameDataFoundError,
+				"The Rex Nebular Bonus Disk requires BONUS.EXE");
+	}
+
+	if (!Common::File::exists("section9.hag"))
+		warning("SECTION9.HAG not found. The Bonus Disk requires data from the full game.");
+
+	readConfigFile();
+	env_search_mode = ENV_SEARCH_CONCAT_FILES;
+	art_hags_are_on_hd = true;
+
+	Sound::RexSoundManager *soundManager =
+			new Sound::RexSoundManager(_mixer, _soundFlag, false);
+	_soundManager = soundManager;
+	soundManager->validate();
+
+	BonusApplication application(*soundManager);
+	Common::String errorMessage;
+	if (!application.init(errorMessage))
+		return Common::Error(Common::kNoGameDataFoundError, errorMessage);
+
+	application.run();
+	return Common::kNoError;
+}
+
+bool BonusEngine::hasFeature(EngineFeature feature) const {
+	return feature == kSupportsReturnToLauncher;
+}
+
+Common::Point BonusEngine::screenToGame(const Common::Point &point) const {
+	return Common::Point(CLIP<int>(point.x / 2, 0, 319),
+			CLIP<int>(point.y / 2, 0, 199));
+}
+
+Common::Point BonusEngine::gameToScreen(const Common::Point &point) const {
+	return Common::Point(point.x * 2, point.y * 2);
+}
+
+void BonusEngine::presentScreen(int shakeOffset) {
+	const int shift = shakeOffset > 0 ? shakeOffset % 320 : 0;
+	for (int y = 0; y < 200; ++y) {
+		const byte *source = (const byte *)_screen->getBasePtr(0, y);
+		byte *line1 = (byte *)_presentation.getBasePtr(0, y * 2);
+		byte *line2 = (byte *)_presentation.getBasePtr(0, y * 2 + 1);
+
+		for (int x = 0; x < 320; ++x) {
+			const byte color = source[(x + shift) % 320];
+			line1[x * 2] = color;
+			line1[x * 2 + 1] = color;
+		}
+		memcpy(line2, line1, 640);
+	}
+
+	g_system->copyRectToScreen(_presentation.getPixels(), _presentation.pitch,
+			0, 0, 640, 400);
+	g_system->updateScreen();
+}
+
+} // namespace RexNebular
+} // namespace MADS
diff --git a/engines/mads/nebular/bonus/bonus.h b/engines/mads/nebular/bonus/bonus.h
new file mode 100644
index 00000000000..ead2caebae6
--- /dev/null
+++ b/engines/mads/nebular/bonus/bonus.h
@@ -0,0 +1,42 @@
+/* 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.
+ */
+
+#ifndef MADS_NEBULAR_BONUS_H
+#define MADS_NEBULAR_BONUS_H
+
+#include "graphics/surface.h"
+#include "mads/nebular/nebular.h"
+
+namespace MADS {
+namespace RexNebular {
+
+class BonusEngine final : public RexNebularEngine {
+public:
+	BonusEngine(OSystem *syst, const MADSGameDescription *gameDesc);
+	~BonusEngine() override;
+
+	Common::Error run() override;
+
+protected:
+	bool hasFeature(EngineFeature feature) const override;
+	Common::Point screenToGame(const Common::Point &point) const override;
+	Common::Point gameToScreen(const Common::Point &point) const override;
+	void presentScreen(int shakeOffset) override;
+
+private:
+	Graphics::Surface _presentation;
+};
+
+} // namespace RexNebular
+} // namespace MADS
+
+#endif // MADS_NEBULAR_BONUS_H
diff --git a/engines/mads/nebular/bonus/bonus_exe_data.cpp b/engines/mads/nebular/bonus/bonus_exe_data.cpp
new file mode 100644
index 00000000000..a42aa756dab
--- /dev/null
+++ b/engines/mads/nebular/bonus/bonus_exe_data.cpp
@@ -0,0 +1,123 @@
+/* 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.
+ */
+
+#include "mads/nebular/bonus/bonus_exe_data.h"
+
+#include "common/file.h"
+#include "common/stream.h"
+#include "common/util.h"
+
+namespace MADS {
+namespace RexNebular {
+
+namespace {
+
+static const BonusExeLayout kBonusExeLayout = {
+	0xE270,
+	{ 0x0072, 0x00C2, 0x0112 },
+	{
+		0x0309, 0x0324, 0x033E, 0x0352,
+		0x036A, 0x037E, 0x0392, 0x03AC,
+		0x03C6, 0x03E1, 0x03F7, 0x040E,
+		0x0427, 0x043F, 0x0452, 0x046B
+	},
+	0x0480,
+	0x0493,
+	0x04AD,
+	{ 0x04C6, 0x04E2, 0x04FB, 0x0512, 0x052C, 0x0544 },
+	0x0551,
+	0x0565,
+	0x0045,
+	0x0572
+};
+
+} // namespace
+
+const BonusExeLayout &BonusExeData::layout() {
+	return kBonusExeLayout;
+}
+
+bool BonusExeData::readStringAt(Common::SeekableReadStream &stream, uint32 offset,
+		Common::String &result, Common::String &errorMessage) {
+	if (!stream.seek(offset)) {
+		errorMessage = Common::String::format(
+				"Unable to read BONUS.EXE string at 0x%X", offset);
+		return false;
+	}
+
+	result = stream.readString();
+	if (stream.err()) {
+		errorMessage = Common::String::format(
+				"Unable to read BONUS.EXE string at 0x%X", offset);
+		return false;
+	}
+	return true;
+}
+
+bool BonusExeData::load(const Common::Path &filename, Common::String &errorMessage) {
+	Common::File executable;
+	if (!executable.open(filename)) {
+		errorMessage = Common::String::format("Unable to open %s",
+				filename.toString().c_str());
+		return false;
+	}
+
+	const BonusExeLayout &exeLayout = layout();
+	for (uint i = 0; i < ARRAYSIZE(title); ++i) {
+		if (!readStringAt(executable,
+				exeLayout.dataFileOffset + exeLayout.title[i], title[i], errorMessage))
+			return false;
+	}
+	for (uint i = 0; i < ARRAYSIZE(musicTitles); ++i) {
+		if (!readStringAt(executable,
+				exeLayout.dataFileOffset + exeLayout.musicTitles[i],
+				musicTitles[i], errorMessage))
+			return false;
+	}
+	if (!readStringAt(executable, exeLayout.dataFileOffset + exeLayout.musicExit,
+			musicExit, errorMessage))
+		return false;
+	if (!readStringAt(executable,
+			exeLayout.dataFileOffset + exeLayout.musicMenuTitle,
+			musicMenuTitle, errorMessage))
+		return false;
+	if (!readStringAt(executable, exeLayout.dataFileOffset + exeLayout.nowPlaying,
+			nowPlaying, errorMessage))
+		return false;
+	for (uint i = 0; i < ARRAYSIZE(mainMenu); ++i) {
+		if (!readStringAt(executable,
+				exeLayout.dataFileOffset + exeLayout.mainMenu[i],
+				mainMenu[i], errorMessage))
+			return false;
+	}
+	if (!readStringAt(executable,
+			exeLayout.dataFileOffset + exeLayout.mainMenuTitle,
+			mainMenuTitle, errorMessage))
+		return false;
+	if (!readStringAt(executable,
+			exeLayout.dataFileOffset + exeLayout.bonusTextFilename,
+			bonusTextFilename, errorMessage))
+		return false;
+	if (!readStringAt(executable,
+			exeLayout.dataFileOffset + exeLayout.continuePrompt,
+			continuePrompt, errorMessage))
+		return false;
+	if (!readStringAt(executable, exeLayout.dataFileOffset + exeLayout.goodbye,
+			goodbye, errorMessage))
+		return false;
+
+	goodbye.trim();
+	return true;
+}
+
+} // namespace RexNebular
+} // namespace MADS
diff --git a/engines/mads/nebular/bonus/bonus_exe_data.h b/engines/mads/nebular/bonus/bonus_exe_data.h
new file mode 100644
index 00000000000..04232814116
--- /dev/null
+++ b/engines/mads/nebular/bonus/bonus_exe_data.h
@@ -0,0 +1,67 @@
+/* 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.
+ */
+
+#ifndef MADS_NEBULAR_BONUS_EXE_DATA_H
+#define MADS_NEBULAR_BONUS_EXE_DATA_H
+
+#include "common/path.h"
+#include "common/scummsys.h"
+#include "common/str.h"
+
+namespace Common {
+class SeekableReadStream;
+}
+
+namespace MADS {
+namespace RexNebular {
+
+struct BonusExeLayout {
+	uint32 dataFileOffset;
+	uint16 title[3];
+	uint16 musicTitles[16];
+	uint16 musicExit;
+	uint16 musicMenuTitle;
+	uint16 nowPlaying;
+	uint16 mainMenu[6];
+	uint16 mainMenuTitle;
+	uint16 bonusTextFilename;
+	uint16 continuePrompt;
+	uint16 goodbye;
+};
+
+/** Text resources embedded in the known Rex Nebular BONUS.EXE layout. */
+class BonusExeData {
+public:
+	bool load(const Common::Path &filename, Common::String &errorMessage);
+
+	Common::String title[3];
+	Common::String musicTitles[16];
+	Common::String musicExit;
+	Common::String musicMenuTitle;
+	Common::String nowPlaying;
+	Common::String mainMenu[6];
+	Common::String mainMenuTitle;
+	Common::String bonusTextFilename;
+	Common::String continuePrompt;
+	Common::String goodbye;
+
+	static const BonusExeLayout &layout();
+
+private:
+	static bool readStringAt(Common::SeekableReadStream &stream, uint32 offset,
+			Common::String &result, Common::String &errorMessage);
+};
+
+} // namespace RexNebular
+} // namespace MADS
+
+#endif // MADS_NEBULAR_BONUS_EXE_DATA_H
diff --git a/engines/mads/nebular/bonus/bonus_text_ui.cpp b/engines/mads/nebular/bonus/bonus_text_ui.cpp
new file mode 100644
index 00000000000..2f106043e6b
--- /dev/null
+++ b/engines/mads/nebular/bonus/bonus_text_ui.cpp
@@ -0,0 +1,496 @@
+/* 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.
+ */
+
+#include "mads/nebular/bonus/bonus_text_ui.h"
+
+#include "common/events.h"
+#include "common/file.h"
+#include "common/keyboard.h"
+#include "common/system.h"
+#include "common/util.h"
+#include "graphics/pixelformat.h"
+#include "mads/core/mouse.h"
+#include "mads/core/timer.h"
+#include "mads/mads.h"
+
+namespace MADS {
+namespace RexNebular {
+
+namespace {
+
+static const byte kDesktopAttribute = 0x17;
+static const byte kDesktopCharacter = 0xB1;
+static const byte kShadowAttribute = 0x08;
+static const byte kNormalAttribute = 0x47;
+static const byte kSelectedAttribute = 0x74;
+static const byte kHotkeyAttribute = 0x4F;
+static const byte kSelectedHotkeyAttribute = 0x7F;
+static const byte kTextCursorXorMask = 0x77;
+static const byte kTitleAttribute = 0x1F;
+static const uint32 kGoodbyeDuration = 3000;
+
+} // namespace
+
+BonusTextUI::BonusTextUI(const BonusExeData &text) :
+		_text(text), _mouseCell(0, 0), _mouseCellValid(false),
+		_restoreSystemCursor(false), _titleRect(1, 1, 25, 6),
+		_mainRect(24, 9, 54, 20), _musicRect(40, 1, 70, 23),
+		_viewerRect(5, 4, 76, 19), _nowPlayingRect(24, 10, 56, 15) {
+}
+
+BonusTextUI::~BonusTextUI() {
+	_surface.free();
+	if (_restoreSystemCursor)
+		mouse_show();
+}
+
+bool BonusTextUI::init(Common::String &) {
+	_surface.create(DOSTextScreen::kRasterWidth, DOSTextScreen::kRasterHeight,
+			Graphics::PixelFormat::createFormatCLUT8());
+
+	_restoreSystemCursor = !mouse_hidden;
+	restorePresentation();
+	return true;
+}
+
+void BonusTextUI::drawDesktop() {
+	_cells.clear(kDesktopCharacter, kDesktopAttribute);
+}
+
+void BonusTextUI::drawTitlePanel() {
+	_cells.drawShadow(_titleRect, kShadowAttribute);
+	_cells.fill(Common::Rect(_titleRect.left + 1, _titleRect.top + 1,
+			_titleRect.right - 1, _titleRect.bottom - 1), 0x20, kTitleAttribute);
+	_cells.drawBox(_titleRect, kTitleAttribute, true);
+
+	for (int line = 0; line < 3; ++line) {
+		_cells.drawCenteredText(_titleRect.top + 1 + line,
+				_titleRect.left + 1, _titleRect.right - 1,
+				_text.title[line], kTitleAttribute, kTitleAttribute, false);
+	}
+}
+
+int BonusTextUI::itemRow(const Common::Rect &rect, int itemCount, int index) {
+	if (index < 0 || index >= itemCount)
+		return -1;
+	if (index == itemCount - 1)
+		return rect.bottom - 2;
+	return rect.top + 3 + index;
+}
+
+void BonusTextUI::drawMenu(const Common::Rect &rect, const Common::String &title,
+		const Common::String *items, int itemCount, int selected) {
+	_cells.drawShadow(rect, kShadowAttribute);
+	_cells.fill(Common::Rect(rect.left + 1, rect.top + 1,
+			rect.right - 1, rect.bottom - 1), 0x20, kNormalAttribute);
+	_cells.drawBox(rect, kNormalAttribute, true);
+
+	_cells.drawCenteredText(rect.top + 1, rect.left + 1, rect.right - 1,
+			title, kNormalAttribute, kHotkeyAttribute, false);
+	_cells.drawSeparator(rect, rect.top + 2, kNormalAttribute);
+	_cells.drawSeparator(rect, rect.bottom - 3, kNormalAttribute);
+
+	for (int index = 0; index < itemCount; ++index) {
+		const int y = itemRow(rect, itemCount, index);
+		const byte attribute = index == selected ?
+				kSelectedAttribute : kNormalAttribute;
+		const byte hotkeyAttribute = index == selected ?
+				kSelectedHotkeyAttribute : kHotkeyAttribute;
+
+		_cells.fill(Common::Rect(rect.left + 1, y, rect.right - 1, y + 1),
+				0x20, attribute);
+		_cells.drawText(rect.left + 2, y, items[index], attribute,
+				hotkeyAttribute, true, rect.width() - 4);
+	}
+}
+
+void BonusTextUI::drawMouseCursor() {
+	if (_mouseCellValid && _cells.isValidCell(_mouseCell.x, _mouseCell.y)) {
+		const byte attribute = _cells.getCell(_mouseCell.x, _mouseCell.y).attribute;
+		_cells.setAttribute(_mouseCell.x, _mouseCell.y,
+				attribute ^ kTextCursorXorMask);
+	}
+}
+
+void BonusTextUI::present(bool forcePalette) {
+	if (forcePalette)
+		DOSTextScreen::installVGAPalette();
+	if (!_cells.render(_surface))
+		return;
+
+	g_system->copyRectToScreen(_surface.getPixels(), _surface.pitch,
+			0, 0, _surface.w, _surface.h);
+	g_system->updateScreen();
+}
+
+void BonusTextUI::restorePresentation() {
+	mouse_hide();
+	updateMouseCell(g_system->getEventManager()->getMousePos());
+	DOSTextScreen::installVGAPalette();
+}
+
+void BonusTextUI::updateMouseCell(const Common::Point &position) {
+	_mouseCell.x = position.x / DOSTextScreen::kCellWidth;
+	_mouseCell.y = position.y / DOSTextScreen::kCellHeight;
+	_mouseCellValid = _cells.isValidCell(_mouseCell.x, _mouseCell.y);
+}
+
+int BonusTextUI::rowAtMouse(const Common::Rect &rect, int itemCount,
+		int mouseX, int mouseY) {
+	const int cellX = mouseX / DOSTextScreen::kCellWidth;
+	const int cellY = mouseY / DOSTextScreen::kCellHeight;
+	if (cellX <= rect.left || cellX >= rect.right - 1)
+		return -1;
+
+	for (int index = 0; index < itemCount; ++index) {
+		if (cellY == itemRow(rect, itemCount, index))
+			return index;
+	}
+	return -1;
+}
+
+int BonusTextUI::acceleratorChoice(const Common::String *items, int itemCount,
+		int ascii) {
+	if (ascii >= 'A' && ascii <= 'Z')
+		ascii += 'a' - 'A';
+
+	for (int index = 0; index < itemCount; ++index) {
+		int accelerator = (byte)DOSTextScreen::accelerator(items[index]);
+		if (accelerator >= 'A' && accelerator <= 'Z')
+			accelerator += 'a' - 'A';
+		if (accelerator && accelerator == ascii)
+			return index;
+	}
+	return -1;
+}
+
+bool BonusTextUI::processQuitEvent(const Common::Event &event) {
+	if (event.type != Common::EVENT_QUIT &&
+			event.type != Common::EVENT_RETURN_TO_LAUNCHER)
+		return false;
+
+	g_engine->quitGame();
+	return true;
+}
+
+bool BonusTextUI::processGameMenuEvent(const Common::Event &event) {
+	if (event.type != Common::EVENT_MAINMENU &&
+			(event.type != Common::EVENT_CUSTOM_ENGINE_ACTION_START ||
+			event.customType != kActionGameMenu))
+		return false;
+
+	g_engine->openMainMenuDialog();
+	restorePresentation();
+	return true;
+}
+
+int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
+		const Common::String *items, int itemCount, int &selected) {
+	restorePresentation();
+	selected = CLIP<int>(selected, 0, itemCount - 1);
+	bool dirty = true;
+
+	while (!g_engine->shouldQuit()) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (processQuitEvent(event))
+				return -1;
+			if (processGameMenuEvent(event)) {
+				dirty = true;
+				continue;
+			}
+
+			switch (event.type) {
+			case Common::EVENT_MOUSEMOVE: {
+				updateMouseCell(event.mouse);
+				const int hit = rowAtMouse(rect, itemCount,
+						event.mouse.x, event.mouse.y);
+				if (hit >= 0)
+					selected = hit;
+				dirty = true;
+				break;
+			}
+			case Common::EVENT_LBUTTONDOWN: {
+				updateMouseCell(event.mouse);
+				const int hit = rowAtMouse(rect, itemCount,
+						event.mouse.x, event.mouse.y);
+				if (hit >= 0) {
+					selected = hit;
+					return selected;
+				}
+				dirty = true;
+				break;
+			}
+			case Common::EVENT_KEYDOWN: {
+				const Common::KeyCode key = event.kbd.keycode;
+				if (key == Common::KEYCODE_UP) {
+					selected = (selected + itemCount - 1) % itemCount;
+					dirty = true;
+				} else if (key == Common::KEYCODE_DOWN) {
+					selected = (selected + 1) % itemCount;
+					dirty = true;
+				} else if (key == Common::KEYCODE_HOME) {
+					selected = 0;
+					dirty = true;
+				} else if (key == Common::KEYCODE_END) {
+					selected = itemCount - 1;
+					dirty = true;
+				} else if (key == Common::KEYCODE_RETURN ||
+						key == Common::KEYCODE_KP_ENTER) {
+					return selected;
+				} else if (key == Common::KEYCODE_ESCAPE) {
+					selected = itemCount - 1;
+					return selected;
+				} else {
+					const int hit = acceleratorChoice(items, itemCount,
+							event.kbd.ascii);
+					if (hit >= 0) {
+						selected = hit;
+						return selected;
+					}
+				}
+				break;
+			}
+			case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+				if (event.customType == kActionEscape) {
+					selected = itemCount - 1;
+					return selected;
+				}
+				break;
+			default:
+				break;
+			}
+		}
+
+		if (dirty) {
+			drawDesktop();
+			drawTitlePanel();
+			drawMenu(rect, title, items, itemCount, selected);
+			drawMouseCursor();
+			present(true);
+			dirty = false;
+		}
+		g_system->delayMillis(10);
+	}
+
+	return -1;
+}
+
+BonusTextUI::MainChoice BonusTextUI::runMainMenu(int &selected) {
+	const int result = runMenu(_mainRect, _text.mainMenuTitle,
+			_text.mainMenu, ARRAYSIZE(_text.mainMenu), selected);
+	return result < 0 ? kAbort : (MainChoice)result;
+}
+
+int BonusTextUI::runMusicMenu(int &selected) {
+	Common::String items[17];
+	for (uint index = 0; index < ARRAYSIZE(_text.musicTitles); ++index)
+		items[index] = _text.musicTitles[index];
+	items[16] = _text.musicExit;
+
+	return runMenu(_musicRect, _text.musicMenuTitle,
+			items, ARRAYSIZE(items), selected);
+}
+
+void BonusTextUI::appendWrappedLine(const Common::String &source, int width,
+		Common::Array<Common::String> &lines) {
+	if (source.empty()) {
+		lines.push_back(Common::String());
+		return;
+	}
+
+	Common::String remaining = source;
+	while ((int)remaining.size() > width) {
+		int split = width;
+		while (split > 0 && remaining[split] != ' ')
+			--split;
+		if (split == 0)
+			split = width;
+
+		lines.push_back(remaining.substr(0, split));
+		while (split < (int)remaining.size() && remaining[split] == ' ')
+			++split;
+		remaining = remaining.substr(split);
+	}
+	lines.push_back(remaining);
+}
+
+bool BonusTextUI::showBonusText(const Common::Path &filename) {
+	Common::File file;
+	if (!file.open(filename))
+		return false;
+
+	Common::Array<Common::String> lines;
+	const int textWidth = _viewerRect.width() - 2;
+	while (!file.eos() && !file.err()) {
+		const Common::String line = file.readLine();
+		if (file.err())
+			break;
+		if (file.eos() && line.empty())
+			break;
+		appendWrappedLine(line, textWidth, lines);
+	}
+	if (lines.empty())
+		lines.push_back(Common::String());
+	restorePresentation();
+
+	const int firstTextRow = _viewerRect.top + 1;
+	const int linesPerPage = _viewerRect.height() - 2;
+	int firstLine = 0;
+	bool dirty = true;
+
+	while (!g_engine->shouldQuit()) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (processQuitEvent(event))
+				return false;
+			if (processGameMenuEvent(event)) {
+				dirty = true;
+				continue;
+			}
+
+			if (Common::isMouseEvent(event)) {
+				updateMouseCell(event.mouse);
+				dirty = true;
+			}
+			if (event.type == Common::EVENT_KEYDOWN) {
+				if (event.kbd.keycode == Common::KEYCODE_ESCAPE)
+					return true;
+				if (event.kbd.keycode == Common::KEYCODE_SPACE) {
+					if (firstLine + linesPerPage >= (int)lines.size())
+						return true;
+					firstLine += linesPerPage;
+					dirty = true;
+				}
+			} else if (event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START &&
+					event.customType == kActionEscape) {
+				return true;
+			} else if (event.type == Common::EVENT_LBUTTONDOWN) {
+				if (firstLine + linesPerPage >= (int)lines.size())
+					return true;
+				firstLine += linesPerPage;
+				dirty = true;
+			} else if (event.type == Common::EVENT_RBUTTONDOWN) {
+				return true;
+			}
+		}
+
+		if (dirty) {
+			drawDesktop();
+			drawTitlePanel();
+			_cells.drawShadow(_viewerRect, kShadowAttribute);
+			_cells.fill(Common::Rect(_viewerRect.left + 1, _viewerRect.top + 1,
+					_viewerRect.right - 1, _viewerRect.bottom - 1),
+					0x20, kNormalAttribute);
+			_cells.drawBox(_viewerRect, kNormalAttribute, true);
+
+			Common::String viewerTitle = _text.bonusTextFilename;
+			viewerTitle.toUppercase();
+			_cells.drawTitle(_viewerRect, viewerTitle,
+					kNormalAttribute, kHotkeyAttribute);
+			for (int row = 0; row < linesPerPage &&
+					firstLine + row < (int)lines.size(); ++row) {
+				_cells.drawText(_viewerRect.left + 1, firstTextRow + row,
+						lines[firstLine + row], kNormalAttribute,
+						kHotkeyAttribute, false, textWidth);
+			}
+			_cells.drawFooter(_viewerRect, _text.continuePrompt,
+					kNormalAttribute, kHotkeyAttribute);
+			drawMouseCursor();
+			present(true);
+			dirty = false;
+		}
+		g_system->delayMillis(10);
+	}
+
+	return false;
+}
+
+void BonusTextUI::showNowPlaying(const Common::String &trackTitle,
+		Common::Functor0<bool> &isPlaying) {
+	restorePresentation();
+	bool dirty = true;
+	bool completed = false;
+	long completionTime = 0;
+	while (!g_engine->shouldQuit()) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (processQuitEvent(event))
+				return;
+			if (processGameMenuEvent(event)) {
+				dirty = true;
+				continue;
+			}
+			if (Common::isMouseEvent(event)) {
+				updateMouseCell(event.mouse);
+				dirty = true;
+			}
+			if (event.type == Common::EVENT_KEYDOWN ||
+					event.type == Common::EVENT_LBUTTONDOWN ||
+					event.type == Common::EVENT_RBUTTONDOWN ||
+					(event.type == Common::EVENT_CUSTOM_ENGINE_ACTION_START &&
+					event.customType == kActionEscape))
+				return;
+		}
+
+		if (!completed && !isPlaying()) {
+			completed = true;
+			completionTime = timer_read_60();
+		}
+		// The executable leaves the card up for another 60 native timer
+		// ticks after command 8 reports natural completion.
+		if (completed && timer_read_60() - completionTime >= 60)
+			return;
+
+		if (dirty) {
+			drawDesktop();
+			drawTitlePanel();
+			_cells.drawShadow(_nowPlayingRect, kShadowAttribute);
+			_cells.fill(Common::Rect(_nowPlayingRect.left + 1,
+					_nowPlayingRect.top + 1, _nowPlayingRect.right - 1,
+					_nowPlayingRect.bottom - 1), 0x20, kNormalAttribute);
+			_cells.drawBox(_nowPlayingRect, kNormalAttribute, true);
+			_cells.drawTitle(_nowPlayingRect, _text.nowPlaying,
+					kNormalAttribute, kHotkeyAttribute);
+			_cells.drawCenteredText(_nowPlayingRect.top + 2,
+					_nowPlayingRect.left + 1, _nowPlayingRect.right - 1,
+					trackTitle, kNormalAttribute, kHotkeyAttribute, false);
+			drawMouseCursor();
+			present(true);
+			dirty = false;
+		}
+		g_system->delayMillis(10);
+	}
+}
+
+void BonusTextUI::showGoodbye() {
+	restorePresentation();
+	_cells.clear(0x20, 0x07);
+	_cells.drawText(0, 0, _text.goodbye, 0x07, 0x07, false,
+			DOSTextScreen::kColumns);
+	present(true);
+
+	const uint32 start = g_system->getMillis();
+	while (!g_engine->shouldQuit() &&
+			g_system->getMillis() - start < kGoodbyeDuration) {
+		Common::Event event;
+		while (g_system->getEventManager()->pollEvent(event)) {
+			if (processQuitEvent(event))
+				return;
+			processGameMenuEvent(event);
+		}
+		g_system->delayMillis(10);
+	}
+}
+
+} // namespace RexNebular
+} // namespace MADS
diff --git a/engines/mads/nebular/bonus/bonus_text_ui.h b/engines/mads/nebular/bonus/bonus_text_ui.h
new file mode 100644
index 00000000000..1c49115b6cf
--- /dev/null
+++ b/engines/mads/nebular/bonus/bonus_text_ui.h
@@ -0,0 +1,90 @@
+/* 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.
+ */
+
+#ifndef MADS_NEBULAR_BONUS_TEXT_UI_H
+#define MADS_NEBULAR_BONUS_TEXT_UI_H
+
+#include "common/array.h"
+#include "common/events.h"
+#include "common/func.h"
+#include "common/rect.h"
+#include "common/str.h"
+#include "graphics/surface.h"
+#include "mads/nebular/bonus/dos_text_screen.h"
+#include "mads/nebular/bonus/bonus_exe_data.h"
+
+namespace MADS {
+namespace RexNebular {
+
+class BonusTextUI {
+public:
+	enum MainChoice {
+		kDeathScenes = 0,
+		kEvolution,
+		kSets,
+		kMusic,
+		kCoolStuff,
+		kExit,
+		kAbort = -1
+	};
+
+	explicit BonusTextUI(const BonusExeData &text);
+	~BonusTextUI();
+
+	bool init(Common::String &errorMessage);
+	MainChoice runMainMenu(int &selected);
+	int runMusicMenu(int &selected);
+	bool showBonusText(const Common::Path &filename);
+	void showNowPlaying(const Common::String &trackTitle,
+			Common::Functor0<bool> &isPlaying);
+	void showGoodbye();
+
+private:
+	const BonusExeData &_text;
+	DOSTextScreen _cells;
+	Graphics::Surface _surface;
+	Common::Point _mouseCell;
+	bool _mouseCellValid;
+	bool _restoreSystemCursor;
+
+	const Common::Rect _titleRect;
+	const Common::Rect _mainRect;
+	const Common::Rect _musicRect;
+	const Common::Rect _viewerRect;
+	const Common::Rect _nowPlayingRect;
+
+	void drawDesktop();
+	void drawTitlePanel();
+	void drawMenu(const Common::Rect &rect, const Common::String &title,
+			const Common::String *items, int itemCount, int selected);
+	void drawMouseCursor();
+	void present(bool forcePalette = false);
+	void restorePresentation();
+	void updateMouseCell(const Common::Point &position);
+
+	int runMenu(const Common::Rect &rect, const Common::String &title,
+			const Common::String *items, int itemCount, int &selected);
+	static int itemRow(const Common::Rect &rect, int itemCount, int index);
+	static int rowAtMouse(const Common::Rect &rect, int itemCount,
+			int mouseX, int mouseY);
+	static int acceleratorChoice(const Common::String *items, int itemCount,
+			int ascii);
+	static void appendWrappedLine(const Common::String &source, int width,
+			Common::Array<Common::String> &lines);
+	bool processQuitEvent(const Common::Event &event);
+	bool processGameMenuEvent(const Common::Event &event);
+};
+
+} // namespace RexNebular
+} // namespace MADS
+
+#endif // MADS_NEBULAR_BONUS_TEXT_UI_H
diff --git a/engines/mads/nebular/bonus/dos_text_screen.cpp b/engines/mads/nebular/bonus/dos_text_screen.cpp
new file mode 100644
index 00000000000..4488bd66acd
--- /dev/null
+++ b/engines/mads/nebular/bonus/dos_text_screen.cpp
@@ -0,0 +1,266 @@
+/* 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.
+ */
+
+#include "mads/nebular/bonus/dos_text_screen.h"
+
+#include "common/system.h"
+#include "common/util.h"
+#include "graphics/fonts/dosfont.h"
+#include "graphics/paletteman.h"
+#include "graphics/surface.h"
+
+namespace MADS {
+namespace RexNebular {
+
+namespace {
+
+static const byte kVGAPalette[16 * 3] = {
+	  0,   0,   0,    0,   0, 170,    0, 170,   0,    0, 170, 170,
+	170,   0,   0,  170,   0, 170,  170,  85,   0,  170, 170, 170,
+	 85,  85,  85,   85,  85, 255,   85, 255,  85,   85, 255, 255,
+	255,  85,  85,  255,  85, 255,  255, 255,  85,  255, 255, 255
+};
+
+} // namespace
+
+DOSTextScreen::DOSTextScreen() {
+	clear();
+}
+
+void DOSTextScreen::clear(byte character, byte attribute) {
+	for (int i = 0; i < kColumns * kRows; ++i) {
+		_cells[i].character = character;
+		_cells[i].attribute = attribute;
+	}
+}
+
+bool DOSTextScreen::isValidCell(int x, int y) const {
+	return x >= 0 && x < kColumns && y >= 0 && y < kRows;
+}
+
+const DOSTextScreen::Cell &DOSTextScreen::getCell(int x, int y) const {
+	assert(isValidCell(x, y));
+	return _cells[indexOf(x, y)];
+}
+
+void DOSTextScreen::setCell(int x, int y, byte character, byte attribute) {
+	if (!isValidCell(x, y))
+		return;
+
+	Cell &cell = _cells[indexOf(x, y)];
+	cell.character = character;
+	cell.attribute = attribute;
+}
+
+void DOSTextScreen::setCharacter(int x, int y, byte character) {
+	if (isValidCell(x, y))
+		_cells[indexOf(x, y)].character = character;
+}
+
+void DOSTextScreen::setAttribute(int x, int y, byte attribute) {
+	if (isValidCell(x, y))
+		_cells[indexOf(x, y)].attribute = attribute;
+}
+
+void DOSTextScreen::fill(const Common::Rect &rect, byte character, byte attribute) {
+	const int left = MAX<int>(0, rect.left);
+	const int top = MAX<int>(0, rect.top);
+	const int right = MIN<int>(kColumns, rect.right);
+	const int bottom = MIN<int>(kRows, rect.bottom);
+
+	for (int y = top; y < bottom; ++y)
+		for (int x = left; x < right; ++x)
+			setCell(x, y, character, attribute);
+}
+
+void DOSTextScreen::recolor(const Common::Rect &rect, byte attribute) {
+	const int left = MAX<int>(0, rect.left);
+	const int top = MAX<int>(0, rect.top);
+	const int right = MIN<int>(kColumns, rect.right);
+	const int bottom = MIN<int>(kRows, rect.bottom);
+
+	for (int y = top; y < bottom; ++y)
+		for (int x = left; x < right; ++x)
+			setAttribute(x, y, attribute);
+}
+
+void DOSTextScreen::drawBox(const Common::Rect &rect, byte attribute, bool doubleLine) {
+	if (rect.width() < 2 || rect.height() < 2)
+		return;
+
+	const byte horizontal = doubleLine ? 0xCD : 0xC4;
+	const byte vertical = doubleLine ? 0xBA : 0xB3;
+	const byte topLeft = doubleLine ? 0xC9 : 0xDA;
+	const byte topRight = doubleLine ? 0xBB : 0xBF;
+	const byte bottomLeft = doubleLine ? 0xC8 : 0xC0;
+	const byte bottomRight = doubleLine ? 0xBC : 0xD9;
+	const int right = rect.right - 1;
+	const int bottom = rect.bottom - 1;
+
+	setCell(rect.left, rect.top, topLeft, attribute);
+	setCell(right, rect.top, topRight, attribute);
+	setCell(rect.left, bottom, bottomLeft, attribute);
+	setCell(right, bottom, bottomRight, attribute);
+
+	for (int x = rect.left + 1; x < right; ++x) {
+		setCell(x, rect.top, horizontal, attribute);
+		setCell(x, bottom, horizontal, attribute);
+	}
+	for (int y = rect.top + 1; y < bottom; ++y) {
+		setCell(rect.left, y, vertical, attribute);
+		setCell(right, y, vertical, attribute);
+	}
+}
+
+void DOSTextScreen::drawShadow(const Common::Rect &rect, byte shadowAttribute) {
+	// MADS uses a two-column right shadow and a one-row bottom shadow by
+	// recoloring the desktop cells that are already present.
+	recolor(Common::Rect(rect.right, rect.top + 1,
+			MIN<int>(kColumns, rect.right + 2),
+			MIN<int>(kRows, rect.bottom + 1)), shadowAttribute);
+	recolor(Common::Rect(MIN<int>(kColumns, rect.left + 2), rect.bottom,
+			MIN<int>(kColumns, rect.right + 2),
+			MIN<int>(kRows, rect.bottom + 1)), shadowAttribute);
+}
+
+void DOSTextScreen::drawSeparator(const Common::Rect &rect, int y, byte attribute) {
+	if (y <= rect.top || y >= rect.bottom - 1)
+		return;
+
+	setCell(rect.left, y, 0xC7, attribute);
+	setCell(rect.right - 1, y, 0xB6, attribute);
+	for (int x = rect.left + 1; x < rect.right - 1; ++x)
+		setCell(x, y, 0xC4, attribute);
+}
+
+Common::String DOSTextScreen::visibleText(const Common::String &source) {
+	Common::String result;
+	for (uint i = 0; i < source.size(); ++i) {
+		if (source[i] == '~' && i + 1 < source.size())
+			continue;
+		result += source[i];
+	}
+	return result;
+}
+
+int DOSTextScreen::visibleTextWidth(const Common::String &source) {
+	return (int)visibleText(source).size();
+}
+
+char DOSTextScreen::accelerator(const Common::String &source) {
+	for (uint i = 0; i + 1 < source.size(); ++i)
+		if (source[i] == '~')
+			return source[i + 1];
+	return 0;
+}
+
+int DOSTextScreen::drawText(int x, int y, const Common::String &text,
+		byte attribute, byte hotkeyAttribute, bool parseAccelerator, int maxCells) {
+	int used = 0;
+	bool hotkey = false;
+
+	for (uint i = 0; i < text.size(); ++i) {
+		if (parseAccelerator && text[i] == '~' && i + 1 < text.size()) {
+			hotkey = true;
+			continue;
+		}
+		if (maxCells >= 0 && used >= maxCells)
+			break;
+
+		setCell(x + used, y, (byte)text[i], hotkey ? hotkeyAttribute : attribute);
+		hotkey = false;
+		++used;
+	}
+
+	return used;
+}
+
+int DOSTextScreen::drawCenteredText(int y, int left, int right,
+		const Common::String &text, byte attribute, byte hotkeyAttribute,
+		bool parseAccelerator) {
+	const int width = parseAccelerator ? visibleTextWidth(text) : (int)text.size();
+	const int x = left + MAX<int>(0, (right - left - width) / 2);
+	return drawText(x, y, text, attribute, hotkeyAttribute, parseAccelerator,
+			MAX<int>(0, right - x));
+}
+
+void DOSTextScreen::drawBorderText(const Common::Rect &rect, int y,
+		const Common::String &text, byte attribute, byte hotkeyAttribute) {
+	const int width = visibleTextWidth(text) + 2;
+	const int x = rect.left + MAX<int>(1, (rect.width() - width) / 2);
+
+	for (int i = 0; i < width && x + i < rect.right - 1; ++i)
+		setCell(x + i, y, 0x20, attribute);
+	drawText(x + 1, y, text, attribute, hotkeyAttribute, true,
+			MAX<int>(0, rect.right - x - 2));
+}
+
+void DOSTextScreen::drawTitle(const Common::Rect &rect, const Common::String &title,
+		byte attribute, byte hotkeyAttribute) {
+	drawBorderText(rect, rect.top, title, attribute, hotkeyAttribute);
+}
+
+void DOSTextScreen::drawFooter(const Common::Rect &rect, const Common::String &text,
+		byte attribute, byte hotkeyAttribute) {
+	drawBorderText(rect, rect.bottom - 1, text, attribute, hotkeyAttribute);
+}
+
+bool DOSTextScreen::render(Graphics::Surface &surface, bool blinkVisible) const {
+	if (surface.w != kRasterWidth || surface.h != kRasterHeight ||
+			surface.format.bytesPerPixel != 1)
+		return false;
+
+	Graphics::DosFont font;
+	Graphics::Surface glyphSurface;
+	glyphSurface.create(kCellWidth, 8, surface.format);
+
+	for (int cellY = 0; cellY < kRows; ++cellY) {
+		for (int cellX = 0; cellX < kColumns; ++cellX) {
+			const Cell &cell = _cells[indexOf(cellX, cellY)];
+			const byte foreground = cell.attribute & 0x0F;
+			const byte background = (cell.attribute >> 4) & 0x07;
+			const bool drawForeground = !(cell.attribute & 0x80) || blinkVisible;
+			const int pixelX = cellX * kCellWidth;
+			const int pixelY = cellY * kCellHeight;
+
+			surface.fillRect(Common::Rect(pixelX, pixelY,
+					pixelX + kCellWidth, pixelY + kCellHeight), background);
+			if (!drawForeground || cell.character == 0x20 || cell.character == 0)
+				continue;
+
+			glyphSurface.fillRect(Common::Rect(0, 0, kCellWidth, 8), 0);
+			font.drawChar(&glyphSurface, cell.character, 0, 0, 1);
+			// The native 80-column text mode uses 8x16 cells. ScummVM's DOS
+			// font contains the complete 8x8 CP437 set, including borders and
+			// shading, so expand each scanline exactly twice.
+			for (int destY = 0; destY < kCellHeight; ++destY) {
+				const int sourceY = destY / 2;
+				for (int destX = 0; destX < kCellWidth; ++destX) {
+					if (*((const byte *)glyphSurface.getBasePtr(destX, sourceY)) != 0)
+						*((byte *)surface.getBasePtr(pixelX + destX,
+								pixelY + destY)) = foreground;
+				}
+			}
+		}
+	}
+
+	glyphSurface.free();
+	return true;
+}
+
+void DOSTextScreen::installVGAPalette() {
+	if (g_system && g_system->getPaletteManager())
+		g_system->getPaletteManager()->setPalette(kVGAPalette, 0, 16);
+}
+
+} // namespace RexNebular
+} // namespace MADS
diff --git a/engines/mads/nebular/bonus/dos_text_screen.h b/engines/mads/nebular/bonus/dos_text_screen.h
new file mode 100644
index 00000000000..7495f1f0a28
--- /dev/null
+++ b/engines/mads/nebular/bonus/dos_text_screen.h
@@ -0,0 +1,91 @@
+/* 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.
+ */
+
+#ifndef MADS_NEBULAR_BONUS_DOS_TEXT_SCREEN_H
+#define MADS_NEBULAR_BONUS_DOS_TEXT_SCREEN_H
+
+#include "common/rect.h"
+#include "common/scummsys.h"
+#include "common/str.h"
+
+namespace Graphics {
+struct Surface;
+}
+
+namespace MADS {
+namespace RexNebular {
+
+/** Semantic 80x25 DOS text screen rendered as 8x16 cells. */
+class DOSTextScreen {
+public:
+	enum {
+		kColumns = 80,
+		kRows = 25,
+		kCellWidth = 8,
+		kCellHeight = 16,
+		kRasterWidth = kColumns * kCellWidth,
+		kRasterHeight = kRows * kCellHeight
+	};
+
+	struct Cell {
+		byte character;
+		byte attribute;
+	};
+
+	DOSTextScreen();
+
+	void clear(byte character = 0x20, byte attribute = 0x07);
+	bool isValidCell(int x, int y) const;
+	const Cell &getCell(int x, int y) const;
+	void setCell(int x, int y, byte character, byte attribute);
+	void setCharacter(int x, int y, byte character);
+	void setAttribute(int x, int y, byte attribute);
+
+	void fill(const Common::Rect &rect, byte character, byte attribute);
+	void recolor(const Common::Rect &rect, byte attribute);
+	void drawBox(const Common::Rect &rect, byte attribute, bool doubleLine = true);
+	void drawShadow(const Common::Rect &rect, byte shadowAttribute = 0x08);
+	void drawSeparator(const Common::Rect &rect, int y, byte attribute);
+	void drawTitle(const Common::Rect &rect, const Common::String &title,
+			byte attribute, byte hotkeyAttribute = 0x0F);
+	void drawFooter(const Common::Rect &rect, const Common::String &text,
+			byte attribute, byte hotkeyAttribute = 0x0F);
+
+	/**
+	 * Draw CP437/ASCII text. A tilde marks the following accelerator and
+	 * is not displayed. Returns the number of cells consumed.
+	 */
+	int drawText(int x, int y, const Common::String &text, byte attribute,
+			byte hotkeyAttribute, bool parseAccelerator = true, int maxCells = -1);
+	int drawCenteredText(int y, int left, int right, const Common::String &text,
+			byte attribute, byte hotkeyAttribute, bool parseAccelerator = true);
+
+	/** Render to an existing 640x400 CLUT8 surface. */
+	bool render(Graphics::Surface &surface, bool blinkVisible = true) const;
+
+	static void installVGAPalette();
+	static Common::String visibleText(const Common::String &source);
+	static int visibleTextWidth(const Common::String &source);
+	static char accelerator(const Common::String &source);
+
+private:
+	Cell _cells[kColumns * kRows];
+
+	static int indexOf(int x, int y) { return y * kColumns + x; }
+	void drawBorderText(const Common::Rect &rect, int y,
+			const Common::String &text, byte attribute, byte hotkeyAttribute);
+};
+
+} // namespace RexNebular
+} // namespace MADS
+
+#endif // MADS_NEBULAR_BONUS_DOS_TEXT_SCREEN_H
diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index 505de6e43d3..ef515089955 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -100,6 +100,14 @@ ISound::OverlayLayout ISound::readOverlayLayout(
 	return result;
 }
 
+void ISound::validate() {
+	for (int section = 1; section <= 9; ++section) {
+		const Common::Path filename(Common::String::format(
+				"ISOUND.%03d", section));
+		(void)readOverlayLayout(filename);
+	}
+}
+
 ISound::ISound(Audio::Mixer *mixer, const Common::Path &filename) :
 	ISound(mixer, filename, readOverlayLayout(filename)) {
 }
diff --git a/engines/mads/nebular/sound/isound.h b/engines/mads/nebular/sound/isound.h
index 76c73bd6d75..4c8d1660637 100644
--- a/engines/mads/nebular/sound/isound.h
+++ b/engines/mads/nebular/sound/isound.h
@@ -149,6 +149,8 @@ protected:
 	int16 generateSample();
 
 public:
+	static void validate();
+
 	ISound(Audio::Mixer *mixer, const Common::Path &filename);
 	~ISound() override;
 
diff --git a/engines/mads/nebular/sound/sound.cpp b/engines/mads/nebular/sound/sound.cpp
index 7d1f9fee054..cbe5323017b 100644
--- a/engines/mads/nebular/sound/sound.cpp
+++ b/engines/mads/nebular/sound/sound.cpp
@@ -38,7 +38,7 @@ void RexSoundManager::validate() {
 		break;
 
 	case SOUND_PCSPEAKER:
-		// No validation needed
+		ISound::validate();
 		break;
 
 	default:


Commit: 114d094748d8fefe4136dc12997561ae6462e909
    https://github.com/scummvm/scummvm/commit/114d094748d8fefe4136dc12997561ae6462e909
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: NEBULAR: Fix Bonus Disk playback

Wait for driver initialization before dispatching a music selection and
preserve natural completion polling through command 8. Initialize the
first AnimView background and restrict Bonus presentation interruption
to Escape.

Assisted-by: Codex:GPT-5.4

Changed paths:
    engines/mads/animview/animview.cpp
    engines/mads/nebular/bonus/bonus.cpp
    engines/mads/nebular/bonus/bonus_text_ui.cpp
    engines/mads/nebular/bonus/bonus_text_ui.h


diff --git a/engines/mads/animview/animview.cpp b/engines/mads/animview/animview.cpp
index 361b860567b..a9e19f845a7 100644
--- a/engines/mads/animview/animview.cpp
+++ b/engines/mads/animview/animview.cpp
@@ -32,6 +32,7 @@
 #include "mads/core/speech.h"
 #include "mads/core/tile.h"
 #include "mads/core/timer.h"
+#include "mads/core/video.h"
 #include "mads/animview/animview.h"
 #include "mads/animview/anim_timer.h"
 #include "mads/animview/functions.h"
@@ -145,6 +146,19 @@ static void init_globals() {
 	imageFrame = 0;
 }
 
+static bool isBonusDiskMode() {
+	return (g_engine->getGameFeatures() & GF_BONUS_DISK) != 0;
+}
+
+static bool bonusDiskAbortRequested() {
+	while (g_engine->hasPendingKey()) {
+		if (g_engine->getKey() == Common::KEYCODE_ESCAPE)
+			return true;
+	}
+
+	return false;
+}
+
 static void anim_inter_timer() {
 	// This doesn't seem to be ever used, so I didn't implement it. It seems to be
 	// for the alternate "interface" style of animations
@@ -285,16 +299,23 @@ static void run_animation(int animIndex) {
 			}
 		}
 
-		if (g_engine->shouldQuit())
-			current_error_code = 1;
-		if (g_engine->hasPendingKey()) {
-			g_engine->flushKeys();
-			error_code = 1;
-			current_error_code = 1;
-		}
-		if (mouse_get_status(&mouse_x, &mouse_y)) {
-			current_error_code = -1;
-			error_code = 3;
+		if (isBonusDiskMode()) {
+			if (g_engine->shouldQuit() || bonusDiskAbortRequested()) {
+				error_code = 1;
+				current_error_code = 1;
+			}
+		} else {
+			if (g_engine->shouldQuit())
+				current_error_code = 1;
+			if (g_engine->hasPendingKey()) {
+				g_engine->flushKeys();
+				error_code = 1;
+				current_error_code = 1;
+			}
+			if (mouse_get_status(&mouse_x, &mouse_y)) {
+				current_error_code = -1;
+				error_code = 3;
+			}
 		}
 
 		// Animation loop delay
@@ -314,15 +335,20 @@ static void run_animation(int animIndex) {
 				// Brief pause
 				g_system->delayMillis(10);
 
-				// Check for any keypress
-				if (g_engine->hasPendingKey()) {
-					g_engine->flushKeys();
-					error_code = 1;
-					current_error_code = 1;
+				if (isBonusDiskMode()) {
+					if (g_engine->shouldQuit() || bonusDiskAbortRequested()) {
+						error_code = 1;
+						current_error_code = 1;
+					}
+				} else {
+					if (g_engine->hasPendingKey()) {
+						g_engine->flushKeys();
+						error_code = 1;
+						current_error_code = 1;
+					}
+					if (g_engine->shouldQuit())
+						current_error_code = 1;
 				}
-
-				if (g_engine->shouldQuit())
-					current_error_code = 1;
 			} while (timer_read() < timer2);
 
 			if (peelFlag) {
@@ -339,19 +365,25 @@ static void run_animation(int animIndex) {
 	if ((animIndex == (anim_count - 1)) &&
 			(wait_for_music_at_end || !exit_immediately_at_end)) {
 		while (current_error_code == 0) {
-			// Check for any keypress or mouse clicks
-			if (g_engine->hasPendingKey()) {
-				g_engine->flushKeys();
-				error_code = 1;
-				current_error_code = 1;
-			}
+			if (isBonusDiskMode()) {
+				if (g_engine->shouldQuit() || bonusDiskAbortRequested()) {
+					error_code = 1;
+					current_error_code = 1;
+				}
+			} else {
+				if (g_engine->hasPendingKey()) {
+					g_engine->flushKeys();
+					error_code = 1;
+					current_error_code = 1;
+				}
 
-			int mouseX = 0, mouseY = 0;
-			if (mouse_get_status(&mouseX, &mouseY))
-				current_error_code = 1;
+				int mouseX = 0, mouseY = 0;
+				if (mouse_get_status(&mouseX, &mouseY))
+					current_error_code = 1;
 
-			if (g_engine->shouldQuit())
-				current_error_code = 1;
+				if (g_engine->shouldQuit())
+					current_error_code = 1;
+			}
 
 			if (!exit_immediately_at_end)
 				continue;
@@ -507,6 +539,18 @@ static void animate() {
 		viewing_at_y2 = viewing_at_y;
 
 		buffer_fill(scr_work, 0);
+		if (isBonusDiskMode() && count == 0) {
+			// AnimView normally relies on its first timer callback for the full
+			// background refresh. The Bonus text renderer uses a separate output
+			// surface, so establish the MADS game surface before any delta frames
+			// can expose the preceding cutscene. Do not present it yet: the first
+			// animation effect still owns the transition from the black screen.
+			buffer_rect_copy_2(scr_orig, scr_work,
+					picture_map.pan_offset_x, picture_map.pan_offset_y,
+					0, 0, scr_work.x, scr_work.y);
+			video_update(&scr_work, 0, 0, 0, viewing_at_y,
+					scr_work.x, scr_work.y);
+		}
 
 		// Speech handling
 		hasSpeechAudio = false;
@@ -605,6 +649,16 @@ void animview_main(const char *resName) {
 
 	init_globals();
 
+	if (isBonusDiskMode()) {
+		// The Bonus Disk text UI draws directly to the backend. Clear the MADS
+		// game surface as well so a subsequent AnimView cannot inherit pixels
+		// from an earlier, interrupted presentation.
+		g_engine->getScreen()->fillRect(
+				Common::Rect(0, 0, g_engine->getScreen()->w,
+						g_engine->getScreen()->h), 0);
+		g_engine->updateDisplay();
+	}
+
 	pack_enable_pfab_explode();
 	(void)env_verify();
 	mouse_hide();
@@ -638,6 +692,5 @@ void animview_main(const char *resName) {
 
 	g_engine->flushKeys();
 }
-
 } // namespace AnimView
 } // namespace MADS
diff --git a/engines/mads/nebular/bonus/bonus.cpp b/engines/mads/nebular/bonus/bonus.cpp
index eac5016b317..a88752efec9 100644
--- a/engines/mads/nebular/bonus/bonus.cpp
+++ b/engines/mads/nebular/bonus/bonus.cpp
@@ -204,7 +204,20 @@ private:
 			return;
 
 		const BonusTrack &track = kBonusTracks[index];
+		// Present the card before loading the driver. This keeps the final
+		// AnimView frame from leaking through while a driver is starting.
+		_ui.prepareNowPlaying(_text.musicTitles[index]);
 		_soundManager.init(track.section);
+
+		// ISOUND initializes with a one-tick null sequence. The original host
+		// services that sequence before dispatching a menu selection; wait on
+		// the driver's own completion status instead of racing its priority.
+		const uint32 startupTime = g_system->getMillis();
+		while (_soundManager.isDriverActive() &&
+				!g_engine->shouldQuit() &&
+				g_system->getMillis() - startupTime < 1000)
+			g_system->delayMillis(1);
+
 		_soundManager.command(track.command, 127);
 
 		// These follow the control flow surrounding the native track table in
@@ -222,7 +235,7 @@ private:
 
 		Common::Functor0Mem<bool, BonusApplication> isPlaying(
 				this, &BonusApplication::isDriverActive);
-		_ui.showNowPlaying(_text.musicTitles[index], isPlaying);
+		_ui.waitForNowPlaying(_text.musicTitles[index], isPlaying);
 		if (_soundManager.isLoaded())
 			_soundManager.closeDriver();
 	}
diff --git a/engines/mads/nebular/bonus/bonus_text_ui.cpp b/engines/mads/nebular/bonus/bonus_text_ui.cpp
index 2f106043e6b..8d4596ca771 100644
--- a/engines/mads/nebular/bonus/bonus_text_ui.cpp
+++ b/engines/mads/nebular/bonus/bonus_text_ui.cpp
@@ -415,10 +415,27 @@ bool BonusTextUI::showBonusText(const Common::Path &filename) {
 	return false;
 }
 
-void BonusTextUI::showNowPlaying(const Common::String &trackTitle,
-		Common::Functor0<bool> &isPlaying) {
+void BonusTextUI::prepareNowPlaying(const Common::String &trackTitle) {
 	restorePresentation();
-	bool dirty = true;
+	drawDesktop();
+	drawTitlePanel();
+	_cells.drawShadow(_nowPlayingRect, kShadowAttribute);
+	_cells.fill(Common::Rect(_nowPlayingRect.left + 1,
+			_nowPlayingRect.top + 1, _nowPlayingRect.right - 1,
+			_nowPlayingRect.bottom - 1), 0x20, kNormalAttribute);
+	_cells.drawBox(_nowPlayingRect, kNormalAttribute, true);
+	_cells.drawTitle(_nowPlayingRect, _text.nowPlaying,
+			kNormalAttribute, kHotkeyAttribute);
+	_cells.drawCenteredText(_nowPlayingRect.top + 2,
+			_nowPlayingRect.left + 1, _nowPlayingRect.right - 1,
+			trackTitle, kNormalAttribute, kHotkeyAttribute, false);
+	drawMouseCursor();
+	present(true);
+}
+
+void BonusTextUI::waitForNowPlaying(const Common::String &trackTitle,
+		Common::Functor0<bool> &isPlaying) {
+	bool dirty = false;
 	bool completed = false;
 	long completionTime = 0;
 	while (!g_engine->shouldQuit()) {
@@ -452,20 +469,7 @@ void BonusTextUI::showNowPlaying(const Common::String &trackTitle,
 			return;
 
 		if (dirty) {
-			drawDesktop();
-			drawTitlePanel();
-			_cells.drawShadow(_nowPlayingRect, kShadowAttribute);
-			_cells.fill(Common::Rect(_nowPlayingRect.left + 1,
-					_nowPlayingRect.top + 1, _nowPlayingRect.right - 1,
-					_nowPlayingRect.bottom - 1), 0x20, kNormalAttribute);
-			_cells.drawBox(_nowPlayingRect, kNormalAttribute, true);
-			_cells.drawTitle(_nowPlayingRect, _text.nowPlaying,
-					kNormalAttribute, kHotkeyAttribute);
-			_cells.drawCenteredText(_nowPlayingRect.top + 2,
-					_nowPlayingRect.left + 1, _nowPlayingRect.right - 1,
-					trackTitle, kNormalAttribute, kHotkeyAttribute, false);
-			drawMouseCursor();
-			present(true);
+			prepareNowPlaying(trackTitle);
 			dirty = false;
 		}
 		g_system->delayMillis(10);
diff --git a/engines/mads/nebular/bonus/bonus_text_ui.h b/engines/mads/nebular/bonus/bonus_text_ui.h
index 1c49115b6cf..875663af415 100644
--- a/engines/mads/nebular/bonus/bonus_text_ui.h
+++ b/engines/mads/nebular/bonus/bonus_text_ui.h
@@ -44,7 +44,8 @@ public:
 	MainChoice runMainMenu(int &selected);
 	int runMusicMenu(int &selected);
 	bool showBonusText(const Common::Path &filename);
-	void showNowPlaying(const Common::String &trackTitle,
+	void prepareNowPlaying(const Common::String &trackTitle);
+	void waitForNowPlaying(const Common::String &trackTitle,
 			Common::Functor0<bool> &isPlaying);
 	void showGoodbye();
 


Commit: 986ad9c7093461fa4f67b005c59155e70107e0df
    https://github.com/scummvm/scummvm/commit/986ad9c7093461fa4f67b005c59155e70107e0df
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: Fix AnimView cleanup after early exit

Release room palettes and active speech-message resources through the
common AnimView shutdown path. Reset timer state and reconstruct
interrupted presentations so palette, matte and delta-frame state cannot
affect subsequent playback.

Previously, interrupting a Bonus Disk presentation at the first scene
could leave its palette allocation active, causing incomplete
backgrounds when the same presentation was reopened.

Assisted-by: Codex:GPT-5.4

Changed paths:
    engines/mads/animview/anim_timer.cpp
    engines/mads/animview/anim_timer.h
    engines/mads/animview/animview.cpp


diff --git a/engines/mads/animview/anim_timer.cpp b/engines/mads/animview/anim_timer.cpp
index f5f21bf0cb1..dedb6f6727b 100644
--- a/engines/mads/animview/anim_timer.cpp
+++ b/engines/mads/animview/anim_timer.cpp
@@ -47,6 +47,20 @@ static int normalTimer1, imageCount;
 static int messageCount;
 static int16 panningX, panningY;
 
+static void clearSpeechMessages() {
+	if (runVal8) {
+		matte_clear_message(matteId);
+		pal_deallocate(paletteHandle);
+		runVal8 = 0;
+	}
+
+	for (int count = 0; count < messageCount; ++count)
+		matte_clear_message(messageHandle[count]);
+	messageCount = 0;
+	paletteHandle = 0;
+	matteId = 0;
+}
+
 void anim_timer_init() {
 	paletteHandle = 0;
 	palIndex1 = palIndex2 = 0;
@@ -57,6 +71,10 @@ void anim_timer_init() {
 	normalTimer1 = imageCount = 0;
 }
 
+void anim_timer_shutdown() {
+	clearSpeechMessages();
+}
+
 void anim_timer() {
 	bool flag = false;
 	long currTimer = timer_read();
@@ -299,16 +317,7 @@ block3:
 		imageFrame = speech->first_image;
 	} else {
 		speechIndex = -1;
-
-		if (runVal8) {
-			matte_clear_message(matteId);
-			pal_deallocate(paletteHandle);
-			runVal8 = 0;
-
-			for (count = 0; count < messageCount; ++count)
-				matte_clear_message(messageHandle[count]);
-			messageCount = 0;
-		}
+		clearSpeechMessages();
 	}
 
 done:
diff --git a/engines/mads/animview/anim_timer.h b/engines/mads/animview/anim_timer.h
index b97772a8c1a..43f5655d4ab 100644
--- a/engines/mads/animview/anim_timer.h
+++ b/engines/mads/animview/anim_timer.h
@@ -28,6 +28,7 @@ namespace MADS {
 namespace AnimView {
 
 extern void anim_timer_init();
+extern void anim_timer_shutdown();
 extern void anim_timer();
 
 } // namespace AnimView
diff --git a/engines/mads/animview/animview.cpp b/engines/mads/animview/animview.cpp
index a9e19f845a7..217a6d32754 100644
--- a/engines/mads/animview/animview.cpp
+++ b/engines/mads/animview/animview.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "audio/audiostream.h"
+#include "common/debug.h"
 #include "common/file.h"
 #include "mads/core/env.h"
 #include "mads/core/himem.h"
@@ -152,13 +153,27 @@ static bool isBonusDiskMode() {
 
 static bool bonusDiskAbortRequested() {
 	while (g_engine->hasPendingKey()) {
-		if (g_engine->getKey() == Common::KEYCODE_ESCAPE)
+		if (g_engine->getKey() == Common::KEYCODE_ESCAPE) {
+			debug(2, "MADS Bonus Disk AnimView: Escape requested early exit");
 			return true;
+		}
 	}
 
 	return false;
 }
 
+static void unloadRoom() {
+	if (!room)
+		return;
+
+	if (isBonusDiskMode())
+		debug(2, "MADS Bonus Disk AnimView: releasing room palette handle %d",
+				room->color_handle);
+	pal_deallocate(room->color_handle);
+	mem_free(room);
+	room = nullptr;
+}
+
 static void anim_inter_timer() {
 	// This doesn't seem to be ever used, so I didn't implement it. It seems to be
 	// for the alternate "interface" style of animations
@@ -496,8 +511,7 @@ static void animate() {
 			tile_map_free(&depth_map);
 
 			if (room) {
-				pal_deallocate(room->color_handle);
-				mem_free(room);
+				unloadRoom();
 			} else {
 				pal_init(1, 8);
 				mouse_hard_cursor_mode(2, master_palette);
@@ -626,6 +640,7 @@ static void animate() {
 	}
 done:
 	timer_activate_low_priority(nullptr);
+	anim_timer_shutdown();
 	buffer_free(&scr_work);
 	anim_unload(current_anim);
 	buffer_free(&scr_depth);
@@ -633,8 +648,7 @@ done:
 	tile_map_free(&picture_map);
 	tile_map_free(&depth_map);
 
-	if (room)
-		mem_free(room);
+	unloadRoom();
 	timer_set_sound_flag(false);
 
 	if (g_engine->_soundManager->isLoaded())


Commit: eb6c8285fb9ff15d22869c0b850e871d71962af8
    https://github.com/scummvm/scummvm/commit/eb6c8285fb9ff15d22869c0b850e871d71962af8
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: NEBULAR: Handle Bonus Disk PC Speaker music

Identify Bonus music commands that have playable ISOUND sequences.
Disable selections whose commands are absent or terminate immediately,
retain the two supported selections and add level-2 diagnostics for
Bonus and ISOUND command processing.

Assisted-by: Codex:GPT-5.4

Changed paths:
    engines/mads/core/sound_manager.h
    engines/mads/nebular/bonus/bonus.cpp
    engines/mads/nebular/bonus/bonus_text_ui.cpp
    engines/mads/nebular/bonus/bonus_text_ui.h
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound_nebular.cpp


diff --git a/engines/mads/core/sound_manager.h b/engines/mads/core/sound_manager.h
index 4bbbd2b81a6..888d8062143 100644
--- a/engines/mads/core/sound_manager.h
+++ b/engines/mads/core/sound_manager.h
@@ -130,6 +130,13 @@ public:
 	 */
 	bool isDriverActive();
 
+	/**
+	 * Returns whether the selected driver family uses PC speaker data.
+	 */
+	bool usesPCSpeaker() const {
+		return _driverType == SOUND_PCSPEAKER;
+	}
+
 	/**
 	 * Stop any currently active sound and remove the driver
 	 */
diff --git a/engines/mads/nebular/bonus/bonus.cpp b/engines/mads/nebular/bonus/bonus.cpp
index a88752efec9..a1bd50e6a5e 100644
--- a/engines/mads/nebular/bonus/bonus.cpp
+++ b/engines/mads/nebular/bonus/bonus.cpp
@@ -13,6 +13,7 @@
 #include "mads/nebular/bonus/bonus.h"
 
 #include "common/array.h"
+#include "common/debug.h"
 #include "common/file.h"
 #include "common/func.h"
 #include "common/path.h"
@@ -40,13 +41,17 @@ namespace {
 struct BonusTrack {
 	byte section;
 	byte command;
+	// BONUS uses one cross-driver command table. For ISOUND, false entries
+	// either have no sequence or point directly at the native 00 00 terminator;
+	// do not substitute a nearby gameplay sound without executable evidence.
+	bool pcSpeakerAvailable;
 };
 
 static const BonusTrack kBonusTracks[] = {
-	{ 1, 12 }, { 1, 10 }, { 9, 49 }, { 2, 9 },
-	{ 2, 15 }, { 2, 19 }, { 3, 11 }, { 3, 10 },
-	{ 3, 16 }, { 3, 17 }, { 4, 12 }, { 5, 29 },
-	{ 6, 24 }, { 7, 24 }, { 7, 27 }, { 7, 9 }
+	{ 1, 12, false }, { 1, 10, false }, { 9, 49, false }, { 2, 9, false },
+	{ 2, 15, false }, { 2, 19, true }, { 3, 11, false }, { 3, 10, false },
+	{ 3, 16, false }, { 3, 17, false }, { 4, 12, false }, { 5, 29, false },
+	{ 6, 24, false }, { 7, 24, false }, { 7, 27, true }, { 7, 9, false }
 };
 
 static void showError(const Common::String &message) {
@@ -191,8 +196,20 @@ private:
 	bool _section3ExtraCommand;
 
 	void runMusicMenu() {
+		bool enabled[ARRAYSIZE(kBonusTracks) + 1];
+		const bool pcSpeaker = _soundManager.usesPCSpeaker();
+		for (uint index = 0; index < ARRAYSIZE(kBonusTracks); ++index)
+			enabled[index] = !pcSpeaker || kBonusTracks[index].pcSpeakerAvailable;
+		enabled[ARRAYSIZE(kBonusTracks)] = true;
+
+		if (pcSpeaker)
+			debug(2, "MADS Bonus Disk: PC Speaker music menu enables "
+					"tracks 6 and 15; the remaining ISOUND commands are "
+					"absent or immediate terminators");
+
 		while (!g_engine->shouldQuit()) {
-			const int selected = _ui.runMusicMenu(_musicSelection);
+			const int selected = _ui.runMusicMenu(_musicSelection,
+					pcSpeaker ? enabled : nullptr);
 			if (selected < 0 || selected == (int)ARRAYSIZE(kBonusTracks))
 				return;
 			playTrack(selected);
@@ -204,6 +221,17 @@ private:
 			return;
 
 		const BonusTrack &track = kBonusTracks[index];
+		if (_soundManager.usesPCSpeaker() &&
+				!track.pcSpeakerAvailable) {
+			debug(2, "MADS Bonus Disk: rejected unavailable PC Speaker "
+					"track %d (section %u command %u)", index + 1,
+					track.section, track.command);
+			return;
+		}
+
+		debug(2, "MADS Bonus Disk: starting track %d from section %u, command %u",
+				index + 1, track.section, track.command);
+
 		// Present the card before loading the driver. This keeps the final
 		// AnimView frame from leaking through while a driver is starting.
 		_ui.prepareNowPlaying(_text.musicTitles[index]);
@@ -217,8 +245,14 @@ private:
 				!g_engine->shouldQuit() &&
 				g_system->getMillis() - startupTime < 1000)
 			g_system->delayMillis(1);
+		debug(2, "MADS Bonus Disk: driver startup completed after %u ms; "
+				"active=%d", g_system->getMillis() - startupTime,
+				_soundManager.isDriverActive() ? 1 : 0);
 
 		_soundManager.command(track.command, 127);
+		debug(2, "MADS Bonus Disk: dispatched section %u command %u; active=%d",
+				track.section, track.command,
+				_soundManager.isDriverActive() ? 1 : 0);
 
 		// These follow the control flow surrounding the native track table in
 		// BONUS.EXE; they are player commands, not additional track entries.
@@ -236,6 +270,8 @@ private:
 		Common::Functor0Mem<bool, BonusApplication> isPlaying(
 				this, &BonusApplication::isDriverActive);
 		_ui.waitForNowPlaying(_text.musicTitles[index], isPlaying);
+		debug(2, "MADS Bonus Disk: track %d dismissed or completed; active=%d",
+				index + 1, _soundManager.isDriverActive() ? 1 : 0);
 		if (_soundManager.isLoaded())
 			_soundManager.closeDriver();
 	}
diff --git a/engines/mads/nebular/bonus/bonus_text_ui.cpp b/engines/mads/nebular/bonus/bonus_text_ui.cpp
index 8d4596ca771..5da8f6eeb29 100644
--- a/engines/mads/nebular/bonus/bonus_text_ui.cpp
+++ b/engines/mads/nebular/bonus/bonus_text_ui.cpp
@@ -31,6 +31,7 @@ static const byte kDesktopAttribute = 0x17;
 static const byte kDesktopCharacter = 0xB1;
 static const byte kShadowAttribute = 0x08;
 static const byte kNormalAttribute = 0x47;
+static const byte kDisabledAttribute = 0x48;
 static const byte kSelectedAttribute = 0x74;
 static const byte kHotkeyAttribute = 0x4F;
 static const byte kSelectedHotkeyAttribute = 0x7F;
@@ -88,7 +89,8 @@ int BonusTextUI::itemRow(const Common::Rect &rect, int itemCount, int index) {
 }
 
 void BonusTextUI::drawMenu(const Common::Rect &rect, const Common::String &title,
-		const Common::String *items, int itemCount, int selected) {
+		const Common::String *items, int itemCount, int selected,
+		const bool *enabled) {
 	_cells.drawShadow(rect, kShadowAttribute);
 	_cells.fill(Common::Rect(rect.left + 1, rect.top + 1,
 			rect.right - 1, rect.bottom - 1), 0x20, kNormalAttribute);
@@ -101,10 +103,11 @@ void BonusTextUI::drawMenu(const Common::Rect &rect, const Common::String &title
 
 	for (int index = 0; index < itemCount; ++index) {
 		const int y = itemRow(rect, itemCount, index);
-		const byte attribute = index == selected ?
-				kSelectedAttribute : kNormalAttribute;
-		const byte hotkeyAttribute = index == selected ?
-				kSelectedHotkeyAttribute : kHotkeyAttribute;
+		const bool itemEnabled = !enabled || enabled[index];
+		const byte attribute = !itemEnabled ? kDisabledAttribute :
+				(index == selected ? kSelectedAttribute : kNormalAttribute);
+		const byte hotkeyAttribute = !itemEnabled ? kDisabledAttribute :
+				(index == selected ? kSelectedHotkeyAttribute : kHotkeyAttribute);
 
 		_cells.fill(Common::Rect(rect.left + 1, y, rect.right - 1, y + 1),
 				0x20, attribute);
@@ -113,6 +116,19 @@ void BonusTextUI::drawMenu(const Common::Rect &rect, const Common::String &title
 	}
 }
 
+int BonusTextUI::nextEnabled(const bool *enabled, int itemCount,
+		int selected, int direction) {
+	if (!enabled)
+		return (selected + itemCount + direction) % itemCount;
+
+	for (int count = 0; count < itemCount; ++count) {
+		selected = (selected + itemCount + direction) % itemCount;
+		if (enabled[selected])
+			return selected;
+	}
+	return selected;
+}
+
 void BonusTextUI::drawMouseCursor() {
 	if (_mouseCellValid && _cells.isValidCell(_mouseCell.x, _mouseCell.y)) {
 		const byte attribute = _cells.getCell(_mouseCell.x, _mouseCell.y).attribute;
@@ -194,9 +210,12 @@ bool BonusTextUI::processGameMenuEvent(const Common::Event &event) {
 }
 
 int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
-		const Common::String *items, int itemCount, int &selected) {
+		const Common::String *items, int itemCount, int &selected,
+		const bool *enabled, bool showPCSpeakerNotice) {
 	restorePresentation();
 	selected = CLIP<int>(selected, 0, itemCount - 1);
+	if (enabled && !enabled[selected])
+		selected = nextEnabled(enabled, itemCount, selected - 1, 1);
 	bool dirty = true;
 
 	while (!g_engine->shouldQuit()) {
@@ -214,7 +233,7 @@ int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
 				updateMouseCell(event.mouse);
 				const int hit = rowAtMouse(rect, itemCount,
 						event.mouse.x, event.mouse.y);
-				if (hit >= 0)
+				if (hit >= 0 && (!enabled || enabled[hit]))
 					selected = hit;
 				dirty = true;
 				break;
@@ -223,7 +242,7 @@ int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
 				updateMouseCell(event.mouse);
 				const int hit = rowAtMouse(rect, itemCount,
 						event.mouse.x, event.mouse.y);
-				if (hit >= 0) {
+				if (hit >= 0 && (!enabled || enabled[hit])) {
 					selected = hit;
 					return selected;
 				}
@@ -233,16 +252,16 @@ int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
 			case Common::EVENT_KEYDOWN: {
 				const Common::KeyCode key = event.kbd.keycode;
 				if (key == Common::KEYCODE_UP) {
-					selected = (selected + itemCount - 1) % itemCount;
+					selected = nextEnabled(enabled, itemCount, selected, -1);
 					dirty = true;
 				} else if (key == Common::KEYCODE_DOWN) {
-					selected = (selected + 1) % itemCount;
+					selected = nextEnabled(enabled, itemCount, selected, 1);
 					dirty = true;
 				} else if (key == Common::KEYCODE_HOME) {
-					selected = 0;
+					selected = nextEnabled(enabled, itemCount, -1, 1);
 					dirty = true;
 				} else if (key == Common::KEYCODE_END) {
-					selected = itemCount - 1;
+					selected = nextEnabled(enabled, itemCount, 0, -1);
 					dirty = true;
 				} else if (key == Common::KEYCODE_RETURN ||
 						key == Common::KEYCODE_KP_ENTER) {
@@ -253,7 +272,7 @@ int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
 				} else {
 					const int hit = acceleratorChoice(items, itemCount,
 							event.kbd.ascii);
-					if (hit >= 0) {
+					if (hit >= 0 && (!enabled || enabled[hit])) {
 						selected = hit;
 						return selected;
 					}
@@ -274,7 +293,15 @@ int BonusTextUI::runMenu(const Common::Rect &rect, const Common::String &title,
 		if (dirty) {
 			drawDesktop();
 			drawTitlePanel();
-			drawMenu(rect, title, items, itemCount, selected);
+			if (showPCSpeakerNotice) {
+				_cells.drawCenteredText(11, 1, 39,
+						"Some Musical Options do not", kTitleAttribute,
+						kTitleAttribute, false);
+				_cells.drawCenteredText(12, 1, 39,
+						"work with PC Speaker.", kTitleAttribute,
+						kTitleAttribute, false);
+			}
+			drawMenu(rect, title, items, itemCount, selected, enabled);
 			drawMouseCursor();
 			present(true);
 			dirty = false;
@@ -291,14 +318,14 @@ BonusTextUI::MainChoice BonusTextUI::runMainMenu(int &selected) {
 	return result < 0 ? kAbort : (MainChoice)result;
 }
 
-int BonusTextUI::runMusicMenu(int &selected) {
+int BonusTextUI::runMusicMenu(int &selected, const bool *enabled) {
 	Common::String items[17];
 	for (uint index = 0; index < ARRAYSIZE(_text.musicTitles); ++index)
 		items[index] = _text.musicTitles[index];
 	items[16] = _text.musicExit;
 
 	return runMenu(_musicRect, _text.musicMenuTitle,
-			items, ARRAYSIZE(items), selected);
+			items, ARRAYSIZE(items), selected, enabled, enabled != nullptr);
 }
 
 void BonusTextUI::appendWrappedLine(const Common::String &source, int width,
diff --git a/engines/mads/nebular/bonus/bonus_text_ui.h b/engines/mads/nebular/bonus/bonus_text_ui.h
index 875663af415..b49b3a1acab 100644
--- a/engines/mads/nebular/bonus/bonus_text_ui.h
+++ b/engines/mads/nebular/bonus/bonus_text_ui.h
@@ -42,7 +42,7 @@ public:
 
 	bool init(Common::String &errorMessage);
 	MainChoice runMainMenu(int &selected);
-	int runMusicMenu(int &selected);
+	int runMusicMenu(int &selected, const bool *enabled);
 	bool showBonusText(const Common::Path &filename);
 	void prepareNowPlaying(const Common::String &trackTitle);
 	void waitForNowPlaying(const Common::String &trackTitle,
@@ -66,14 +66,18 @@ private:
 	void drawDesktop();
 	void drawTitlePanel();
 	void drawMenu(const Common::Rect &rect, const Common::String &title,
-			const Common::String *items, int itemCount, int selected);
+			const Common::String *items, int itemCount, int selected,
+			const bool *enabled);
 	void drawMouseCursor();
 	void present(bool forcePalette = false);
 	void restorePresentation();
 	void updateMouseCell(const Common::Point &position);
 
 	int runMenu(const Common::Rect &rect, const Common::String &title,
-			const Common::String *items, int itemCount, int &selected);
+			const Common::String *items, int itemCount, int &selected,
+			const bool *enabled = nullptr, bool showPCSpeakerNotice = false);
+	static int nextEnabled(const bool *enabled, int itemCount,
+			int selected, int direction);
 	static int itemRow(const Common::Rect &rect, int itemCount, int index);
 	static int rowAtMouse(const Common::Rect &rect, int itemCount,
 			int mouseX, int mouseY);
diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index ef515089955..16953c79da5 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "common/debug.h"
 #include "common/endian.h"
 #include "common/file.h"
 #include "common/textconsole.h"
@@ -247,9 +248,14 @@ void ISound::playSequence(uint16 sequenceOffset, byte priority) {
 	// Native code uses CMP followed by JS. Preserve the sign bit of the
 	// wrapped eight-bit subtraction rather than using a wider comparison.
 	if ((int8)(byte)(priority - _priority) < 0) {
+		debug(2, "MADS ISOUND: rejected sequence 0x%04x priority %u "
+				"while priority %u is active", sequenceOffset, priority,
+				_priority);
 		_updatesEnabled = wasEnabled;
 		return;
 	}
+	debug(2, "MADS ISOUND: starting sequence 0x%04x priority %u "
+			"(replacing priority %u)", sequenceOffset, priority, _priority);
 
 	_priority = priority;
 	_sequenceStart = sequenceOffset;
@@ -412,6 +418,8 @@ void ISound::processOrdinaryEvent() {
 		stopSpeaker();
 
 	if (!_activeTicks) {
+		debug(2, "MADS ISOUND: sequence 0x%04x completed at 0x%04x",
+				_sequenceStart, (uint16)(_position - 2));
 		_priority = 0;
 		_pitchStep = 0;
 		_sweepInitialized = false;
diff --git a/engines/mads/nebular/sound/isound_nebular.cpp b/engines/mads/nebular/sound/isound_nebular.cpp
index 8668e684d47..d640fdd68c3 100644
--- a/engines/mads/nebular/sound/isound_nebular.cpp
+++ b/engines/mads/nebular/sound/isound_nebular.cpp
@@ -21,6 +21,8 @@
 
 #include "mads/nebular/sound/isound_nebular.h"
 
+#include "common/debug.h"
+
 namespace MADS {
 namespace RexNebular {
 namespace Sound {
@@ -408,19 +410,35 @@ ISoundSection::ISoundSection(Audio::Mixer *mixer, const char *filename,
 
 int ISoundSection::command(int commandId, int param) {
 	Common::StackLock lock(_driverMutex);
-	if (commandId < 0 || (uint)commandId >= _commandCount)
+	if (commandId < 0 || (uint)commandId >= _commandCount) {
+		debug(2, "MADS ISOUND: command %d is outside the section table", commandId);
 		return 0;
+	}
 
 	beginCommand(param);
 	if (commandId <= 8)
 		return executeCommonCommand(commandId);
 
 	const ISoundCommandSequence &entry = _commands[commandId];
-	if (entry.parameterAtLeast120 && _commandParam < 0x78)
+	if (entry.parameterAtLeast120 && _commandParam < 0x78) {
+		debug(2, "MADS ISOUND: command %d ignored because parameter %u "
+				"is below 120", commandId, _commandParam);
 		return 0;
+	}
 
-	if (entry.sequenceOffset)
+	if (entry.sequenceOffset) {
+		const bool immediateTerminator =
+				readSequenceByte(entry.sequenceOffset) == 0 &&
+				readSequenceByte((uint16)(entry.sequenceOffset + 1)) == 0;
+		debug(2, "MADS ISOUND: command %d parameter %u maps to sequence "
+				"0x%04x priority %u%s", commandId, _commandParam,
+				entry.sequenceOffset, entry.priority,
+				immediateTerminator ? " (immediate terminator)" : "");
 		playSequence(entry.sequenceOffset, entry.priority);
+	} else {
+		debug(2, "MADS ISOUND: command %d parameter %u has no sequence",
+				commandId, _commandParam);
+	}
 
 	return 0;
 }


Commit: 08f28056a54e9983cf3a240b560e715cbd2adfc7
    https://github.com/scummvm/scummvm/commit/08f28056a54e9983cf3a240b560e715cbd2adfc7
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: Reposition Bonus Disk goodbye message

Render the Bonus Disk goodbye message two text rows below the top edge
for better visibility and a more balanced appearance.

Changed paths:
    engines/mads/nebular/bonus/bonus_text_ui.cpp


diff --git a/engines/mads/nebular/bonus/bonus_text_ui.cpp b/engines/mads/nebular/bonus/bonus_text_ui.cpp
index 5da8f6eeb29..8d643eaac3b 100644
--- a/engines/mads/nebular/bonus/bonus_text_ui.cpp
+++ b/engines/mads/nebular/bonus/bonus_text_ui.cpp
@@ -506,7 +506,7 @@ void BonusTextUI::waitForNowPlaying(const Common::String &trackTitle,
 void BonusTextUI::showGoodbye() {
 	restorePresentation();
 	_cells.clear(0x20, 0x07);
-	_cells.drawText(0, 0, _text.goodbye, 0x07, 0x07, false,
+	_cells.drawText(0, 2, _text.goodbye, 0x07, 0x07, false,
 			DOSTextScreen::kColumns);
 	present(true);
 


Commit: c9322e3ae392f40851dad64f725c80b3725c3014
    https://github.com/scummvm/scummvm/commit/c9322e3ae392f40851dad64f725c80b3725c3014
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-10T22:35:07+10:00

Commit Message:
MADS: Remove obsolete Bonus Disk diagnostics

Remove temporary sound tracing from Bonus music selection and ISOUND
command processing now that unsupported PC speaker entries are disabled
before dispatch.
Remove the temporary early-exit and room-palette trace messages now that
Bonus Disk presentation cleanup is established.

Assisted-by: Codex:GPT-5.4

Changed paths:
    engines/mads/animview/animview.cpp
    engines/mads/nebular/bonus/bonus.cpp
    engines/mads/nebular/sound/isound.cpp
    engines/mads/nebular/sound/isound_nebular.cpp


diff --git a/engines/mads/animview/animview.cpp b/engines/mads/animview/animview.cpp
index 217a6d32754..9e6576f260f 100644
--- a/engines/mads/animview/animview.cpp
+++ b/engines/mads/animview/animview.cpp
@@ -20,7 +20,6 @@
  */
 
 #include "audio/audiostream.h"
-#include "common/debug.h"
 #include "common/file.h"
 #include "mads/core/env.h"
 #include "mads/core/himem.h"
@@ -153,10 +152,8 @@ static bool isBonusDiskMode() {
 
 static bool bonusDiskAbortRequested() {
 	while (g_engine->hasPendingKey()) {
-		if (g_engine->getKey() == Common::KEYCODE_ESCAPE) {
-			debug(2, "MADS Bonus Disk AnimView: Escape requested early exit");
+		if (g_engine->getKey() == Common::KEYCODE_ESCAPE)
 			return true;
-		}
 	}
 
 	return false;
@@ -166,9 +163,6 @@ static void unloadRoom() {
 	if (!room)
 		return;
 
-	if (isBonusDiskMode())
-		debug(2, "MADS Bonus Disk AnimView: releasing room palette handle %d",
-				room->color_handle);
 	pal_deallocate(room->color_handle);
 	mem_free(room);
 	room = nullptr;
diff --git a/engines/mads/nebular/bonus/bonus.cpp b/engines/mads/nebular/bonus/bonus.cpp
index a1bd50e6a5e..8bd3f12cef0 100644
--- a/engines/mads/nebular/bonus/bonus.cpp
+++ b/engines/mads/nebular/bonus/bonus.cpp
@@ -13,7 +13,6 @@
 #include "mads/nebular/bonus/bonus.h"
 
 #include "common/array.h"
-#include "common/debug.h"
 #include "common/file.h"
 #include "common/func.h"
 #include "common/path.h"
@@ -202,11 +201,6 @@ private:
 			enabled[index] = !pcSpeaker || kBonusTracks[index].pcSpeakerAvailable;
 		enabled[ARRAYSIZE(kBonusTracks)] = true;
 
-		if (pcSpeaker)
-			debug(2, "MADS Bonus Disk: PC Speaker music menu enables "
-					"tracks 6 and 15; the remaining ISOUND commands are "
-					"absent or immediate terminators");
-
 		while (!g_engine->shouldQuit()) {
 			const int selected = _ui.runMusicMenu(_musicSelection,
 					pcSpeaker ? enabled : nullptr);
@@ -222,15 +216,8 @@ private:
 
 		const BonusTrack &track = kBonusTracks[index];
 		if (_soundManager.usesPCSpeaker() &&
-				!track.pcSpeakerAvailable) {
-			debug(2, "MADS Bonus Disk: rejected unavailable PC Speaker "
-					"track %d (section %u command %u)", index + 1,
-					track.section, track.command);
+				!track.pcSpeakerAvailable)
 			return;
-		}
-
-		debug(2, "MADS Bonus Disk: starting track %d from section %u, command %u",
-				index + 1, track.section, track.command);
 
 		// Present the card before loading the driver. This keeps the final
 		// AnimView frame from leaking through while a driver is starting.
@@ -245,14 +232,8 @@ private:
 				!g_engine->shouldQuit() &&
 				g_system->getMillis() - startupTime < 1000)
 			g_system->delayMillis(1);
-		debug(2, "MADS Bonus Disk: driver startup completed after %u ms; "
-				"active=%d", g_system->getMillis() - startupTime,
-				_soundManager.isDriverActive() ? 1 : 0);
 
 		_soundManager.command(track.command, 127);
-		debug(2, "MADS Bonus Disk: dispatched section %u command %u; active=%d",
-				track.section, track.command,
-				_soundManager.isDriverActive() ? 1 : 0);
 
 		// These follow the control flow surrounding the native track table in
 		// BONUS.EXE; they are player commands, not additional track entries.
@@ -270,8 +251,6 @@ private:
 		Common::Functor0Mem<bool, BonusApplication> isPlaying(
 				this, &BonusApplication::isDriverActive);
 		_ui.waitForNowPlaying(_text.musicTitles[index], isPlaying);
-		debug(2, "MADS Bonus Disk: track %d dismissed or completed; active=%d",
-				index + 1, _soundManager.isDriverActive() ? 1 : 0);
 		if (_soundManager.isLoaded())
 			_soundManager.closeDriver();
 	}
diff --git a/engines/mads/nebular/sound/isound.cpp b/engines/mads/nebular/sound/isound.cpp
index 16953c79da5..ef515089955 100644
--- a/engines/mads/nebular/sound/isound.cpp
+++ b/engines/mads/nebular/sound/isound.cpp
@@ -19,7 +19,6 @@
  *
  */
 
-#include "common/debug.h"
 #include "common/endian.h"
 #include "common/file.h"
 #include "common/textconsole.h"
@@ -248,14 +247,9 @@ void ISound::playSequence(uint16 sequenceOffset, byte priority) {
 	// Native code uses CMP followed by JS. Preserve the sign bit of the
 	// wrapped eight-bit subtraction rather than using a wider comparison.
 	if ((int8)(byte)(priority - _priority) < 0) {
-		debug(2, "MADS ISOUND: rejected sequence 0x%04x priority %u "
-				"while priority %u is active", sequenceOffset, priority,
-				_priority);
 		_updatesEnabled = wasEnabled;
 		return;
 	}
-	debug(2, "MADS ISOUND: starting sequence 0x%04x priority %u "
-			"(replacing priority %u)", sequenceOffset, priority, _priority);
 
 	_priority = priority;
 	_sequenceStart = sequenceOffset;
@@ -418,8 +412,6 @@ void ISound::processOrdinaryEvent() {
 		stopSpeaker();
 
 	if (!_activeTicks) {
-		debug(2, "MADS ISOUND: sequence 0x%04x completed at 0x%04x",
-				_sequenceStart, (uint16)(_position - 2));
 		_priority = 0;
 		_pitchStep = 0;
 		_sweepInitialized = false;
diff --git a/engines/mads/nebular/sound/isound_nebular.cpp b/engines/mads/nebular/sound/isound_nebular.cpp
index d640fdd68c3..8668e684d47 100644
--- a/engines/mads/nebular/sound/isound_nebular.cpp
+++ b/engines/mads/nebular/sound/isound_nebular.cpp
@@ -21,8 +21,6 @@
 
 #include "mads/nebular/sound/isound_nebular.h"
 
-#include "common/debug.h"
-
 namespace MADS {
 namespace RexNebular {
 namespace Sound {
@@ -410,35 +408,19 @@ ISoundSection::ISoundSection(Audio::Mixer *mixer, const char *filename,
 
 int ISoundSection::command(int commandId, int param) {
 	Common::StackLock lock(_driverMutex);
-	if (commandId < 0 || (uint)commandId >= _commandCount) {
-		debug(2, "MADS ISOUND: command %d is outside the section table", commandId);
+	if (commandId < 0 || (uint)commandId >= _commandCount)
 		return 0;
-	}
 
 	beginCommand(param);
 	if (commandId <= 8)
 		return executeCommonCommand(commandId);
 
 	const ISoundCommandSequence &entry = _commands[commandId];
-	if (entry.parameterAtLeast120 && _commandParam < 0x78) {
-		debug(2, "MADS ISOUND: command %d ignored because parameter %u "
-				"is below 120", commandId, _commandParam);
+	if (entry.parameterAtLeast120 && _commandParam < 0x78)
 		return 0;
-	}
 
-	if (entry.sequenceOffset) {
-		const bool immediateTerminator =
-				readSequenceByte(entry.sequenceOffset) == 0 &&
-				readSequenceByte((uint16)(entry.sequenceOffset + 1)) == 0;
-		debug(2, "MADS ISOUND: command %d parameter %u maps to sequence "
-				"0x%04x priority %u%s", commandId, _commandParam,
-				entry.sequenceOffset, entry.priority,
-				immediateTerminator ? " (immediate terminator)" : "");
+	if (entry.sequenceOffset)
 		playSequence(entry.sequenceOffset, entry.priority);
-	} else {
-		debug(2, "MADS ISOUND: command %d parameter %u has no sequence",
-				commandId, _commandParam);
-	}
 
 	return 0;
 }




More information about the Scummvm-git-logs mailing list