[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