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

bluegr noreply at scummvm.org
Mon Aug 19 04:40:00 UTC 2024


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

Summary:
f6e38f6d52 AGI: New AGIv1 loader and detection
fe12daf665 AGI: Add Apple II loader and detection
e45022abc1 AGI: Add Apple II view support


Commit: f6e38f6d52241e26e4b19a2e0d9f4a07d6dd7c53
    https://github.com/scummvm/scummvm/commit/f6e38f6d52241e26e4b19a2e0d9f4a07d6dd7c53
Author: sluicebox (22204938+sluicebox at users.noreply.github.com)
Date: 2024-08-19T07:39:54+03:00

Commit Message:
AGI: New AGIv1 loader and detection

- Detection table now uses LOGDIR hash instead of disk image hash.
- Detection no longer requires hard-coded names for image files.
- Loader now parses INITDIR to locate files
- King's Quest II now loads resources. Promoted to Unstable.
- All AGIv1 game versions are now detected and can load resources.

Changed paths:
  A engines/agi/disk_image.h
    engines/agi/agi.h
    engines/agi/detection.cpp
    engines/agi/detection_tables.h
    engines/agi/loader_v1.cpp
    engines/agi/objects.cpp


diff --git a/engines/agi/agi.h b/engines/agi/agi.h
index 77e6d89966c..f15ed9b2182 100644
--- a/engines/agi/agi.h
+++ b/engines/agi/agi.h
@@ -113,11 +113,6 @@ enum AgiGameFeatures {
 	GF_EXTCHAR     = (1 << 6)  // use WORDS.TOK.EXTENDED
 };
 
-enum BooterDisks {
-	BooterDisk1 = 0,
-	BooterDisk2 = 1
-};
-
 enum AgiGameID {
 	GID_AGIDEMO,
 	GID_BC,
@@ -144,6 +139,7 @@ enum AgiGameID {
 
 enum AGIErrors {
 	errOK = 0,
+	errFilesNotFound,
 	errBadFileOpen,
 	errNotEnoughMemory,
 	errBadResource,
@@ -533,6 +529,14 @@ struct AgiGame {
 	}
 };
 
+struct AgiDiskVolume {
+	uint32 disk;
+	uint32 offset;
+
+	AgiDiskVolume() : disk(_EMPTY), offset(0) {}
+	AgiDiskVolume(uint32 d, uint32 o) : disk(d), offset(o) {}
+};
+
 class AgiLoader {
 public:
 	AgiLoader(AgiEngine *vm) : _vm(vm) {}
@@ -570,13 +574,6 @@ protected:
 };
 
 class AgiLoader_v1 : public AgiLoader {
-private:
-	Common::Path _filenameDisk0;
-	Common::Path _filenameDisk1;
-
-	int loadDir_DDP(AgiDir *agid, int offset, int max);
-	int loadDir_BC(AgiDir *agid, int offset, int max);
-
 public:
 	AgiLoader_v1(AgiEngine *vm) : AgiLoader(vm) {}
 
@@ -585,6 +582,23 @@ public:
 	uint8 *loadVolumeResource(AgiDir *agid) override;
 	int loadObjects() override;
 	int loadWords() override;
+
+private:
+	Common::Array<Common::String> _imageFiles;
+	Common::Array<AgiDiskVolume> _volumes;
+	AgiDir _logDir;
+	AgiDir _picDir;
+	AgiDir _viewDir;
+	AgiDir _soundDir;
+	AgiDir _objects;
+	AgiDir _words;
+
+	bool readDiskOneV1(Common::SeekableReadStream &stream);
+	bool readDiskOneV2001(Common::SeekableReadStream &stream, int &vol0Offset);
+	static bool readInitDirV1(Common::SeekableReadStream &stream, byte index, AgiDir &agid);
+	static bool readInitDirV2001(Common::SeekableReadStream &stream, byte index, AgiDir &agid);
+
+	bool loadDir(AgiDir *dir, Common::File &disk, uint32 dirOffset, uint32 dirLength);
 };
 
 class AgiLoader_v2 : public AgiLoader {
@@ -879,13 +893,12 @@ public:
 	// Objects
 public:
 	int loadObjects(const char *fname);
-	int loadObjects(Common::File &fp);
+	int loadObjects(Common::File &fp, int flen);
 	const char *objectName(uint16 objectNr);
 	int objectGetLocation(uint16 objectNr);
 	void objectSetLocation(uint16 objectNr, int location);
 private:
 	int decodeObjects(uint8 *mem, uint32 flen);
-	int readObjects(Common::File &fp, int flen);
 
 	// Logic
 public:
diff --git a/engines/agi/detection.cpp b/engines/agi/detection.cpp
index f3067b89894..34286e0effe 100644
--- a/engines/agi/detection.cpp
+++ b/engines/agi/detection.cpp
@@ -23,12 +23,14 @@
 #include "common/config-manager.h"
 #include "common/system.h"
 #include "common/debug.h"
+#include "common/md5.h"
 
 #include "base/plugins.h"
 #include "engines/advancedDetector.h"
 #include "engines/metaengine.h"
 
 #include "agi/detection.h"
+#include "agi/disk_image.h"
 #include "agi/wagparser.h" // for fallback detection
 #include "agi/agi.h"
 
@@ -80,7 +82,7 @@ static const PlainGameDescriptor agiGames[] = {
 
 #include "agi/detection_tables.h"
 
-using namespace Agi;
+namespace Agi {
 
 class AgiMetaEngineDetection : public AdvancedMetaEngineDetection<AGIGameDescription> {
 	mutable Common::String _gameid;
@@ -112,6 +114,17 @@ public:
 	}
 
 	ADDetectedGame fallbackDetect(const FileMap &allFiles, const Common::FSList &fslist, ADDetectedGameExtraInfo **extra) const override;
+
+	ADDetectedGames detectGame(const Common::FSNode &parent, const FileMap &allFiles, Common::Language language, Common::Platform platform, const Common::String &extra, uint32 skipADFlags, bool skipIncomplete) override;
+
+private:
+	static void getPotentialDiskImages(const FileMap &allFiles, const char * const *imageExtensions, size_t extensionCount, Common::Array<Common::Path> &imageFiles);
+
+	static ADDetectedGame detectPcDiskImageGame(const FileMap &allFiles, uint32 skipADFlags);
+	static Common::String getLogDirHashFromPcDiskImageV1(Common::SeekableReadStream &stream);
+	static Common::String getLogDirHashFromPcDiskImageV2001(Common::SeekableReadStream &stream);
+
+	static Common::String getLogDirHashFromDiskImage(Common::SeekableReadStream &stream, uint32 position);
 };
 
 ADDetectedGame AgiMetaEngineDetection::fallbackDetect(const FileMap &allFilesXXX, const Common::FSList &fslist, ADDetectedGameExtraInfo **extra) const {
@@ -282,4 +295,184 @@ ADDetectedGame AgiMetaEngineDetection::fallbackDetect(const FileMap &allFilesXXX
 	return ADDetectedGame();
 }
 
-REGISTER_PLUGIN_STATIC(AGI_DETECTION, PLUGIN_TYPE_ENGINE_DETECTION, AgiMetaEngineDetection);
+/**
+ * Detection override for handling disk images after file-based detection.
+ */
+ADDetectedGames AgiMetaEngineDetection::detectGame(
+	const Common::FSNode &parent,
+	const FileMap &allFiles,
+	Common::Language language,
+	Common::Platform platform,
+	const Common::String &extra,
+	uint32 skipADFlags,
+	bool skipIncomplete) {
+
+	// Run the file-based detection first, if it finds a match then do not search for disk images.
+	ADDetectedGames matched = AdvancedMetaEngineDetection::detectGame(parent, allFiles, language, platform, extra, skipADFlags, skipIncomplete);
+
+	// Detect games within PC disk images. This detection will find one game at most.
+	if (matched.empty() &&
+		(language == Common::UNK_LANG || language == Common::EN_ANY) &&
+		(platform == Common::kPlatformUnknown || platform == Common::kPlatformDOS)) {
+		ADDetectedGame game = detectPcDiskImageGame(allFiles, skipADFlags);
+		if (game.desc != nullptr) {
+			matched.push_back(game);
+		}
+	}
+
+	return matched;
+}
+
+void AgiMetaEngineDetection::getPotentialDiskImages(
+	const FileMap &allFiles,
+	const char * const *imageExtensions,
+	size_t imageExtensionCount,
+	Common::Array<Common::Path> &imageFiles) {
+
+	// build an array of files with disk image extensions
+	for (FileMap::const_iterator f = allFiles.begin(); f != allFiles.end(); ++f) {
+		for (size_t i = 0; i < imageExtensionCount; i++) {
+			if (f->_key.baseName().hasSuffixIgnoreCase(imageExtensions[i])) {
+				debug(3, "potential disk image: %s", f->_key.baseName().c_str());
+				imageFiles.push_back(f->_key);
+			}
+		}
+	}
+
+	// sort potential image files by name
+	Common::sort(imageFiles.begin(), imageFiles.end());
+}
+
+/**
+ * Detects a PC Booter game by searching for 360k floppy images, reading LOGDIR,
+ * hashing LOGDIR, and comparing to DOS GType_V1 entries in the detection table.
+ * See AgiLoader_v1 in loader_v1.cpp for more details.
+ */
+ADDetectedGame AgiMetaEngineDetection::detectPcDiskImageGame(const FileMap &allFiles, uint32 skipADFlags) {
+	// build array of files with pc disk image extensions
+	Common::Array<Common::Path> imageFiles;
+	getPotentialDiskImages(allFiles, pcDiskImageExtensions, ARRAYSIZE(pcDiskImageExtensions), imageFiles);
+
+	// find disk one by reading potential images until a match is found
+	for (const Common::Path &imageFile : imageFiles) {
+		Common::SeekableReadStream *stream = allFiles[imageFile].createReadStream();
+		if (stream == nullptr) {
+			warning("unable to open disk image: %s", imageFile.baseName().c_str());
+			continue;
+		}
+
+		// image file size must be 360k
+		int64 fileSize = stream->size();
+		if (fileSize != PC_DISK_SIZE) {
+			delete stream;
+			continue;
+		}
+
+		// attempt to locate and hash logdir using both possible inidir disk locations
+		Common::String logdirHash1 = getLogDirHashFromPcDiskImageV1(*stream);
+		Common::String logdirHash2 = getLogDirHashFromPcDiskImageV2001(*stream);
+		delete stream;
+
+		if (!logdirHash1.empty()) {
+			debug(3, "pc disk logdir hash: %s, %s", logdirHash1.c_str(), imageFile.baseName().c_str());
+		}
+		if (!logdirHash2.empty()) {
+			debug(3, "pc disk logdir hash: %s, %s", logdirHash2.c_str(), imageFile.baseName().c_str());
+		}
+
+		// if logdir hash found then compare against hashes of DOS GType_V1 entries
+		if (!logdirHash1.empty() || !logdirHash2.empty()) {
+			for (const AGIGameDescription *game = gameDescriptions; game->desc.gameId != nullptr; game++) {
+				if (game->desc.platform == Common::kPlatformDOS && game->gameType == GType_V1 && !(game->desc.flags & skipADFlags)) {
+					const ADGameFileDescription *file;
+					for (file = game->desc.filesDescriptions; file->fileName != nullptr; file++) {
+						// select the logdir hash to use by the game's interpreter version
+						Common::String &logdirHash = (game->version < 0x2001) ? logdirHash1 : logdirHash2;
+						if (file->md5 != nullptr && !logdirHash.empty() && file->md5 == logdirHash) {
+							debug(3, "disk image match: %s, %s, %s", game->desc.gameId, game->desc.extra, imageFile.baseName().c_str());
+
+							// logdir hash match found
+							ADDetectedGame detectedGame(&game->desc);
+							FileProperties fileProps;
+							fileProps.md5 = file->md5;
+							fileProps.md5prop = kMD5Archive;
+							fileProps.size = fileSize;
+							detectedGame.matchedFiles[imageFile] = fileProps;
+							return detectedGame;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return ADDetectedGame();
+}
+
+Common::String AgiMetaEngineDetection::getLogDirHashFromPcDiskImageV1(Common::SeekableReadStream &stream) {
+	// read magic number from initdir resource header
+	stream.seek(PC_INITDIR_POSITION_V1);
+	uint16 magic = stream.readUint16BE();
+	if (magic != 0x1234) {
+		return "";
+	}
+
+	// seek to initdir entry for logdir (and skip remaining 3 bytes of header)
+	stream.skip(3 + (PC_INITDIR_LOGDIR_INDEX_V1 * PC_INITDIR_ENTRY_SIZE_V1));
+
+	// read logdir location
+	byte volume = stream.readByte();
+	byte head = stream.readByte();
+	uint16 track = stream.readUint16LE();
+	uint16 sector = stream.readUint16LE();
+	uint16 offset = stream.readUint16LE();
+
+	// logdir volume must be one
+	if (volume != 1) {
+		return "";
+	}
+
+	// read logdir
+	uint32 logDirPosition = PC_DISK_POSITION(head, track, sector, offset);
+	return getLogDirHashFromDiskImage(stream, logDirPosition);
+}
+
+Common::String AgiMetaEngineDetection::getLogDirHashFromPcDiskImageV2001(Common::SeekableReadStream &stream) {
+	// seek to initdir entry for logdir
+	stream.seek(PC_INITDIR_POSITION_V2001 + (PC_INITDIR_LOGDIR_INDEX_V2001 * PC_INITDIR_ENTRY_SIZE_V2001));
+
+	// read logdir location
+	// volume      4 bits
+	// position   12 bits  (in half-sectors)
+	byte b0 = stream.readByte();
+	byte b1 = stream.readByte();
+	byte volume = b0 >> 4;
+	uint32 position = (((b0 & 0x0f) << 8) + b1) * 256;
+
+	// logdir volume must be one
+	if (volume != 1) {
+		return "";
+	}
+
+	// read logdir
+	return getLogDirHashFromDiskImage(stream, position);
+}
+
+Common::String AgiMetaEngineDetection::getLogDirHashFromDiskImage(Common::SeekableReadStream &stream, uint32 position) {
+	stream.seek(position);
+	uint16 magic = stream.readUint16BE();
+	if (magic != 0x1234) {
+		return "";
+	}
+	stream.skip(1); // volume
+	uint16 logDirSize = stream.readUint16LE();
+	if (!(stream.pos() + logDirSize <= stream.size())) {
+		return "";
+	}
+
+	return Common::computeStreamMD5AsString(stream, logDirSize);
+}
+
+} // end of namespace Agi
+
+REGISTER_PLUGIN_STATIC(AGI_DETECTION, PLUGIN_TYPE_ENGINE_DETECTION, Agi::AgiMetaEngineDetection);
diff --git a/engines/agi/detection_tables.h b/engines/agi/detection_tables.h
index 5f120e38749..69e4696331f 100644
--- a/engines/agi/detection_tables.h
+++ b/engines/agi/detection_tables.h
@@ -62,22 +62,6 @@ namespace Agi {
 		ver \
 	}
 
-#define GAME_LVFPN_PIRATED(id,extra,fname,md5,size,lang,ver,features,gid,platform,interp,guioptions) { \
-		{ \
-			id, \
-			extra, \
-			AD_ENTRY1s(fname,md5,size), \
-			lang, \
-			platform, \
-			ADGF_PIRATED, \
-			guioptions \
-		}, \
-		gid, \
-		interp, \
-		features, \
-		ver \
-	}
-
 #define GAME_LVFPNF(id,name,fname,md5,size,lang,ver,features,gid,platform,interp,guioptions) { \
 		{ \
 			id, \
@@ -94,14 +78,14 @@ namespace Agi {
 		ver \
 	}
 
-#define GAME_LVFPNU(id,msg,fname,md5,size,lang,ver,features,gid,platform,interp,guioptions) { \
+#define GAME_LVFPN_FLAGS(id,msg,fname,md5,size,lang,ver,features,gid,platform,interp,guioptions,flags) { \
 		{ \
 			id, \
 			msg, \
 			AD_ENTRY1s(fname,md5,size), \
 			lang, \
 			platform, \
-			ADGF_UNSUPPORTED, \
+			flags, \
 			guioptions \
 		}, \
 		gid, \
@@ -110,14 +94,14 @@ namespace Agi {
 		ver \
 	}
 
-#define GAME_LVFPN2U(id,msg,fname_1,md5_1,size_1,fname_2,md5_2,size_2,lang,ver,features,gid,platform,interp,guioptions) { \
+#define GAME_LVFPN2_FLAGS(id,msg,fname_1,md5_1,size_1,fname_2,md5_2,size_2,lang,ver,features,gid,platform,interp,guioptions,flags) { \
 		{ \
 			id, \
 			msg, \
 			AD_ENTRY2s(fname_1,md5_1,size_1,fname_2,md5_2,size_2), \
 			lang, \
 			platform, \
-			ADGF_UNSUPPORTED, \
+			flags, \
 			guioptions \
 		}, \
 		gid, \
@@ -126,11 +110,12 @@ namespace Agi {
 		ver \
 	}
 
-#define BOOTER1_U(id,msg,fname,md5,size,ver,gid) GAME_LVFPNU(id,msg,fname,md5,size,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT)
-#define BOOTER2(id,extra,fname,md5,size,ver,gid) GAME_LVFPN(id,extra,fname,md5,size,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V2,GAMEOPTIONS_DEFAULT)
+#define BOOTER(id,extra,md5,ver,gid) GAME_LVFPN(id,extra,"*",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT)
+#define BOOTER_UNSTABLE(id,extra,md5,ver,gid) GAME_LVFPN_FLAGS(id,extra,"*",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT,ADGF_UNSTABLE)
+#define BOOTER_UNSUPPORTED(id,msg,fname,md5,size,ver,gid) GAME_LVFPN_FLAGS(id,msg,fname,md5,size,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT,ADGF_UNSUPPORTED)
 #define GAME(id,extra,md5,ver,gid) GAME_LVFPN(id,extra,"logdir",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V2,GAMEOPTIONS_DEFAULT)
 #define GAME3(id,extra,fname,md5,ver,gid) GAME_LVFPN(id,extra,fname,md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V3,GAMEOPTIONS_DEFAULT)
-#define GAME3_PIRATED(id,extra,fname,md5,ver,gid) GAME_LVFPN_PIRATED(id,extra,fname,md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V3,GAMEOPTIONS_DEFAULT)
+#define GAME3_PIRATED(id,extra,fname,md5,ver,gid) GAME_LVFPN_FLAGS(id,extra,fname,md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V3,GAMEOPTIONS_DEFAULT,ADGF_PIRATED)
 
 #define GAME_P(id,extra,md5,ver,gid,platform) GAME_LVFPN(id,extra,"logdir",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,platform,GType_V2,GAMEOPTIONS_DEFAULT)
 #define GAME_PO(id,extra,md5,ver,gid,platform,guioptions) GAME_LVFPN(id,extra,"logdir",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,platform,GType_V2,guioptions)
@@ -151,10 +136,10 @@ namespace Agi {
 #define GAME3_PO(id,extra,fname,md5,ver,flags,gid,platform,guioptions) GAME_LVFPN(id,extra,fname,md5,AD_NO_SIZE,Common::EN_ANY,ver,flags,gid,platform,GType_V3,guioptions)
 
 #define GAMEpre_P(id,extra,fname_1,md5_1,size_1,fname_2,md5_2,size_2,ver,gid,platform) GAME_LVFPN2(id,extra,fname_1,md5_1,size_1,fname_2,md5_2,size_2,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,GAMEOPTIONS_DEFAULT)
-#define GAMEpre_PU(id,msg,fname_1,md5_1,size_1,fname_2,md5_2,size_2,ver,gid,platform) GAME_LVFPN2U(id,msg,fname_1,md5_1,size_1,fname_2,md5_2,size_2,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,GAMEOPTIONS_DEFAULT)
+#define GAMEpre_PU(id,msg,fname_1,md5_1,size_1,fname_2,md5_2,size_2,ver,gid,platform) GAME_LVFPN2_FLAGS(id,msg,fname_1,md5_1,size_1,fname_2,md5_2,size_2,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,GAMEOPTIONS_DEFAULT,ADGF_UNSUPPORTED)
 #define GAMEpre_PO(id,extra,fname_1,md5_1,size_1,fname_2,md5_2,size_2,ver,gid,platform,guioptions) GAME_LVFPN2(id,extra,fname_1,md5_1,size_1,fname_2,md5_2,size_2,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,guioptions)
 #define GAMEpre_PS(id,extra,fname,md5,size,ver,gid,platform) GAME_LVFPN(id,extra,fname,md5,size,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,GAMEOPTIONS_DEFAULT)
-#define GAMEpre_PSU(id,msg,fname,md5,size,ver,gid,platform) GAME_LVFPNU(id,msg,fname,md5,size,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,GAMEOPTIONS_DEFAULT)
+#define GAMEpre_PSU(id,msg,fname,md5,size,ver,gid,platform) GAME_LVFPN_FLAGS(id,msg,fname,md5,size,Common::EN_ANY,ver,0,gid,platform,GType_PreAGI,GAMEOPTIONS_DEFAULT,ADGF_UNSUPPORTED)
 
 #define GAME3_PS(id,extra,fname,md5,size,ver,flags,gid,platform) GAME_LVFPN(id,extra,fname,md5,size,Common::EN_ANY,ver,flags,gid,platform,GType_V3,GAMEOPTIONS_DEFAULT)
 #define GAME3_PSO(id,extra,fname,md5,size,ver,flags,gid,platform,guioptions) GAME_LVFPN(id,extra,fname,md5,size,Common::EN_ANY,ver,flags,gid,platform,GType_V3,guioptions)
@@ -207,68 +192,16 @@ static const AGIGameDescription gameDescriptions[] = {
 	// AGI Demo for Kings Quest III and Space Quest I
 	GAME("agidemo", "Demo Kings Quest III and Space Quest I", "502e6bf96827b6c4d3e67c9cdccd1033", 0x2272, GID_AGIDEMO),
 
-	{
-		// Black Cauldron (PC 3.5" booter) 1.1J [AGI 1.12]
-		{
-			"bc",
-			"Booter 1.1J",
-			{
-				{ "bc-d1.img", BooterDisk1, "1d29a82b41c9c7491e2b68d16864bd11", 368640},
-				{ "bc-d2.img", BooterDisk2, "5568f7a52e787305656246f95e2aa375", 368640},
-				AD_LISTEND
-			},
-			Common::EN_ANY,
-			Common::kPlatformDOS,
-			ADGF_NO_FLAGS,
-			GAMEOPTIONS_DEFAULT
-		},
-		GID_BC,
-		GType_V1,
-		0,
-		0x1120
-	},
+	// Black Cauldron (PC 5.25" booter) 1.1J [AGI 1.12]
+	BOOTER_UNSTABLE("bc", "1.1J 5.25\" Booter", "0f69951170868481acebf831dd743b21", 0x1120, GID_BC),
 
-	{
-		// Black Cauldron (PC 3.5" booter) 1.1K [AGI 1.12]
-		{
-			"bc",
-			"Booter 1.1K",
-			{
-				{ "bc-d1.img", BooterDisk1, "98a51d3a372baa9df288b6c0f0232567", 368640},
-				{ "bc-d2.img", BooterDisk2, "5568f7a52e787305656246f95e2aa375", 368640},
-				AD_LISTEND
-			},
-			Common::EN_ANY,
-			Common::kPlatformDOS,
-			ADGF_NO_FLAGS,
-			GAMEOPTIONS_DEFAULT
-		},
-		GID_BC,
-		GType_V1,
-		0,
-		0x1120
-	},
+	// Black Cauldron (PC 5.25" booter) 1.1K [AGI 1.12]
+	// This also matches against Tandy version 01.00.00. The game resources are identical
+	// except for the copyright/version text on the title screen and three pics.
+	BOOTER_UNSTABLE("bc", "1.1K 3.5\" Booter", "297a586027a5eba60219b339ebe53443", 0x1120, GID_BC),
 
-	{
-		// Black Cauldron (PC 3.5" booter) 1.1M [AGI 1.12]
-		{
-			"bc",
-			"Booter 1.1M",
-			{
-				{ "bc-d1.img", BooterDisk1, "edc0e5befbe5e44bb109cdf9137ee12d", 368640},
-				{ "bc-d2.img", BooterDisk2, "5568f7a52e787305656246f95e2aa375", 368640},
-				AD_LISTEND
-			},
-			Common::EN_ANY,
-			Common::kPlatformDOS,
-			ADGF_NO_FLAGS,
-			GAMEOPTIONS_DEFAULT
-		},
-		GID_BC,
-		GType_V1,
-		0,
-		0x1120
-	},
+	// Black Cauldron (PC 5.25" booter) 1.1M [AGI 1.12]
+	BOOTER_UNSTABLE("bc", "1.1M 3.5\" Booter", "29bc82f2acfd0c7deeb7941cafd745d2", 0x1120, GID_BC),
 
 	// Black Cauldron (Amiga) 2.00 6/14/87
 	GAME_PO("bc", "2.00 1987-06-14", "7b01694af21213b4727bb94476f64eb5", 0x2440, GID_BC, Common::kPlatformAmiga, GAMEOPTIONS_AMIGA),
@@ -297,8 +230,8 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Unofficial port by Guillaume Major
 	GAME_PS("bc", "updated", "c4e1937f74e8100cd0152b904434d8b4", 357, 0x2440, GID_BC, Common::kPlatformCoCo3),
 
-	// Donald Duck's Playground (PC Booter) 1.0Q
-	BOOTER2("ddp", "Booter 1.0Q", "ddp.img", "f323f10abf8140ffb2668b09af2e7b87", 368640, 0x2001, GID_DDP),
+	// Donald Duck's Playground (PC 5.25" Booter) 1.0Q 06/09/1986
+	BOOTER("ddp", "1.0Q 1986-06-09 5.25\" Booter", "f0f35d60e3e3303480a6bd109d54248d", 0x2001, GID_DDP),
 
 	// Donald Duck's Playground (Amiga) 1.0C
 	GAME_PO("ddp", "1.0C 1987-04-27", "550971d196f65190a5c760d2479406ef", 0x2272, GID_DDP, Common::kPlatformAmiga, GAMEOPTIONS_AMIGA),
@@ -393,11 +326,11 @@ static const AGIGameDescription gameDescriptions[] = {
 	GAME_P("kq1", "2.0C 1987-03-26", "d4c4739d4ac63f7dbd29255425077d48", 0x2440, GID_KQ1, Common::kPlatformMacintosh),
 
 	// King's Quest 1 (IBM PCjr) 1.00 1502265 5/10/84
-	BOOTER1_U("kq1", "Early King\'s Quest releases are not currently supported.",
+	BOOTER_UNSUPPORTED("kq1", "Early King\'s Quest releases are not currently supported.",
 		"kq1.img", "127675735f9d2c148738c1e96ea9d2cf", 368640, 0x1120, GID_KQ1),
 
 	// King's Quest 1 (Tandy 1000) 01.01.00 5/24/84
-	BOOTER1_U("kq1", "Early King\'s Quest releases are not currently supported.",
+	BOOTER_UNSUPPORTED("kq1", "Early King\'s Quest releases are not currently supported.",
 		"kq1.img", "0a22131d0eaf66d955afecfdc83ef9d6", 368640, 0x1120, GID_KQ1),
 
 	// King's Quest 1 (PC 5.25"/3.5") 2.0F [AGI 2.917]
@@ -427,47 +360,14 @@ static const AGIGameDescription gameDescriptions[] = {
 	// King's Quest 2 (Mac) 2.0R 3/23/88
 	GAME_P("kq2", "2.0R 1988-03-23", "cbdb0083317c8e7cfb7ac35da4bc7fdc", 0x2440, GID_KQ2, Common::kPlatformMacintosh),
 
-	{
-		// King's Quest 2 (PC booter) 1.0W
-		{
-			"kq2",
-			"Early King\'s Quest releases are not currently supported.",
-			{
-				{ "kq2-d1.img", BooterDisk1, "68302776c012f5036ceb66e36920d353", 368640},
-				{ "kq2-d2.img", BooterDisk2, "5fa6d8222608aee556627c67cb5fb4d4", 368640},
-				AD_LISTEND
-			},
-			Common::EN_ANY,
-			Common::kPlatformDOS,
-			ADGF_UNSUPPORTED,
-			GAMEOPTIONS_DEFAULT
-		},
-		GID_KQ2,
-		GType_V1,
-		0,
-		0x1120
-	},
+	// King's Quest 2 (Tandy 5.25" booter) 01.00.00 [AGI 1.12]
+	BOOTER_UNSTABLE("kq2", "01.00.00 5.25\" Booter", "59119ff7a21965c0fb5f001f0d049765", 0x1120, GID_KQ2),
 
-	{
-		// King's Quest 2 (PC booter) 1.1H
-		{
-			"kq2",
-			"Early King\'s Quest releases are not currently supported.",
-			{
-				{ "kq2-d1.img", BooterDisk1, "c7216589aca72348bc063950cb80b266", 368640},
-				{ "kq2-d2.img", BooterDisk2, "9d29b6d41740945dce569cb59b2a6c5f", 368640},
-				AD_LISTEND
-			},
-			Common::EN_ANY,
-			Common::kPlatformDOS,
-			ADGF_UNSUPPORTED,
-			GAMEOPTIONS_DEFAULT
-		},
-		GID_KQ2,
-		GType_V1,
-		0,
-		0x1120
-	},
+	// King's Quest 2 (PC 5.25" booter) 1.0W [AGI 1.12]
+	BOOTER_UNSTABLE("kq2", "1.0W 5.25\" Booter", "0b4172d13b0fb7e5b83244a964e52ece", 0x1120, GID_KQ2),
+
+	// King's Quest 2 (PC 5.25" booter) 1.1H [AGI 1.12]
+	BOOTER_UNSTABLE("kq2", "1.1H 5.25\" Booter", "4924e12c90f883b81db426e11e091beb", 0x1120, GID_KQ2),
 
 	// King's Quest 2 (PC) 2.1 [AGI 2.411]; entry from DAGII, but missing from Sarien?
 	// XXX: any major differences from 2.411 to 2.440?
diff --git a/engines/agi/disk_image.h b/engines/agi/disk_image.h
new file mode 100644
index 00000000000..0d8790255f2
--- /dev/null
+++ b/engines/agi/disk_image.h
@@ -0,0 +1,59 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef AGI_DISK_IMAGE_H
+#define AGI_DISK_IMAGE_H
+
+namespace Agi {
+
+// PC disk image values and helpers for AgiLoader_v1 and AgiMetaEngineDetection
+
+// Disk image detection requires that image files have a known extension
+static const char * const pcDiskImageExtensions[] = { ".ima", ".img" };
+
+#define PC_DISK_SIZE                     (2 * 40 * 9 * 512)
+#define PC_DISK_POSITION(h,t,s,o)        (((((h + (t * 2)) * 9) + (s - 1)) * 512) + o)
+
+#define PC_INITDIR_POSITION_V1           PC_DISK_POSITION(0, 0, 9, 0)
+#define PC_INITDIR_ENTRY_SIZE_V1         8
+#define PC_INITDIR_SIZE_V1               (PC_INITDIR_ENTRY_SIZE_V1 * 10)
+
+#define PC_INITDIR_LOGDIR_INDEX_V1       3
+#define PC_INITDIR_PICDIR_INDEX_V1       4
+#define PC_INITDIR_VIEWDIR_INDEX_V1      5
+#define PC_INITDIR_SOUNDDIR_INDEX_V1     6
+#define PC_INITDIR_OBJECTS_INDEX_V1      1
+#define PC_INITDIR_WORDS_INDEX_V1        2
+
+#define PC_INITDIR_POSITION_V2001        PC_DISK_POSITION(0, 0, 2, 0)
+#define PC_INITDIR_ENTRY_SIZE_V2001      3
+
+#define PC_INITDIR_LOGDIR_INDEX_V2001    10
+#define PC_INITDIR_PICDIR_INDEX_V2001    11
+#define PC_INITDIR_VIEWDIR_INDEX_V2001   12
+#define PC_INITDIR_SOUNDDIR_INDEX_V2001  13
+#define PC_INITDIR_OBJECTS_INDEX_V2001   8
+#define PC_INITDIR_WORDS_INDEX_V2001     9
+#define PC_INITDIR_VOL0_INDEX_V2001      14
+
+} // End of namespace Agi
+
+#endif /* AGI_DISK_IMAGE_H */
diff --git a/engines/agi/loader_v1.cpp b/engines/agi/loader_v1.cpp
index 060cdde39c4..66b563ad1c8 100644
--- a/engines/agi/loader_v1.cpp
+++ b/engines/agi/loader_v1.cpp
@@ -20,181 +20,407 @@
  */
 
 #include "agi/agi.h"
+#include "agi/disk_image.h"
 #include "agi/words.h"
 
-#include "common/md5.h"
-
-#define IMAGE_SIZE 368640 // = 40 * 2 * 9 * 512 = tracks * sides * sectors * sector size
-#define SECTOR_OFFSET(s) ((s) * 512)
-
-#define DDP_BASE_SECTOR 0x1C2
-#define DDP_LOGDIR_SEC  SECTOR_OFFSET(171) + 5
-#define DDP_LOGDIR_MAX  43
-#define DDP_PICDIR_SEC  SECTOR_OFFSET(180) + 5
-#define DDP_PICDIR_MAX  30
-#define DDP_VIEWDIR_SEC SECTOR_OFFSET(189) + 5
-#define DDP_VIEWDIR_MAX 171
-#define DDP_SNDDIR_SEC  SECTOR_OFFSET(198) + 5
-#define DDP_SNDDIR_MAX  64
-
-#define BC_LOGDIR_SEC   SECTOR_OFFSET(90) + 5
-#define BC_LOGDIR_MAX   118
-#define BC_VIEWDIR_SEC  SECTOR_OFFSET(96) + 5
-#define BC_VIEWDIR_MAX  180
-#define BC_PICDIR_SEC   SECTOR_OFFSET(93) + 8
-#define BC_PICDIR_MAX   117
-#define BC_SNDDIR_SEC   SECTOR_OFFSET(99) + 5
-#define BC_SNDDIR_MAX   29
-#define BC_WORDS        SECTOR_OFFSET(0x26D) + 5
-#define BC_OBJECTS      SECTOR_OFFSET(0x1E6) + 3
+#include "common/config-manager.h"
+#include "common/fs.h"
 
 namespace Agi {
 
+// AgiLoader_v1 reads PC Booter floppy disk images.
+//
+// - King's Quest II           V1      2 disks
+// - The Black Cauldron        V1      2 disks
+// - Donald Duck's Playground  V2.001  1 disk
+//
+// All disks are 360k. The only supported image format is "raw". There are no
+// headers, footers, or metadata. Each image file must be exactly 368,640 bytes.
+//
+// The disks do not use a standard file system. Instead, file locations are
+// stored in an INITDIR structure at a fixed location. The interpreter version
+// determines the location and format of INITDIR.
+//
+// File detection is done a little differently. Instead of requiring hard-coded
+// names for the image files, we scan the game directory for the first usable
+// image of disk one, and then scan for disk two. The only naming requirement is
+// that the images have a known file extension.
+//
+// AgiMetaEngineDetection also scans for usable disk images. It finds the LOGDIR
+// file inside disk one, hashes LOGDIR, and matches against the detection table.
+
+typedef Common::HashMap<Common::Path, Common::FSNode, Common::Path::IgnoreCase_Hash, Common::Path::IgnoreCase_EqualTo> FileMap;
+
 void AgiLoader_v1::init() {
-	// Find filenames for the disk images
-	_filenameDisk0 = _vm->getDiskName(BooterDisk1);
-	_filenameDisk1 = _vm->getDiskName(BooterDisk2);
-}
+	// get all files in game directory
+	Common::FSList allFiles;
+	Common::FSNode dir(ConfMan.getPath("path"));
+	if (!dir.getChildren(allFiles, Common::FSNode::kListFilesOnly)) {
+		warning("AgiLoader_v1: invalid game path: %s", dir.getPath().toString(Common::Path::kNativeSeparator).c_str());
+		return;
+	}
 
-int AgiLoader_v1::loadDir_DDP(AgiDir *agid, int offset, int max) {
-	Common::File fp;
+	// build array of files with pc disk image extensions
+	Common::Array<Common::Path> imageFiles;
+	FileMap fileMap;
+	for (const Common::FSNode &file : allFiles) {
+		for (int i = 0; i < ARRAYSIZE(pcDiskImageExtensions); i++) {
+			if (file.getName().hasSuffixIgnoreCase(pcDiskImageExtensions[i])) {
+				Common::Path path = file.getPath();
+				imageFiles.push_back(path);
+				fileMap[path] = file;
+			}
+		}
+	}
 
-	if (!fp.open(_filenameDisk0))
-		return errBadFileOpen;
+	// sort potential image files by name
+	Common::sort(imageFiles.begin(), imageFiles.end());
+
+	// find disk one by reading potential images until successful
+	uint diskOneIndex;
+	for (diskOneIndex = 0; diskOneIndex < imageFiles.size(); diskOneIndex++) {
+		const Common::Path &imageFile = imageFiles[diskOneIndex];
+		Common::SeekableReadStream *stream = fileMap[imageFile].createReadStream();
+		if (stream == nullptr) {
+			warning("AgiLoader_v1: unable to open disk image: %s", imageFile.baseName().c_str());
+			continue;
+		}
 
-	fp.seek(offset, SEEK_SET);
-	for (int i = 0; i <= max; i++) {
-		int b0 = fp.readByte();
-		int b1 = fp.readByte();
-		int b2 = fp.readByte();
+		// image file size must be 360k
+		int32 fileSize = stream->size();
+		if (fileSize != PC_DISK_SIZE) {
+			delete stream;
+			continue;
+		}
 
-		if (b0 == 0xFF && b1 == 0xFF && b2 == 0xFF) {
-			agid[i].volume = 0xFF;
-			agid[i].offset = _EMPTY;
+		// read image as disk one
+		bool success;
+		int vol0Offset = 0;
+		if (_vm->getVersion() < 0x2001) {
+			success = readDiskOneV1(*stream);
 		} else {
-			int sec = (DDP_BASE_SECTOR + (((b0 & 0xF) << 8) | b1)) >> 1;
-			int off = ((b1 & 0x1) << 8) | b2;
-			agid[i].volume = 0;
-			agid[i].offset = SECTOR_OFFSET(sec) + off;
+			success = readDiskOneV2001(*stream, vol0Offset);
+		}
+		delete stream;
+
+		if (success) {
+			debugC(3, "AgiLoader_v1: disk one found: %s", imageFile.baseName().c_str());
+			_imageFiles.push_back(imageFile.baseName());
+			if (_vm->getVersion() < 0x2001) {
+				// the first disk contains volumes 0 and 1.
+				// there is no volume offset, resource
+				// directories use absolute disk positions.
+				_volumes.push_back(AgiDiskVolume(0, 0));
+				_volumes.push_back(AgiDiskVolume(0, 0));
+			} else {
+				// the first disk contains volume 0.
+				// resource offsets are relative to its location.
+				_volumes.push_back(AgiDiskVolume(0, vol0Offset));
+			}
+			break;
 		}
 	}
 
-	return errOK;
-}
+	// if disk one wasn't found, we're done
+	if (_imageFiles.empty()) {
+		warning("AgiLoader_v1: disk one not found");
+		return;
+	}
 
-int AgiLoader_v1::loadDir_BC(AgiDir *agid, int offset, int max) {
-	Common::File fp;
+	// two games have a second disk
+	if (!(_vm->getGameID() == GID_KQ2 || _vm->getGameID() == GID_BC)) {
+		return;
+	}
 
-	if (!fp.open(_filenameDisk0))
-		return errBadFileOpen;
+	// find disk two by locating the next image file that begins with a resource
+	// header with a volume number set to two. since the potential image file list
+	// is sorted, begin with the file after disk one and try until successful.
+	for (uint i = 1; i < imageFiles.size(); i++) {
+		uint diskTwoIndex = (diskOneIndex + i) % imageFiles.size();
+		Common::Path &imageFile = imageFiles[diskTwoIndex];
+
+		Common::SeekableReadStream *stream = fileMap[imageFile].createReadStream();
+		if (stream == nullptr) {
+			warning("AgiLoader_v1: unable to open disk image: %s", imageFile.baseName().c_str());
+			continue;
+		}
 
-	fp.seek(offset, SEEK_SET);
-	for (int i = 0; i <= max; i++) {
-		int b0 = fp.readByte();
-		int b1 = fp.readByte();
-		int b2 = fp.readByte();
+		// image file size must be 360k
+		int64 fileSize = stream->size();
+		if (fileSize != PC_DISK_SIZE) {
+			delete stream;
+			continue;
+		}
 
-		if (b0 == 0xFF && b1 == 0xFF && b2 == 0xFF) {
-			agid[i].volume = 0xFF;
-			agid[i].offset = _EMPTY;
-		} else {
-			int sec = (b0 & 0x3F) * 18 + ((b1 >> 1) & 0x1) * 9 + ((b1 >> 2) & 0x1F) - 1;
-			int off = ((b1 & 0x1) << 8) | b2;
-			int vol = (b0 & 0xC0) >> 6;
-			agid[i].volume = 0;
-			agid[i].offset = (vol == 2) * IMAGE_SIZE + SECTOR_OFFSET(sec) + off;
+		// read resource header
+		uint16 magic = stream->readUint16BE();
+		byte volume = stream->readByte();
+		uint16 size = stream->readUint16LE();
+		delete stream;
+
+		if (magic == 0x1234 && volume == 2 && 5 + size <= PC_DISK_SIZE) {
+			debugC(3, "AgiLoader_v1: disk two found: %s", imageFile.baseName().c_str());
+			_imageFiles.push_back(imageFile.baseName());
+			_volumes.push_back(AgiDiskVolume(_imageFiles.size() - 1, 0));
+			break;
 		}
 	}
 
-	return errOK;
+	if (imageFiles.size() < 2) {
+		warning("AviLoader_v1: disk two not found");
+	}
+}
+
+bool AgiLoader_v1::readDiskOneV1(Common::SeekableReadStream &stream) {
+	// INITDIR V1 is located at the 9th sector after the 5-byte resource header.
+	// Each entry is 10 bytes and there are always 8.
+	stream.seek(PC_INITDIR_POSITION_V1);
+	uint16 magic = stream.readUint16BE();
+	byte volume = stream.readByte();
+	uint16 size = stream.readUint16LE();
+	if (!(magic == 0x1234 && volume == 1 && size == PC_INITDIR_SIZE_V1)) {
+		return false;
+	}
+
+	bool success = true;
+	success &= readInitDirV1(stream, PC_INITDIR_LOGDIR_INDEX_V1,   _logDir);
+	success &= readInitDirV1(stream, PC_INITDIR_PICDIR_INDEX_V1,   _picDir);
+	success &= readInitDirV1(stream, PC_INITDIR_VIEWDIR_INDEX_V1,  _viewDir);
+	success &= readInitDirV1(stream, PC_INITDIR_SOUNDDIR_INDEX_V1, _soundDir);
+	success &= readInitDirV1(stream, PC_INITDIR_OBJECTS_INDEX_V1,  _objects);
+	success &= readInitDirV1(stream, PC_INITDIR_WORDS_INDEX_V1,    _words);
+	return success;
+}
+
+bool AgiLoader_v1::readInitDirV1(Common::SeekableReadStream &stream, byte index, AgiDir &agid) {
+	// read INITDIR entry
+	stream.seek(PC_INITDIR_POSITION_V1 + 5 + (index * PC_INITDIR_ENTRY_SIZE_V1));
+	byte volume = stream.readByte();
+	byte head = stream.readByte();
+	uint16 track = stream.readUint16LE();
+	uint16 sector = stream.readUint16LE();
+	uint16 offset = stream.readUint16LE();
+	if (stream.eos() || stream.err()) {
+		return false;
+	}
+
+	// resource must be on disk one
+	if (!(volume == 0 || volume == 1)) {
+		return false;
+	}
+
+	// resource begins with a 5-byte header
+	uint32 position = PC_DISK_POSITION(head, track, sector, offset);
+	stream.seek(position);
+	uint16 magic = stream.readUint16BE();
+	volume = stream.readByte();
+	uint16 size = stream.readUint16LE();
+	if (!(magic == 0x1234 && (volume == 0 || volume == 1))) {
+		return false;
+	}
+	if (!(stream.pos() + size <= stream.size())) {
+		return false;
+	}
+
+	// resource found
+	agid.volume = volume;
+	agid.offset = stream.pos();
+	agid.len = size;
+	agid.clen = size;
+	return true;
+}
+
+bool AgiLoader_v1::readDiskOneV2001(Common::SeekableReadStream &stream, int &vol0Offset) {
+	// INITDIR V2001 is located at the 2nd sector with no resource header.
+	// Each entry is 3 bytes. The number of entries is technically variable,
+	// because the list ends in an entry for each volume followed by FF FF FF.
+	// But since there was only one V2001 game (Donald Duck's Playground),
+	// and it only has one disk, there is really only ever one volume.
+
+	bool success = true;
+	success &= readInitDirV2001(stream, PC_INITDIR_LOGDIR_INDEX_V2001,   _logDir);
+	success &= readInitDirV2001(stream, PC_INITDIR_PICDIR_INDEX_V2001,   _picDir);
+	success &= readInitDirV2001(stream, PC_INITDIR_VIEWDIR_INDEX_V2001,  _viewDir);
+	success &= readInitDirV2001(stream, PC_INITDIR_SOUNDDIR_INDEX_V2001, _soundDir);
+	success &= readInitDirV2001(stream, PC_INITDIR_OBJECTS_INDEX_V2001,  _objects);
+	success &= readInitDirV2001(stream, PC_INITDIR_WORDS_INDEX_V2001,    _words);
+
+	// V2001 directories (LOGDIR, etc) contain resource offsets relative to
+	// the start of their volume on disk. All volumes start at the beginning
+	// of the disk, except for volume 0.
+	AgiDir vol0;
+	success &= readInitDirV2001(stream, PC_INITDIR_VOL0_INDEX_V2001, vol0);
+	vol0Offset = vol0.offset - 5;
+
+	return success;
+}
+
+bool AgiLoader_v1::readInitDirV2001(Common::SeekableReadStream &stream, byte index, AgiDir &agid) {
+	// read INITDIR entry
+	stream.seek(PC_INITDIR_POSITION_V2001 + (index * PC_INITDIR_ENTRY_SIZE_V2001));
+	byte b0 = stream.readByte();
+	byte b1 = stream.readByte();
+
+	// volume      4 bits
+	// position   12 bits  (in half-sectors)
+	byte volume = b0 >> 4;
+	uint32 position = (((b0 & 0x0f) << 8) + b1) * 256;
+
+	// resource must be on disk one (because the only V2001 game is one disk)
+	if (!(volume == 0 || volume == 1)) {
+		return false;
+	}
+
+	// resource begins with a 5-byte header
+	stream.seek(position);
+	uint16 magic = stream.readUint16BE();
+	volume = stream.readByte();
+	uint16 size = stream.readUint16LE();
+	if (!(magic == 0x1234 && (volume == 0 || volume == 1))) {
+		return false;
+	}
+	if (!(stream.pos() + size <= stream.size())) {
+		return false;
+	}
+
+	// resource found
+	agid.volume = volume;
+	agid.offset = stream.pos();
+	agid.len = size;
+	agid.clen = size;
+	return true;
 }
 
 int AgiLoader_v1::loadDirs() {
-	int ec = errOK;
-
-	switch (_vm->getGameID()) {
-	case GID_DDP:
-		ec = loadDir_DDP(_vm->_game.dirLogic, DDP_LOGDIR_SEC, DDP_LOGDIR_MAX);
-		if (ec == errOK)
-			ec = loadDir_DDP(_vm->_game.dirPic, DDP_PICDIR_SEC, DDP_PICDIR_MAX);
-		if (ec == errOK)
-			ec = loadDir_DDP(_vm->_game.dirView, DDP_VIEWDIR_SEC, DDP_VIEWDIR_MAX);
-		if (ec == errOK)
-			ec = loadDir_DDP(_vm->_game.dirSound, DDP_SNDDIR_SEC, DDP_SNDDIR_MAX);
-		break;
-
-	case GID_BC:
-		ec = loadDir_BC(_vm->_game.dirLogic, BC_LOGDIR_SEC, BC_LOGDIR_MAX);
-		if (ec == errOK)
-			ec = loadDir_BC(_vm->_game.dirPic, BC_PICDIR_SEC, BC_PICDIR_MAX);
-		if (ec == errOK)
-			ec = loadDir_BC(_vm->_game.dirView, BC_VIEWDIR_SEC, BC_VIEWDIR_MAX);
-		if (ec == errOK)
-			ec = loadDir_BC(_vm->_game.dirSound, BC_SNDDIR_SEC, BC_SNDDIR_MAX);
-		break;
-
-	default:
-		ec = errUnk;
-		break;
-	}
-
-	return ec;
+	// if init didn't find disks then fail
+	if (_imageFiles.empty()) {
+		return errFilesNotFound;
+	}
+
+	// open disk one
+	Common::File disk;
+	if (!disk.open(Common::Path(_imageFiles[0]))) {
+		return errBadFileOpen;
+	}
+
+	// load each directory
+	bool success = true;
+	success &= loadDir(_vm->_game.dirLogic, disk, _logDir.offset,   _logDir.len);
+	success &= loadDir(_vm->_game.dirPic,   disk, _picDir.offset,   _picDir.len);
+	success &= loadDir(_vm->_game.dirView,  disk, _viewDir.offset,  _viewDir.len);
+	success &= loadDir(_vm->_game.dirSound, disk, _soundDir.offset, _soundDir.len);
+	return success ? errOK : errBadResource;
 }
 
-uint8 *AgiLoader_v1::loadVolumeResource(AgiDir *agid) {
-	Common::File fp;
-	int offset = agid->offset;
+bool AgiLoader_v1::loadDir(AgiDir *dir, Common::File &disk, uint32 dirOffset, uint32 dirLength) {
+	// seek to directory on disk
+	disk.seek(dirOffset);
 
-	if (offset == _EMPTY)
-		return nullptr;
+	// re-validate length from initdir
+	if (!(disk.pos() + dirLength <= disk.size())) {
+		return false;
+	}
 
-	if (offset > IMAGE_SIZE) {
-		if (!fp.open(_filenameDisk1)) {
-			warning("AgiLoader_v1::loadVolRes: could not open %s", _filenameDisk1.toString().c_str());
-			return nullptr;
+	// read directory entries
+	uint16 dirEntryCount = MIN<uint32>(dirLength / 3, MAX_DIRECTORY_ENTRIES);
+	for (uint16 i = 0; i < dirEntryCount; i++) {
+		byte b0 = disk.readByte();
+		byte b1 = disk.readByte();
+		byte b2 = disk.readByte();
+		if (b0 == 0xff && b1 == 0xff && b2 == 0xff) {
+			continue;
 		}
-		offset -= IMAGE_SIZE;
-	} else {
-		if (!fp.open(_filenameDisk0)) {
-			warning("AgiLoader_v1::loadVolRes: could not open %s", _filenameDisk0.toString().c_str());
-			return nullptr;
+
+		if (_vm->getVersion() < 0x2001) {
+			// volume   2 bits
+			// track    6 bits
+			// sector   6 bits (one based)
+			// head     1 bit
+			// offset   9 bits
+			dir[i].volume = b0 >> 6;
+			byte track = b0 & 0x3f;
+			byte sector = b1 >> 2;
+			byte head = (b1 >> 1) & 1;
+			uint16 offset = ((b1 & 1) << 8) | b2;
+			dir[i].offset = PC_DISK_POSITION(head, track, sector, offset);
+		} else {
+			// volume   4 bits
+			// sector  11 bits (zero based)
+			// offset   9 bits
+			// position is relative to the start of volume
+			dir[i].volume = b0 >> 4;
+			uint16 sector = ((b0 & 0x0f) << 7) | (b1 >> 1);
+			uint16 offset = ((b1 & 0x01) << 8) | b2;
+			dir[i].offset = PC_DISK_POSITION(0, 0, sector + 1, offset);
 		}
 	}
 
-	fp.seek(offset, SEEK_SET);
+	return true;
+}
 
-	uint16 signature = fp.readUint16BE();
-	if (signature != 0x1234) {
-		warning("AgiLoader_v1::loadVolRes: bad signature %04x", signature);
+uint8 *AgiLoader_v1::loadVolumeResource(AgiDir *agid) {
+	if (agid->volume >= _volumes.size()) {
+		warning("AgiLoader_v1: invalid volume: %d", agid->volume);
 		return nullptr;
 	}
 
-	fp.readByte(); // volume number
-	agid->len = fp.readUint16LE();
+	Common::File disk;
+	int diskIndex = _volumes[agid->volume].disk;
+	if (!disk.open(Common::Path(_imageFiles[diskIndex]))) {
+		warning("AgiLoader_v1: unable to open disk image: %s", _imageFiles[diskIndex].c_str());
+		return nullptr;
+	}
+
+	// seek to resource and validate header
+	int offset = _volumes[agid->volume].offset + agid->offset;
+	disk.seek(offset);
+	uint16 magic = disk.readUint16BE();
+	if (magic != 0x1234) {
+		warning("AgiLoader_v1: no resource at volume %d offset %d", agid->volume, agid->offset);
+		return nullptr;
+	}
+	disk.skip(1); // volume
+	agid->len = disk.readUint16LE();
+
 	uint8 *data = (uint8 *)calloc(1, agid->len + 32); // why the extra 32 bytes?
-	fp.read(data, agid->len);
+	if (disk.read(data, agid->len) != agid->len) {
+		warning("AgiLoader_v1: error reading %d bytes at volume %d offset %d", agid->len, agid->volume, agid->offset);
+		free(data);
+		return nullptr;
+	}
 
 	return data;
 }
 
 int AgiLoader_v1::loadObjects() {
-	if (_vm->getGameID() == GID_BC) {
-		Common::File f;
-		f.open(_filenameDisk0);
-		f.seek(BC_OBJECTS, SEEK_SET);
-		return _vm->loadObjects(f);
+	// DDP has an empty-ish objects resource but doesn't use it
+	if (_vm->getGameID() == GID_DDP) {
+		return errOK;
 	}
-	return errOK;
+
+	Common::File disk;
+	if (!disk.open(Common::Path(_imageFiles[0]))) {
+		return errBadFileOpen;
+	}
+
+	disk.seek(_objects.offset);
+	return _vm->loadObjects(disk, _objects.len);
 }
 
 int AgiLoader_v1::loadWords() {
-	if (_vm->getGameID() == GID_BC) {
-		Common::File f;
-		f.open(_filenameDisk0);
-		f.seek(BC_WORDS, SEEK_SET);
-		return _vm->_words->loadDictionary_v1(f);
+	// DDP has an empty-ish words resource but doesn't use it
+	if (_vm->getGameID() == GID_DDP) {
+		return errOK;
 	}
-	return errOK;
-}
 
+	Common::File disk;
+	if (!disk.open(Common::Path(_imageFiles[0]))) {
+		return errBadFileOpen;
+	}
+
+	// TODO: pass length and validate in parser
+	disk.seek(_words.offset);
+	return _vm->_words->loadDictionary_v1(disk);
 }
+
+} // End of namespace Agi
diff --git a/engines/agi/objects.cpp b/engines/agi/objects.cpp
index 22c3d738988..5b1adb16d25 100644
--- a/engines/agi/objects.cpp
+++ b/engines/agi/objects.cpp
@@ -86,17 +86,7 @@ int AgiEngine::loadObjects(const char *fname) {
 	if (!fp.open(fname))
 		return errBadFileOpen;
 
-	return readObjects(fp, fp.size());
-}
-
-/**
- * Loads an object file that is in the common VOL resource format. Expects
- * the file pointer to point to the last field in header, ie. file length.
- * This is used at least by the V1 booter games.
- */
-int AgiEngine::loadObjects(Common::File &fp) {
-	int flen = fp.readUint16LE();
-	return readObjects(fp, flen);
+	return loadObjects(fp, fp.size());
 }
 
 /**
@@ -105,7 +95,7 @@ int AgiEngine::loadObjects(Common::File &fp) {
  * @param  fp    File pointer
  * @param  flen  File length
  */
-int AgiEngine::readObjects(Common::File &fp, int flen) {
+int AgiEngine::loadObjects(Common::File &fp, int flen) {
 	uint8 *mem;
 
 	if ((mem = (uint8 *)calloc(1, flen + 32)) == nullptr) {


Commit: fe12daf66554fbdeb6e9c173f88412fd54591984
    https://github.com/scummvm/scummvm/commit/fe12daf66554fbdeb6e9c173f88412fd54591984
Author: sluicebox (22204938+sluicebox at users.noreply.github.com)
Date: 2024-08-19T07:39:54+03:00

Commit Message:
AGI: Add Apple II loader and detection

Changed paths:
  A engines/agi/loader_a2.cpp
    engines/agi/agi.cpp
    engines/agi/agi.h
    engines/agi/detection.cpp
    engines/agi/detection_tables.h
    engines/agi/disk_image.h
    engines/agi/metaengine.cpp
    engines/agi/module.mk
    engines/agi/words.cpp
    engines/agi/words.h


diff --git a/engines/agi/agi.cpp b/engines/agi/agi.cpp
index 25d55134744..d5d1cf046c4 100644
--- a/engines/agi/agi.cpp
+++ b/engines/agi/agi.cpp
@@ -529,7 +529,9 @@ void AgiEngine::initialize() {
 
 	_text->charAttrib_Set(15, 0);
 
-	if (getVersion() <= 0x2001) {
+	if (getPlatform() == Common::kPlatformApple2) {
+		_loader = new AgiLoader_A2(this);
+	} else if (getVersion() <= 0x2001) {
 		_loader = new AgiLoader_v1(this);
 	} else if (getVersion() <= 0x2999) {
 		_loader = new AgiLoader_v2(this);
diff --git a/engines/agi/agi.h b/engines/agi/agi.h
index f15ed9b2182..388ff18134b 100644
--- a/engines/agi/agi.h
+++ b/engines/agi/agi.h
@@ -101,7 +101,8 @@ enum AgiGameType {
 	GType_PreAGI = 0,
 	GType_V1 = 1,
 	GType_V2 = 2,
-	GType_V3 = 3
+	GType_V3 = 3,
+	GType_A2 = 4
 };
 
 enum AgiGameFeatures {
@@ -537,6 +538,15 @@ struct AgiDiskVolume {
 	AgiDiskVolume(uint32 d, uint32 o) : disk(d), offset(o) {}
 };
 
+/**
+ * Apple II version of the format for LOGDIR, VIEWDIR, etc.
+ * See AgiLoader_A2::loadDir for more details.
+ */
+enum A2DirVersion {
+	A2DirVersionOld,  // 4 bits for volume, 8 for track
+	A2DirVersionNew,  // 5 bits for volume, 7 for track
+};
+
 class AgiLoader {
 public:
 	AgiLoader(AgiEngine *vm) : _vm(vm) {}
@@ -573,6 +583,35 @@ protected:
 	AgiEngine *_vm;
 };
 
+class AgiLoader_A2 : public AgiLoader {
+public:
+	AgiLoader_A2(AgiEngine *vm) : AgiLoader(vm) {}
+
+	void init() override;
+	int loadDirs() override;
+	uint8 *loadVolumeResource(AgiDir *agid) override;
+	int loadObjects() override;
+	int loadWords() override;
+
+private:
+	Common::Array<Common::String> _imageFiles;
+	Common::Array<AgiDiskVolume> _volumes;
+	AgiDir _logDir;
+	AgiDir _picDir;
+	AgiDir _viewDir;
+	AgiDir _soundDir;
+	AgiDir _objects;
+	AgiDir _words;
+
+	int readDiskOne(Common::SeekableReadStream &stream, Common::Array<uint32> &volumeMap);
+	static bool readInitDir(Common::SeekableReadStream &stream, byte index, AgiDir &agid);
+	static bool readDir(Common::SeekableReadStream &stream, int position, AgiDir &agid);
+	static bool readVolumeMap(Common::SeekableReadStream &stream, uint32 position, uint32 bufferLength, Common::Array<uint32> &volumeMap);
+
+	A2DirVersion detectDirVersion(Common::SeekableReadStream &stream);
+	bool loadDir(AgiDir *dir, Common::File &disk, uint32 dirOffset, uint32 dirLength, A2DirVersion dirVersion);
+};
+
 class AgiLoader_v1 : public AgiLoader {
 public:
 	AgiLoader_v1(AgiEngine *vm) : AgiLoader(vm) {}
diff --git a/engines/agi/detection.cpp b/engines/agi/detection.cpp
index 34286e0effe..bc337ce3806 100644
--- a/engines/agi/detection.cpp
+++ b/engines/agi/detection.cpp
@@ -124,6 +124,9 @@ private:
 	static Common::String getLogDirHashFromPcDiskImageV1(Common::SeekableReadStream &stream);
 	static Common::String getLogDirHashFromPcDiskImageV2001(Common::SeekableReadStream &stream);
 
+	static ADDetectedGame detectA2DiskImageGame(const FileMap &allFiles, uint32 skipADFlags);
+	static Common::String getLogDirHashFromA2DiskImage(Common::SeekableReadStream &stream);
+
 	static Common::String getLogDirHashFromDiskImage(Common::SeekableReadStream &stream, uint32 position);
 };
 
@@ -320,6 +323,16 @@ ADDetectedGames AgiMetaEngineDetection::detectGame(
 		}
 	}
 
+	// Detect games within Apple II disk images. This detection will find one game at most.
+	if (matched.empty() &&
+		(language == Common::UNK_LANG || language == Common::EN_ANY) &&
+		(platform == Common::kPlatformUnknown || platform == Common::kPlatformApple2)) {
+		ADDetectedGame game = detectA2DiskImageGame(allFiles, skipADFlags);
+		if (game.desc != nullptr) {
+			matched.push_back(game);
+		}
+	}
+
 	return matched;
 }
 
@@ -458,6 +471,109 @@ Common::String AgiMetaEngineDetection::getLogDirHashFromPcDiskImageV2001(Common:
 	return getLogDirHashFromDiskImage(stream, position);
 }
 
+/**
+ * Detects an Apple II game by searching for 140k floppy images, reading LOGDIR,
+ * hashing LOGDIR, and comparing to Apple II entries in the detection table.
+ * See AgiLoader_A2 in loader_a2.cpp for more details.
+ */
+ADDetectedGame AgiMetaEngineDetection::detectA2DiskImageGame(const FileMap &allFiles, uint32 skipADFlags) {
+	// build array of files with a2 disk image extensions
+	Common::Array<Common::Path> imageFiles;
+	getPotentialDiskImages(allFiles, a2DiskImageExtensions, ARRAYSIZE(a2DiskImageExtensions), imageFiles);
+
+	// find disk one by reading potential images until a match is found
+	for (const Common::Path &imageFile : imageFiles) {
+		Common::SeekableReadStream *stream = allFiles[imageFile].createReadStream();
+		if (stream == nullptr) {
+			warning("unable to open disk image: %s", imageFile.baseName().c_str());
+			continue;
+		}
+
+		// image file size must be 140k.
+		// this simple check will be removed when more image formats are supported.
+		int64 fileSize = stream->size();
+		if (fileSize != A2_DISK_SIZE) {
+			delete stream;
+			continue;
+		}
+
+		// attempt to locate and hash logdir by reading initdir,
+		// and also known logdir locations for games without initdir.
+		Common::String logdirHashInitdir = getLogDirHashFromA2DiskImage(*stream);
+		Common::String logdirHashBc = getLogDirHashFromDiskImage(*stream, A2_BC_LOGDIR_POSITION);
+		Common::String logdirHashKq2 = getLogDirHashFromDiskImage(*stream, A2_KQ2_LOGDIR_POSITION);
+		delete stream;
+
+		if (!logdirHashInitdir.empty()) {
+			debug(3, "disk image logdir hash: %s, %s", logdirHashInitdir.c_str(), imageFile.baseName().c_str());
+		}
+		if (!logdirHashBc.empty()) {
+			debug(3, "disk image logdir hash: %s, %s", logdirHashBc.c_str(), imageFile.baseName().c_str());
+		}
+		if (!logdirHashKq2.empty()) {
+			debug(3, "disk image logdir hash: %s, %s", logdirHashKq2.c_str(), imageFile.baseName().c_str());
+		}
+
+		// if logdir hash found then compare against hashes of Apple II entries
+		if (!logdirHashInitdir.empty() || !logdirHashBc.empty() || !logdirHashKq2.empty()) {
+			for (const AGIGameDescription *game = gameDescriptions; game->desc.gameId != nullptr; game++) {
+				if (game->desc.platform == Common::kPlatformApple2 && !(game->desc.flags & skipADFlags)) {
+					const ADGameFileDescription *file;
+					for (file = game->desc.filesDescriptions; file->fileName != nullptr; file++) {
+						// select the logdir hash to use
+						Common::String &logdirHash = (game->gameID == GID_BC)  ? logdirHashBc :
+						                             (game->gameID == GID_KQ2) ? logdirHashKq2 :
+						                             logdirHashInitdir;
+						if (file->md5 != nullptr && !logdirHash.empty() && file->md5 == logdirHash) {
+							debug(3, "disk image match: %s, %s, %s", game->desc.gameId, game->desc.extra, imageFile.baseName().c_str());
+
+							// logdir hash match found
+							ADDetectedGame detectedGame(&game->desc);
+							FileProperties fileProps;
+							fileProps.md5 = file->md5;
+							fileProps.md5prop = kMD5Archive;
+							fileProps.size = fileSize;
+							detectedGame.matchedFiles[imageFile] = fileProps;
+							return detectedGame;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return ADDetectedGame();
+}
+
+Common::String AgiMetaEngineDetection::getLogDirHashFromA2DiskImage(Common::SeekableReadStream &stream) {
+	// read magic number from initdir resource header
+	stream.seek(A2_INITDIR_POSITION);
+	uint16 magic = stream.readUint16BE();
+	if (magic != 0x1234) {
+		return "";
+	}
+
+	// seek to initdir entry for logdir (and skip remaining 3 bytes of header)
+	// also skip the one-byte volume number at the start of initdir
+	stream.skip(3 + 1 + (A2_INITDIR_LOGDIR_INDEX * A2_INITDIR_ENTRY_SIZE));
+
+	// read logdir location
+	byte volume = stream.readByte();
+	byte track = stream.readByte();
+	byte sector = stream.readByte();
+	byte offset = stream.readByte();
+
+	// logdir volume must be one
+	if (volume != 1) {
+		return "";
+	}
+
+	// read logdir
+	uint32 logDirPosition = A2_DISK_POSITION(track, sector, offset);
+	return getLogDirHashFromDiskImage(stream, logDirPosition);
+}
+
+// this works for both pc and a2 disk images
 Common::String AgiMetaEngineDetection::getLogDirHashFromDiskImage(Common::SeekableReadStream &stream, uint32 position) {
 	stream.seek(position);
 	uint16 magic = stream.readUint16BE();
diff --git a/engines/agi/detection_tables.h b/engines/agi/detection_tables.h
index 69e4696331f..e9048b636c8 100644
--- a/engines/agi/detection_tables.h
+++ b/engines/agi/detection_tables.h
@@ -110,6 +110,7 @@ namespace Agi {
 		ver \
 	}
 
+#define A2(id,extra,md5,ver,gid) GAME_LVFPN_FLAGS(id,extra,"*",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformApple2,GType_A2,GAMEOPTIONS_DEFAULT,ADGF_UNSTABLE)
 #define BOOTER(id,extra,md5,ver,gid) GAME_LVFPN(id,extra,"*",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT)
 #define BOOTER_UNSTABLE(id,extra,md5,ver,gid) GAME_LVFPN_FLAGS(id,extra,"*",md5,AD_NO_SIZE,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT,ADGF_UNSTABLE)
 #define BOOTER_UNSUPPORTED(id,msg,fname,md5,size,ver,gid) GAME_LVFPN_FLAGS(id,msg,fname,md5,size,Common::EN_ANY,ver,0,gid,Common::kPlatformDOS,GType_V1,GAMEOPTIONS_DEFAULT,ADGF_UNSUPPORTED)
@@ -171,6 +172,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// AGI Demo 1 (PC) 05/87 [AGI 2.425]
 	GAME("agidemo", "Demo 1 1987-05-20", "9c4a5b09cc3564bc48b4766e679ea332", 0x2440, GID_AGIDEMO),
 
+	// AGI Demo (Apple II) 1987 (A2 Int. 0.048)
+	A2("agidemo", "Demo 1987", "1abef8018f42dc21a59e03f3d227024f", 0x2917, GID_AGIDEMO),
+
 	// AGI Demo 2 (IIgs) 1.0C (Censored)
 	GAME_P("agidemo", "Demo 2 1987-11-24 1.0C", "580ffdc569ff158f56fb92761604f70e", 0x2917, GID_AGIDEMO, Common::kPlatformApple2GS),
 
@@ -206,6 +210,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Black Cauldron (Amiga) 2.00 6/14/87
 	GAME_PO("bc", "2.00 1987-06-14", "7b01694af21213b4727bb94476f64eb5", 0x2440, GID_BC, Common::kPlatformAmiga, GAMEOPTIONS_AMIGA),
 
+	// Black Cauldron (Apple II) 1.1H [AGI 1.20]
+	A2("bc", "1.1H", "80c7d0af6c89bf28ae44d2aa5ca83dc1", 0x1120, GID_BC),
+
 	// Black Cauldron (Apple IIgs) 1.0O 2/24/89 (CE)
 	GAME3_PO("bc", "1.0O 1989-02-24 (CE)", "bcdir", "dc09d30b147242692f4f85b9811962db", 0x3149, 0, GID_BC, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -262,6 +269,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Gold Rush! (Amiga) 1.01 1/13/89 aka 2.05 3/9/89  # 2.316
 	GAME3_PSO("goldrush", "1.01 1989-01-13 aka 2.05 1989-03-09", "dirs", "a1d4de3e75c2688c1e2ca2634ffc3bd8", 2399, 0x3149, 0, GID_GOLDRUSH, Common::kPlatformAmiga, GAMEOPTIONS_AMIGA),
 
+	// Gold Rush! (Apple II) 1.0M 11/16/1989 (A2 Int. 0.144)
+	A2("goldrush", "1.0M 1989-11-16", "a0bf4d801eaf1af4728ea85d6dedf8a6", 0x3149, GID_GOLDRUSH),
+
 	// Gold Rush! (Apple IIgs) 1.0M 2/28/89 (CE) aka 2.01 12/22/88
 	GAME3_PO("goldrush", "1.0M 1989-02-28 (CE) aka 2.01 1988-12-22", "grdir", "3f7b9ce62631434389f85371b11921d6", 0x3149, GF_2GSOLDSOUND, GID_GOLDRUSH, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -351,6 +361,12 @@ static const AGIGameDescription gameDescriptions[] = {
 	// King's Quest 1 (Russian)
 	GAME_LPS("kq1", "", "973f5830ed5e1c919354dfbcd5036c53", 315, Common::RU_RUS, 0x2440, GID_KQ1, Common::kPlatformDOS),
 
+	// King's Quest 2 (Apple II) 1.0G [AGI 1.08]
+	A2("kq2", "1.0G", "8e8d562e50233c939112c89bba55d249", 0x1120, GID_KQ2),
+
+	// King's Quest 2 (Apple II) 1.0H [AGI 1.10]
+	A2("kq2", "1.0H", "3a25cb0a87316f449d559ceb93d349e9", 0x1120, GID_KQ2),
+
 	// King's Quest 2 (IIgs) 2.0A 6/16/88 (CE)
 	GAME_PO("kq2", "2.0A 1988-06-16 (CE)", "5203c8b95250a2ecfee93ddb99414753", 0x2917, GID_KQ2, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -408,6 +424,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// King's Quest 3 (Mac) 2.14 3/15/88
 	GAME_P("kq3", "2.14 1988-03-15", "7639c0da5ce94848227d409351fabda2", 0x2440, GID_KQ3, Common::kPlatformMacintosh),
 
+	// King's Quest 3 (Apple II) 2.0A 3/13/88 (A2 Int. 0.101)
+	A2("kq3", "2.0A 1988-03-13", "6d3982705071a59b65fe0953333074f0", 0x2440, GID_KQ3),
+
 	// King's Quest 3 (IIgs) 2.0A 8/28/88 (CE)
 	GAME_PO("kq3", "2.0A 1988-08-28 (CE)", "ac30b7ca5a089b5e642fbcdcbe872c12", 0x2917, GID_KQ3, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -460,6 +479,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// King's Quest 4 (PC 3.5") 2.3 9/27/88 [AGI 3.002.086]
 	GAME3("kq4", "2.3 1988-09-27 3.5\"", "kq4dir", "82a0d39af891042e99ac1bd6e0b29046", 0x3086, GID_KQ4),
 
+	// King's Quest 4 (Apple II) 1.0W 3/16/89 (A2 Int. 0.144)
+	A2("kq4", "1.0W 1989-03-16", "e5c6f8f0b5db09b00477012fc57fe775", 0x3086, GID_KQ4),
+
 	// King's Quest 4 (IIgs) 1.0K 11/22/88 (CE)
 	GAME3_PO("kq4", "1.0K 1988-11-22", "kq4dir", "8536859331159f15012e35dc82cb154e", 0x3086, 0, GID_KQ4, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -496,6 +518,12 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Leisure Suit Larry 1 (Amiga) 1.05 6/26/87    # x.yyy
 	GAME_PO("lsl1", "1.05 1987-06-26", "3f5d26d8834ca49c147fb60936869d56", 0x2440, GID_LSL1, Common::kPlatformAmiga, GAMEOPTIONS_AMIGA),
 
+	// Leisure Suit Larry 1 (Apple II) 1.0L (A2 Int. 0.080)
+	A2("lsl1", "1.0L", "d1d4204485c2735f343ab54ff609631f", 0x2440, GID_LSL1),
+
+	// Leisure Suit Larry 1 (Apple II) 1.0M (A2 Int. 0.080)
+	A2("lsl1", "1.0M", "cf5452e0e36d0c0bd86dea9ad630e001", 0x2440, GID_LSL1),
+
 	// Leisure Suit Larry 1 (IIgs) 1.0E
 	GAME_PO("lsl1", "1.0E 1987", "5f9e1dd68d626c6d303131c119582ad4", 0x2440, GID_LSL1, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -509,6 +537,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Manhunter NY (ST) 1.03 10/20/88
 	GAME3_P("mh1", "1.03 1988-10-20", "mhdir", "f2d58056ad802452d60776ee920a52a6", 0x3149, 0, GID_MH1, Common::kPlatformAtariST),
 
+	// Manhunter NY (Apple II) 1.0I 4/19/90 (AGI Int. 1.050)
+	A2("mh1", "1.0I 1990-04-19", "a197817633e17cec3407ea194da4a372", 0x3149, GID_MH1),
+
 	// Manhunter NY (IIgs) 2.0E 10/05/88 (CE)
 	GAME3_P("mh1", "2.0E 1988-10-05 (CE)", "mhdir", "2f1509f76f24e6e7d213f2dadebbf156", 0x3149, 0, GID_MH1, Common::kPlatformApple2GS),
 
@@ -603,6 +634,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Files are timestamped 1986-12-10, but this is a 1989 AGI 3 interpreter.
 	GAME3_PSO("mixedup", "1.1", "dirs", "5c1295fe6daaf95831195ba12894dbd9", 2021, 0x3149, 0, GID_MIXEDUP, Common::kPlatformAmiga, GAMEOPTIONS_AMIGA),
 
+	// Mixed Up Mother Goose (Apple II) 1.0I (A2 Int. 0.100)
+	A2("mixedup", "1.0I", "ba33c035fa9f9bfb5655f59e677cabed", 0x2917, GID_MIXEDUP),
+
 	// Mixed Up Mother Goose (IIgs)
 	GAME_PO("mixedup", "1987", "3541954a7303467c6df87665312ffb6a", 0x2917, GID_MIXEDUP, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -619,6 +653,9 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Police Quest 1 (Mac) 2.0G 12/3/87
 	GAME_P("pq1", "2.0G 1987-12-03", "805750b66c1c5b88a214e67bfdca17a1", 0x2440, GID_PQ1, Common::kPlatformMacintosh),
 
+	// Police Quest 1 (Apple II) 1.0I 11/23/88 (A2 Int. 0.099)
+	A2("pq1", "1.0I 1988-11-23", "581e54c4d89bd53e775482cee9cd3ea0", 0x2917, GID_PQ1),
+
 	// Police Quest 1 (IIgs) 2.0B-88421
 	GAME_PO("pq1", "2.0B 1988-04-21", "e7c175918372336461e3811d594f482f", 0x2917, GID_PQ1, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -676,6 +713,11 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Space Quest 1 (Mac) 1.5D
 	GAME_P("sq1", "1.5D 1987-04-02", "ce88419aadd073d1c6682d859b3d8aa2", 0x2440, GID_SQ1, Common::kPlatformMacintosh),
 
+	// Space Quest 1 (Apple II) 1.0P (A2 Int. 0.073)
+	// also matches:
+	// Space Quest 1 (Apple II) 1.0Q (A2 Int. 0.073)
+	A2("sq1", "1.0P", "2a738214e1d89bb7f810bcceb32828a0", 0x2272, GID_SQ1),
+
 	// Space Quest 1 (IIgs) 2.2
 	GAME_PO("sq1", "2.2 1987", "64b9b3d04c1066d36e6a6e56187a83f7", 0x2917, GID_SQ1, Common::kPlatformApple2GS, GAMEOPTIONS_APPLE2GS),
 
@@ -703,6 +745,12 @@ static const AGIGameDescription gameDescriptions[] = {
 	// Unofficial port by Guillaume Major
 	GAME_PS("sq1", "updated", "7fa54e6bb7ffeb4cf20eca39d86f5fb2", 387, 0x2440, GID_SQ1, Common::kPlatformCoCo3),
 
+	// Space Quest 2 (Apple II) 2.0A (A2 Int. 0.089)
+	A2("sq2", "2.0A", "5b15026eee7a3a9e36e645feb026d931", 0x2917, GID_SQ2),
+
+	// Space Quest 2 (Apple II) 2.0F (A2 Int. 0.099)
+	A2("sq2", "2.0F", "5ca2c0e49918acb2517742922717201c", 0x2917, GID_SQ2),
+
 	// Space Quest 2 (IIgs) 2.0A 7/25/88 (CE)
 	// We have to see this as AGI < 2.936, because otherwise a set.pri.base call would somewhat break
 	// priority in SQ2, when entering Vohaul's vault.
diff --git a/engines/agi/disk_image.h b/engines/agi/disk_image.h
index 0d8790255f2..c8340857433 100644
--- a/engines/agi/disk_image.h
+++ b/engines/agi/disk_image.h
@@ -54,6 +54,45 @@ static const char * const pcDiskImageExtensions[] = { ".ima", ".img" };
 #define PC_INITDIR_WORDS_INDEX_V2001     9
 #define PC_INITDIR_VOL0_INDEX_V2001      14
 
+// A2 disk image values and helpers for AgiLoader_A2 and AgiMetaEngineDetection
+
+// Disk image detection requires that image files have a known extension
+static const char * const a2DiskImageExtensions[] = { ".do", ".dsk" };
+
+#define A2_DISK_SIZE                     (35 * 16 * 256)
+#define A2_DISK_POSITION(t, s, o)        ((((t * 16) + s) * 256) + o)
+
+#define A2_INITDIR_POSITION              A2_DISK_POSITION(1, 3, 0)
+#define A2_INITDIR_ENTRY_SIZE            4
+
+#define A2_INITDIR_LOGDIR_INDEX          3
+#define A2_INITDIR_PICDIR_INDEX          4
+#define A2_INITDIR_VIEWDIR_INDEX         5
+#define A2_INITDIR_SOUNDDIR_INDEX        6
+#define A2_INITDIR_OBJECTS_INDEX         1
+#define A2_INITDIR_WORDS_INDEX           2
+#define A2_INITDIR_VOLUME_MAP_POSITION   (A2_INITDIR_POSITION + 5 + 33)
+
+#define A2_KQ2_LOGDIR_POSITION           A2_DISK_POSITION(1,  0,  0)
+#define A2_KQ2_PICDIR_POSITION           A2_DISK_POSITION(1,  3,  0)
+#define A2_KQ2_VIEWDIR_POSITION          A2_DISK_POSITION(1,  6,  0)
+#define A2_KQ2_SOUNDDIR_POSITION         A2_DISK_POSITION(1,  9,  0)
+#define A2_KQ2_OBJECTS_POSITION          A2_DISK_POSITION(2,  9,  0)
+#define A2_KQ2_WORDS_POSITION            A2_DISK_POSITION(3,  0,  0)
+#define A2_KQ2_VOL0_POSITION             A2_DISK_POSITION(26, 0,  0)
+#define A2_KQ2_VOL1_POSITION             A2_DISK_POSITION(18, 0,  0)
+#define A2_KQ2_DISK_COUNT                5
+
+#define A2_BC_LOGDIR_POSITION            A2_DISK_POSITION(1,  7,  0)
+#define A2_BC_PICDIR_POSITION            A2_DISK_POSITION(1,  12, 0)
+#define A2_BC_VIEWDIR_POSITION           A2_DISK_POSITION(1,  9,  0)
+#define A2_BC_SOUNDDIR_POSITION          A2_DISK_POSITION(1,  13, 0)
+#define A2_BC_OBJECTS_POSITION           A2_DISK_POSITION(1,  3,  0)
+#define A2_BC_WORDS_POSITION             A2_DISK_POSITION(1,  5,  0)
+#define A2_BC_VOLUME_MAP_POSITION        A2_DISK_POSITION(7,  11, 254)
+#define A2_BC_DISK_COUNT                 5
+#define A2_BC_VOLUME_COUNT               9
+
 } // End of namespace Agi
 
 #endif /* AGI_DISK_IMAGE_H */
diff --git a/engines/agi/loader_a2.cpp b/engines/agi/loader_a2.cpp
new file mode 100644
index 00000000000..c1437dd929d
--- /dev/null
+++ b/engines/agi/loader_a2.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.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "agi/agi.h"
+#include "agi/disk_image.h"
+#include "agi/words.h"
+
+#include "common/config-manager.h"
+#include "common/fs.h"
+
+namespace Agi {
+
+// AgiLoader_A2 reads Apple II floppy disk images.
+//
+// Floppy disks have two sides; each side is a disk with its own image file.
+// All disk sides are 140k with 35 tracks and 16 sectors per track.
+//
+// Currently, the only supported image format is "raw", with sectors in logical
+// order. Each image file must be exactly 143,360 bytes.
+// TODO: Add support for other image formats with ADL's disk iamge code.
+//
+// The disks do not use a standard file system. Instead, file locations are
+// stored in an INITDIR structure at a fixed location. KQ2 and BC don't have
+// INITDIR, so we use the known locations of their files.
+//
+// Almost every AGI game was released on Apple II. Due to the small disk size,
+// games can have many image files. KQ4 and Gold Rush each have eight physical
+// floppies, for a total of sixteen disks. Disk one contains the disk count and
+// a volume map with the location of each volume on each disk. Disks can contain
+// multiple volumes. Volumes can appear on multiple disks in any location.
+// Later games have so many volumes that Sierra had to change the DIR format.
+//
+// File detection is done a little differently. Instead of requiring hard-coded
+// names for the image files, we scan the game directory for the first usable
+// image of disk one, and then continue scanning until all disks are found.
+// The volume map from disk one is used to identify each disk by its content.
+// The only naming requirement is that the images have a known file extension.
+//
+// AgiMetaEngineDetection also scans for usable disk images. It finds the LOGDIR
+// file inside disk one, hashes LOGDIR, and matches against the detection table.
+
+typedef Common::HashMap<Common::Path, Common::FSNode, Common::Path::IgnoreCase_Hash, Common::Path::IgnoreCase_EqualTo> FileMap;
+
+void AgiLoader_A2::init() {
+	// get all files in game directory
+	Common::FSList allFiles;
+	Common::FSNode dir(ConfMan.getPath("path"));
+	if (!dir.getChildren(allFiles, Common::FSNode::kListFilesOnly)) {
+		warning("AgiLoader_A2: invalid game path: %s", dir.getPath().toString(Common::Path::kNativeSeparator).c_str());
+		return;
+	}
+
+	// build array of files with a2 disk image extensions
+	Common::Array<Common::Path> imageFiles;
+	FileMap fileMap;
+	for (const Common::FSNode &file : allFiles) {
+		for (int i = 0; i < ARRAYSIZE(a2DiskImageExtensions); i++) {
+			if (file.getName().hasSuffixIgnoreCase(a2DiskImageExtensions[i])) {
+				Common::Path path = file.getPath();
+				imageFiles.push_back(path);
+				fileMap[path] = file;
+			}
+		}
+	}
+
+	// sort potential image files by name
+	Common::sort(imageFiles.begin(), imageFiles.end());
+
+	// find disk one by reading potential images until successful
+	int diskCount = 0;
+	Common::Array<uint32> volumeMap;
+	uint diskOneIndex;
+	for (diskOneIndex = 0; diskOneIndex < imageFiles.size(); diskOneIndex++) {
+		const Common::Path &imageFile = imageFiles[diskOneIndex];
+		Common::SeekableReadStream *stream = fileMap[imageFile].createReadStream();
+		if (stream == nullptr) {
+			warning("AgiLoader_A2: unable to open disk image: %s", imageFile.baseName().c_str());
+			continue;
+		}
+
+		// image file size must be 140k
+		int64 fileSize = stream->size();
+		if (fileSize != A2_DISK_SIZE) {
+			delete stream;
+			continue;
+		}
+
+		// read image as disk one
+		diskCount = readDiskOne(*stream, volumeMap);
+		delete stream;
+
+		if (diskCount > 0) {
+			debugC(3, "AgiLoader_A2: disk one found: %s", imageFile.baseName().c_str());
+			_imageFiles.resize(diskCount);
+			_imageFiles[0] = imageFile.baseName();
+			break;
+		}
+	}
+
+	// if disk one wasn't found, we're done
+	if (diskCount <= 0) {
+		warning("AgiLoader_A2: disk one not found");
+		return;
+	}
+
+	// find all other disks by comparing their contents to the volume map.
+	// if every volume that's supposed to be on a disk has a valid header
+	// at that location, then it's a match. continue until all disks are found.
+	// since the potential image file list is sorted, begin with the file after
+	// disk one and try until successful.
+	int volumeCount = volumeMap.size() / diskCount;
+	int disksFound = 1;
+	for (uint i = 1; i < imageFiles.size() && disksFound < diskCount; i++) {
+		uint imageFileIndex = (diskOneIndex + i) % imageFiles.size();
+		Common::Path &imageFile = imageFiles[imageFileIndex];
+
+		Common::SeekableReadStream *stream = fileMap[imageFile].createReadStream();
+		if (stream == nullptr) {
+			warning("AgiLoader_A2: unable to open disk image: %s", imageFile.baseName().c_str());
+			continue;
+		}
+
+		// image file size must be 140k
+		int32 fileSize = stream->size();
+		if (fileSize != A2_DISK_SIZE) {
+			delete stream;
+			continue;
+		}
+
+		// check each disk
+		for (int d = 1; d < diskCount; d++) {
+			// has disk already been found?
+			if (!_imageFiles[d].empty()) {
+				continue;
+			}
+
+			bool match = false;
+			for (int v = 0; v < volumeCount; v++) {
+				uint32 offset = volumeMap[(v * diskCount) + d];
+				if (offset == _EMPTY) {
+					continue;
+				}
+
+				// test for expected resource header
+				stream->seek(offset);
+				uint16 magic = stream->readUint16BE();
+				byte volume = stream->readByte();
+				uint16 size = stream->readUint16LE();
+				if (magic == 0x1234 && volume == v && stream->pos() + size <= stream->size()) {
+					match = true;
+				} else {
+					match = false;
+					break;
+				}
+			}
+
+			if (match) {
+				_imageFiles[d] = imageFile.baseName();
+				disksFound++;
+				break;
+			}
+		}
+
+		delete stream;
+	}
+
+	// populate _volumes with the locations of the ones we will use.
+	// for each volume, select the one on the first available disk.
+	_volumes.resize(volumeCount);
+	for (uint32 i = 0; i < volumeMap.size(); i++) {
+		int volume = i / diskCount;
+		int disk = i % diskCount;
+		if (volumeMap[i] != _EMPTY) {
+			// use this disk's copy of the volume
+			_volumes[volume].disk = disk;
+			_volumes[volume].offset = volumeMap[i];
+
+			// skip to next volume
+			i = ((volume + 1) * diskCount) - 1;
+		}
+	}
+}
+
+// returns disk count on success, 0 on failure
+int AgiLoader_A2::readDiskOne(Common::SeekableReadStream &stream, Common::Array<uint32> &volumeMap) {
+	// INITDIR is located at track 1, sector 3, for games that have it.
+	int diskCount;
+	bool success = true;
+	if (_vm->getGameID() == GID_KQ2) {
+		// KQ2 doesn't have INITDIR. Use known locations.
+		diskCount = A2_KQ2_DISK_COUNT;
+		success &= readDir(stream, A2_KQ2_LOGDIR_POSITION,   _logDir);
+		success &= readDir(stream, A2_KQ2_PICDIR_POSITION,   _picDir);
+		success &= readDir(stream, A2_KQ2_VIEWDIR_POSITION,  _viewDir);
+		success &= readDir(stream, A2_KQ2_SOUNDDIR_POSITION, _soundDir);
+		success &= readDir(stream, A2_KQ2_OBJECTS_POSITION,  _objects);
+		success &= readDir(stream, A2_KQ2_WORDS_POSITION,    _words);
+		// KQ2 doesn't have a volume map, probably because all the
+		// volumes on the data disks start at the first sector.
+		// Create one with known values so that it can also be
+		// used for disk detection.
+		volumeMap.clear();
+		volumeMap.resize(A2_KQ2_DISK_COUNT * (A2_KQ2_DISK_COUNT + 1), _EMPTY);
+		volumeMap[0 * diskCount + 0] = A2_KQ2_VOL0_POSITION;
+		volumeMap[1 * diskCount + 0] = A2_KQ2_VOL1_POSITION;
+		volumeMap[2 * diskCount + 1] = 0;
+		volumeMap[3 * diskCount + 2] = 0;
+		volumeMap[4 * diskCount + 3] = 0;
+		volumeMap[5 * diskCount + 4] = 0;
+	} else if (_vm->getGameID() == GID_BC) {
+		// BC doesn't have INITDIR. Use known locations.
+		diskCount = A2_BC_DISK_COUNT;
+		success &= readDir(stream, A2_BC_LOGDIR_POSITION,   _logDir);
+		success &= readDir(stream, A2_BC_PICDIR_POSITION,   _picDir);
+		success &= readDir(stream, A2_BC_VIEWDIR_POSITION,  _viewDir);
+		success &= readDir(stream, A2_BC_SOUNDDIR_POSITION, _soundDir);
+		success &= readDir(stream, A2_BC_OBJECTS_POSITION,  _objects);
+		success &= readDir(stream, A2_BC_WORDS_POSITION,    _words);
+		// BC has a volume map even though it doesn't have INITDIR.
+		// The uint16 in front of it might be volume count.
+		int volumeMapBufferSize = A2_BC_DISK_COUNT * A2_BC_VOLUME_COUNT * 2;
+		success &= readVolumeMap(stream, A2_BC_VOLUME_MAP_POSITION, volumeMapBufferSize, volumeMap);
+	} else {
+		stream.seek(A2_INITDIR_POSITION);
+		uint16 magic = stream.readUint16BE();
+		byte volume = stream.readByte();
+		uint16 size = stream.readUint16LE();
+		if (!(magic == 0x1234 && volume == 0)) {
+			return 0;
+		}
+
+		diskCount = stream.readByte(); // first byte of INITDIR
+		success &= readInitDir(stream, A2_INITDIR_LOGDIR_INDEX,   _logDir);
+		success &= readInitDir(stream, A2_INITDIR_PICDIR_INDEX,   _picDir);
+		success &= readInitDir(stream, A2_INITDIR_VIEWDIR_INDEX,  _viewDir);
+		success &= readInitDir(stream, A2_INITDIR_SOUNDDIR_INDEX, _soundDir);
+		success &= readInitDir(stream, A2_INITDIR_OBJECTS_INDEX,  _objects);
+		success &= readInitDir(stream, A2_INITDIR_WORDS_INDEX,    _words);
+		// volume map begins at byte 33 of INITDIR and runs until the end.
+		int volumeMapBufferSize = size - 33;
+		success &= readVolumeMap(stream, A2_INITDIR_VOLUME_MAP_POSITION, volumeMapBufferSize, volumeMap);
+	}
+
+	return success ? diskCount : 0;
+}
+
+bool AgiLoader_A2::readInitDir(Common::SeekableReadStream &stream, byte index, AgiDir &agid) {
+	// read INITDIR entry
+	stream.seek(A2_INITDIR_POSITION + 5 + 1 + (index * A2_INITDIR_ENTRY_SIZE));
+	byte volume = stream.readByte();
+	byte track = stream.readByte();
+	byte sector = stream.readByte();
+	byte offset = stream.readByte();
+	if (stream.eos() || stream.err()) {
+		return false;
+	}
+
+	// resource must be on disk one
+	if (!(volume == 0 || volume == 1)) {
+		return false;
+	}
+
+	int position = A2_DISK_POSITION(track, sector, offset);
+	return readDir(stream, position, agid);
+}
+
+bool AgiLoader_A2::readDir(Common::SeekableReadStream &stream, int position, AgiDir &agid) {
+	// resource begins with a 5-byte header
+	stream.seek(position);
+	uint16 magic = stream.readUint16BE();
+	byte volume = stream.readByte();
+	uint16 size = stream.readUint16LE();
+	if (!(magic == 0x1234 && (volume == 0 || volume == 1))) {
+		return false;
+	}
+	if (!(stream.pos() + size <= stream.size())) {
+		return false;
+	}
+
+	// resource found
+	agid.volume = volume;
+	agid.offset = stream.pos();
+	agid.len = size;
+	agid.clen = size;
+	return true;
+}
+
+bool AgiLoader_A2::readVolumeMap(
+	Common::SeekableReadStream &stream,
+	uint32 position,
+	uint32 bufferLength,
+	Common::Array<uint32> &volumeMap) {
+
+	// Volume map contains the location of every volume on every disk.
+	// Each entry is the location in sectors. Volumes can appear on
+	// multiple disks.
+	// ## ##   location of VOL.0 on disk 1. FF FF if empty.
+	// ## ##   location of VOL.0 on disk 2. FF FF if empty.
+	// ...
+	// ## ##   location of VOL.1 on disk 1. FF FF if empty.
+	stream.seek(position);
+	uint32 entryCount = bufferLength / 2;
+	volumeMap.clear();
+	volumeMap.resize(entryCount, _EMPTY);
+	for (uint32 i = 0; i < entryCount; i++) {
+		uint16 sectors = stream.readUint16LE();
+		if (sectors != 0xffff) {
+			volumeMap[i] = A2_DISK_POSITION(0, sectors, 0);
+		}
+	}
+	return !stream.eos() && !stream.err();
+}
+
+
+int AgiLoader_A2::loadDirs() {
+	// if init didn't find disks then fail
+	if (_imageFiles.empty()) {
+		return errFilesNotFound;
+	}
+	for (uint32 i = 0; i < _imageFiles.size(); i++) {
+		if (_imageFiles.empty()) {
+			warning("AgiLoader_A2: disk %d not found", i);
+			return errFilesNotFound;
+		}
+	}
+
+	// open disk one
+	Common::File disk;
+	if (!disk.open(Common::Path(_imageFiles[0]))) {
+		return errBadFileOpen;
+	}
+
+	// detect dir format
+	A2DirVersion dirVersion = detectDirVersion(disk);
+
+	// load each directory
+	bool success = true;
+	success &= loadDir(_vm->_game.dirLogic, disk, _logDir.offset,   _logDir.len,   dirVersion);
+	success &= loadDir(_vm->_game.dirPic,   disk, _picDir.offset,   _picDir.len,   dirVersion);
+	success &= loadDir(_vm->_game.dirView,  disk, _viewDir.offset,  _viewDir.len,  dirVersion);
+	success &= loadDir(_vm->_game.dirSound, disk, _soundDir.offset, _soundDir.len, dirVersion);
+	return success ? errOK : errBadResource;
+}
+
+A2DirVersion AgiLoader_A2::detectDirVersion(Common::SeekableReadStream &stream) {
+	// A2 DIR format:
+	//         old      new
+	// volume  4 bits   5 bits
+	// track   8 bits   7 bits
+	// sector  4 bits   4 bits
+	// offset  8 bits   8 bits
+	//
+	// This can be detected by scanning all dirs for entry 08 00 00.
+	// It must exist in the new format, but can't exist in the old.
+	// In the new format it's the first resource in volume 1.
+	// In the old format it would be track 128, which is invalid.
+	AgiDir *dirs[4] = { &_logDir, &_picDir, &_viewDir, &_soundDir };
+	for (int d = 0; d < 4; d++) {
+		stream.seek(dirs[d]->offset);
+		uint16 dirEntryCount = MIN<uint32>(dirs[d]->len / 3, MAX_DIRECTORY_ENTRIES);
+		for (uint16 i = 0; i < dirEntryCount; i++) {
+			byte b0 = stream.readByte();
+			byte b1 = stream.readByte();
+			byte b2 = stream.readByte();
+			if (b0 == 0x08 && b1 == 0x00 && b2 == 0x00) {
+				return A2DirVersionNew;
+			}
+		}
+	}
+	return A2DirVersionOld;
+}
+
+bool AgiLoader_A2::loadDir(AgiDir *dir, Common::File &disk, uint32 dirOffset, uint32 dirLength, A2DirVersion dirVersion) {
+	// seek to directory on disk
+	disk.seek(dirOffset);
+
+	// re-validate length from initdir
+	if (!(disk.pos() + dirLength <= disk.size())) {
+		return false;
+	}
+
+	// read directory entries
+	uint16 dirEntryCount = MIN<uint32>(dirLength / 3, MAX_DIRECTORY_ENTRIES);
+	for (uint16 i = 0; i < dirEntryCount; i++) {
+		byte b0 = disk.readByte();
+		byte b1 = disk.readByte();
+		byte b2 = disk.readByte();
+		if (b0 == 0xff && b1 == 0xff && b2 == 0xff) {
+			continue;
+		}
+
+		// A2 DIR format:
+		//         old      new
+		// volume  4 bits   5 bits
+		// track   8 bits   7 bits
+		// sector  4 bits   4 bits
+		// offset  8 bits   8 bits
+		// position is relative to the start of volume
+		byte track;
+		if (dirVersion == A2DirVersionOld) {
+			dir[i].volume = b0 >> 4;
+			track = ((b0 & 0x0f) << 4) | (b1 >> 4);
+		} else {
+			dir[i].volume = b0 >> 3;
+			track = ((b0 & 0x07) << 4) | (b1 >> 4);
+		}
+		byte sector = b1 & 0x0f;
+		byte offset = b2;
+		dir[i].offset = A2_DISK_POSITION(track, sector, offset);
+	}
+
+	return true;
+}
+
+uint8 *AgiLoader_A2::loadVolumeResource(AgiDir *agid) {
+	if (agid->volume >= _volumes.size()) {
+		warning("AgiLoader_A2: invalid volume: %d", agid->volume);
+		return nullptr;
+	}
+	if (_volumes[agid->volume].disk == _EMPTY) {
+		warning("AgiLoader_A2: volume not found: %d", agid->volume);
+		return nullptr;
+	}
+
+	Common::File disk;
+	int diskIndex = _volumes[agid->volume].disk;
+	if (!disk.open(Common::Path(_imageFiles[diskIndex]))) {
+		warning("AgiLoader_A2: unable to open disk image: %s", _imageFiles[diskIndex].c_str());
+		return nullptr;
+	}
+
+	// seek to resource and validate header
+	int offset = _volumes[agid->volume].offset + agid->offset;
+	disk.seek(offset);
+	uint16 magic = disk.readUint16BE();
+	if (magic != 0x1234) {
+		warning("AgiLoader_A2: no resource at volume %d offset %d", agid->volume, agid->offset);
+		return nullptr;
+	}
+	disk.skip(1); // volume
+	agid->len = disk.readUint16LE();
+
+	uint8 *data = (uint8 *)calloc(1, agid->len + 32); // why the extra 32 bytes?
+	if (disk.read(data, agid->len) != agid->len) {
+		warning("AgiLoader_A2: error reading %d bytes at volume %d offset %d", agid->len, agid->volume, agid->offset);
+		free(data);
+		return nullptr;
+	}
+
+	return data;
+}
+
+int AgiLoader_A2::loadObjects() {
+	Common::File disk;
+	if (!disk.open(Common::Path(_imageFiles[0]))) {
+		return errBadFileOpen;
+	}
+
+	disk.seek(_objects.offset);
+	return _vm->loadObjects(disk, _objects.len);
+}
+
+int AgiLoader_A2::loadWords() {
+	Common::File disk;
+	if (!disk.open(Common::Path(_imageFiles[0]))) {
+		return errBadFileOpen;
+	}
+
+	// TODO: pass length and validate in parser
+	disk.seek(_words.offset);
+	if (_vm->getVersion() < 0x2000) {
+		return _vm->_words->loadDictionary_v1(disk);
+	} else {
+		return _vm->_words->loadDictionary(disk);
+	}
+}
+
+} // End of namespace Agi
diff --git a/engines/agi/metaengine.cpp b/engines/agi/metaengine.cpp
index e0af7988f21..269a6d754f6 100644
--- a/engines/agi/metaengine.cpp
+++ b/engines/agi/metaengine.cpp
@@ -234,6 +234,7 @@ Common::Error AgiMetaEngine::createInstance(OSystem *syst, Engine **engine, cons
 	case Agi::GType_V1:
 	case Agi::GType_V2:
 	case Agi::GType_V3:
+	case Agi::GType_A2:
 		*engine = new Agi::AgiEngine(syst, gd);
 		break;
 	default:
diff --git a/engines/agi/module.mk b/engines/agi/module.mk
index 045f2644ed3..8711a4a3f51 100644
--- a/engines/agi/module.mk
+++ b/engines/agi/module.mk
@@ -10,6 +10,7 @@ MODULE_OBJS := \
 	graphics.o \
 	inv.o \
 	keyboard.o \
+	loader_a2.o \
 	loader_v1.o \
 	loader_v2.o \
 	loader_v3.o \
diff --git a/engines/agi/words.cpp b/engines/agi/words.cpp
index 4e5f3b500c7..522d420f160 100644
--- a/engines/agi/words.cpp
+++ b/engines/agi/words.cpp
@@ -36,7 +36,7 @@ Words::~Words() {
 	clearEgoWords();
 }
 
-int Words::loadDictionary_v1(Common::File &f) {
+int Words::loadDictionary_v1(Common::SeekableReadStream &stream) {
 	char str[64];
 	int k;
 
@@ -44,11 +44,11 @@ int Words::loadDictionary_v1(Common::File &f) {
 
 	// Loop through alphabet, as words in the dictionary file are sorted by
 	// first character
-	f.seek(f.pos() + 26 * 2, SEEK_SET);
+	stream.seek(26 * 2, SEEK_CUR);
 	do {
 		// Read next word
 		for (k = 0; k < (int)sizeof(str) - 1; k++) {
-			str[k] = f.readByte();
+			str[k] = stream.readByte();
 			if (str[k] == 0 || (uint8)str[k] == 0xFF)
 				break;
 		}
@@ -59,7 +59,7 @@ int Words::loadDictionary_v1(Common::File &f) {
 			byte firstCharNr = str[0] - 'a';
 
 			newWord->word = Common::String(str, k + 1); // myStrndup(str, k + 1);
-			newWord->id = f.readUint16LE();
+			newWord->id = stream.readUint16LE();
 
 			_dictionaryWords[firstCharNr].push_back(newWord);
 			debug(3, "'%s' (%d)", newWord->word.c_str(), newWord->id);
@@ -73,26 +73,31 @@ int Words::loadDictionary(const char *fname) {
 	Common::File fp;
 
 	if (!fp.open(fname)) {
-		warning("loadWords: can't open %s", fname);
+		warning("loadDictionary: can't open %s", fname);
 		return errOK; // err_BadFileOpen
 	}
+
 	debug(0, "Loading dictionary: %s", fname);
+	return loadDictionary(fp);
+}
 
+int Words::loadDictionary(Common::SeekableReadStream &stream) {
 	// Loop through alphabet, as words in the dictionary file are sorted by
 	// first character
+	uint32 start = stream.pos();
 	char str[64] = { 0 };
 	char c;
 	for (int i = 0; i < 26; i++) {
-		fp.seek(i * 2, SEEK_SET);
-		int offset = fp.readUint16BE();
+		stream.seek(start + i * 2);
+		int offset = stream.readUint16BE();
 		if (offset == 0)
 			continue;
-		fp.seek(offset, SEEK_SET);
-		int k = fp.readByte();
-		while (!fp.eos() && !fp.err()) {
+		stream.seek(start + offset);
+		int k = stream.readByte();
+		while (!stream.eos() && !stream.err()) {
 			// Read next word
 			do {
-				c = fp.readByte();
+				c = stream.readByte();
 				str[k++] = (c ^ 0x7F) & 0x7F;
 			} while (!(c & 0x80) && k < (int)sizeof(str) - 1);
 			str[k] = 0;
@@ -105,13 +110,13 @@ int Words::loadDictionary(const char *fname) {
 				// And store it in our internal dictionary
 				WordEntry *newWord = new WordEntry;
 				newWord->word = Common::String(str, k);
-				newWord->id = fp.readUint16BE();
+				newWord->id = stream.readUint16BE();
 				_dictionaryWords[i].push_back(newWord);
 			} else {
-				fp.readUint16BE();
+				stream.readUint16BE();
 			}
 
-			k = fp.readByte();
+			k = stream.readByte();
 
 			// Are there more words with an already known prefix?
 			// WORKAROUND: We only break after already seeing words with the
diff --git a/engines/agi/words.h b/engines/agi/words.h
index ad34eec939d..2626dc6fc44 100644
--- a/engines/agi/words.h
+++ b/engines/agi/words.h
@@ -52,8 +52,9 @@ public:
 	const char *getEgoWord(int16 wordNr);
 	uint16 getEgoWordId(int16 wordNr);
 
-	int  loadDictionary_v1(Common::File &f);
+	int  loadDictionary_v1(Common::SeekableReadStream &stream);
 	int  loadDictionary(const char *fname);
+	int  loadDictionary(Common::SeekableReadStream &stream);
 	// used for fan made translations requiring extended char set
 	int  loadExtendedDictionary(const char *fname);
 	void unloadDictionary();


Commit: e45022abc12105b323e742086f0e32563c6497f9
    https://github.com/scummvm/scummvm/commit/e45022abc12105b323e742086f0e32563c6497f9
Author: sluicebox (22204938+sluicebox at users.noreply.github.com)
Date: 2024-08-19T07:39:54+03:00

Commit Message:
AGI: Add Apple II view support

Changed paths:
    engines/agi/agi.h
    engines/agi/view.cpp


diff --git a/engines/agi/agi.h b/engines/agi/agi.h
index 388ff18134b..b55d3363cec 100644
--- a/engines/agi/agi.h
+++ b/engines/agi/agi.h
@@ -993,8 +993,8 @@ public:
 	int decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr);
 
 private:
-	void unpackViewCelData(AgiViewCel *celData, byte *compressedData, uint16 compressedSize);
-	void unpackViewCelDataAGI256(AgiViewCel *celData, byte *compressedData, uint16 compressedSize);
+	void unpackViewCelData(AgiViewCel *celData, byte *compressedData, uint16 compressedSize, int16 viewNr);
+	void unpackViewCelDataAGI256(AgiViewCel *celData, byte *compressedData, uint16 compressedSize, int16 viewNr);
 
 public:
 	bool isEgoView(const ScreenObjEntry *screenObj);
diff --git a/engines/agi/view.cpp b/engines/agi/view.cpp
index 080ead7b3ac..5467b1e9c7d 100644
--- a/engines/agi/view.cpp
+++ b/engines/agi/view.cpp
@@ -25,6 +25,26 @@
 
 namespace Agi {
 
+// Apple II V2+ views use different color values.
+static const byte apple2ViewColorMap[16] = {
+	/*00*/ 0x00,
+	/*01*/ 0x04,
+	/*02*/ 0x01,
+	/*03*/ 0x05,
+	/*04*/ 0x02,
+	/*05*/ 0x08,
+	/*06*/ 0x09,
+	/*07*/ 0x0b,
+	/*08*/ 0x06,
+	/*09*/ 0x0d,
+	/*0a*/ 0x07,
+	/*0b*/ 0x0c,
+	/*0c*/ 0x0a,
+	/*0d*/ 0x0e,
+	/*0e*/ 0x03,
+	/*0f*/ 0x0f
+};
+
 void AgiEngine::updateView(ScreenObjEntry *screenObj) {
 	if (screenObj->flags & fDontUpdate) {
 		screenObj->flags &= ~fDontUpdate;
@@ -84,43 +104,46 @@ void AgiEngine::updateView(ScreenObjEntry *screenObj) {
 	setCel(screenObj, celNr);
 }
 
-/*
- * Public functions
- */
-
 /**
  * Decode an AGI view resource.
  * This function decodes the raw data of the specified AGI view resource
  * and fills the corresponding views array element.
- * @param n number of view resource to decode
+ * @param viewNr number of view resource to decode
  */
 int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr) {
 	AgiView *viewData = &_game.views[viewNr];
-	uint16 headerId = 0;
-	byte   headerStepSize = 0;
-	byte   headerCycleTime = 0;
-	byte   headerLoopCount = 0;
-	uint16 headerDescriptionOffset = 0;
-	bool   isAGI256Data = false;
 
-	debugC(5, kDebugLevelResources, "decode_view(%d)", viewNr);
+	debugC(5, kDebugLevelResources, "decodeView(%d)", viewNr);
 
 	if (resourceSize < 5)
 		error("unexpected end of view data for view %d", viewNr);
 
-	headerId = READ_LE_UINT16(resourceData);
 	if (getVersion() < 0x2000) {
-		headerStepSize = resourceData[0];
-		headerCycleTime = resourceData[1];
+		viewData->headerStepSize = resourceData[0];
+		viewData->headerCycleTime = resourceData[1];
+	} else {
+		viewData->headerStepSize = 0;
+		viewData->headerCycleTime = 0;
+	}
+
+	bool isAGI256Data = false;
+	if (getFeatures() & GF_AGI256) {
+		uint16 headerId = READ_LE_UINT16(resourceData);
+		isAGI256Data = (headerId == 0xF00F); // AGI 256-2 view detected, 256 color view
 	}
-	headerLoopCount = resourceData[2];
-	headerDescriptionOffset = READ_LE_UINT16(resourceData + 3);
 
-	if (headerId == 0xF00F)
-		isAGI256Data = true; // AGI 256-2 view detected, 256 color view
+	// Apple II V2+ views stopped including the first two bytes
+	int headerLoopCountOffset = 2;
+	int viewHeaderSize = 5;
+	const bool apple2 = (getPlatform() == Common::kPlatformApple2) && getVersion() >= 0x2000;
+	if (apple2) {
+		headerLoopCountOffset = 0;
+		viewHeaderSize = 3;
+	}
+
+	byte headerLoopCount = resourceData[headerLoopCountOffset];
+	uint16 headerDescriptionOffset = READ_LE_UINT16(resourceData + headerLoopCountOffset + 1);
 
-	viewData->headerStepSize = headerStepSize;
-	viewData->headerCycleTime = headerCycleTime;
 	viewData->loopCount = headerLoopCount;
 	viewData->description = nullptr;
 	viewData->loop = nullptr;
@@ -146,7 +169,7 @@ int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr)
 		return errOK;
 
 	// Check, if at least the loop-offsets are available
-	if (resourceSize < 5 + (headerLoopCount * 2))
+	if (resourceSize < viewHeaderSize + (headerLoopCount * 2))
 		error("unexpected end of view data for view %d", viewNr);
 
 	// Allocate space for loop-information
@@ -154,7 +177,7 @@ int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr)
 	viewData->loop = loopData;
 
 	for (int16 loopNr = 0; loopNr < headerLoopCount; loopNr++) {
-		uint16 loopOffset = READ_LE_UINT16(resourceData + 5 + (loopNr * 2));
+		uint16 loopOffset = READ_LE_UINT16(resourceData + viewHeaderSize + (loopNr * 2));
 
 		// Check, if at least the loop-header is available
 		if (resourceSize < (loopOffset + 1))
@@ -195,6 +218,10 @@ int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr)
 				byte celHeaderWidth = resourceData[celOffset + 0];
 				byte celHeaderHeight = resourceData[celOffset + 1];
 				byte celHeaderTransparencyMirror = resourceData[celOffset + 2];
+				if (apple2) {
+					// Apple II views switched the transparency and mirror bits
+					celHeaderTransparencyMirror = (celHeaderTransparencyMirror << 4) | (celHeaderTransparencyMirror >> 4);
+				}
 
 				byte celHeaderClearKey;
 				bool celHeaderMirrored = false;
@@ -205,6 +232,11 @@ int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr)
 					//  Bit 4-6 - original loop, that is not supposed to be mirrored in any case
 					//  Bit 7   - apply mirroring
 					celHeaderClearKey = celHeaderTransparencyMirror & 0x0F; // bit 0-3 is the clear key
+					if (apple2) {
+						// Apple II views use different color values
+						celHeaderClearKey = apple2ViewColorMap[celHeaderClearKey];
+					}
+
 					if (celHeaderTransparencyMirror & 0x80) {
 						// mirror bit is set
 						byte celHeaderMirrorLoop = (celHeaderTransparencyMirror >> 4) & 0x07;
@@ -234,9 +266,9 @@ int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr)
 					error("compressed size of loop within view %d is 0 bytes", viewNr);
 
 				if (!isAGI256Data) {
-					unpackViewCelData(celData, celCompressedData, celCompressedSize);
+					unpackViewCelData(celData, celCompressedData, celCompressedSize, viewNr);
 				} else {
-					unpackViewCelDataAGI256(celData, celCompressedData, celCompressedSize);
+					unpackViewCelDataAGI256(celData, celCompressedData, celCompressedSize, viewNr);
 				}
 				celData++;
 			}
@@ -248,19 +280,17 @@ int AgiEngine::decodeView(byte *resourceData, uint16 resourceSize, int16 viewNr)
 	return errOK;
 }
 
-void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uint16 compressedSize) {
+void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uint16 compressedSize, int16 viewNr) {
 	byte *rawBitmap = new byte[celData->width * celData->height];
 	int16 remainingHeight = celData->height;
 	int16 remainingWidth = celData->width;
-	bool  isMirrored = celData->mirrored;
-	byte curColor;
-	byte curChunkLen;
 	int16 adjustPreChangeSingle = 0;
 	int16 adjustAfterChangeSingle = +1;
+	const bool apple2 = (getPlatform() == Common::kPlatformApple2) && getVersion() >= 0x2000;
 
 	celData->rawBitmap = rawBitmap;
 
-	if (isMirrored) {
+	if (celData->mirrored) {
 		adjustPreChangeSingle = -1;
 		adjustAfterChangeSingle = 0;
 		rawBitmap += celData->width;
@@ -268,11 +298,13 @@ void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uin
 
 	while (remainingHeight) {
 		if (!compressedSize)
-			error("unexpected end of data, while unpacking AGI256 data");
+			error("unexpected end of data, while unpacking view %d", viewNr);
 
 		byte curByte = *compressedData++;
 		compressedSize--;
 
+		byte curColor;
+		byte curChunkLen;
 		if (curByte == 0) {
 			curColor = celData->clearKey;
 			curChunkLen = remainingWidth;
@@ -280,7 +312,10 @@ void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uin
 			curColor = curByte >> 4;
 			curChunkLen = curByte & 0x0F;
 			if (curChunkLen > remainingWidth)
-				error("invalid chunk in view data");
+				error("invalid chunk in view %d", viewNr);
+			if (apple2) {
+				curColor = apple2ViewColorMap[curColor];
+			}
 		}
 
 		switch (curChunkLen) {
@@ -292,21 +327,25 @@ void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uin
 			rawBitmap += adjustAfterChangeSingle;
 			break;
 		default:
-			if (isMirrored)
+			if (celData->mirrored)
 				rawBitmap -= curChunkLen;
 			memset(rawBitmap, curColor, curChunkLen);
-			if (!isMirrored)
+			if (!celData->mirrored)
 				rawBitmap += curChunkLen;
 			break;
 		}
 
 		remainingWidth -= curChunkLen;
 
-		if (curByte == 0) {
+		// Each row is terminated by a zero byte; any remaining pixels are transparent.
+		// Apple II views don't use terminators, instead they explicitly draw remaining
+		// transparent pixels with a normal chunk byte, and rows end when they're full.
+		// The Apple II method uses one less byte on full rows.
+		if (curByte == 0 || (apple2 && remainingWidth == 0)) {
 			remainingWidth = celData->width;
 			remainingHeight--;
 
-			if (isMirrored)
+			if (celData->mirrored)
 				rawBitmap += celData->width * 2;
 		}
 	}
@@ -321,7 +360,7 @@ void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uin
 
 		rawBitmap = celData->rawBitmap;
 		for (uint16 pixelNr = 0; pixelNr < totalPixels; pixelNr++) {
-			curColor = *rawBitmap;
+			byte curColor = *rawBitmap;
 			*rawBitmap = _gfx->getCGAMixtureColor(curColor);
 			rawBitmap++;
 		}
@@ -332,7 +371,7 @@ void AgiEngine::unpackViewCelData(AgiViewCel *celData, byte *compressedData, uin
 	}
 }
 
-void AgiEngine::unpackViewCelDataAGI256(AgiViewCel *celData, byte *compressedData, uint16 compressedSize) {
+void AgiEngine::unpackViewCelDataAGI256(AgiViewCel *celData, byte *compressedData, uint16 compressedSize, int16 viewNr) {
 	byte *rawBitmap = new byte[celData->width * celData->height];
 	int16 remainingHeight = celData->height;
 	int16 remainingWidth = celData->width;
@@ -341,7 +380,7 @@ void AgiEngine::unpackViewCelDataAGI256(AgiViewCel *celData, byte *compressedDat
 
 	while (remainingHeight) {
 		if (!compressedSize)
-			error("unexpected end of data, while unpacking AGI256 view");
+			error("unexpected end of data, while unpacking AGI256 view %d", viewNr);
 
 		byte curByte = *compressedData++;
 		compressedSize--;
@@ -356,7 +395,7 @@ void AgiEngine::unpackViewCelDataAGI256(AgiViewCel *celData, byte *compressedDat
 			}
 		} else {
 			if (!remainingWidth) {
-				error("broken view data, while unpacking AGI256 view");
+				error("broken view data, while unpacking AGI256 view %d", viewNr);
 				break;
 			}
 			*rawBitmap = curByte;




More information about the Scummvm-git-logs mailing list