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

neuromancer noreply at scummvm.org
Wed Jun 24 12:14:04 UTC 2026


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

Summary:
cf378b61f1 FREESCAPE: improved OPL music rendition for Driller
015a24a173 FREESCAPE: improved culling support in Driller
d54c958df5 FREESCAPE: improved culling support in Total Eclipse and Castle Master
d3ddaec0ea EEM: allow to play the Mac release from the installer


Commit: cf378b61f17f7fbcf51d4415754940231ed14cd4
    https://github.com/scummvm/scummvm/commit/cf378b61f17f7fbcf51d4415754940231ed14cd4
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-24T14:13:48+02:00

Commit Message:
FREESCAPE: improved OPL music rendition for Driller

Changed paths:
    engines/freescape/games/driller/opl.music.cpp


diff --git a/engines/freescape/games/driller/opl.music.cpp b/engines/freescape/games/driller/opl.music.cpp
index a6f95b2fb22..6fd3c41886f 100644
--- a/engines/freescape/games/driller/opl.music.cpp
+++ b/engines/freescape/games/driller/opl.music.cpp
@@ -72,9 +72,9 @@ const DrillerOPLBasePatch kDrillerOPLBasePatches[] = {
 	{ 0x22, 0x21, 0x18, 0x00, 0xF4, 0xF2, 0x55, 0x36, 0x00, 0x00, 0x08 }  // default lead
 };
 
-const byte kDrillerMusicAttenuation = 12;
+const byte kDrillerMusicAttenuation = 4;
 const byte kDrillerNoiseAttenuation = 0;
-const byte kDrillerNarrowPulseAttenuation = 6;
+const byte kDrillerNarrowPulseAttenuation = 2;
 const uint16 kDrillerNarrowPulseEdgeDistance = 0x0300;
 const byte kDrillerPulseBrightnessBoostMax = 24;
 const int kDrillerArpeggioSize = 3;
@@ -117,6 +117,44 @@ byte getDrillerInstrumentControl(const uint8_t *instA0, const uint8_t *instA1, b
 	return instA0[1];
 }
 
+// SID sustain level (0-15, linear) -> OPL2 sustain-level nibble (attenuation).
+const byte kDrillerSidSustainToOPL[16] = {
+	15, 8, 6, 5, 4, 3, 3, 2, 2, 2, 1, 1, 1, 0, 0, 0
+};
+
+// SID rate (0=fast..15=slow) -> OPL rate (15=fast..0=never), inverted and
+// compressed into [5,15] so even the slowest SID rate stays audible.
+byte sidRateToOPL(byte sidRate) {
+	return 15 - (sidRate * 10) / 15;
+}
+
+// Convert a SID instrument's AD/SR bytes to OPL2 carrier envelope registers.
+void deriveDrillerOPLEnvelope(byte sidAD, byte sidSR, byte &oplAD, byte &oplSR, bool &sustaining) {
+	byte sidAttack = sidAD >> 4;
+	byte sidSustain = sidSR >> 4;
+	byte attack = sidRateToOPL(sidAttack);
+	byte decay = sidRateToOPL(sidAD & 0x0F);
+	byte release = sidRateToOPL(sidSR & 0x0F);
+
+	byte sustainLevel;
+	if (sidSustain != 0) {
+		sustaining = true;
+		sustainLevel = kDrillerSidSustainToOPL[sidSustain];
+	} else if (sidAttack >= 10) {
+		// Slow-attack swell, no SID sustain: hold it so the note does not
+		// collapse into a short puff (instruments 1, 2, 14).
+		sustaining = true;
+		sustainLevel = 0;
+	} else {
+		// Fast attack, no sustain: plucked voice that decays away.
+		sustaining = false;
+		sustainLevel = 15;
+	}
+
+	oplAD = (attack << 4) | decay;
+	oplSR = (sustainLevel << 4) | release;
+}
+
 void DrillerOPLMusicPlayer::VoiceState::reset() {
 	trackDataPtr = nullptr;
 	trackIndex = 0;
@@ -650,12 +688,22 @@ void DrillerOPLMusicPlayer::setOPLInstrument(int channel, VoiceState &v) {
 	byte mod = kOPLModOffset[channel];
 	byte car = kOPLCarOffset[channel];
 
+	// Give the carrier the instrument's own envelope (from its SID AD/SR) so the
+	// 22 instruments stay distinct and sustained voices hold instead of fading.
+	int instBase = v.instrumentIndex;
+	if (instBase < 0 || instBase >= NUM_INSTRUMENTS * 8)
+		instBase = 0;
+	byte carAD, carSR;
+	bool sustaining;
+	deriveDrillerOPLEnvelope(instrumentDataA0[instBase + 2], instrumentDataA0[instBase + 3], carAD, carSR, sustaining);
+	byte carChar = (patch.carChar & ~0x20) | (sustaining ? 0x20 : 0x00);
+
 	_opl->writeReg(0x20 + mod, patch.modChar);
-	_opl->writeReg(0x20 + car, patch.carChar);
+	_opl->writeReg(0x20 + car, carChar);
 	_opl->writeReg(0x60 + mod, patch.modAD);
-	_opl->writeReg(0x60 + car, patch.carAD);
+	_opl->writeReg(0x60 + car, carAD);
 	_opl->writeReg(0x80 + mod, patch.modSR);
-	_opl->writeReg(0x80 + car, patch.carSR);
+	_opl->writeReg(0x80 + car, carSR);
 	_opl->writeReg(0xE0 + mod, patch.modWave);
 	_opl->writeReg(0xE0 + car, patch.carWave);
 	_opl->writeReg(0xC0 + channel, patch.feedbackConnection);
@@ -681,7 +729,8 @@ void DrillerOPLMusicPlayer::applyPulseWidth(int channel, const VoiceState &v) {
 		modLevel = patch.modLevel > brightnessBoost ? patch.modLevel - brightnessBoost : 0;
 
 		byte feedback = (patch.feedbackConnection >> 1) & 0x07;
-		feedback = MIN<byte>(7, feedback + (centerDistance >> 8));
+		// Only a little feedback; near the max (7) the FM tone turns to noise.
+		feedback = MIN<byte>(4, feedback + (centerDistance >> 9));
 		feedbackConnection = (patch.feedbackConnection & 0x01) | (feedback << 1);
 		if (edgeDistance <= kDrillerNarrowPulseEdgeDistance)
 			attenuation = kDrillerNarrowPulseAttenuation;


Commit: 015a24a17305ca81f61ad804e6bb441dcd79d096
    https://github.com/scummvm/scummvm/commit/015a24a17305ca81f61ad804e6bb441dcd79d096
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-24T14:13:48+02:00

Commit Message:
FREESCAPE: improved culling support in Driller

Changed paths:
    engines/freescape/area.cpp
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/objects/object.h


diff --git a/engines/freescape/area.cpp b/engines/freescape/area.cpp
index 65b6d3b4fbd..144db15a0f9 100644
--- a/engines/freescape/area.cpp
+++ b/engines/freescape/area.cpp
@@ -257,11 +257,8 @@ static float aabbMinProjection(const Math::AABB &aabb, const Math::Vector3d &axi
 	return support.dotProduct(axis);
 }
 
-static bool aabbIntersectsHalfSpace(const Math::AABB &aabb, const Math::Vector3d &camera, const Math::Vector3d &normal, float padding) {
-	return aabbMaxProjection(aabb, normal) - camera.dotProduct(normal) >= -padding;
-}
-
 static bool aabbIntersectsViewVolume(const Math::AABB &aabb, const Math::Vector3d &camera, const Math::Vector3d &direction, float fov, float aspectRatio, float nearClipPlane, float farClipPlane) {
+	(void)aspectRatio;
 	if (!aabb.isValid())
 		return false;
 
@@ -269,17 +266,7 @@ static bool aabbIntersectsViewVolume(const Math::AABB &aabb, const Math::Vector3
 	if (front.getSquareMagnitude() == 0.0f)
 		return true;
 
-	const Math::Vector3d worldUp(0.0f, 1.0f, 0.0f);
-	Math::Vector3d right = Math::Vector3d::crossProduct(front, worldUp);
-	if (right.getSquareMagnitude() < 0.0001f)
-		right = Math::Vector3d(1.0f, 0.0f, 0.0f);
-	else
-		right.normalize();
-	Math::Vector3d up = Math::Vector3d::crossProduct(right, front).getNormalized();
-
 	const float padding = 32.0f;
-	const float horizontalScale = tan(Math::deg2rad(fov) / 2.0f) * 1.25f;
-	const float verticalScale = MAX(horizontalScale / MAX(aspectRatio, 0.001f), horizontalScale);
 	const float minDepth = aabbMinProjection(aabb, front) - camera.dotProduct(front);
 	const float maxDepth = aabbMaxProjection(aabb, front) - camera.dotProduct(front);
 
@@ -288,14 +275,22 @@ static bool aabbIntersectsViewVolume(const Math::AABB &aabb, const Math::Vector3
 	if (minDepth > farClipPlane + padding)
 		return false;
 
-	if (!aabbIntersectsHalfSpace(aabb, camera, front * horizontalScale + right, padding))
-		return false;
-	if (!aabbIntersectsHalfSpace(aabb, camera, front * horizontalScale - right, padding))
-		return false;
-	if (!aabbIntersectsHalfSpace(aabb, camera, front * verticalScale + up, padding))
-		return false;
-	if (!aabbIntersectsHalfSpace(aabb, camera, front * verticalScale - up, padding))
-		return false;
+	// Coarse view-octant cull matching the original (compute_view_clip_bounds):
+	// keep every object in the world octant(s) the frustum spans rather than a tight
+	// cone, and on axes the frustum straddles (its component within the fov
+	// half-angle) keep both sides. The kept set must match the original because the
+	// painter's bubble sort below is non-transitive: a tighter cone drops
+	// straddle-axis side objects and reorders the visible ones.
+	const float threshold = (float)sin(Math::deg2rad(fov) / 2.0f);
+	const Math::Vector3d mn = aabb.getMin();
+	const Math::Vector3d mx = aabb.getMax();
+	for (int i = 0; i < 3; i++) {
+		const float comp = front.getValue(i);
+		if (comp > threshold && mx.getValue(i) < camera.getValue(i))
+			return false;
+		if (comp < -threshold && mn.getValue(i) > camera.getValue(i))
+			return false;
+	}
 
 	return true;
 }
@@ -465,16 +460,14 @@ void Area::draw(Freescape::Renderer *gfx, uint32 animationTicks, Math::Vector3d
 	// It is only applied to the vertices during the projection phase (L850f/L9177).
 	int n = _sortedObjects.size();
 	if (n > 1 && sort) {
-		// Pre-sort by distance from camera (furthest first) to provide a stable initial
-		// ordering for the non-transitive bubble sort below. The original game achieves
-		// this by culling off-screen objects via a rendering volume check (L8bb7/L845b)
-		// before sorting, which prevents distant off-screen objects from interfering with
-		// the depth ordering of visible objects through non-transitive comparisons.
+		// Seed the non-transitive bubble sort below in object load (file) order --
+		// the same order the original game's renderer iterates its object list.
+		// That data order is what makes the Newell pairwise sort resolve
+		// correctly; any depth heuristic (center distance, nearest depth) is only
+		// an approximation and mis-orders some scenes, the file order does not.
 		Common::sort(_sortedObjects.begin(), _sortedObjects.end(),
-			[&camera](Object *a, Object *b) {
-				Math::Vector3d centerA = (a->_occlusionBox.getMin() + a->_occlusionBox.getMax()) * 0.5f;
-				Math::Vector3d centerB = (b->_occlusionBox.getMin() + b->_occlusionBox.getMax()) * 0.5f;
-				return (centerA - camera).getSquareMagnitude() > (centerB - camera).getSquareMagnitude();
+			[](Object *a, Object *b) {
+				return a->_loadIndex < b->_loadIndex;
 			});
 		for (int i = 0; i < n; i++) { // L9c31_whole_object_pass_loop
 			bool changed = false;
@@ -652,16 +645,10 @@ void Area::drawDepthLayer(Freescape::Renderer *gfx, uint32 animationTicks, Math:
 	// It is only applied to the vertices during the projection phase (L850f/L9177).
 	int n = _depthLayerSortedObjects.size();
 	if (n > 1 && sort) {
-		// Pre-sort by distance from camera (furthest first) to provide a stable initial
-		// ordering for the non-transitive bubble sort below. The original game achieves
-		// this by culling off-screen objects via a rendering volume check (L8bb7/L845b)
-		// before sorting, which prevents distant off-screen objects from interfering with
-		// the depth ordering of visible objects through non-transitive comparisons.
+		// Seed in object load (file) order, matching the original (see Area::draw).
 		Common::sort(_depthLayerSortedObjects.begin(), _depthLayerSortedObjects.end(),
-			[&camera](Object *a, Object *b) {
-				Math::Vector3d centerA = (a->_occlusionBox.getMin() + a->_occlusionBox.getMax()) * 0.5f;
-				Math::Vector3d centerB = (b->_occlusionBox.getMin() + b->_occlusionBox.getMax()) * 0.5f;
-				return (centerA - camera).getSquareMagnitude() > (centerB - camera).getSquareMagnitude();
+			[](Object *a, Object *b) {
+				return a->_loadIndex < b->_loadIndex;
 			});
 		for (int i = 0; i < n; i++) { // L9c31_whole_object_pass_loop
 			bool changed = false;
diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 0e21095df66..464dfe797ea 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -785,6 +785,7 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 
 		if (newObject) {
 			newObject->scale(scale);
+			newObject->_loadIndex = object;
 			if (newObject->getType() == kEntranceType) {
 				if (entrancesByID->contains(newObject->getObjectID() & 0x7fff))
 					error("WARNING: replacing object id %d (%d)", newObject->getObjectID(), newObject->getObjectID() & 0x7fff);
@@ -1154,6 +1155,7 @@ void FreescapeEngine::loadGlobalObjects(Common::SeekableReadStream *file, int of
 	for (int i = 0; i < size; i++) {
 		Object *gobj = load8bitObject(file);
 		assert(gobj);
+		gobj->_loadIndex = 0x4000 + i; // after area objects, in global load order
 		assert(!globalObjectsByID->contains(gobj->getObjectID()));
 		debugC(1, kFreescapeDebugParser, "Adding global object: %d", gobj->getObjectID());
 		(*globalObjectsByID)[gobj->getObjectID()] = gobj;
diff --git a/engines/freescape/objects/object.h b/engines/freescape/objects/object.h
index b1b852c27b4..e85b7c1cb8e 100644
--- a/engines/freescape/objects/object.h
+++ b/engines/freescape/objects/object.h
@@ -93,6 +93,9 @@ public:
 	Math::AABB _boundingBox;
 	Math::AABB _occlusionBox;
 	Object *_partOfGroup = nullptr;
+	// Position in the object file, used to seed the renderer's depth sort in the
+	// same order the original game iterates its object list (see Area::draw).
+	uint16 _loadIndex = 0xFFFF;
 };
 
 } // End of namespace Freescape


Commit: d54c958df5118de9ad4615ecc71ca91eafccfd4a
    https://github.com/scummvm/scummvm/commit/d54c958df5118de9ad4615ecc71ca91eafccfd4a
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-24T14:13:48+02:00

Commit Message:
FREESCAPE: improved culling support in Total Eclipse and Castle Master

Changed paths:
    engines/freescape/loaders/8bitBinaryLoader.cpp
    engines/freescape/objects/geometricobject.cpp


diff --git a/engines/freescape/loaders/8bitBinaryLoader.cpp b/engines/freescape/loaders/8bitBinaryLoader.cpp
index 464dfe797ea..a604be0ebc0 100644
--- a/engines/freescape/loaders/8bitBinaryLoader.cpp
+++ b/engines/freescape/loaders/8bitBinaryLoader.cpp
@@ -785,7 +785,7 @@ Area *FreescapeEngine::load8bitArea(Common::SeekableReadStream *file, uint16 nco
 
 		if (newObject) {
 			newObject->scale(scale);
-			newObject->_loadIndex = object;
+			newObject->_loadIndex = 0x4000 + object; // area objects render after globals (original pass 2)
 			if (newObject->getType() == kEntranceType) {
 				if (entrancesByID->contains(newObject->getObjectID() & 0x7fff))
 					error("WARNING: replacing object id %d (%d)", newObject->getObjectID(), newObject->getObjectID() & 0x7fff);
@@ -1155,7 +1155,7 @@ void FreescapeEngine::loadGlobalObjects(Common::SeekableReadStream *file, int of
 	for (int i = 0; i < size; i++) {
 		Object *gobj = load8bitObject(file);
 		assert(gobj);
-		gobj->_loadIndex = 0x4000 + i; // after area objects, in global load order
+		gobj->_loadIndex = i; // global objects render before area objects (original pass 1)
 		assert(!globalObjectsByID->contains(gobj->getObjectID()));
 		debugC(1, kFreescapeDebugParser, "Adding global object: %d", gobj->getObjectID());
 		(*globalObjectsByID)[gobj->getObjectID()] = gobj;
diff --git a/engines/freescape/objects/geometricobject.cpp b/engines/freescape/objects/geometricobject.cpp
index 311f462698e..e98717b8ba0 100644
--- a/engines/freescape/objects/geometricobject.cpp
+++ b/engines/freescape/objects/geometricobject.cpp
@@ -268,6 +268,7 @@ Object *GeometricObject::duplicate() {
 	);
 
 	copy->_cyclingColors = _cyclingColors;
+	copy->_loadIndex = _loadIndex;
 	return copy;
 }
 


Commit: d3ddaec0ea030078f4be31aeeafcb7e681f4e59c
    https://github.com/scummvm/scummvm/commit/d3ddaec0ea030078f4be31aeeafcb7e681f4e59c
Author: neuromancer (gustavo.grieco at gmail.com)
Date: 2026-06-24T14:13:48+02:00

Commit Message:
EEM: allow to play the Mac release from the installer

Changed paths:
  A engines/eem/installer.cpp
  A engines/eem/installer.h
    engines/eem/detection.cpp
    engines/eem/eem.cpp
    engines/eem/module.mk


diff --git a/engines/eem/detection.cpp b/engines/eem/detection.cpp
index cae6ac41901..9665f18d349 100644
--- a/engines/eem/detection.cpp
+++ b/engines/eem/detection.cpp
@@ -81,7 +81,7 @@ const ADGameDescription gameDescriptions[] = {
 	},
 	{
 		"eem",
-		"Mac",
+		"",
 		AD_ENTRY2s("MysteryData", "d94c087c27e68cc299d7c5e737e458f9", 941029,
 				   "PICS.DBD",    "8905041070ff1352666d98cd78d5501c", 3800445),
 		Common::EN_ANY,
@@ -89,6 +89,19 @@ const ADGameDescription gameDescriptions[] = {
 		ADGF_UNSTABLE,
 		GUI_OPTIONS_EEM_MAC
 	},
+	{
+		// Macintosh floppy release played straight from its installer files
+		// ("Eagle Eye Installer" + "EEM Install Data 2".."6"); the engine
+		// decompresses them on the fly (see installer.cpp).
+		"eem",
+		"",
+		AD_ENTRY2s("Eagle Eye Installer", "08440dbf0cb47fb57e522f050159ffaa", 1391577,
+				   "EEM Install Data 2",  "aebccc677e149b37285f291f6ac72f57", 1446428),
+		Common::EN_ANY,
+		Common::kPlatformMacintosh,
+		ADGF_UNSTABLE,
+		GUI_OPTIONS_EEM_MAC
+	},
 	{
 		// Eagle Eye Mysteries in London 
 		"eem2",
diff --git a/engines/eem/eem.cpp b/engines/eem/eem.cpp
index c9cab6db0a4..d89eea60354 100644
--- a/engines/eem/eem.cpp
+++ b/engines/eem/eem.cpp
@@ -39,6 +39,7 @@
 #include "eem/audio.h"
 #include "eem/detection.h"
 #include "eem/eem.h"
+#include "eem/installer.h"
 #include "eem/music.h"
 #include "eem/site.h"
 
@@ -771,6 +772,20 @@ void EEMEngine::setSiteHotspotCursorId(int cursorId) {
 bool EEMEngine::openArchives() {
 	const bool mac = isMacintosh();
 
+	// The Mac release can be played straight from its floppy installer. When
+	// those files are present, mount a virtual archive that decompresses the
+	// game data (PICS.DBD, MysteryData, fonts, ...) on demand, so the opens
+	// below and the Mac resource-fork lookups resolve transparently.
+	if (mac && !SearchMan.hasArchive("eem-installer")) {
+		const Common::FSNode gameDir(ConfMan.getPath("path"));
+		if (Common::Archive *installer = createInstallerArchive(gameDir)) {
+			SearchMan.add("eem-installer", installer);
+			debugC(1, kDebugGeneral, "Mounted Eagle Eye Mysteries Mac installer archive");
+		} else {
+			warning("EEMTEST: createInstallerArchive returned null");
+		}
+	}
+
 	if (!_picsArchive.open(Common::Path("PICS.DBD"), Common::Path("PICS.DBX"), mac)) {
 		warning("PICS archive missing");
 		return false;
diff --git a/engines/eem/installer.cpp b/engines/eem/installer.cpp
new file mode 100644
index 00000000000..8c2096787b0
--- /dev/null
+++ b/engines/eem/installer.cpp
@@ -0,0 +1,573 @@
+/* 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 "eem/installer.h"
+
+#include "common/algorithm.h"
+#include "common/array.h"
+#include "common/debug.h"
+#include "common/endian.h"
+#include "common/fs.h"
+#include "common/memstream.h"
+#include "common/str.h"
+#include "common/stream.h"
+#include "common/textconsole.h"
+
+#include "eem/detection.h"
+
+namespace EEM {
+
+// ===========================================================================
+//  Compact Pro decompressor
+//
+//  The installer compresses each fork with Compact Pro's scheme: an outer RLE
+//  escape layer (ESC1 = 0x81, ESC2 = 0x82) wrapped around an LZH stage built
+//  from three Huffman tables (256-entry literals, 64-entry match lengths,
+//  128-entry match offsets) feeding an 8 KiB sliding window. Ported from the
+//  reference cpt.c (macutils) decoder.
+// ===========================================================================
+
+namespace {
+
+class CompactProDecompressor {
+public:
+	CompactProDecompressor(const byte *src, uint32 srcLen, byte *dst, uint32 dstLen)
+		: _src(src), _srcLen(srcLen), _pos(0), _dst(dst), _dstLen(dstLen), _outPos(0),
+		  _remaining((int32)dstLen), _outstat(kNone), _save(0), _lzptr(0),
+		  _newbits(0), _bitsavail(0), _error(false) {
+		memset(_lz, 0, sizeof(_lz));
+	}
+
+	// `compressed` selects the LZH stage; otherwise the bytes are only RLE
+	// escaped. `inLen` is the size of the compressed span (the RLE stage reads
+	// exactly that many input bytes).
+	bool unpack(bool compressed, uint32 inLen) {
+		if (compressed)
+			unpackLZH();
+		else
+			unpackRLE(inLen);
+		return !_error && _remaining == 0;
+	}
+
+private:
+	enum { ESC1 = 0x81, ESC2 = 0x82 };
+	enum { kNone = 0, kEsc1Seen = 1, kEsc2Seen = 2 };
+	enum { kBlockSize = 0x1fff0 };
+
+	struct HuffNode {
+		byte leaf;
+		int value;
+		int left;
+		int right;
+	};
+
+	struct HuffEnt {
+		int value;
+		int length;
+	};
+
+	struct HuffEntLess {
+		bool operator()(const HuffEnt &a, const HuffEnt &b) const {
+			return a.length != b.length ? a.length < b.length : a.value < b.value;
+		}
+	};
+
+	byte getByte() {
+		if (_pos >= _srcLen) {
+			_error = true;
+			return 0;
+		}
+		return _src[_pos++];
+	}
+
+	void emit(byte b) {
+		if (_outPos < _dstLen)
+			_dst[_outPos++] = b;
+		_remaining--;
+	}
+
+	void outCh(int chIn) {
+		byte ch = chIn & 0xff;
+		_lz[_lzptr & 8191] = ch;
+		_lzptr++;
+
+		if (_outstat == kNone) {
+			if (ch == ESC1 && _remaining != 1) {
+				_outstat = kEsc1Seen;
+			} else {
+				_save = ch;
+				emit(ch);
+			}
+		} else if (_outstat == kEsc1Seen) {
+			if (ch == ESC2) {
+				_outstat = kEsc2Seen;
+			} else {
+				_save = ESC1;
+				emit(ESC1);
+				if (_remaining == 0)
+					return;
+				if (ch == ESC1 && _remaining != 1)
+					return; // remains in kEsc1Seen
+				_outstat = kNone;
+				_save = ch;
+				emit(ch);
+			}
+		} else { // kEsc2Seen: ch is a run length for the previous byte
+			_outstat = kNone;
+			if (ch != 0) {
+				int c = (ch - 1) & 0xff;
+				while (c != 0) {
+					emit(_save);
+					if (_remaining == 0)
+						return;
+					c = (c - 1) & 0xff;
+				}
+			} else {
+				emit(ESC1);
+				if (_remaining == 0)
+					return;
+				_save = ESC2;
+				emit(_save);
+			}
+		}
+	}
+
+	void unpackRLE(uint32 inLen) {
+		for (uint32 i = 0; i < inLen; i++) {
+			outCh(getByte());
+			if (_error || _remaining == 0)
+				break;
+		}
+	}
+
+	bool readHuff(Common::Array<HuffNode> &nodes, int size) {
+		int treeBytes = getByte();
+		if (size < treeBytes * 2) {
+			_error = true;
+			return false;
+		}
+
+		int treeCount[32];
+		memset(treeCount, 0, sizeof(treeCount));
+		Common::Array<HuffEnt> entries;
+		int i = 0, maxlen = 0;
+		for (int t = 0; t < treeBytes; t++) {
+			int b = getByte();
+			int lens[2] = { b >> 4, b & 0xf };
+			for (int k = 0; k < 2; k++) {
+				int length = lens[k];
+				if (length) {
+					if (length > maxlen)
+						maxlen = length;
+					treeCount[length]++;
+					HuffEnt e;
+					e.value = i;
+					e.length = length;
+					entries.push_back(e);
+				}
+				i++;
+			}
+		}
+
+		int j = 0;
+		for (int x = 0; x <= maxlen; x++)
+			j = (j << 1) + treeCount[x];
+		int unused = maxlen ? ((1 << maxlen) - j) : 0;
+		for (int u = 0; u < unused; u++) {
+			HuffEnt e;
+			e.value = size;
+			e.length = maxlen;
+			entries.push_back(e);
+		}
+
+		Common::sort(entries.begin(), entries.end(), HuffEntLess());
+
+		const int total = size * 2 + 32;
+		nodes.clear();
+		nodes.resize(total);
+		for (int n = 0; n < total; n++) {
+			nodes[n].leaf = 0;
+			nodes[n].value = 0;
+			nodes[n].left = 0;
+			nodes[n].right = 0;
+		}
+
+		int idx = (int)entries.size() - 1;
+		int lvlstart = total - 1, nxt = total - 1;
+		for (int codelen = maxlen; codelen >= 1; codelen--) {
+			while (idx >= 0 && entries[idx].length == codelen) {
+				nodes[nxt].leaf = 1;
+				nodes[nxt].value = entries[idx].value;
+				nxt--;
+				idx--;
+			}
+			int parents = nxt;
+			if (codelen > 1) {
+				int jj = lvlstart;
+				while (jj > parents + 1) {
+					nodes[nxt].leaf = 0;
+					nodes[nxt].left = jj - 1;
+					nodes[nxt].right = jj;
+					nxt--;
+					jj -= 2;
+				}
+			}
+			lvlstart = parents;
+		}
+		nodes[0].leaf = 0;
+		nodes[0].left = nxt + 1;
+		nodes[0].right = nxt + 2;
+		return true;
+	}
+
+	int getBit() {
+		int b = (_newbits >> 31) & 1;
+		_bitsavail--;
+		if (_bitsavail < 16) {
+			uint32 hi = getByte();
+			uint32 lo = getByte();
+			_newbits |= (hi << 8) | lo;
+			_bitsavail += 16;
+		}
+		_newbits = _newbits << 1;
+		return b;
+	}
+
+	int get6Bits() {
+		int b = (_newbits >> 26) & 0x3f;
+		_bitsavail -= 6;
+		_newbits = _newbits << 6;
+		if (_bitsavail < 16) {
+			uint32 hi = getByte();
+			uint32 lo = getByte();
+			uint32 cn = (hi << 8) | lo;
+			_newbits |= cn << (16 - _bitsavail);
+			_bitsavail += 16;
+		}
+		return b;
+	}
+
+	int getHuffByte(const Common::Array<HuffNode> &nodes) {
+		int idx = 0;
+		while (!nodes[idx].leaf)
+			idx = getBit() ? nodes[idx].right : nodes[idx].left;
+		return nodes[idx].value;
+	}
+
+	void unpackLZH() {
+		_lz[8189] = _lz[8190] = _lz[8191] = 0;
+		_lzptr = 0;
+		while (_remaining != 0 && !_error) {
+			Common::Array<HuffNode> huffData, huffLen, huffOff;
+			if (!readHuff(huffData, 256) || !readHuff(huffLen, 64) || !readHuff(huffOff, 128))
+				return;
+
+			int blockCount = 0;
+			uint32 hi = getByte();
+			uint32 lo = getByte();
+			_newbits = ((hi << 8) | lo) << 16;
+			_bitsavail = 16;
+
+			while (blockCount < (int)kBlockSize && _remaining != 0 && !_error) {
+				if (getBit()) {
+					outCh(getHuffByte(huffData));
+					blockCount += 2;
+				} else {
+					int length = getHuffByte(huffLen);
+					int off = (getHuffByte(huffOff) << 6) | get6Bits();
+					uint32 bptr = _lzptr - off;
+					while (length > 0) {
+						outCh(_lz[bptr & 8191]);
+						bptr++;
+						length--;
+					}
+					blockCount += 3;
+				}
+			}
+		}
+	}
+
+	const byte *_src;
+	uint32 _srcLen;
+	uint32 _pos;
+	byte *_dst;
+	uint32 _dstLen;
+	uint32 _outPos;
+	int32 _remaining;
+	int _outstat;
+	int _save;
+	uint32 _lzptr;
+	byte _lz[8192];
+	uint32 _newbits;
+	int _bitsavail;
+	bool _error;
+};
+
+} // End of anonymous namespace
+
+class InstallerArchive : public Common::Archive {
+private:
+	struct Entry {
+		Common::String name;
+		uint32 pos;       // logical offset of the resource span within _archive
+		uint32 rsrcLen;
+		uint32 dataLen;
+		uint32 compRLen;
+		uint32 compDLen;
+		uint16 cptFlag;
+	};
+
+	class Member : public Common::ArchiveMember {
+	public:
+		Member(const InstallerArchive *archive, const Entry *entry)
+			: _archive(archive), _entry(entry) {}
+
+		Common::SeekableReadStream *createReadStream() const override {
+			return _archive->createStream(*_entry, false);
+		}
+		Common::SeekableReadStream *createReadStreamForAltStream(Common::AltStreamType altStreamType) const override {
+			if (altStreamType == Common::AltStreamType::MacResourceFork)
+				return _archive->createStream(*_entry, true);
+			return nullptr;
+		}
+		Common::String getName() const override { return _entry->name; }
+		Common::Path getPathInArchive() const override { return Common::Path(_entry->name); }
+		Common::String getFileName() const override { return _entry->name; }
+		bool isInMacArchive() const override { return true; }
+
+	private:
+		const InstallerArchive *_archive;
+		const Entry *_entry;
+	};
+
+public:
+	bool load(const Common::FSNode &dir);
+
+	bool hasFile(const Common::Path &path) const override;
+	int listMembers(Common::ArchiveMemberList &list) const override;
+	const Common::ArchiveMemberPtr getMember(const Common::Path &path) const override;
+	Common::SeekableReadStream *createReadStreamForMember(const Common::Path &path) const override;
+	Common::SeekableReadStream *createReadStreamForMemberAltStream(const Common::Path &path,
+			Common::AltStreamType altStreamType) const override;
+	char getPathSeparator() const override { return ':'; }
+
+private:
+	const Entry *findEntry(const Common::Path &path) const;
+	Common::SeekableReadStream *createStream(const Entry &entry, bool resFork) const;
+	void parseCatalog();
+
+	Common::Array<byte> _archive;
+	Common::Array<Entry> _entries;
+	Common::Array<uint32> _diskBase; // [1..6] start offset of each disk within _archive
+};
+
+bool InstallerArchive::load(const Common::FSNode &dir) {
+	// disk 1 first, then the five continuation segments, concatenated into one
+	// logical stream (payload spans cross floppy boundaries).
+	Common::Array<Common::String> names;
+	names.push_back("Eagle Eye Installer");
+	for (int i = 2; i <= 6; i++)
+		names.push_back(Common::String::format("EEM Install Data %d", i));
+
+	_diskBase.resize(names.size() + 1); // 1-based; index 0 unused
+	for (uint i = 0; i < _diskBase.size(); i++)
+		_diskBase[i] = 0xffffffff;
+
+	for (uint i = 0; i < names.size(); i++) {
+		Common::FSNode node = dir.getChild(names[i]);
+		if (!node.exists()) {
+			if (i == 0)
+				return false; // installer not present here
+			// Missing continuation segments are tolerated (e.g. CD layout);
+			// only entries that reference them will fail to load.
+			break;
+		}
+		Common::ScopedPtr<Common::SeekableReadStream> stream(node.createReadStream());
+		if (!stream) {
+			warning("EEM installer: cannot read %s", names[i].c_str());
+			return i != 0;
+		}
+		const uint32 oldSize = _archive.size();
+		const uint32 addSize = (uint32)stream->size();
+		_diskBase[i + 1] = oldSize; // disk numbers are 1-based
+		_archive.resize(oldSize + addSize);
+		if (stream->read(_archive.data() + oldSize, addSize) != addSize) {
+			warning("EEM installer: short read on %s", names[i].c_str());
+			return false;
+		}
+	}
+
+	parseCatalog();
+	if (_entries.empty()) {
+		warning("EEM installer: no catalog entries found");
+		return false;
+	}
+	debugC(1, kDebugGeneral, "EEM installer: %u entries, %u bytes",
+		   _entries.size(), _archive.size());
+	return true;
+}
+
+void InstallerArchive::parseCatalog() {
+	// The catalog occupies the start of disk 1. Each entry is a Pascal name,
+	// followed by a disk byte + big-endian 32-bit offset, an 8-byte marker
+	// ("DATASFS1"/"APPLSFS1") and a 32-byte field block.
+	const byte *cat = _archive.data();
+	const uint32 catSize = _archive.size();
+	const uint32 scanEnd = MIN<uint32>(catSize, 0x500);
+	const char *markers[2] = { "DATASFS1", "APPLSFS1" };
+
+	for (int mi = 0; mi < 2; mi++) {
+		const char *mk = markers[mi];
+		for (uint32 pos = 5; pos + 8 + 32 <= catSize && pos + 8 <= scanEnd; pos++) {
+			if (memcmp(cat + pos, mk, 8) != 0)
+				continue;
+
+			const int disk = cat[pos - 5];
+			const uint32 off = READ_BE_UINT32(cat + pos - 4);
+
+			// Recover the file name: a Pascal string ending right before the
+			// 5-byte disk/offset prefix.
+			const uint32 nameEnd = pos - 5;
+			Common::String name;
+			const uint32 lo = nameEnd > 80 ? nameEnd - 80 : 0;
+			for (uint32 lpos = lo; lpos < nameEnd; lpos++) {
+				const uint32 len = cat[lpos];
+				if (lpos + 1 + len != nameEnd)
+					continue;
+				bool printable = true;
+				for (uint32 c = lpos + 1; c < nameEnd; c++) {
+					if (cat[c] < 32 || cat[c] >= 127) {
+						printable = false;
+						break;
+					}
+				}
+				if (printable)
+					name = Common::String((const char *)cat + lpos + 1, len);
+			}
+			if (name.empty())
+				continue;
+
+			const byte *f = cat + pos + 8;
+			Entry e;
+			e.name = name;
+			e.cptFlag = READ_BE_UINT16(f + 14);
+			e.rsrcLen = READ_BE_UINT32(f + 16);
+			e.dataLen = READ_BE_UINT32(f + 20);
+			e.compRLen = READ_BE_UINT32(f + 24);
+			e.compDLen = READ_BE_UINT32(f + 28);
+
+			// Map the per-disk relative offset to the concatenated stream. The
+			// segments were appended in order, so the global position is the
+			// loaded base of `disk` plus `off`; spans flowing into later disks
+			// stay contiguous. Skip entries on segments that were not loaded.
+			if (disk < 1 || (uint)disk >= _diskBase.size() ||
+				_diskBase[disk] == 0xffffffff)
+				continue;
+			e.pos = _diskBase[disk] + off;
+			_entries.push_back(e);
+		}
+	}
+}
+
+bool InstallerArchive::hasFile(const Common::Path &path) const {
+	return findEntry(path) != nullptr;
+}
+
+int InstallerArchive::listMembers(Common::ArchiveMemberList &list) const {
+	for (uint i = 0; i < _entries.size(); i++)
+		list.push_back(Common::ArchiveMemberPtr(new Member(this, &_entries[i])));
+	return _entries.size();
+}
+
+const Common::ArchiveMemberPtr InstallerArchive::getMember(const Common::Path &path) const {
+	const Entry *e = findEntry(path);
+	if (!e)
+		return nullptr;
+	return Common::ArchiveMemberPtr(new Member(this, e));
+}
+
+Common::SeekableReadStream *InstallerArchive::createReadStreamForMember(const Common::Path &path) const {
+	const Entry *e = findEntry(path);
+	if (!e)
+		return nullptr;
+	return createStream(*e, false);
+}
+
+Common::SeekableReadStream *InstallerArchive::createReadStreamForMemberAltStream(
+		const Common::Path &path, Common::AltStreamType altStreamType) const {
+	const Entry *e = findEntry(path);
+	if (!e || altStreamType != Common::AltStreamType::MacResourceFork)
+		return nullptr;
+	return createStream(*e, true);
+}
+
+const InstallerArchive::Entry *InstallerArchive::findEntry(const Common::Path &path) const {
+	const Common::String want = path.baseName();
+	for (uint i = 0; i < _entries.size(); i++) {
+		if (_entries[i].name.equalsIgnoreCase(want))
+			return &_entries[i];
+	}
+	return nullptr;
+}
+
+Common::SeekableReadStream *InstallerArchive::createStream(const Entry &entry, bool resFork) const {
+	const uint32 outLen = resFork ? entry.rsrcLen : entry.dataLen;
+	if (outLen == 0) {
+		if (!resFork)
+			return new Common::MemoryReadStream(nullptr, 0, DisposeAfterUse::NO);
+		return nullptr;
+	}
+
+	const uint32 srcPos = resFork ? entry.pos : entry.pos + entry.compRLen;
+	const uint32 srcLen = resFork ? entry.compRLen : entry.compDLen;
+	const bool compressed = resFork ? (entry.cptFlag & 0x02) != 0
+									: (entry.cptFlag & 0x04) != 0;
+
+	if (srcPos > _archive.size() || srcLen > _archive.size() - srcPos) {
+		warning("EEM installer: %s span out of range", entry.name.c_str());
+		return nullptr;
+	}
+
+	byte *dst = (byte *)malloc(outLen);
+	if (!dst)
+		return nullptr;
+
+	CompactProDecompressor cp(_archive.data() + srcPos, srcLen, dst, outLen);
+	if (!cp.unpack(compressed, srcLen)) {
+		warning("EEM installer: failed to decompress %s%s", entry.name.c_str(),
+				resFork ? " (resource fork)" : "");
+		free(dst);
+		return nullptr;
+	}
+
+	return new Common::MemoryReadStream(dst, outLen, DisposeAfterUse::YES);
+}
+
+Common::Archive *createInstallerArchive(const Common::FSNode &dir) {
+	InstallerArchive *archive = new InstallerArchive();
+	if (!archive->load(dir)) {
+		delete archive;
+		return nullptr;
+	}
+	return archive;
+}
+
+} // End of namespace EEM
diff --git a/engines/eem/installer.h b/engines/eem/installer.h
new file mode 100644
index 00000000000..69a983f6c50
--- /dev/null
+++ b/engines/eem/installer.h
@@ -0,0 +1,55 @@
+/* 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 EEM_INSTALLER_H
+#define EEM_INSTALLER_H
+
+#include "common/archive.h"
+
+namespace Common {
+class FSNode;
+}
+
+namespace EEM {
+
+// The Macintosh floppy release of Eagle Eye Mysteries ships a custom
+// self-extracting installer spread across six files:
+//
+//     "Eagle Eye Installer"   (disk 1: catalog + first payload span)
+//     "EEM Install Data 2".."EEM Install Data 6"   (disks 2-6: payload)
+//
+// The catalog lives in the data fork of "Eagle Eye Installer"; each entry is
+// flagged "DATASFS1" (a data file) or "APPLSFS1" (the game application) and the
+// fork payloads are Compact Pro compressed (an RLE escape layer over an LZH
+// stage). createInstallerArchive() parses that catalog and returns an archive
+// that decompresses members on demand, so ScummVM can run straight from the
+// installer files with no manual extraction step.
+//
+// Data files (PICS.DBD, ANI.DBX, MysteryData, ...) are exposed as plain
+// members; the application and "EEM Sound&Music" resource forks are exposed as
+// Mac resource-fork alt streams so Common::MacResManager can read them.
+//
+// Returns nullptr when the installer files are absent or cannot be parsed.
+Common::Archive *createInstallerArchive(const Common::FSNode &dir);
+
+} // End of namespace EEM
+
+#endif
diff --git a/engines/eem/module.mk b/engines/eem/module.mk
index 6879b600e85..917289b1522 100644
--- a/engines/eem/module.mk
+++ b/engines/eem/module.mk
@@ -7,6 +7,7 @@ MODULE_OBJS = \
 	eem.o \
 	font.o \
 	graphics.o \
+	installer.o \
 	metaengine.o \
 	music.o \
 	mystery.o \




More information about the Scummvm-git-logs mailing list