[Scummvm-git-logs] scummvm master -> 05fd0b0d2b85d1fd374e3020ec19b96a81a0415c

dreammaster noreply at scummvm.org
Tue Aug 11 10:33:24 UTC 2026


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

Summary:
f238ebd55b HOPKINS: Harden APC sample ownership and routing
bbce0fabe2 HOPKINS: WBASE: Add validated resource loading
42ae734201 HOPKINS: WBASE: Add the recovered simulation
b4671d4dc8 HOPKINS: WBASE: Add the CLUT8 software renderer
4d8d3d8313 HOPKINS: Integrate WBASE rooms with engine lifecycle
958b37f124 HOPKINS: WBASE: Move texture toggle away from F5
05fd0b0d2b HOPKINS: WBASE: Clear held keys after the main menu


Commit: f238ebd55b35ca330f1e1c82129591945039f997
    https://github.com/scummvm/scummvm/commit/f238ebd55b35ca330f1e1c82129591945039f997
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: Harden APC sample ownership and routing

Validate APC headers, sample rates, file bounds, decoder creation, and
stream ownership before installing logical sample state. Stop owned
mixer handles and clear stale streams when a slot is replaced or
decoding fails.

Restore Hopkins routing for play modes 5 through 9 while keeping channel
selection separate from speech and SFX volume classes. This shared
Hopkins layer is used by both the ordinary adventure and WBASE; WBASE
does not add a private mixer or additional voices.

The change is intentionally limited to sample lifetime and recovered
routing semantics. Whole-game and overlapping WBASE audio remain runtime
acceptance gates. Original behavior.

Assisted-by: Codex:GPT-5.4

Changed paths:
    engines/hopkins/sound.cpp
    engines/hopkins/sound.h


diff --git a/engines/hopkins/sound.cpp b/engines/hopkins/sound.cpp
index eccfafd9535..e3fa5485d47 100644
--- a/engines/hopkins/sound.cpp
+++ b/engines/hopkins/sound.cpp
@@ -58,15 +58,24 @@ private:
 };
 
 Audio::RewindableAudioStream *makeAPCStream(Common::SeekableReadStream *stream, DisposeAfterUse::Flag disposeAfterUse) {
-	if (stream->readUint32BE() != MKTAG('C', 'R', 'Y', 'O'))
-		return nullptr;
-	if (stream->readUint32BE() != MKTAG('_', 'A', 'P', 'C'))
+	if (!stream || stream->size() < 32 ||
+			stream->readUint32BE() != MKTAG('C', 'R', 'Y', 'O') ||
+			stream->readUint32BE() != MKTAG('_', 'A', 'P', 'C')) {
+		if (disposeAfterUse == DisposeAfterUse::YES)
+			delete stream;
 		return nullptr;
+	}
+
 	stream->readUint32BE(); // version
 	stream->readUint32LE(); // out size
-	uint32 rate = stream->readUint32LE();
+	const uint32 rate = stream->readUint32LE();
 	stream->skip(8); // initial values, will be handled by the class
-	bool stereo = stream->readUint32LE() != 0;
+	const bool stereo = stream->readUint32LE() != 0;
+	if (stream->err() || stream->eos() || rate == 0) {
+		if (disposeAfterUse == DisposeAfterUse::YES)
+			delete stream;
+		return nullptr;
+	}
 
 	return new APC_ADPCMStream(stream, disposeAfterUse, rate, stereo ? 2 : 1);
 }
@@ -371,6 +380,8 @@ void SoundManager::stopSound() {
 
 	for (int i = 1; i <= 48; ++i)
 		removeWavSample(i);
+	for (int i = 0; i < SOUND_COUNT; ++i)
+		_sound[i]._active = false;
 
 	if (_modPlayingFl) {
 		stopMusic();
@@ -616,6 +627,9 @@ bool SoundManager::mixVoice(int voiceId, int voiceMode, bool dispTxtFl) {
 		}
 	}
 	int oldMusicVol = _musicVolume;
+	// Speech always occupies slot 20. Clear any stale stream/handle first so
+	// a failed replacement cannot leave an older voice marked as current.
+	removeWavSample(20);
 	if (!loadVoice(filename, catPos, catLen, _sWav[20])) {
 		// This case only concerns the English Win95 demo
 		// If it's not possible to load the voice, we force the active flag
@@ -670,10 +684,13 @@ bool SoundManager::mixVoice(int voiceId, int voiceMode, bool dispTxtFl) {
 }
 
 void SoundManager::removeSample(int soundIndex) {
-	if (checkVoiceStatus(1))
-		stopVoice(1);
-	if (checkVoiceStatus(2))
-		stopVoice(2);
+	if (!isValidSoundIndex(soundIndex))
+		return;
+
+	for (int voiceIndex = 0; voiceIndex < VOICE_COUNT; ++voiceIndex) {
+		if (_voice[voiceIndex]._status && _voice[voiceIndex]._wavIndex == soundIndex)
+			stopVoice(voiceIndex);
+	}
 	removeWavSample(soundIndex);
 	_sound[soundIndex]._active = false;
 }
@@ -727,40 +744,70 @@ void SoundManager::setMODMusicVolume(int volume) {
 		_vm->_mixer->setChannelVolume(_musicHandle, volume * 255 / 16);
 }
 
-void SoundManager::loadSample(int wavIndex, const Common::Path &file) {
-	loadWavSample(wavIndex, file, false);
-	_sound[wavIndex]._active = true;
+bool SoundManager::loadSample(int wavIndex, const Common::Path &file) {
+	if (!isValidSoundIndex(wavIndex)) {
+		warning("Hopkins: invalid sample index %d", wavIndex);
+		return false;
+	}
+
+	const bool loaded = loadWavSample(wavIndex, file, false);
+	_sound[wavIndex]._active = loaded;
+	return loaded;
 }
 
 void SoundManager::playSample(int wavIndex, int voiceMode) {
-	if (_soundOffFl || !_sound[wavIndex]._active)
+	if (!isValidSoundIndex(wavIndex) || _soundOffFl || !_sound[wavIndex]._active)
 		return;
 
 	if (_soundFl)
 		delWav(_currentSoundIndex);
 
+	int voiceIndex = -1;
 	switch (voiceMode) {
 	case 5:
-	// Case added to identify the former PLAY_SAMPLE2 calls
-	case 9:
-		if (checkVoiceStatus(1))
-			stopVoice(1);
-		playWavSample(1, wavIndex);
+	case 8:
+		voiceIndex = 0;
 		break;
 	case 6:
-		if (checkVoiceStatus(2))
-			stopVoice(1);
-		playWavSample(2, wavIndex);
+		voiceIndex = 1;
 		break;
-	default:
+	case 7:
+		voiceIndex = 2;
 		break;
+	// ScummVM uses mode 9 to identify calls to the original PLAY_SAMPLE2
+	// helper, which always used channel 0.
+	case 9:
+		voiceIndex = 0;
+		break;
+	default:
+		return;
 	}
+
+	if (checkVoiceStatus(voiceIndex))
+		stopVoice(voiceIndex);
+	playWavSample(voiceIndex, wavIndex);
+}
+
+bool SoundManager::isValidVoiceIndex(int voiceIndex) const {
+	return voiceIndex >= 0 && voiceIndex < VOICE_COUNT;
+}
+
+bool SoundManager::isValidWavIndex(int wavIndex) const {
+	return wavIndex >= 0 && wavIndex < SWAV_COUNT;
+}
+
+bool SoundManager::isValidSoundIndex(int soundIndex) const {
+	return soundIndex >= 0 && soundIndex < SOUND_COUNT;
 }
 
 bool SoundManager::checkVoiceStatus(int voiceIndex) {
+	if (!isValidVoiceIndex(voiceIndex))
+		return false;
+
 	if (_voice[voiceIndex]._status) {
-		int wavIndex = _voice[voiceIndex]._wavIndex;
-		if (_sWav[wavIndex]._audioStream && _sWav[wavIndex]._audioStream->endOfStream())
+		const int wavIndex = _voice[voiceIndex]._wavIndex;
+		if (!isValidWavIndex(wavIndex) || !_sWav[wavIndex]._active ||
+				!_sWav[wavIndex]._audioStream || _sWav[wavIndex]._audioStream->endOfStream())
 			stopVoice(voiceIndex);
 	}
 
@@ -768,36 +815,56 @@ bool SoundManager::checkVoiceStatus(int voiceIndex) {
 }
 
 void SoundManager::stopVoice(int voiceIndex) {
+	if (!isValidVoiceIndex(voiceIndex))
+		return;
+
 	if (_voice[voiceIndex]._status) {
+		const int wavIndex = _voice[voiceIndex]._wavIndex;
 		_voice[voiceIndex]._status = false;
-		int wavIndex = _voice[voiceIndex]._wavIndex;
-		if (_sWav[wavIndex]._active && _sWav[wavIndex]._freeSampleFl)
-			removeWavSample(wavIndex);
+		_voice[voiceIndex]._wavIndex = 0;
+
+		if (isValidWavIndex(wavIndex) && _sWav[wavIndex]._active) {
+			if (_sWav[wavIndex]._freeSampleFl)
+				removeWavSample(wavIndex);
+			else
+				_vm->_mixer->stopHandle(_sWav[wavIndex]._soundHandle);
+		}
+	} else {
+		_voice[voiceIndex]._wavIndex = 0;
 	}
-	_voice[voiceIndex]._status = false;
 }
 
 void SoundManager::playVoice() {
-	if (!_sWav[20]._active)
+	if (!_sWav[20]._active || !_sWav[20]._audioStream)
 		return;
 
 	if (!_voice[2]._status) {
-		int wavIndex = _voice[2]._wavIndex;
-		if (_sWav[wavIndex]._active && _sWav[wavIndex]._freeSampleFl)
+		const int wavIndex = _voice[2]._wavIndex;
+		if (isValidWavIndex(wavIndex) && _sWav[wavIndex]._active && _sWav[wavIndex]._freeSampleFl)
 			removeWavSample(wavIndex);
 	}
 
-	playWavSample(2, 20);
+	playWavSample(2, 20, true);
 }
 
 bool SoundManager::removeWavSample(int wavIndex) {
-	if (!_sWav[wavIndex]._active)
+	if (!isValidWavIndex(wavIndex))
+		return false;
+
+	const bool existed = _sWav[wavIndex]._active || _sWav[wavIndex]._audioStream != nullptr;
+	if (!existed)
 		return false;
 
 	_vm->_mixer->stopHandle(_sWav[wavIndex]._soundHandle);
 	delete _sWav[wavIndex]._audioStream;
-	_sWav[wavIndex]._audioStream = nullptr;
-	_sWav[wavIndex]._active = false;
+	_sWav[wavIndex] = SwavItem();
+
+	for (int voiceIndex = 0; voiceIndex < VOICE_COUNT; ++voiceIndex) {
+		if (_voice[voiceIndex]._wavIndex == wavIndex) {
+			_voice[voiceIndex]._status = false;
+			_voice[voiceIndex]._wavIndex = 0;
+		}
+	}
 
 	return true;
 }
@@ -815,23 +882,63 @@ bool SoundManager::loadVoice(const Common::Path &filename, size_t fileOffset, si
 		}
 	}
 
-	f.seek(fileOffset);
-	item._audioStream = makeSoundStream(f.readStream((entryLength == 0) ? f.size() : entryLength));
+	const int64 signedFileSize = f.size();
+	if (signedFileSize < 0) {
+		warning("Hopkins: could not determine audio size for %s", filename.toString().c_str());
+		return false;
+	}
+
+	const uint64 fileSize = (uint64)signedFileSize;
+	const uint64 offset = (uint64)fileOffset;
+	const uint64 requestedLength = (uint64)entryLength;
+	if (offset > fileSize || (requestedLength != 0 && requestedLength > fileSize - offset)) {
+		warning("Hopkins: invalid audio range in %s", filename.toString().c_str());
+		return false;
+	}
+
+	const uint64 readLength = requestedLength == 0 ? fileSize - offset : requestedLength;
+	if (readLength > 0xffffffffULL) {
+		warning("Hopkins: audio range too large in %s", filename.toString().c_str());
+		return false;
+	}
+	if (!f.seek((int64)offset)) {
+		warning("Hopkins: could not seek in audio file %s", filename.toString().c_str());
+		return false;
+	}
+
+	Common::SeekableReadStream *audioData = f.readStream((uint32)readLength);
 	f.close();
+	if (!audioData || audioData->size() != (int64)readLength) {
+		delete audioData;
+		warning("Hopkins: truncated audio data in %s", filename.toString().c_str());
+		return false;
+	}
+
+	item._audioStream = makeSoundStream(audioData);
+	if (!item._audioStream) {
+		item = SwavItem();
+		warning("Hopkins: unsupported or malformed audio file %s", filename.toString().c_str());
+		return false;
+	}
 
 	return true;
 }
 
-void SoundManager::loadWavSample(int wavIndex, const Common::Path &filename, bool freeSample) {
-	if (_sWav[wavIndex]._active)
+bool SoundManager::loadWavSample(int wavIndex, const Common::Path &filename, bool freeSample) {
+	if (!isValidWavIndex(wavIndex)) {
+		warning("Hopkins: invalid WAV slot %d", wavIndex);
+		return false;
+	}
+
+	if (_sWav[wavIndex]._active || _sWav[wavIndex]._audioStream)
 		removeWavSample(wavIndex);
 
-	if (loadVoice(filename, 0, 0, _sWav[wavIndex])) {
-		_sWav[wavIndex]._active = true;
-		_sWav[wavIndex]._freeSampleFl = freeSample;
-	} else{
-		_sWav[wavIndex]._active = false;
-	}
+	if (!loadVoice(filename, 0, 0, _sWav[wavIndex]))
+		return false;
+
+	_sWav[wavIndex]._active = true;
+	_sWav[wavIndex]._freeSampleFl = freeSample;
+	return true;
 }
 
 void SoundManager::loadWav(const Common::Path &file, int wavIndex) {
@@ -839,7 +946,8 @@ void SoundManager::loadWav(const Common::Path &file, int wavIndex) {
 }
 
 void SoundManager::playWav(int wavIndex) {
-	if (_soundFl || _soundOffFl)
+	if (_soundFl || _soundOffFl || !isValidWavIndex(wavIndex) ||
+			!_sWav[wavIndex]._active || !_sWav[wavIndex]._audioStream)
 		return;
 
 	_soundFl = true;
@@ -848,35 +956,49 @@ void SoundManager::playWav(int wavIndex) {
 }
 
 void SoundManager::delWav(int wavIndex) {
-	if (!removeWavSample(wavIndex))
+	if (!isValidWavIndex(wavIndex))
 		return;
 
-	if (checkVoiceStatus(1))
-		stopVoice(1);
+	for (int voiceIndex = 0; voiceIndex < VOICE_COUNT; ++voiceIndex) {
+		if (_voice[voiceIndex]._status && _voice[voiceIndex]._wavIndex == wavIndex)
+			stopVoice(voiceIndex);
+	}
+	removeWavSample(wavIndex);
 
-	_currentSoundIndex = 0;
-	_soundFl = false;
+	if (_currentSoundIndex == wavIndex) {
+		_currentSoundIndex = 0;
+		_soundFl = false;
+	}
 }
 
-void SoundManager::playWavSample(int voiceIndex, int wavIndex) {
-	if (!_sWav[wavIndex]._active)
-		warning("Bad handle");
+void SoundManager::playWavSample(int voiceIndex, int wavIndex, bool useVoiceVolume) {
+	if (!isValidVoiceIndex(voiceIndex) || !isValidWavIndex(wavIndex) ||
+			!_sWav[wavIndex]._active || !_sWav[wavIndex]._audioStream) {
+		warning("Hopkins: cannot play unloaded sample %d on voice %d", wavIndex, voiceIndex);
+		return;
+	}
 
-	if (_voice[voiceIndex]._status && _sWav[wavIndex]._active && _sWav[wavIndex]._freeSampleFl)
-		removeWavSample(wavIndex);
+	if (_voice[voiceIndex]._status) {
+		const int oldWavIndex = _voice[voiceIndex]._wavIndex;
+		if (oldWavIndex == wavIndex) {
+			_vm->_mixer->stopHandle(_sWav[wavIndex]._soundHandle);
+			_voice[voiceIndex]._status = false;
+			_voice[voiceIndex]._wavIndex = 0;
+		} else {
+			stopVoice(voiceIndex);
+		}
+	}
 
 	_voice[voiceIndex]._status = true;
 	_voice[voiceIndex]._wavIndex = wavIndex;
 
-	int volume = (voiceIndex == 2) ? _voiceVolume * 255 / 16 : _soundVolume * 255 / 16;
+	const int volume = (useVoiceVolume ? _voiceVolume : _soundVolume) * 255 / 16;
 
 	// If the handle is still in use, stop it. Otherwise we'll lose the
-	// handle to that sound. This can currently happen (but probably
-	// shouldn't) when skipping a movie.
+	// handle to that sound. This can currently happen when skipping a movie.
 	if (_vm->_mixer->isSoundHandleActive(_sWav[wavIndex]._soundHandle))
-		  _vm->_mixer->stopHandle(_sWav[wavIndex]._soundHandle);
+		_vm->_mixer->stopHandle(_sWav[wavIndex]._soundHandle);
 
-	// Start the voice playing
 	_sWav[wavIndex]._audioStream->rewind();
 	_vm->_mixer->playStream(Audio::Mixer::kSFXSoundType, &_sWav[wavIndex]._soundHandle,
 		_sWav[wavIndex]._audioStream, -1, volume, 0, DisposeAfterUse::NO);
diff --git a/engines/hopkins/sound.h b/engines/hopkins/sound.h
index a62d16ea1a7..f09c4b71334 100644
--- a/engines/hopkins/sound.h
+++ b/engines/hopkins/sound.h
@@ -102,8 +102,11 @@ private:
 	Common::Path setExtension(const Common::Path &str, const Common::String &ext);
 	Audio::RewindableAudioStream *makeSoundStream(Common::SeekableReadStream *stream);
 	bool removeWavSample(int wavIndex);
-	void loadWavSample(int wavIndex, const Common::Path &filename, bool freeSample);
-	void playWavSample(int voiceIndex, int wavIndex);
+	bool loadWavSample(int wavIndex, const Common::Path &filename, bool freeSample);
+	void playWavSample(int voiceIndex, int wavIndex, bool useVoiceVolume = false);
+	bool isValidVoiceIndex(int voiceIndex) const;
+	bool isValidWavIndex(int wavIndex) const;
+	bool isValidSoundIndex(int soundIndex) const;
 
 public:
 	bool _musicOffFl;
@@ -123,7 +126,7 @@ public:
 	void loadAnimSound();
 	void playAnimSound(int animFrame);
 
-	void loadSample(int wavIndex, const Common::Path &file);
+	bool loadSample(int wavIndex, const Common::Path &file);
 	void playSample(int wavIndex, int voiceMode = 9);
 	void removeSample(int soundIndex);
 


Commit: bbce0fabe22bd15b3ef483a39a174a8fd7a6b7ae
    https://github.com/scummvm/scummvm/commit/bbce0fabe22bd15b3ef483a39a174a8fd7a6b7ae
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: WBASE: Add validated resource loading

Add strict readers for BASE.MAP, BASE.PAL, INFO.DAT, PBM textures, and
Hopkins sprite banks. Preserve ACK column-major texture storage and
derive only the fixed lookup data used by the restored engine.

Assisted-by: Codex:GPT-5.4

Changed paths:
  A engines/hopkins/base_data.cpp
  A engines/hopkins/base_data.h
  A engines/hopkins/base_types.h
    engines/hopkins/module.mk


diff --git a/engines/hopkins/base_data.cpp b/engines/hopkins/base_data.cpp
new file mode 100644
index 00000000000..b99ae837b8d
--- /dev/null
+++ b/engines/hopkins/base_data.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/>.
+ *
+ */
+
+// Resource loaders and fixed-point lookup tables for Hopkins WBASE.
+
+#include "hopkins/base_data.h"
+
+#include "common/algorithm.h"
+#include "common/endian.h"
+#include "common/file.h"
+#include "common/stream.h"
+#include "common/util.h"
+
+namespace Hopkins {
+
+namespace {
+
+struct BitmapResource {
+	uint16 id;
+	const char *filename;
+};
+
+static const BitmapResource kWallResources[] = {
+	{ 1,  "MUR1.BBM" },
+	{ 2,  "MUR2.BBM" },
+	{ 3,  "MUR3.BBM" },
+	{ 4,  "PLAFOND.BBM" },
+	{ 5,  "SOL.BBM" },
+	{ 6,  "MUR4.BBM" },
+	{ 7,  "MUR5.BBM" },
+	{ 8,  "MUR6.BBM" },
+	{ 9,  "MUR7.BBM" },
+	{ 10, "MUR8.BBM" },
+	{ 11, "MUR9.BBM" },
+	{ 12, "MUR10.BBM" },
+	{ 13, "MUR11.BBM" },
+	{ 14, "MUR12.BBM" },
+	{ 15, "MUR13.BBM" },
+	{ 16, "PORTE3.BBM" },
+	{ 59, "MURS.BBM" },
+	{ 60, "PORTE2.BBM" },
+	{ 62, "PORTE2.BBM" }
+};
+
+static const BitmapResource kObjectResources[] = {
+	{ 1,  "H1.BBM" },
+	{ 2,  "H2.BBM" },
+	{ 3,  "H3.BBM" },
+	{ 4,  "H4.BBM" },
+	{ 5,  "TIRE1.BBM" },
+	{ 6,  "TIRE2.BBM" },
+	{ 7,  "FUITE1.BBM" },
+	{ 8,  "FUITE2.BBM" },
+	{ 9,  "FUITE3.BBM" },
+	{ 10, "FUITE4.BBM" },
+	{ 11, "MF1.BBM" },
+	{ 12, "MF2.BBM" },
+	{ 13, "MF3.BBM" },
+	{ 14, "MF4.BBM" },
+	{ 15, "MF5.BBM" },
+	{ 16, "MF6.BBM" }
+};
+
+static const char *const kRequiredResources[] = {
+	"BASE.MAP", "BASE.PAL", "INFO.DAT", "MAIN.SPR", "BASEFONT.SPR",
+	"MUR1.BBM", "MUR2.BBM", "MUR3.BBM", "PLAFOND.BBM", "SOL.BBM",
+	"MUR4.BBM", "MUR5.BBM", "MUR6.BBM", "MUR7.BBM", "MUR8.BBM",
+	"MUR9.BBM", "MUR10.BBM", "MUR11.BBM", "MUR12.BBM", "MUR13.BBM",
+	"PORTE3.BBM", "MURS.BBM", "PORTE2.BBM",
+	"H1.BBM", "H2.BBM", "H3.BBM", "H4.BBM", "TIRE1.BBM", "TIRE2.BBM",
+	"FUITE1.BBM", "FUITE2.BBM", "FUITE3.BBM", "FUITE4.BBM",
+	"MF1.BBM", "MF2.BBM", "MF3.BBM", "MF4.BBM", "MF5.BBM", "MF6.BBM"
+};
+
+static const char *const kAudioResources[] = {
+	"SOUND54.WAV", "SOUND40.WAV", "SOUND53.WAV", "SOUND55.WAV"
+};
+
+static const uint32 kTagFORM = MKTAG('F', 'O', 'R', 'M');
+static const uint32 kTagPBM = MKTAG('P', 'B', 'M', ' ');
+static const uint32 kTagBMHD = MKTAG('B', 'M', 'H', 'D');
+static const uint32 kTagBODY = MKTAG('B', 'O', 'D', 'Y');
+
+static bool appendMissing(const char *filename, Common::String *missingResources) {
+	if (Common::File::exists(Common::Path(filename)))
+		return false;
+	if (missingResources) {
+		if (!missingResources->empty())
+			*missingResources += ", ";
+		*missingResources += filename;
+	}
+	return true;
+}
+
+static bool readExact(Common::SeekableReadStream &stream, void *dest, uint32 size) {
+	return size == 0 || stream.read(dest, size) == size;
+}
+
+static bool decodeByteRun1(const Common::Array<byte> &source, uint32 expectedSize, Common::Array<byte> &dest) {
+	dest.clear();
+	dest.reserve(expectedSize);
+	uint sourcePos = 0;
+	while (sourcePos < source.size() && dest.size() < expectedSize) {
+		const int8 control = (int8)source[sourcePos++];
+		if (control >= 0) {
+			const uint count = (uint)control + 1;
+			if (sourcePos + count > source.size() || dest.size() + count > expectedSize)
+				return false;
+			for (uint i = 0; i < count; ++i)
+				dest.push_back(source[sourcePos++]);
+		} else if (control != -128) {
+			const uint count = (uint)(1 - control);
+			if (sourcePos >= source.size() || dest.size() + count > expectedSize)
+				return false;
+			const byte value = source[sourcePos++];
+			for (uint i = 0; i < count; ++i)
+				dest.push_back(value);
+		}
+	}
+	return dest.size() == expectedSize;
+}
+
+} // End of anonymous namespace
+
+BaseData::BaseData() {
+	Common::fill(_map, _map + ARRAYSIZE(_map), 0);
+	Common::fill(_objectMap, _objectMap + ARRAYSIZE(_objectMap), 0);
+	Common::fill(_palette, _palette + ARRAYSIZE(_palette), 0);
+	Common::fill(_viewCos, _viewCos + ARRAYSIZE(_viewCos), 0);
+	Common::fill(_floorCos, _floorCos + ARRAYSIZE(_floorCos), 0);
+	Common::fill(_distanceHeight, _distanceHeight + ARRAYSIZE(_distanceHeight), 0);
+	Common::fill(_adjust, _adjust + ARRAYSIZE(_adjust), 0);
+	_wallBitmaps.resize(256);
+	_objectBitmaps.resize(256);
+}
+
+bool BaseData::hasRequiredResources(Common::String *missingResources) {
+	bool missing = false;
+	if (missingResources)
+		missingResources->clear();
+	for (uint i = 0; i < ARRAYSIZE(kRequiredResources); ++i)
+		missing |= appendMissing(kRequiredResources[i], missingResources);
+	return !missing;
+}
+
+void BaseData::appendAudioResourceReport(Common::String &missingResources) {
+	for (uint i = 0; i < ARRAYSIZE(kAudioResources); ++i)
+		appendMissing(kAudioResources[i], &missingResources);
+}
+
+bool BaseData::load(Common::String &errorMessage) {
+	errorMessage.clear();
+	if (!loadMap(errorMessage) || !loadPalette(errorMessage) || !loadInfo(errorMessage) ||
+			!loadBitmaps(errorMessage))
+		return false;
+	buildDerivedTables();
+	buildShadeTable();
+	return true;
+}
+
+bool BaseData::loadMap(Common::String &errorMessage) {
+	Common::File file;
+	if (!file.open(Common::Path("BASE.MAP"))) {
+		errorMessage = "BASE.MAP not found";
+		return false;
+	}
+	if (file.size() != kBaseMapCellCount * 4) {
+		errorMessage = Common::String::format("BASE.MAP has unexpected size %u", (uint)file.size());
+		return false;
+	}
+	for (int i = 0; i < kBaseMapCellCount; ++i)
+		_map[i] = file.readUint16LE();
+	for (int i = 0; i < kBaseMapCellCount; ++i)
+		_objectMap[i] = file.readUint16LE();
+	if (file.err()) {
+		errorMessage = "BASE.MAP is truncated";
+		return false;
+	}
+	return true;
+}
+
+bool BaseData::loadPalette(Common::String &errorMessage) {
+	Common::File file;
+	if (!file.open(Common::Path("BASE.PAL"))) {
+		errorMessage = "BASE.PAL not found";
+		return false;
+	}
+	if (file.size() != sizeof(_palette) || !readExact(file, _palette, sizeof(_palette))) {
+		errorMessage = "BASE.PAL must contain exactly 768 bytes";
+		return false;
+	}
+	return true;
+}
+
+bool BaseData::loadInfo(Common::String &errorMessage) {
+	Common::File file;
+	if (!file.open(Common::Path("INFO.DAT"))) {
+		errorMessage = "INFO.DAT not found";
+		return false;
+	}
+	const uint32 expected = 7 * kBaseAngleCount * sizeof(int32);
+	if (file.size() != expected) {
+		errorMessage = Common::String::format("INFO.DAT has size %u; expected %u", (uint)file.size(), expected);
+		return false;
+	}
+	for (uint table = 0; table < ARRAYSIZE(_trig); ++table) {
+		_trig[table].resize(kBaseAngleCount);
+		for (int angle = 0; angle < kBaseAngleCount; ++angle)
+			_trig[table][angle] = file.readSint32LE();
+	}
+	return !file.err();
+}
+
+bool BaseData::loadBitmaps(Common::String &errorMessage) {
+	for (uint i = 0; i < ARRAYSIZE(kWallResources); ++i) {
+		if (!loadBbm(Common::Path(kWallResources[i].filename), _wallBitmaps[kWallResources[i].id], errorMessage))
+			return false;
+	}
+	for (uint i = 0; i < ARRAYSIZE(kObjectResources); ++i) {
+		if (!loadBbm(Common::Path(kObjectResources[i].filename), _objectBitmaps[kObjectResources[i].id], errorMessage))
+			return false;
+	}
+	if (!loadSpr(Common::Path("MAIN.SPR"), _weaponFrames, errorMessage))
+		return false;
+	if (!loadSpr(Common::Path("BASEFONT.SPR"), _fontFrames, errorMessage))
+		return false;
+	if (_weaponFrames.size() != 2 || _fontFrames.size() != 10) {
+		errorMessage = "Unexpected MAIN.SPR or BASEFONT.SPR frame count";
+		return false;
+	}
+	return true;
+}
+
+bool BaseData::loadBbm(const Common::Path &filename, BaseBitmap &bitmap, Common::String &errorMessage) {
+	Common::File file;
+	if (!file.open(filename)) {
+		errorMessage = Common::String::format("%s not found", filename.toString(Common::Path::kNativeSeparator).c_str());
+		return false;
+	}
+	if (file.size() < 12 || file.readUint32BE() != kTagFORM) {
+		errorMessage = Common::String::format("%s is not an IFF FORM", filename.toString().c_str());
+		return false;
+	}
+	const uint32 formSize = file.readUint32BE();
+	if (file.readUint32BE() != kTagPBM) {
+		errorMessage = Common::String::format("%s is not an IFF PBM", filename.toString().c_str());
+		return false;
+	}
+
+	uint16 width = 0;
+	uint16 height = 0;
+	byte compression = 0xff;
+	Common::Array<byte> body;
+	const uint32 formEnd = MIN((uint32)file.size(), formSize + 8);
+	while ((uint32)file.pos() + 8 <= formEnd) {
+		const uint32 tag = file.readUint32BE();
+		const uint32 chunkSize = file.readUint32BE();
+		if ((uint32)file.pos() + chunkSize > formEnd) {
+			errorMessage = Common::String::format("%s contains a truncated IFF chunk", filename.toString().c_str());
+			return false;
+		}
+		if (tag == kTagBMHD) {
+			if (chunkSize < 20) {
+				errorMessage = Common::String::format("%s has a short BMHD", filename.toString().c_str());
+				return false;
+			}
+			width = file.readUint16BE();
+			height = file.readUint16BE();
+			file.skip(6);
+			compression = file.readByte();
+			file.skip(chunkSize - 11);
+		} else if (tag == kTagBODY) {
+			body.resize(chunkSize);
+			if (!readExact(file, body.begin(), chunkSize)) {
+				errorMessage = Common::String::format("%s has a truncated BODY", filename.toString().c_str());
+				return false;
+			}
+		} else {
+			file.skip(chunkSize);
+		}
+		if (chunkSize & 1)
+			file.skip(1);
+	}
+
+	if (width != 64 || height != 64 || body.empty()) {
+		errorMessage = Common::String::format("%s is not a 64x64 ACK bitmap", filename.toString().c_str());
+		return false;
+	}
+	Common::Array<byte> rowMajor;
+	const uint32 expected = (uint32)width * height;
+	if (compression == 0) {
+		if (body.size() < expected) {
+			errorMessage = Common::String::format("%s has a short uncompressed BODY", filename.toString().c_str());
+			return false;
+		}
+		rowMajor.resize(expected);
+		Common::copy(body.begin(), body.begin() + expected, rowMajor.begin());
+	} else if (compression == 1) {
+		if (!decodeByteRun1(body, expected, rowMajor)) {
+			errorMessage = Common::String::format("%s has invalid ByteRun1 data", filename.toString().c_str());
+			return false;
+		}
+	} else {
+		errorMessage = Common::String::format("%s uses unsupported IFF compression %u", filename.toString().c_str(), compression);
+		return false;
+	}
+
+	// AckReadiff transposes PBM scanlines into contiguous texture columns.
+	bitmap.width = width;
+	bitmap.height = height;
+	bitmap.columnMajor = true;
+	bitmap.pixels.resize(expected);
+	bitmap.blankColumns.resize(width);
+	for (uint x = 0; x < width; ++x) {
+		bool blank = true;
+		for (uint y = 0; y < height; ++y) {
+			const byte pixel = rowMajor[y * width + x];
+			bitmap.pixels[x * height + y] = pixel;
+			blank &= pixel == 0;
+		}
+		bitmap.blankColumns[x] = blank ? 1 : 0;
+	}
+	return true;
+}
+
+bool BaseData::loadSpr(const Common::Path &filename, Common::Array<BaseBitmap> &frames, Common::String &errorMessage) {
+	Common::File file;
+	if (!file.open(filename)) {
+		errorMessage = Common::String::format("%s not found", filename.toString().c_str());
+		return false;
+	}
+	frames.clear();
+	while (file.pos() < file.size()) {
+		if (file.size() - file.pos() < 6) {
+			errorMessage = Common::String::format("%s has a partial sprite header", filename.toString().c_str());
+			return false;
+		}
+		const uint16 pixelCount = file.readUint16LE();
+		const uint16 width = file.readUint16LE();
+		const uint16 height = file.readUint16LE();
+		if (!width || !height || pixelCount != (uint32)width * height || file.size() - file.pos() < pixelCount) {
+			errorMessage = Common::String::format("%s has an invalid sprite frame", filename.toString().c_str());
+			return false;
+		}
+		BaseBitmap frame;
+		frame.width = width;
+		frame.height = height;
+		frame.columnMajor = false;
+		frame.pixels.resize(pixelCount);
+		if (!readExact(file, frame.pixels.begin(), pixelCount))
+			return false;
+		frames.push_back(frame);
+	}
+	return !frames.empty();
+}
+
+void BaseData::buildDerivedTables() {
+	// The public ACK code repairs tangent singularities; Hopkins uses the same
+	// layout with quarter turns at 480, 960 and 1440.
+	for (int angle = kBaseQuarterTurn; angle <= kBaseThreeQuarterTurn; angle += kBaseQuarterTurn) {
+		_trig[2][angle] = _trig[2][angle + 1];
+		_trig[3][angle] = _trig[3][angle + 1];
+	}
+
+	_xNext.resize(kBaseAngleCount);
+	_yNext.resize(kBaseAngleCount);
+	for (int angle = 0; angle < kBaseAngleCount; ++angle) {
+		// ACK performs these multiplies in 32-bit Watcom arithmetic.  The
+		// tangent entries adjacent to a singularity intentionally wrap; use an
+		// unsigned shift so the modern C++ port has the same defined result.
+		_yNext[angle] = (int32)((uint32)_trig[2][angle] << kBaseCellShift);
+		_xNext[angle] = (int32)((uint32)_trig[3][angle] << kBaseCellShift);
+	}
+
+	int cameraAngle = kBaseAngleHalfFov;
+	int direction = -1;
+	for (int column = 0; column < kBaseViewWidth; ++column) {
+		_viewCos[column] = _trig[6][cameraAngle];
+		_floorCos[column] = _trig[4][cameraAngle] >> 6;
+		cameraAngle += direction;
+		if (cameraAngle <= 0) {
+			cameraAngle = -cameraAngle;
+			direction = -direction;
+		}
+	}
+	_floorCos[kBaseViewWidth] = _floorCos[kBaseViewWidth - 1];
+
+	// Preserve the two differently scaled inverse tables used by ACK rays.
+	for (int angle = 0; angle < kBaseAngleCount; ++angle) {
+		_trig[4][angle] >>= 4;
+		_trig[5][angle] >>= 6;
+	}
+
+	const int32 heightScale = kBaseCellSize * 128;
+	_distanceHeight[0] = kBaseMaximumWallHeight;
+	_adjust[0] = 4194304L / heightScale;
+	for (int distance = 1; distance < kBaseMaximumDistance; ++distance) {
+		int height = heightScale / distance;
+		if (heightScale - height * distance > distance / 2)
+			++height;
+		height = CLIP(height, kBaseMinimumWallHeight, kBaseMaximumWallHeight);
+		_distanceHeight[distance] = height;
+		_adjust[distance] = 2097152L / height;
+	}
+	_distanceHeight[kBaseMaximumDistance] = kBaseMinimumWallHeight;
+	_adjust[kBaseMaximumDistance] = _adjust[kBaseMaximumDistance - 1];
+}
+
+void BaseData::buildShadeTable() {
+	static const byte ranges[][2] = {
+		{ 32, 16 }, { 48, 16 }, { 64, 16 }, { 80, 16 },
+		{ 96, 8 }, { 104, 8 }, { 112, 8 }, { 120, 8 },
+		{ 128, 8 }, { 136, 8 }, { 144, 8 }, { 152, 8 },
+		{ 160, 8 }, { 168, 8 }, { 176, 8 }, { 184, 8 },
+		{ 192, 16 }, { 208, 16 }, { 224, 8 }, { 232, 8 }
+	};
+
+	for (int level = 0; level < 16; ++level) {
+		for (int color = 0; color < 256; ++color)
+			_shadeTable[level][color] = (byte)color;
+
+		for (uint range = 0; range < ARRAYSIZE(ranges); ++range) {
+			const int first = ranges[range][0];
+			const int end = first + ranges[range][1];
+			for (int color = first; color < end; ++color)
+				_shadeTable[level][color] = color + level < end ? (byte)(color + level) : 0;
+		}
+	}
+}
+
+uint16 BaseData::mapCodeAt(int mapPos) const {
+	return mapPos >= 0 && mapPos < kBaseMapCellCount ? _map[mapPos] : 0;
+}
+
+uint16 BaseData::mapCodeXY(int x, int y) const {
+	return x >= 0 && x < kBaseMapWidth && y >= 0 && y < kBaseMapHeight ? _map[baseMapIndex(x, y)] : 0;
+}
+
+uint16 BaseData::objectCodeAt(int mapPos) const {
+	return mapPos >= 0 && mapPos < kBaseMapCellCount ? _objectMap[mapPos] : 0;
+}
+
+const BaseBitmap &BaseData::wallBitmap(uint id) const {
+	static const BaseBitmap empty;
+	return id < _wallBitmaps.size() ? _wallBitmaps[id] : empty;
+}
+
+const BaseBitmap &BaseData::objectBitmap(uint id) const {
+	static const BaseBitmap empty;
+	return id < _objectBitmaps.size() ? _objectBitmaps[id] : empty;
+}
+
+const BaseBitmap &BaseData::weaponFrame(uint id) const {
+	static const BaseBitmap empty;
+	return id < _weaponFrames.size() ? _weaponFrames[id] : empty;
+}
+
+const BaseBitmap &BaseData::fontFrame(uint id) const {
+	static const BaseBitmap empty;
+	return id < _fontFrames.size() ? _fontFrames[id] : empty;
+}
+
+int32 BaseData::sinQ16(int angle) const { return _trig[0][normalizeBaseAngle(angle)]; }
+int32 BaseData::cosQ16(int angle) const { return _trig[1][normalizeBaseAngle(angle)]; }
+int32 BaseData::longTanQ16(int angle) const { return _trig[2][normalizeBaseAngle(angle)]; }
+int32 BaseData::longInvTanQ16(int angle) const { return _trig[3][normalizeBaseAngle(angle)]; }
+int32 BaseData::invCos(int angle) const { return _trig[4][normalizeBaseAngle(angle)]; }
+int32 BaseData::invSin(int angle) const { return _trig[5][normalizeBaseAngle(angle)]; }
+int32 BaseData::longCosQ16(int angle) const { return _trig[6][normalizeBaseAngle(angle)]; }
+int32 BaseData::xNextQ16(int angle) const { return _xNext[normalizeBaseAngle(angle)]; }
+int32 BaseData::yNextQ16(int angle) const { return _yNext[normalizeBaseAngle(angle)]; }
+int32 BaseData::viewCosQ16(int column) const { return _viewCos[CLIP(column, 0, kBaseViewWidth - 1)]; }
+int32 BaseData::floorCos(int column) const { return _floorCos[CLIP(column, 0, kBaseViewWidth)]; }
+int16 BaseData::distanceHeight(int distance) const { return _distanceHeight[CLIP(distance, 0, kBaseMaximumDistance)]; }
+int32 BaseData::adjustTable(int distance) const { return _adjust[CLIP(distance, 0, kBaseMaximumDistance)]; }
+byte BaseData::shadedColor(int level, byte color) const { return _shadeTable[CLIP(level, 0, 15)][color]; }
+
+} // End of namespace Hopkins
diff --git a/engines/hopkins/base_data.h b/engines/hopkins/base_data.h
new file mode 100644
index 00000000000..190752172b2
--- /dev/null
+++ b/engines/hopkins/base_data.h
@@ -0,0 +1,100 @@
+/* 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/>.
+ *
+ */
+
+// Immutable WBASE resource model.
+
+#ifndef HOPKINS_BASE_DATA_H
+#define HOPKINS_BASE_DATA_H
+
+#include "hopkins/base_types.h"
+
+#include "common/array.h"
+#include "common/path.h"
+#include "common/str.h"
+
+namespace Hopkins {
+
+class BaseData {
+public:
+	BaseData();
+
+	static bool hasRequiredResources(Common::String *missingResources = nullptr);
+	static void appendAudioResourceReport(Common::String &missingResources);
+
+	bool load(Common::String &errorMessage);
+
+	uint16 mapCodeAt(int mapPos) const;
+	uint16 mapCodeXY(int x, int y) const;
+	uint16 objectCodeAt(int mapPos) const;
+
+	const byte *palette() const { return _palette; }
+	const BaseBitmap &wallBitmap(uint id) const;
+	const BaseBitmap &objectBitmap(uint id) const;
+	const BaseBitmap &weaponFrame(uint id) const;
+	const BaseBitmap &fontFrame(uint id) const;
+
+	int32 sinQ16(int angle) const;
+	int32 cosQ16(int angle) const;
+	int32 longTanQ16(int angle) const;
+	int32 longInvTanQ16(int angle) const;
+	int32 invCos(int angle) const;
+	int32 invSin(int angle) const;
+	int32 longCosQ16(int angle) const;
+	int32 xNextQ16(int angle) const;
+	int32 yNextQ16(int angle) const;
+	int32 viewCosQ16(int column) const;
+	int32 floorCos(int column) const;
+	int16 distanceHeight(int distance) const;
+	int32 adjustTable(int distance) const;
+	byte shadedColor(int level, byte color) const;
+
+private:
+	bool loadMap(Common::String &errorMessage);
+	bool loadPalette(Common::String &errorMessage);
+	bool loadInfo(Common::String &errorMessage);
+	bool loadBitmaps(Common::String &errorMessage);
+	bool loadBbm(const Common::Path &filename, BaseBitmap &bitmap, Common::String &errorMessage);
+	bool loadSpr(const Common::Path &filename, Common::Array<BaseBitmap> &frames, Common::String &errorMessage);
+	void buildDerivedTables();
+	void buildShadeTable();
+
+	uint16 _map[kBaseMapCellCount];
+	uint16 _objectMap[kBaseMapCellCount];
+	byte _palette[256 * 3];
+	byte _shadeTable[16][256];
+
+	Common::Array<int32> _trig[7];
+	Common::Array<int32> _xNext;
+	Common::Array<int32> _yNext;
+	int32 _viewCos[kBaseViewWidth];
+	int32 _floorCos[kBaseViewWidth + 1];
+	int16 _distanceHeight[kBaseMaximumDistance + 1];
+	int32 _adjust[kBaseMaximumDistance + 1];
+
+	Common::Array<BaseBitmap> _wallBitmaps;
+	Common::Array<BaseBitmap> _objectBitmaps;
+	Common::Array<BaseBitmap> _weaponFrames;
+	Common::Array<BaseBitmap> _fontFrames;
+};
+
+} // End of namespace Hopkins
+
+#endif // HOPKINS_BASE_DATA_H
diff --git a/engines/hopkins/base_types.h b/engines/hopkins/base_types.h
new file mode 100644
index 00000000000..abc73d50aac
--- /dev/null
+++ b/engines/hopkins/base_types.h
@@ -0,0 +1,192 @@
+/* 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/>.
+ *
+ */
+
+// Hopkins-specific ACK data types and constants.
+
+#ifndef HOPKINS_BASE_TYPES_H
+#define HOPKINS_BASE_TYPES_H
+
+#include "common/array.h"
+#include "common/scummsys.h"
+
+namespace Hopkins {
+
+static const int kBaseMapWidth = 64;
+static const int kBaseMapHeight = 64;
+static const int kBaseMapCellCount = kBaseMapWidth * kBaseMapHeight;
+static const int kBaseAckGridArray = (kBaseMapWidth + 2) * (kBaseMapHeight + 2);
+static const int kBaseCellSize = 64;
+static const int kBaseCellShift = 6;
+static const int kBaseGridMask = 0xffc0;
+static const int kBaseWorldExtent = kBaseMapWidth * kBaseCellSize;
+
+static const int kBaseFixedShift = 16;
+static const int32 kBaseFixedOne = 1 << kBaseFixedShift;
+
+// Hopkins' ACK fork uses 1920 angular units per revolution, not 1800.
+static const int kBaseAngleCount = 1920;
+static const int kBaseAngleHalfFov = 160;
+static const int kBaseQuarterTurn = 480;
+static const int kBaseHalfTurn = 960;
+static const int kBaseThreeQuarterTurn = 1440;
+
+static const int kBaseFrameWidth = 320;
+static const int kBaseFrameHeight = 200;
+static const int kBaseViewWidth = 320;
+static const int kBaseViewHeight = 180;
+static const int kBaseViewHalfWidth = 160;
+static const int kBaseHorizon = 90;
+static const int kBaseMaximumDistance = 2048;
+static const int kBaseMinimumWallHeight = 8;
+static const int kBaseMaximumWallHeight = 960;
+
+static const int kBaseMaxObjects = 60;
+static const int kBaseMaxDoors = 20;
+
+static const uint16 kBaseWallTransparent = 0x0800;
+static const uint16 kBaseWallMulti = 0x0400;
+static const uint16 kBaseWallUpper = 0x0200;
+static const uint16 kBaseWallPass = 0x0100;
+
+static const uint16 kBaseDoorSecret = 0x8000;
+static const uint16 kBaseDoorLocked = 0x4000;
+static const uint16 kBaseDoorSlide = 0x2000;
+static const uint16 kBaseDoorSplit = 0x1000;
+static const uint16 kBaseDoorOpening = 0x0080;
+static const uint16 kBaseDoorClosing = 0x0040;
+static const uint16 kBaseDoorXCode = 60;
+static const uint16 kBaseDoorSideCode = 59;
+static const uint16 kBaseDoorYCode = 62;
+static const uint16 kBaseExitBitmap = 15;
+
+inline int normalizeBaseAngle(int angle) {
+	angle %= kBaseAngleCount;
+	if (angle < 0)
+		angle += kBaseAngleCount;
+	return angle;
+}
+
+inline int baseMapIndex(int x, int y) {
+	return y * kBaseMapWidth + x;
+}
+
+inline int baseWorldMapIndex(int x, int y) {
+	return (y & kBaseGridMask) + (x >> kBaseCellShift);
+}
+
+enum BaseObjectMode {
+	kBaseObjectChase = 1,
+	kBaseObjectFiring = 2,
+	kBaseObjectFlee = 3,
+	kBaseObjectDying = 4,
+	kBaseObjectDead = 5
+};
+
+enum BaseRayAxis {
+	kBaseRayNone = 0,
+	kBaseRayX = 1,
+	kBaseRayY = 2
+};
+
+struct BaseBitmap {
+	uint16 width;
+	uint16 height;
+	bool columnMajor;
+	Common::Array<byte> pixels;
+	Common::Array<byte> blankColumns;
+
+	BaseBitmap() : width(0), height(0), columnMajor(false) {}
+
+	bool valid() const {
+		return width && height && pixels.size() == (uint32)width * height;
+	}
+
+	byte sample(uint x, uint y) const {
+		if (!valid())
+			return 0;
+		x %= width;
+		y %= height;
+		return columnMajor ? pixels[x * height + y] : pixels[y * width + x];
+	}
+};
+
+struct BaseDoor {
+	int16 mPos;
+	int16 mPos1;
+	uint16 mCode;
+	uint16 mCode1;
+	int16 offset;
+	int8 speed;
+	byte type;
+	uint16 flags;
+
+	BaseDoor() : mPos(-1), mPos1(-1), mCode(0), mCode1(0), offset(0),
+		speed(0), type(0), flags(0) {}
+};
+
+struct BaseObject {
+	bool active;
+	bool passable;
+	int16 direction;
+	int16 x;
+	int16 y;
+	int16 mapPos;
+	byte id;
+	byte bitmap;
+	byte animationTick;
+	byte mode;
+	int16 timer;
+	int16 oldX;
+	int16 oldY;
+
+	BaseObject() : active(false), passable(false), direction(0), x(0), y(0),
+		mapPos(0), id(0), bitmap(1), animationTick(0), mode(kBaseObjectChase),
+		timer(0), oldX(0), oldY(0) {}
+};
+
+struct BaseRayHit {
+	bool hit;
+	BaseRayAxis axis;
+	uint16 code;
+	int mapPos;
+	int textureColumn;
+	int32 distance;
+	int32 rawDistance;
+	int32 worldX;
+	int32 worldY;
+
+	BaseRayHit() : hit(false), axis(kBaseRayNone), code(0), mapPos(-1),
+		textureColumn(0), distance(kBaseMaximumDistance - 1),
+		rawDistance(0x7fffffff), worldX(0), worldY(0) {}
+};
+
+struct BaseEntryPoint {
+	int entryId;
+	int16 playerX;
+	int16 playerY;
+	int16 playerAngle;
+	int mapIndex;
+	int returnId;
+};
+
+} // End of namespace Hopkins
+
+#endif // HOPKINS_BASE_TYPES_H
diff --git a/engines/hopkins/module.mk b/engines/hopkins/module.mk
index 2110a45e2c4..a2f71d6e4d9 100644
--- a/engines/hopkins/module.mk
+++ b/engines/hopkins/module.mk
@@ -2,6 +2,7 @@ MODULE := engines/hopkins
 
 MODULE_OBJS := \
 	anim.o \
+	base_data.o \
 	computer.o \
 	debugger.o \
 	dialogs.o \


Commit: 42ae7342018ac6108a96815afa8495881644a3ff
    https://github.com/scummvm/scummvm/commit/42ae7342018ac6108a96815afa8495881644a3ff
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: WBASE: Add the recovered simulation

Translate the recovered Hopkins WBASE gameplay model into an
engine-local deterministic 24 Hz simulation. It covers ACK X/Y edge
grids, movement and collision, split doors, the guard state machine,
player and guard hitscan, damage, weapon timing, typed results, and
queued sound events.

Assisted-by: Codex:GPT-5.4

Changed paths:
  A engines/hopkins/base_engine.cpp
  A engines/hopkins/base_engine.h
    engines/hopkins/module.mk


diff --git a/engines/hopkins/base_engine.cpp b/engines/hopkins/base_engine.cpp
new file mode 100644
index 00000000000..aa4fef81c7a
--- /dev/null
+++ b/engines/hopkins/base_engine.cpp
@@ -0,0 +1,1167 @@
+/* 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/>.
+ *
+ */
+
+// Source-backed Hopkins WBASE simulation and ACK-style ray traversal.
+
+#include "hopkins/base_engine.h"
+
+#include "common/algorithm.h"
+#include "common/util.h"
+
+namespace Hopkins {
+
+namespace {
+
+enum AckMoveResult {
+	kAckNothing = 0,
+	kAckXWall = 1,
+	kAckYWall = 2,
+	kAckObject = 3,
+	kAckPlayer = 4,
+	kAckSlideX = 5,
+	kAckSlideY = 6
+};
+
+static const int kPlayerMoveAmount = 12;
+static const int kGuardMoveAmount = 5;
+static const int kAckCollisionDistance = 28;
+static const int kDoorSpeed = 4;
+static const int kDoorMaximumOffset = 0xa0;
+static const int kWeaponStartCounter = 8;
+static const int kDeathExit = 152;
+
+static int iabs(int value) {
+	return value < 0 ? -value : value;
+}
+
+static int imax(int a, int b) {
+	return a > b ? a : b;
+}
+
+static uint16 roundedIntegerSquareRoot(uint32 value) {
+	if (value <= 1)
+		return (uint16)value;
+
+	uint32 remainingBits = value;
+	uint32 remainder = 0;
+	uint16 result = 0;
+	for (int bitPair = 0; bitPair < 16; ++bitPair) {
+		result += result;
+		remainder = (remainder << 2) | ((remainingBits >> 30) & 3);
+		remainingBits <<= 2;
+		const uint16 trial = result + result + 1;
+		if (remainder >= trial) {
+			++result;
+			remainder -= trial;
+		}
+	}
+
+	// Hopkins adds this rounding step after ACK's 16-pass integer root.
+	// Preserve the executable's comparison rather than substituting a
+	// floating-point or conventional nearest-integer square root.
+	const uint32 squareRemainder = value - (uint32)result * result;
+	if (squareRemainder >= (uint32)(result - 1))
+		++result;
+	return result;
+}
+
+} // End of anonymous namespace
+
+BaseEngine::BaseEngine(const BaseData &data) :
+		_data(data), _entry(nullptr), _playerX(0), _playerY(0), _playerAngle(0),
+		_health(1000), _weaponCounter(0), _texturesEnabled(true), _lastObjectHit(0),
+		_turnRamp(0), _pendingTurn(0) {
+	Common::fill(_xGrid, _xGrid + ARRAYSIZE(_xGrid), 0);
+	Common::fill(_yGrid, _yGrid + ARRAYSIZE(_yGrid), 0);
+}
+
+void BaseEngine::initialize(const BaseEntryPoint &entry) {
+	_entry = &entry;
+	_playerX = entry.playerX;
+	_playerY = entry.playerY;
+	_playerAngle = normalizeBaseAngle(entry.playerAngle);
+	_health = 1000;
+	_weaponCounter = 0;
+	_texturesEnabled = true;
+	_lastObjectHit = 0;
+	_turnRamp = 0;
+	_pendingTurn = 0;
+	_soundEvents.clear();
+
+	for (int i = 0; i < kBaseMaxDoors; ++i)
+		_doors[i] = BaseDoor();
+
+	buildGrid();
+	createObjects();
+}
+
+void BaseEngine::buildGrid() {
+	Common::fill(_xGrid, _xGrid + ARRAYSIZE(_xGrid), 0);
+	Common::fill(_yGrid, _yGrid + ARRAYSIZE(_yGrid), 0);
+
+	for (int y = 0; y < kBaseMapHeight; ++y) {
+		for (int x = 0; x < kBaseMapWidth; ++x) {
+			const int mapPos = y * kBaseMapWidth + x;
+			const uint16 code = _data.mapCodeAt(mapPos);
+			const byte lowCode = code & 0xff;
+
+			if (code == 0xfc) {
+				_playerX = x * kBaseCellSize + kBaseCellSize / 2;
+				_playerY = y * kBaseCellSize + kBaseCellSize / 2;
+				continue;
+			}
+			if (code == 0xfd || code == 0xfe || code == 0xff)
+				continue;
+			if (!code)
+				continue;
+
+			// This is the original ACK BuildAckGrid algorithm. A map square
+			// contributes its right/bottom edge and normally its left/top edge.
+			// Doors replace the perpendicular pair with the jamb texture (59).
+			if (lowCode == kBaseDoorYCode) {
+				_xGrid[mapPos] = kBaseDoorSideCode;
+				_xGrid[mapPos + 1] = kBaseDoorSideCode;
+			} else {
+				if (_xGrid[mapPos] != kBaseDoorSideCode)
+					_xGrid[mapPos] = code;
+				_xGrid[mapPos + 1] = code;
+			}
+
+			if (lowCode == kBaseDoorXCode) {
+				_yGrid[mapPos] = kBaseDoorSideCode;
+				_yGrid[mapPos + kBaseMapWidth] = kBaseDoorSideCode;
+			} else {
+				if (_yGrid[mapPos] != kBaseDoorSideCode)
+					_yGrid[mapPos] = code;
+				_yGrid[mapPos + kBaseMapWidth] = code;
+			}
+		}
+	}
+}
+
+void BaseEngine::createObjects() {
+	for (int i = 0; i <= kBaseMaxObjects; ++i)
+		_objects[i] = BaseObject();
+
+	for (int mapPos = 0; mapPos < kBaseMapCellCount; ++mapPos) {
+		const int id = _data.objectCodeAt(mapPos) & 0x7f;
+		if (id < 1 || id > kBaseMaxObjects)
+			continue;
+
+		BaseObject &object = _objects[id];
+		object.active = true;
+		object.passable = false;
+		object.id = id;
+		object.x = (mapPos & 63) * kBaseCellSize + kBaseCellSize / 2;
+		object.y = (mapPos >> 6) * kBaseCellSize + kBaseCellSize / 2;
+		object.mapPos = mapPos;
+		object.bitmap = 1;
+		object.mode = kBaseObjectChase;
+		object.direction = 0;
+		object.timer = 0;
+		object.animationTick = 0;
+		object.oldX = object.x;
+		object.oldY = object.y;
+	}
+}
+
+int BaseEngine::tick(BaseInputState &input) {
+	_soundEvents.clear();
+
+	if (input.toggleTextures) {
+		_texturesEnabled = !_texturesEnabled;
+		input.toggleTextures = false;
+	}
+
+	updateTurn(input);
+	updatePlayer(input);
+	updateWeapon(input);
+
+	int exitResult = -1;
+	if (input.exitRequested) {
+		exitResult = tryExit();
+		input.exitRequested = false;
+		if (exitResult >= 94 && exitResult <= 99)
+			_soundEvents.push_back(kBaseSoundDoorOrExit);
+	}
+
+	updateGuards();
+
+	if (checkDoorOpen(_playerX, _playerY, _playerAngle) != 0)
+		_soundEvents.push_back(kBaseSoundDoorOrExit);
+	updateDoors();
+
+	if (_weaponCounter > 0)
+		--_weaponCounter;
+
+	if (_health < 10) {
+		_health = 1;
+		return kDeathExit;
+	}
+
+	return exitResult;
+}
+
+void BaseEngine::updateTurn(const BaseInputState &input) {
+	if (_turnRamp != 0) {
+		_turnRamp >>= 1;
+		_playerAngle = normalizeBaseAngle(_playerAngle + _pendingTurn);
+	}
+
+	if (input.turnRight) {
+		++_turnRamp;
+		_pendingTurn = _turnRamp * 20;
+	}
+	if (input.turnLeft) {
+		++_turnRamp;
+		_pendingTurn = -_turnRamp * 20;
+	}
+}
+
+void BaseEngine::updatePlayer(const BaseInputState &input) {
+	if (input.forward)
+		movePlayer(_playerAngle, kPlayerMoveAmount);
+	if (input.backward)
+		movePlayer(normalizeBaseAngle(_playerAngle + kBaseHalfTurn), kPlayerMoveAmount);
+}
+
+void BaseEngine::updateWeapon(const BaseInputState &input) {
+	if (input.fire && _weaponCounter == 0)
+		fireWeapon();
+}
+
+uint16 BaseEngine::getWallX(int mapPos) const {
+	if (mapPos < 0 || mapPos >= kBaseAckGridArray)
+		return 1;
+	const uint16 code = _xGrid[mapPos];
+	return (code & kBaseWallPass) ? 0 : code;
+}
+
+uint16 BaseEngine::getWallY(int mapPos) const {
+	if (mapPos < 0 || mapPos >= kBaseAckGridArray)
+		return 1;
+	const uint16 code = _yGrid[mapPos];
+	return (code & kBaseWallPass) ? 0 : code;
+}
+
+int BaseEngine::checkObjectPosition(int16 x, int16 y, int ignoredObject) const {
+	const int mapPos = baseWorldMapIndex(x, y);
+	for (int id = 1; id <= kBaseMaxObjects; ++id) {
+		const BaseObject &object = _objects[id];
+		if (!object.active || object.passable || id == ignoredObject)
+			continue;
+		if (object.mapPos == mapPos)
+			return id;
+	}
+	return 0;
+}
+
+int BaseEngine::moveWithAckCollision(int16 &x, int16 &y, int angle, int amount, int ignoredObject) const {
+	angle = normalizeBaseAngle(angle);
+	const int xp = x;
+	const int yp = y;
+	int x1 = xp + (int)(((int64)_data.cosQ16(angle) * amount) >> 16);
+	int y1 = yp + (int)(((int64)_data.sinQ16(angle) * amount) >> 16);
+	const int xLeft = xp & kBaseGridMask;
+	const int yTop = yp & kBaseGridMask;
+	const bool movingObject = ignoredObject != 0;
+	const int xRight = xLeft + kBaseCellSize - (movingObject ? 1 : 0);
+	const int yBottom = yTop + kBaseCellSize - (movingObject ? 1 : 0);
+	const int mapPos = yTop + (xp >> 6);
+	int result = kAckNothing;
+
+	// ACK checks object-vs-object overlap before applying edge-grid collision.
+	// Player movement performs the same check after wall sliding instead.
+	if (movingObject && checkObjectPosition((int16)x1, (int16)y1, ignoredObject))
+		return kAckObject;
+
+	if (x1 < xp && getWallX(mapPos) && (x1 < xLeft || iabs(x1 - xLeft) < kAckCollisionDistance)) {
+		x1 = xp;
+		result = kAckSlideX;
+	}
+	if (x1 > xp && getWallX(mapPos + 1) && (x1 > xRight || iabs(xRight - x1) < kAckCollisionDistance)) {
+		x1 = xp;
+		result = kAckSlideX;
+	}
+	if (y1 < yp && getWallY(mapPos) && (y1 < yTop || iabs(y1 - yTop) < kAckCollisionDistance)) {
+		y1 = yp;
+		result = kAckSlideY;
+	}
+	if (y1 > yp && getWallY(mapPos + kBaseMapWidth) && (y1 > yBottom || iabs(yBottom - y1) < kAckCollisionDistance)) {
+		y1 = yp;
+		result = kAckSlideY;
+	}
+
+	if (!result) {
+		uint16 wallX = 0;
+		uint16 wallY = 0;
+		if (y1 < yTop + 32) {
+			if (x1 < xLeft + 32) {
+				wallX = getWallX(mapPos - kBaseMapWidth);
+				wallY = getWallY(mapPos - 1);
+				if (wallX && y1 < yTop + 28 && x1 < xLeft + 28) {
+					if (xp > xLeft + 27) { x1 = xp; result = kAckSlideX; }
+					else { y1 = yp; result = kAckSlideY; }
+				}
+				if (wallY && x1 < xLeft + 28 && y1 < yTop + 28) {
+					if (yp > yTop + 27) { y1 = yp; result = kAckSlideY; }
+					else { x1 = xp; result = kAckSlideX; }
+				}
+			}
+			if (x1 > xRight - 32 && !result) {
+				wallX = getWallX(mapPos + 1 - kBaseMapWidth);
+				wallY = getWallY(mapPos + 1);
+				if (wallX && y1 < yTop + 28 && x1 > xRight - 28) {
+					if (xp < xRight - 27) { x1 = xp; result = kAckSlideX; }
+					else { y1 = yp; result = kAckSlideY; }
+				}
+				if (wallY && x1 > xRight - 28 && y1 < yTop + 28) {
+					if (yp > yTop + 27) { y1 = yp; result = kAckSlideY; }
+					else { x1 = xp; result = kAckSlideX; }
+				}
+			}
+		}
+
+		if (y1 > yTop + 32 && !result) {
+			if (x1 < xLeft + 32) {
+				wallX = getWallX(mapPos + kBaseMapWidth);
+				wallY = getWallY(mapPos - 1 + kBaseMapWidth);
+				if (wallX && y1 > yBottom - 28 && x1 < xLeft + 28) {
+					if (xp > xLeft + 27) { x1 = xp; result = kAckSlideX; }
+					else { y1 = yp; result = kAckSlideY; }
+				}
+				if (wallY && x1 < xLeft + 28 && y1 > yBottom - 28) {
+					if (yp < yBottom - 27) { y1 = yp; result = kAckSlideY; }
+					else { x1 = xp; result = kAckSlideX; }
+				}
+			}
+			if (x1 > xRight - 32 && !result) {
+				wallX = getWallX(mapPos + 1 + kBaseMapWidth);
+				wallY = getWallY(mapPos + 1 + kBaseMapWidth);
+				if (wallX && y1 > yBottom - 28 && x1 > xRight - 28) {
+					if (xp < xRight - 27) { x1 = xp; result = kAckSlideX; }
+					else { y1 = yp; result = kAckSlideY; }
+				}
+				if (wallY && x1 > xRight - 28 && y1 > yBottom - 28) {
+					if (yp < yBottom - 27) { y1 = yp; result = kAckSlideY; }
+					else { x1 = xp; result = kAckSlideX; }
+				}
+			}
+		}
+	}
+
+	if (!movingObject && checkObjectPosition((int16)x1, (int16)y1, 0))
+		return kAckObject;
+
+	if (result == kAckSlideX && y1 == yp)
+		result = kAckXWall;
+	if (result == kAckSlideY && x1 == xp)
+		result = kAckYWall;
+
+	x = (int16)x1;
+	y = (int16)y1;
+	return result;
+}
+
+int BaseEngine::movePlayer(int angle, int amount) {
+	int16 x = _playerX;
+	int16 y = _playerY;
+	const int result = moveWithAckCollision(x, y, angle, amount, 0);
+	if (result != kAckObject) {
+		_playerX = x;
+		_playerY = y;
+	}
+	return result;
+}
+
+int BaseEngine::moveObject(int objectId, int angle, int amount) {
+	if (objectId < 1 || objectId > kBaseMaxObjects || !_objects[objectId].active)
+		return kAckNothing;
+	BaseObject &object = _objects[objectId];
+	int16 x = object.x;
+	int16 y = object.y;
+	const int result = moveWithAckCollision(x, y, angle, amount, objectId);
+	if (result == kAckObject)
+		return result;
+	object.x = x;
+	object.y = y;
+	object.mapPos = baseWorldMapIndex(x, y);
+	if (object.mapPos == baseWorldMapIndex(_playerX, _playerY))
+		return kAckPlayer;
+	return result;
+}
+
+int BaseEngine::findDoor(int mapPos) const {
+	for (int i = 0; i < kBaseMaxDoors; ++i) {
+		if (_doors[i].mPos == mapPos || _doors[i].mPos1 == mapPos)
+			return i;
+	}
+	return -1;
+}
+
+int BaseEngine::findDoorSlot(int mapPos) const {
+	const int existing = findDoor(mapPos);
+	if (existing >= 0 && _doors[existing].offset)
+		return -1;
+	for (int i = 0; i < kBaseMaxDoors; ++i) {
+		if (_doors[i].mPos == -1)
+			return i;
+	}
+	return -1;
+}
+
+const BaseDoor *BaseEngine::doorForMapPosition(int mapPos) const {
+	const int index = findDoor(mapPos);
+	return index >= 0 ? &_doors[index] : nullptr;
+}
+
+BaseDoor *BaseEngine::doorForMapPosition(int mapPos) {
+	const int index = findDoor(mapPos);
+	return index >= 0 ? &_doors[index] : nullptr;
+}
+
+int BaseEngine::doorOffsetForMapPosition(int mapPos) const {
+	const BaseDoor *door = doorForMapPosition(mapPos);
+	return door ? door->offset : 0;
+}
+
+int BaseEngine::checkDoorOpen(int16 x, int16 y, int angle) {
+	angle = normalizeBaseAngle(angle);
+	if (angle == 240 || angle == 720 || angle == 1200 || angle == 1680)
+		angle = normalizeBaseAngle(angle + 1);
+
+	const BaseRayHit hit = castRayFrom(x, y, angle, kBaseViewHalfWidth);
+	if (!hit.hit)
+		return 0;
+	int checkDistance = 56;
+	if (hit.code & (kBaseDoorSlide | kBaseDoorSplit))
+		checkDistance += 64;
+	if (hit.distance > checkDistance)
+		return 0;
+
+	const byte lowCode = hit.code & 0xff;
+	if (!(hit.code & (kBaseDoorSlide | kBaseDoorSplit)))
+		return 0;
+	if (lowCode != kBaseDoorXCode && lowCode != kBaseDoorYCode)
+		return 0;
+
+	const int slot = findDoorSlot(hit.mapPos);
+	if (slot < 0)
+		return 0;
+	if (hit.code & kBaseDoorLocked)
+		return 0x80 | (hit.axis == kBaseRayX ? 1 : 2);
+
+	BaseDoor &door = _doors[slot];
+	door.mPos = hit.mapPos;
+	if (hit.axis == kBaseRayX)
+		door.mPos1 = (hit.worldX > x) ? hit.mapPos + 1 : hit.mapPos - 1;
+	else
+		door.mPos1 = (hit.worldY > y) ? hit.mapPos + kBaseMapWidth : hit.mapPos - kBaseMapWidth;
+	door.mCode = hit.axis == kBaseRayX ? _xGrid[door.mPos] : _yGrid[door.mPos];
+	door.mCode1 = hit.axis == kBaseRayX ? _xGrid[door.mPos1] : _yGrid[door.mPos1];
+	door.offset = 1;
+	door.speed = kDoorSpeed;
+	door.type = hit.axis == kBaseRayX ? kBaseDoorXCode : kBaseDoorYCode;
+	door.flags = kBaseDoorOpening;
+	return hit.axis == kBaseRayX ? 1 : 2;
+}
+
+void BaseEngine::updateDoors() {
+	checkDoors();
+}
+
+void BaseEngine::checkDoors() {
+	const int playerMapPos = baseWorldMapIndex(_playerX, _playerY);
+	for (int i = 0; i < kBaseMaxDoors; ++i) {
+		BaseDoor &door = _doors[i];
+		if (!door.offset)
+			continue;
+
+		door.offset += door.speed;
+		const int openColumn = (door.mCode & kBaseDoorSplit) ? 31 : 63;
+
+		// In ACKVIEW, the first ray through a fully open door leaves the two
+		// wall-grid entries at zero. Doing it here makes that renderer side
+		// effect deterministic and preserves collision behavior.
+		if (door.speed > 0 && door.offset >= openColumn) {
+			if (door.type == kBaseDoorXCode) {
+				_xGrid[door.mPos] = 0;
+				_xGrid[door.mPos1] = 0;
+			} else {
+				_yGrid[door.mPos] = 0;
+				_yGrid[door.mPos1] = 0;
+			}
+		}
+
+		if (door.speed < 1 && door.offset < 65) {
+			if (playerMapPos == door.mPos || playerMapPos == door.mPos1) {
+				door.offset -= door.speed;
+				continue;
+			}
+			if (door.type == kBaseDoorXCode) {
+				_xGrid[door.mPos] = door.mCode;
+				_xGrid[door.mPos1] = door.mCode1;
+			} else {
+				_yGrid[door.mPos] = door.mCode;
+				_yGrid[door.mPos1] = door.mCode1;
+			}
+			if (door.offset < 3)
+				door = BaseDoor();
+		}
+
+		if (door.offset > kDoorMaximumOffset) {
+			door.speed = -door.speed;
+			door.flags &= ~kBaseDoorOpening;
+			door.flags |= kBaseDoorClosing;
+		}
+	}
+}
+
+int BaseEngine::testGuardShotPosition(int16 x, int16 y) const {
+	const int mapPos = baseWorldMapIndex(x, y);
+	if (mapPos < 0 || mapPos >= kBaseMapCellCount)
+		return kAckXWall;
+
+	const byte lowCode = _data.mapCodeAt(mapPos) & 0xff;
+	if (lowCode != 0) {
+		if (lowCode != kBaseDoorXCode && lowCode != kBaseDoorYCode)
+			return kAckXWall;
+
+		const int doorIndex = findDoor(mapPos);
+		if (doorIndex < 0 || _doors[doorIndex].offset < 30)
+			return kAckXWall;
+	}
+
+	if (mapPos == baseWorldMapIndex(_playerX, _playerY))
+		return kAckPlayer;
+	return kAckNothing;
+}
+
+bool BaseEngine::guardCanShoot(int objectId) {
+	BaseObject &object = _objects[objectId];
+	const int xDistance = iabs(object.x - _playerX) + 1;
+	const int yDistance = iabs(object.y - _playerY) + 1;
+	int maximumDistance = imax(xDistance, yDistance);
+	if (maximumDistance <= 0)
+		return false;
+
+	int32 xStep = (xDistance * 1000) / maximumDistance;
+	int32 yStep = (yDistance * 1000) / maximumDistance;
+	int64 fixedX = (int64)object.x * 1000;
+	int64 fixedY = (int64)object.y * 1000;
+	if (object.x > _playerX)
+		xStep = -xStep;
+	if (object.y > _playerY)
+		yStep = -yStep;
+
+	maximumDistance = MIN(maximumDistance, 600);
+	const int sampleCount = maximumDistance / 20;
+	int hitSample = -1;
+	for (int sample = 0; sample <= sampleCount; ++sample) {
+		fixedX += (int64)xStep * 20;
+		fixedY += (int64)yStep * 20;
+		const int16 testX = (int16)(fixedX / 1000);
+		const int16 testY = (int16)(fixedY / 1000);
+		const int result = testGuardShotPosition(testX, testY);
+		if (result == kAckPlayer) {
+			hitSample = sample;
+			break;
+		}
+		if (result == kAckXWall)
+			break;
+	}
+
+	if (hitSample < 0)
+		return false;
+
+	object.bitmap = 5;
+	object.timer = 50;
+	object.mode = kBaseObjectFiring;
+	_soundEvents.push_back(kBaseSoundEnemyShot);
+
+	const int damageDistance = hitSample * 15;
+	if (damageDistance > 0 && damageDistance < 50)
+		_health -= 40;
+	else if (damageDistance > 50 && damageDistance < 150)
+		_health -= 30;
+	else if (damageDistance > 150 && damageDistance < 250)
+		_health -= 20;
+	else if (damageDistance > 250 && damageDistance < 400)
+		_health -= 15;
+	else if (damageDistance > 400)
+		_health -= 5;
+	return true;
+}
+
+void BaseEngine::updateGuards() {
+	for (int id = 1; id <= kBaseMaxObjects; ++id) {
+		if (_objects[id].active)
+			updateGuard(_objects[id]);
+	}
+}
+
+void BaseEngine::updateGuard(BaseObject &object) {
+	int absX = iabs(_playerX - object.x);
+	int absY = iabs(_playerY - object.y);
+	if (absX >= 900 || absY >= 900)
+		return;
+
+	if (object.mode == kBaseObjectFiring) {
+		--object.timer;
+		if (object.timer < 20) {
+			object.mode = kBaseObjectChase;
+			object.timer = 10;
+		}
+
+		switch (object.timer) {
+		case 50: case 46: case 42: case 38: case 34: case 30: case 26: case 22:
+			object.bitmap = 6;
+			break;
+		case 48: case 44: case 40: case 36: case 32: case 28: case 24: case 20:
+			object.bitmap = 5;
+			break;
+		default:
+			break;
+		}
+
+		if (object.mode == kBaseObjectFiring)
+			return;
+	}
+
+	if (object.mode == kBaseObjectDying) {
+		if (object.bitmap < 16)
+			++object.bitmap;
+		if (object.bitmap >= 16) {
+			object.bitmap = 16;
+			object.passable = true;
+			object.mode = kBaseObjectDead;
+		}
+		return;
+	}
+	if (object.mode == kBaseObjectDead)
+		return;
+
+	if (object.mode == kBaseObjectChase) {
+		if (object.timer > 0)
+			--object.timer;
+		if (absX < 500 && absY < 500 && object.timer == 0)
+			guardCanShoot(object.id);
+
+		object.animationTick = 0;
+		++object.bitmap;
+		if (object.bitmap >= 5)
+			object.bitmap = 1;
+
+		int result = kAckNothing;
+		if (absX < absY) {
+			if (absX < 24) {
+				object.x = _playerX;
+				object.mapPos = baseWorldMapIndex(object.x, object.y);
+				absX = 0;
+			}
+			if (absX > 24)
+				result = moveObject(object.id, object.x < _playerX ? 0 : 915, kGuardMoveAmount);
+			if (result != kAckNothing || absX < 24)
+				result = moveObject(object.id, object.y < _playerY ? 440 : 1440, kGuardMoveAmount);
+		} else {
+			if (absY < 24) {
+				object.y = _playerY;
+				object.mapPos = baseWorldMapIndex(object.x, object.y);
+				absY = 0;
+			}
+			if (absY > 24)
+				result = moveObject(object.id, object.y < _playerY ? 440 : 1440, kGuardMoveAmount);
+			if (result != kAckNothing || absY < 24)
+				result = moveObject(object.id, object.x < _playerX ? 0 : 915, kGuardMoveAmount);
+		}
+
+		if (result != kAckNothing || (absX < 100 && absY < 100))
+			object.mode = kBaseObjectFlee;
+		if (object.mode != kBaseObjectFlee)
+			return;
+	}
+
+	if (object.mode != kBaseObjectFlee)
+		return;
+
+	// A chase guard that switches to escape mode enters this block in the same
+	// update in DEPLACE_GARDE, after its chase movement. Recompute the deltas
+	// from that new position before choosing the escape axis.
+	absX = iabs(_playerX - object.x);
+	absY = iabs(_playerY - object.y);
+
+	// The original escape state recomputes the dominant axis every update.
+	object.animationTick = 0;
+	++object.bitmap;
+	if (object.bitmap <= 7 || object.bitmap >= 10)
+		object.bitmap = 7;
+
+	const int16 savedDirection = object.direction;
+	const int16 oldX = object.x;
+	const int16 oldY = object.y;
+	int result = kAckNothing;
+	if (absX < absY) {
+		result = moveObject(object.id, object.x + 24 > _playerX ? 0 : 915, kGuardMoveAmount);
+		if (result != kAckNothing)
+			result = moveObject(object.id, object.y > _playerY ? 440 : 1440, kGuardMoveAmount);
+	} else {
+		result = moveObject(object.id, object.y + 24 > _playerY ? 440 : 1440, kGuardMoveAmount);
+		if (result != kAckNothing)
+			result = moveObject(object.id, object.x > _playerX ? 0 : 915, kGuardMoveAmount);
+	}
+	object.direction = savedDirection;
+	if (result != kAckNothing)
+		object.mode = kBaseObjectChase;
+	object.oldX = oldX;
+	object.oldY = oldY;
+
+	if (absX < 600 && absY < 600 && (absX < 100 || absY < 100))
+		guardCanShoot(object.id);
+}
+
+int BaseEngine::checkObjectPositionShot(int16 x, int16 y, int angle) {
+	angle = normalizeBaseAngle(angle);
+	const int mapPos = baseWorldMapIndex(x, y);
+	const int32 cosine = _data.cosQ16(angle);
+	const int32 sine = _data.sinQ16(angle);
+	const int32 halfFovTangent = _data.longTanQ16(kBaseAngleHalfFov);
+	int bestMetric = 0x2dc6c0;
+	int bestObject = 0;
+
+	for (int id = 1; id <= kBaseMaxObjects; ++id) {
+		const BaseObject &object = _objects[id];
+		if (!object.active || object.mode > kBaseObjectFlee || object.passable || object.mapPos != mapPos)
+			continue;
+
+		int deltaX = object.x - x;
+		int deltaY = object.y - y;
+		if (angle > kBaseHalfTurn && deltaY > 63)
+			continue;
+		if (angle < kBaseHalfTurn && deltaY < -63)
+			continue;
+		if ((angle > kBaseThreeQuarterTurn || angle < kBaseQuarterTurn) && deltaX < -63)
+			continue;
+		if (angle > kBaseQuarterTurn && angle < kBaseThreeQuarterTurn && deltaX > 63)
+			continue;
+		if ((angle == 0 || angle == kBaseHalfTurn) && deltaX == 0)
+			continue;
+		if ((angle == kBaseQuarterTurn || angle == kBaseThreeQuarterTurn) && deltaY == 0)
+			continue;
+
+		const int32 forward = (int32)(((int64)deltaX * cosine + (int64)deltaY * sine) >> 16);
+		int32 lateral = (int32)(((int64)deltaY * cosine - (int64)deltaX * sine) >> 16);
+		int32 boundary = (int32)(((int64)halfFovTangent * forward) >> 16);
+
+		// This odd comparison is present in the Hopkins executable. With normal
+		// positive world coordinates it negates both values for every candidate.
+		if (deltaY < object.y) {
+			boundary = -boundary;
+			lateral = -lateral;
+		}
+		if (lateral + 32 < boundary)
+			continue;
+
+		deltaX = iabs(deltaX);
+		deltaY = iabs(deltaY);
+		const int metric = deltaX + deltaY - MIN(deltaX, deltaY) / 2;
+		if (metric > kBaseMaximumDistance || metric >= bestMetric)
+			continue;
+
+		bestMetric = metric;
+		bestObject = id;
+	}
+
+	_lastObjectHit = bestObject;
+	return bestObject ? kAckObject : kAckNothing;
+}
+
+bool BaseEngine::testWall(int mapPos) const {
+	if (mapPos < 0 || mapPos >= kBaseMapCellCount)
+		return true;
+	const uint16 code = _data.mapCodeAt(mapPos);
+	const byte low = code & 0xff;
+	return low > 0 && low < 60 && !(code & kBaseDoorSecret);
+}
+
+int BaseEngine::checkHitAt(int16 x, int16 y, int angle) const {
+	angle = normalizeBaseAngle(angle);
+	if (angle == 240 || angle == 720 || angle == 1200 || angle == 1680)
+		angle = normalizeBaseAngle(angle + 1);
+
+	// AckCheckHit casts and filters the two axes independently. In
+	// particular, an upper/pass X wall must not hide a solid Y wall (or vice
+	// versa) before the nearest surviving hit is selected.
+	BaseRayHit xHit;
+	BaseRayHit yHit;
+	if (angle != kBaseQuarterTurn && angle != kBaseThreeQuarterTurn) {
+		castXRay(x, y, angle, xHit);
+		if (xHit.code & (kBaseWallUpper | kBaseWallPass))
+			xHit = BaseRayHit();
+	}
+	if (angle != 0 && angle != kBaseHalfTurn) {
+		castYRay(x, y, angle, yHit);
+		if (yHit.code & (kBaseWallUpper | kBaseWallPass))
+			yHit = BaseRayHit();
+	}
+
+	BaseRayHit hit;
+	if (xHit.hit && (!yHit.hit || xHit.rawDistance <= yHit.rawDistance))
+		hit = xHit;
+	else if (yHit.hit)
+		hit = yHit;
+	if (!hit.hit)
+		return kAckNothing;
+
+	finalizeRayDistance(hit, kBaseViewHalfWidth);
+	int collisionDistance = 48;
+	if ((hit.code & 0xff) >= kBaseDoorXCode)
+		collisionDistance += 64;
+	if (hit.distance > collisionDistance)
+		return kAckNothing;
+	return hit.axis == kBaseRayY ? kAckYWall : kAckXWall;
+}
+
+int BaseEngine::moveShotStep(int16 &x, int16 &y, int angle, int amount) {
+	angle = normalizeBaseAngle(angle);
+	const int16 targetX = x + (int16)(((int64)_data.cosQ16(angle) * amount) >> 16);
+	const int16 targetY = y + (int16)(((int64)_data.sinQ16(angle) * amount) >> 16);
+
+	const int hit = checkHitAt(x, y, angle);
+	if (hit != kAckNothing)
+		return hit;
+
+	if (checkObjectPositionShot(targetX, targetY, angle) != kAckNothing)
+		return kAckObject;
+
+	if (testWall(baseWorldMapIndex(targetX, targetY)))
+		return kAckXWall;
+
+	x = targetX;
+	y = targetY;
+	return kAckNothing;
+}
+
+void BaseEngine::fireWeapon() {
+	_weaponCounter = kWeaponStartCounter;
+	_soundEvents.push_back(kBaseSoundPlayerShot);
+
+	int16 shotX = _playerX;
+	int16 shotY = _playerY;
+	_lastObjectHit = 0;
+	for (int step = 0; step < 20; ++step) {
+		const int result = moveShotStep(shotX, shotY, _playerAngle, 30);
+		if (result == kAckXWall)
+			break;
+		if (result != kAckObject)
+			continue;
+
+		if (_lastObjectHit >= 1 && _lastObjectHit <= kBaseMaxObjects) {
+			BaseObject &object = _objects[_lastObjectHit];
+			_soundEvents.push_back(kBaseSoundGuardHit);
+			object.animationTick = 0;
+			object.bitmap = 11;
+			object.mode = kBaseObjectDying;
+		}
+		break;
+	}
+}
+
+int BaseEngine::tryExit() const {
+	const int16 x = _playerX + (int16)(((int64)_data.cosQ16(_playerAngle) * 63) >> 16);
+	const int16 y = _playerY + (int16)(((int64)_data.sinQ16(_playerAngle) * 63) >> 16);
+	const int mapPos = baseWorldMapIndex(x, y);
+	if ((_data.mapCodeAt(mapPos) & 0xff) != 15)
+		return -1;
+
+	switch (mapPos) {
+	case 0x0860: return 94;
+	case 0x087a: return 95;
+	case 0x01df: return 96;
+	case 0x05ab: return 97;
+	case 0x0bab: return 98;
+	case 0x01fb: return 99;
+	default: return -1;
+	}
+}
+
+int BaseEngine::castXRay(int16 originX, int16 originY, int angle, BaseRayHit &hit) const {
+	int32 yNext = _data.yNextQ16(angle);
+	const int xBegin = originX & kBaseGridMask;
+	int32 xPos;
+	int32 xNext;
+	if (angle > 1440 || angle < 480) {
+		xPos = xBegin + kBaseCellSize;
+		xNext = kBaseCellSize;
+	} else {
+		xPos = xBegin;
+		xNext = -kBaseCellSize;
+		yNext = -yNext;
+	}
+	int64 yPos = (int64)(xPos - originX) * _data.longTanQ16(angle) + ((int64)originY << 16);
+	int ignoredDoor = -1;
+
+	while (xPos >= 0 && xPos <= kBaseWorldExtent && yPos >= 0 && yPos <= ((int64)kBaseWorldExtent << 16)) {
+		const int mapPos = (int)((yPos >> 16) & kBaseGridMask) + (xPos >> 6);
+		uint16 code = (mapPos >= 0 && mapPos < kBaseAckGridArray) ? _xGrid[mapPos] : 0;
+		if (ignoredDoor >= 0 && (_doors[ignoredDoor].mPos == mapPos || _doors[ignoredDoor].mPos1 == mapPos))
+			code = 0;
+		if (code) {
+			int32 hitX = xPos;
+			int64 hitY = yPos;
+			if ((code & 0xff) == kBaseDoorXCode) {
+				const int side = (int)((yPos >> 16) & kBaseGridMask);
+				const int doorIntercept = (int)((yPos + (yNext >> 1)) >> 16);
+				if (doorIntercept < side || doorIntercept > side + kBaseCellSize) {
+					xPos += xNext;
+					yPos += yNext;
+					continue;
+				}
+				hitY = yPos + (yNext >> 1);
+				hitX += xNext >> 1;
+			}
+
+			int column = (int)((hitY >> 16) & 63);
+			if (hitX < originX)
+				column = 63 - column;
+			const int doorIndex = findDoor(mapPos);
+			if (doorIndex >= 0 && (code & (kBaseDoorSlide | kBaseDoorSplit))) {
+				int offset = _doors[doorIndex].offset;
+				bool gap = false;
+				if (code & kBaseDoorSlide) {
+					if (hitX > originX)
+						offset = -offset;
+					column += offset;
+					gap = column < 0 || column > 63;
+				} else {
+					if (column < 32) {
+						column += offset;
+						gap = column > 31;
+					} else {
+						column -= offset;
+						gap = column < 32;
+					}
+				}
+				if (gap) {
+					ignoredDoor = doorIndex;
+					xPos += xNext;
+					yPos += yNext;
+					continue;
+				}
+			}
+
+			hit.hit = true;
+			hit.axis = kBaseRayX;
+			hit.code = code;
+			hit.mapPos = mapPos;
+			hit.textureColumn = column & 63;
+			hit.worldX = hitX;
+			hit.worldY = (int32)(hitY >> 16);
+			int64 raw = ((int64)(hitX - originX) * _data.invCos(angle)) >> 10;
+			if (raw < 0)
+				raw = -raw;
+			hit.rawDistance = (int32)MIN(raw, (int64)0x7fffffff);
+			return code;
+		}
+		xPos += xNext;
+		yPos += yNext;
+	}
+	return 0;
+}
+
+int BaseEngine::castYRay(int16 originX, int16 originY, int angle, BaseRayHit &hit) const {
+	int32 xNext = _data.xNextQ16(angle);
+	const int yBegin = originY & kBaseGridMask;
+	int32 yPos;
+	int32 yNext;
+	if (angle < 960) {
+		yPos = yBegin + kBaseCellSize;
+		yNext = kBaseCellSize;
+	} else {
+		yPos = yBegin;
+		yNext = -kBaseCellSize;
+		xNext = -xNext;
+	}
+	int64 xPos = (int64)(yPos - originY) * _data.longInvTanQ16(angle) + ((int64)originX << 16);
+	int ignoredDoor = -1;
+
+	while (xPos >= 0 && xPos <= ((int64)kBaseWorldExtent << 16) && yPos >= 0 && yPos <= kBaseWorldExtent) {
+		const int mapPos = (yPos & kBaseGridMask) + (int)(xPos >> 22);
+		uint16 code = (mapPos >= 0 && mapPos < kBaseAckGridArray) ? _yGrid[mapPos] : 0;
+		if (ignoredDoor >= 0 && (_doors[ignoredDoor].mPos == mapPos || _doors[ignoredDoor].mPos1 == mapPos))
+			code = 0;
+		if (code) {
+			int64 hitX = xPos;
+			int32 hitY = yPos;
+			if ((code & 0xff) == kBaseDoorYCode) {
+				const int side = (int)((xPos >> 16) & kBaseGridMask);
+				const int doorIntercept = (int)((xPos + (xNext >> 1)) >> 16);
+				if (doorIntercept < side || doorIntercept > side + kBaseCellSize) {
+					xPos += xNext;
+					yPos += yNext;
+					continue;
+				}
+				hitX = xPos + (xNext >> 1);
+				hitY += yNext >> 1;
+			}
+
+			int column = (int)((hitX >> 16) & 63);
+			if (hitY > originY)
+				column = 63 - column;
+			const int doorIndex = findDoor(mapPos);
+			if (doorIndex >= 0 && (code & (kBaseDoorSlide | kBaseDoorSplit))) {
+				int offset = _doors[doorIndex].offset;
+				bool gap = false;
+				if (code & kBaseDoorSlide) {
+					if (hitY < originY)
+						offset = -offset;
+					column += offset;
+					gap = column < 0 || column > 63;
+				} else {
+					if (column < 32) {
+						column += offset;
+						gap = column > 31;
+					} else {
+						column -= offset;
+						gap = column < 32;
+					}
+				}
+				if (gap) {
+					ignoredDoor = doorIndex;
+					xPos += xNext;
+					yPos += yNext;
+					continue;
+				}
+			}
+
+			hit.hit = true;
+			hit.axis = kBaseRayY;
+			hit.code = code;
+			hit.mapPos = mapPos;
+			hit.textureColumn = column & 63;
+			hit.worldX = (int32)(hitX >> 16);
+			hit.worldY = hitY;
+			int64 raw = ((int64)(hitY - originY) * _data.invSin(angle)) >> 8;
+			if (raw < 0)
+				raw = -raw;
+			hit.rawDistance = (int32)MIN(raw, (int64)0x7fffffff);
+			return code;
+		}
+		xPos += xNext;
+		yPos += yNext;
+	}
+	return 0;
+}
+
+void BaseEngine::finalizeRayDistance(BaseRayHit &hit, int viewColumn) const {
+	if (!hit.hit)
+		return;
+	viewColumn = CLIP(viewColumn, 0, kBaseFrameWidth - 1);
+	int64 value = (int64)hit.rawDistance * (_data.viewCosQ16(viewColumn) >> 3);
+	int64 rounded = value >> 12;
+	if (value - (rounded << 12) >= 2048)
+		++rounded;
+	int64 distance = rounded >> 5;
+	if (rounded - (distance << 5) >= 16)
+		++distance;
+	if (distance < 1)
+		distance = 1;
+	if (distance >= kBaseMaximumDistance)
+		distance = kBaseMaximumDistance - 1;
+	hit.distance = (int32)distance;
+}
+
+BaseRayHit BaseEngine::castRayFrom(int16 originX, int16 originY, int angle, int viewColumn) const {
+	angle = normalizeBaseAngle(angle);
+	BaseRayHit xHit;
+	BaseRayHit yHit;
+	if (angle != 480 && angle != 1440)
+		castXRay(originX, originY, angle, xHit);
+	if (angle != 0 && angle != 960)
+		castYRay(originX, originY, angle, yHit);
+
+	BaseRayHit result;
+	if (xHit.hit && (!yHit.hit || xHit.rawDistance <= yHit.rawDistance))
+		result = xHit;
+	else if (yHit.hit)
+		result = yHit;
+	finalizeRayDistance(result, viewColumn);
+	return result;
+}
+
+BaseRayHit BaseEngine::castRay(int angle, int viewColumn) const {
+	return castRayFrom(_playerX, _playerY, angle, viewColumn);
+}
+
+int BaseEngine::objectAngle(int32 deltaX, int32 deltaY) const {
+	if (deltaX == 0 || deltaY == 0) {
+		if (deltaX == 0)
+			return deltaY < 0 ? kBaseThreeQuarterTurn : kBaseQuarterTurn;
+		return deltaX < 0 ? kBaseHalfTurn : 0;
+	}
+
+	int quadrant = 0;
+	if (deltaX < 0 && deltaY > 0)
+		quadrant = kBaseHalfTurn;
+	else if (deltaX < 0 && deltaY < 0)
+		quadrant = kBaseThreeQuarterTurn;
+	else if (deltaX > 0 && deltaY < 0)
+		quadrant = kBaseAngleCount;
+
+	const uint32 absoluteX = deltaX < 0 ? (uint32)-deltaX : (uint32)deltaX;
+	const uint32 absoluteY = deltaY < 0 ? (uint32)-deltaY : (uint32)deltaY;
+	const int32 ratio = (int32)(((uint64)absoluteY << 16) / absoluteX);
+
+	// The original returns this near-vertical clamp before applying the
+	// quadrant correction. Although unusual, it is observable ACK behavior.
+	if (_data.longTanQ16(kBaseQuarterTurn - 1) <= ratio)
+		return kBaseQuarterTurn - 1;
+
+	int firstAngle = 0;
+	const int lowerPivot = kBaseQuarterTurn / 2;
+	const int upperPivot = kBaseQuarterTurn * 3 / 4;
+	if (_data.longTanQ16(lowerPivot) < ratio)
+		firstAngle = _data.longTanQ16(upperPivot) < ratio ? upperPivot : lowerPivot;
+
+	int objectAngle = 0;
+	for (int angle = firstAngle; angle < kBaseQuarterTurn; ++angle) {
+		if (_data.longTanQ16(angle) > ratio) {
+			objectAngle = angle - 1;
+			break;
+		}
+	}
+	objectAngle = MAX(0, objectAngle);
+
+	if (quadrant) {
+		if (quadrant != kBaseThreeQuarterTurn)
+			objectAngle = quadrant - objectAngle;
+		else
+			objectAngle += kBaseHalfTurn;
+	}
+	return objectAngle;
+}
+
+int BaseEngine::objectDistance(const BaseObject &object) const {
+	const int dx = object.x - _playerX;
+	const int dy = object.y - _playerY;
+	return roundedIntegerSquareRoot((uint32)(dx * dx + dy * dy));
+}
+
+} // End of namespace Hopkins
diff --git a/engines/hopkins/base_engine.h b/engines/hopkins/base_engine.h
new file mode 100644
index 00000000000..77e895bd9ae
--- /dev/null
+++ b/engines/hopkins/base_engine.h
@@ -0,0 +1,148 @@
+/* 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/>.
+ *
+ */
+
+// Source-backed Hopkins WBASE simulation state.
+
+#ifndef HOPKINS_BASE_ENGINE_H
+#define HOPKINS_BASE_ENGINE_H
+
+#include "hopkins/base_data.h"
+#include "hopkins/base_types.h"
+
+#include "common/array.h"
+#include "common/scummsys.h"
+
+namespace Hopkins {
+
+struct BaseInputState {
+	bool forward;
+	bool backward;
+	bool turnLeft;
+	bool turnRight;
+	bool fire;
+	bool exitRequested;
+	bool toggleTextures;
+
+	BaseInputState() : forward(false), backward(false), turnLeft(false),
+		turnRight(false), fire(false), exitRequested(false), toggleTextures(false) {}
+};
+
+enum BaseSoundEvent {
+	kBaseSoundEnemyShot = 2,
+	kBaseSoundPlayerShot = 3,
+	kBaseSoundDoorOrExit = 4,
+	kBaseSoundGuardHit = 5
+};
+
+class BaseEngine {
+public:
+	explicit BaseEngine(const BaseData &data);
+
+	void initialize(const BaseEntryPoint &entry);
+
+	/** Run one original 24 Hz WBASE simulation step. Returns -1 while running. */
+	int tick(BaseInputState &input);
+
+	const BaseData &data() const { return _data; }
+	const uint16 *xGrid() const { return _xGrid; }
+	const uint16 *yGrid() const { return _yGrid; }
+	const BaseObject &object(int id) const { return _objects[id]; }
+	const BaseDoor &door(int id) const { return _doors[id]; }
+	const Common::Array<int> &soundEvents() const { return _soundEvents; }
+	void clearSoundEvents() { _soundEvents.clear(); }
+
+	int playerX() const { return _playerX; }
+	int playerY() const { return _playerY; }
+	int playerAngle() const { return _playerAngle; }
+	int health() const { return _health; }
+	int weaponCounter() const { return _weaponCounter; }
+	bool texturesEnabled() const { return _texturesEnabled; }
+	int entryReturnId() const { return _entry ? _entry->returnId : 0; }
+
+	BaseRayHit castRay(int angle, int viewColumn) const;
+	BaseRayHit castRayFrom(int16 originX, int16 originY, int angle, int viewColumn) const;
+	int objectAngle(int32 deltaX, int32 deltaY) const;
+	int objectDistance(const BaseObject &object) const;
+
+private:
+	void buildGrid();
+	void createObjects();
+	void updateTurn(const BaseInputState &input);
+	void updatePlayer(const BaseInputState &input);
+	void updateWeapon(const BaseInputState &input);
+	void updateDoors();
+	void updateGuards();
+	void updateGuard(BaseObject &object);
+
+	uint16 getWallX(int mapPos) const;
+	uint16 getWallY(int mapPos) const;
+	int moveWithAckCollision(int16 &x, int16 &y, int angle, int amount, int ignoredObject) const;
+	int movePlayer(int angle, int amount);
+	int moveObject(int objectId, int angle, int amount);
+	int checkObjectPosition(int16 x, int16 y, int ignoredObject) const;
+	int checkObjectPositionShot(int16 x, int16 y, int angle);
+	int checkHitAt(int16 x, int16 y, int angle) const;
+	int moveShotStep(int16 &x, int16 &y, int angle, int amount);
+	int testGuardShotPosition(int16 x, int16 y) const;
+	bool testWall(int mapPos) const;
+
+	int checkDoorOpen(int16 x, int16 y, int angle);
+	void checkDoors();
+	int findDoor(int mapPos) const;
+	int findDoorSlot(int mapPos) const;
+	const BaseDoor *doorForMapPosition(int mapPos) const;
+	BaseDoor *doorForMapPosition(int mapPos);
+	int doorOffsetForMapPosition(int mapPos) const;
+
+	bool guardCanShoot(int objectId);
+	void fireWeapon();
+	int tryExit() const;
+
+	int castXRay(int16 originX, int16 originY, int angle, BaseRayHit &hit) const;
+	int castYRay(int16 originX, int16 originY, int angle, BaseRayHit &hit) const;
+	void finalizeRayDistance(BaseRayHit &hit, int viewColumn) const;
+
+	const BaseData &_data;
+	const BaseEntryPoint *_entry;
+	uint16 _xGrid[kBaseAckGridArray];
+	uint16 _yGrid[kBaseAckGridArray];
+	BaseObject _objects[kBaseMaxObjects + 1];
+	BaseDoor _doors[kBaseMaxDoors];
+
+	int16 _playerX;
+	int16 _playerY;
+	int16 _playerAngle;
+	int _health;
+	int _weaponCounter;
+	bool _texturesEnabled;
+	int _lastObjectHit;
+
+	// Original keyboard inertia: apply the previous delta, halve the ramp,
+	// then build the next pending turn from the currently held key.
+	int _turnRamp;
+	int _pendingTurn;
+
+	Common::Array<int> _soundEvents;
+};
+
+} // End of namespace Hopkins
+
+#endif // HOPKINS_BASE_ENGINE_H
diff --git a/engines/hopkins/module.mk b/engines/hopkins/module.mk
index a2f71d6e4d9..a64dab2ec43 100644
--- a/engines/hopkins/module.mk
+++ b/engines/hopkins/module.mk
@@ -3,6 +3,7 @@ MODULE := engines/hopkins
 MODULE_OBJS := \
 	anim.o \
 	base_data.o \
+	base_engine.o \
 	computer.o \
 	debugger.o \
 	dialogs.o \


Commit: b4671d4dc8f8174a44eba68c5cd300dd14d5f59a
    https://github.com/scummvm/scummvm/commit/b4671d4dc8f8174a44eba68c5cd300dd14d5f59a
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: WBASE: Add the CLUT8 software renderer

Add the Hopkins-local 320x200 indexed reference renderer for floor,
ceiling, ACK edge-grid walls, projected objects, split doors, the weapon
overlay, and the health HUD.

Match the original WBASE rendering behavior by copying unshaded palette
indices directly, reproducing the centered 64-pixel column transfer used
for walls and objects, and streaming validated column-major texture data
without per-pixel resource checks.

Use the initialized ACK camera ViewHeight when constructing floor and
ceiling projection geometry, preserving the original scan range and
screen-coordinate bias.

Assisted-by: Codex:GPT-5.4

Changed paths:
  A engines/hopkins/base_renderer.cpp
  A engines/hopkins/base_renderer.h
    engines/hopkins/base_data.cpp
    engines/hopkins/base_data.h
    engines/hopkins/module.mk


diff --git a/engines/hopkins/base_data.cpp b/engines/hopkins/base_data.cpp
index b99ae837b8d..2c38b4c65e0 100644
--- a/engines/hopkins/base_data.cpp
+++ b/engines/hopkins/base_data.cpp
@@ -172,7 +172,6 @@ bool BaseData::load(Common::String &errorMessage) {
 			!loadBitmaps(errorMessage))
 		return false;
 	buildDerivedTables();
-	buildShadeTable();
 	return true;
 }
 
@@ -424,28 +423,6 @@ void BaseData::buildDerivedTables() {
 	_adjust[kBaseMaximumDistance] = _adjust[kBaseMaximumDistance - 1];
 }
 
-void BaseData::buildShadeTable() {
-	static const byte ranges[][2] = {
-		{ 32, 16 }, { 48, 16 }, { 64, 16 }, { 80, 16 },
-		{ 96, 8 }, { 104, 8 }, { 112, 8 }, { 120, 8 },
-		{ 128, 8 }, { 136, 8 }, { 144, 8 }, { 152, 8 },
-		{ 160, 8 }, { 168, 8 }, { 176, 8 }, { 184, 8 },
-		{ 192, 16 }, { 208, 16 }, { 224, 8 }, { 232, 8 }
-	};
-
-	for (int level = 0; level < 16; ++level) {
-		for (int color = 0; color < 256; ++color)
-			_shadeTable[level][color] = (byte)color;
-
-		for (uint range = 0; range < ARRAYSIZE(ranges); ++range) {
-			const int first = ranges[range][0];
-			const int end = first + ranges[range][1];
-			for (int color = first; color < end; ++color)
-				_shadeTable[level][color] = color + level < end ? (byte)(color + level) : 0;
-		}
-	}
-}
-
 uint16 BaseData::mapCodeAt(int mapPos) const {
 	return mapPos >= 0 && mapPos < kBaseMapCellCount ? _map[mapPos] : 0;
 }
@@ -491,6 +468,5 @@ int32 BaseData::viewCosQ16(int column) const { return _viewCos[CLIP(column, 0, k
 int32 BaseData::floorCos(int column) const { return _floorCos[CLIP(column, 0, kBaseViewWidth)]; }
 int16 BaseData::distanceHeight(int distance) const { return _distanceHeight[CLIP(distance, 0, kBaseMaximumDistance)]; }
 int32 BaseData::adjustTable(int distance) const { return _adjust[CLIP(distance, 0, kBaseMaximumDistance)]; }
-byte BaseData::shadedColor(int level, byte color) const { return _shadeTable[CLIP(level, 0, 15)][color]; }
 
 } // End of namespace Hopkins
diff --git a/engines/hopkins/base_data.h b/engines/hopkins/base_data.h
index 190752172b2..4dcad965d60 100644
--- a/engines/hopkins/base_data.h
+++ b/engines/hopkins/base_data.h
@@ -64,7 +64,6 @@ public:
 	int32 floorCos(int column) const;
 	int16 distanceHeight(int distance) const;
 	int32 adjustTable(int distance) const;
-	byte shadedColor(int level, byte color) const;
 
 private:
 	bool loadMap(Common::String &errorMessage);
@@ -74,12 +73,10 @@ private:
 	bool loadBbm(const Common::Path &filename, BaseBitmap &bitmap, Common::String &errorMessage);
 	bool loadSpr(const Common::Path &filename, Common::Array<BaseBitmap> &frames, Common::String &errorMessage);
 	void buildDerivedTables();
-	void buildShadeTable();
 
 	uint16 _map[kBaseMapCellCount];
 	uint16 _objectMap[kBaseMapCellCount];
 	byte _palette[256 * 3];
-	byte _shadeTable[16][256];
 
 	Common::Array<int32> _trig[7];
 	Common::Array<int32> _xNext;
diff --git a/engines/hopkins/base_renderer.cpp b/engines/hopkins/base_renderer.cpp
new file mode 100644
index 00000000000..de631a45888
--- /dev/null
+++ b/engines/hopkins/base_renderer.cpp
@@ -0,0 +1,311 @@
+/* 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/>.
+ *
+ */
+
+// Portable indexed-colour ACK-style renderer for Hopkins WBASE.
+
+#include "hopkins/base_renderer.h"
+
+#include "common/algorithm.h"
+#include "common/util.h"
+
+namespace Hopkins {
+
+namespace {
+
+struct VisibleObject {
+	int id;
+	int distance;
+	int centerColumn;
+	int halfSize;
+};
+
+class BaseSoftwareRenderer : public BaseRenderer {
+public:
+	BaseSoftwareRenderer() {
+		_wallDistance.resize(kBaseViewWidth);
+		_floorWallDistance.resize(kBaseViewWidth);
+		_hits.resize(kBaseViewWidth);
+	}
+
+	void render(const BaseEngine &engine, byte *framebuffer) override;
+
+private:
+	void buildWallColumns(const BaseEngine &engine);
+	void renderFloorAndCeiling(const BaseEngine &engine, byte *framebuffer) const;
+	void renderWalls(const BaseEngine &engine, byte *framebuffer) const;
+	void renderObjects(const BaseEngine &engine, byte *framebuffer) const;
+	void renderHud(const BaseEngine &engine, byte *framebuffer) const;
+	void drawCenteredTextureColumn(const BaseBitmap &bitmap, int textureColumn, int distance,
+			int screenColumn, byte *framebuffer) const;
+	void drawSprite(const BaseBitmap &bitmap, int destX, int destY, byte *framebuffer) const;
+
+	Common::Array<BaseRayHit> _hits;
+	Common::Array<int> _wallDistance;
+	Common::Array<int> _floorWallDistance;
+};
+
+static int signedAngleDelta(int from, int to) {
+	int delta = normalizeBaseAngle(to - from);
+	if (delta > kBaseHalfTurn)
+		delta -= kBaseAngleCount;
+	return delta;
+}
+
+void BaseSoftwareRenderer::render(const BaseEngine &engine, byte *framebuffer) {
+	if (!framebuffer)
+		return;
+
+	Common::fill(framebuffer, framebuffer + kBaseFrameWidth * kBaseFrameHeight, 0);
+	buildWallColumns(engine);
+	renderFloorAndCeiling(engine, framebuffer);
+	renderWalls(engine, framebuffer);
+	renderObjects(engine, framebuffer);
+	renderHud(engine, framebuffer);
+}
+
+void BaseSoftwareRenderer::buildWallColumns(const BaseEngine &engine) {
+	for (int column = 0; column < kBaseViewWidth; ++column) {
+		const int angle = normalizeBaseAngle(engine.playerAngle() - kBaseAngleHalfFov + column);
+		_hits[column] = engine.castRay(angle, column);
+		_wallDistance[column] = _hits[column].hit ? _hits[column].distance : kBaseMaximumDistance - 1;
+
+		// Hopkins' Windows ACK fork stores the pre-fisheye distance with six
+		// fractional bits remaining at the floor renderer handoff.
+		int floorDistance = _hits[column].hit ? (int)(_hits[column].rawDistance >> 6) : kBaseMaximumDistance - 1;
+		_floorWallDistance[column] = CLIP(floorDistance, 1, kBaseMaximumDistance - 1);
+	}
+}
+
+void BaseSoftwareRenderer::renderFloorAndCeiling(const BaseEngine &engine, byte *framebuffer) const {
+	const BaseData &data = engine.data();
+	if (!engine.texturesEnabled()) {
+		for (int y = 0; y < kBaseHorizon; ++y)
+			Common::fill(framebuffer + y * kBaseFrameWidth,
+					framebuffer + y * kBaseFrameWidth + kBaseViewWidth, 4);
+		for (int y = kBaseHorizon; y < kBaseViewHeight; ++y)
+			Common::fill(framebuffer + y * kBaseFrameWidth,
+					framebuffer + y * kBaseFrameWidth + kBaseViewWidth, 5);
+		return;
+	}
+
+	const BaseBitmap &floorBitmap = data.wallBitmap(5);
+	const BaseBitmap &ceilingBitmap = data.wallBitmap(4);
+	// Hopkins retains ACK's initialized camera ViewHeight value of 31.
+	// This is distinct from the 180-pixel viewport height.
+	const int floorHeight = 89 - 31;
+	const int scaleHeight = floorHeight * (floorHeight * 5);
+	// The Hopkins fork uses a one-row-biased screen base and scans rows 7..91.
+	const int firstScanRow = 7;
+	const int lastScanRow = kBaseHorizon + 1;
+	const int floorScanOrigin = kBaseHorizon - 2;
+	const int ceilingScanOrigin = kBaseHorizon + 2;
+
+	for (int column = 0; column < kBaseViewWidth; column += 2) {
+		const int angle = normalizeBaseAngle(engine.playerAngle() - kBaseAngleHalfFov + column);
+		const int32 cv = data.cosQ16(angle);
+		const int32 sv = data.sinQ16(angle);
+		const int32 floorCos = data.floorCos(column);
+		const int wallDistance = _floorWallDistance[column];
+		int previousDistance = -1;
+		int worldX = 0;
+		int worldY = 0;
+
+		for (int row = firstScanRow; row <= lastScanRow; ++row) {
+			const int scan = scaleHeight / row;
+			const int distance = (int)(((int64)floorCos * scan) >> 15);
+			if (distance >= wallDistance)
+				continue;
+
+			if (distance != previousDistance) {
+				worldX = engine.playerX() + (int)(((int64)cv * distance) >> 16);
+				worldY = engine.playerY() + (int)(((int64)sv * distance) >> 16);
+				previousDistance = distance;
+			}
+			const int mapPos = baseWorldMapIndex(worldX, worldY);
+			if (mapPos < 0 || mapPos >= kBaseMapCellCount)
+				continue;
+
+			const uint textureX = worldX & 63;
+			const uint textureY = worldY & 63;
+			const byte floorPixel = floorBitmap.sample(textureX, textureY);
+			const byte ceilingPixel = ceilingBitmap.sample(textureX, textureY);
+			const int floorY = floorScanOrigin + row;
+			const int ceilingY = ceilingScanOrigin - row;
+
+			if (floorY >= 0 && floorY < kBaseViewHeight) {
+				framebuffer[floorY * kBaseFrameWidth + column] = floorPixel;
+				if (column + 1 < kBaseViewWidth)
+					framebuffer[floorY * kBaseFrameWidth + column + 1] = floorPixel;
+			}
+			if (ceilingY >= 0 && ceilingY < kBaseViewHeight) {
+				framebuffer[ceilingY * kBaseFrameWidth + column] = ceilingPixel;
+				if (column + 1 < kBaseViewWidth)
+					framebuffer[ceilingY * kBaseFrameWidth + column + 1] = ceilingPixel;
+			}
+		}
+	}
+}
+
+void BaseSoftwareRenderer::renderWalls(const BaseEngine &engine, byte *framebuffer) const {
+	const BaseData &data = engine.data();
+	for (int column = 0; column < kBaseViewWidth; ++column) {
+		const BaseRayHit &hit = _hits[column];
+		if (!hit.hit)
+			continue;
+
+		const uint bitmapId = hit.code & 0xff;
+		const BaseBitmap &bitmap = data.wallBitmap(bitmapId);
+		if (!bitmap.valid())
+			continue;
+		drawCenteredTextureColumn(bitmap, hit.textureColumn, hit.distance, column, framebuffer);
+	}
+}
+
+void BaseSoftwareRenderer::renderObjects(const BaseEngine &engine, byte *framebuffer) const {
+	const BaseData &data = engine.data();
+	Common::Array<VisibleObject> visible;
+
+	for (int id = 1; id <= kBaseMaxObjects; ++id) {
+		const BaseObject &object = engine.object(id);
+		if (!object.active)
+			continue;
+
+		const int deltaX = object.x - engine.playerX();
+		const int deltaY = object.y - engine.playerY();
+		const int angle = engine.objectAngle(deltaX, deltaY);
+		const int relative = signedAngleDelta(engine.playerAngle(), angle);
+		const int distance = engine.objectDistance(object);
+		if (distance <= 0 || distance >= kBaseMaximumDistance - 10)
+			continue;
+
+		const int halfSize = data.distanceHeight(distance);
+		if (halfSize < 3 || halfSize > 300)
+			continue;
+		if (relative + halfSize < -kBaseAngleHalfFov || relative - halfSize > kBaseAngleHalfFov)
+			continue;
+
+		VisibleObject item;
+		item.id = id;
+		item.distance = distance;
+		item.centerColumn = kBaseViewHalfWidth + relative;
+		item.halfSize = halfSize;
+
+		uint insertAt = 0;
+		while (insertAt < visible.size() && visible[insertAt].distance > distance)
+			++insertAt;
+		visible.push_back(item);
+		for (uint pos = visible.size() - 1; pos > insertAt; --pos)
+			visible[pos] = visible[pos - 1];
+		visible[insertAt] = item;
+	}
+
+	for (uint index = 0; index < visible.size(); ++index) {
+		const VisibleObject &item = visible[index];
+		const BaseObject &object = engine.object(item.id);
+		const BaseBitmap &bitmap = data.objectBitmap(object.bitmap);
+		if (!bitmap.valid())
+			continue;
+
+		const int left = item.centerColumn - item.halfSize;
+		const int right = item.centerColumn + item.halfSize;
+		const int fullSize = MAX(2, item.halfSize * 2);
+		for (int x = MAX(0, left); x < MIN(kBaseViewWidth, right); ++x) {
+			if (item.distance >= _wallDistance[x])
+				continue;
+			const int textureX = CLIP(((x - left) * 64) / fullSize, 0, 63);
+			if (bitmap.blankColumns.size() == bitmap.width && bitmap.blankColumns[textureX])
+				continue;
+			drawCenteredTextureColumn(bitmap, textureX, item.distance, x, framebuffer);
+		}
+	}
+}
+
+void BaseSoftwareRenderer::drawCenteredTextureColumn(const BaseBitmap &bitmap, int textureColumn,
+		int distance, int screenColumn, byte *framebuffer) const {
+	if (!bitmap.valid() || !bitmap.columnMajor || distance <= 0 ||
+			screenColumn < 0 || screenColumn >= kBaseViewWidth)
+		return;
+	const byte *sourceColumn = bitmap.pixels.begin() +
+			((uint)textureColumn % bitmap.width) * bitmap.height;
+
+	// Hopkins' non-shaded transfer starts at source rows 31 and 32, then
+	// advances both halves away from the horizon with an 8.8 accumulator.
+	// This preserves ACK's integer rounding and its asymmetric center pair.
+	uint32 sourcePosition = 0;
+	for (int row = 0; row < kBaseViewHeight; ++row) {
+		const uint sourceOffset = sourcePosition >> 8;
+		if (sourceOffset >= 32)
+			break;
+
+		const int upperY = kBaseHorizon - row;
+		const int lowerY = kBaseHorizon + row + 1;
+		if (upperY >= 0) {
+			const byte pixel = sourceColumn[31 - sourceOffset];
+			if (pixel)
+				framebuffer[upperY * kBaseFrameWidth + screenColumn] = pixel;
+		}
+		if (lowerY < kBaseViewHeight) {
+			const byte pixel = sourceColumn[32 + sourceOffset];
+			if (pixel)
+				framebuffer[lowerY * kBaseFrameWidth + screenColumn] = pixel;
+		}
+		if (upperY < 0 && lowerY >= kBaseViewHeight)
+			break;
+		sourcePosition += distance;
+	}
+}
+
+void BaseSoftwareRenderer::drawSprite(const BaseBitmap &bitmap, int destX, int destY, byte *framebuffer) const {
+	if (!bitmap.valid())
+		return;
+	for (uint y = 0; y < bitmap.height; ++y) {
+		const int screenY = destY + (int)y;
+		if (screenY < 0 || screenY >= kBaseFrameHeight)
+			continue;
+		for (uint x = 0; x < bitmap.width; ++x) {
+			const int screenX = destX + (int)x;
+			if (screenX < 0 || screenX >= kBaseFrameWidth)
+				continue;
+			const byte pixel = bitmap.sample(x, y);
+			if (pixel)
+				framebuffer[screenY * kBaseFrameWidth + screenX] = pixel;
+		}
+	}
+}
+
+void BaseSoftwareRenderer::renderHud(const BaseEngine &engine, byte *framebuffer) const {
+	const BaseData &data = engine.data();
+	const uint weaponFrame = engine.weaponCounter() > 3 ? 1 : 0;
+	drawSprite(data.weaponFrame(weaponFrame), 128, 75, framebuffer);
+
+	const int health = CLIP(engine.health() / 10, 0, 999);
+	const int digits[3] = { health / 100, (health / 10) % 10, health % 10 };
+	for (int i = 0; i < 3; ++i)
+		drawSprite(data.fontFrame(digits[i]), 13 + i * 12, 160, framebuffer);
+}
+
+} // End of anonymous namespace
+
+BaseRenderer *BaseRenderer::createSoftware() {
+	return new BaseSoftwareRenderer();
+}
+
+} // End of namespace Hopkins
diff --git a/engines/hopkins/base_renderer.h b/engines/hopkins/base_renderer.h
new file mode 100644
index 00000000000..6d4e849c705
--- /dev/null
+++ b/engines/hopkins/base_renderer.h
@@ -0,0 +1,49 @@
+/* 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/>.
+ *
+ */
+
+// Renderer interface for Hopkins WBASE.
+
+#ifndef HOPKINS_BASE_RENDERER_H
+#define HOPKINS_BASE_RENDERER_H
+
+#include "hopkins/base_engine.h"
+
+#include "common/array.h"
+#include "common/scummsys.h"
+
+namespace Hopkins {
+
+/**
+ * Renderer seam for WBASE. The software implementation is the fidelity
+ * reference; a future backend may consume the same ACK state without
+ * changing simulation or resource handling.
+ */
+class BaseRenderer {
+public:
+	virtual ~BaseRenderer() {}
+	virtual void render(const BaseEngine &engine, byte *framebuffer) = 0;
+
+	static BaseRenderer *createSoftware();
+};
+
+} // End of namespace Hopkins
+
+#endif // HOPKINS_BASE_RENDERER_H
diff --git a/engines/hopkins/module.mk b/engines/hopkins/module.mk
index a64dab2ec43..0c1d4ed1be4 100644
--- a/engines/hopkins/module.mk
+++ b/engines/hopkins/module.mk
@@ -4,6 +4,7 @@ MODULE_OBJS := \
 	anim.o \
 	base_data.o \
 	base_engine.o \
+	base_renderer.o \
 	computer.o \
 	debugger.o \
 	dialogs.o \


Commit: 4d8d3d83131eafd1c6502ea0d7a374fc1fa67d11
    https://github.com/scummvm/scummvm/commit/4d8d3d83131eafd1c6502ea0d7a374fc1fa67d11
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: Integrate WBASE rooms with engine lifecycle

Route full Windows Hopkins exits 194 through 199 through BaseGame when
mandatory WBASE data validates, while retaining handleBaseMap() for
unsupported variants and initialization failure. Preserve typed outcomes
for room or death completion, static fallback, and host quit or Return
to Launcher.

Assisted-by: Codex:GPT-5.4

Changed paths:
  A engines/hopkins/base.cpp
  A engines/hopkins/base.h
    engines/hopkins/hopkins.cpp
    engines/hopkins/hopkins.h
    engines/hopkins/metaengine.cpp
    engines/hopkins/module.mk


diff --git a/engines/hopkins/base.cpp b/engines/hopkins/base.cpp
new file mode 100644
index 00000000000..725068d6490
--- /dev/null
+++ b/engines/hopkins/base.cpp
@@ -0,0 +1,502 @@
+/* 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/>.
+ *
+ */
+
+// ScummVM integration glue for Hopkins FBI's Windows-only WBASE game.
+
+#include "hopkins/base.h"
+
+#include "hopkins/base_renderer.h"
+#include "hopkins/events.h"
+#include "hopkins/globals.h"
+#include "hopkins/graphics.h"
+#include "hopkins/hopkins.h"
+#include "hopkins/sound.h"
+
+#include "backends/keymapper/keymap.h"
+#include "backends/keymapper/keymapper.h"
+#include "common/algorithm.h"
+#include "common/events.h"
+#include "common/file.h"
+#include "common/system.h"
+#include "common/textconsole.h"
+#include "common/util.h"
+
+namespace Hopkins {
+
+namespace {
+
+static const char *const kBaseKeymapId = "hopkins-base";
+static const char *const kDefaultKeymapId = "hopkins-default";
+static const char *const kShortcutKeymapId = "game-shortcuts";
+
+static const char *const kBaseSounds[] = {
+	nullptr,
+	nullptr,
+	"SOUND54.WAV", // guard shot
+	"SOUND40.WAV", // player shot
+	"SOUND53.WAV", // door / exit
+	"SOUND55.WAV"  // guard hit
+};
+
+static int voiceModeForSound(int soundIndex) {
+	switch (soundIndex) {
+	case kBaseSoundPlayerShot:
+		return 5;
+	case kBaseSoundDoorOrExit:
+		return 6;
+	case kBaseSoundEnemyShot:
+	case kBaseSoundGuardHit:
+		return 7;
+	default:
+		return 9;
+	}
+}
+
+} // End of anonymous namespace
+
+class BaseGame::SessionGuard {
+public:
+	explicit SessionGuard(BaseGame &game) :
+			_game(game), _presentationReady(false), _audioReady(false),
+			_keymapsReady(false), _engineMarkedActive(false) {
+	}
+
+	~SessionGuard() {
+		_game._input = BaseInputState();
+		if (_keymapsReady)
+			_game.switchKeymaps(false);
+		if (_audioReady)
+			_game.releaseAudio();
+		if (_presentationReady)
+			_game.restorePresentation(_backup);
+		if (_engineMarkedActive)
+			_game.setEngineActive(false);
+	}
+
+	bool initialize() {
+		_game.setEngineActive(true);
+		_engineMarkedActive = true;
+
+		if (!_game.initializePresentation(_backup))
+			return false;
+		_presentationReady = true;
+
+		_game.initializeAudio();
+		_audioReady = true;
+
+		_game.switchKeymaps(true);
+		_keymapsReady = true;
+		return true;
+	}
+
+private:
+	BaseGame &_game;
+	GraphicsStateBackup _backup;
+	bool _presentationReady;
+	bool _audioReady;
+	bool _keymapsReady;
+	bool _engineMarkedActive;
+};
+
+BaseGame::BaseGame(HopkinsEngine *vm) :
+		_vm(vm), _engine(nullptr), _renderer(nullptr), _entry(nullptr), _result(-1),
+		_keymapsSwitched(false), _defaultKeymapWasEnabled(false),
+		_shortcutKeymapWasEnabled(false), _baseKeymapWasEnabled(false),
+		_inputSuspended(false), _mainMenuRequested(false),
+		_presentationRefreshRequested(false), _timingResetRequested(false),
+		_quitRequested(false) {
+	_framebuffer.resize(kBaseFrameWidth * kBaseFrameHeight);
+	Common::fill(_audioLoaded, _audioLoaded + ARRAYSIZE(_audioLoaded), false);
+}
+
+BaseGame::~BaseGame() {
+	delete _renderer;
+	delete _engine;
+}
+
+bool BaseGame::hasRequiredResources(Common::String *missingResources) {
+	return BaseData::hasRequiredResources(missingResources);
+}
+
+const BaseEntryPoint *BaseGame::entryPoints(uint &count) {
+	static const BaseEntryPoint entries[] = {
+		{ 194, 2144, 2144,    0, 0x0860, 94 },
+		{ 195, 3680, 2144,  915, 0x087a, 95 },
+		{ 196, 2016,  544,  440, 0x01df, 96 },
+		{ 197, 2784, 1504,  440, 0x05ab, 97 },
+		{ 198, 2784, 2912, 1440, 0x0bab, 98 },
+		{ 199, 3808,  544,  480, 0x01fb, 99 }
+	};
+	count = ARRAYSIZE(entries);
+	return entries;
+}
+
+const BaseEntryPoint *BaseGame::findEntryPoint(int entryId) {
+	uint count = 0;
+	const BaseEntryPoint *entries = entryPoints(count);
+	for (uint i = 0; i < count; ++i) {
+		if (entries[i].entryId == entryId)
+			return &entries[i];
+	}
+	return nullptr;
+}
+
+BaseRunResult BaseGame::run(int entryId) {
+	_entry = findEntryPoint(entryId);
+	if (!_entry) {
+		warning("Hopkins WBASE: unsupported entry id %d", entryId);
+		return BaseRunResult(kBaseRunFallback);
+	}
+
+	Common::String errorMessage;
+	if (!_data.load(errorMessage)) {
+		warning("Hopkins WBASE initialization failed: %s", errorMessage.c_str());
+		return BaseRunResult(kBaseRunFallback);
+	}
+
+	delete _engine;
+	_engine = new BaseEngine(_data);
+	_engine->initialize(*_entry);
+
+	delete _renderer;
+	_renderer = BaseRenderer::createSoftware();
+	if (!_renderer) {
+		warning("Hopkins WBASE: could not create the software renderer");
+		return BaseRunResult(kBaseRunFallback);
+	}
+
+	SessionGuard session(*this);
+	if (!session.initialize())
+		return BaseRunResult(kBaseRunFallback);
+
+	_input = BaseInputState();
+	_inputSuspended = false;
+	_mainMenuRequested = false;
+	_presentationRefreshRequested = false;
+	_timingResetRequested = false;
+	_quitRequested = false;
+	_result = -1;
+	renderFrame();
+
+	// The original uses a 240 Hz timer and advances WBASE every tenth tick.
+	// Accumulating elapsed milliseconds * 24 reproduces that 24 Hz cadence
+	// without tying simulation speed to the host display refresh rate.
+	uint32 previousTime = g_system->getMillis();
+	uint32 accumulator = 0;
+	while (_result == -1 && !_quitRequested && !_vm->shouldQuit()) {
+		pollInput();
+		const uint32 now = g_system->getMillis();
+		if (_timingResetRequested) {
+			previousTime = now;
+			accumulator = 0;
+			_timingResetRequested = false;
+		}
+		if (_result != -1 || _quitRequested || _vm->shouldQuit())
+			break;
+		if (_inputSuspended) {
+			previousTime = now;
+			accumulator = 0;
+			_vm->_soundMan->checkSounds();
+			g_system->delayMillis(10);
+			continue;
+		}
+		const uint32 elapsed = MIN<uint32>(now - previousTime, 250);
+		previousTime = now;
+		accumulator += elapsed * 24;
+
+		bool advanced = false;
+		while (accumulator >= 1000 && _result == -1) {
+			accumulator -= 1000;
+			const int tickResult = _engine->tick(_input);
+			processSoundEvents();
+			if (tickResult >= 0)
+				_result = tickResult;
+			advanced = true;
+		}
+
+		if (advanced && _result == -1)
+			renderFrame();
+		_vm->_soundMan->checkSounds();
+		g_system->delayMillis(2);
+	}
+
+	if (_quitRequested || _vm->shouldQuit())
+		return BaseRunResult(kBaseRunQuit);
+
+	return BaseRunResult(kBaseRunCompleted, _result);
+}
+
+void BaseGame::setEngineActive(bool active) {
+	_vm->_inBaseGame = active;
+}
+
+bool BaseGame::initializePresentation(GraphicsStateBackup &backup) {
+	GraphicsManager *graphics = _vm->_graphicsMan;
+	EventsManager *events = _vm->_events;
+	if (!graphics || !events || !graphics->_frontBuffer || !graphics->_backBuffer)
+		return false;
+
+	backup.breakout = events->_breakoutFl;
+	backup.mouseVisible = events->_mouseFl;
+	backup.disableInventory = _vm->_globals->_disableInventFl;
+	backup.lineNbr = graphics->_lineNbr;
+	backup.lineNbr2 = graphics->_lineNbr2;
+	backup.minX = graphics->_minX;
+	backup.minY = graphics->_minY;
+	backup.maxX = graphics->_maxX;
+	backup.maxY = graphics->_maxY;
+	Common::copy(graphics->_paletteBuffer, graphics->_paletteBuffer + sizeof(backup.paletteBuffer), backup.paletteBuffer);
+	Common::copy(graphics->_palette, graphics->_palette + sizeof(backup.palette), backup.palette);
+	Common::copy(graphics->_oldPalette, graphics->_oldPalette + sizeof(backup.oldPalette), backup.oldPalette);
+
+	_vm->_globals->_disableInventFl = true;
+	events->_breakoutFl = true;
+	events->_escKeyFl = false;
+	events->_gameKey = KEY_NONE;
+	events->mouseOff();
+
+	graphics->resetDirtyRects();
+	graphics->resetRefreshRects();
+	graphics->setScreenWidth(kBaseFrameWidth);
+	graphics->_minX = 0;
+	graphics->_minY = 0;
+	graphics->_maxX = kBaseFrameWidth;
+	graphics->_maxY = kBaseFrameHeight;
+	Common::fill(graphics->_frontBuffer, graphics->_frontBuffer + kBaseFrameWidth * kBaseFrameHeight, 0);
+	Common::fill(graphics->_backBuffer, graphics->_backBuffer + kBaseFrameWidth * kBaseFrameHeight, 0);
+	Common::fill(graphics->_palette, graphics->_palette + sizeof(graphics->_palette), 0);
+	Common::copy(_data.palette(), _data.palette() + 256 * 3, graphics->_palette);
+	graphics->setPaletteVGA256(_data.palette());
+	graphics->clearScreen();
+	return true;
+}
+
+void BaseGame::restorePresentation(const GraphicsStateBackup &backup) {
+	GraphicsManager *graphics = _vm->_graphicsMan;
+	EventsManager *events = _vm->_events;
+
+	graphics->resetDirtyRects();
+	graphics->resetRefreshRects();
+	graphics->clearScreen();
+	graphics->updateScreen();
+
+	events->_breakoutFl = backup.breakout;
+	events->_escKeyFl = false;
+	events->_gameKey = KEY_NONE;
+	_vm->_globals->_disableInventFl = backup.disableInventory;
+	graphics->_lineNbr = backup.lineNbr;
+	graphics->_lineNbr2 = backup.lineNbr2;
+	graphics->_minX = backup.minX;
+	graphics->_minY = backup.minY;
+	graphics->_maxX = backup.maxX;
+	graphics->_maxY = backup.maxY;
+	Common::copy(backup.paletteBuffer, backup.paletteBuffer + sizeof(backup.paletteBuffer), graphics->_paletteBuffer);
+	Common::copy(backup.palette, backup.palette + sizeof(backup.palette), graphics->_palette);
+	Common::copy(backup.oldPalette, backup.oldPalette + sizeof(backup.oldPalette), graphics->_oldPalette);
+
+	if (backup.mouseVisible)
+		events->mouseOn();
+	else
+		events->mouseOff();
+}
+
+void BaseGame::initializeAudio() {
+	Common::String missingAudio;
+	BaseData::appendAudioResourceReport(missingAudio);
+	if (!missingAudio.empty())
+		warning("Hopkins WBASE: optional audio resources unavailable: %s", missingAudio.c_str());
+
+	for (int soundIndex = kBaseSoundEnemyShot; soundIndex <= kBaseSoundGuardHit; ++soundIndex) {
+		const Common::Path filename(kBaseSounds[soundIndex]);
+		if (!Common::File::exists(filename))
+			continue;
+
+		_audioLoaded[soundIndex] = _vm->_soundMan->loadSample(soundIndex, filename);
+		if (!_audioLoaded[soundIndex])
+			warning("Hopkins WBASE: could not decode optional audio resource %s", filename.toString().c_str());
+	}
+}
+
+void BaseGame::releaseAudio() {
+	for (int soundIndex = kBaseSoundEnemyShot; soundIndex <= kBaseSoundGuardHit; ++soundIndex) {
+		if (_audioLoaded[soundIndex]) {
+			_vm->_soundMan->removeSample(soundIndex);
+			_audioLoaded[soundIndex] = false;
+		}
+	}
+}
+
+void BaseGame::switchKeymaps(bool entering) {
+	Common::Keymapper *keymapper = _vm->getEventManager()->getKeymapper();
+	if (!keymapper)
+		return;
+
+	Common::Keymap *defaultKeymap = keymapper->getKeymap(kDefaultKeymapId);
+	Common::Keymap *shortcutKeymap = keymapper->getKeymap(kShortcutKeymapId);
+	Common::Keymap *baseKeymap = keymapper->getKeymap(kBaseKeymapId);
+
+	if (entering) {
+		if (_keymapsSwitched)
+			return;
+		_defaultKeymapWasEnabled = defaultKeymap && defaultKeymap->isEnabled();
+		_shortcutKeymapWasEnabled = shortcutKeymap && shortcutKeymap->isEnabled();
+		_baseKeymapWasEnabled = baseKeymap && baseKeymap->isEnabled();
+		if (defaultKeymap)
+			defaultKeymap->setEnabled(false);
+		if (shortcutKeymap)
+			shortcutKeymap->setEnabled(false);
+		if (baseKeymap)
+			baseKeymap->setEnabled(true);
+		_keymapsSwitched = true;
+	} else if (_keymapsSwitched) {
+		if (defaultKeymap)
+			defaultKeymap->setEnabled(_defaultKeymapWasEnabled);
+		if (shortcutKeymap)
+			shortcutKeymap->setEnabled(_shortcutKeymapWasEnabled);
+		if (baseKeymap)
+			baseKeymap->setEnabled(_baseKeymapWasEnabled);
+		_keymapsSwitched = false;
+	}
+}
+
+void BaseGame::pollInput() {
+	Common::Event event;
+	while (g_system->getEventManager()->pollEvent(event)) {
+		switch (event.type) {
+		case Common::EVENT_QUIT:
+		case Common::EVENT_RETURN_TO_LAUNCHER:
+			_input = BaseInputState();
+			_quitRequested = true;
+			break;
+		case Common::EVENT_MAINMENU:
+			_mainMenuRequested = true;
+			break;
+		case Common::EVENT_SCREEN_CHANGED:
+			_input = BaseInputState();
+			_presentationRefreshRequested = true;
+			_timingResetRequested = true;
+			break;
+		case Common::EVENT_CUSTOM_ENGINE_ACTION_START:
+			handleAction(event.customType, true);
+			break;
+		case Common::EVENT_CUSTOM_ENGINE_ACTION_END:
+			handleAction(event.customType, false);
+			break;
+		case Common::EVENT_FOCUS_LOST:
+			_input = BaseInputState();
+			_inputSuspended = true;
+			_timingResetRequested = true;
+			break;
+		case Common::EVENT_FOCUS_GAINED:
+			_inputSuspended = false;
+			_timingResetRequested = true;
+			break;
+		case Common::EVENT_INPUT_CHANGED:
+			_input = BaseInputState();
+			_timingResetRequested = true;
+			break;
+		default:
+			break;
+		}
+	}
+
+	if (_mainMenuRequested && _result == -1 && !_quitRequested && !_vm->shouldQuit())
+		openMainMenu();
+	if (_presentationRefreshRequested && _result == -1 && !_quitRequested && !_vm->shouldQuit())
+		refreshPresentation();
+}
+
+void BaseGame::handleAction(uint32 action, bool pressed) {
+	switch (action) {
+	case kActionBaseForward:
+		_input.forward = pressed;
+		break;
+	case kActionBaseBackward:
+		_input.backward = pressed;
+		break;
+	case kActionBaseTurnLeft:
+		_input.turnLeft = pressed;
+		break;
+	case kActionBaseTurnRight:
+		_input.turnRight = pressed;
+		break;
+	case kActionBaseFire:
+		_input.fire = pressed;
+		break;
+	case kActionBaseUse:
+		if (pressed)
+			_input.exitRequested = true;
+		break;
+	case kActionBaseToggleTextures:
+		if (pressed)
+			_input.toggleTextures = true;
+		break;
+	case kActionBaseMenu:
+		if (pressed)
+			_mainMenuRequested = true;
+		break;
+	default:
+		break;
+	}
+}
+
+void BaseGame::openMainMenu() {
+	_mainMenuRequested = false;
+	_input = BaseInputState();
+	_inputSuspended = true;
+	_vm->openMainMenuDialog();
+	_inputSuspended = false;
+	_timingResetRequested = true;
+
+	if (_vm->shouldQuit())
+		_quitRequested = true;
+	else
+		refreshPresentation();
+}
+
+void BaseGame::refreshPresentation() {
+	_presentationRefreshRequested = false;
+	if (!_vm->_graphicsMan || !_renderer || !_engine)
+		return;
+
+	_vm->_graphicsMan->setPaletteVGA256(_data.palette());
+	renderFrame();
+}
+
+void BaseGame::processSoundEvents() {
+	const Common::Array<int> &events = _engine->soundEvents();
+	for (uint i = 0; i < events.size(); ++i) {
+		const int soundIndex = events[i];
+		if (soundIndex >= 0 && soundIndex < (int)ARRAYSIZE(_audioLoaded) && _audioLoaded[soundIndex])
+			_vm->_soundMan->playSample(soundIndex, voiceModeForSound(soundIndex));
+	}
+	_engine->clearSoundEvents();
+}
+
+void BaseGame::renderFrame() {
+	if (!_renderer || !_engine || _framebuffer.empty())
+		return;
+	_renderer->render(*_engine, _framebuffer.begin());
+	Common::copy(_framebuffer.begin(), _framebuffer.end(), _vm->_graphicsMan->_frontBuffer);	_vm->_graphicsMan->addDirtyRect(0, 0, kBaseFrameWidth, kBaseFrameHeight);
+	_vm->_graphicsMan->updateScreen();
+}
+
+} // End of namespace Hopkins
diff --git a/engines/hopkins/base.h b/engines/hopkins/base.h
new file mode 100644
index 00000000000..5eda1c2f906
--- /dev/null
+++ b/engines/hopkins/base.h
@@ -0,0 +1,119 @@
+/* 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/>.
+ *
+ */
+
+// Hopkins FBI underwater-base shooter (the original Windows WBASE module).
+
+#ifndef HOPKINS_BASE_H
+#define HOPKINS_BASE_H
+
+#include "hopkins/base_data.h"
+#include "hopkins/base_engine.h"
+
+#include "common/array.h"
+#include "common/scummsys.h"
+#include "common/str.h"
+
+namespace Hopkins {
+
+class BaseRenderer;
+class HopkinsEngine;
+
+enum BaseRunStatus {
+	kBaseRunCompleted,
+	kBaseRunFallback,
+	kBaseRunQuit
+};
+
+struct BaseRunResult {
+	BaseRunStatus status;
+	int roomId;
+
+	BaseRunResult(BaseRunStatus runStatus, int resultRoom = 0) :
+			status(runStatus), roomId(resultRoom) {}
+};
+
+class BaseGame {
+public:
+	explicit BaseGame(HopkinsEngine *vm);
+	~BaseGame();
+
+	static bool hasRequiredResources(Common::String *missingResources = nullptr);
+
+	/** Run entry 194..199 and distinguish gameplay, fallback and host-exit results. */
+	BaseRunResult run(int entryId);
+
+private:
+	struct GraphicsStateBackup {
+		bool breakout;
+		bool mouseVisible;
+		bool disableInventory;
+		int lineNbr;
+		int lineNbr2;
+		int minX;
+		int minY;
+		int maxX;
+		int maxY;
+		byte paletteBuffer[256 * 2];
+		byte palette[800];
+		byte oldPalette[800];
+	};
+
+	class SessionGuard;
+
+	static const BaseEntryPoint *entryPoints(uint &count);
+	static const BaseEntryPoint *findEntryPoint(int entryId);
+
+	bool initializePresentation(GraphicsStateBackup &backup);
+	void restorePresentation(const GraphicsStateBackup &backup);
+	void initializeAudio();
+	void releaseAudio();
+	void switchKeymaps(bool entering);
+	void pollInput();
+	void handleAction(uint32 action, bool pressed);
+	void openMainMenu();
+	void refreshPresentation();
+	void processSoundEvents();
+	void renderFrame();
+	void setEngineActive(bool active);
+
+	HopkinsEngine *_vm;
+	BaseData _data;
+	BaseEngine *_engine;
+	BaseRenderer *_renderer;
+	Common::Array<byte> _framebuffer;
+	BaseInputState _input;
+	const BaseEntryPoint *_entry;
+	int _result;
+	bool _audioLoaded[6];
+	bool _keymapsSwitched;
+	bool _defaultKeymapWasEnabled;
+	bool _shortcutKeymapWasEnabled;
+	bool _baseKeymapWasEnabled;
+	bool _inputSuspended;
+	bool _mainMenuRequested;
+	bool _presentationRefreshRequested;
+	bool _timingResetRequested;
+	bool _quitRequested;
+};
+
+} // End of namespace Hopkins
+
+#endif // HOPKINS_BASE_H
diff --git a/engines/hopkins/hopkins.cpp b/engines/hopkins/hopkins.cpp
index c125043cbb7..f85df21545a 100644
--- a/engines/hopkins/hopkins.cpp
+++ b/engines/hopkins/hopkins.cpp
@@ -20,6 +20,7 @@
  */
 
 #include "hopkins/hopkins.h"
+#include "hopkins/base.h"
 #include "hopkins/graphics.h"
 #include "hopkins/files.h"
 #include "hopkins/saveload.h"
@@ -31,11 +32,12 @@
 #include "common/debug-channels.h"
 #include "common/events.h"
 #include "common/file.h"
+#include "common/textconsole.h"
 
 namespace Hopkins {
 
 HopkinsEngine::HopkinsEngine(OSystem *syst, const HopkinsGameDescription *gameDesc) : Engine(syst),
-		_gameDescription(gameDesc), _randomSource("Hopkins") {
+		_gameDescription(gameDesc), _randomSource("Hopkins"), _inBaseGame(false) {
 	_animMan = new AnimationManager(this);
 	_computer = new ComputerManager(this);
 	_dialog = new DialogsManager(this);
@@ -78,14 +80,14 @@ HopkinsEngine::~HopkinsEngine() {
  * Returns true if it is currently okay to restore a game
  */
 bool HopkinsEngine::canLoadGameStateCurrently(Common::U32String *msg) {
-	return !_globals->_exitId && !_globals->_cityMapEnabledFl && _events->_mouseFl && _globals->_curRoomNum != 0;
+	return !_inBaseGame && !_globals->_exitId && !_globals->_cityMapEnabledFl && _events->_mouseFl && _globals->_curRoomNum != 0;
 }
 
 /**
  * Returns true if it is currently okay to save the game
  */
 bool HopkinsEngine::canSaveGameStateCurrently(Common::U32String *msg) {
-	return !_globals->_exitId && !_globals->_cityMapEnabledFl && _events->_mouseFl
+	return !_inBaseGame && !_globals->_exitId && !_globals->_cityMapEnabledFl && _events->_mouseFl
 		&& _globals->_curRoomNum != 0 && !isUnderwaterSubScene();
 }
 
@@ -1555,19 +1557,48 @@ bool HopkinsEngine::runFull() {
 		case 196:
 		case 197:
 		case 198:
-		case 199:
+		case 199: {
+			const int baseEntryId = _globals->_exitId;
+			bool baseQuitRequested = false;
 			_globals->_characterSpriteBuf = _globals->freeMemory(_globals->_characterSpriteBuf);
 			_globals->_eventMode = EVENTMODE_IGNORE;
 			_soundMan->stopSound();
 			_soundMan->playSound(23);
-			_globals->_exitId = handleBaseMap();	// Handles the base map (non-Windows)
-			//_globals->_exitId = WBASE();	// Handles the 3D Doom level (Windows)
+
+			if (getPlatform() == Common::kPlatformWindows && !getIsDemo()) {
+				Common::String missingBaseResources;
+				if (BaseGame::hasRequiredResources(&missingBaseResources)) {
+					BaseGame baseGame(this);
+					const BaseRunResult baseResult = baseGame.run(baseEntryId);
+					switch (baseResult.status) {
+					case kBaseRunCompleted:
+						_globals->_exitId = baseResult.roomId;
+						break;
+					case kBaseRunFallback:
+						_globals->_exitId = handleBaseMap();
+						break;
+					case kBaseRunQuit:
+						baseQuitRequested = true;
+						break;
+					}
+				} else {
+					debug(1, "Hopkins WBASE resources unavailable (%s); using static map", missingBaseResources.c_str());
+					_globals->_exitId = handleBaseMap();
+				}
+			} else {
+				debug(1, "Hopkins WBASE is only enabled for the full Windows data set; using static map");
+				_globals->_exitId = handleBaseMap();
+			}
+
 			_soundMan->stopSound();
 			_globals->_characterSpriteBuf = _fileIO->loadFile("PERSO.SPR");
 			_globals->_characterType = CHARACTER_HOPKINS;
 			_globals->_eventMode = EVENTMODE_DEFAULT;
-			_graphicsMan->_lineNbr = SCREEN_WIDTH;
+			_graphicsMan->setScreenWidth(SCREEN_WIDTH);
+			if (baseQuitRequested)
+				return false;
 			break;
+		}
 
 		default:
 			break;
diff --git a/engines/hopkins/hopkins.h b/engines/hopkins/hopkins.h
index cf83a38ff87..afeae2e7851 100644
--- a/engines/hopkins/hopkins.h
+++ b/engines/hopkins/hopkins.h
@@ -67,7 +67,15 @@ enum HOPKINSAction {
 	kActionInventory,
 	kActionSave,
 	kActionLoad,
-	kActionOptions
+	kActionOptions,
+	kActionBaseForward,
+	kActionBaseBackward,
+	kActionBaseTurnLeft,
+	kActionBaseTurnRight,
+	kActionBaseFire,
+	kActionBaseUse,
+	kActionBaseToggleTextures,
+	kActionBaseMenu
 };
 
 enum HopkinsDebugChannels {
@@ -82,11 +90,15 @@ enum HopkinsDebugChannels {
 #define MKTAG24(a0,a1,a2) ((uint32)((a2) | (a1) << 8 | ((a0) << 16)))
 
 struct HopkinsGameDescription;
+class BaseGame;
 
 class HopkinsEngine : public Engine {
 private:
+	friend class BaseGame;
+
 	const HopkinsGameDescription *_gameDescription;
 	Common::RandomSource _randomSource;
+	bool _inBaseGame;
 
 	void initializeSystem();
 
diff --git a/engines/hopkins/metaengine.cpp b/engines/hopkins/metaengine.cpp
index 792c6f16c66..7cbcf4eae5a 100644
--- a/engines/hopkins/metaengine.cpp
+++ b/engines/hopkins/metaengine.cpp
@@ -205,6 +205,8 @@ Common::KeymapArray HopkinsMetaEngine::initKeymaps(const char *target) const {
 
 	Keymap *engineKeyMap = new Keymap(Keymap::kKeymapTypeGame, "hopkins-default", _("Default keymappings"));
 	Keymap *gameKeyMap = new Keymap(Keymap::kKeymapTypeGame, "game-shortcuts", _("Game keymappings"));
+	Keymap *baseKeyMap = new Keymap(Keymap::kKeymapTypeGame, "hopkins-base", _("Underwater base shooter"));
+	baseKeyMap->setEnabled(false);
 
 	Action *act;
 
@@ -252,11 +254,59 @@ Common::KeymapArray HopkinsMetaEngine::initKeymaps(const char *target) const {
 	act->addDefaultInputMapping("JOY_Y");
 	gameKeyMap->addAction(act);
 
+	act = new Action("BASE_FORWARD", _("Move forward"));
+	act->setCustomEngineActionEvent(kActionBaseForward);
+	act->addDefaultInputMapping("UP");
+	act->addDefaultInputMapping("JOY_UP");
+	baseKeyMap->addAction(act);
+
+	act = new Action("BASE_BACKWARD", _("Move backward"));
+	act->setCustomEngineActionEvent(kActionBaseBackward);
+	act->addDefaultInputMapping("DOWN");
+	act->addDefaultInputMapping("JOY_DOWN");
+	baseKeyMap->addAction(act);
+
+	act = new Action("BASE_TURN_LEFT", _("Turn left"));
+	act->setCustomEngineActionEvent(kActionBaseTurnLeft);
+	act->addDefaultInputMapping("LEFT");
+	act->addDefaultInputMapping("JOY_LEFT");
+	baseKeyMap->addAction(act);
+
+	act = new Action("BASE_TURN_RIGHT", _("Turn right"));
+	act->setCustomEngineActionEvent(kActionBaseTurnRight);
+	act->addDefaultInputMapping("RIGHT");
+	act->addDefaultInputMapping("JOY_RIGHT");
+	baseKeyMap->addAction(act);
+
+	act = new Action("BASE_FIRE", _("Fire"));
+	act->setCustomEngineActionEvent(kActionBaseFire);
+	act->addDefaultInputMapping("LCTRL");
+	act->addDefaultInputMapping("RCTRL");
+	act->addDefaultInputMapping("JOY_A");
+	baseKeyMap->addAction(act);
+
+	act = new Action("BASE_USE", _("Exit through base doorway"));
+	act->setCustomEngineActionEvent(kActionBaseUse);
+	act->addDefaultInputMapping("SPACE");
+	act->addDefaultInputMapping("JOY_X");
+	baseKeyMap->addAction(act);
+
+	act = new Action("BASE_TEXTURES", _("Toggle floor and ceiling textures"));
+	act->setCustomEngineActionEvent(kActionBaseToggleTextures);
+	act->addDefaultInputMapping("F5");
+	act->addDefaultInputMapping("JOY_Y");
+	baseKeyMap->addAction(act);
 
+	act = new Action(kStandardActionOpenMainMenu, _("Open main menu"));
+	act->setCustomEngineActionEvent(kActionBaseMenu);
+	act->addDefaultInputMapping("ESCAPE");
+	act->addDefaultInputMapping("JOY_BACK");
+	baseKeyMap->addAction(act);
 
-	KeymapArray keymaps(2);
+	KeymapArray keymaps(3);
 	keymaps[0] = engineKeyMap;
 	keymaps[1] = gameKeyMap;
+	keymaps[2] = baseKeyMap;
 
 	return keymaps;
 }
diff --git a/engines/hopkins/module.mk b/engines/hopkins/module.mk
index 0c1d4ed1be4..dd3eff7082a 100644
--- a/engines/hopkins/module.mk
+++ b/engines/hopkins/module.mk
@@ -2,6 +2,7 @@ MODULE := engines/hopkins
 
 MODULE_OBJS := \
 	anim.o \
+	base.o \
 	base_data.o \
 	base_engine.o \
 	base_renderer.o \


Commit: 958b37f1248df1e340b4440b085760cb0df39435
    https://github.com/scummvm/scummvm/commit/958b37f1248df1e340b4440b085760cb0df39435
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: WBASE: Move texture toggle away from F5

The shooter assigned F5 to its floor and ceiling texture toggle. ScummVM
also uses Ctrl+F5 for the global main menu while bare Ctrl remains the
original WBASE fire control, making F5 an unnecessarily ambiguous engine
binding.

Bind the texture toggle to T instead. This keeps the original
Ctrl-to-fire and joystick mappings, while WBASE continues to expose
Escape as its direct main-menu action.

Changed paths:
    engines/hopkins/metaengine.cpp


diff --git a/engines/hopkins/metaengine.cpp b/engines/hopkins/metaengine.cpp
index 7cbcf4eae5a..5a7fb8d66e3 100644
--- a/engines/hopkins/metaengine.cpp
+++ b/engines/hopkins/metaengine.cpp
@@ -293,7 +293,9 @@ Common::KeymapArray HopkinsMetaEngine::initKeymaps(const char *target) const {
 
 	act = new Action("BASE_TEXTURES", _("Toggle floor and ceiling textures"));
 	act->setCustomEngineActionEvent(kActionBaseToggleTextures);
-	act->addDefaultInputMapping("F5");
+	// Hopkins used F5 here, but ScummVM reserves Ctrl+F5 for its global
+	// main menu. Use T so the WBASE action never competes with that shortcut.
+	act->addDefaultInputMapping("t");
 	act->addDefaultInputMapping("JOY_Y");
 	baseKeyMap->addAction(act);
 


Commit: 05fd0b0d2b85d1fd374e3020ec19b96a81a0415c
    https://github.com/scummvm/scummvm/commit/05fd0b0d2b85d1fd374e3020ec19b96a81a0415c
Author: fusefib (fibofuse at gmail.com)
Date: 2026-08-11T20:33:17+10:00

Commit Message:
HOPKINS: WBASE: Clear held keys after the main menu

Modal dialogs can consume the KEYUP corresponding to the key that opened
them. On return, the keymapper may otherwise retain a held modifier or
movement action and feed it back into the shooter.

Purge only queued keyboard events after the ScummVM main menu closes,
following Freescape modal-resume pattern. WBASE already clears its local
input state before opening the dialog; quit, launcher, mouse and screen
events remain untouched.

Changed paths:
    engines/hopkins/base.cpp


diff --git a/engines/hopkins/base.cpp b/engines/hopkins/base.cpp
index 725068d6490..1d98ad27140 100644
--- a/engines/hopkins/base.cpp
+++ b/engines/hopkins/base.cpp
@@ -463,6 +463,9 @@ void BaseGame::openMainMenu() {
 	_input = BaseInputState();
 	_inputSuspended = true;
 	_vm->openMainMenuDialog();
+	// A modal menu may consume the KEYUP for the key that opened it. Purge
+	// keyboard events before resuming so WBASE cannot inherit a stuck action.
+	g_system->getEventManager()->purgeKeyboardEvents();
 	_inputSuspended = false;
 	_timingResetRequested = true;
 




More information about the Scummvm-git-logs mailing list